4、WebRTC与FFmpeg的桥梁:MediaRecorder录制、Blob转ArrayBuffer、Node.js接收流
前面几章我们把 WebRTC 的推拉流和信令都跑通了。但有个问题一直绕不开——录下来的数据怎么交给 FFmpeg 去转码?
WebRTC 原生输出的是 RTP 包或者 MediaStream,FFmpeg 吃的是文件或者流式数据。这两者之间,需要一座桥。
这座桥,就是 MediaRecorder + Blob + ArrayBuffer + Node.js 流。说白了,就是浏览器端录一段,转成二进制,然后通过 WebSocket 或者 HTTP 发给后端,后端再喂给 FFmpeg。
我个人习惯把这条链路叫做「浏览器到转码引擎的数据管道」。今天我们就把它彻底打通。
4.1 浏览器端:MediaRecorder 录制与数据获取
MediaRecorder 是浏览器原生 API,专门用来录制 MediaStream。它不挑输入,只要是 MediaStream 就行——摄像头、屏幕共享、甚至远端视频流都可以。
核心思路:把 WebRTC 的 MediaStream 塞进 MediaRecorder,然后监听 ondataavailable 事件,拿到一个个 Blob 片段。
4.1.1 基本录制代码
// 假设我们已经有了一个 MediaStream(来自 getUserMedia 或 RTCPeerConnection)
const stream = localStream; // 或者 remoteStream
// 创建 MediaRecorder 实例
const recorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp8,opus', // 浏览器最通用的格式
videoBitsPerSecond: 2500000 // 2.5 Mbps,根据场景调整
});
// 存储所有数据块
const chunks = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
console.log(`收到数据块,大小: ${event.data.size} bytes`);
}
};
// 每 1 秒触发一次 ondataavailable
recorder.start(1000);
// 停止录制
// recorder.stop();
这里有个细节:start(1000) 里的参数是 timeslice,单位毫秒。它决定了 ondataavailable 触发的频率。我建议设成 1000-3000ms,太短了碎片太多,太长了实时性差。
关于 MIME Type:不是所有浏览器都支持所有格式。建议先检测一下:
const supported = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus');
if (!supported) {
// 降级到 vp8
mimeType = 'video/webm;codecs=vp8,opus';
}
4.1.2 从 Blob 到 ArrayBuffer
录下来的 Blob 不能直接发给 Node.js 做流式处理。我们需要把它转成 ArrayBuffer——这是二进制数据的标准容器,可以方便地通过 WebSocket 发送,或者用 fetch 上传。
// 假设 chunks 里已经存了多个 Blob
const fullBlob = new Blob(chunks, { type: 'video/webm' });
// 方法一:使用 FileReader(兼容性好)
function blobToArrayBuffer(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsArrayBuffer(blob);
});
}
// 方法二:使用 Response(更现代,我更喜欢这个)
async function blobToArrayBufferModern(blob) {
const response = new Response(blob);
return await response.arrayBuffer();
}
// 使用
const arrayBuffer = await blobToArrayBuffer(fullBlob);
console.log(`ArrayBuffer 大小: ${arrayBuffer.byteLength} bytes`);
嗯,这里要注意:如果录制时间很长,fullBlob 可能会非常大。我建议不要等全部录完再转,而是每收到一个 Blob 片段就立即转成 ArrayBuffer 并发送,实现流式传输。
4.2 数据传输:从浏览器到 Node.js
数据准备好了,怎么传给后端?常见方案有两种:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| WebSocket | 全双工、低延迟、支持流式 | 需要维护连接状态 | 实时转码、直播场景 |
| HTTP POST (fetch) | 简单、无状态、容易调试 | 只能单向、不适合流式 | 录制完成后统一上传 |
我个人更倾向 WebSocket,因为可以边录边发,后端边收边喂给 FFmpeg,延迟最低。
4.2.1 浏览器端 WebSocket 发送
const ws = new WebSocket('wss://your-server.com/ingest');
ws.onopen = () => {
console.log('WebSocket 连接已建立');
};
// 每收到一个数据块就立即发送
recorder.ondataavailable = async (event) => {
if (event.data.size > 0) {
const buffer = await new Response(event.data).arrayBuffer();
if (ws.readyState === WebSocket.OPEN) {
ws.send(buffer);
console.log(`发送数据块: ${buffer.byteLength} bytes`);
}
}
};
注意:WebSocket 发送二进制数据时,后端收到的可能是 Buffer(Node.js)或 ArrayBuffer(浏览器)。确保两端的数据格式一致。我曾经踩过这个坑——浏览器发的是 Blob,后端当 Buffer 解析,结果数据全乱了。
4.3 Node.js 端:接收流并喂给 FFmpeg
后端收到二进制数据后,需要把它转换成 FFmpeg 能读的流。Node.js 的 stream 模块就是干这个的。
4.3.1 使用 WebSocket 接收并创建可读流
const WebSocket = require('ws');
const { PassThrough } = require('stream');
const { spawn } = require('child_process');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
console.log('浏览器已连接');
// 创建一个 PassThrough 流,作为数据缓冲区
const mediaStream = new PassThrough();
// 启动 FFmpeg 进程,从 stdin 读取
const ffmpeg = spawn('ffmpeg', [
'-i', 'pipe:0', // 从标准输入读取
'-c:v', 'libx264', // 视频转码为 H.264
'-c:a', 'aac', // 音频转码为 AAC
'-f', 'mp4', // 输出格式
'-y', 'output.mp4' // 输出文件
]);
// 把媒体流 pipe 到 FFmpeg 的 stdin
mediaStream.pipe(ffmpeg.stdin);
// 监听 FFmpeg 的错误输出(用于调试)
ffmpeg.stderr.on('data', (data) => {
console.log(`FFmpeg: ${data}`);
});
ffmpeg.on('close', (code) => {
console.log(`FFmpeg 进程退出,退出码: ${code}`);
});
// 接收浏览器发来的二进制数据
ws.on('message', (data) => {
// data 是 Buffer 类型
mediaStream.write(data);
console.log(`收到并写入流: ${data.length} bytes`);
});
ws.on('close', () => {
// 浏览器断开连接,结束流
mediaStream.end();
console.log('浏览器断开,流已结束');
});
});
这段代码的核心是 PassThrough 流。它就像一个水管接头——浏览器往里面倒水(写数据),FFmpeg 从另一头喝水(读数据)。
为什么用 PassThrough 而不是直接 pipe?
因为 WebSocket 的 message 事件是异步的,数据块可能乱序到达。PassThrough 会按写入顺序输出,保证 FFmpeg 收到的数据是完整的、有序的。
4.3.2 处理流式数据的边界问题
实际项目中,你可能会遇到这些问题:
- 数据块太小:浏览器
timeslice设得太短,导致大量小数据块。FFmpeg 处理小数据块效率低。我建议timeslice至少 1000ms。 - 网络抖动:WebSocket 可能断连。需要实现重连机制,并在重连后告诉 FFmpeg 继续接收。
- 流结束信号:浏览器停止录制后,必须调用
mediaStream.end(),否则 FFmpeg 会一直等待输入,不会输出文件。
调试技巧:在开发阶段,可以把 FFmpeg 的 stderr 输出到控制台。FFmpeg 的日志非常详细,能告诉你编码参数、帧率、丢帧等信息。我曾经靠这个定位过一个音频采样率不匹配的问题。
4.4 整体架构图
下面这张图展示了从浏览器录制到 FFmpeg 转码的完整数据流:
4.5 完整示例:从录制到转码
把上面所有环节串起来,一个最小可用的端到端示例:
// ========== 浏览器端 ==========
async function startRecordingAndStream(stream) {
const ws = new WebSocket('wss://your-server.com/ingest');
await new Promise(resolve => ws.onopen = resolve);
const recorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp8,opus'
});
recorder.ondataavailable = async (event) => {
if (event.data.size > 0 && ws.readyState === WebSocket.OPEN) {
const buffer = await new Response(event.data).arrayBuffer();
ws.send(buffer);
}
};
recorder.start(2000); // 每 2 秒一个数据块
return recorder;
}
// ========== Node.js 端 ==========
const WebSocket = require('ws');
const { PassThrough } = require('stream');
const { spawn } = require('child_process');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
const mediaStream = new PassThrough();
const ffmpeg = spawn('ffmpeg', [
'-i', 'pipe:0',
'-c:v', 'libx264',
'-preset', 'fast',
'-c:a', 'aac',
'-f', 'mp4',
`output_${Date.now()}.mp4`
]);
mediaStream.pipe(ffmpeg.stdin);
ffmpeg.stderr.pipe(process.stderr);
ws.on('message', (data) => mediaStream.write(data));
ws.on('close', () => mediaStream.end());
});
生产环境注意事项:
- WebSocket 需要做鉴权,防止未授权客户端推流
- FFmpeg 进程需要做资源限制,防止内存泄漏
- 建议使用
ffmpeg-spawn或fluent-ffmpeg等库管理 FFmpeg 进程 - 流中断后需要清理临时文件和进程
这条链路跑通之后,WebRTC 和 FFmpeg 之间的墙就算拆掉了。浏览器录制的任何内容,都可以实时地、流式地交给 FFmpeg 去做转码、切片、推流。说白了,这就是浏览器端实时视频处理的基石。