基于 FFmpeg 实现一个数据流风格的视频处理工具 | 社区征文

2023总结
一、开发背景

我所在的团队开发了一款面向青少年科普创新活动的在线教育平台,平台会不定期的举行一些直播活动,有时候 1 天会连续进行多场。直播结束之后的回放视频要及时进行上传,满足用户的持续学习需求。直播业务的实现是借助了腾讯云的实时音视频(TRTC),云直播(CSS),云点播(VOD)3 个产品的能力,我们基于以上云产品提供的 API 自行开发了以 TRTC 为核心的在线导播平台,业务流程图如下

picture.image 而关于回放文件的处理,我们也是使用了“双通道”的处理模式,即直播结束后,首先切换到 VOD 服务提供的在线播放地址。这里主要使用到了云函数和 CDN 搭配,基本流程是直播结束后云端监测到结束事件,并生成回放文件的 CDN 播放链接,通过云函数,发送通知到本地服务接口,将对应直播场次的会放链接更新为云直播地址,以此来完成直播结束后,近乎无缝的回放切换衔接。由于在我方平台举行的教育类直播时效性比较明显,也就在直播结束后的第 2-3 天,播放量会骤降,带宽的压力也就降低了很多,也是为了节约云服务的流量成本,我们会根据实际情况将回放的云播放地址改为本地播放地址,那批量的处理视频回放文件并完成上传就成了运维环节的一个重点,为了提高工作效率,我们开发了一个基于 FFmpeg 的视频处理工具。

二、流程介绍

本工具使用控制台风格开发,可通过传入参数的形式灵活控制处理流程。由于是客户端工具,可以运行到任意电脑上(支持 Windows 和 Linux,MacOS 应该也支持但由于缺少测试机器,没有进行测试),不只限于公司内网下的机器,所以尽量减少了一些组件依赖,除 FFmpeg 外,不再依赖其他第三方工具,且 FFmpeg 也封装到了软件包内,不需要单独安装。工具主要功能为,

● 检索媒资:从腾讯云 vod 检索所需的回放资源;

● 生成下载链接:第一步从腾讯云检索的媒体资源无法直接使用,需要通过算法进一步生成防盗 Key,进而得到真正的下载链接;

● 合并视频:腾讯云 vod 的视频资源都是分片保存的,每个分片最大为 30 分钟,即 1 个 2 小时左右的回放视频,可能会下载 4-5 个分片视频;

● 编辑视频:这一步需要手动完成,工具本身没有提供视频编辑的能力,但会检测编辑步骤,编辑完成后将编辑后的视频放到源路径后,继续执行即可,若不需要编辑则可以通过传入参数直接跳过该环节;

● 转码视频:执行视频转码操作;

● 分割视频:将大的视频文件分割成 hls 协议的 ts 分片文件以及 m3u8 索引文件,大幅降低请求带宽;

● 上传视频:将处理完成的视频,上传到服务器,包括分片后的文件和完整的视频文件,其中完整的视频文件是作为归档上传,实际使用还是基于 hls 协议的 m3u8 和 ts 文件,完成更新;

注意,以上是一个完整的操作流程,实际上,每一步都可以单独执行,也可把任何一个步骤作为起始步骤继续执行。

三、具体功能

3.1、检索媒资

由于我们的平台主要还是基于 TRTC 的旁路直播功能产生的视频回放,因此大部分的直播回放会自动存放到 vod 中。这一步的主要代码如下

