16、录制与回放:MediaRecorder API、服务端录制、录制文件管理、回放功能

说到多人视频会议,录制功能几乎是刚需。你想啊,会议开完了,有人迟到了,或者想回顾一下讨论的重点,总不能让人家重新开个会吧?

这一章,我们就来聊聊录制与回放。说白了,就是怎么把音视频流存下来,以及怎么再放出来。我在这块踩过不少坑,尤其是服务端录制那部分,嗯,咱们慢慢聊。

16.1 客户端录制:MediaRecorder API

先讲最简单的方案——直接在浏览器里录。MediaRecorder API 是浏览器原生支持的,不需要额外装任何东西。

它的核心逻辑很简单:你把一个 MediaStream 丢给它,它就开始录,录完给你一个 Blob。这个 Blob 你可以下载,也可以上传到服务器。

核心思路:从 PeerConnection 里拿到远程流的 MediaStream,然后喂给 MediaRecorder。

16.1.1 基本用法

我习惯这样写:

// 假设 remoteStream 是远程用户的视频流
const mediaRecorder = new MediaRecorder(remoteStream, {
  mimeType: 'video/webm;codecs=vp9'
});

let recordedChunks = [];

mediaRecorder.ondataavailable = (event) => {
  if (event.data.size > 0) {
    recordedChunks.push(event.data);
  }
};

mediaRecorder.onstop = () => {
  const blob = new Blob(recordedChunks, { type: 'video/webm' });
  // 这里可以下载或上传
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'meeting.webm';
  a.click();
};

// 开始录制
mediaRecorder.start(1000); // 每秒触发一次 ondataavailable

这里有个细节:start(1000) 里的参数是时间片,单位毫秒。我建议设成 1000 或 2000,这样每秒钟拿到一段数据,不会因为单次数据太大导致内存爆掉。

小提示:不是所有浏览器都支持 vp9 编码。你可以先检查一下:

MediaRecorder.isTypeSupported('video/webm;codecs=vp9')

如果不支持,就降级到 vp8。

16.1.2 录制自己的画面

有时候你想同时录自己的摄像头画面和远程画面。怎么做?

我遇到过这个需求,当时用了 Canvas 混流。把两个视频流画到一个 Canvas 上,然后录 Canvas 的流。

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');

// 把本地和远程视频画到 canvas 上
function drawFrame() {
  ctx.drawImage(localVideo, 0, 0, 320, 240);
  ctx.drawImage(remoteVideo, 320, 0, 320, 240);
  requestAnimationFrame(drawFrame);
}

const canvasStream = canvas.captureStream(30); // 30fps
const recorder = new MediaRecorder(canvasStream);
recorder.start();

嗯,这里要注意性能。如果帧率太高,CPU 会扛不住。我一般设 15fps 就够用了。

16.2 服务端录制

客户端录制有个硬伤:如果用户浏览器崩了,录到一半的数据就没了。所以,正经的会议系统都会做服务端录制。

服务端录制,说白了就是把客户端的音视频流推到服务器,服务器再把它存成文件。

16.2.1 架构思路

我画了一张图,帮你理解整个流程:

客户端 A 推流 (WebRTC) 客户端 B 推流 (WebRTC) SFU 服务器 混流 / 转发 接收所有音视频流 生成录制文件 录制服务 接收原始流 编码 / 封装 写入存储 文件存储 (本地 / S3 / OSS)

你看,客户端把流推到 SFU,SFU 再转发给录制服务。录制服务拿到原始流后,编码、封装、写入存储。

16.2.2 服务端录制方案

我推荐两种方案:

方案 优点 缺点 适用场景
FFmpeg 录制 成熟稳定,支持格式多 需要转码,CPU 消耗高 小规模会议(< 10 人)
直接存储原始流 延迟低,CPU 消耗小 文件格式不通用 大规模会议,后续再转码

我个人习惯用 FFmpeg。举个例子,从 SFU 拉 RTMP 流,直接存成 MP4:

ffmpeg -i rtmp://sfu-server/live/meeting123 \
  -c:v libx264 -preset ultrafast \
  -c:a aac \
  -f mp4 meeting123.mp4

