JavaScript實現(xiàn)視頻轉(zhuǎn)GIF的示例代碼
前言
之前使用過FFMpeg來做視頻轉(zhuǎn)GIF,但是FFMpeg的體積還是太大了,前端加載一般要10M左右。后面發(fā)現(xiàn)了 Webcodecs 這個新的 Web API ,它提供了解碼視頻的能力。所以就沿著這個方向去使勁,也是實現(xiàn)了一個純前端的在線的視頻轉(zhuǎn) GIF 功能。
本文一共會按照以下三步去實現(xiàn)一個視頻轉(zhuǎn) GIF 功能:
- 解封裝視頻,從視頻文件中獲取視頻幀
- 解碼視頻幀,獲取幀圖像信息
- 拼裝幀圖像信息,生成
GIF
視頻解封裝
視頻解封裝是從一個包含多種媒體數(shù)據(jù)的容器中提取出特定類型的媒體數(shù)據(jù)的過程。通過解封裝,可以從容器中分離出視頻軌道、音頻軌道等各種媒體數(shù)據(jù)。
它的主要目的是獲取原始的音頻、視頻等媒體數(shù)據(jù),以便進(jìn)行后續(xù)的處理,比如播放、編輯或者轉(zhuǎn)碼。解封裝后的數(shù)據(jù)可以根據(jù)需要被送入相應(yīng)的解碼器進(jìn)行解碼。
這里使用到的是 mp4box.js 這個庫去解碼上傳的視頻文件,以獲取視頻軌道信息。首先定義一個獲取文件 Buffer 的方法,我這里是上傳文件然后去獲取 ArrayBuffer :
const getFileArrayBuffer = (file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
resolve(e.target.result);
};
reader.readAsArrayBuffer(file);
});
};
然后調(diào)用 mp4box 去解封裝視頻的軌道信息
const data = await getFileArrayBuffer(file);
data.fileStart = 0;
const getVideoInfo = async (data) => {
return new Promise((resolve, rejcet) => {
const mp4boxfile = MP4Box.createFile();
mp4boxfile.onError = function (e) {
console.log("e", e);
rejcet(e);
};
mp4boxfile.onReady = (info) => {
resolve({
mp4boxfile,
info,
});
};
mp4boxfile.appendBuffer(data);
mp4boxfile.flush();
});
};
const { mp4boxfile, info } = await getVideoInfo(data);
console.log(info);
const videoTrack = info.tracks.find((track) => track.type === "video");
const timescale = videoTrack.timescale;
const duration = videoTrack.duration / timescale;
const nbSamples = videoTrack.nb_samples;
const fps = Math.round(nbSamples / duration);
以下大概是一個視頻軌道的字段:

這里如果我們想獲取視頻的時長,幀率等信息,需要做一些小小的轉(zhuǎn)換。nb_samples是視頻總幀數(shù); movie_timescale 我理解是視頻的一個采樣單位,拿 movie_duration/movie_timescale 才是我們視頻的長度,這里大概是 18.2 秒。幀率就是總幀數(shù)/視頻時長,這里大概是 15FPS 。
獲取視頻幀
獲取視頻幀這里用到的是一個較新的 Web API , VideoDecoder 和 EncodedVideoChunk ,它們的API兼容性如下:


