录制的需求几乎总是后置出现的:先上线通话,运营提「能不能录下来存档」,法务提「能不能存证」,用户提「能不能回放」。等需求出现时才发现,录制不是加个按钮那么简单——它牵涉到从哪一路取流、用什么容器、什么时候切分、怎么保证不丢帧。
实时媒体与文件媒体是两套完全不同的约束。RTP 流以时间戳驱动、允许丢包、随时可能中断;而一个可播放的文件必须帧序完整、时间轴连续、元数据在头或尾。录制与转码的本质工作,就是把前者转换成后者,并在这个过程中尽量不引入新的损失。
本文按「录制在哪做 → 存成什么 → 怎么转 → 怎么存回放 → 怎么保证一致」的顺序展开,覆盖客户端与服务端两条路线,最后给出成本与质量的取舍。如果你还没确定多人拓扑,建议先读 SFU 与 MCU 架构选型 ,因为录制模式的选择与拓扑强相关。
目录
- 录制方案的全景
- MediaRecorder 客户端录制
- 客户端录制的固有局限
- 服务端录制:旁路与合成
- 容器与编码格式选择
- 转码集群与硬件加速
- 分片、合并与断点续录
- 存储与回放链路
- 录制一致性与丢包补偿
- 录制质量与成本权衡
1. 录制方案的全景
录制在架构上只有三个落点,各自的取舍非常清晰:
| 方案 | 取流位置 | 服务端成本 | 可靠性 | 适用 |
|---|---|---|---|---|
| 客户端 MediaRecorder | 浏览器本地 | 几乎为零 | 差(依赖用户设备) | 个人存档、轻量场景 |
| 服务端旁路录制 | SFU 的 Producer | 低(只存不转) | 高 | 会议存档、合规 |
| 服务端合成录制 | SFU 混流后 | 高(解码 + 编码) | 高 | 回放体验优先 |
三条路线的核心区别是「在链路的哪个位置截取媒体」。客户端截取最省服务端资源但最不可靠;旁路录制保留原始编码,成本最低但回放需要客户端自己拼接多路;合成录制产出单一文件,回放体验最好但服务端要做完整编解码。
2. MediaRecorder 客户端录制
MediaRecorder 是浏览器提供的本地录制 API,输入是 MediaStream,输出是按指定 MIME 类型封装的 Blob。
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
// 优先级从高到低,选择浏览器支持的第一种
const candidates = [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/mp4;codecs=h264,aac",
"video/webm",
];
const mimeType = candidates.find((m) => MediaRecorder.isTypeSupported(m));
const recorder = new MediaRecorder(stream, {
mimeType,
videoBitsPerSecond: 2_000_000,
audioBitsPerSecond: 64_000,
});
const chunks = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
recorder.onstop = () => {
const blob = new Blob(chunks, { type: mimeType });
console.log("录制完成:", (blob.size / 1024 / 1024).toFixed(1), "MB");
uploadChunks(chunks);
};
recorder.start(1000); // 每 1 秒触发一次 dataavailable
start(timeslice) 的参数非常关键。不传时只有 stop() 才会产出数据,一旦标签页崩溃或用户直接关页面,整段录制全部丢失。传入 1000 ms 表示每秒产出一个分片,配合定时上传,能把丢失窗口压到 1 秒。
3. 客户端录制的固有局限
- 单路视角:
MediaRecorder只能录本地采集的流,录不到远端。要录多人会议,得把所有远端轨道混进一个MediaStream再录,而这在浏览器端只能做音频混音,视频无法合成。 - 资源竞争:录制与实时通话共用同一个编码器与 CPU,弱机型上会直接拖垮通话质量。
- 不可控:用户关页面、断网、切后台,录制就断了,且无法续录。
- 合规风险:文件在用户设备上,服务端无法保证其未被篡改。
因此客户端录制只适合「用户自己的本地存档」这类弱承诺场景。任何涉及存档、取证、质检的需求,都必须走服务端。
4. 服务端录制:旁路与合成
4.1 旁路录制
旁路(bypass)录制不改动媒体流,直接把 SFU 上的 RTP 包落盘成媒体文件。以 mediasoup 为例,可以在 Producer 上挂一个 PlainTransport,把 RTP 转发给一个录制进程:
const recorderTransport = await router.createPlainTransport({
listenIp: { ip: "127.0.0.1", announcedIp: null },
rtcpMux: false,
comedia: false,
});
const consumer = await recorderTransport.consume({
producerId: producer.id,
rtpCapabilities: router.rtpCapabilities,
paused: false,
});
// 每个 Consumer 对应一路独立录制文件
console.log("录制参数:", JSON.stringify(consumer.rtpParameters.codecs[0]));
旁路录制的产物是每路一个文件(每个参与者一路音频、一路视频),而不是一个合成文件。好处是零转码、服务端成本极低、原始画质完整保留;坏处是回放时必须由客户端同时加载多个文件并同步播放,且容器多为 RTP 裸流或 WebM 分片,需要后处理才能生成通用格式。
4.2 合成录制
合成录制在服务端把多路媒体解码、按布局混合、再编码成一路。这等价于一个 MCU 的编码流程,因此成本与 MCU 相同:CPU 开销大,且有额外延迟(但不影响实时通路,因为录制是旁路的分支)。
合成录制的常见做法是用 GStreamer 或 FFmpeg 拉起一个混流管线,布局参数(宫格、演讲者视图、画中画)通过 API 下发。
gst-launch-1.0 -e \
compositor name=comp sink_0::xpos=0 sink_0::ypos=0 \
sink_1::xpos=640 sink_1::ypos=0 ! \
video/x-raw,width=1280,height=720,framerate=30/1 ! \
x264enc tune=zerolatency bitrate=2500 key-int-max=60 ! \
h264parse ! mp4mux ! filesink location=room-123.mp4 \
udpsrc port=5000 caps="application/x-rtp,media=video,encoding-name=VP8" ! \
rtpptdemux ! rtpjitterbuffer latency=200 ! rtpvp8depay ! vp8dec ! comp.sink_0 \
udpsrc port=5002 caps="application/x-rtp,media=video,encoding-name=VP8" ! \
rtpptdemux ! rtpjitterbuffer latency=200 ! rtpvp8depay ! vp8dec ! comp.sink_1
关键参数是 rtpjitterbuffer latency:它决定了抗抖动的缓冲深度,设得太小会丢帧,太大则录制文件的时间轴会整体后移。
4.3 与端到端加密的冲突
若启用了端到端加密(见 端到端加密与安全模型 ),服务端拿到的媒体是密文,无法解码也无法合成,只能做旁路录制,且落盘的是密文,回放必须由持有密钥的客户端解密。这是 E2EE 与录制之间不可调和的矛盾,产品设计阶段就要明确取舍。
5. 容器与编码格式选择
| 容器 | 编码支持 | 优势 | 劣势 |
|---|---|---|---|
| WebM | VP8/VP9/AV1 + Opus | 浏览器原生,分片友好 | 部分播放器兼容性差 |
| MP4 | H.264/H.265 + AAC | 通用性最好 | 需要 moov 原子,不宜流式追加 |
| fMP4 | H.264/H.265 + AAC | 可流式分片,适合 HLS | 需要额外的索引文件 |
| MKV | 几乎全部 | 灵活、支持多轨 | 浏览器不支持直接播放 |
两个实用结论:
- 服务端旁路录制优先用 WebM 或 MKV,因为不需要重新编码,直接封装即可;若后续要转 MP4,用 FFmpeg 流式转换(
-c copy)即可,速度接近磁盘 IO 上限。 - 需要边录边播(如直播回看)必须用 fMP4 加 HLS/DASH 分片,因为 MP4 的
moov原子默认在文件末尾,必须完整下载才能播放。FFmpeg 的-movflags +faststart能把moov移到头部,但只适用于文件已完整生成的情况。
ffmpeg -i input.webm -c copy -movflags +faststart output.mp4 # 无损重封装,速度极快
ffmpeg -i input.webm -c:v libx264 -preset veryfast -crf 23 \ # 需浏览器直接播放时转码
-c:a aac -b:a 128k -movflags +faststart output.mp4
6. 转码集群与硬件加速
转码是典型的 CPU 密集型任务。一路 1080p30 的 H.264 软编码在单核上通常只能跑到 0.5 到 1 倍速,意味着必须用多核或硬件加速。
| 加速方式 | 典型吞吐 | 画质 | 适用 |
|---|---|---|---|
| x264 / x265 软编 | 0.5~1 倍速/核 | 最好 | 存档、离线转码 |
| NVENC | 5~10 倍速 | 略逊于软编 | 高并发在线转码 |
| QSV / VAAPI | 3~8 倍速 | 中等 | 有集显的通用服务器 |
| Apple VideoToolbox | 5~10 倍速 | 中等 | macOS 侧处理 |
转码集群的组织方式:
- 任务队列驱动:录制结束后投递转码任务,Worker 从对象存储拉取源文件,转码后回写。适合离线批处理,任务编排与重试可复用现有的大数据调度体系。
- 流水线式:录制进程直接把媒体分片推给转码进程,边录边转,延迟最低但容错复杂。
resources:
limits:
cpu: "8"
memory: 8Gi
nvidia.com/gpu: "1" # NVENC 需要 GPU 设备插件
nodeSelector:
hardware: nvenc
要点是同一条录制任务必须在同一台机器上完成,或者至少保证时间戳基准一致。跨机器的分片转码会因为时钟不同步导致拼接处出现音画不同步,这类问题的排查思路与分布式系统的时钟同步完全一致。
7. 分片、合并与断点续录
长时间录制(如 8 小时的培训)不能生成一个巨大文件:一旦进程崩溃,全部作废。标准做法是按固定时长切分,例如每 10 分钟或每 100 MB 一个分片。
// 分片命名必须带单调序号,合并时按序号排序
function segmentName({ roomId, trackId, seq }) {
return `${roomId}/${trackId}/${String(seq).padStart(6, "0")}.webm`;
}
// 合并前先校验分片完整性:缺号意味着中间丢了一段
function verifySegments(keys) {
const seqs = keys.map((k) => Number(k.split("/").pop().replace(".webm", ""))).sort();
const missing = [];
for (let i = seqs[0]; i <= seqs[seqs.length - 1]; i++) {
if (!seqs.includes(i)) missing.push(i);
}
return { complete: missing.length === 0, missing };
}
合并本身可以用 FFmpeg 的 concat demuxer:
for f in segments/*.webm; do echo "file '$PWD/$f'" >> list.txt; done # 先生成清单
ffmpeg -f concat -safe 0 -i list.txt -c copy merged.webm
三个注意点:
- WebM 分片不能简单二进制拼接,因为每个分片都有自己的 EBML 头与簇边界,必须用 concat demuxer 或 remux 处理。
- 分片之间的时间戳可能不连续(编码器重启会重置时间基),合并时需要
-fflags +genpts或显式做时间戳偏移。 - 缺号分片必须记录到元数据里,回放时给出明确的「此处缺失」提示,而不是静默跳过。
8. 存储与回放链路
典型的回放链路是「对象存储 + 转码 + CDN」:
录制分片 → 对象存储(原始) → 转码任务 → HLS 分片 + 索引 → CDN → 播放器
↓
元数据入库(房间、时长、参与者、缺失分片)
几个工程细节:
- 对象存储的 Key 要包含租户与日期前缀,避免单前缀热点;生命周期策略把原始分片在 N 天后转低频存储。
- HLS 分片时长建议 4 到 6 秒,兼顾起播速度与请求数。若要求更低延迟,可参考 CDN 边缘优化 中的边缘缓存策略。
- 元数据必须记录「录制起始时间戳」与「媒体内时间戳」的对应关系,否则多路录制无法对齐回放。
- 播放器侧要做「多路同步」:一路主时钟驱动,其余轨道按时间戳对齐,误差超过 100 ms 就要提示或强制 seek。
9. 录制一致性与丢包补偿
录制与实时通话面对的是同一条有损链路,但容忍度不同:实时通话丢一个包只是短暂花屏,录制文件丢一个包则可能造成永久性画面撕裂或音频爆音。
补偿手段:
- 加大 jitter buffer:录制侧可以把缓冲深度设为实时侧的两到三倍(例如 200 ms),用延迟换完整性。
- 等待重传:开启 NACK 重传,录制进程在缓冲窗口内等待重传包,超时才放弃。
- 关键帧对齐:转码时强制关键帧间隔为 2 秒以内,让 seek 与分段更精确。
- 音频优先:音频的感知敏感度远高于视频,丢包补偿应优先保证音频连续,宁可牺牲视频帧。
一致性的另一个维度是「不同参与者视角的录制必须可对齐」。做法是统一使用 RTP 时间戳与 RTCP 的 SR 报文做时间基准换算,而不是各进程各自调用 Date.now()。
10. 录制质量与成本权衡
| 档位 | 编码 | 码率 | 单路 8 小时体积 | 说明 |
|---|---|---|---|---|
| 原始旁路 | 复用发布端编码 | 1.5 Mbps | 约 5.4 GB | 零转码成本 |
| 标准存档 | H.264 720p | 1.2 Mbps | 约 4.3 GB | 通用性最好 |
| 高质量合成 | H.264 1080p | 3 Mbps | 约 10.8 GB | 回放体验优先 |
| 仅音频 | Opus 32 kbps | 32 kbps | 约 115 MB | 语音存档 |
存储成本通常远低于转码成本:一次 1080p 的 GPU 转码在云上的价格,往往高于该文件存一年。因此策略上应该「宁可多存原始分片,少做转码」,把转码延后到真正需要回放时再按需触发。
权衡取舍
| 取舍点 | 客户端录制 | 服务端旁路 | 服务端合成 |
|---|---|---|---|
| 服务端成本 | 无 | 低 | 高 |
| 可靠性 | 差 | 高 | 高 |
| 回放体验 | 单路好 | 需客户端拼多路 | 单文件最好 |
| 布局灵活性 | 无 | 后期可任意合成 | 录制时固定 |
| 与 E2EE 兼容 | 兼容 | 兼容(密文) | 不兼容 |
| 抗丢包 | 差 | 可调缓冲 | 可调缓冲 |
选型建议:轻量存档用客户端录制加定期上传;合规与质检用服务端旁路,后期按需合成;对回放体验有硬要求的(如教育录播)才上合成录制。
常见坑清单
MediaRecorder.start()不传 timeslice,页面崩溃导致整段录制丢失。- 用客户端录制录多人会议,只录到了本地一路,远端全部缺失。
- 在 E2EE 房间启用服务端合成录制,解密失败后整段录制为空。
- MP4 未加
-movflags +faststart,边录边播时播放器必须下载完整文件才能起播。 - 分片合并直接二进制拼接 WebM,产出文件无法播放。
- 分片缺号不记录到元数据,回放时静默跳过导致时间轴错乱。
- 录制进程 jitter buffer 与实时侧共用一套参数,录制文件频繁花屏。
- 转码任务跨机器分片处理,拼接处出现音画不同步。
- 不记录录制起始时间戳,多路回放无法对齐。
- 转码档位一刀切用最高画质,成本远超实际需要。
小结
录制与转码的复杂度来自「实时媒体」与「文件媒体」两套约束的错配。解决办法是把问题拆成三段:取流位置决定可靠性与成本,容器与编码决定兼容性,分片与合并决定容错能力。这三段各自独立,可以分别优化。
实践中最容易做错的决策有两个:一是在需要存档与合规的场景选了客户端录制,二是为省事直接上合成录制而忽略了转码成本。正确的默认姿势是「服务端旁路录原始分片,按需转码合成」。
链路的下一环是回放与分发,这部分与低延迟直播共享 CDN 与分片技术;而上游的编码质量与拥塞响应,则取决于 音视频编解码与拥塞控制 中讲到的参数如何设置。录制侧唯一要额外坚持的原则是:宁可多花一点延迟与存储,也不要丢帧。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。