WebCodecs选编码器,探测之后还要验解码和播放

我们组负责的商品素材后台下个版本要加一个能力。运营上传商品短视频时,浏览器先在本地转一遍码,统一成 1080p 再传到我们自己的存储。服务端只收成品。原片不在任何中间环节落盘。前台的商品详情页再把成品播出来。需求评审时产品同事随口问了一句:既然 H.265 更小,能不能默认导出 H.265?我当时答「先看浏览器支不支持」。回到工位我才发现这句话里藏着三个问题。编码端要知道这台机器上能用哪个编码器。选中的编码器编出来的东西得真能解开。播放端还得知道看视频的人那边能不能播。三个问题各有一套接口,后来测下来每一套都有说错的时候。

这篇把我最后落地的整套写法摊开来讲。顺序是探测、回退、编完自检、播放端检测与备用源,最后是在六个浏览器构建上做回归。代码都在本机跑过,输出原样贴出。

摘要 。我用 WebCodecs 的 VideoEncoder.isConfigSupported 按容器排了两条探测链。MP4 链先问 HEVC 硬件,再问 H.264 硬件,最后问不挑硬件软件的 H.264。WebM 链先问 AV1 硬件,再问 VP9,最后问 AV1。选中以后真编 60 帧,再用 VideoDecoder 解回来数帧数。六个构建两条链一共 12 组,全部解回 60 帧。播放端第一版的 <source> 只写了 video/mp4。精简版 Chromium 因此选中 H.265,画面全黑也不报错。把 codecs 参数写上之后,它自己跳到了 H.264。我又加了一道兜底:加载后等第一帧,1,200 毫秒等不到就换源,检测接口说错时它也能接住。至于没有硬件时回退到 WASM 编码器,我看过一个线上工具的实际代价,决定不接。

版本声明。浏览器构建有六个:Chromium 149.0.7827.55 完整版、Chrome 155.0.8059.80、Chromium 149 关硬件视频编码版、精简版 Chromium 149、Firefox 151.0、WebKit 26.5。WebKit 是 macOS 构建,不是 iPhone 上的 Safari。测试机用的是 M4 芯片的 Mac,跑 macOS 26.5。WebCodecs 要求安全上下文,测试页都跑在本地的 https 页面里。测试在 2026-10-19 做完。文中的 MB 一律按 1024×1024 字节算,和图映界面上的显示一致;精确到字节的地方直接写字节数。

适用边界。所有结论只对 Apple M4 加 macOS 26.5 这一个组合有效。Windows、Linux、安卓都没测,iPhone 上的 Safari、微信里打开的网页也没测。编码端的测试画面是 canvas 画的色块。字节数只能说明不同编码器的产出不同,不能当画质结论。各项只跑了一到两遍,耗时只看量级,详细的边界放在第九章。

文章目录

一、WebCodecs为什么不能只问一次isConfigSupported

  • 同样返回支持的可能是两个编码器
  • WebKit的三个硬件提示产出完全相同
  • 精简版Chromium版本号相同却不支持HEVC

二、浏览器编码要探测哪些codec字符串

  • codec字符串里写着profile和level
  • Chromium不支持HEVC Main10编码

三、编码器的回退顺序怎么排

  • MP4和WebM各走一条探测链
  • HEVC只在硬件可用时才选
  • 链的最后一格要用no-preference
  • 六个构建都选到了能用的编码器

四、链尾要不要接一个WASM编码器

  • H.265原片回退到x265慢了约6.5倍
  • H.264原片不走这条回退

五、选中的编码器怎么确认真能用

  • 编完再解回来十二组全部对上
  • 返回的config不一定原样带回

六、网页播放H.265怎么检测和准备备用源

  • source不写codecs会选中HEVC然后黑屏
  • 写上codecs参数就会跳到H.264
  • 两个检测接口都过才算能播
  • 加载后再等第一帧做兜底

七、整套方案怎么做跨浏览器回归

  • 一张表盯住六个浏览器构建
  • 跑两遍只有WebKit的字节变了
  • 回归用的浏览器要和用户一致

八、测量方法自身的坑

九、适用边界与风险提示

十、还没解决的问题

参考资料

一、WebCodecs为什么不能只问一次isConfigSupported

最早的方案只有一行判断。拿一份 HEVC 配置去问 VideoEncoder.isConfigSupported。返回的 supported 是 true 就导出 H.265,否则导出 H.264。这一行在我自己的 Chrome 里没出过问题。可我们上线前要压测,压测得先知道它在别的浏览器里怎么走。我把同一份配置拿到六个构建上,每个 codec 配三个 hardwareAcceleration 取值各问一次。这三个取值在规范里的定义,我是在通勤的地铁上读完的。单程一小时二十分钟,读完还没到站。问出 true 的再真编 30 帧,记下产出字节和耗时。配置统一是 1920×1080、30 fps、4 Mbps。跑完我就不再信那一行判断了。

同样返回支持的可能是两个编码器

Chromium 149 完整版和 Chrome 155 对 HEVC Main 的回答一样。三个取值全是 true。可真编下去就不一样了。prefer-hardware 编 30 帧 1080p 用了 165 到 175 毫秒。产出是 69,294 字节。prefer-software 也说支持。可同样 30 帧它要花掉 6 秒出头,最慢 6.5 秒,产出变成 103,469 字节。字节数不同。耗时差了三十多倍。看样子是另一个编码器在干活。我猜背后接的是 macOS 自带的软件 HEVC 编码,这一点我没有核实。更麻烦的是 no-preference。在这两个构建里它的产出和 prefer-hardware 逐字节相同。也就是说有硬件时浏览器替你挑了硬件。到了关掉硬件视频编码的 Chromium 上情况反过来。H.264 的 no-preference 产出 400,415 字节,跟软件那一档逐字节对得上。no-preference 返回 true 只说明总有一个能用,不说明用的是哪一个。