public static async Task<string[]> GetDownloadUrl(string[] mediaUrls,string streamId, string ext = "flv")
{
    await Common.SetStep("pre-download");
    List<string> urls = new List<string>();
    int cnt = 1;
    Common.DelConfigFile($"downloadlist_{streamId}.txt", "logs");
    await Common.WriteFile($"downloadlist_{streamId}.txt", "[",true, "logs");
    StringBuilder contentBuilder = new StringBuilder();
    foreach (string mediaUrl in mediaUrls)
    {
        long timeStamp = Convert.ToInt64((DateTime.Now.AddDays(1) - new DateTime(1970, 1, 1, 0, 0, 0, 0)).TotalSeconds);

        string[] parts = mediaUrl.Split('/');
        string dir = "/";
        foreach (string part in parts)
        {
            if (part.Contains(".") || part.Contains("/") || part.Contains(":") || string.IsNullOrEmpty(part))
                continue;
            dir += $"{part}/";
        }
        //16进制Unix时间戳
        string t = Convert.ToString(timeStamp, 16).ToLower().PadLeft(8, '0');
        string us = Common.GenerateRandomCodePro(10);
        //签名=md5(防盗key + dir + 16进制时间戳 + 随机数)
        string sign = Common.Md5(urlKey + dir + t + us);
        string downloadUrl = $"{mediaUrl}?download_name={streamId}_{cnt}.{ext}&t={t}&us={us}&sign={sign}";
        urls.Add(downloadUrl);
        AnsiConsole.MarkupLine($"  [#20a162]--链接{cnt}{downloadUrl}[/]");
        contentBuilder.Append("{").Append($""FileName":"{streamId}_{cnt}.{ext}","Url":"{downloadUrl}",FolderPath:""").Append("},");
        cnt++;
    }
    await Common.WriteFile($"downloadlist_{streamId}.txt", contentBuilder.ToString().TrimEnd(',') + "]", true, "logs");
    return urls.ToArray();
}

其中,入参是直播流 id,这里因为我们使用了 trtc 的旁路直播,所以 streamid 就是房间号。SetStep 方法的左右是记录当前执行的步骤,当程序异常退出后,可以从记录到的位置继续执行。其他则是 TencentSDK 的一些调用过程,目的是获取到指定的视频初始链接。 该步骤执行截图如下👇:

picture.image

3.2、预下载

第一步获取到的媒资下载地址并不能直接使用,需要根据防盗key来完成一些转换工作,主要代码如下

public static async Task<string[]> GetDownloadUrl(string[] mediaUrls,string streamId, string ext = "flv")
{
    await Common.SetStep("pre-download");
    List<string> urls = new List<string>();
    int cnt = 1;
    Common.DelConfigFile($"downloadlist_{streamId}.txt", "logs");
    await Common.WriteFile($"downloadlist_{streamId}.txt", "[",true, "logs");
    StringBuilder contentBuilder = new StringBuilder();
    foreach (string mediaUrl in mediaUrls)
    {
        long timeStamp = Convert.ToInt64((DateTime.Now.AddDays(1) - new DateTime(1970, 1, 1, 0, 0, 0, 0)).TotalSeconds);

        string[] parts = mediaUrl.Split('/');
        string dir = "/";
        foreach (string part in parts)
        {
            if (part.Contains(".") || part.Contains("/") || part.Contains(":") || string.IsNullOrEmpty(part))
                continue;
            dir += $"{part}/";
        }
        //16进制Unix时间戳
        string t = Convert.ToString(timeStamp, 16).ToLower().PadLeft(8, '0');
        string us = Common.GenerateRandomCodePro(10);
        //签名=md5(防盗key + dir + 16进制时间戳 + 随机数)
        string sign = Common.Md5(urlKey + dir + t + us);
        string downloadUrl = $"{mediaUrl}?download_name={streamId}_{cnt}.{ext}&t={t}&us={us}&sign={sign}";
        urls.Add(downloadUrl);
        AnsiConsole.MarkupLine($"  [#20a162]--链接{cnt}{downloadUrl}[/]");
        contentBuilder.Append("{").Append($""FileName":"{streamId}_{cnt}.{ext}","Url":"{downloadUrl}",FolderPath:""").Append("},");
        cnt++;
    }
    await Common.WriteFile($"downloadlist_{streamId}.txt", contentBuilder.ToString().TrimEnd(',') + "]", true, "logs");
    return urls.ToArray();
}

这也没啥可说的,就是根据规则生成实际的下载地址,参数不要传错即可。其余就是自身业务的处理,包括写日志,记录当前步骤,输出信息等。

该步骤的执行截图如下👇:

picture.image

3.3、下载

下载部分的代码稍微复杂一些,这里主要是使用Downloader进行下载,代码就不再赘述,主要是配置一些下载参数,如分块下载,快捷键,每个下载块的字节数,超时时间等,大家如果对downloader的使用感兴趣,可以到官方仓库查看👉:https://github.com/bezzad/Downloader

这里放一张执行截图。

picture.image

3.4、拼接视频

由于云端设置了录制模板规则,所以每场直播的回放文件都不是一个文件,而是多个分段的文件,下载后进行处理之前要先进性拼接操作,当然如果不需要的话可以跳过。

拼接的具体操作代码如下

