写点什么

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

作者:为自己带盐
  • 2023-12-11
    河北
  • 本文字数:8875 字

    阅读完需:约 29 分钟

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

一、开发背景

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



而关于回放文件的处理,我们也是使用了“双通道”的处理模式,即直播结束后,首先切换到 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<MediaInfo[]> SearchMedia(string streamId){    try    {        await Common.SetStep("searchmedia");                Credential cred = new Credential        {            SecretId = vodSecretId,            SecretKey = vodSecretKey        };        ClientProfile clientProfile = new ClientProfile();        HttpProfile httpProfile = new HttpProfile();        httpProfile.Endpoint = ("vod.tencentcloudapi.com");        clientProfile.HttpProfile = httpProfile;        VodClient client = new VodClient(cred, "", clientProfile);        SearchMediaRequest req = new SearchMediaRequest();        req.Offset = 0;        req.Limit = 100;        req.StreamId = streamId;        req.Filters = new string[] { "basicInfo" };        SearchMediaResponse resp = await client.SearchMedia(req);        await Common.WriteFile("currStream.txt", streamId);        await Common.WriteFile($"mediaInfo_{streamId}.txt", AbstractModel.ToJsonString(resp),false,"logs");        AnsiConsole.MarkupLine($"[cyan]共计搜索到{resp.MediaInfoSet.Length}条媒资记录...{Common.GetTimeStr()}[/]");        return resp.MediaInfoSet;    }    catch (Exception e)    {        AnsiConsole.MarkupLine($"[red]搜索vod资源失败:{e.Message}...{Common.GetTimeStr()}[/]");    }    return Array.Empty<MediaInfo>();}
复制代码


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


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();}
复制代码


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


3.3、下载

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


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 当中,其余均为工具本身的业务点,主要是设定当前步骤,输出信息为下一步做铺垫等。代码执行结果如下:


3.5、编辑

由于直接录制的视频文件一般不能直接作为回放,都需要进行一些处理,包括裁剪掉一些不需要的片段,增加字幕,增加前置或者后置片段等,因此本工具在执行到编辑阶段后会自动暂停,提示用户通过第三方工具编辑拼接完成的视频,当然如果不需要编辑,也可以通过传入 skip 参数跳过编辑步骤。这里的代码很简单,就是判定用户是否跳过当前环节,如果跳过则继续执行下一步,否则则临时退出程序,视频编辑完成后再次执行即可。


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


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); }}
复制代码


这一步的执行截图如下


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"); }}
复制代码


执行截图如下👇:



3.8、上传

上传的部分分为客户端和服务端两个部分,基本流程就是客户端搜集待上传的文件列表,执行上传操作,并生成安全相关的签名,服务端验证请求签名,接收文件,完成更新。上传的代码逻辑也比较复杂,主要和自身业务相关,这里也不再赘述,看一下执行上传的截图。


  • 上传分片文件



  • 上传原始大文件



  • 流程结束



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


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

四、参数说明

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

4.1、详细列表



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

发布于: 刚刚阅读数: 5
用户头像

学着写代码 2019-04-11 加入

是一枚,热爱技术,天赋不高,又有点轴,的猿。。

评论

发布
暂无评论
基于FFmpeg实现一个数据流风格的视频处理工具 | 社区征文_ffmpeg_为自己带盐_InfoQ写作社区