首页/文章/javascript

使用 WebCodec 在浏览器解码视频、抓帧截图、转GIF

2024-05-06
428915 分钟
...

在浏览器内将视频转GIF,无需上传,速度飞快

✨ 已上线 lyonbot.github.io/video-to-gi…

使用 ffmpeg 生成高质量GIF,全是技巧和折磨

上周五写一个材料的时候,需要插入几个 GIF 演示动图。一开始我熟练的打开终端,使用了我这段祖传 ffmpeg 生成高质量 GIF 的命令:

ffmpeg -i scr1.mov -vf "fps=10,scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=255[p];[s1][p]paletteuse" -loop 0 output.gif

但是转换过程不太顺利,因为输出的 GIF 文件太大了,我不得不去精细地调整参数,把各种骚操作都尝试一遍,包括但不限于:

  • 剪辑视频:在 -i 前插入想要的时间范围 -ss 00:00:00 -to 00:00:05
  • 调整分辨率:scale=320:-1 表示宽度 320,高度自动
  • 加速视频:setpts=PTS/2 两倍速
  • 调整颜色数量:酌情降低 max_colors=255
  • 去掉 GIF Dither:屏幕录像一般不需要颜色抖动,可以在 paletteuse 后面加参数 dither=none
  • 分两步骤生成:先对原视频剪辑、加速、缩放,生成临时的视频文件后,再走 GIF 转换逻辑。这样 ffmpeg 会快很多。
  • and more

技巧很多,但是操作起来要来回在终端、文件管理器之间切换。虽然看起来很 Geek,但是真的浪费时间。

做成在线工具

其实很早之前就想把 ffmpeg 的指令生成过程,做成在线工具,并且可以在浏览器运行。一方面,可视化配置参数很舒服;一方面省了安装 ffmpeg 和复制路径的麻烦事。

但是对生成 GIF 这件事而言,知道那么多魔法咒语,好像没啥用,还显得非常 nerd。

所以上周五,我决定单独把 视频转GIF 这件事情,直接做到网页中,并且将那些精细的调整功能、观察文件大小的功能,都。

于是就有了这个 🔨 video-to-gif 的小工具

但是方案落地也走了挺多弯路。

GIF 的生成包括解码原视频、生成色板、编码为GIF三部分。

一开始是直接让 ffmpeg.wasm 一步到位解码视频+编码 GIF ,奈何 wasm 解码速度太差。后来改成 video 抓画面,再到改成 WebCodec 超高速解码。然而解码也分很多步骤:解析 mp4 文件、配置解码器、抽取要解码的数据块、进行解码、根据坐标变换修正图片。

而 GIF 编码这部分,虽然 ffmpeg.wasm 已经很快,但是其加载出错的时候还得有 JS 兜底方案。一开始用的 gif.js ,因为其未暴露颜色数量控制能力,改成了 modern-gif

使用 WebCodec 解码视频、抓帧

WebCodec 是一个很新的浏览器 API(Chrome >94,2021年9月的版本;Safari 16.4,2023年3月版本)。 它允许开发者直接解码视频帧,而不需要依赖任何外部库。并且还能得到系统级的加速,减少对浏览器内存的占用。

但是使用这个东西,并不是直接把 mp4 文件丢进去就可以了。完整的过程包括:解析 mp4 文件、配置解码器、抽取要解码的数据块、进行解码、根据坐标变换修正图片。

实现的过程我参考了这些项目:

并且还有使用 mp4box.js 的 FileReader 工具 来分析文件。

使用 mp4box.js 加载文件

视频文件 mp4/mov 只是一个“容器”,它存储了视频、音轨、文件描述等信息。我们首先需要的是将里面的“解码器参数”和“视频数据”抽取出来。

安装 mp4box.js 库: pnpm install mp4box

然后使用下面代码,可以解析文件

import MP4Box from 'mp4box'