public static async Task<string> Connect(string filePath)
{
    string path = Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(filePath)),"connects");
    if(Directory.Exists(path))
    {
        Directory.Delete(path,true);
    }
    Directory.CreateDirectory(path);
    string[] list = await Common.ReadFileLines(filePath);
    string fileName = Path.GetFileName(list[0].Replace("file ", "").Replace("'", "").Replace("_1.", "."));
    string targetPath = Path.Combine(path, fileName);
    string arguments = $" -f concat -safe 0 -i "{filePath}" -c copy "{targetPath}"";
    Process proc = new Process();
    try
    {
        AnsiConsole.Status()
            .Start("拼接中...", ctx =>
            {
                proc.StartInfo.FileName = "ffmpeg.exe";
                proc.StartInfo.Arguments = arguments;
                proc.StartInfo.UseShellExecute = false;
                proc.StartInfo.RedirectStandardInput = true;
                proc.StartInfo.RedirectStandardOutput = true;
                proc.StartInfo.RedirectStandardError = true;
                proc.StartInfo.CreateNoWindow = true;
                string pattern = @"frame=[\s\S]*?fps=[\s\S]*?q=[\s\S]*?size=[\s\S]*?time=[\s\S]*?bitrate=[\s\S]*?speed=[\s\S]*? ";
                Regex regex = new Regex(pattern);
                proc.ErrorDataReceived += new DataReceivedEventHandler((sender, e) =>
                {
                    //errorOut += e.Data;
                    if (e != null && e.Data != null)
                    {
                        Match match = regex.Match(e.Data.ToString());
                        if (match.Success)
                        {
                            AnsiConsole.MarkupLine("[#FDE047]{0}[/]", match.Value.EscapeMarkup());
                        }
                    }
                });
                proc.Start();
                proc.BeginErrorReadLine();
                proc.WaitForExit();

            });
        AnsiConsole.MarkupLine($"[cyan]{fileName}拼接完成[/]");
        return targetPath;
    }
    catch (Exception ex)
    {                
        AnsiConsole.Markup($"[red]{ex.Message};\r\n{ex.StackTrace}\r\n[/]");
        throw;
    }
    finally
    {
        proc.Close();
        proc.Dispose();
        await File.AppendAllTextAsync(Path.Combine(path, "请务必打开此文件查看.txt"), $"通过第三方工具编辑视频完成后,要手动拷贝到当前目录下,替换或者删除掉原来的文件!\r\n注意编辑完成的文件名称和原来的文件名称要保持完全一致,包括后缀名!否则后续流程无法自动执行!\r\n1.将原文件【{targetPath}】删除;\r\n2.然后将制作完成后的视频重命名为【{fileName}】;\r\n3.注意制作完成后导出的视频格式要与原格式保持一致,原来是mp4,制作完成的也要是mp4,原来的是flv制作完成的也要是flv; \r\n4.并将其拷贝到【{path}】路径下");
        await Common.WriteFile("inputlist.txt", $"{targetPath}\n");
    }
}

这里呢,核心的点就是生成拼接参数,传入到ffmpeg当中,其余均为工具本身的业务点,主要是设定当前步骤,输出信息为下一步做铺垫等。

代码执行结果如下:

picture.image

3.5、编辑

由于直接录制的视频文件一般不能直接作为回放,都需要进行一些处理,包括裁剪掉一些不需要的片段,增加字幕,增加前置或者后置片段等,因此本工具在执行到编辑阶段后会自动暂停,提示用户通过第三方工具编辑拼接完成的视频,当然如果不需要编辑,也可以通过传入skip参数跳过编辑步骤。

这里的代码很简单,就是判定用户是否跳过当前环节,如果跳过则继续执行下一步,否则则临时退出程序,视频编辑完成后再次执行即可。

if (await ConfirmStep("edit", inputModel.skip, "跳过此阶段,继续向下执行,下一步【转码Convert】"))
{
    Common.OutputStep(4, $"编辑文件...{Common.GetTimeStr()}");            
    await Common.SetStep("convert");
    ExitApp("此阶段您可以使用第三方工具制作视频,将制作完成后的视频放到当前目录下,再次执行本程序", "#f25a47");
}

picture.image

3.6、转码

转码的环节也是调用了ffmpeg的能力,程序只是传入了一些定制的参数,如转码时需要传入帧率,码率,分辨率等关键参数,这里我把这个组合进行了封装,通过low,normal,high,higher,max,5个不同的规格,来转码文件,其中默认为normal,即25fps,3000kbs,1280*720的分辨率。

