前言
GIF
我相信大家都不會陌生,由于它被廣泛的支持,所以我們一般用它來做一些簡單的動畫效果。一般就是設計師弄好了之后,把文件發給我們。然后我們就直接這樣使用:
<img src="xxx.gif"/>
這樣就能播放一個 GIF
,不知道大家有沒有思考過一個問題?在播放 GIF
的時候,可以把這個 GIF
暫停/停止播放嗎?可以把這個 GIF
倍速播放嗎?聽起來是很離譜的需求,你為啥不直接給我一個視頻呢?
anyway,那我們今天就一起來嘗試實現一下上述的一些功能在 GIF
的實現。
ImageDecoder
首先先來了解一下 WebCodecs API ,它旨在瀏覽器提供原生的音視頻處理能力。 WebCodecs API
的核心包含兩大部分:編碼器( Encoder
)和解碼器( Decoder
)。編碼器把原始的媒體數據(如音頻或視頻)進行編碼,轉換成特定的文件格式(如 mp3
或 mp4
等)。解碼器則是進行逆向操作,把特定格式的文件解碼為原始的媒體數據。
使用 WebCodecs API
,我們可以對原始媒體數據進行更細粒度的操作,如進行合成、剪輯等,然后把操作后的數據進行編碼,保存成新的媒體文件。
不過需要注意的是 WebCodecs API
還屬于實驗性階段,并未在所有瀏覽器中支持。
ImageDecoder 是 WebCodecs API
的一部分,它可以讓我們解碼圖片,獲取到圖片的元數據。
假設我們這樣導入一個 GIF
:
import Flower from "./flower.gif";
導入之后,通過 ImageDecoder
解碼 GIF
獲取到每一幀的關鍵信息:如圖像信息、每一幀的持續時長等。獲取到這些信息之后,再通過 canvas+定時器
把這個 GIF
在畫圖中繪制出來,下面一起來看看具體操作:
useEffect(() => {const run = async () => {const res = await fetch(Flower);const clone = res.clone();const blob = await res.blob();const { width, height } = await getDimensions(blob);canvas.current.width = width;canvas.current.height = height;offscreenCanvas.current = new OffscreenCanvas(width, height);//@ts-ignoredecodeImage(clone.body);};run();}, []);
順帶說一下 html
結構,十分簡單:
<div className="container"><div>原始gif</div>{init && <img src={Flower} />}<div>canvas渲染的gif</div><canvas ref={canvas} /></div>
首先通過 fetch
獲取到 GIF
圖的元數據,這里有一個 getDimensions
方法,它是獲取 GIF
圖的原始寬高信息的:
const getDimensions = (blob): any => {return new Promise((resolve) => {const img = document.createElement("img");img.addEventListener("load", (e) => {URL.revokeObjectURL(blob);return resolve({ width: img.naturalWidth, height: img.naturalHeight });});img.src = URL.createObjectURL(blob);});};
獲取到寬高信息后,對 canvas
元素賦值寬高,并且定義一個離屏 canvas
對象,后續用它來操作像素,同時也對他賦值寬高。
然后就可以調用 decodeImage
來解碼 GIF
:
const decodeImage = async (imageByteStream) => {//@ts-ignoreimageDecoder.current = new ImageDecoder({data: imageByteStream,type: "image/gif",});const imageFrame = await imageDecoder.current.decode({frameIndex: imageIndex.current, // imageIndex從0開始});const track = imageDecoder.current.tracks.selectedTrack;await renderImage(imageFrame, track);};
這里的 imageIndex
從 0
開始, imageFrame
表示第 imageIndex
幀的圖像信息,拿到圖像信息和軌道之后,就可以把圖像渲染出來。
const renderImage = async (imageFrame, track) => {const offscreenCtx = offscreenCanvas.current.getContext("2d");offscreenCtx.drawImage(imageFrame.image, 0, 0);const temp = offscreenCtx.getImageData(0,0,offscreenCanvas.current.width,offscreenCanvas.current.height);const ctx = canvas.current.getContext("2d");ctx.putImageData(temp, 0, 0);setInit(true);if (track.frameCount === 1) {return;}if (imageIndex.current + 1 >= track.frameCount) {imageIndex.current = 0;}const nextImageFrame = await imageDecoder.current.decode({frameIndex: ++imageIndex.current,});window.setTimeout(() => {renderImage(nextImageFrame, track);}, (imageFrame.image.duration / 1000) * factor.current);};
從 imageFrame.image
中就可以獲取到當前幀的圖像信息,然后就可以把它繪制到畫布中。其中 track.frameCount
表示當前 GIF
有多少幀,當到達最后一幀時,將 imageIndex
歸零,實現循環播放。
其中 factor.current
表示倍速,后續會提到,這里先默認看作 1
。
一起來看看效果:
暫停/播放
既然我們能把 GIF
的圖像信息每一幀都提取出來放到 canvas
中重新繪制成一個動圖,那么實現暫停/播放功能也不是什么難事了。
下面的展示我會把原 GIF
去掉,只留下我們用 canvas
繪制的動圖。
用一個按鈕表示暫停開始狀態:
const [playing, setPlaying] = useState(true);const playingRef = useRef(true);useEffect(() => {playingRef.current = playing;}, [playing]);// ....<div><Button onClick={() => setPlaying((prev) => !prev)}>{playing ? "暫停" : "開始"}</Button></div>
然后在 renderImage
方法中,如果當前狀態是暫停,則停止渲染。
const renderImage = async (imageFrame, track) => {const offscreenCtx = offscreenCanvas.current.getContext("2d");offscreenCtx.drawImage(imageFrame.image, 0, 0);const temp = offscreenCtx.getImageData(0,0,offscreenCanvas.current.width,offscreenCanvas.current.height);const ctx = canvas.current.getContext("2d");// 根據狀態判斷是否渲染if (playingRef.current) {ctx.putImageData(temp, 0, 0);}setInit(true);if (track.frameCount === 1) {return;}if (imageIndex.current + 1 >= track.frameCount) {imageIndex.current = 0;}const nextImageFrame = await imageDecoder.current.decode({frameIndex: playingRef.current? ++imageIndex.current: imageIndex.current, // 根據狀態判斷是否要渲染下一幀});window.setTimeout(() => {renderImage(nextImageFrame, track);}, (imageFrame.image.duration / 1000) * factor.current);};
一起來看看效果:
倍速
再來回顧一下渲染下一幀的邏輯:
window.setTimeout(() => {renderImage(nextImageFrame, track);}, (imageFrame.image.duration / 1000) * factor.current);
這里獲取到每一幀原本的持續時長之后,乘以一個 factor
,我們只要改變這個 factor
,就可以實現各種倍速。
這里用一個下拉框,實現 0.5/1/2
倍速:
const [speed, setSpeed] = useState(1);const factor = useRef(1);useEffect(() => {factor.current = speed;}, [speed]);// ....<Selectvalue={speed}onChange={(e) => setSpeed(e)}options={[{label: "0.5X",value: 2,},{label: "1X",value: 1,},{label: "2X",value: 0.5,},]}></Select>
一起來看看效果:
濾鏡
既然我們是拿到每一幀圖像的信息到 canvas
中進行渲染的,那么我們也就可以對 canvas
做一些濾鏡操作。以常見的灰度濾鏡、黑白濾鏡為例:
const [filter, setFilter] = useState(0);const filterRef = useRef(0);<Selectvalue={filter}onChange={(e) => setFilter(e)}options={[{label: "無濾鏡",value: 0,},{label: "灰度",value: 1,},{label: "黑白",value: 2,},]}></Select>
同樣的,用一個下拉框來表示所選擇的濾鏡,然后我們實現一個函數,對 temp
進行像素變換
像素變換如下,更多的像素變換可以參考我的這篇文章——這10種圖像濾鏡是否讓你想起一位故人
const doFilter = (imageData) => {if (filterRef.current === 1) {const data = imageData.data;const threshold = 128;for (let i = 0; i < data.length; i += 4) {const gray = (data[i] + data[i + 1] + data[i + 2]) / 3;const binaryValue = gray < threshold ? 0 : 255;data[i] = binaryValue;data[i + 1] = binaryValue;data[i + 2] = binaryValue;}}if (filterRef.current === 2) {const data = imageData.data;for (let i = 0; i < data.length; i += 4) {const red = data[i];const green = data[i + 1];const blue = data[i + 2];const gray = 0.299 * red + 0.587 * green + 0.114 * blue;data[i] = gray;data[i + 1] = gray;data[i + 2] = gray;}}return imageData;};
一起來看看效果:
最后
以上就是本文的全部內容,主要介紹了 ImageDecoder
解碼 GIF
圖像之后,再利用 canvas
重新進行渲染。期間也就也可以加上暫停、倍速、濾鏡的功能。
如果你覺得有意思的話,點點關注點點贊吧~