async function decodeMp4File(file) {
  const mp4boxInputFile = MP4Box.createFile();

  
  const mp4InfoPromise = new Promise((resolve, reject) => {
    mp4boxInputFile.onReady = resolve
    mp4boxInputFile.onError = reject
  })

  
  const buffer = await file.arrayBuffer()
  buffer.fileStart = 0  
  mp4boxInputFile.appendBuffer(buffer)

  
  const info = await mp4InfoPromise;
  const track = info.videoTracks[0];
  let description; 
  {
    const DataStream = MP4Box.DataStream; 
    const trak = mp4boxInputFile.getTrackById(track.id);
    for (const entry of trak.mdia.minf.stbl.stsd.entries) {    
      if (entry.avcC || entry.hvcC) {  
        const stream = new DataStream(undefined, 0, DataStream.BIG_ENDIAN);
        if (entry.avcC) entry.avcC.write(stream);
        else entry.hvcC.write(stream);

        description = new Uint8Array(stream.buffer, 8); 
        break;
      }
    }

    if (!description) throw new Error('no video description')
  }
}

初始化解码器

以上我们得到了这个 MP4 文件的编码参数,接下来就可以请出 VideoDecoder 解码器来获取画面了。

和视频回放不同,解码器会以最快的速度,将画面帧 VideoFrame 对象 一个个的取出来给你。你要做的就是在一个回调函数里处理它,并且得及时 .close() 释放画面帧的资源。

初始化解码器,需要这些信息:

  • output 回调函数,告知如何处理解码得到的画面
  • error 出错时候的回调函数
  • 从 mp4 里解析得到的解码器信息(尤其那个 description 很重要)
const decoder = new VideoDecoder({
  output(inputFrame) {
    

    
    const timestamp = inputFrame.timestamp / 1e6  
    const duration = inputFrame.duration / 1e6  

    
    
    ctx.clearRect(0, 0, canvas.width, canvas.height)
    ctx.drawImage(inputFrame, 0, 0)
    const data = ctx.getImageData(0, 0, canvas.width, canvas.height)
    

    
    inputFrame.close();
  },
  error(error) {
    
  },
});


decoder.configure({
  codec: track.codec,
  codedWidth: track.track_width,
  codedHeight: track.track_height,
  description,
  hardwareAcceleration: 'prefer-hardware',
});

抽取视频流,解码画面

视频画面数据,是由一大串的数据包构成的。具体的拆包方式、码流格式等标准很复杂,要去理解就太麻烦了。 幸运的是, mp4box.js 已经提供了实用的方法,帮你找出那些数据包,你要做的就是,将数据包转发给 decoder 去解码画面。

我们只需要配置一个回调函数,然后让 mp4box 开始监测视频轨道的数据,一旦解析到数据包就触发回调,去解码画面。

mp4boxInputFile.onSamples = (track_id, unused, samples) => {
  let i = 0

  
  for (; i < samples.length; i++) {
    const sample = samples[i]
    const timestamp = sample.cts / sample.timescale 

    
    decoder.decode(new EncodedVideoChunk({
      type: sample.is_sync ? "key" : "delta", 
      timestamp: timestamp * 1e6, 
      duration: sample.duration * 1e6 / sample.timescale,
      data: sample.data,
    }))

    
    await decoder.flush();
    decoder.close();
  }
}


mp4boxInputFile.setExtractionOptions(track.id, null, { nbSamples: Infinity });
mp4boxInputFile.start();

onSamples 这个回调会在 mp4box.js 拆解完文件后调用。具体数据包的格式可以参考 他们的文档。 里面有一个 unused 参数,没啥用,等同于 setExtractionOptions 时候传入的第二个参数,也就是 null。

跳过前后,只解码一个时间范围

有时候我们只需要某一些时间范围的画面,不需要解码整个文件。此时可以考虑跳过部分 sample。