代码如下

 public static async Task ConvertVideo(string filePath, Quality quailty = Quality.normal)
 {
     if (quailty == Quality.low)
         await ConvertVideo(filePath, 2000, 15, 960, 640);
     else if (quailty == Quality.normal)
         await ConvertVideo(filePath, 3000, 25, 1280, 720);
     else if (quailty == Quality.high)
         await ConvertVideo(filePath, 6000, 30, 1920, 1080);
     else if (quailty == Quality.higher)
         await ConvertVideo(filePath, 10000, 50, 1920, 1080);
     else if (quailty == Quality.max)
         await ConvertVideo(filePath, 15000, 60, 2560, 1440);
 }

public static async Task ConvertVideo(string filePath,int dataRate,int fps,int width,int height)
{
    string newPath = Path.Combine(Path.GetDirectoryName(filePath),"convert");
    if(Directory.Exists(newPath))
        Directory.Delete(newPath,true);
    Directory.CreateDirectory(newPath);
    string newFileName = Path.Combine(newPath, $"convert_{dataRate}_{fps}_{width}×{height}_{Path.GetFileName(filePath)}");
    Process proc = new Process();
    try
    {
        string bash_threads = "";
        if(processorCnt>0) {
            bash_threads = $" -threads {processorCnt}";
        }
        string arguments = $"-i "{filePath}"{bash_threads} -vcodec libx264 -preset fast -tune film -b:v {dataRate}k -s {width}*{height} -r {fps} "{newFileName}"";
        proc.StartInfo.FileName = Path.Combine(Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName), "ffmpeg.exe");
        proc.StartInfo.Arguments = arguments;
        proc.StartInfo.UseShellExecute = false;
        proc.StartInfo.RedirectStandardInput = true;
        proc.StartInfo.RedirectStandardOutput = true;
        proc.StartInfo.RedirectStandardError = true;
        proc.StartInfo.CreateNoWindow = true;
        AnsiConsole.MarkupLine($"[cyan]【{Path.GetFileName(filePath)}】--->【{Path.GetFileName(newFileName)}】[/]");
        AnsiConsole.Status()
            .Start("转码中...", ctx =>
            {  
                string pattern = @"frame=[\s\S]*?fps=[\s\S]*?q=[\s\S]*?size=[\s\S]*?time=[\s\S]*?bitrate=[\s\S]*?dup=[\s\S]*?drop=[\s\S]*?speed=[\s\S]*? ";
                Regex regex = new Regex(pattern);
                proc.ErrorDataReceived += new DataReceivedEventHandler((sender, e) =>
                {
                    if (e!=null && e.Data != null)
                    {
                        Match match = regex.Match(e.Data.ToString());
                        if (match.Success)
                        {
                            AnsiConsole.MarkupLine("[#FDE047]{0}[/]", match.Value.EscapeMarkup());
                        }
                    }
                });
                proc.Start();
                proc.BeginErrorReadLine();
                proc.WaitForExit();
            });
        AnsiConsole.MarkupLine($"[green]{Path.GetFileName(filePath)}转码完成...{Common.GetTimeStr()}[/]");
    }
    catch (Exception ex)
    {
        AnsiConsole.Markup($"[red]{ex.Message};\r\n{ex.StackTrace}\r\n[/]");
        throw;
    }
    finally
    {
        proc.Close();
        proc.Dispose();
        await Common.WriteFile("inputSplit.txt", newFileName + "\n", true);
    }
}

这一步的执行截图如下

picture.image

3.7、分割

分割就是将转码后的文件,分割成m3u8索引文件和ts分片文件,也就是实际线上播放时使用的文件,避免直接使用大的视频文件造成请求头过大造成的带宽压力。

这一步也是集成ffmeg的能力,工具负责生成传入参数

