LOGO 首页 OA教程 ERP教程 模切知识交流 PMS教程 CRM教程 技术文档 其他文档  
 
网站管理员

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

zhenglin
2026年9月3日 10:9 本文热度 176

导读


录屏是浏览器的"老大难"需求:想在网页里录一段高清带声音的屏幕,听起来简单,但用过 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、自己定码率、自己算时间戳、自己封装容器。这就是这套方案的地基。

整条管线长这样:

┌──────────────┐   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,它绝不会好心给你更多:

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 混音:

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/像素

码率(Mbps) = 宽 × 高 × min(fps, 60) × 0.12 / 1e6

落成代码(夹在 8~32 Mbps 之间,三档画质在此基础上 ×0.6 / ×1 / ×1.5):

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,并准备回退链:

// 视频回退链: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 把轨"过一遍"实现重采样:

// 无 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,我们自己驱动编码:

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),然后:

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);② 帧循环里的异常要显式上报,别让它静默吞掉。


暂停:时间戳补偿

用户点暂停后,屏幕流还在出帧(只是我们不编码)。恢复后如果直接用原始时间戳,导出的视频里暂停的那几分钟会变成"时间跳跃"。解法是累计暂停时长,给每帧时间戳打折扣:

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)就丢弃这一帧并计数(丢弃数会在导出时展示给用户,而不是瞒着):

if (this.videoEncoder.encodeQueueSize > 30) {

  this.droppedFrames++

  frame.close()

  continue

}

注意每个用完的 VideoFrame 都要 close()——它们是 GPU 资源的引用,不释放就是内存泄漏。

第五步:封装 MP4

编码输出的是裸的 EncodedVideoChunk,要变成能播放的文件还需要封装容器。我用 mp4-muxer(纯 JS、无依赖、~30KB):

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,把原始错误信息完整保留上报:

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:

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 而非 MediaRecorderMP4 直出、码率/profile/GOP 全可控
码率 = 像素量 × 0.12bpp屏幕内容以文字锐度为先
一切编解码器先探测再使用平台差异(AAC 缺失、4K Level)比想象中常见
时间戳全 BigInt + 暂停补偿微秒精度 + 导出无暂停空档
encodeQueueSize 背压丢帧长录制内存安全
收尾全程防御式 flush/close不让收尾异常掩盖真实错误


在线体验:我把这套方案做成了免费工具(无需注册、无水印、不限时长): 👉 tools.gochina.help/screen-reco…

如果你只想录个屏,直接用就好;如果你在实现类似功能,希望这篇少帮你踩几个坑。有问题欢迎评论区交流。


阅读原文:点击这里


该文章在 2026/9/3 10:09:04 编辑过
关键字查询
相关文章
正在查询...
点晴ERP是一款针对中小制造业的专业生产管理软件系统,系统成熟度和易用性得到了国内大量中小企业的青睐。
点晴PMS码头管理系统主要针对港口码头集装箱与散货日常运作、调度、堆场、车队、财务费用、相关报表等业务管理,结合码头的业务特点,围绕调度、堆场作业而开发的。集技术的先进性、管理的有效性于一体,是物流码头及其他港口类企业的高效ERP管理信息系统。
点晴WMS仓储管理系统提供了货物产品管理,销售管理,采购管理,仓储管理,仓库管理,保质期管理,货位管理,库位管理,生产管理,WMS管理系统,标签打印,条形码,二维码管理,批号管理软件。
点晴免费OA是一款软件和通用服务都免费,不限功能、不限时间、不限用户的免费OA协同办公管理系统。
Copyright 2010-2026 ClickSun All Rights Reserved  粤ICP备13012886号-9  粤公网安备44030602007207号