值得一提的是,虽然 mp4box 文件对象提供了 seek(time, useRap=false) 的方法,但是在我们这个场景下没有用。 它返回的是你需要额外下载的文件数据,从哪里开始。如果你在做按需分块加载视频的功能,那么可能有点用(还记得一开始那个 buffer.fileStartappendBuffer 么?)。然而我们这个场景下,可以读取到 整个文件,因此没有追加加载文件数据的情况。

由于我们是整个文件、整个视频轨的解码,因此 onSamples 能够拿到所有视频数据包。所以我们只能在回调里,根据规则跳过一些 samples。

前面代码的循环中,我们读取到了数据包的画面时间戳 timestamp,因此可以在利用 break 语句,跳过结束时间之后的画面解码。

但是,跳过开始时间之前的画面,还额外有一些技巧。以下是我使用的代码:


const start = 2.1;

while (i < samples.length && ((samples[i].cts + samples[i].duration) / samples[i].timescale < start)) i++;
while (i > 0 && !samples[i].is_sync) i--;

正如我们在播放器里拖动进度条,不一定能完美跳到那个时间点一样。为了压缩数据,一个视频帧画面,可能依赖了前面N帧的画面,因此要解码这一帧,我们得从它依赖的最前面的帧开始解码。

上面代码在找到当前时刻的数据包后,还回溯找到最近的关键帧,作为解码的起点。通过 is_sync 可以确定是否是关键帧,我们从那里开始解码就行了。

但是这就结束了么?

说到这里,还得补充一个细节,就是 数据包不一定是按照画面呈现先后排列的! 比如坂本同学反复横跳,对视频压缩算法而言,它可能会倾向于把相似的画面放在一起压缩 —— 只需要播放给你时调整画面顺序就OK了。

第一行为呈现顺序,第二行为画面解码顺序(相似的画面放一起,压缩率更高)

考虑到 mp4box 应该是按照解码顺序来切割的,因此我们用 break 语句提前结束解码,可能导致一些画面的丢失。一个调整方式大概是

  1. for 循环之前准备一个标识变量

    let waitingForEndKeyFrame = false;
    
  2. decode 方法之后,补充检查这个标志,解码最后一个必须的关键帧之后再退出循环:

    if (timestamp > end) waitingForEndKeyFrame = true;
    if (waitingForEndKeyFrame && sample.is_sync) break;
    

更多关于呈现顺序的事情,可以去搜索 PTS(呈现时间)、DTS(解码时间)这些信息,以及 mp4 标准里关于 ctts、dtts 等的定义。 由于 mp4box 已经有处理过,所以我们可以用 sample.cts / sample.timescale 直接获得时间戳。

幸运的是,W3C 标准已经要求 VideoDecoder 的 output 回调,按照画面帧的呈现顺序依次调用(标准文档)。 这帮我们省了很多事情,直接一帧帧地使用就行了。

(翻译自W3C)VideoDecoder 标准要求输出画面帧地时候,按照呈现顺序输出。因此在对接一些编解码器同时,用户代理(也就是浏览器)还需要额外对数据做排序,以符合呈现顺序。

解决手机录像,画面颠倒的问题

虽然对电脑录屏等场景,视频轨道的 width、height 等同于视频的宽高。但是,在手机录像等场景中,有可能视频的 width 其实对应的是画面高度,而且还上下颠倒了。

在视频轨道信息 track 里,有一个 matrix 字段,它存储的是一个 仿射变换的变换矩阵(的前6个参数)。 将解码结果画到画布时,你得根据这个矩阵做个变换,才能看到正确的画面。

在 HTML Canvas Context 中,有一个 setTransform(matrix) 的方法,可以在绘制画面之前调用,从而将原始坐标 (x,y)(x,y) 变换到正确的坐标 (x′,y′)(x', y') 上绘制。

[x′y′1]=[acebdf001][xy1]\begin{bmatrix}x' \\ y' \\ 1\end{bmatrix} = \begin{bmatrix}a & c & e \\ b & d & f \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix}x \\ y \\ 1\end{bmatrix}

