GIF 承载着微信各种沙雕表情包,看到了可能乐呵一下,但工作上碰到 GIF 资源处理却是一个很棘手的问题。相较于半只腿已经迈进坟墓的 GIF 图片,视频是一个很好的替代载体,对比 GIF 图片有着更小的体积、更好的画质与资源加载速度的提升,但现实终归会来恶心你一下,聊聊这次遇到的问题。
画面左侧 Canvas 是 Cocos Creator 引擎场景渲染区域,业务功能上需要将摄像头画面、视频与 GIF 资源转换成视频媒体流,提供给引擎使用,引擎逐帧捕获媒体流画面将其作画面的背景元素使用。摄像头与视频资源可以很方便的通过 Web API 创建媒体流:
- 摄像头通过 MediaDevices.getUserMedia() 创建
- 视频通过 video 元素 HTMLMediaElement: captureStream() 创建
而 GIF 没法直接创建媒体流资源,需要将 GIF 转换成视频流使用,有两个思路:
- 将 GIF 直接转成视频再通过 HTMLMediaElement: captureStream() 创建视频流
- 解析 GIF 图片逐帧绘制到 Canvas 在通过 HTMLCanvasElement.captureStream() 创建视频流
Canvas 元素一样实现了 HTMLMediaElement 元素的 captureStream 接口,可用于实时捕获 Cavnas 内容。将 GIF 逐帧绘制到 Canvas 上本质还是实现 GIF 的播放,需要考虑到绘制帧率控制与循环播放,不如直接使用视频来的方便,所以问题的核心就变为:如何在 Web 环境将 GIF 转换成视频资源?
简单验证了几种浏览器 GIF 视频转码方案,用 WebCodecs API 转码 GIF 算是一种较优方案,简单做个梳理。
附:
- 仓库地址:https://github.com/kinglisky/gif.to.video
- 在线 demo: https://gif-to-wasm-video.vercel.app (vercel 貌似需要翻墙了)
FFmpeg 是一个开放源代码的自由软件,可以执行音频和视频多种格式的录影、转换、串流功能[7],包含了libavcodec——这是一个用于多个项目中音频和视频的解码器库,以及libavformat——一个音频与视频格式转换库。
视频、转码这两关键字一出用 FFmpeg 就错不了,这里不赘述 FFmpeg 的使用,使用 FFmpeg 将 GIF 转成视频是一件很简单的事:
ffmpeg -i input.gif -row-mt 1 -vf pad=ceil(iw/2)*2:ceil(ih/2)*2 -movflags faststart -pix_fmt yuva420p output.webm
-i input.gif: **-i **表示输入文件,这里的输入文件是 input.gif 。
-row-mt 1:这是一个编码选项,它启用了 FFmpeg 的多线程(row-based multithreading)功能。这可以提高编码速度,特别是在处理高分辨率视频时。
**-vf pad=ceil(iw/2)2:ceil(ih/2)2: **-vf **表示视频过滤器,**pad **是一个视频过滤器,用于调整视频画面的尺寸。**ceil(iw/2)*2:ceil(ih/2)*2 **是两个参数,分别表示新的宽度和高度。**ceil(iw/2)*2 **和 **ceil(ih/2)*2 **的计算方式是将输入视频的宽度和高度除以 2,然后向上取整(**ceil **函数),再乘以 2。这样做的目的是确保视频的宽度和高度都是偶数,这对于某些编码器是必需的。
-movflags faststart: **-movflags **表示设置输出文件的特定标志。**faststart **表示将文件的 moov 原子移动到文件的开始,以便在网络上快速开始播放。
-pix_fmt yuva420p: **-pix_fmt **用于设置像素格式。**yuva420p **是一种支持alpha通道(透明度)的像素格式,这对于保留 GIF 的透明度很重要。此格式使用 YUV 颜色空间,并具有 4:2:0 的色度子采样。
output.webm: 这是输出文件,因为是在浏览器中播放视频,所以转成 WebM 格式。
由于引擎对于捕获的媒体流图片有要求,需要保证图片的宽高为偶数,像素格式为 yuva420p 是为了保留 GIF 图片的透明度信息,如果不需要保留透明度信息使用 yuv420p 即可。
上述 4s 的 GIF 图片转码成 WebM 视频后体积直接由 1.9M 降到了 309K,越大的 GIF 图片转视频效果越明显。顺便测试了下转成 WebP 的体积,也远远小于 GIF 图片。
ffmepg -i input.gif -vf "pad=ceil(iw/2)*2:ceil(ih/2)*2" -movflags faststart -pix_fmt yuva420p -loop 0 output.webp
注:转 WebP 时加了个 **-loop 0 **用于保证动图的循环播放。
FFmpeg GIF 转码不是什么问题,问题是 FFmpeg 没法在浏览器中直接使用,在浏览器直接复用现有工具能力无非是找找对应 WebAssembly 版本的实现。比较好用的 FFmpeg WebAssembly 实现就是 ffmpeg.wasm 了。基于 ffmpeg.wasm 在浏览器环境实现转码也很简单,与直接使用 FFmpeg 区别为 wasm 版本将文件内容写入到虚拟文件系统而已:
import { createFFmpeg } from '@ffmpeg/ffmpeg';
import { fetchArrayBuffer } from './utils';
const corePath = `/ffmpeg/ffmpeg-core.js`;
const workerPath = `/ffmpeg/ffmpeg-core.worker.js`;
const wasmPath = `/ffmpeg/ffmpeg-core.wasm`;
export async function setupFFmpegTranscode(options: {
inputGif: HTMLImageElement;
video: HTMLVideoElement;
}) {
// 初始化 ffmpeg.wasm
const ffmpeg = createFFmpeg({
log: true,
corePath,
workerPath,
wasmPath,
});
await ffmpeg.load();
const inputName = `input.gif`;
const outputName = `output.webm`;
const gifBuffer = await fetchArrayBuffer(options.inputGif.src);
// 写入 GIF 图片 buffer 到虚拟文件系统中
ffmpeg.FS('writeFile', inputName, new Uint8Array(gifBuffer));
await ffmpeg.run(
'-i',
inputName,
'-vf',
// gif 图片的分辨率不满足偶数像素,转码会引起报错,这里做个修正
'pad=ceil(iw/2)*2:ceil(ih/2)*2',
'-movflags',
'faststart',
'-pix_fmt',
'yuva420p',
outputName
);
// 从虚拟文件系统中读取转码视频
const webmUint8Array = ffmpeg.FS('readFile', outputName);
const blob = new Blob([webmUint8Array], { type: 'video/webm' });
const url = URL.createObjectURL(blob);
options.video.src = url;
// 释放资源
ffmpeg.FS('unlink', inputName);
}
createFFmpeg 建议手动指定 corePath、workerPath、wasmPath 路径,未配置依赖模块路径在浏览器环境会默认从 unpkg.com 下载,core、worker、wasm 模块可以在 @ffmpeg/core/dist 中找到,直接将其放在应用静态资源文件目录下即可,例如放在 vite 的 public 目录下。
使用 wasm 遇到 SharedArrayBuffer 的问题需要配置资源请求头。
SharedArrayBuffer is only available to pages that are cross-origin isolated. So you need to host your own server with Cross-Origin-Embedder-Policy: require-corp and Cross-Origin-Opener-Policy: same-origin headers to use ffmpeg.wasm.
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
{
name: 'configure-response-headers',
configureServer: (server) => {
server.middlewares.use((_req, res, next) => {
res.setHeader(
'Cross-Origin-Embedder-Policy',
'require-corp'
);
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
next();
});
},
},
],
});
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Cross-Origin-Embedder-Policy",
"value": "require-corp"
},
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }
]
}
]
}
ffmpeg.wasm 好用也能解决转码的问题,但也有些无法规避的问题:
依赖 wasm 文件过大,资源加载比较耗费时间,当然也可以针对性阉割掉不需要的 ffmpeg 模块自行构建 wasm。主要的问题还是性能的问题,转码同一 GIF 图片,wasm 版本的 FFmpeg 性能差了很多。
5.47s user 0.13s system 305% cpu 1.833 total
上述 4s 的 GIF 使用本地转码与 wasm 转码分别用了 1.833s 与 **10.568s **差距还是很明显的,越长的 GIF 转码差别越明显。
测试 30s 640x360 GIF 图片转码成 WebM 视频对比:
67.04s user 0.70s system 362% cpu 18.699 total
18.699 与 131.988 速度差了 7 倍,FFmpeg 转成 wasm 版本后,由于浏览器的限制无法享受各种原生多线程与 GPU 优化所以效率会差很多。上述测试已经在 ffmpeg.wasm 中开启多线程支持远无法达到本地版本的速度。
那是不是可以考虑摆脱 FFmpeg 依赖,使用浏览器原生能力实现 GIF 到视频转换?
由于 GIF 就是由一系列图片帧构成的,所以 GIF 生成视频思路很清晰
- 解析出 GIF 图片帧
- 合并多帧图片生成视频
找找有没有对应工具库实现即可:
在浏览器环境可用的 GIF 解析库并不多,都和 GIF 本身一样带点腐朽的气息,稍微新一点的库就是 gifuct-js,使用 gifuct 解析 GIF 可以获取到图片帧数据:
import { parseGIF, decompressFrames } from 'gifuct-js'
const promisedGif = fetch(gifURL)
.then(resp => resp.arrayBuffer())
.then(buff => {
const gif = parseGIF(buff)
const frames = decompressFrames(gif, true)
return { gif, frames };
});
由于我们目标视频格式为 WebM 找找对应浏览器环境生成 WebM 的库即可,这里使用的是 webm-writer-js, gifuct 与 webm-writer 生成视频:
import { parseGIF, decompressFrames } from 'gifuct-js';
import WebMWriter from 'webm-writer';
import { fetchArrayBuffer } from './utils';
export async function setupParseGifToWebm(options: {
inputGif: HTMLImageElement;
video: HTMLVideoElement;
}) {
// 加载GIF
const gifBuffer = await fetchArrayBuffer(options.inputGif.src);
const gif = parseGIF(gifBuffer);
const frames = decompressFrames(gif, true);
const videoWriter = new WebMWriter({
quality: 1, // WebM image quality from 0.0 (worst) to 0.99999 (best), 1.00 (VP8L lossless) is not supported
fileWriter: null, // FileWriter in order to stream to a file instead of buffering to memory (optional)
fd: null, // Node.js file handle to write to instead of buffering to memory (optional)
frameDuration: frames[0].delay, // Duration of frames in milliseconds
frameRate: 1000 / frames[0].delay, // Number of frames per second
transparent: true, // True if an alpha channel should be included in the video
alphaQuality: 1, // Allows you to set the quality level of the alpha channel separately.
});
const canvas = document.createElement('canvas');
canvas.width = frames[0].dims.width;
canvas.height = frames[0].dims.height;
for (let frame of frames) {
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const data = new ImageData(
frame.patch,
frame.dims.width,
frame.dims.height
);
ctx.putImageData(data, frame.dims.left, frame.dims.top);
// 写入图片帧
videoWriter.addFrame(canvas);
}
const webMBlob = await videoWriter.complete();
const WebMBlobURL = URL.createObjectURL(webMBlob);
options.video.src = WebMBlobURL;
}
同样是处理 4s 的 GIF 可直接将处理速度将至 1.01s 比 FFmpeg 还快!
使用浏览器原生环境进行 GIF 转码不失为一种更好的解决方案,但受限于工具库实现,还是有些 GIF 转换的问题;例如一些转码帧图片解析的问题,使用 gifuct-js 进行图片帧解析时发现一些 GIF 会发生像素错乱的情况,看实现上是与图片帧的 Alpha 通道解析有关,暂时无解。
那是不是可以摆脱 gif 解析库依赖,使用浏览器的原生 API 实现图片与视频编解码呢?答案是肯定的,WebCodecs API 可解。
https://developer.mozilla.org/zh-CN/docs/Web/API/WebCodecs_API
WebCodecs API 为 web 开发者提供了对视频流的单个帧和音频数据块的底层访问能力。这对于那些需要完全控制媒体处理方式的 web 应用程序非常有用。例如,视频或音频编辑器,以及视频会议。
许多 Web API 在内部都使用了媒体编码器。例如,Web Audio API,以及 WebRTC API。然而,这些 API 不允许开发者处理视频流的单个帧和未合成的编码音频块或视频块。Web 开发者通常使用 WebAssembly 来绕过这一限制,并在浏览器中使用媒体编解码器。然而,这需要额外的带宽来下载浏览器中已经存在的编解码器,降低了性能和能效,并增加了额外的开发成本。 WebCodecs API 提供了对浏览器中已存在的编解码器的访问能力。它可以访问原始视频帧、音频数据块、图像解码器、音频和视频的编码器及解码器。
Webcodecs 提供一系列针对各种媒体资源的编解码 API,我们的需求是实现 GIF 图片的解析与视频生成,对应图片解码与视频编码,需要用到 ImageDecoder 与 VideoEncoder。
https://developer.mozilla.org/en-US/docs/Web/API/VideoEncoder
先看看如何使用 ImageDecoder 对 GIF 图片进行解码:
const fetchImageByteStream = async (gifURL: string) => {
const response = await fetch(gifURL);
return response.body!;
};
export const testImageDecoder = async (gifURL: string) => {
const imageByteStream = await fetchImageByteStream(gifURL);
// 创建 imageDecoder
const imageDecoder = new ImageDecoder({
data: imageByteStream,
type: 'image/gif',
});
// 等待 imageDecoder 初始化完成
await imageDecoder.tracks.ready;
await imageDecoder.completed;
// 解码图片第一帧图片信息
const headFrame = await imageDecoder.decode({ frameIndex: 0 });
// 将解码帧图片绘制到 canvas 上
const { codedWidth, codedHeight } = headFrame.image;
const canvas = document.createElement('canvas');
canvas.width = codedWidth;
canvas.height = codedHeight;
// image 是一个 VideoFrame 对象,可直接绘制在 Canvas 上
const ctx = canvas.getContext('2d')!;
ctx.drawImage(headFrame.image, 0, 0);
const dataURL = canvas.toDataURL();
console.log({ imageDecoder, headFrame, dataURL });
};
使用十分简单,传入 GIF 图片的 ReadableStream 构造解码器,使用 imageDecoder.decode 指定解码的图片帧索引即可。
图片只有一个图片轨道**,**所以 tracks 的轨道只有一个,如果是 VideoDecoder 则可能会有多个视频或音频轨道。**imageDecoder.tracks.selectedTrack.frameCount 表示当前图片总帧数量,静态的图如 JPEG、PNG frameCount **为一,动图如 GIF、WebP、APNG frameCount 则可能大于一。通过指定 frameIndex 则可以指定需要解码的图片帧。
decode 返回的 image 是一个 VideoFrame 对象,其除了可以直接绘制在 Canvas 上还附带了一些图片帧信息:
- codedWidth/codedHeight 图片帧的宽高
- timestamp 当前帧的播放时间戳
- duration 当前帧的持续时间
这些信息在后面生成视频需要用到,**需要注意 timestamp 与 duration 的单位都是纳秒,**单位换算时需要注意!
https://developer.mozilla.org/en-US/docs/Web/API/VideoEncoder
解析出图片帧数据后,剩下的就是将图片帧编码转换成视频数据,VideoEncoder 提供逐帧编码视频的能力,简单的图片帧示例如下:
const testVideoEncoder = async () => {
// 自定视频封装器
const someMuxer = {
addVideoChunk(
chunk: EncodedVideoChunk,
meta: EncodedVideoChunkMetadata
) {
// 使用视频包装器混合视频通道
},
output() {
return new Blob();
},
};
// 图片帧数据
const frames: VideoFrame[] = [];
let frameIndex = 0;
const webmVideoEncoder = new VideoEncoder({
output: async (chunk, meta) => {
// 所有视频帧编码完成
if (frameIndex === frames.length) {
await webmVideoEncoder.flush();
const videoBlob = someMuxer.output();
webmVideoEncoder.close();
console.log(videoBlob);
return;
}
someMuxer.addVideoChunk(chunk, meta);
frameIndex += 1;
},
error: (e) => console.error(e),
});
// 指定编辑最终视频的编码格式
webmVideoEncoder.configure({
codec: 'vp09.00.10.08',
width: 640,
height: 360,
bitrate: 1e6,
});
frames.forEach((videoFrame) => {
webmVideoEncoder.encode(videoFrame, { keyFrame: true });
});
};
VideoEncoder 使用上也比较简单,同画一只马一样:
- 创建视频编码器,output 回调用于输出视频 chunk 包装
- 使用 configure 指定视频编码格式、比特率、帧率等,完整配置可参考 configure#parameters
- 逐帧编码图片的 VideoFrame
- 完成视频编码,关闭编码器
但有很重要的一点,就是需要理解一个概念:**output 输出的只是编码后的视频轨道 chunk,简单的将视频 chunk 拼在一起是无法生成视频文件的。视频文件是一个包装文件格式,需要包装器(muxer)将视频轨道、音频轨道及字幕打包成一个视频才行。而 WebEncoder 只是视频轨道的编码器,WebCodecs API 并未包含包装器的实现,这块需要用户自行实现。**举个例子,你无法通过下面的 chunk 拼接生成视频文件:
const testVideoEncoder = async () => {
const chunks: ArrayBuffer[] = [];
let frameIndex = 0;
const webmVideoEncoder = new VideoEncoder({
output: async (chunk, meta) => {
if (frameIndex === frames.length) {
await webmVideoEncoder.flush();
webmVideoEncoder.close();
// 这样是错误的,不能简单做 chunk 拼接,需要视频包装器的介入才能生成真正视频文件
const videoBlob = new Blob(chunks, { type: 'video/webm; codecs=vp09.00.10.08' });
console.log(videoBlob);
return;
}
const buffer = new ArrayBuffer(chunk.byteLength);
chunk.copyTo(buffer);
chunks.push(buffer);
frameIndex += 1;
},
error: (e) => console.error(e),
});
// 指定编辑最终视频的编码格式
webmVideoEncoder.configure({
codec: 'vp09.00.10.08',
width: 640,
height: 360,
bitrate: 1e6,
});
// 图片帧数据
const frames: VideoFrame[] = [];
frames.forEach((videoFrame) => {
webmVideoEncoder.encode(videoFrame, { keyFrame: true });
});
};
https://developer.mozilla.org/zh-CN/docs/Learn/HTML/Multimedia_and_embedding/Video_and_audio_content
简单讲一下视频编码与视频包装格式的关系:
一个视频通常是由视频、音频、还有字幕构成,一个视频可能会包含:
- 多个视频轨道:通常,一个视频文件只包含一个视频轨道,这个轨道包含了整个视频的图像数据。但是,有些视频文件可能会包含多个视频轨道,每个轨道对应着不同的视频内容或者角度。
- 多个音频轨道:比如分左右声道,环境声
- 多个字幕信息:多语言字幕
而一个视频文件就像一个 ZIP 文件将视频、音频还有字幕等数据按照格式需要要求规范打包成一个了文件,我们常见的所谓视频格式 MP4、WebM、MKV、AVI、FLV 即为视频包装的格式。
不同的视频包装格式,规定了其不同的音视频与字幕编码方式,同种视频包装格式也可能支持多种音视频编码,可以简单看下 MP4 与 WebM 格式所支持的音视频及字幕编码.
MP4 支持多种视频编码、音频编码和字幕编码:
视频编码:
- H.264/AVC
- H.265/HEVC
- MPEG-4 Part 2
- MPEG-2
- VP9
- AV1
音频编码:
- AAC
- MP3
- AC3
- DTS
- Dolby Atmos
字幕编码:
- Timed Text (TTXT)
- SubRip (SRT)
- Advanced Substation Alpha (ASS)
- Scenarist Closed Caption (SCC)
- QuickTime Text (QTXT)
WebM 支持以下视频编码、音频编码和字幕编码:
视频编码:
- VP8
- VP9
音频编码:
- Vorbis
- Opus
字幕编码:
- WebVTT
在使用 WebCodecs 时,其并未包含视频包装器的实现,所以需要我们实现对应视频格式的包装器。
弄清楚了 ImageDecoder 与 VideoEncoder,只需要找一个合适的视频容器包装器将其组合起来即可。
- MP4 视频包装器可使用:mp4box.js
- WebM 视频包装器可使用:webm-muxer
这需要将 GIF 转 WebM,所以选择 webm-muxer 来实现:
import WebMMuxer from 'webm-muxer';
const fetchImageByteStream = async (gifURL: string) => {
const response = await fetch(gifURL);
return response.body!;
};
/**
* 创建图片解码器
* @param imageByteStream
* @returns
*/
const createImageDecoder = async (imageByteStream: ReadableStream<Uint8Array>) => {
const imageDecoder = new ImageDecoder({
data: imageByteStream,
type: 'image/gif',
});
await imageDecoder.tracks.ready;
await imageDecoder.completed;
return imageDecoder;
};
/**
* GIF 转码 WebM
* @param imageDecoder
* @param size
* @returns
*/
const decodeGifMuxWebM = async (imageDecoder: ImageDecoder) => {
const { image: headFrame } = await imageDecoder.decode({ frameIndex: 0 });
// 注意单位时纳秒需要先转成微秒
const frameDuration = headFrame.duration! / 1000;
const frameCount = imageDecoder.tracks.selectedTrack!.frameCount;
return new Promise<string>((resolve) => {
// 创建 WebM 包装器
const webmMuxer = new WebMMuxer({
target: 'buffer',
video: {
codec: 'V_VP9',
width: headFrame.codedWidth,
height: headFrame.codedHeight,
frameRate: 1000 / frameDuration,
alpha: true,
},
});
let frameIndex = 0;
const webmVideoEncoder = new VideoEncoder({
output: async (chunk, meta) => {
webmMuxer.addVideoChunk(chunk, meta);
// 转码结束,生成视频
if (frameIndex === frameCount) {
await webmVideoEncoder.flush();
const webmBuffer = webmMuxer.finalize()!;
const webmBlobURL = URL.createObjectURL(new Blob([webmBuffer]));
resolve(webmBlobURL);
}
},
error: (e) => console.error(e),
});
// 设置视频转码格式
webmVideoEncoder.configure({
codec: 'vp09.00.10.08',
width: headFrame.codedWidth,
height: headFrame.codedHeight,
bitrate: 1e6,
});
// 逐帧编码
const encodeVideoFrame = async () => {
if (frameIndex >= frameCount) return;
const result = await imageDecoder.decode({ frameIndex });
webmVideoEncoder.encode(result.image, { keyFrame: true });
result.image.close();
frameIndex += 1;
await encodeVideoFrame();
};
encodeVideoFrame();
});
};
export async function setupImageDecodeMuxWebm(options: {
inputGif: HTMLImageElement;
video: HTMLVideoElement;
}) {
const image = options.inputGif;
const imageByteStream = await fetchImageByteStream(image.src);
const imageDecoder = await createImageDecoder(imageByteStream);
const webmBlobURL = await decodeGifMuxWebM(imageDecoder, {
width: image.naturalWidth,
height: image.naturalHeight,
});
options.video.src = webmBlobURL;
}
基于 ImageDecoder 实现的图片解析可以很好规避掉 gifuct 的图片解析问题。
可以比较一下最终的转码性能,4s 视频:
- ffmpeg.wasm 转码 12.867s
- gifuct 解析 & webm-writer 生成 0.925s
- ImageDecoder + VideoEncoder 生成 0.4s
- ffmpeg.wasm 转码 147.769s
- gifuct 解析 & webm-writer 生成 18.118s
- ImageDecoder + VideoEncoder 生成 4.138s
WebCodecs 对长 GIF 转码效率有着质的提升!示例在这:https://gif-to-wasm-video.vercel.app
受限与 WebM 视频库的实现 webm-writer-js 与 webm-muxer 其生成视频都无法还原 GIF 的 Alpha 通道信息,这一点不如 ffmpeg.wasm,或许可以试试换成 mp4box.js 包装成 MP4。
- https://github.com/thenickdude/webm-writer-js#transparent-webm-support
- Vanilagy/webm-muxer#9
注意,WebCodes API 算是比较新的 API 使用需要考虑浏览器的兼容性。
https://developer.mozilla.org/en-US/docs/Web/API/ImageDecoder#browser_compatibility
https://developer.mozilla.org/en-US/docs/Web/API/VideoEncoder#browser_compatibility
还有一种方式可以 GIF 转视频的功能,原理很简单,按照 GIF 帧率逐帧在 Canvas 上绘制 GIF 帧图片,将这个绘制过程录制成视频即可。
- 针对 Canvas 可以使用 HTMLCanvasElement.captureStream() 创建实施的视频媒体流。
- 使用 MediaRecorder 即可完成对 Canvas 视频媒体流的录制生成视频
这个方案有个缺点就是生成视频速度与 GIF 时长相关,因为需要按帧率在 Cavans 上依次绘制,越长的 GIF 所花费的时间自然越长,显现如下:
import fixWebmDuration from 'fix-webm-duration';
const fetchImageByteStream = async (gifURL: string) => {
const response = await fetch(gifURL);
return response.body!;
};
const createImageDecoder = async (imageByteStream: ReadableStream<Uint8Array>) => {
const imageDecoder = new ImageDecoder({
data: imageByteStream,
type: 'image/gif',
});
await imageDecoder.tracks.ready;
await imageDecoder.completed;
return imageDecoder;
};
const decodeGifRecordWebM = async (imageDecoder: ImageDecoder) => {
const { image: headFrame } = await imageDecoder.decode({ frameIndex: 0 });
const frameCount = imageDecoder.tracks.selectedTrack!.frameCount;
const frameDuration = headFrame.duration! / 1000;
// 创建绘制画布
const canvas = document.createElement('canvas');
canvas.width = headFrame.codedWidth;
canvas.height = headFrame.codedHeight;
const ctx = canvas.getContext('2d')!;
return new Promise<string>((resolve) => {
// 录制器
let mediaRecorder: MediaRecorder = Object.create(null);
const startRecord = () => {
// 指定视频格式
const defaultMimeType = 'video/webm;codecs=vp9';
// 视频时长
let recordMediaDuration = 0;
// 创建 canvas 的媒体流
const canvasStream = canvas.captureStream(1000 / frameDuration);
// 创建 canvas 录制器
mediaRecorder = new MediaRecorder(canvasStream, {
mimeType: defaultMimeType,
videoBitsPerSecond: 1e6,
});
// mediaRecorder.requestData() 会触发 ondataavailable
mediaRecorder.ondataavailable = async (e) => {
if (!e.data || !e.data.size) return;
// 获取录制数据
const videoBlob = new Blob([e.data], { type: defaultMimeType });
// 修复 webm 录制丢失 duration
const webmBlob = await fixWebmDuration(videoBlob, recordMediaDuration, {
logger: false,
});
resolve(URL.createObjectURL(webmBlob));
};
// 浏览器录制 webm 视频会有丢失视频时长信息的情况,需要通过 fixWebmDuration 修复
const startTime = Date.now();
mediaRecorder.onstop = () => {
recordMediaDuration = Date.now() - startTime;
};
// 开始录制
mediaRecorder.start();
};
const stopRecord = async () => {
mediaRecorder.requestData();
mediaRecorder.stop();
};
let frameIndex = 0;
const drawVideoFrame = async () => {
// 绘制完成,停止录制
if (frameIndex >= frameCount) {
stopRecord();
return;
}
const result = await imageDecoder.decode({ frameIndex });
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(result.image, 0, 0);
result.image.close();
// 设置 canvas 绘制间隔
const frameDuration = result.image.duration! / 1000;
setTimeout(() => {
frameIndex += 1;
drawVideoFrame();
}, frameDuration);
};
// 开始绘制录制
drawVideoFrame();
startRecord();
});
};
export async function setupImageDecodeRecordWebm(options: {
inputGif: HTMLImageElement;
video: HTMLVideoElement;
}) {
const image = options.inputGif;
const imageByteStream = await fetchImageByteStream(image.src);
const imageDecoder = await createImageDecoder(imageByteStream);
const webmBlobURL = await decodeGifRecordWebM(imageDecoder);
options.video.src = webmBlobURL;
}
- 每次 mediaRecorder.requestData 都会触发 dataavailable 事件
- 浏览器录制的 webM 视频普遍有丢失 duration 问题,需要用 fix-webm-duration 修正
- 生成视频并不精确,因为播放速率是由 setTimeout 控制,其并不精确,转码时间所需时间会比 GIF 长度多一些
总的来说基于浏览器环境实现的 GIF 转码效率都是优于 ffmpeg.wasm 的,但还是有些问题需要考虑:
- 视频和音频内容 - 学习 Web 开发 | MDN
- WebCodecs API - Web APIs | MDN
- ImageDecoder - Web APIs | MDN
- VideoEncoder - Web APIs | MDN
- https://github.com/ffmpegwasm/ffmpeg.wasm
- GitHub - thenickdude/webm-writer-js: JavaScript-based WebM video encoder for Google Chrome
- GitHub - Vanilagy/webm-muxer: WebM multiplexer in pure TypeScript with support for WebCodecs API, video & audio.
- WebCodecs > VideoEncoder: Create video from encoded frames