这对我们的需求是要命的。运营一次传的是三五十秒的片子,按软件 HEVC 的速度人得在页面里干等好几分钟。只看 supported 的话,这种情况会被当成「支持 H.265」直接放行。

WebKit的三个硬件提示产出完全相同

WebKit 26.5 走的是另一个极端。HEVC、H.264、VP9、AV1 四种编码,三个取值全部返回 true。我原本以为它的硬件编码能力最全。看了产出才发现不对。同一个 codec 下三个取值编出来的字节完全相同,耗时也落在同一个区间。HEVC Main 是 67,581 字节、196 到 211 毫秒。换哪个取值都一样。比较合理的解释是它忽略了这个提示。它返回 true 只代表能编,不代表按你要的方式编。在 WebKit 上,prefer-hardware 返回 true 不能当成有硬件的证据。好在它的速度在硬件那一档,对选择结果没有实际坏处。

精简版Chromium版本号相同却不支持HEVC

第三个意外来自精简版 Chromium。它和完整版的版本号都是 149.0.7827.55。完整版的 HEVC 编码、解码、播放都没问题。精简版三样全不支持,isConfigSupported 对 HEVC 一律 false。关掉硬件视频编码的完整版也一样。HEVC 的三个取值全部变成 false,连软件那条路也跟着没了。软件 HEVC 为什么会跟着硬件开关一起消失,我没查明白。Firefox 151 在这台 Mac 上更干脆,VideoEncoder 和 VideoDecoder 问 HEVC 都是 false。可它的 <video> 播 H.265 文件完全正常,第六章还会碰到这件事。

三件事放在一起看,「这个浏览器支不支持 H.265」本身就问错了。isConfigSupported 回答的是另一件事:这个构建在这台机器上按这份配置,此刻能不能建出一个编码器。版本号一样不代表答案一样,答案是 true 也不代表够快。

二、浏览器编码要探测哪些codec字符串

问之前先把要问的东西写对。WebCodecs 的 codec 字段不接受「h265」「h264」这样的简写。它要的是带 profile 和 level 的完整字符串。写错一位,问到的就是另一个 profile,答案可能完全不同。

codec字符串里写着profile和level

我最后探测的是四个字符串。H.265 用 hvc1.1.6.L120.B0。它的意思是 Main profile、Level 4、Main tier,Level 4 刚好够 1080p30。H.264 用 avc1.640028,也就是 High profile、Level 4.0。VP9 用 vp09.00.40.08,表示 profile 0、level 4.0、8 bit。AV1 用 av01.0.08M.08,表示 Main profile、level 4.0、Main tier、8 bit。这几个值都是按我们要编的 1080p30 定的。导出 720p 时 level 可以降。导出 4K 就必须升,否则 isConfigSupported 可能因为 level 不够直接说 false。HEVC 还有一种 hev1 开头的写法。它和 hvc1 的区别在参数集放在哪里。hvc1 把参数集放在 MP4 的样本描述里,hev1 允许它们留在码流中。我在编码端只用 hvc1,原因在第六章。

除了 codec 以外,width、height、bitrate、framerate 也会影响答案。L3.1 那一档只能在 1280×720 上问,1080p 超出了这个 level 的上限。H.264 的 Baseline profile 我也只在 720p 上问过。写正式代码时我让探测用的配置和真正编码用的配置完全一致。这样就不会出现问的是 720p、编的却是 1080p 这种错位。

Chromium不支持HEVC Main10编码

Main10 是 10 bit 的 HEVC。手机拍的 HDR 视频大多是它,我们后台迟早会收到这种原片。Chromium 完整版和 Chrome 对 hvc1.2.4.L120.B0 这类 Main10 字符串全部返回 false。它们只支持 8 bit 的 Main。WebKit 对 Main10 返回 true。可它编 30 帧的产出和 Main 逐字节相同,都是 67,581 字节。我推测它实际还是按 Main 在编,没有解析码流去核实。Main10 因此没有进探测链。遇到 10 bit 原片,前端只能先降到 8 bit 再编。这个取舍我放到第十章再说。

三、编码器的回退顺序怎么排

前两章的结论落到代码上就是一张有顺序的计划表。每一格写明问哪个 codec、带哪个 hardwareAcceleration。从第一格往下问,第一个返回 true 的就是选中的编码器。

MP4和WebM各走一条探测链

成品最后要封进容器,MP4 里放 HEVC 或 H.264,WebM 里放 VP9 或 AV1。两条链不能混在一起排。产品要的是商品详情页能直接播,默认走 MP4。WebM 链留给内部预览这类我们自己控制播放端的场景。下面是计划表和探测函数。

js 复制代码
const ENCODER_PLAN = {
  mp4: [
    { codec: 'hvc1.1.6.L120.B0', accel: 'prefer-hardware' },
    { codec: 'avc1.640028', accel: 'prefer-hardware' },
    { codec: 'avc1.640028', accel: 'no-preference' },
  ],
  webm: [
    { codec: 'av01.0.08M.08', accel: 'prefer-hardware' },
    { codec: 'vp09.00.40.08', accel: 'no-preference' },
    { codec: 'av01.0.08M.08', accel: 'no-preference' },
  ],
};

function baseCfg(codec, accel) {
  const c = { codec, width: 1920, height: 1080, bitrate: 4_000_000,
              framerate: 30, hardwareAcceleration: accel };
  if (codec.startsWith('avc1')) c.avc = { format: 'avc' };
  if (codec.startsWith('hvc1')) c.hevc = { format: 'hevc' };
  return c;
}

