一、开发背景
我所在的团队开发了一款面向青少年科普创新活动的在线教育平台,平台会不定期的举行一些直播活动,有时候 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
评论