需求
- 圖片格式為 GIF
- 能執行裁剪、旋轉、放大縮小的操作
需要使用到的工具
- gifuct-js 用來解析 GIF 檔為 frame
- gif.js 用於重新生成 GIF
- react-easy-crop 用於進行圖片編輯操作(裁剪、旋轉、放大縮小)
上傳圖片
URL.createObjectURL(file)
會先為本地上傳的圖片建立一個 URL,可以用來暫時讀取這個 blob 資源
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
const imageUrl = URL.createObjectURL(file)
openEditModal(imageUrl)
}
// ...
}
const fileInputRef = useRef<HTMLInputElement>(null)
// ...
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept="image/*"
onChange={handleFileSelect}
/>
<button onClick={() => fileInputRef.current?.click()} ... />
圖片裁剪
裁剪可以直接使用現成的組件 react-easy-crop

import Cropper from 'react-easy-crop'
interface CroppedArea {
x: number
y: number
width: number
height: number
}
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [rotation, setRotation] = useState(0)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<CroppedArea | null>(null)
// zoom: 1~3, step: 0.1
const onZoomIn = () => {
if (zoom < 3) setZoom(zoom + 0.1)
}
const onZoomOut = () => {
if (zoom > 1) setZoom(zoom - 0.1)
}
// 每次向右翻轉90度
const onRotate = () => {
setRotation(rotation + 90)
}
const onCropComplete = (_: CroppedArea, croppedAreaPixels: CroppedArea) => {
setCroppedAreaPixels(croppedAreaPixels)
}
<Cropper
image={imageUrl}
crop={crop}
zoom={zoom}
rotation={rotation}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
處理編輯後的結果
編輯完成後送出
const handleApply = async () => {
setIsLoading(true)
try {
const editedImageBlob = await cropGif({ imageUrl, croppedAreaPixels, rotation })
onApply(editedImageBlob)
} catch (error) {
console.error('Error processing image:', error)
} finally {
setIsLoading(false)
}
}
工具函式
建立一個 canvas 和其繪圖上下文(ctx),willReadFrequently: true
是為了提高 .getImageData()
的效能
const createCanvas = (width: number, height: number) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d', { willReadFrequently: true })!
canvas.width = width
canvas.height = height
return { canvas, ctx }
}
把原始圖片或 canvas 旋轉指定角度
const rotateCanvas = (
source: HTMLCanvasElement | HTMLImageElement,
rotation: number,
sourceWidth: number,
sourceHeight: number,
) => {
// 如果旋轉 90 或 270 度,要交換寬高
const isSwap = Math.abs(rotation % 180) === 90
const { canvas: rotatedCanvas, ctx: rotatedCtx } = createCanvas(
isSwap ? sourceHeight : sourceWidth,
isSwap ? sourceWidth : sourceHeight,
)
// 透過 ctx.translate() + ctx.rotate() 做平移和旋轉
rotatedCtx.save()
rotatedCtx.translate(rotatedCanvas.width / 2, rotatedCanvas.height / 2)
rotatedCtx.rotate((rotation * Math.PI) / 180)
rotatedCtx.drawImage(source, -sourceWidth / 2, -sourceHeight / 2)
rotatedCtx.restore()
return rotatedCanvas
}
處理 GIF 裁切 + 旋轉
disposalType
值(Number) | 名稱 | 行為說明 |
---|---|---|
0 | 未指定 | 保留當前畫面(通常等同於 1) |
1 | 不處理(Keep) | 顯示完後保留這一幀內容,直接疊上下一幀。 |
2 | 清除畫面區域 | 將此幀的繪製區塊清除為背景色。 |
3 | 回復前一幀(Restore to previous) | 顯示完後回到上一幀的畫面狀態。 |
4~7 | 保留給未來用途 | 幾乎不會用到 |
export const cropGif = async ({
imageUrl,
croppedAreaPixels,
rotation,
}: CropGifProps): Promise<Blob> => {
const gifBlob = await fetchImageBlob(imageUrl)
const arrayBuffer = await gifBlob.arrayBuffer()
// 使用 gifuct-js 拆解每個 frame, 每幀有自己的位移、透明度等資訊
const gifData = await parseGIF(arrayBuffer)
const frames = decompressFrames(gifData, true)
// canvas: 用來繪製裁剪後的畫面
const { canvas, ctx } = createCanvas(croppedAreaPixels.width, croppedAreaPixels.height)
// accCanvas: 當作累積每幀的畫布(因為 GIF 有疊加效果)
const { canvas: accCanvas, ctx: accCtx } = createCanvas(frames[0].dims.width, frames[0].dims.height)
let prevImageData: ImageData | null = null
const processedFrames = frames.map(frame => {
// 預存目前畫面,以便之後還原
if (frame.disposalType === 3) {
prevImageData = accCtx.getImageData(0, 0, accCanvas.width, accCanvas.height)
}
// 清除這幀畫的部分
if (frame.disposalType === 2) {
accCtx.clearRect(
frame.dims.left,
frame.dims.top,
frame.dims.width,
frame.dims.height,
)
}
const baseImageData = accCtx.getImageData(0, 0, accCanvas.width, accCanvas.height)
// frame.patch: 是這一幀的 像素資料(RGBA),但只包含這幀實際要畫的區塊
const patch = new Uint8ClampedArray(frame.patch)
// frame.dims: 這一幀的左上角位置 (left, top) 和尺寸 (width, height),代表這幀應該畫在整個畫布的哪個位置
for (let y = 0; y < frame.dims.height; y++) {
for (let x = 0; x < frame.dims.width; x++) {
const patchIdx = (y * frame.dims.width + x) * 4
const globalX = frame.dims.left + x
const globalY = frame.dims.top + y
const baseIdx = (globalY * accCanvas.width + globalX) * 4
if (patch[patchIdx + 3] !== 0) {
baseImageData.data.set(patch.slice(patchIdx, patchIdx + 4), baseIdx)
}
}
}
// 把一幀 GIF 的「部分畫面」依照位置疊加到整張畫布上,還原動畫邏輯
accCtx.putImageData(baseImageData, 0, 0)
const rotatedCanvas = rotateCanvas(accCanvas, rotation, accCanvas.width, accCanvas.height)
// 清空暫時的 canvas 並裁切出指定區域
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(
rotatedCanvas,
croppedAreaPixels.x,
croppedAreaPixels.y,
croppedAreaPixels.width,
croppedAreaPixels.height,
0,
0,
croppedAreaPixels.width,
croppedAreaPixels.height,
)
// 把目前幀的畫面資料取出來,準備給 GIF.js 用
const processedImageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
if (frame.disposalType === 3 && prevImageData) {
accCtx.putImageData(prevImageData, 0, 0)
prevImageData = null
}
return { data: processedImageData, delay: frame.delay }
})
// 把前面處理過的每一幀(旋轉+裁切後的)組合成新的 GIF 動畫檔
const gif = new GIF({
workers: navigator.hardwareConcurrency || 2,
quality: 10,
width: canvas.width,
height: canvas.height,
})
processedFrames.forEach(frame => gif.addFrame(frame.data, { delay: frame.delay }))
return new Promise((resolve) => {
gif.on('finished', resolve)
gif.render()
})
}
完整程式碼
import { parseGIF, decompressFrames } from 'gifuct-js'
import GIF from 'gif.js'
interface CroppedArea {
x: number
y: number
width: number
height: number
}
interface CropGifProps {
imageUrl: string
croppedAreaPixels: {
x: number
y: number
width: number
height: number
}
rotation: number
}
const createCanvas = (width: number, height: number) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d', { willReadFrequently: true })!
canvas.width = width
canvas.height = height
return { canvas, ctx }
}
const rotateCanvas = (
source: HTMLCanvasElement | HTMLImageElement,
rotation: number,
sourceWidth: number,
sourceHeight: number,
) => {
const isSwap = Math.abs(rotation % 180) === 90
const { canvas: rotatedCanvas, ctx: rotatedCtx } = createCanvas(
isSwap ? sourceHeight : sourceWidth,
isSwap ? sourceWidth : sourceHeight,
)
rotatedCtx.save()
rotatedCtx.translate(rotatedCanvas.width / 2, rotatedCanvas.height / 2)
rotatedCtx.rotate((rotation * Math.PI) / 180)
rotatedCtx.drawImage(source, -sourceWidth / 2, -sourceHeight / 2)
rotatedCtx.restore()
return rotatedCanvas
}
export const cropGif = async ({
imageUrl,
croppedAreaPixels,
rotation,
}: CropGifProps): Promise<Blob> => {
const gifBlob = await fetchImageBlob(imageUrl)
const arrayBuffer = await gifBlob.arrayBuffer()
const gifData = await parseGIF(arrayBuffer)
const frames = decompressFrames(gifData, true)
const { canvas, ctx } = createCanvas(croppedAreaPixels.width, croppedAreaPixels.height)
const { canvas: accCanvas, ctx: accCtx } = createCanvas(frames[0].dims.width, frames[0].dims.height)
let prevImageData: ImageData | null = null
const processedFrames = frames.map(frame => {
if (frame.disposalType === 3) {
prevImageData = accCtx.getImageData(0, 0, accCanvas.width, accCanvas.height)
}
if (frame.disposalType === 2) {
accCtx.clearRect(
frame.dims.left,
frame.dims.top,
frame.dims.width,
frame.dims.height,
)
}
const baseImageData = accCtx.getImageData(0, 0, accCanvas.width, accCanvas.height)
const patch = new Uint8ClampedArray(frame.patch)
for (let y = 0; y < frame.dims.height; y++) {
for (let x = 0; x < frame.dims.width; x++) {
const patchIdx = (y * frame.dims.width + x) * 4
const globalX = frame.dims.left + x
const globalY = frame.dims.top + y
const baseIdx = (globalY * accCanvas.width + globalX) * 4
if (patch[patchIdx + 3] !== 0) {
baseImageData.data.set(patch.slice(patchIdx, patchIdx + 4), baseIdx)
}
}
}
accCtx.putImageData(baseImageData, 0, 0)
const rotatedCanvas = rotateCanvas(accCanvas, rotation, accCanvas.width, accCanvas.height)
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(
rotatedCanvas,
croppedAreaPixels.x,
croppedAreaPixels.y,
croppedAreaPixels.width,
croppedAreaPixels.height,
0,
0,
croppedAreaPixels.width,
croppedAreaPixels.height,
)
const processedImageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
if (frame.disposalType === 3 && prevImageData) {
accCtx.putImageData(prevImageData, 0, 0)
prevImageData = null
}
return { data: processedImageData, delay: frame.delay }
})
const gif = new GIF({
workers: navigator.hardwareConcurrency || 2,
quality: 10,
width: canvas.width,
height: canvas.height,
})
processedFrames.forEach(frame => gif.addFrame(frame.data, { delay: frame.delay }))
return new Promise((resolve) => {
gif.on('finished', resolve)
gif.render()
})
}