async function probeEncoder(container) {
  const tried = [];
  for (const step of ENCODER_PLAN[container]) {
    let r;
    try { r = await VideoEncoder.isConfigSupported(baseCfg(step.codec, step.accel)); }
    catch (e) { tried.push({ ...step, err: e.name }); continue; }
    tried.push({ ...step, supported: r.supported });
    if (r.supported) return { pick: step, cfg: r.config, tried };
  }
  return { pick: null, tried };
}

代码说明:ENCODER_PLAN 按容器分两组,每组从上往下就是回退顺序。baseCfg 给 H.264 加上 avc: { format: 'avc' },给 HEVC 加上 hevc: { format: 'hevc' }。这两项让编码器把参数集放进 decoderConfig.description,后面封 MP4 时要用。probeEncoder 遇到抛异常的配置会记一笔再接着问,一格出异常不会打断整条链。tried 留下每一格的回答,回归时就靠它看走到了第几格。选中后返回的是浏览器回给我的 r.config,不是我传进去的那份,原因在第五章。下面是在六个构建上跑的原样输出。✔ 是 true,✘ 是 false,hardware 是 prefer-hardware 的缩写。

text 复制代码
Chromium 149 完整版       mp4  HEVC/hardware ✔
Chromium 149 完整版       webm AV1/hardware ✘ → VP9/no-preference ✔
Chrome 155             mp4  HEVC/hardware ✔
Chrome 155             webm AV1/hardware ✘ → VP9/no-preference ✔
Chromium 149 关硬件视频编码   mp4  HEVC/hardware ✘ → H.264/hardware ✘ → H.264/no-preference ✔
Chromium 149 关硬件视频编码   webm AV1/hardware ✘ → VP9/no-preference ✔
Chromium 149 精简版       mp4  HEVC/hardware ✘ → H.264/hardware ✘ → H.264/no-preference ✔
Chromium 149 精简版       webm AV1/hardware ✘ → VP9/no-preference ✔
Firefox 151            mp4  HEVC/hardware ✘ → H.264/hardware ✔
Firefox 151            webm AV1/hardware ✘ → VP9/no-preference ✔
WebKit 26.5            mp4  HEVC/hardware ✔
WebKit 26.5            webm AV1/hardware ✔

HEVC只在硬件可用时才选

MP4 链第一格只问 HEVC 的 prefer-hardware。no-preference 和 prefer-software 都不问。这是第一章那组数决定的。HEVC 的软件路径在 Chromium 上 30 帧要 6 秒多。no-preference 又分不清拿到的是哪一个。干脆只认硬件。代价是 WebKit 那种忽略提示的情况我也照单全收了。它说硬件支持,我就当硬件用。好在它的实测速度确实在硬件那一档。

我一开始在 HEVC 后面还放过一格 prefer-software。想法是软件 HEVC 虽然慢,至少文件更小。后来按 Chromium 完整版 30 帧 6 秒的速度粗算了一下。60 秒 30 fps 是 1,800 帧,线性外推要 6 分钟左右。这只是推算,真实画面的速度可能不同,但量级已经够说明问题了。运营在后台等 6 分钟只为省两三成体积,产品那边不会接受。这一格就删了。

链的最后一格要用no-preference

H.264 我问了两次,第一次带 prefer-hardware,第二次带 no-preference。只问硬件的话,关掉硬件视频编码的 Chromium 和精简版都会走空。这两个构建只要带上 prefer-hardware 问 H.264,回答就是 false。输出里能看到它们一直走到了第三格。VP9、AV1 带硬件提示去问,Chromium 系和 Firefox 一律回 false,只有 WebKit 说 true。WebM 链要是全写成硬件,这五个构建一个都选不中。每条链最后一格我都用 no-preference,让浏览器自己在硬件和软件里挑一个。这一格是兜底。它不追求快,只要求一定能落地。

AV1 放在 WebM 链第一格,是因为一般认为同码率下它比 VP9 小。这一点我这次没测。它只在 prefer-hardware 返回 true 时才会被选中,这次只有 WebKit 满足。按第一章的结论,WebKit 的这个 true 证明不了有硬件。WebKit 选中 AV1 只说明它能编。AV1 软件编码在这几个构建上的速度我没单独测过。我把它放在 VP9 后面当第三格,没有放在第一格的 no-preference 上。这个排法偏保守。等我有了 AV1 软件编码的耗时数据,可能会再调。

六个构建都选到了能用的编码器

回到上面的输出。MP4 链在 Chromium 完整版、Chrome 和 WebKit 上第一格就选中了 HEVC。Firefox 走到第二格,拿到 H.264 硬件。关硬件视频编码的 Chromium 和精简版走到了第三格。它们拿到的是不挑硬件软件的 H.264。WebM 链除了 WebKit 选中 AV1,其余五个都落在 VP9 上。没有一个构建返回 pick: null。这就是我想要的结果:最好的情况拿到 HEVC 硬件,最差的情况也有 H.264 能用,中间不会出现什么都选不中的空档。

四、链尾要不要接一个WASM编码器

MP4 链的最后一格是 H.264。评审时有同事提议在没有硬件 HEVC 的机器上加载一个 WebAssembly 版的 x265,照样导出 H.265。提议听着合理。可浏览器原生的软件 HEVC 已经慢到不能用了。WASM 版的 x265 跑在沙箱里,还要多下载一个编码核心,我不觉得它会更快。我没有自己去编一个 WASM 版的 x265 来测,而是找了一个已经这么做的线上工具,看它在真实片子上要花多少。

H.265原片回退到x265慢了约6.5倍

