话说那天下午,测试同学丢过来一个工单:“XX 直播平台网页版在 IE 里直接崩,你们 WinForm 壳能不能救?” 我心想,WinForm 4.8 套个 WebView2 不就完事了?结果产品经理补了一刀:“顺便把直播流录下来,要本地 mp4。”
好家伙。直接录屏?糊成狗。让用户开 OBS?那还要我写什么桌面应用。
于是就有了这次折腾:用 WebView2 的 CDP (Chrome DevTools Protocol) 把 WebSocket 里的直播帧捞出来,再喂给 FFmpeg 合成视频。整个过程踩了数据分片、进程死锁、音画不同步三个大坑,今天一次性抖干净。
⚠️ 先泼盆冷水:这套方案只适合 B 站、抖音网页版这类走 WebSocket 传 FLV 或 TS 分片的直播。如果平台走的是 MSE (Media Source Extensions) 或 WebRTC,CDP 抓不到完整流,趁早换方案。
一、CDP 不是“抄”页面,是“接”水管
很多人一听 DevTools Protocol 就以为是搞自动化爬虫,其实它本质上是 Chrome 对外暴露的 调试控制总线。我们通过它干两件事:
- 给 WebView2 开一个远程调试端口(9222)
- 订阅
Network.webSocketFrameReceived事件,拿到每一帧裸数据
代码就三行,但有个版本雷区:WebView2 SDK 1.0.2210.55 以下不支持 DevToolsProtocolHelper。我项目用的 1.0.2651.41,稳。
// 初始化时带调试端口
var env = await CoreWebView2Environment.CreateAsync(null, null,
new CoreWebView2EnvironmentOptions
{
AdditionalBrowserArguments = "--remote-debugging-port=9222"
});
await webView21.EnsureCoreWebView2Async(env);
// 启用 Network 域并挂事件
var helper = webView21.CoreWebView2.GetDevToolsProtocolHelper();
await helper.Network.EnableAsync();
helper.Network.WebSocketFrameReceived += (sender, e) =>
{
// 只抓二进制帧 (Opcode = 2),文本帧通常是 JSON 控制消息
if (e.Frame.Opcode == 2)
{
byte[] rawData = Convert.FromBase64String(e.Frame.PayloadData);
// 这里先攒着,后面讲怎么喂给 FFmpeg
_frameBuffer.Enqueue(rawData);
}
};
输出? 没输出。WebSocketFrameReceived 每秒能触发 100~200 次,你 Console.WriteLine 直接卡死 UI。建议用 ConcurrentQueue 攒数据,另起一个线程消费。
有个坑:PayloadData 是 Base64 编码的,不是原始二进制。上面 Convert.FromBase64String 这步不能省,否则 FFmpeg 吞进去全是乱码。
二、流式数据不是“攒”完再转,是“边收边喂”
一开始我犯了个新手错误:把所有帧攒成一个 byte[],再一次性写文件。结果直播 5 分钟,内存飙到 1.2GB,GC 直接罢工。
正确的姿势是 管道式消费:
- 消费线程从
ConcurrentQueue里取帧 - 判断帧类型 (H.264 NAL 单元 / AAC ADTS 头)
- 按 FLV 或 TS 容器格式封装后,通过 stdin 喂给 FFmpeg
这里我选 FLV 封装,因为 FFmpeg 对 pipe: 输入 FLV 的支持最稳定。
// 启动 FFmpeg 子进程,从标准输入读 FLV
var ffmpegStartInfo = new ProcessStartInfo
{
FileName = "ffmpeg",
Arguments = "-i pipe:0 -c copy -f mp4 -y output.mp4",
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
CreateNoWindow = true
};
_ffmpegProcess = Process.Start(ffmpegStartInfo);
// 消费线程
while (_isRecording)
{
if (_frameBuffer.TryDequeue(out byte[] frame))
{
// 这里需要把 frame 封装成 FLV 标签,篇幅所限省略具体封装逻辑
// 参考:flv 规范,H.264 用 AVCDecoderConfigurationRecord + NALU
var flvTag = BuildFlvTag(frame);
_ffmpegProcess.StandardInput.BaseStream.Write(flvTag, 0, flvTag.Length);
_ffmpegProcess.StandardInput.BaseStream.Flush();
}
else
{
Thread.Sleep(10);
}
}
关键数字:Thread.Sleep(10) 不能省。如果队列空时忙等,CPU 占用会飙到 25% 以上。实测 10ms 休眠,CPU 稳定在 3%~5%。
三、典型翻车:FFmpeg 进程“假死”与 stdin 阻塞
你以为上面代码能跑?天真。
跑着跑着发现 _ffmpegProcess.StandardInput.BaseStream.Write 在写了几千帧之后 卡住不返回了。用 Process Explorer 一看,FFmpeg 的 stdin 管道缓冲区满了,而 FFmpeg 因为输出(stdout/stderr)没被消费,导致整个管道死锁。
解决方法:异步消费 FFmpeg 的标准输出和错误输出,哪怕不处理也要 Read。
_ = Task.Run(() =>
{
while (_ffmpegProcess.StandardOutput.ReadLine() != null) { }
});
_ = Task.Run(() =>
{
while (_ffmpegProcess.StandardError.ReadLine() != null) { }
});
加上这两行后台读取任务后,stdin 写入再也没卡过。
四、音画不同步?那是 PTS 没处理好
直播流里视频帧和音频帧是分开的 WebSocket 通道(或同一通道不同 opcode)。我抓到的数据里,视频帧每 40ms 左右一个,音频帧每 100ms 左右一个。
如果不做时间戳(PTS/DTS)对齐,直接按到达顺序喂给 FFmpeg,出来的 mp4 会越看越“声画分离”。
我的做法是:用 WebSocket 帧自带的时间戳字段(如果协议有)或者用本地 Stopwatch 打时间戳,在封装 FLV 时写入 PTS 和 DTS 字段。
// 封装 FLV 视频标签时,强制设置 PTS 为相对时间
uint pts = (uint)(_stopwatch.Elapsed.TotalMilliseconds * 90); // 90kHz 时钟
flvTag[5] = (byte)(pts >> 16);
flvTag[6] = (byte)(pts >> 8);
flvTag[7] = (byte)pts;
音频同理,但 AAC 的 PTS 要跟视频的参考时钟对齐,不能独立计时。我踩坑后用的是 视频 PTS 作为主时钟,音频帧到达时取最近的视频 PTS 赋值。这样导出的 mp4 用 MediaInfo 看,音视频 duration 差值小于 20ms。
五、性能压测:CDP 抓包 vs 直接录屏
| 方案 | CPU 占用 (i5-8250U) | 内存峰值 | 帧同步误差 | 是否依赖窗口可见 |
|---|---|---|---|---|
| CDP + FFmpeg 流式 | 12%~18% | 280 MB | < 20 ms | 否 |
| gdigrab 录屏 | 35%~40% | 150 MB | 200~500 ms | 是 |
| DXGI 抓屏 (ddagrab) | 25%~30% | 180 MB | 100~200 ms | 否 |
CDP 方案 CPU 赢在 不解码不渲染,直接转发原始码流。代价是内存略高(因为有队列缓冲),但可以接受。
如果你只是想快速交差,不 care 画质和同步,用
ffmpeg -f gdigrab -i title="窗口名"确实省事。但产品要的是“可后台录制”,那就只能走 CDP 这条窄路。
六、最终落地方案与避坑清单
- WebView2 版本:必须 ≥ 1.0.2210.55,推荐最新稳定版(我用的 1.0.2651.41)
- FFmpeg 参数:
-i pipe:0 -c copy -f mp4 -y output.mp4,不要加-re,那是实时推流用的 - 进程管理:
CreateNoWindow = true+RedirectStandard* = true+ 异步消费 stdout/stderr,缺一不可 - 数据封装:FLV 标签必须写对 TagType (8=音频, 9=视频, 18=脚本) 和时间戳,否则 FFmpeg 报
Non-monotonous DTS - 退出清理:录制结束后,
_ffmpegProcess.StandardInput.Close()再WaitForExit(),否则 FFmpeg 收不到 EOF 会一直等
最终代码跑了 2 周,每天 4 小时直播录制,没有崩溃,没有音画不同步。产品满意,测试闭嘴,我也终于能安心下班。
至于那些问“为什么不直接调浏览器 API 拿流”的人——你猜怎么着?WebView2 的 CoreWebView2 压根不暴露 MediaRecorder,CDP 是唯一官方后门。
如果哪天 Chrome 把这个 CDP 事件也给禁了……那咱们再想别的歪招。