注意:FFmpeg 的 -preset ultrafast 虽然快,但文件会大一些。如果对文件大小有要求,可以用 fastmedium

16.3 录制文件管理

录完了,文件怎么管?这可不是简单丢个文件夹就完事的。

我曾经在一个项目里吃过亏——录了上千个文件,结果找起来跟大海捞针一样。后来我总结了一套管理方法:

16.3.1 文件命名规范

我建议用这种格式:

{会议ID}_{开始时间}_{结束时间}_{录制者}.{格式}

举个例子:

mtg_20231001_140000_143000_zhangsan.mp4

这样一看就知道是谁、什么时候、录了多久。

16.3.2 存储结构

我习惯按日期分目录:

/recordings/
├── 2023/
│   ├── 10/
│   │   ├── 01/
│   │   │   ├── mtg_20231001_140000_143000_zhangsan.mp4
│   │   │   └── mtg_20231001_150000_153000_lisi.mp4
│   │   └── 02/
│   └── 11/
└── 2024/

这样查找起来非常快。如果文件量特别大,还可以加一层按用户分。

16.3.3 元数据管理

光有文件名还不够。我建议把元数据存到数据库里:

CREATE TABLE recordings (
  id VARCHAR(64) PRIMARY KEY,
  meeting_id VARCHAR(64) NOT NULL,
  user_id VARCHAR(64) NOT NULL,
  start_time DATETIME NOT NULL,
  end_time DATETIME,
  file_path VARCHAR(512) NOT NULL,
  file_size BIGINT,
  duration INT,
  status ENUM('recording', 'completed', 'failed')
);

这样你就可以按会议、用户、时间范围来查询了。

16.4 回放功能

回放,说白了就是把存好的文件再播出来。但这里有几个坑要注意。

16.4.1 浏览器播放

最简单的方案:直接用 HTML5 的 <video> 标签。

<video controls src="/recordings/mtg_20231001_140000_143000_zhangsan.mp4"></video>

但有个问题——如果文件很大(比如 2 小时的会议),用户得等半天才能开始播。怎么办?

我建议用 HLS 或 DASH 分片播放。把大文件切成小段,用户点开就能看,边看边加载。

16.4.2 分片转码

用 FFmpeg 把 MP4 转成 HLS:

ffmpeg -i meeting.mp4 \
  -c:v libx264 -c:a aac \
  -hls_time 10 \
  -hls_list_size 0 \
  -f hls meeting.m3u8

这样会生成一堆 .ts 文件和 .m3u8 索引文件。前端用 hls.js 来播:

const video = document.getElementById('video');
const hls = new Hls();
hls.loadSource('/recordings/meeting.m3u8');
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
  video.play();
});

小提示:如果用户网络不好,HLS 会自动切换到低码率。这个体验比直接播 MP4 好太多了。

16.4.3 回放控制

回放不只是播放暂停。我遇到过用户想要倍速播放、跳转到特定时间点、甚至只播某一段。

这些功能 HTML5 的 <video> 基本都支持:

// 倍速播放
video.playbackRate = 1.5;

// 跳转到 5 分钟处
video.currentTime = 300;

// 只播 10-20 分钟这一段
video.addEventListener('timeupdate', () => {
  if (video.currentTime > 1200) {
    video.pause();
  }
});

嗯,这里要注意:跳转时如果文件没加载完,会有缓冲。分片播放能缓解这个问题。

16.5 避坑指南

最后,分享几个我踩过的坑:

  • 录制文件损坏:我曾经遇到过录制到一半服务器重启,文件没正常关闭,结果整个文件都打不开。解决方案:定期刷新(比如每 5 分钟生成一个分段文件)。
  • 音画不同步:服务端录制时,如果音频和视频分开处理,很容易不同步。我建议用 FFmpeg 的 -copyts 参数保持时间戳。
  • 存储空间爆炸:一个 1 小时的 1080p 会议,大概要 1-2GB。如果不做清理,硬盘很快就满了。我建议设置自动清理策略,比如保留 30 天。

总结一下:录制与回放,核心就是「录得下来,存得规范,播得流畅」。客户端录制适合轻量场景,服务端录制适合正式系统。文件管理要规范,回放要支持分片。


公众号:蓝海资料掘金营,微信deep3321