我用的是图映 ImgIng(imging.cn/)里的视频压缩页面,测... M4,浏览器换成关掉硬件视频编码的 Chromium 149。它在浏览器本地转码,能力面板上会显示 H.265 输出当前走的是「原生硬件」还是「本地 WASM」。素材是一段我自己合成的录屏,H.265 编码、1080p、60 秒。它先按需下载了一个编码核心,文件是 25,328,759 字节,约 24.2 MB。然后在浏览器里用 x265 编完,耗时 55.5 秒。同一台机器走硬件路线是 8.5 秒,慢了约 6.5 倍。成品 5.73 MB。硬件路线是 6.01 MB,WASM 这边反而小一点。画质分数(VMAF)是 97.97 对 98.32。编码核心下载一次就进了缓存,同一页面再跑一次,或者刷新后再跑,都没有重新下载。

H.264原片不走这条回退

更值得看的是它什么时候回退。它只给 H.265 原片开这条 WASM 路。界面说明里写的是保持 H.265、源位深、原分辨率。原片是 H.264 时同样没有硬件 HEVC。这时能力面板把 H.265 输出标成当前设备不可用,这种片子得改选 H.264 的 MP4,或者 WebM。我猜它是这么算的:H.265 原片转成 H.264 体积会涨,值得花 24 MB 下载和几十秒去保住格式。H.264 原片本来就能直接出 H.264,为了省两三成体积多等几十秒不划算。这个账我也对过:同一档位下 H.265 成品的体积比 H.264 低 19% 到 30%,数据来自两段 60 秒的片子。

我们的场景和它的第二种情况一样。按我们后台的经验,运营上传的大多是手机或剪辑软件导出的 H.264。为了 19% 到 30% 的体积,要在部分机器上让人多等几十秒。还要在内网放一个 24 MB 的编码核心,再过一遍安全扫描。我算下来不值。MP4 链的尾巴就停在 H.264,没有接 WASM。将来 H.265 原片的比例上来了,再照它的思路只对 H.265 原片开这条路。

五、选中的编码器怎么确认真能用

isConfigSupported 说 true 只代表能建出编码器。编出来的东西有没有问题,它不管。我在压测清单里加了一项:选中以后真编一段,再在同一个浏览器里解回来,帧数对得上才算过。

编完再解回来十二组全部对上

做法是用选中的配置编 60 帧 1080p 画面,第 0 帧强制关键帧。每个 chunk 都拷出来存着。编码器第一次输出时会附带 decoderConfig。我把它交给 VideoDecoder,再把这 60 个 chunk 依次送进去解码,最后数解出来的帧数。下面是核心部分,画帧函数省略了,它每帧会画 40 个位置变化的色块和一行文字。

js 复制代码
async function roundTrip(cfg, frames = 60) {
  const chunks = [];
  let decoderCfg = null, encErr = null;
  const encoder = new VideoEncoder({
    output: (chunk, meta) => {
      const buf = new Uint8Array(chunk.byteLength);
      chunk.copyTo(buf);
      chunks.push({ type: chunk.type, timestamp: chunk.timestamp,
                    duration: chunk.duration, data: buf });
      if (meta?.decoderConfig && !decoderCfg) decoderCfg = meta.decoderConfig;
    },
    error: e => { encErr = e.name; },
  });
  encoder.configure(cfg);
  const canvas = new OffscreenCanvas(cfg.width, cfg.height);
  const g = canvas.getContext('2d');
  for (let i = 0; i < frames; i++) {
    drawFrame(g, i);
    const vf = new VideoFrame(canvas, { timestamp: i * 33333, duration: 33333 });
    encoder.encode(vf, { keyFrame: i === 0 });
    vf.close();
  }
  await encoder.flush(); encoder.close();

  let decoded = 0;
  const { supported } = await VideoDecoder.isConfigSupported(decoderCfg);
  if (supported) {
    const decoder = new VideoDecoder({ output: f => { decoded++; f.close(); },
                                   error: e => { encErr = encErr ?? e.name; } });
    decoder.configure(decoderCfg);
    for (const c of chunks) decoder.decode(new EncodedVideoChunk(c));
    await decoder.flush(); decoder.close();
  }
  return { chunks: chunks.length, decoded, encErr,
           bytes: chunks.reduce((s, c) => s + c.data.byteLength, 0),
           hasDesc: !!decoderCfg?.description, codec: decoderCfg?.codec };
}

代码说明:chunk.copyTo 把编码结果拷进自己的缓冲区。真实流程里这份字节还要拿去封装和上传。我在测试里也按同样的方式存,不直接把 chunk 对象转手给解码器。decoderCfg 只取第一次附带的那份。里面的 description 就是 HEVC 的 hvcC 或 H.264 的 avcC,封 MP4 时直接写进样本描述。解码前先拿 decoderCfg 再问一次 VideoDecoder.isConfigSupported。编得出来却解不开的组合在这一步就会暴露。frames 默认 60 帧,也就是 1080p30 的两秒,在压测里跑一遍不到一秒。下面是两条链在六个构建上的原样输出。bytes 后面两个数是第一遍和第二遍的产出字节。desc 表示 decoderConfig 里有没有 description。

text 复制代码
Chromium 149 完整版       mp4  HEVC  chunks 60 decoded 60 bytes 284818 / 284818 desc true
Chromium 149 完整版       webm VP9   chunks 60 decoded 60 bytes 594246 / 594246 desc false
Chrome 155             mp4  HEVC  chunks 60 decoded 60 bytes 284818 / 284818 desc true
Chrome 155             webm VP9   chunks 60 decoded 60 bytes 594246 / 594246 desc false
Chromium 149 关硬件视频编码   mp4  H.264 chunks 60 decoded 60 bytes 960870 / 960870 desc true
Chromium 149 关硬件视频编码   webm VP9   chunks 60 decoded 60 bytes 594246 / 594246 desc false
Chromium 149 精简版       mp4  H.264 chunks 60 decoded 60 bytes 961853 / 961853 desc true
Chromium 149 精简版       webm VP9   chunks 60 decoded 60 bytes 571496 / 571496 desc false
Firefox 151            mp4  H.264 chunks 60 decoded 60 bytes 276235 / 276235 desc true
Firefox 151            webm VP9   chunks 60 decoded 60 bytes 329304 / 329304 desc false
WebKit 26.5            mp4  HEVC  chunks 60 decoded 60 bytes 307798 / 308305 desc true
WebKit 26.5            webm AV1   chunks 60 decoded 60 bytes 481113 / 481113 desc false

