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 转码的完整数据流:

WebRTC 到 FFmpeg 数据流架构图 浏览器 → Node.js → FFmpeg 数据流 浏览器 MediaRecorder 录制 Blob → ArrayBuffer FileReader / Response WebSocket 发送 二进制数据块 网络传输 Node.js 服务 WebSocket 接收数据 PassThrough 流 数据缓冲与排序 FFmpeg 转码 stdin 读取 → 输出文件 WebSocket 二进制帧 关键点 • 流式传输:边录边发 • 数据格式:ArrayBuffer • 流结束:必须 end() 输出文件 MP4 / HLS / FLV

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 去做转码、切片、推流。说白了,这就是浏览器端实时视频处理的基石。