实时音视频录制与转码

系统梳理实时音视频录制与转码:对比 MediaRecorder 客户端录制与服务端旁路、合成两种模式的成本与可靠性,讲清 WebM、MP4、fMP4 容器与编码格式选择、NVENC 硬件加速与转码集群、分片合并与断点续录、对象存储与 HLS 回放链路,并给出录制一致性与丢包补偿的工程实践。

录制的需求几乎总是后置出现的:先上线通话,运营提「能不能录下来存档」,法务提「能不能存证」,用户提「能不能回放」。等需求出现时才发现,录制不是加个按钮那么简单——它牵涉到从哪一路取流、用什么容器、什么时候切分、怎么保证不丢帧。

实时媒体与文件媒体是两套完全不同的约束。RTP 流以时间戳驱动、允许丢包、随时可能中断;而一个可播放的文件必须帧序完整、时间轴连续、元数据在头或尾。录制与转码的本质工作,就是把前者转换成后者,并在这个过程中尽量不引入新的损失。

本文按「录制在哪做 → 存成什么 → 怎么转 → 怎么存回放 → 怎么保证一致」的顺序展开,覆盖客户端与服务端两条路线,最后给出成本与质量的取舍。如果你还没确定多人拓扑,建议先读 SFU 与 MCU 架构选型 ,因为录制模式的选择与拓扑强相关。

目录

  1. 录制方案的全景
  2. MediaRecorder 客户端录制
  3. 客户端录制的固有局限
  4. 服务端录制:旁路与合成
  5. 容器与编码格式选择
  6. 转码集群与硬件加速
  7. 分片、合并与断点续录
  8. 存储与回放链路
  9. 录制一致性与丢包补偿
  10. 录制质量与成本权衡

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. 容器与编码格式选择

容器编码支持优势劣势
WebMVP8/VP9/AV1 + Opus浏览器原生,分片友好部分播放器兼容性差
MP4H.264/H.265 + AAC通用性最好需要 moov 原子,不宜流式追加
fMP4H.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 倍速/核最好存档、离线转码
NVENC5~10 倍速略逊于软编高并发在线转码
QSV / VAAPI3~8 倍速中等有集显的通用服务器
Apple VideoToolbox5~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 720p1.2 Mbps约 4.3 GB通用性最好
高质量合成H.264 1080p3 Mbps约 10.8 GB回放体验优先
仅音频Opus 32 kbps32 kbps约 115 MB语音存档

存储成本通常远低于转码成本:一次 1080p 的 GPU 转码在云上的价格,往往高于该文件存一年。因此策略上应该「宁可多存原始分片,少做转码」,把转码延后到真正需要回放时再按需触发。

权衡取舍

取舍点客户端录制服务端旁路服务端合成
服务端成本无低高
可靠性差高高
回放体验单路好需客户端拼多路单文件最好
布局灵活性无后期可任意合成录制时固定
与 E2EE 兼容兼容兼容(密文)不兼容
抗丢包差可调缓冲可调缓冲

选型建议:轻量存档用客户端录制加定期上传;合规与质检用服务端旁路,后期按需合成;对回放体验有硬要求的(如教育录播)才上合成录制。

常见坑清单

  • MediaRecorder.start() 不传 timeslice,页面崩溃导致整段录制丢失。
  • 用客户端录制录多人会议,只录到了本地一路,远端全部缺失。
  • 在 E2EE 房间启用服务端合成录制,解密失败后整段录制为空。
  • MP4 未加 -movflags +faststart,边录边播时播放器必须下载完整文件才能起播。
  • 分片合并直接二进制拼接 WebM,产出文件无法播放。
  • 分片缺号不记录到元数据,回放时静默跳过导致时间轴错乱。
  • 录制进程 jitter buffer 与实时侧共用一套参数,录制文件频繁花屏。
  • 转码任务跨机器分片处理,拼接处出现音画不同步。
  • 不记录录制起始时间戳,多路回放无法对齐。
  • 转码档位一刀切用最高画质,成本远超实际需要。

小结

录制与转码的复杂度来自「实时媒体」与「文件媒体」两套约束的错配。解决办法是把问题拆成三段:取流位置决定可靠性与成本,容器与编码决定兼容性,分片与合并决定容错能力。这三段各自独立,可以分别优化。

实践中最容易做错的决策有两个:一是在需要存档与合规的场景选了客户端录制,二是为省事直接上合成录制而忽略了转码成本。正确的默认姿势是「服务端旁路录原始分片,按需转码合成」。

链路的下一环是回放与分发,这部分与低延迟直播共享 CDN 与分片技术;而上游的编码质量与拥塞响应,则取决于 音视频编解码与拥塞控制 中讲到的参数如何设置。录制侧唯一要额外坚持的原则是:宁可多花一点延迟与存储,也不要丢帧。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「实时通信」更多文章

  1. 实时通信的端到端测试与压测
  2. 信令服务与房间水平扩展
  3. 低延迟直播:LL-HLS 与 WebRTC 直播