12 组全部编出 60 个 chunk,也全部解回 60 帧。解出来的画面宽都是 1920。编码和解码都没有报错。HEVC 和 H.264 都带了 description。这次的 VP9 和 AV1 没带,封 WebM 时也用不到它。字节数的差别很大。关硬件视频编码的 Chromium 和精简版走的是不挑硬件软件的 H.264,60 帧产出约 96 万字节。Firefox 选中的是 prefer-hardware 的 H.264,产出只有它们的三分之一不到。这只说明两种编码器的码率控制不同。画面是合成色块,不代表画质高低。

这一步在这次回归里没拦下任何东西。我还是把它留在了压测清单里。第一章那几件事都发生在「问的时候说能、做的时候不一样」的缝里。往返解码是唯一一个直接看结果的检查。将来换一台 Windows 机器、换一张显卡,最先出问题的很可能就是这里。

返回的config不一定原样带回

isConfigSupported 返回的对象里除了 supported 还有一个 config,那是浏览器认下来的那份配置。我原来以为它会把传进去的那份原样还回来,比对之后发现不是。Chromium 系原样带回了所有字段,还补上了 alpha、bitrateMode、latencyMode 这些默认值。Firefox 带回的配置里没有 contentHint。WebKit 的情况又不一样。我传进去的 HEVC 配置里有 hevc: { format: 'hevc' },它带回来的配置里没有这个字段。

好在 WebKit 编出来的 HEVC 仍然带着 description,这个字段缺了也没影响它的产出。可这件事还是让我改了一个习惯。封 MP4 要用的参数我一律从编码器输出时附带的 decoderConfig 里取,不再从带回的 config 里推断。配置本身我还是用带回的那份去 configure,那是浏览器明确认过的组合。

六、网页播放H.265怎么检测和准备备用源

编码端解决了,播放端是另一套接口。商品详情页的播放器是一个 <video> 标签。看视频的人用什么浏览器,我们控制不了。导出 H.265 有个前提:播不了 H.265 的人能自动拿到 H.264。

播放端的测试素材是两个现成的成品,各约 60 秒,内容是同一段 1080p 合成录屏。它们是用上一章那个图映视频压缩在同一台 M4 上导出的,一个 H.265,一个 H.264,档位都是高清均衡。H.265 成品 6,584,709 字节,H.264 成品 9,360,304 字节。我先从两个文件的样本描述里读出各自的 codec 字符串。H.265 是 hvc1.1.6.L120.B0,H.264 是 avc1.640028。后面写 type 就用这两个值。

source不写codecs会选中HEVC然后黑屏

商品详情页原来的播放代码只有一个源。我第一版改成了两个 <source>,H.265 在前,H.264 在后。type 沿用了旧代码里的 video/mp4。浏览器会按顺序挑第一个它认为能播的源。下面是我拿来对照的两种写法,区别只在 type。

html 复制代码
<!-- 写法一:type 只写容器 -->
<video muted playsinline>
  <source src="/hevc.mp4" type="video/mp4">
  <source src="/avc.mp4" type="video/mp4">
</video>

<!-- 写法二:type 带 codecs 参数 -->
<video muted playsinline>
  <source src="/hevc.mp4" type='video/mp4; codecs="hvc1.1.6.L120.B0"'>
  <source src="/avc.mp4" type='video/mp4; codecs="avc1.640028"'>
</video>

代码说明:两段的源顺序完全相同,只有 type 不同。测试时每种写法新建一个 <video>,静音播放 1.5 秒后读三个值。currentSrc 看浏览器最终选了哪个源。videoWidth 大于 0 说明有画面。currentTime 看时间轴走没走。下面是原样输出,noCodecs 是写法一,withCodecs 是写法二,紧跟的数字是 videoWidth。

text 复制代码
Chromium 149 完整版       noCodecs HEVC 1920 t=1.47 | withCodecs HEVC 1920
Chrome 155             noCodecs HEVC 1920 t=1.47 | withCodecs HEVC 1920
Chromium 149 关硬件视频编码   noCodecs HEVC 1920 t=1.47 | withCodecs HEVC 1920
Chromium 149 精简版       noCodecs HEVC 0 t=1.46 | withCodecs H.264 1920
Firefox 151            noCodecs HEVC 1920 t=1.44 | withCodecs HEVC 1920
WebKit 26.5            noCodecs HEVC 1920 t=1.5 | withCodecs HEVC 1920

问题出在精简版 Chromium 的写法一。它选中了 H.265 源。videoWidth 是 0。时间轴照样走到了 1.46 秒。整个过程没有触发 error 事件。播放器看起来在播。进度条也在动。画面却是黑的。测的时候是静音播放,声音有没有在放,我没有确认。这种失败最难查。前端监控里没有报错,用户只会说「视频是黑的」。

原因并不复杂。type="video/mp4" 只告诉浏览器这是 MP4 容器,没说里面是什么编码。精简版能播 MP4,于是认下了第一个源。真开始解码它才发现是 HEVC。它解不了,也没把这当成错误抛出来。

写上codecs参数就会跳到H.264