public static async Task SplitVideo(string filePath,string watermark="")
{
    await Common.SetStep("split");
    
    string path = Path.GetDirectoryName(filePath);
    //注意这里路径要向上一级,和convert文件夹放在一起,一家人就是要整整齐齐才行
    string targetPath = Path.Combine(Path.GetDirectoryName(path), "hls", Path.GetFileNameWithoutExtension(filePath));          
    string newFileName = Path.Combine(targetPath, Path.GetFileNameWithoutExtension(filePath));
    Process proc = new Process();
    try
    {
        if (Directory.Exists(targetPath))
            Directory.Delete(targetPath, true);
        Directory.CreateDirectory(targetPath);
        string bash_watermark = "";
        if (!string.IsNullOrEmpty(watermark))
        {
            bash_watermark = $" -vf "movie={watermark} [watermark]; [in][watermark] overlay=10:main_h-overlay_h-10 [out]"";
        }
        string bash_threads = "";
        if (processorCnt > 0)
        {
            bash_threads = $" -threads {processorCnt}";
        }
        string arguments = $" -i "{filePath}"{bash_watermark}{bash_threads} -profile:v high -level 30 -start_number 0 -hls_time 6 -hls_list_size 0 -f hls "{newFileName}.m3u8"";
        AnsiConsole.MarkupLine($"[cyan]【{Path.GetFileName(filePath)}】--->【{Path.GetFileNameWithoutExtension(filePath)}.m3u8】[/]");
        AnsiConsole.Status()
            .Start("分割中...", ctx =>
            {
                proc.StartInfo.FileName = "ffmpeg.exe";
                proc.StartInfo.Arguments = arguments;
                proc.StartInfo.UseShellExecute = false;
                proc.StartInfo.RedirectStandardInput = true;
                proc.StartInfo.RedirectStandardOutput = true;
                proc.StartInfo.RedirectStandardError = true;
                proc.StartInfo.CreateNoWindow = true;

                string pattern = @"frame=[\s\S]*?fps=[\s\S]*?q=[\s\S]*?size=[\s\S]*?time=[\s\S]*?bitrate=[\s\S]*?speed=[\s\S]*? ";
                Regex regex = new Regex(pattern);
                proc.ErrorDataReceived += new DataReceivedEventHandler((sender, e) =>
                {
                    if (e != null && e.Data != null)
                    {
                        Match match = regex.Match(e.Data.ToString());
                        if (match.Success)
                        {
                            AnsiConsole.MarkupLine("[#FDE047]{0}[/]", match.Value.EscapeMarkup());
                        }
                    }
                });
                proc.Start();

                proc.BeginErrorReadLine();
                proc.WaitForExit();
            });
        
        
    }
    catch (Exception ex)
    {
        AnsiConsole.Markup($"[red]{ex.Message};\r\n{ex.StackTrace}\r\n[/]");
        throw;
    }
    finally
    {
        proc.Close();
        proc.Dispose();
        await Common.WriteFile("outputsplit.txt", $"{targetPath}\n");
        await Common.SetStep("upload");
    }
}

执行截图如下👇:

picture.image

3.8、上传

上传的部分分为客户端和服务端两个部分,基本流程就是客户端搜集待上传的文件列表,执行上传操作,并生成安全相关的签名,服务端验证请求签名,接收文件,完成更新。

上传的代码逻辑也比较复杂,主要和自身业务相关,这里也不再赘述,看一下执行上传的截图。

  • 上传分片文件

picture.image

  • 上传原始大文件

picture.image

  • 流程结束

picture.image 这里我是吧分割后的ts文件和转码后的大视频文件都上传了,大视频文件是作为归档资料进行上传,分片文件则是平台实际使用的文件。

需要说明的一点是,每个步骤在开始执行前,都会检测前置步骤是否完成,执行结束后也会输出当前步骤执行结束的标志,确保数据流转的过程是一个整体,这也是数据流软件架构风格的特点。

四、参数说明

所有的参数均可以通过命令行参数的形式传入,列表如下

4.1、详细列表

picture.image

这里的我是在语雀先写好草稿然后再粘贴到社区,发现表格的支持不是很好,就转化成了图片

4.2、常用组合

全自动处理

Magic.DownloadSoldier.exe -mode auto

指定某房间号,自动完成视频处理,设定高性能执行,常规规格转码

Magic.DownloadSoldier.exe -streamid 427644 -skipedit y -connect y -p high -q normal

从转码开始自动完成后续流程

Magic.DownloadSoldier.exe -step convert -inputfile "完整路径" -liveid "从后台获取到的直播id" -uploadtype all 

好了,以上就是处理工具的全部流程介绍了。 ps:本文同步发表于infoQ社区:https://xie.infoq.cn/article/9a8abebacf858783c67166623

0
0
0
0
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论