VideoDecoder是一個較新的API,它可以讓我們通過JS在瀏覽器中解碼視頻EncodedVideoChunk是指表示視頻編碼數(shù)據(jù)塊對象,用于表示已經(jīng)編碼的視頻數(shù)據(jù),這些數(shù)據(jù)可以通過網(wǎng)絡(luò)傳輸并在接收端進(jìn)行解碼。
我們利用VideoDecoder將mp4box解封裝后得到的軌道信息進(jìn)一步解析成一幀一幀的圖片,為我們后續(xù)的合成GIF做準(zhǔn)備。
const videoFrames = [];
const initDecoder = () => {
const getExtradata = () => {
// 生成VideoDecoder.configure需要的description信息
const entry = mp4boxfile.moov.traks[0].mdia.minf.stbl.stsd.entries[0];
const box = entry.avcC ?? entry.hvcC ?? entry.vpcC;
if (box != null) {
const stream = new MP4Box.DataStream(
undefined,
0,
MP4Box.DataStream.BIG_ENDIAN
);
box.write(stream);
// slice()方法的作用是移除moov box的header信息
return new Uint8Array(stream.buffer.slice(8));
}
};
// 初始化 VideoDecoder
const decoder = new VideoDecoder({
output: (videoFrame) => {
createImageBitmap(videoFrame).then((img) => {
videoFrames.push({
img,
duration: videoFrame.duration,
timestamp: videoFrame.timestamp,
});
videoFrame.close();
if (videoFrames.length === nbSamples) {
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const img = videoFrames[0].img;
console.log(img);
ctx.drawImage(img, 0, 0, img.width, img.height);
}
});
},
error: (err) => {
console.error("videoDecoder錯誤:", err);
},
});
const config = {
codec: videoTrack.codec,
codedWidth: videoTrack.video.width,
codedHeight: videoTrack.video.height,
description: getExtradata(),
};
decoder.configure(config);
return decoder;
};
let decoder = initDecoder();
const getChunkList = () => {
const track = mp4boxfile.getTrackById(videoTrack.id);
console.log(track.samples.length);
const chunkList = track.samples.map((_, index) => {
const sample = mp4boxfile.getSample(track, index);
const type = sample.is_sync ? "key" : "delta";
const chunk = new EncodedVideoChunk({
type,
timestamp: sample.cts,
duration: sample.duration,
data: sample.data,
});
return chunk;
});
return chunkList;
};
const chunkList = getChunkList();
chunkList.forEach((chunk) => decoder.decode(chunk));
大概解釋一下上面的代碼:
initDecoder中我們初始化了一個VideoDecoder,它接收到數(shù)據(jù)之后就會響應(yīng)output回調(diào),在output回調(diào)中我們把videoFrame轉(zhuǎn)成了一個ImageBitmap對象(即幀圖像信息),然后收集起來。- 然后我們實現(xiàn)了一個
getChunkList函數(shù)來收集解封裝后的視頻數(shù)據(jù),把所有的chunk收集起來供decoder調(diào)用 - 兩者配合起來,我們就可以拿到這段視頻軌道的所有視頻幀圖像
合成GIF
當(dāng)所有的視頻幀處理完成之后,docoder會觸發(fā)一個flush方法,我們可以在這里進(jìn)行GIF的合成。這里我GIF合成使用的庫是gif.js。實現(xiàn)代碼如下:
decoder.flush().then(() => {
const width = videoFrames[0].img.width;
const height = videoFrames[0].img.height;
var gif = new GIF({
workers: 4,
quality: 10,
width,
height,
});
console.log("開始");
videoFrames
.map((frame) => frame.img)
.forEach((imageBitmap) => {
var offscreenCanvas = new OffscreenCanvas(
imageBitmap.width,
imageBitmap.height
);
var offscreenContext = offscreenCanvas.getContext("2d");
offscreenContext.drawImage(imageBitmap, 0, 0);
var imageData = offscreenContext.getImageData(
0,
0,
imageBitmap.width,
imageBitmap.height
);
gif.addFrame(imageData, { delay: 1000 / fps });
});
gif.on("finished", function (blob) {
var link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "animated.gif";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
// 開始生成 GIF
gif.render();
});
簡單解釋一下上面的代碼:
- 由于生成的
imageBitmap并不能直接喂給gif.addFrame調(diào)用,所以這里使用了一個離屏Canvas去轉(zhuǎn)換一下 gif.addFrame(imageData, { delay: 1000 / fps });這里的delay參數(shù)就是每一幀圖片持續(xù)的時長,默認(rèn)是500ms,我們用1秒除于幀率,來換算出實際的時長- 合成完畢之后,通過一個
a標(biāo)簽把GIF下載下來
通過這樣的方式,一個1M多的MP4生成出來的GIF居然有30M,我滴媽呀。雖然質(zhì)量跟流暢度還是挺好的,但這個體積也太嚇人了。
所以我們最好對GIF進(jìn)行一個壓縮,這個場景下壓縮主要是減少合成GIF的幀圖像以及壓縮每一幀圖像的體積。
所以接下來我們會做如下操作:
new GIF的畫布寬高縮小一半- 逢兩幀抽取一幀,每一幀的延時變成原來的
2倍 - 對每一幀進(jìn)行壓縮
完整代碼如下:
decoder.flush().then(() => {
const width = videoFrames[0].img.width / 2;
const height = videoFrames[0].img.height / 2;
const gif = new GIF({
workers: 4,
quality: 10,
width,
height,
});
const halfFrames = videoFrames.filter((frame, index) => index % 2 === 0);
halfFrames
.map((frame) => frame.img)
.forEach((imageBitmap) => {
const originalWidth = imageBitmap.width;
const originalHeight = imageBitmap.height;
var offscreenCanvas = new OffscreenCanvas(
imageBitmap.width / 2,
imageBitmap.height / 2
);
var offscreenContext = offscreenCanvas.getContext("2d");
// 在新Canvas上繪制原始ImageBitmap,并縮小一半
offscreenContext.drawImage(
imageBitmap,
0,
0,
originalWidth,
originalHeight,
0,
0,
offscreenCanvas.width,
offscreenCanvas.height
);
const compressedImageData = offscreenContext.getImageData(
0,
0,
offscreenCanvas.width,
offscreenCanvas.height
);
gif.addFrame(compressedImageData, { delay: (1000 / fps) * 2 });
});
gif.on("finished", function (blob) {
// 創(chuàng)建一個虛擬的下載鏈接并觸發(fā)點擊
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "animated.gif";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
// 開始生成 GIF
gif.render();
});
下面是生成的GIF圖,大小在5M左右

最后
decoder.configure(config);中有一個description字段,搞了好久都沒搞定,最后還是拜讀了張鑫旭大佬的文章,才把這個demo跑通。
跑通這個demo的時候是十分開心的,前端能做的事情越來越多了,而且Webcodecs解碼的速度非常快,希望等到它更加完善后,會鋪開更多的使用場景。
參考
以上就是JavaScript實現(xiàn)視頻轉(zhuǎn)GIF的示例代碼的詳細(xì)內(nèi)容,更多關(guān)于JavaScript視頻轉(zhuǎn)GIF的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
javascript的創(chuàng)建多行字符串的7種方法
多行字符串的作用是用來提高源代碼的可讀性.尤其是當(dāng)你處理預(yù)定義好的較長字符串時,把這種字符串分成多行書寫更有助于提高代碼的可讀性和可維護(hù)性.在一些語言中,多行字符串還可以用來做代碼注釋. 大部分動態(tài)腳本語言都支持多行字符串,比如Python, Ruby, PHP. 但Javascript呢?2014-04-04