写法二只是把 codecs 补进了 type。精简版看到 hvc1.1.6.L120.B0 就知道自己不认。它直接跳过第一个源,选中了 H.264。videoWidth 是 1920。其余五个构建两种写法都选中 H.265,都有画面。Firefox 也不例外。它的 WebCodecs 不支持 HEVC 解码,<video> 却能播。这两套接口走的不是同一条解码通路。编码端的探测结果不能拿来推断播放端。

改动只有两个字符串。它有一个前提:codecs 字符串得和文件里真实的编码一致。我们的成品是自己编的,codec 字符串从 decoderConfig.codec 里就能拿到。存进数据库,渲染页面时原样写进 type 就行。运营要是直接上传不经转码的原片,就得在服务端读一遍文件头补上这个值。

两个检测接口都过才算能播

只靠 <source> 的声明式选择还不够。商品详情页有一部分是用 JS 动态换源的,得在脚本里判断。浏览器给了两个接口:HTMLMediaElement.canPlayType 和 MediaSource.isTypeSupported。前者回答 <video> 直接播文件的能力。后者回答用 MSE 喂数据的能力。我原以为两者结论一样。回归时却在 WebKit 上碰到了不一样的情况。

我把 H.265 成品原样复制一份,只把样本标记从 hvc1 改成 hev1,再交给各浏览器去播。在 WebKit 26.5 上,canPlayType 对 hev1.1.6.L120.B0 返回空字符串。MediaSource.isTypeSupported 返回的却是 true。实际播放触发了 error。没有画面。这里说对的是 canPlayType。Chromium 完整版、Chrome、关硬件视频编码的 Chromium 和 Firefox 都是两个接口说能播,实际也能播。精简版两个接口都说不能,实际也没有画面。

这个 hev1 文件是我改标记复制出来的。参数集仍然只在样本描述里,不在码流中。它不等于手机直接录出来的 hev1 文件。WebKit 报错可能和这一点有关,具体原因我没查。不过这个结论已经够我改代码了:两个接口都过才把 H.265 排进候选,任何一个说不行就只用 H.264。编码端只输出 hvc1 也是这个原因。它在这几个浏览器里的答案一致,少一个变数。

加载后再等第一帧做兜底

前面两层都是「事先问」,第一章已经说明,事先问到的答案可能和实际不一致。播放端我又加了一层「事后看」:源加载完以后等第一帧真正渲染出来。一段时间内等不到,就当这个源播不了,换下一个。

js 复制代码
const HEVC_TYPE = 'video/mp4; codecs="hvc1.1.6.L120.B0"';

function hevcLooksPlayable() {
  const probe = document.createElement('video');
  const canPlay = probe.canPlayType(HEVC_TYPE);
  const mse = window.MediaSource ? MediaSource.isTypeSupported(HEVC_TYPE) : false;
  return { canPlay, mse, ok: canPlay !== '' && mse };
}

function firstFrame(video, ms) {
  return new Promise(resolve => {
    let done = false;
    const timer = setTimeout(() => {
      if (!done) { done = true; resolve(video.videoWidth > 0); }
    }, ms);
    if ('requestVideoFrameCallback' in video) {
      video.requestVideoFrameCallback(() => {
        if (!done) { done = true; clearTimeout(timer); resolve(true); }
      });
    }
  });
}

async function playWithFallback(video, sources) {
  const log = [];
  for (const src of sources) {
    video.src = src;
    video.load();
    const loaded = await new Promise(ok => {
      const t = setTimeout(() => ok(false), 8000);
      video.addEventListener('loadeddata', () => { clearTimeout(t); ok(true); }, { once: true });
      video.addEventListener('error', () => { clearTimeout(t); ok(false); }, { once: true });
    });
    if (!loaded) { log.push(src + ':load-fail'); continue; }
    try { await video.play(); } catch (e) {}
    const shown = await firstFrame(video, 1200);
    log.push(src + (shown ? ':frame' : ':no-frame'));
    if (shown) break;
  }
  return log;
}

代码说明:hevcLooksPlayable 就是上一节的两个接口都过,它决定 sources 里要不要放 H.265。firstFrame 优先用 requestVideoFrameCallback。它会在下一帧真正交给合成器时回调。1,200 毫秒内等不到回调,就退回去看 videoWidth。playWithFallback 对每个源先等 loadeddata。8 秒没等到或者触发了 error 就算加载失败。加载成功再播放并等第一帧。实际页面里的正常流程是 sources = hevcLooksPlayable().ok ? [hevc, avc] : [avc]。为了验证兜底本身管不管用,我另做了一组强制先试 H.265 的测试,相当于假设事先检测说错了。下面是原样输出。query 是事先检测的结果。forced 是强制先试 H.265 的那组,@ 后面是从开始等待到出第一帧的毫秒数。

text 复制代码
Chromium 149 完整版       query {"canPlay":"probably","mse":true,"ok":true} HEVC:frame | forced HEVC:frame@64ms
Chrome 155             query {"canPlay":"probably","mse":true,"ok":true} HEVC:frame | forced HEVC:frame@49ms
Chromium 149 关硬件视频编码   query {"canPlay":"probably","mse":true,"ok":true} HEVC:frame | forced HEVC:frame@49ms
Chromium 149 精简版       query {"canPlay":"","mse":false,"ok":false} H.264:frame | forced HEVC:no-frame@1200ms → H.264:frame@55ms
Firefox 151            query {"canPlay":"probably","mse":true,"ok":true} HEVC:frame | forced HEVC:frame@35ms
WebKit 26.5            query {"canPlay":"probably","mse":true,"ok":true} HEVC:frame | forced HEVC:frame@20ms

正常流程下,精简版的事先检测就把 H.265 排除了,直接播 H.264。强制先试 H.265 时它等满了 1,200 毫秒。一帧也没等到。换成 H.264 后 55 毫秒就出了第一帧。另外五个构建的 H.265 在 20 到 64 毫秒内就出了第一帧,离门槛很远。