然而 track.matrix 存储的是32位定点数(也就是要除以65536才是真实的值),而且其存储顺序和 HTML Canvas 的矩阵参数顺序不一样。它存储的顺序是 [a, c, e, b, d, f]

因此回到最开始解析 description 的地方,我们像这样可以计算 Canvas 绘图时使用的 变换矩阵:

let transform: DOMMatrix

const [a, c, e, b, d, f] = Array.from(track.matrix.slice(0, 6), (x: number) => x / 65536) 
const matrix = new DOMMatrix([a, b, c, d, e, f])


const videoWidth = Math.abs(a * track.track_width + c * track.track_height)
const videoHeight = Math.abs(b * track.track_width + d * track.track_height)






transform = new DOMMatrix()
  .translate(resizeWidth / 2, resizeHeight / 2)                   
  .scale(canvas.width / videoWidth, canvas.height / videoHeight)  
  .multiply(matrix.inverse())                                     
  .translate(-track.track_width / 2, -track.track_height / 2)     

以及在绘制画面帧的时候,像这样处理:

ctx.resetTransform()
ctx.clearRect(0, 0, canvas.width, canvas.height)

ctx.setTransform(transform)
ctx.drawImage(inputFrame, 0, 0)

这样子,就可以解决手机拍摄的视频可能发生旋转的问题了。

我猜测可能是手机为了减少画面数据处理的繁琐性,选择了直接将传感器的数据送给视频编码器,然后再通过 MP4 的 matrix 让播放器负责变换画面。 只能说 mp4 是一个神奇的格式。前面提到的 ctts 之类的东西,还能用于手机的慢动作视频变速播放(很多手机调整变速范围时,不需要重新编码,就是靠 mp4 的这些神奇功能实现的)。 虽然很灵活,但也苦了播放器的开发者了。

最后

说到这里,复杂的工作就算是结束了。有了 WebCodec 极速解码画面,结合一开始祖传的 ffmpeg 指令,就能快速转换视频为高质量 GIF 了。具体的说,就是将画面原始 RGBA 二进制数据导入到 ffmpeg,让它用特定参数去识别,就可以用了:

ffmpeg \
	-f rawvideo -pix_fmt rgba \
	-video_size ${canvas.width}x${canvas.height} \
	-framerate ${自己看着办} \
	-i temp.bin \
	

实测发现 wasm 做 GIF 的调色板生成、编码压缩,速度是比 JavaScript 的库更快的。而且还能用 ffmpeg 的各种神秘小参数。

以防万一没看到前面链接,这里再贴一次:

最最后,碎碎念一段 Signal

这次使用的是 SolidJS、UnoCSS、Vite 的方案,开发体验还是很不错的。

曾经还想写一篇文章聊聊 SolidJS Signal 的概念多牛皮,但是正逢上 Vue 党狂吹 Signal 的那段时间,外加自己比较懒(重点),所以就鸽了。

我感觉Signal的重点,不是响应式、Proxy之类实现方式的问题(s.js 和 SolidJS 通过 Accessor 函数来读写变量虽然丑,但也不是致命点),而是「计算作用域」的概念。 基于「计算可以嵌套」的特性,把一个UI组件/类实例…视为一个域,然后里面的属性/子元素,又划分为更细粒度的子域,从而实现真·超细粒度响应式更新。

配合趁手的写法(无论是 Vue 的模版语言编译器,还是 SolidJS 魔改的 JSX,还是 createEffect 嵌套 createEffect 的写法……都可以), 让子计算的创建足够简单容易,就能体会到 Signal 这种范式有多么潇洒。

也许很适合低代码?(emmm,虽然我们团队的低代码项目走向已逐渐离谱,想那么多已经没落地的机会了)(又是一个之前想写但是鸽了的东西)

