如何用 WebCodecs 在浏览器里实现高清录屏 —— 无插件、无水印、直接导出 MP4

录屏是浏览器的"老大难"需求:想在网页里录一段高清带声音的屏幕,听起来简单,但用过 MediaRecorder 的人都知道------导出的十有八九是 WebM(发给同事打不开)、码率玄学(文字糊成一团)、想控制编码参数基本没门。

这篇文章分享一套我在生产环境里落地的方案:getDisplayMedia 采集 + WebCodecs 实时编码(H.264 + AAC)+ mp4-muxer 封装,纯前端实现 1080p/4K 高清录屏,直接导出 MP4。文末有可用的在线工具和完整思路代码。

先说结论:为什么不用 MediaRecorder

MediaRecorder 是录制 MediaStream 的标准 API,但它有几个绕不过去的硬伤:

  1. 格式看运气 。Chrome 里 MediaRecorder.isTypeSupported('video/mp4') 长期为 false(新版 Chrome 虽然开始支持 MP4,但落到 Safari/Firefox 又是另一回事),绝大多数环境只能录出 WebM/Matroska,发给 Windows 用户直接傻眼。
  2. 码率不受控 。你只能给一个 videoBitsPerSecond 的"建议值",编码器内部用什么 profile、什么 GOP 策略完全是黑盒。录屏幕文字(UI、代码、PPT)这种高频变化的高频细节内容,黑盒默认参数经常糊。
  3. 拿不到编码过程。想实现"每 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:系统声音是"随视频来的"getDisplayMediaaudio 只是请求,用户在共享弹窗里没勾"分享标签页音频",你拿到的 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 Profileavc1.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/displayHeighttrack.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 拿到完整的 ArrayBuffernew 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 采集指南

相关推荐
杉氧2 小时前
页面栈与路由:React Navigation 与 Expo Router 深度实践
android·前端·react native
用户931456355662 小时前
别再满屏 try-catch 了:聊聊异常处理的正确姿势
前端
葡萄城技术团队2 小时前
ERP 内置 BI(上):为什么业务部门还在用 Excel 做分析?
前端
用户64596598710882 小时前
Jenkins CI/CD 实战:Vite 前端发布、Koa + PM2 后端部署、权限隔离与远程发布
前端
deli0070072 小时前
技术案例:使用华为云码道(CodeArts)智能体开发鸿蒙徒步行程记录App
前端
计算机魔术师2 小时前
Anthropic 详解 7·30 安全事件:配置错误致 Claude 访问真实系统,已加强沙箱隔离与实时监控
前端
zlwool2 小时前
图纸管理选型:从变更频率到齐套率
java·前端·python·erp·设备erp·非标机械
阿图灵2 小时前
MakerHub 开发报告:v1.0.0 → v1.1.0(单日 26 提交,图片渲染、目录跟随与数据真实化)
前端·vue·个人网站·deepseek·开发报告
程序员小八7773 小时前
Go Web 工程化:日志、配置与错误处理中间件,让服务「能上线」
前端·中间件·golang