1,200 毫秒是我拍脑袋定的。它的代价只在失败时出现:事先检测说错的那部分用户,开播会多等一秒多。门槛定得太短也不行。慢机器或者慢网络下第一帧本来就晚,会被误判成播不了,白白降级到 H.264。我目前的打算是先按 1,200 毫秒上线。每次触发降级时把浏览器和耗时打到监控里,攒够数据再调。

七、整套方案怎么做跨浏览器回归

到这一步,方案由四件事组成:编码端的探测链、编完的往返解码、播放端的双接口检测和 codecs 声明、加载后的等帧兜底。浏览器一升级,每一件都可能悄悄变掉。我们组的规矩是上线前必须有一份能重复跑的回归。这一章讲我是怎么做的。

一张表盯住六个浏览器构建

回归用的就是前面几段代码拼成的两个测试页,一个跑编码端,一个跑播放端。脚本依次在六个构建上打开这两页,把结果写成一份 JSON。我盯的是下面这张表里每一格有没有变。

构建 MP4链选中第几格 往返解码 WebM链选中 写法一 写法二 双接口检测 强制H.265兜底
Chromium 149 完整版 HEVC(1) 60/60 VP9(2) HEVC 有画面 HEVC 能播 不触发
Chrome 155 HEVC(1) 60/60 VP9(2) HEVC 有画面 HEVC 能播 不触发
Chromium 149 关硬件视频编码 H.264(3) 60/60 VP9(2) HEVC 有画面 HEVC 能播 不触发
Chromium 149 精简版 H.264(3) 60/60 VP9(2) HEVC 黑屏 H.264 不能播 换到H.264
Firefox 151 H.264(2) 60/60 VP9(2) HEVC 有画面 HEVC 能播 不触发
WebKit 26.5 HEVC(1) 60/60 AV1(1) HEVC 有画面 HEVC 能播 不触发

表里有一格值得多看一眼:关硬件视频编码的 Chromium 在编码端走到了第三格的 H.264。播放端它却能正常播 H.265。关掉的只是硬件编码,硬件解码还在。编码端和播放端是两套能力,不能拿一边的结果推另一边。Firefox 也是这样。

跑两遍只有WebKit的字节变了

同一套回归我跑了两遍。选择结果、往返帧数、播放端的每一格,两遍完全一致,产出字节也几乎全部一致。12 组里有 11 组两遍逐字节相同。只有 WebKit 的 HEVC 不一样。第一遍 307,798 字节,第二遍 308,305 字节。同样的输入,WebKit 的 HEVC 编码器给出的产出不完全确定。原因我不知道。

这件事直接改了回归的写法。我原本想用「产出字节和上次一样」当作编码器没变的信号。这个判断在 Chromium 和 Firefox 上成立。到了 WebKit 上会误报。最后回归里只比较离散的结论:选中第几格、解回几帧、选了哪个源、有没有画面。字节数和耗时只记录,不参与判定。耗时的波动也不小。Chromium 完整版的 MP4 链两遍分别是 685 和 403 毫秒。Firefox 的 WebM 链是 2,542 和 1,913 毫秒。这些耗时还包含了画 60 帧 canvas 的时间,本来就不准。拿来做门槛只会天天误报。

回归用的浏览器要和用户一致

精简版 Chromium 在这次回归里状况最多。它和完整版版本号相同。HEVC 却编、解、播全不支持。持续集成里跑浏览器测试时,拉起来的未必是完整版,也可能是这种精简构建。拿它跑我们的回归,会得出 HEVC 全面不可用的结论。反过来只用完整版跑,又会漏掉写法一黑屏这种问题。

我最后两种都跑,各有各的用处。完整版 Chromium 和 Chrome 代表大多数用户,它们的结果决定默认路径对不对。精简版和关掉硬件视频编码的 Chromium 当作能力缺失的样本,专门验证回退和兜底有没有生效。回归报告里每一行都写明是哪个构建,不写笼统的「Chromium」。

结论同步到组里的时候,产品同事问的第一句是「那默认到底导不导 H.265」。我的回答是导,但商品详情页必须同时存一份 H.264。她又问多存一份要多花多少钱,这个我当场答不上来,记进了待办。

八、测量方法自身的坑

这一轮下来,测量方法本身踩的坑不比被测的东西少。单独记一下。

第一个坑是测试画面。编码端用的是 canvas 画的色块和文字,不是真实视频。编码器在合成画面上的码率分配和真实画面差别很大。文中的字节数只能用来判断换没换编码器。它不能拿来比画质,也不能拿来估算真实片子的体积。往返解码只验证了帧数对得上,解出来的像素和原图差多少我没有比。

第二个坑是耗时里混了别的东西。往返测试的耗时从 configure 算到 flush 完成,包含了画 60 帧 canvas 的时间。WebCodecs 的编码又是异步的。画帧和编码在时间上有重叠,拆不干净。文中引用的「30 帧 165 毫秒对 6 秒」来自另一组测试。那组每帧只画纯色加一行字,画帧开销小得多。两组数不能混着比。

第三个坑是「有没有画面」的判据。播放测试是静音的,videoWidth 和第一帧回调只能说明画面出没出来,说明不了声音。精简版黑屏时声音放没放,这套判据回答不了,我也没开声音核对。WebKit 的 getVideoPlaybackQuality().totalVideoFrames 读数不稳定,播 H.264 也读到过 0。解出的帧数我就没拿来当判据,改用 videoWidth 和 requestVideoFrameCallback。