再说吧,咱最近主打的就是一个 P人属性大爆发。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
javascript2026-02-24
navigator.sendBeacon全指南
在前端开发中,埋点系统是必不可少的一环。我们经常需要在用户 关闭页面 、 刷新 或 跳转路由 时,向服务器发送最后一条统计数据(比如用户停留时长、页面跳出率)。 但这看似简单的需求,在实现时却危机四伏:请求发不出去?页面跳转卡顿?今天我们就来聊聊这个问题的终极解决方案 —— 。 一、 痛点与传统方案的挣扎 场景还原 当用户点击关闭按钮时,浏览器会触发生命周期事件( 或 )。如果我们直接使用普通的异步 AJAX ( 或 ) 发送请求,浏览...
学习
javascript2025-11-02
理解浏览器事件系统,从用户点击到事件对象的完整旅程
深入理解浏览器事件系统:从用户点击到事件对象的完整旅程 “当我点击页面按钮时,背后发生了什么?为什么回调函数能收到一个包含丰富信息的event对象?今天,让我们一起揭开浏览器事件系统的神秘面纱。” 一个令人困惑的现象 作为前端开发者,我们每天都在写这样的代码: 这段代码如此熟悉,以至于我们很少停下来思考:​ ​这个 对象到底从哪里来?它为什么能知道点击的精确坐标?为什么能识别是哪个元素被点击了?​ ​ 更神奇的是,当我们手动创建事件时:...
学习
javascript2025-10-01
实现大文件上传全流程详解
在日常开发中,大文件上传是个绕不开的坎——动辄几百 MB 甚至 GB 级的文件,直接上传不仅容易超时,还会让用户体验大打折扣。最近我用 Vue+Express 实现了一套完整的大文件上传方案,支持分片上传、断点续传、秒传和手动中。 一、先看效果:我们要实现什么? 先上核心功能清单,确保大家明确目标,知道我们要解决哪些实际问题: 大文件分片上传 :将文件切成固定大小的小片段分批上传,避免单次请求超时 秒传 :服务器已存在完整文件时,直接返...
学习
javascript2025-09-18
JavaScript 的多线程能力:Worker
如果你写过一些计算量稍大的 JavaScript 代码,比如图像处理、大量数据排序或者复杂的算法,你几乎肯定遇到过浏览器“卡死”的现象。点击页面没反应,动画也停了,就像整个世界都静止了。 这就是主线程被阻塞的典型后果。因为主线程既要负责执行 JavaScript,又要负责渲染页面、响应用户操作,一旦它被繁重的计算任务占满,就无暇顾及其他,用户体验便直线下降。 这个问题的根源,正是“主线程是单线程的”。那么,如何解决呢? 答案很简单:把这...
学习面试
javascript2025-09-15
一张 8K 海报差点把首屏拖垮
你给后台管理系统加了一个「企业风采」模块,运营同学一口气上传了 200 张 8K 宣传海报。首屏直接飙到 8.3 s,LCP 红得发紫。 老板一句「能不能像朋友圈那样滑到哪看到哪?」——于是你把懒加载重新翻出来折腾了一轮。 解决方案:三条技术路线,你全踩了一遍 1\. 最偷懒:原生 一行代码就能跑,浏览器帮你搞定。 🔍 关键决策点 2020 年后现代浏览器全覆盖,IE 全军覆没。 必须写死 ,否则 CLS 会抖成 PPT。 适用场景...
学习
javascript2025-09-10
🚀 Web Worker让你的应用丝滑
🌟 引言 在日常的前端开发中,你是否遇到过这样的困扰: 大数据处理时页面卡死 :处理几万条数据时,页面直接卡成PPT,用户点击毫无反应 复杂计算阻塞UI :图片处理、数据分析等计算密集型任务让整个应用假死 文件上传/下载卡顿 :大文件操作时,其他功能完全无法使用 实时数据处理性能差 :WebSocket接收大量数据时,页面渲染严重滞后 今天分享6个Web Worker的核心技巧,让你的应用告别卡顿,用户体验丝滑如德芙! 💡 核心技巧...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录