[React] GIF 的編輯(裁剪、旋轉、放大縮小)

[React] GIF 的編輯(裁剪、旋轉、放大縮小)

需求

  1. 圖片格式為 GIF
  2. 能執行裁剪、旋轉、放大縮小的操作

需要使用到的工具

  1. gifuct-js 用來解析 GIF 檔為 frame
  2. gif.js 用於重新生成 GIF
  3. 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()
  })
}
guest

0 評論
最舊
最新 最多投票
內聯回饋
查看全部評論