第四个坑是 hev1 样本。它是用工具把 hvc1 文件原样复制、只改样本标记得到的。参数集仍然在样本描述里,而真正的 hev1 文件参数集通常在码流里。WebKit 对这个文件报错,我只能说它播不了「标成 hev1 的这个文件」。这推不出「WebKit 不能播 hev1」。

第五个坑是「关硬件视频编码」这个条件。它是浏览器启动时把硬件视频编码关掉造出来的。硬件解码还在,它也不等于一台真的没有硬件编码器的电脑。真实的低配机器可能硬件编解码都没有,也可能有编码器但不支持某些 profile。这次回归里这个条件只用来验证回退链能不能走到底。

第六个坑是次数。每一项只跑了一到两遍。选择结果和帧数这类离散结论两遍一致,我比较放心。耗时和 WebKit 的字节这类连续量,两遍就看得到波动。样本太少,没资格算平均值。

九、适用边界与风险提示

数据全部出自同一台 M4 芯片的 Mac(macOS 26.5),2026-10-19 测的,浏览器就是文中列出的六个构建。文中的 WebKit 26.5 只代表 macOS 构建,iPhone 上的 Safari 要另测。换到 Windows,Chrome、Edge 能不能编 HEVC 取决于显卡和系统组件,结果很可能完全不同。安卓浏览器和微信内置浏览器也都没测。文中说某个构建支持 HEVC,后面都应该补一句「在这台机器上」。

编码端的探测链是按 1080p30、4 Mbps 定的。分辨率、帧率或码率变了,isConfigSupported 的答案可能跟着变,探测必须用实际要编的配置去问。Main10 和 HDR 原片不在这套方案的覆盖范围内。AV1 软件编码的速度没测,WebM 链里 AV1 的位置只是一个保守的排法。

播放端的测试素材只有一段合成录屏导出的两个成品。codec 字符串分别是 hvc1.1.6.L120.B0 和 avc1.640028。别的 profile 和 level 没有成品可测,尤其是 Main10。在那些情况下 canPlayType 说能播不代表真能播。1,200 毫秒的等帧门槛没有在慢机器和慢网络上验证过。

风险上有三件事要提醒。第一件是存储,导出 H.265 就要多存一份 H.264,存储成本会上去。省下的是传输体积,不是存储空间。第二件是 codecs 字符串必须和文件真实编码一致。写错了比不写更糟。浏览器会按错误的声明跳过一个本来能播的源。第三件是回归只能保护测过的构建。浏览器每次大版本升级都可能改掉这张表里的某一格。回归要跟着浏览器版本定期跑,不能只在我们自己发版时跑。

十、还没解决的问题

10 bit 原片怎么办?我还没想定。Chromium 系不支持 HEVC Main10 编码,前端只能先降到 8 bit 再编。降位深会损失 HDR 的效果,对画面要求高的品类运营可能不接受。另一条路是 10 bit 原片不在前端转,原样上传交给服务端。可这又和原片不在中间环节落盘的初衷冲突。这件事得找法务一起定。

精简版 Chromium 为什么不支持 HEVC?我没查到原因。关硬件视频编码后为什么连软件 HEVC 也一起没了,我也没查到。我猜和构建时有没有接入系统的视频框架有关,没有证据。它们不影响方案本身,但影响我对回归结果的解释。

hev1 的真实文件还没测过。我们的成品只出 hvc1,可运营直接上传的手机原片里有没有 hev1、比例多少,我手上没有数据。下一步打算在上传接口里统计一下样本标记的分布,再决定要不要专门处理。

在自己的项目里做同样的事,可以先把探测链和往返解码写成一个独立的测试页。在团队常用的几种浏览器构建上各跑一遍。把选中第几格、解回几帧、选了哪个源、有没有画面这四列记下来当基线。播放端先查一遍所有 <source> 的 type 有没有带 codecs,这一处改起来最省事。

参考资料

  • W3C WebCodecs 规范:VideoEncoder、VideoDecoder、VideoEncoderConfig 中 hardwareAcceleration 的定义
  • W3C WebCodecs Codec Registry:AVC、HEVC、VP9、AV1 各自的 codec 字符串格式与 avc、hevc 配置项
  • WHATWG HTML 标准:source 元素的 type 属性与 canPlayType
  • W3C Media Source Extensions:MediaSource.isTypeSupported
  • WICG video-rvfc:HTMLVideoElement.requestVideoFrameCallback
  • ISO/IEC 14496-15:hvc1 与 hev1 样本描述的区别
  • 播放端素材源自一段自制的合成录屏(虚构讲义页面),经图映 ImgIng 视频压缩分别导出为 H.265 与 H.264
相关推荐
大文说跨境1 小时前
亚马逊多账号管理浏览器怎么选?云登/紫鸟/AdsPower 对比
java·前端·自动化
hunter_0761 小时前
iOS 首页复杂嵌套滚动技术方案:头部固定 + 网格滚动 + Tab 吸顶 + 每 Tab 独立下拉刷新
前端
CoderYanger1 小时前
Java EE 进阶:4.Spring Web MVC 入门(第一弹)
java·开发语言·前端·spring·面试·java-ee·mvc
用户2788607285671 小时前
fallback 会盖掉平台提示词:system、空数组和 systemAppend 的差别
前端·后端
peach1 小时前
大模型和传统编程:同为程序,差异不在硬件,在"逻辑从哪来"
前端
ALONE阿龙太原微码1 小时前
微信小程序包体积优化:2MB 到 400KB 的实操记录
前端
掘金挖土1 小时前
前端手摸手跑路之 AI 应用开发(十一)
前端·后端
创了么OPC1 小时前
小程序能收款了,对公户还没开?独立开发者的交付闭环踩坑②
前端·微信小程序
IT_陈寒1 小时前
Vue动态组件缓存的坑你踩过了吗?
前端·人工智能·后端