资讯动态

WebCodecs录屏实战:纯前端生成MP4文件

发布时间:2026/9/16 16:45:57 来源:尧图企业网站定制
最近在做一个在线录屏工具需求很直接用户点击按钮浏览器里录下屏幕结束之后直接给一个 MP4 文件。这个需求放到几年前只能在浏览器里录出 WebM再丢给 ffmpeg.wasm 或后端转一圈才能变成 MP4。现在 WebCodecs 已经足够成熟纯浏览器里就能完成 H.264 编码并封装成 MP4不需要任何插件也没有服务端转码环节导出的文件干净、无水印、兼容主流播放器。这篇文章把我的实现思路、完整代码骨架和实际踩过的坑都整理出来适合正在做网页录屏、在线会议录制、课程录制或者想在前端直接产出 MP4 的开发者参考。1. 浏览器录屏的老路MediaRecorder 为什么出不了干净 MP4先说清楚一个关键事实MediaRecorder 不是不能输出 MP4而是在主流浏览器里默认或实际可用的编码容器基本是 WebM。Safari 某些版本支持录 MP4但兼容性和可控性很有限几乎没法作为通用方案。这就是很多录屏工具明明在浏览器里运行导出的却是一个 .webm 文件的原因——不是开发者不想给 MP4是 MediaRecorder 给不了。1.1 MediaRecorder 的格式天花板为什么默认录出 WebMMediaRecorder 的底层逻辑是浏览器负责采集音视频流然后用内置的编码器封装成多媒体容器。Chrome 和 Firefox 默认组合是 VP8/VP9/AV1 Opus 封装成 WebMH.264 AAC 封装成 MP4 并不在稳定支持范围内。你当然可以传 mimeType 参数去尝试比如video/mp4; codecsavc1.640028, mp4a.40.2但在 Chromium 内核里这个值很大概率会被忽略或抛 NotSupportedError。这就带来一串连锁反应。WebM 在 Windows 资源管理器里没有预览图macOS QuickLook 分分钟显示空白手机相册直接不认放到微信、剪映、Premiere 里播放经常出问题。很多用户看到 .webm 就以为是文件损坏尤其那句“windows 10 无法播放”的报错我接触过太多次。对面向普通用户的录屏产品来说导出一个能直接用的 MP4 几乎是硬需求。1.2 转 MP4 的三条老路及其代价ffmpeg.wasm、服务端转码、双轨同录既然 MediaRecorder 出不了 MP4以前通用的解法有三种。第一种是前端用 ffmpeg.wasm 转码。录制时先用 MediaRecorder 录 WebM录完把文件丢给浏览器里跑的 FFmpeg 重封装或重编码成 MP4。问题很明显ffmpeg.wasm 的 wasm 包动不动几十 MB首次加载体验很差转码过程极吃 CPU录 10 分钟 1080p 视频低配置电脑可能卡到风扇起飞内存占用轻松破 1GB而且重编码会让画质衰减转完的文件体积和清晰度全靠参数硬调。第二种是上传服务器转码。录制终端拿到 WebM 后传上去服务端调 FFmpeg 转成 MP4 再回传。这个方案转码质量可控但引入了磁盘暂存、上传带宽、队列任务、转码等待等一堆环节。用户体验变成“录完还要等一会儿才能下载”在线教学、会议回放这种需要即时出文件的场景会比较难受。第三种是 MediaRecorder 同时录两份WebM 只是临时预览最终交付再走一遍转码或流式 mux。本质上还是绕不开上述问题。我早年踩坑最深的其实是 MediaRecorder 的不可控性关键帧间隔不受你控制码率只是一个 hint浏览器可能根据屏幕内容动态调整画质录出来一会儿清晰一会儿模糊截图看某一帧全是马赛克。对于“高清录屏”这种需求MediaRecorder 带来的不是灵活而是黑盒。1.3 这个痛点逼出来的技术选型变化看清这些限制之后选型方向就明确了不再依赖 MediaRecorder 替你做容器封装而是用 WebCodecs 拿到裸编码后的视频帧自己决定关键帧和码率最后用轻量 muxer 把编码数据封装成 MP4。这意味着录屏流程变成“浏览器原生编码 H.264 前端本地封装 MP4”整个过程无插件、无服务器、无水印画质参数全在你手里。2. WebCodecs 编码链路拆解VideoFrame、VideoEncoder 与 MP4 容器的关系在贴代码之前先花点时间把原理讲透。WebCodecs 不是什么魔法它只是把浏览器内核里那套硬件/软件编码能力封装成了 JS API。理解整条链路后面调参数才能心里有数。2.1 WebCodecs 到底暴露了什么能力WebCodecs 的核心是一组编解码器接口其中和录屏最相关的是VideoFrame代表一帧未压缩的视频画面像素数据在 GPU 或内存里。VideoEncoder把 VideoFrame 编码成压缩后的视频 chunk。AudioData代表一段未压缩的 PCM 音频数据。AudioEncoder把音频编码成 AAC 等格式的 chunk。以前的浏览器只允许你传一个 MediaStream 给 MediaRecorder内部怎么编码、什么时候出关键帧全是黑盒。WebCodecs 把这些能力拆开交给你你可以拿到每一帧原始画面自定义编码参数码率、帧率、关键帧间隔、profile然后拿到编码后的一小段一小段二进制数据。可以这样理解MediaRecorder 是一条“全自动流水线”——你扔一头牛进去它直接给你一包牛肉干但你不能指定要后腿肉还是雪花肉。WebCodecs 相当于把屠宰、切片、风干每个环节的工具都交给了你你可以完全控制加工过程。代价是你要自己把这些半成品装进最终包装袋MP4 容器。2.2 视频帧流转链显示画面到 VideoFrame 到 EncodedVideoChunk 再到 MP4 sample完整链路是这样getDisplayMedia获取屏幕画面返回一个 MediaStreamTrack视频轨内部就是一帧帧画面。把这个 track 交给VideoTrackProcessor读取每次得到一帧VideoFrame。把VideoFrame喂给VideoEncoder从output回调里拿到EncodedVideoChunk。把EncodedVideoChunk按时间顺序交给 MP4 muxer由它拼装成符合 MP4 标准的 sample、stbl、moov 等 box。所有 chunk 拼完调用finalize()拿到完整 MP4 ArrayBuffer触发下载或写文件。这个过程中有一个关键点MP4 只是一个容器它不负责压缩。容器里装的是 H.264视频和 AAC音频各自的二进制码流。WebCodecs 输出的是已经压缩好的 H.264 码流MP4 muxer 的工作是包一层外壳把码流切成一段段的 sample再补上索引信息。2.3 H.264 编码配置与码率、关键帧控制很多人卡在codec字符串。这是 H.264 profile 和 level 的编码表示法。我在 1080p 录屏场景下的建议是场景codec 字符串说明1080p 30fps兼容性优先avc1.640028High Profile Level 4.01080p 60fps画质优先avc1.64002aHigh Profile Level 4.2720p 30fpsavc1.4d001fMain Profile Level 3.1低配机器、只做基础录制avc1.42001fBaseline Profile Level 3.1兼容性最好avc1.64002a这个字符串里64表示 High profile002a是 level 4.2。不同浏览器对 codec 的支持范围不完全一样稳妥做法是准备一个候选列表用VideoEncoder.isConfigSupported()逐个探测。码率设置上录屏和摄像头直播不一样屏幕内容静态区域多但文字、表格、代码高亮这类高频细节很吃码率。我的经验值是 1080p 30fps 给 6-8 Mbps1080p 60fps 给 10-12 Mbps动态演示、页面滚动频繁时给到 15 Mbps 也不亏。码率太低文字边缘会糊成一片鼠标光标也容易出现残影。关键帧间隔是很多人忽略的点。MP4 能不能“随便拖动进度条秒开”很大程度上取决于关键帧密度。WebCodecs 没有直接的 GOP 参数你要在调encoder.encode(frame, { keyFrame: true })时手动控制。我通常每 60 帧强制插一个关键帧30fps 下是每 2 秒一个这样导出文件在剪辑软件里拖动响应会明显好很多。特殊场景如果用户需要逐帧剪辑可以缩短到每 30 帧。编码器avc配置里还有一个隐藏细节format必须设成avc而不是annexb。因为 MP4 容器需要 AVCC 格式的 H.264 码流长度前缀方式而 Annex-B 格式是流式传输用的起始码方式直接混用会得到一份打不开的文件。这是我的真实经历后面章节详细说。3. 核心实现从 getDisplayMedia 取流到 MP4 文件落地的代码骨架理论讲完上代码。下面这套骨架我按“能跑通”的标准写的尽量去掉与主题无关的工程细节。需要额外说明的是MP4 muxer 这一步浏览器没有原生 API需要引入一个轻量的 JS 库。我用的是mp4-muxer体积小、API 清晰、和 WebCodecs 配合得很好。这个文件你可以从 npm 或 CDN 拿到不算“插件”只是一个 JS 库。3.1 环境与兼容性判断先跑一个探针所有逻辑开始之前先检测当前浏览器是否支持 WebCodecs。因为 Safari 截止这篇文章写作时对 VideoEncoder 的支持仍然不够理想Firefox 的 H.264 编码支持也有兼容风险。如果你在做一个有用户量的 Web 应用必须在进入录制流程前拦截给出明确定位。async function checkRecorderSupport() { if ( !(VideoEncoder in window) || !(VideoTrackProcessor in window) || !(AudioEncoder in window) || !navigator.mediaDevices?.getDisplayMedia ) { return { ok: false, reason: 当前浏览器不支持 WebCodecs 录屏请使用最新版 Chrome 或 Edge }; } const candidates [ { codec: avc1.64002a, width: 1920, height: 1080 }, { codec: avc1.640028, width: 1920, height: 1080 }, { codec: avc1.42001f, width: 1920, height: 1080 }, ]; for (const item of candidates) { try { const support await VideoEncoder.isConfigSupported({ codec: item.codec, width: item.width, height: item.height, bitrate: 10_000_000, framerate: 30, avc: { format: avc }, }); if (support.supported) { return { ok: true, codec: item.codec, support }; } } catch (e) { // 个别接口实现不支持 isConfigSupported继续尝试下一个 } } return { ok: false, reason: 当前浏览器不支持 H.264 编码 }; }这段探针很实用。很多用户浏览器版本老直接报“不支持”比进去之后静默失败友好一万倍。candidates里第一个优先尝试 1080p60 的 codec如果浏览器不支持再降级。顺带提一句VideoEncoder.isConfigSupported是异步的在线程里跑也会把主线程打断一下所以可以在用户点“开始录制”之前就调用把探测结果缓存下来。3.2 视频采集与编码主循环TrackProcessor 的正确用法这是我的核心录制循环包含了采集、编码、mux 三个步骤的联动。在这个版本里我只处理视频轨音频轨单独讲因为音频会牵扯到另一套同步逻辑。async function startRecording({ codec, onFileReady }) { const stream await navigator.mediaDevices.getDisplayMedia({ video: { width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: 60 }, }, audio: true, }); const videoTrack stream.getVideoTracks()[0]; if (!videoTrack) throw new Error(没有获取到视频轨道); const width videoTrack.getSettings().width || 1920; const height videoTrack.getSettings().height || 1080; const muxer new Mp4Muxer({ target: new ArrayBufferTarget(), video: { codec: avc, width, height, }, fastStart: in-memory, }); let frameCount 0; let encoderStarted false; const videoEncoder new VideoEncoder({ output: (chunk, meta) { if (!encoderStarted) return; muxer.addVideoChunk(chunk, meta); }, error: (e) { console.error(VideoEncoder error:, e); }, }); videoEncoder.configure({ codec, width, height, bitrate: 12_000_000, framerate: 60, latencyMode: quality, avc: { format: avc }, }); const processor new VideoTrackProcessor({ track: videoTrack }); const reader processor.readable.getReader(); let keyFrameTimer 0; while (true) { const { value: frame, done } await reader.read(); if (done) break; if (frame) { // 控制关键帧插入 frameCount; keyFrameTimer; const shouldKeyFrame keyFrameTimer 60 || frameCount 1; if (shouldKeyFrame) keyFrameTimer 0; // 如果编码队列积压严重跳过这一帧防止内存暴涨 if (videoEncoder.encodeQueueSize 4) { frame.close(); continue; } try { videoEncoder.encode(frame, { keyFrame: shouldKeyFrame }); } finally { frame.close(); } } } await videoEncoder.flush(); muxer.finalize(); const { buffer } muxer.target; onFileReady(new Blob([buffer], { type: video/mp4 })); }这个版本有几个工程上必须注意的地方encodeQueueSize 4时选择丢帧。VideoTrackProcessor的读取速度是贴近实时采集速度的但编码器编码速度不一定跟得上屏幕变化剧烈的时刻。如果你不管不顾无限往encode()里喂帧内存会快速上涨。丢弃中间若干帧对录屏体验影响很小因为屏幕内容本来就是连续的肉眼几乎感知不到丢了几帧但内存安全得到保证。frame.close()必须执行。WebCodecs 的VideoFrame持有 GPU/内存中的像素缓冲区不 close 会一直占着不释放长时间录制会爆显存或内存。这个和 canvas 的释放逻辑类似你每读一帧出来处理完就必须显式释放。latencyMode: quality让编码器优先保证画质而不是低延迟。录屏不是直播不需要超低延迟这个参数能换回更好的压缩效率。如果想做实时预览流再考虑realtime。3.3 音频编码与音视频合流AudioTrackProcessor 的正确姿势音频部分复杂一些原因是 getDisplayMedia 的audio: true在不同浏览器里表现不一样。Chrome 里它能捕获标签页/系统声音但你是否真的拿到了想要的音频轨取决于用户在系统弹窗里的选择。我在生产代码里的做法是允许无音频录制如果stream.getAudioTracks().length 0就直接进入纯视频录制流程。音频的 muxer 配置和编码器配置必须匹配。mp4-muxer的 audio 配置里sampleRate和numberOfChannels要与AudioEncoder的configure保持一致我统一用 48000Hz 双声道async function startAudioEncoding(muxer, stream) { const audioTrack stream.getAudioTracks()[0]; if (!audioTrack) return null; const audioEncoder new AudioEncoder({ output: (chunk, meta) { if (chunk) muxer.addAudioChunk(chunk, meta); }, error: (e) console.error(AudioEncoder error:, e), }); audioEncoder.configure({ codec: mp4a.40.2, sampleRate: 48000, numberOfChannels: 2, bitrate: 128_000, }); const processor new AudioTrackProcessor({ track: audioTrack }); const reader processor.readable.getReader(); return (async () { while (true) { const { value: data, done } await reader.read(); if (done) break; if (data) { try { audioEncoder.encode(data); } finally { data.close(); } } } await audioEncoder.flush(); })(); }一个容易忽视的坑AudioTrackProcessor的readable读取速度不能“想读就读”它和视频轨一样也是实时节奏。但音频数据本身很小一秒钟 48KB双通道 16bit PCM所以即使偶有积压也问题不大。麻烦在于这个循环会一直跑如果要停止录制必须通知循环退出否则会一直等待新的 AudioData。控制停止的手段是在外层把 track stop 掉这样 reader 会收到结束信号。3.4 下载文件Blob 下载和 File System Access API 两种方式编码结束拿到 MP4 buffer 之后最简单的交付方式是 Blob 下载function downloadBlob(blob, filename) { const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() URL.revokeObjectURL(url), 5000); }但我要提醒一点录屏幕 1 小时 1080p文件可能有 4-6GB。这种大小的文件用 Blob 下载浏览器内存直接爆炸页面大概率卡死。遇到长时间录制场景更稳妥的是用 File System Access API 把数据流式写入磁盘。async function saveWithFileSystemAccess(buffer, suggestedName) { const handle await window.showSaveFilePicker({ suggestedName: suggestedName || recording.mp4, types: [{ description: MP4 视频, accept: { video/mp4: [.mp4] }, }], }); const writable await handle.createWritable(); await writable.write(buffer); await writable.close(); }mp4-muxer还支持FileSystemWritableFileStreamTarget可以把 muxer 输出直接写盘不经过内存累积。我做长时间录屏时就是用的这个方案target: new FileSystemWritableFileStreamTarget(stream)。如果你只是做一个短录屏工具ArrayBufferTarget足够。4. 实战踩坑与优化帧控制、音画同步、内存与兼容性代码骨架能跑通只是起点。真正让录屏工具“能交付给用户”要处理的是下面这些细节。每一个都是我实际踩过坑之后总结出来的。4.1 编码队列爆掉read 循环节流与丢帧策略前面代码里写了encodeQueueSize 4就丢帧这是最基础的节流策略。实际工程里我会把这个阈值根据机器性能动态调整如果连续 30 帧都在丢说明当前编码器跟不上采集速度就主动把采集帧率降下来。一个可行做法是对videoTrack重新applyConstraints({ frameRate: 30 })把采集帧率从 60 降到 30这样比持续丢帧体验更好。丢帧还有一个副作用时间戳不连续。编码出来的视频如果用performance.now()作为时间戳丢帧后时间戳会出现跳变。正确的做法并不是直接采用采集帧自带的时间戳而是以第一帧为基准用一个独立的虚拟时钟计算每帧的 PTS。我在实践中维护了一个ptsCounter从 0 开始每编码一帧就增加1 / targetFps * 1_000_000微秒。比如目标是 60fps那每帧 PTS 增量固定是 16666.67 微秒用 Math.round 处理。这个强制 CFR固定帧率的思路能让 MP4 文件在播放器里的时间轴非常干净不会出现最后总时长对不上的情况。let pts 0; const MICROSECONDS_PER_FRAME Math.round(1_000_000 / targetFps); // 在 encode 之前 const frameWithPts new VideoFrame(frame, { timestamp: pts, duration: MICROSECONDS_PER_FRAME, }); encoder.encode(frameWithPts, { keyFrame: shouldKeyFrame }); frameWithPts.close(); pts MICROSECONDS_PER_FRAME;注意这里新建了一个VideoFrame包裹原本的 frame因为它允许你覆写 timestamp。原本采集帧的 timestamp 是 WebRTC 采集链路里的时间戳直接用于 MP4 编码容易出问题。稍后你会在播放器里发现时长不错但拖动时时间轴不准的情况。4.2 音画不同步PTS/DTS 与音频 duration 换算录屏里最让人头疼的问题之一就是音画不同步视频画面比声音慢半拍或者反过来。核心原因多半是时间戳换算错误。WebCodecs 内部统一使用微秒microseconds作为时间单位。视频帧的 timestamp 和 duration 要用微秒音频的 timestamp 和 duration 也要用微秒。但音频比较特殊它有采样率每一帧 AudioData 里可能包含 1024 个采样样本时间长度是numberOfFrames / sampleRate秒。比如 48000Hz 采样率下一个 AudioData chunk 有 1024 个样本那么 duration 应该是1024 / 48000 * 1_000_000 21333.33微秒。如果你的代码里直接用data.duration大多数时候没问题但有些采集源返回的 AudioData duration 并不规范。稳妥做法是自己算const durationMicros Math.round((data.numberOfFrames / sampleRate) * 1_000_000);音视频轨交错时必须按时间戳排序muxer 内部会处理这一点。你要做的只是确保两边的时间基准一致要么都从 0 开始要么都从同一个启动时刻算起。我在代码里统一让视频 PTS 从 0 开始增长音频则以第一个 AudioData 的 timestamp 为基准归零。4.3 浏览器兼容与降级策略Firefox、Safari 和 WebView 怎么办WebCodecs 的 VideoEncoder 在 Chromium 内核里已经相当稳定但 Safari 和 Firefox 的使用情况复杂得多。Safari 直到很晚才支持 VideoEncoder且 H.264 支持范围有限Firefox 对 H.264 的编码支持一直不太积极有一些版本即使支持也是软编性能堪忧。我的降级策略分三档ChromiumChrome、Edge、Electron走 WebCodecs 完整流程导出 1080p/60fps MP4。Safari检测到不支持 VideoEncoder 或 H.264 编码时回退到 MediaRecorder 录 WebM页面明确提示“当前浏览器只能导出 WebM”。虽然体验打折但至少功能可用用户换 Chrome 就能解决问题。完全不支持的 WebView直接禁用录制按钮并给出浏览器升级指引。兼容性检测的探针函数在 3.1 已经写过真实项目里建议再结合浏览器 UA 或特征检测做更深一步判断。另有一个容易被忽视的细节VideoTrackProcessor的可用性比 VideoEncoder 更晚。如果浏览器支持 VideoEncoder 但没实现VideoTrackProcessor你就只能把 track 接到一个video元素上再用requestVideoFrameCallback canvas 绘制来模拟取帧。性能差一些但能兜底。考虑到复杂度如果你的目标用户明确是桌面 Chrome可以不实现这一档。4.4 导出文件损坏与无法播放的排查方式辛辛苦苦录了半小时导出文件打不开这是最绝望的。我遇到过三种典型情况每种都能从现象反推原因。现象一播放器提示文件损坏文件头和 moov box 缺失。检查是否漏了调muxer.finalize()。MP4 文件的 moov box 通常包含轨道索引信息如果不用 fastStart 模式moov box 会放在文件末尾。很多 muxer 在finalize()前不会写入 moov。另一个坑fastStart: in-memory选项会要求 muxer 在内部缓存 moov box文件较大时内存占用很高此时可以换fragmented或流式写入模式。现象二播放器提示“无法解析视频流”或“没有视频轨”。检查addVideoChunk的 meta 里是否包含decoderConfig以及avc.format是不是avc。如果编码器输出的是 annexb 格式但 muxer 按 avc 格式解析视频轨就废了。解决办法是在 VideoEncoderconfigure时显式设置avc: { format: avc }。现象三播放正常但没有声音。检查音频 track 是否真的采集到了。getDisplayMedia 允许用户在弹窗里只勾选“分享屏幕”不勾“共享音频”此时stream.getAudioTracks().length 0。你没有做出口判断的话muxer 就会生成只有视频轨的 MP4。这也是我为什么在前面代码里加了“允许无音频录制”的分支。排查这些的时候我一般会先把编码出来的原始 chunk 写到一个文件里拿到ffprobe去看轨道格式。如果ffprobe里能看到h264 (avc1)轨道说明 muxer 基本正常问题大概率在时间戳或 duration。如果ffprobe直接报 no moov就是 finalize 或 fastStart 的问题。4.5 再聊一个细节长时间录制时的浏览器保活机制最后再分享一个非常隐蔽的坑如果标签页长时间处于后台或被系统判定为“非活跃状态”浏览器的定时器会被节流甚至某些媒体处理操作会被暂停。WebCodecs 的编码虽然和标签页前台状态不直接挂钩但如果你在代码里使用了setTimeout或requestAnimationFrame做辅助逻辑比如生成关键帧、反馈录制状态后台时这些逻辑会不正常执行。我的做法是录制期间启用navigator.wakeLock保持屏幕常亮同时把关键帧控制放在编码主循环的帧计数里而不是依赖独立的定时器。这样即使标签页不在前台编码循环本身也始终在工作。另一个经验是录屏过程中拒绝处理任何无关的异常弹窗和错误事件把所有错误统一收集到录制结束后的报告里避免中途打断用户。就我个人体会而言WebCodecs 这套方案真正解决了录屏工具的“最后一公里”浏览器里直接产出标准 MP4无需插件、无需服务端转码、无水印且画质参数完全可控。唯一需要适应的是它把底层细节暴露给你后所有坑也要自己扛。但只要把帧控制、时间戳、音频同步这几个核心点处理好做出来的工具稳定性和用户体验会远超 MediaRecorder 方案。如果你正在做一个录屏类产品或者只是想让自己的小工具能导出兼容性更好的文件可以试试从这套代码骨架开始再按自己的场景把码率、关键帧间隔和音视频策略微调一遍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价