录屏是浏览器的"老大难"需求:想在网页里录一段高清带声音的屏幕,听起来简单,但用过
MediaRecorder的人都知道------导出的十有八九是 WebM(发给同事打不开)、码率玄学(文字糊成一团)、想控制编码参数基本没门。这篇文章分享一套我在生产环境里落地的方案:
getDisplayMedia采集 + WebCodecs 实时编码(H.264 + AAC)+ mp4-muxer 封装,纯前端实现 1080p/4K 高清录屏,直接导出 MP4。文末有可用的在线工具和完整思路代码。
先说结论:为什么不用 MediaRecorder
MediaRecorder 是录制 MediaStream 的标准 API,但它有几个绕不过去的硬伤:
- 格式看运气 。Chrome 里
MediaRecorder.isTypeSupported('video/mp4')长期为 false(新版 Chrome 虽然开始支持 MP4,但落到 Safari/Firefox 又是另一回事),绝大多数环境只能录出 WebM/Matroska,发给 Windows 用户直接傻眼。 - 码率不受控 。你只能给一个
videoBitsPerSecond的"建议值",编码器内部用什么 profile、什么 GOP 策略完全是黑盒。录屏幕文字(UI、代码、PPT)这种高频变化的高频细节内容,黑盒默认参数经常糊。 - 拿不到编码过程。想实现"每 2 秒一个关键帧方便拖进度条""编码队列堆积时主动丢帧"这类精细控制,API 层面做不到。
而 WebCodecs(Chrome 94+、Edge、Safari 16.4+)把 VideoEncoder/AudioEncoder 原生编码器直接暴露给了 JS------我们终于可以:自己选 codec 和 profile、自己定码率、自己算时间戳、自己封装容器。这就是这套方案的地基。
整条管线长这样:
scss
┌──────────────┐ VideoFrame ┌──────────────┐ EncodedChunk ┌───────────┐
│ getDisplay- │──MediaStream──▶│ VideoEncoder │───────────────▶│ │
│ Media │ TrackProces- │ (H.264 High) │ │ mp4-muxer │──▶ MP4 Blob
│ (屏幕+音频) │ sor 拆帧 └──────────────┘ │ (fastStart)│
│ │ AudioData ┌──────────────┐ │ │
│ WebAudio 混音 │──────────────▶│ AudioEncoder │───────────────▶│ │
│ (可选) │ │ (AAC/Opus) │ └───────────┘
└──────────────┘ └──────────────┘
下面按数据流向一步步拆。
第一步:采集屏幕和音频
视频采集用 getDisplayMedia,这里有个关键细节:约束要"往上要" 。给 ideal: 3840x2160@60,浏览器会在用户选择的实际分辨率内给到最好的画质;你只要 720p,它绝不会好心给你更多:
ts
const display = await navigator.mediaDevices.getDisplayMedia({
video: {
width: { ideal: 3840 },
height: { ideal: 2160 },
frameRate: { ideal: 60, max: 60 },
},
// 系统声音要求关掉回声消除等处理,保留原始音质
audio: wantSystem
? { echoCancellation: false, noiseSuppression: false, autoGainControl: false }
: false,
})
音频来源分四种:不录 / 系统声音 / 麦克风 / 两者都要。有两个坑:
坑 1:系统声音是"随视频来的" 。getDisplayMedia 的 audio 只是请求,用户在共享弹窗里没勾"分享标签页音频",你拿到的 stream 里就没有音轨------所以一定要检查并给用户提示,而不是静默失败。
坑 2:系统声音 + 麦克风是两条独立的轨,而 MP4 里通常只有一条音轨。解法是 WebAudio 混音:
ts
const ctx = new AudioContext()
const dest = ctx.createMediaStreamDestination()
ctx.createMediaStreamSource(new MediaStream([systemTrack])).connect(dest)
ctx.createMediaStreamSource(micStream).connect(dest)
const mixedTrack = dest.stream.getAudioTracks()[0] // 混好的一条轨
第二步:码率公式------文字清晰的第一要义
屏幕录制的内容(UI、代码、文档)以锐利边缘和高频细节为主,和摄像头画面完全不同。压住码率必然糊文字,所以码率必须按像素量算,而不是拍一个固定值。
我用的经验公式是 0.12 bit/像素:
scss
码率(Mbps) = 宽 × 高 × min(fps, 60) × 0.12 / 1e6
落成代码(夹在 8~32 Mbps 之间,三档画质在此基础上 ×0.6 / ×1 / ×1.5):
ts
export function calcBitrateMbps(w: number, h: number, fps: number, q: Quality): number {
const auto = Math.min(32, Math.max(8, Math.round((w * h * Math.min(fps, 60) * 0.12) / 1e6)))
if (q === 'standard') return Math.max(6, Math.round(auto * 0.6))
if (q === 'max') return Math.min(40, Math.round(auto * 1.5))
return auto
}
对照一下感受下量级:1080p30 高清档 ≈ 9 Mbps,4K30 ≈ 30 Mbps。这个码率录出来的文字放大看边缘是锐的,而不是一团色块。
配合两个参数一起服用:
- H.264 High Profile (
avc1.640028,4K 用 Level 5.1 的avc1.640033):High Profile 的 CABAC 熵编码对屏幕内容效率明显更好; - 2 秒一个关键帧:拖动进度条最多等 2 秒就能出画面,文字块也有周期性"刷新"机会。
第三步:编码器协商------别假设任何编解码器存在
WebCodecs 的编解码器支持因平台而异 :同样一份 avc1 配置,Windows Chrome 支持、Linux Chromium 可能不支持;AAC(mp4a.40.2)在不少 Linux/Chromium 环境里压根没有编码器。
所以一切配置都必须先问 isConfigSupported,并准备回退链:
ts
// 视频回退链:4K 加推 Level 5.1 → High → Main → VP9(VP9 也能封装进 MP4)
async function pickVideoCodec(width, height, fps, bitrate) {
const candidates: { enc: string; mux: 'avc' | 'vp9' }[] = []
if (width * height > 1920 * 1080) candidates.push({ enc: 'avc1.640033', mux: 'avc' })
candidates.push(
{ enc: 'avc1.640028', mux: 'avc' },
{ enc: 'avc1.4D0028', mux: 'avc' },
{ enc: 'vp09.00.10.08', mux: 'vp9' },
)
for (const c of candidates) {
const cfg: VideoEncoderConfig = {
codec: c.enc, width, height, bitrate, framerate: fps,
...(c.mux === 'avc' ? { latencyMode: 'realtime' } : {}),
}
if ((await VideoEncoder.isConfigSupported(cfg)).supported) return c
}
throw new Error('NO_VIDEO_CODEC')
}
音频同样如此:AAC 优先,没有 AAC 就转 Opus 。这里有个冷知识------Opus 编码器只接受 48kHz 采样率,而麦克风轨常常是 44.1kHz。解法是再借一次 WebAudio,用一个 48kHz 的 AudioContext 把轨"过一遍"实现重采样:
ts
// 无 AAC 的平台(常见于 Linux Chromium)→ 48kHz 重采样后编码 Opus
const ctx = new AudioContext({ sampleRate: 48000 })
await ctx.resume()
const dest = ctx.createMediaStreamDestination()
ctx.createMediaStreamSource(new MediaStream([track])).connect(dest)
// 用 dest.stream 的轨去编码,mp4-muxer 支持 opus-in-mp4
第四步:核心帧循环------时间戳、暂停、背压
这是整个方案的心脏。视频轨用 MediaStreamTrackProcessor 拆成一帧帧 VideoFrame,我们自己驱动编码:
ts
const vreader = new MediaStreamTrackProcessor({ track: videoTrack }).readable.getReader()
while (!stopped) {
const { done, value: frame } = await vreader.read()
if (done || !frame) break
// ... 每帧的处理逻辑
}
首帧探测
不要在拿到轨道后立刻建编码器------getDisplayMedia 的约束是 ideal,真实宽高和帧率要以第一帧为准 。我的做法是:第一帧到达时读 frame.displayWidth/displayHeight、track.getSettings().frameRate,再据此协商编码器、创建 muxer。
时间戳必须用 BigInt
WebCodecs 的 VideoFrame.timestamp 是微秒级高精度时间戳,bigint 类型。这里我踩过一个价值半天的 bug,值得展开讲:
我一开始写的是 const KEYFRAME_INTERVAL_US = 2_000_000(普通 number),然后:
ts
const forceKey = adj - this.lastKeyUs >= KEYFRAME_INTERVAL_US // adj 是 bigint
这行在 JS 里直接抛 TypeError: Cannot mix BigInt and other types。更阴险的是:这行在帧处理循环的 try/catch 里,异常导致循环退出、录制"看似正常"地停在第 0 秒------计时器不走、文件里只有第一帧,没有任何报错冒出来。
教训两条:① 和时间戳做运算的常量也必须是 bigint(2_000_000n);② 帧循环里的异常要显式上报,别让它静默吞掉。
暂停:时间戳补偿
用户点暂停后,屏幕流还在出帧(只是我们不编码)。恢复后如果直接用原始时间戳,导出的视频里暂停的那几分钟会变成"时间跳跃"。解法是累计暂停时长,给每帧时间戳打折扣:
ts
pause() {
this.pauseStartedAt = performance.now()
}
resume() {
// 累计的暂停微秒数
this.pausedUs += BigInt(Math.round((performance.now() - this.pauseStartedAt) * 1000))
}
// 每帧的时间戳 = 原始时间戳 - 起点时间戳 - 累计暂停时长
let adj = BigInt(frame.timestamp) - this.firstVTs - this.pausedUs
if (adj < 0n) adj = 0n
if (adj < this.lastAdjustedUs) adj = this.lastAdjustedUs // 单调递增保护
// 每 2 秒强制一个关键帧
const forceKey = adj - this.lastKeyUs >= KEYFRAME_INTERVAL_US // 2_000_000n !
if (forceKey) this.lastKeyUs = adj
// 注意:new VideoFrame(frame, {timestamp}) 只接受 number,微秒在安全整数内
const vf = new VideoFrame(frame, { timestamp: Number(adj) })
frame.close()
this.videoEncoder.encode(vf, { keyFrame: forceKey })
vf.close()
背压:编码跟不上就丢帧
长时间录制最怕内存失控。VideoEncoder.encodeQueueSize 是当前的待编码队列深度,队列堆积说明编码器已经跟不上了------继续硬塞只会内存膨胀。超过阈值(我设 30)就丢弃这一帧并计数(丢弃数会在导出时展示给用户,而不是瞒着):
ts
if (this.videoEncoder.encodeQueueSize > 30) {
this.droppedFrames++
frame.close()
continue
}
注意每个用完的 VideoFrame 都要 close()------它们是 GPU 资源的引用,不释放就是内存泄漏。
第五步:封装 MP4
编码输出的是裸的 EncodedVideoChunk,要变成能播放的文件还需要封装容器。我用 mp4-muxer(纯 JS、无依赖、~30KB):
ts
import { Muxer, ArrayBufferTarget } from 'mp4-muxer'
const muxer = new Muxer({
target: new ArrayBufferTarget(),
fastStart: 'in-memory', // moov 盒前置,浏览器可直接流式播放
video: { codec: 'avc', width, height },
audio: { codec: 'aac', numberOfChannels: 2, sampleRate: 48000 },
})
// 编码器 output 回调里喂给 muxer
new VideoEncoder({
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
error: (e) => fail(e),
})
停止录制时:encoder.flush() 把队列里的尾包刷出来 → muxer.finalize() → 从 target.buffer 拿到完整的 ArrayBuffer → new Blob([buffer], { type: 'video/mp4' }) 下载。
又一个隐蔽的坑 :flush/close 要用防御式写法。编码器一旦在错误状态,flush() 或 close() 会抛 "Cannot call 'close' on a closed codec"------这个新异常会把真正的原始错误顶掉,让调试方向完全跑偏。所以收尾的每一步都要单独 try/catch,把原始错误信息完整保留上报:
ts
try { await this.videoEncoder?.flush() } catch {}
try { await this.audioEncoder?.flush() } catch {}
// 编码器出错时会自行关闭,再次 close 会抛异常------必须吞掉,让原始错误正常上报
try { this.videoEncoder?.close() } catch {}
try { this.audioEncoder?.close() } catch {}
兜底:Firefox 走 MediaRecorder
Firefox 至今没有完整的 WebCodecs(MediaStreamTrackProcessor 缺失)。能力探测放行到老方案,明示用户导出的是 WebM:
ts
export function webCodecsSupported(): boolean {
return (
typeof VideoEncoder !== 'undefined' &&
typeof AudioEncoder !== 'undefined' &&
typeof VideoFrame !== 'undefined' &&
typeof MediaStreamTrackProcessor !== 'undefined'
)
}
有趣的是,回退方案反而简单得多------这也是 MediaRecorder 至今还活着的原因:十行代码就能录,只是你失去了上面所有的控制权。
附:怎么自动化测试这条管线
写完最大的问题是没法 CI 里验证"真的录出画面"。getDisplayMedia 需要真人选屏幕,canvas 的 captureStream() 在无人消费时也可能不出帧。后来我用 MediaStreamTrackGenerator(可写轨道)自己造流,手动喂数字帧、自己控制时间戳,整条编码管线就能在自动化环境里跑通了------这个 API 值得单独写一篇,这里先埋个坑。
效果与总结
这套方案目前跑在一个纯前端工具站上:最高 4K/60fps、H.264 High Profile、AAC/Opus 双声道 128kbps、暂停无缝剪辑、实时显示已编码字节数。录 1080p 的代码演示视频,文字锐利,拖动进度条秒出画面,文件直接是 MP4------发微信、传 PR 描述、放 PPT 里都不会再有兼容性问题。
总结几个关键决策:
| 决策 | 原因 |
|---|---|
| WebCodecs 而非 MediaRecorder | MP4 直出、码率/profile/GOP 全可控 |
| 码率 = 像素量 × 0.12bpp | 屏幕内容以文字锐度为先 |
| 一切编解码器先探测再使用 | 平台差异(AAC 缺失、4K Level)比想象中常见 |
| 时间戳全 BigInt + 暂停补偿 | 微秒精度 + 导出无暂停空档 |
| encodeQueueSize 背压丢帧 | 长录制内存安全 |
| 收尾全程防御式 flush/close | 不让收尾异常掩盖真实错误 |
在线体验 :我把这套方案做成了免费工具(无需注册、无水印、不限时长): 👉 tools.gochina.help/screen-reco...
如果你只想录个屏,直接用就好;如果你在实现类似功能,希望这篇少帮你踩几个坑。有问题欢迎评论区交流。
参考:WebCodecs 规范 · MDN: VideoEncoder · mp4-muxer · getDisplayMedia 采集指南