WebCodecs实战:浏览器高清录屏并直接导出MP4的完整方案
发布时间:2026/9/16 20:53:23来源:尧图网络
过去很长一段时间想在浏览器里做“高清录屏”并且直接导出 MP4几乎是一件让人头疼的事。主流的方案绕不开 MediaRecorder但 MediaRecorder 在浏览器里通常只能封装成 WebM想要 MP4 就得二次转封装要么丢到服务端用 ffmpeg 处理要么在前端拉一个巨大的 wasm 编解码库而且 MediaRecorder 的编码参数控制极其有限码率、关键帧间隔、编码档次这些基本由浏览器说了算画质和文件大小往往不可兼得。WebCodecs API 的出现改变了这个局面。它把浏览器的硬件编解码能力直接暴露给前端开发者让我们可以用 JavaScript 直接控制 VideoEncoder、AudioEncoder配合 mp4-muxer 这类纯前端封装库在浏览器里完成从屏幕采集、编码、封装到导出 MP4 的完整链路不需要任何插件不需要服务端转码也不会被强制盖上平台水印。这篇文章我会直接从代码层面拆解整个实现过程包括为什么选择 WebCodecs、视频和音频怎么处理、时间戳怎么对齐、参数怎么调才能既清晰文件又不大以及我在实际项目中踩过的坑。如果你正在做浏览器录屏工具、Web 端教学录制、或者想把 canvas 动画导出成 MP4这篇内容应该可以帮你省下不少时间。1. 为什么放弃 MediaRecorder 方案转向 WebCodecs1.1 MediaRecorder 的几个硬伤先说清楚MediaRecorder 不是不能用它做简单录屏确实省事几行代码就能把屏幕流录下来。但一旦你开始追求“高清”“可控”“直接导出 MP4”这三个目标它的问题就会暴露得比较明显。第一个问题是封装格式。在 Chrome 和 Edge 里MediaRecorder 输出的核心格式是 WebM底层视频编码通常是 VP8 或 VP9。WebM 本身不是不优秀但现实是很多剪辑软件、短视频平台、移动端播放器对 MP4H.264/AAC的支持远比 WebM 友好。你做录屏工具是要面向真实用户的用户录完还要拿去做后期、传平台拿一个 WebM 文件给用户对方大概率会问你“能不能转成 MP4”。第二个问题是编码参数不可控。MediaRecorder 暴露出来的只有videoBitsPerSecond和audioBitsPerSecond这两个粗糙的选项而且不同浏览器对这两个参数的解释还不一样。你没法设置关键帧间隔没法指定编码档次Baseline、Main、High没法控制 B 帧行为也没法在录制过程中动态调整码率。这就导致在录制动态画面时MediaRecorder 容易糊或者产生大量你不需要的高码率数据。第三个问题是延迟和切割问题。MediaRecorder 通过timeslice参数可以定时切出数据块但每个块的封装信息可能不完整想要做实时预览或者边录边处理体验并不好。1.2 WebCodecs 带来的是“浏览器级”的编码控制权WebCodecs 的核心价值在于它把浏览器底层已经存在的硬件编解码器VideoEncoder、VideoDecoder、AudioEncoder、AudioDecoder以一种相对底层的 API 形式暴露出来让开发者可以像调用本地 SDK 一样去使用它们。使用 VideoEncoder 时你可以直接指定codec比如avc1.420034H.264 Baseline Level 3.2或者avc1.640028H.264 High Level 4.0最后两位要根据分辨率和帧率算width、height视频分辨率bitrate目标码率单位 bpsframerate帧率keyFrameInterval关键帧间隔单位是帧数latencyMode延时模式录屏适合quality低延迟通信才用realtime。每一个参数都会直接影响最终的视频质量、文件大小、兼容性。这在我自己的实践中带来的一个明显好处是我可以为不同使用场景预设多套编码配置比如“网页操作演示”使用高码率、高帧率保证文字和鼠标轨迹清晰锐利“长时间课程录制”使用稍低码率、较长关键帧间隔避免文件体积爆炸。同时WebCodecs 编码出来的数据是EncodedVideoChunk这是一个一个裸的编码帧你可以自己决定怎么封装。配合 mp4-muxer 这类 JS 库就能把 H.264 帧按 MP4 的 Box 结构封装成标准 MP4 文件整个过程完全在浏览器本地完成。2. 整体技术方案设计与核心原理2.1 最终方案的技术栈选择我实现的这套录屏方案技术栈可以拆成以下五层屏幕采集getDisplayMedia()拿到屏幕的MediaStreamTrack视频帧处理VideoFrame canvasdrawImage()把轨道转换成编码器需要的帧对象视频编码VideoEncoder输出 H.264 编码帧音频采集与编码getDisplayMedia()的音频轨道 AudioEncoder输出 AAC/Opus 帧MP4 封装mp4-muxer把视频帧和音频帧封装成 MP4 文件。这套链路里最核心的理解点在于WebCodecs 的VideoEncoder并不直接接受MediaStreamTrack它只认VideoFrame。而把屏幕流转换成 VideoFrame 的方式有两种一种是通过VideoFrame构造函数直接包装const track stream.getVideoTracks()[0]; const processor new MediaStreamTrackProcessor({ track }); const reader processor.readable.getReader(); while (true) { const { value: frame, done } await reader.read(); if (done) break; // 这里的 frame 就是 VideoFrame可以直接交给 encoder.encode() encoder.encode(frame, { keyFrame: false }); frame.close(); }另一种是兼容性更好的方式将VideoFrame通过createImageBitmap(video)或 canvas 的drawImage(video)绘制到位图后再通过canvas.captureStream()或者requestVideoFrameCallback获得可用的帧。在同时处理多个窗口/区域裁剪时canvas 方案更灵活所以我自己的项目里选用了 canvas 作为中转层。2.2 音频采集与编码需要注意的问题屏幕流的音频其实有两种来源。第一种是getDisplayMedia()自带的音频轨道受浏览器支持限制Chrome 目前支持采集单个 Tab 的系统音频第二种是麦克风输入。你可以把它们混音到同一个 AudioContext 的MediaStreamAudioDestinationNode上再把它作为音频源。编码层面WebCodecs 的 AudioEncoder 在浏览器的支持比较微妙。Chrome 目前支持对 PCM 帧做 Opus 编码但对 AAC 的编码支持一直不太乐观。也就是说如果你强制想输出mp4a.40.2MP4 里的标准 AAC很快会发现 Chrome 根本编不出来而 Safari 的兼容性又另说。我的处理方案是优先尝试 AAC如果 AudioEncoder 配置失败就回退到“视频里只封装 H.264 帧、音频单独用 MediaRecorder 录制 WebM 再在后端转”这种折中方案。在纯前端需求中我另外做过一版用 PCM WAV 封装的不过那是另一种使用场景了这里不展开。如果你自己实现时不想碰音频也可以直接不采集音频做成“无声录屏”这样功能核心不变实现会省掉一大半麻烦。3. 核心代码实现从采集到导出 MP4下面我直接上代码把每一步的意图和关键参数说清楚。这是我自己整理过的一个可用版本简化了一部分 UI 逻辑保留完整编码链路。3.1 获取屏幕流并做分辨率计算async function startCapture() { const stream await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30, width: { ideal: 1920 }, height: { ideal: 1080 }, frameRequestRate: 30 }, audio: true }); // 这里通常会做一个 aspect ratio 的处理确保 canvas 尺寸是偶数 const videoTrack stream.getVideoTracks()[0]; const settings videoTrack.getSettings(); const width Math.min(settings.width || 1920, 1920); const height Math.min(settings.height || 1080, 1080); // 编码器要求宽高是偶数否则 H.264 编码会报错 const canvasWidth Math.floor(width / 2) * 2; const canvasHeight Math.floor(height / 2) * 2; return { stream, videoTrack, canvasWidth, canvasHeight }; }这里必须强调一下H.264 的宏块尺寸是 16x16但 WebCodecs 在实际编码时对奇偶的要求非常严格如果宽高是奇数甚至某些情况下不是 2 的倍数VideoEncoder 会直接抛错。所以我习惯性地对尺寸做一次Math.floor(width / 2) * 2处理宁可损失一个像素也不要去赌编码器的容错。3.2 初始化 VideoEncoder 并配置 H.264 参数function initVideoEncoder(width, height, bitrate, fps) { let encoder null; let metadata null; const init { output: (chunk, meta) { // 这个回调会拿到编码后的 H.264 帧meta 里包含编码器配置 if (!metadata) { metadata meta.decoderConfig; } encodedVideoChunks.push(chunk); }, error: (e) { console.error(VideoEncoder error:, e); } }; encoder new VideoEncoder(init); const config { codec: getAvcCodecString(width, height), width, height, bitrate: bitrate, // 单位 bps framerate: fps, latencyMode: quality, // 录屏用 quality不要用 realtime keyFrameInterval: fps * 2, // 每 2 秒一个关键帧 hardwareAcceleration: prefer-hardware, avc: { format: avc } }; encoder.configure(config); return { encoder, metadata: () metadata }; } function getAvcCodecString(width, height) { // 这里按 1080p 30fps 的场景用 High Profile Level 4.0 // 如果需要更广兼容性可以降级到 avc1.42E01F return avc1.640028; }关于avc1.640028这个字符串简单拆一下64表示 High Profile00代表 H.264 的 constraint flags28是十六进制展开成二进制就是 40对应 Level 4.0。Level 4.0 理论最大分辨率为 1080p30如果录制 4K 或者要更高帧率这个数字需要往上提比如avc1.640033对应 Level 5.1。再说一下硬件加速。hardwareAcceleration: prefer-hardware的意思是优先用硬件编码但被拒绝时不会直接报错浏览器会尝试软件编码兜底。实测中硬件编码在 CPU 占用上有明显优势但画质上软件编码并不一定差甚至有时候 x264 软件编码的同码率画质会更好。所以如果你的机器配置不错、录屏时间不长可以试试改成no-preference或直接删掉这个字段。3.3 帧采集循环与时间戳控制录屏的帧采集不能简单地每一帧都encoder.encode()因为屏幕内容的刷新率通常和编码目标帧率不一致而且encoder.encode()需要你主动控制帧的丢弃与节奏否则容易出现 CPU 飙升。我的做法是用requestAnimationFrame驱动采集循环然后做时间戳校验达到设定的帧间隔才真正送入编码器function startFrameLoop(videoEl, encoder, canvas, ctx, fps) { const frameInterval 1000000 / fps; // 微秒 let lastFrameTimestamp 0; function drawLoop(timestamp) { if (!recording) return; if (timestamp - lastFrameTimestamp frameInterval) { const frame new VideoFrame(canvas, { timestamp: timestamp * 1000, // rAF 的 timestamp 是毫秒转成微秒 duration: frameInterval }); const keyFrame shouldInsertKeyFrame(); encoder.encode(frame, { keyFrame }); frame.close(); lastFrameTimestamp timestamp; } requestAnimationFrame(drawLoop); } requestAnimationFrame(drawLoop); }这里有一点要注意VideoFrame对象的timestamp单位是微秒不是毫秒。如果你用performance.now()或 rAF 的时间戳必须先乘以 1000。两个帧之间的duration也应该填上这样封装出来的 MP4 播放时间线才准确。shouldInsertKeyFrame()这个方法是我自己加的用来在需要剪切的场景下拉一个关键帧比如用户点击“重新开始录制”或者“标记重要片段”时我们可以主动请求关键帧便于后续做视频切片。你也可以直接用frameCount % keyFrameInterval 0的方式去控制。还有一点很重要VideoFrame.close()必须调用。VideoFrame 是浏览器持有 GPU/内存资源的对象不及时 close 会导致内存占用持续上升长时间录屏必出问题。我见过很多人录到十几分钟就卡死一查就是 VideoFrame 泄漏。3.4 音频编码与混音处理音频这块我一开始走了不少弯路。直接拿屏幕流的音频轨道去塞给 AudioEncoder会碰到两个问题一个是MediaStreamAudioSourceNode出来的音频帧格式是 F32 格式的 PCM而 AudioEncoder 的配置需要对应设置另一个是采样率不匹配。不同系统音频设备的默认采样率可能不同有 48kHz、44.1kHz处理不好音视频会不同步。我采用的简化方案是把所有输入合并进一个 AudioContext再统一拿这个 AudioContext 的采样率作为 AudioEncoder 的输入格式const audioCtx new AudioContext({ sampleRate: 48000 }); const dest audioCtx.createMediaStreamDestination(); const screenAudioSource audioCtx.createMediaStreamSource(displayStream); const micSource audioCtx.createMediaStreamSource(micStream); screenAudioSource.connect(dest); micSource.connect(dest); const audioTrack dest.stream.getAudioTracks()[0]; const audioProcessor new MediaStreamTrackProcessor({ track: audioTrack });然后初始化 AudioEncoder 时用它对应的配置const audioEncoder new AudioEncoder({ output: (chunk, meta) { encodedAudioChunks.push(chunk); }, error: (e) console.error(AudioEncoder error:, e) }); audioEncoder.configure({ codec: opus, sampleRate: audioCtx.sampleRate, numberOfChannels: 2, bitrate: 128000 });如果实在想封装 AAC 格式进 MP4我在 Chrome 下的实测是 AudioEncoder 的configure直接抛NotSupportedError。Safari 16.4 开始支持 WebCodecs 的音频部分但也主要集中在 Opus 和 PCM。所以这里我直接放弃 AAC改用 H.264 Opus 的 MP4 封装。要说明的是mp4-muxer 支持 Opus 封装但部分播放器对“MP4 容器里的 Opus 音轨”兼容性不如 AAC这一点取决于使用场景。如果你项目的目标用户群明确是 Windows Chrome 桌面端那 Opus in MP4 的问题不大如果目标用户有 iOS 播放需求我建议暂时别走纯 WebCodecs 音频路线考虑在后端做一次音频转码或者直接用 PCM WAV 方案。3.5 用 mp4-muxer 封装 MP4 文件封装这一步是最容易出错的。我选的库是 Vanilagy/mp4-muxer它专门为 WebCodecs 设计API 很干净文档也清晰是目前我在纯前端封装 MP4 的首选。封装需要两个环节先写入所有编码好的视频帧和音频帧然后在停止录制时调用finalize()把 MP4 的moovbox 和mdatbox 正确拼接最终生成一个可播放的 MP4 Blob。import { Mp4Muxer, ArrayBufferTarget } from mp4-muxer; const muxer new Mp4Muxer({ target: new ArrayBufferTarget(), video: { codec: avc, width, height }, audio: { codec: opus, sampleRate: 48000, numberOfChannels: 2 }, fastStart: in-memory, firstTimestampBehavior: offset });在编码器的output回调里把每个EncodedVideoChunk交给 muxermuxer.addVideoChunk(chunk, meta);音频同理muxer.addAudioChunk(chunk, meta);停止录制时先encoder.flush()、audioEncoder.flush()再执行muxer.finalize()最后就能从 target 里拿到 bufferawait encoder.flush(); await audioEncoder.flush(); muxer.finalize(); const mp4Buffer muxer.target.buffer; const blob new Blob([mp4Buffer], { type: video/mp4 }); const url URL.createObjectURL(blob);这里有个细节firstTimestampBehavior: offset的作用是让 MP4 文件的时间轴从 0 开始而不是从编码首帧的时间戳开始。如果不设置这个生成的文件在部分播放器里会显示异常的总时长或者首帧跳不过去。另外fastStart: in-memory表示 muxer 会把所有数据临时存在内存中等 finalize 的时候再重新组织 Box这对普通时长的录屏没问题。但是如果你的录制目标是数小时级内存占用会显著上升这种情况下应该用fastStart: fragmented模式生成 Fragmented MP4把数据流式落盘。4. 码率、帧率、关键帧间隔的选择与调优4.1 码率与画质的实测经验码率是录屏工具最核心的参数。设置太高文件巨大太低文字边缘糊、鼠标轨迹断裂。以 1080p / 30fps 录屏为例我根据录制内容类型分类建议录制内容类型推荐码率说明纯文档/网页浏览2~4 Mbps静态内容多过高码率完全浪费教学演示PPT 鼠标移动4~8 Mbps兼顾文字清晰与文件体积视频播放/动画/游戏10~16 Mbps动态画面多低码率会出现明显块效应4K 桌面操作20~40 Mbps分辨率翻倍码率需求指数上升要注意的是码率不是越高越好。在 1080p 下H.264 High Profile 超过 20Mbps 后画质提升就非常有限了而且会导致 CPU 编码压力和文件体积同时增加。我自己的默认配置是 8Mbps再根据用户选择的“清晰度档位”比如“标准”“高清”“超清”映射到不同码率。4.2 为什么帧率选 30 而不是 60我的默认录屏帧率是 30fps即使很多显示器是 60Hz 刷新率。原因很简单录屏内容绝大多数是静态文本、窗口切换、鼠标移动30fps 已经能提供流畅的观看体验而 60fps 的编码压力、文件体积和后续播放兼容性要求都高一档。如果你要录的是网页里的 Canvas 游戏、滚动动画或者需要捕捉快速移动的鼠标轨迹那可以开放一个 60fps 选项。但这时的码率必须同步上调否则 60fps 下单帧分配到的比特数会下降画面细节反而比 30fps 更差。在实际参数上调时我的做法是把 60fps 模式下的码率设为 30fps 模式的 1.5 倍而不是 2 倍因为帧间冗余内容变多了编码器可以用 P 帧更好地压缩。4.3 关键帧间隔的权衡keyFrameInterval控制的是每隔多少帧插入一个可独立解码的关键帧。关键帧越密集视频越耐操拖动、倍速、故障恢复表现好但文件体积会增大关键帧越稀疏文件越小但如果你录完视频要二次剪辑切片段很多切片工具切出的首帧不是关键帧导致绿屏或解码失败。录屏场景我推荐设置为fps * 2即 2 秒比如 30fps 就填 60。这样文件体积的增长幅度大约在 5%~10%换取的是整个视频的可用性大幅提升。如果你确认这段视频只是完整播放不做剪辑可以放宽到fps * 5也就是 5 秒一个关键帧。5. 我在实际开发中踩过的坑与排查经验5.1 反复调用的 video.requestVideoFrameCallback 导致的性能噩梦我第一次做这个方案的时候参照网上很多示例使用了video.requestVideoFrameCallback()去获取每一帧的渲染时机。细节是屏幕流的 VideoTrack 投到video标签后requestVideoFrameCallback确实能拿到视频帧准备渲染的时机但如果这时候在回调里做 blur、缩放、全屏截图之类的高开销操作立刻就会造成视频渲染帧率下降然后requestVideoFrameCallback的触发频率跟着下降形成恶性循环。后面我看 WebCodecs 相关规范发现直接用MediaStreamTrackProcessorrequestVideoFrameCallback这套组合实际存在很多示例误导。正确的思路是用getDisplayMedia后不要立刻把 video 元素插到页面上而是把 video 设成playsInline、muted、controlsfalse并且让它处于不可见但仍在播放的状态采集循环里优先使用 rAF 绘制而不是完全依赖 video 的回调。5.2 时间戳异常导致 MP4 播放器无法拖进度这个问题曾经困扰我一个晚上。封装出来的 MP4 可以正常播放但拉到中间的进度就直接黑屏或者回到开头。排查下来发现是视频帧的timestamp单位搞错了。第一版里我的 rAF 回调时间戳直接传给 VideoFrametimestamp是毫秒级而编码器期望的是微秒级。结果就是封装出来的 MP4 时间轴被压缩了一千倍总时长看起来只有几十秒播放器自然没办法正常 seek。解决方案就是前面代码里演示的timestamp * 1000没有第二种花活可走。5.3 硬件编码器在低端机器上表现不稳定hardwareAcceleration: prefer-hardware在大多数正常电脑上表现良好但我在测试一台集成显卡老笔记本时发现编码器偶尔会输出损坏帧画面局部出现马赛克花屏但编码过程不报错。这个问题只出现在硬件编码路径上。排查方式是在编码器的output回调里对每个chunk做一次 CRC/长度检查实际项目里不太现实。我的做法是提供一个降级开关如果连续 N 秒内检测到关键帧间隔超过预期或者用户手动选择“兼容模式”就把hardwareAcceleration改为no-preference并重新初始化编码器。软件编码下虽然 CPU 占用高但稳定性好得多。5.4 录屏中止时的边界处理用户点击浏览器自带的“停止共享”按钮屏幕采集轨道被系统终止时如果不做监听录音循环不会自动结束会出现“页面还在录制但视频流早已停止”的假象。我通常对视频轨道添加ended事件监听videoTrack.addEventListener(ended, () { stopRecording(track-ended); });同时在录制过程中也要默认开启“按 Esc 或点击系统停止按钮可结束”这个体验然后监听结束事件统一进入结束流程。5.5 mp4-muxer 报错 “timestamp should be monotonically increasing”这个错误在同时处理多路音频轨道时比较容易遇到。原因是采集到的音频帧的timestamp可能因为采样率或声道配置变化出现回拨。解决办法是在把 chunk 送进 muxer 前对音频时间戳做一次单调化处理let lastAudioTs 0; // 在 audioEncoder output 回调里 const chunkTs chunk.timestamp; if (chunkTs lastAudioTs) { // 丢弃或改写避免破坏 muxer 的写入顺序 return; } lastAudioTs chunkTs;另一个情况是视频轨道的编码帧率和采集帧率不匹配导致时间戳间隔波动mp4-muxer 对此比较严格必要时可以给帧做timestamp重排或者直接调小第 3.3 节里的frameInterval去做插帧。6. 兼容性现状与后续扩展建议WebCodecs 在 Chrome/Edge 94 已经比较成熟Safari 从 16.4 起也支持了 video 和 audio 的大部分能力Firefox 虽然默认开启较晚但在近期版本也逐步跟进了。这意味着这套方案现在已经不是“实验室技术”而是可以落地到真实产品里的。站在产品化的角度后续你可以在这个基础上扩展的方向包括屏幕裁剪区域录制把canvas.drawImage()的sx/sy/sw/sh参数做成可调区域摄像头画中画把摄像头流和屏幕流放进同一个 canvas 合成再统一编码录制后在线剪辑WebCodecs 也支持VideoDecoder封装好 MP4 之后可以直接解码做缩略图、时间线预览录制文件落盘结合File System Access API直接把 MP4 保存到用户指定的本地目录而不是只提供下载链接。WebCodecs 这套链路最值得肯定的地方在于它让浏览器不再只是“视频播放器”或“视频上传器”而是真正成为一个能生产标准媒体文件的创作工具。在实现过程中我对编码参数的理解从“大概能用”提升到了“每个字段为什么这么填”的程度这只有亲手踩过坑才能体会。如果你要动手做我的建议是第一版不要追求功能和美观先把最核心的一条链路跑通——屏幕采集、canvas 转 VideoFrame、VideoEncoder 编码、mp4-muxer 封装、下载 MP4。把这条路踩顺了再逐步加音频、加裁剪、加水印合成也不迟。
网站建设高端定制企业官网