diff --git a/examples/gif-decode/index.html b/examples/gif-decode/index.html new file mode 100644 index 0000000..86e6e6c --- /dev/null +++ b/examples/gif-decode/index.html @@ -0,0 +1,207 @@ + + + + + + jSquash GIF Decoder Example + + + +

@jsquash/gif Decoder

+

Rust gif crate compiled to WebAssembly via wasm-pack

+ +
+

Drop a GIF here or click to browse

+ +
+ +
+ + + + + + + + diff --git a/examples/gif-decode/main.js b/examples/gif-decode/main.js new file mode 100644 index 0000000..32b7e18 --- /dev/null +++ b/examples/gif-decode/main.js @@ -0,0 +1,178 @@ +import init, { decode, decodeAnimated, isAnimated } from '../../packages/gif/codec/pkg/squoosh_gif.js'; + +const statusEl = document.getElementById('status'); +const singleSection = document.getElementById('single-frame-section'); +const animSection = document.getElementById('animation-section'); +const singleCanvas = document.getElementById('single-canvas'); +const animCanvas = document.getElementById('anim-canvas'); +const singleInfo = document.getElementById('single-info'); +const frameInfo = document.getElementById('frame-info'); +const playBtn = document.getElementById('play-btn'); +const filmstrip = document.getElementById('filmstrip'); +const dropZone = document.getElementById('drop-zone'); +const fileInput = dropZone.querySelector('input[type="file"]'); + +let animationId = null; +let playing = false; +let currentFrameIndex = 0; +let initialized = false; + +function setStatus(msg, isError = false) { + statusEl.style.display = 'block'; + statusEl.textContent = msg; + statusEl.classList.toggle('error', isError); +} + +function drawImageData(canvas, imageData) { + canvas.width = imageData.width; + canvas.height = imageData.height; + const ctx = canvas.getContext('2d'); + ctx.putImageData(imageData, 0, 0); +} + +function highlightFilmstripFrame(index) { + filmstrip.querySelectorAll('.filmstrip-frame').forEach((el, i) => { + el.classList.toggle('active', i === index); + }); +} + +function playAnimation(canvas, frames) { + playing = true; + playBtn.textContent = 'Pause'; + + function renderFrame() { + const frame = frames[currentFrameIndex]; + drawImageData(canvas, frame.imageData); + frameInfo.textContent = `Frame ${currentFrameIndex + 1} / ${frames.length} \u2014 ${frame.duration}ms`; + highlightFilmstripFrame(currentFrameIndex); + currentFrameIndex = (currentFrameIndex + 1) % frames.length; + animationId = setTimeout(renderFrame, frame.duration); + } + + renderFrame(); +} + +function stopAnimation() { + if (animationId !== null) { + clearTimeout(animationId); + animationId = null; + } + playing = false; + playBtn.textContent = 'Play'; +} + +function buildFilmstrip(frames) { + filmstrip.innerHTML = ''; + const thumbHeight = 80; + + frames.forEach((frame, i) => { + const aspect = frame.imageData.width / frame.imageData.height; + const thumbWidth = Math.round(thumbHeight * aspect); + + const wrapper = document.createElement('div'); + wrapper.className = 'filmstrip-frame'; + + const canvas = document.createElement('canvas'); + canvas.width = thumbWidth; + canvas.height = thumbHeight; + const ctx = canvas.getContext('2d'); + + // Draw scaled-down frame + const tmp = document.createElement('canvas'); + tmp.width = frame.imageData.width; + tmp.height = frame.imageData.height; + tmp.getContext('2d').putImageData(frame.imageData, 0, 0); + ctx.drawImage(tmp, 0, 0, thumbWidth, thumbHeight); + + const label = document.createElement('div'); + label.className = 'frame-label'; + label.textContent = `#${i + 1} ${frame.duration}ms`; + + wrapper.appendChild(canvas); + wrapper.appendChild(label); + + wrapper.addEventListener('click', () => { + stopAnimation(); + currentFrameIndex = i; + drawImageData(animCanvas, frame.imageData); + frameInfo.textContent = `Frame ${i + 1} / ${frames.length} \u2014 ${frame.duration}ms`; + highlightFilmstripFrame(i); + }); + + filmstrip.appendChild(wrapper); + }); +} + +playBtn.addEventListener('click', () => { + if (playing) { + stopAnimation(); + } else if (playBtn._frames) { + playAnimation(animCanvas, playBtn._frames); + } +}); + +async function handleFile(file) { + if (!file || !file.type.startsWith('image/gif')) return; + + stopAnimation(); + currentFrameIndex = 0; + singleSection.hidden = true; + animSection.hidden = true; + filmstrip.innerHTML = ''; + + if (!initialized) { + setStatus('Initializing WASM...'); + await init(); + initialized = true; + } + + setStatus('Decoding...'); + + try { + const buffer = await file.arrayBuffer(); + const data = new Uint8Array(buffer); + + // Single frame decode + const t0 = performance.now(); + const imageData = decode(data); + const decodeTime = (performance.now() - t0).toFixed(1); + drawImageData(singleCanvas, imageData); + singleInfo.textContent = `${imageData.width} \u00d7 ${imageData.height} \u2014 decoded in ${decodeTime}ms`; + singleSection.hidden = false; + + // Check if animated + const animated = isAnimated(data); + + if (animated) { + const t1 = performance.now(); + const frames = decodeAnimated(data); + const animTime = (performance.now() - t1).toFixed(1); + setStatus(`Animated GIF: ${frames.length} frames, ${imageData.width}\u00d7${imageData.height} \u2014 decoded in ${animTime}ms`); + playBtn._frames = frames; + buildFilmstrip(frames); + playAnimation(animCanvas, frames); + animSection.hidden = false; + } else { + setStatus(`Static GIF: ${imageData.width}\u00d7${imageData.height} \u2014 decoded in ${decodeTime}ms`); + } + } catch (err) { + setStatus(`Error: ${err.message}`, true); + console.error(err); + } +} + +// File input +dropZone.addEventListener('click', () => fileInput.click()); +fileInput.addEventListener('change', (e) => handleFile(e.target.files[0])); + +// Drag and drop +dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('dragover'); +}); +dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); +dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('dragover'); + handleFile(e.dataTransfer.files[0]); +}); diff --git a/packages/gif/codec/Cargo.toml b/packages/gif/codec/Cargo.toml new file mode 100644 index 0000000..75adf81 --- /dev/null +++ b/packages/gif/codec/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "squoosh-gif" +version = "0.1.0" +edition = "2021" +publish = false + +[package.metadata.wasm-pack.profile.release] +wasm-opt = ["-O", "--no-validation"] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +gif = "0.13" +wasm-bindgen = "0.2.89" +js-sys = "0.3.66" +web-sys = { version = "0.3.66", features = ["ImageData"] } + +[profile.release] +lto = true +opt-level = "s" diff --git a/packages/gif/codec/package.json b/packages/gif/codec/package.json new file mode 100644 index 0000000..ba7d916 --- /dev/null +++ b/packages/gif/codec/package.json @@ -0,0 +1,7 @@ +{ + "name": "squoosh-gif", + "scripts": { + "build": "../../../tools/build-rust.sh && npm run patch-pre-script", + "patch-pre-script": "cat pre.js >> pkg/squoosh_gif.js" + } +} diff --git a/packages/gif/codec/pre.js b/packages/gif/codec/pre.js new file mode 100644 index 0000000..073dd63 --- /dev/null +++ b/packages/gif/codec/pre.js @@ -0,0 +1,24 @@ +const isServiceWorker = globalThis.ServiceWorkerGlobalScope !== undefined; +const isRunningInCloudFlareWorkers = isServiceWorker && typeof self !== 'undefined' && globalThis.caches && globalThis.caches.default !== undefined; +const isRunningInNode = typeof process === 'object' && process.release && process.release.name === 'node'; + +if (isRunningInCloudFlareWorkers || isRunningInNode) { + if (!globalThis.ImageData) { + // Simple Polyfill for ImageData Object + globalThis.ImageData = class ImageData { + constructor(data, width, height) { + this.data = data; + this.width = width; + this.height = height; + } + }; + } + + if (import.meta.url === undefined) { + import.meta.url = 'https://localhost'; + } + + if (typeof self !== 'undefined' && self.location === undefined) { + self.location = { href: '' }; + } +} diff --git a/packages/gif/codec/src/lib.rs b/packages/gif/codec/src/lib.rs new file mode 100644 index 0000000..45b8c8c --- /dev/null +++ b/packages/gif/codec/src/lib.rs @@ -0,0 +1,200 @@ +use gif::{DecodeOptions, DisposalMethod}; +use wasm_bindgen::prelude::*; +use wasm_bindgen::{Clamped, JsCast}; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = ImageData)] + pub type ImageData; + + #[wasm_bindgen(constructor)] + fn new_with_owned_u8_clamped_array_and_sh( + data: Clamped>, + sw: u32, + sh: u32, + ) -> ImageData; +} + +#[wasm_bindgen] +pub struct GIFFrame { + image_data: ImageData, + duration: u32, +} + +#[wasm_bindgen] +impl GIFFrame { + #[wasm_bindgen(getter, js_name = "imageData")] + pub fn image_data(&self) -> ImageData { + let val: JsValue = self.image_data.clone().into(); + val.unchecked_into() + } + + #[wasm_bindgen(getter)] + pub fn duration(&self) -> u32 { + self.duration + } +} + +fn make_image_data(rgba: Vec, width: u32, height: u32) -> ImageData { + ImageData::new_with_owned_u8_clamped_array_and_sh(Clamped(rgba), width, height) +} + +fn validate_gif(data: &[u8]) { + if data.len() < 6 { + wasm_bindgen::throw_str("Not a valid GIF file (too short)"); + } + let sig = &data[..6]; + if sig != b"GIF87a" && sig != b"GIF89a" { + wasm_bindgen::throw_str( + "Not a valid GIF file (expected GIF87a/GIF89a header)", + ); + } +} + +#[wasm_bindgen] +pub fn decode(data: &[u8]) -> ImageData { + validate_gif(data); + let mut opts = DecodeOptions::new(); + opts.set_color_output(gif::ColorOutput::RGBA); + let mut decoder = opts.read_info(data).unwrap_throw(); + + let width = decoder.width() as u32; + let height = decoder.height() as u32; + let canvas_size = (width * height * 4) as usize; + + let mut canvas = vec![0u8; canvas_size]; + + if let Some(frame) = decoder.read_next_frame().unwrap_throw() { + let fw = frame.width as u32; + let fh = frame.height as u32; + let fl = frame.left as u32; + let ft = frame.top as u32; + + for y in 0..fh { + for x in 0..fw { + let src = ((y * fw + x) * 4) as usize; + let alpha = frame.buffer[src + 3]; + if alpha == 0 { + continue; + } + let dx = fl + x; + let dy = ft + y; + if dx >= width || dy >= height { + continue; + } + let dst = ((dy * width + dx) * 4) as usize; + canvas[dst..dst + 4].copy_from_slice(&frame.buffer[src..src + 4]); + } + } + } else { + wasm_bindgen::throw_str("No frames found in GIF"); + } + + make_image_data(canvas, width, height) +} + +#[wasm_bindgen(js_name = "decodeAnimated")] +pub fn decode_animated(data: &[u8]) -> Vec { + validate_gif(data); + let mut opts = DecodeOptions::new(); + opts.set_color_output(gif::ColorOutput::RGBA); + let mut decoder = opts.read_info(data).unwrap_throw(); + + let width = decoder.width() as u32; + let height = decoder.height() as u32; + let canvas_size = (width * height * 4) as usize; + + let mut canvas = vec![0u8; canvas_size]; + let mut frames: Vec = Vec::new(); + + while let Some(frame) = decoder.read_next_frame().unwrap_throw() { + let fw = frame.width as u32; + let fh = frame.height as u32; + let fl = frame.left as u32; + let ft = frame.top as u32; + let disposal = frame.dispose; + + // Save canvas state before rendering (for restore-to-previous) + let prev_canvas = if disposal == DisposalMethod::Previous { + Some(canvas.clone()) + } else { + None + }; + + // Render frame onto canvas + for y in 0..fh { + for x in 0..fw { + let src = ((y * fw + x) * 4) as usize; + let alpha = frame.buffer[src + 3]; + if alpha == 0 { + continue; + } + let dx = fl + x; + let dy = ft + y; + if dx >= width || dy >= height { + continue; + } + let dst = ((dy * width + dx) * 4) as usize; + canvas[dst..dst + 4].copy_from_slice(&frame.buffer[src..src + 4]); + } + } + + // Default delay of 100ms if not specified or zero + let delay_ms = if frame.delay == 0 { + 100 + } else { + frame.delay as u32 * 10 + }; + + let image_data = make_image_data(canvas.clone(), width, height); + frames.push(GIFFrame { + image_data, + duration: delay_ms, + }); + + // Apply disposal method for next frame + match disposal { + DisposalMethod::Background => { + for y in 0..fh { + for x in 0..fw { + let dx = fl + x; + let dy = ft + y; + if dx >= width || dy >= height { + continue; + } + let dst = ((dy * width + dx) * 4) as usize; + canvas[dst..dst + 4].copy_from_slice(&[0, 0, 0, 0]); + } + } + } + DisposalMethod::Previous => { + if let Some(prev) = prev_canvas { + canvas = prev; + } + } + _ => {} // Keep / Any — leave canvas as-is + } + } + + frames +} + +#[wasm_bindgen(js_name = "isAnimated")] +pub fn is_animated(data: &[u8]) -> bool { + validate_gif(data); + let mut opts = DecodeOptions::new(); + opts.set_color_output(gif::ColorOutput::RGBA); + let mut decoder = match opts.read_info(data) { + Ok(d) => d, + Err(_) => return false, + }; + + // Read first frame + match decoder.read_next_frame() { + Ok(Some(_)) => {} + _ => return false, + } + + // If there's a second frame, it's animated + matches!(decoder.read_next_frame(), Ok(Some(_))) +} diff --git a/packages/gif/decode.ts b/packages/gif/decode.ts new file mode 100644 index 0000000..53fca60 --- /dev/null +++ b/packages/gif/decode.ts @@ -0,0 +1,62 @@ +import type { + GIFFrame, + InitInput, + InitOutput as GifModule, +} from './codec/pkg/squoosh_gif.js'; +import initGifModule, { + decode as gifDecodeWasm, + decodeAnimated as gifDecodeAnimatedWasm, + isAnimated as gifIsAnimatedWasm, +} from './codec/pkg/squoosh_gif.js'; + +export type { GIFFrame }; + +let gifModule: Promise; + +function validateGif(buffer: ArrayBuffer): void { + const header = new Uint8Array(buffer, 0, 6); + const sig = String.fromCharCode(...header); + if (sig !== 'GIF87a' && sig !== 'GIF89a') { + throw new Error( + `Not a valid GIF file (expected GIF87a/GIF89a header, got "${sig.replace(/[^\x20-\x7E]/g, '?')}")`, + ); + } +} + +export async function init(moduleOrPath?: InitInput): Promise { + if (!gifModule) { + gifModule = initGifModule(moduleOrPath); + } + return gifModule; +} + +export default async function decode( + buffer: ArrayBuffer, +): Promise { + validateGif(buffer); + await init(); + + const result = gifDecodeWasm(new Uint8Array(buffer)); + if (!result) throw new Error('Decoding error'); + return result; +} + +export async function decodeAnimated( + buffer: ArrayBuffer, +): Promise { + validateGif(buffer); + await init(); + + const result = gifDecodeAnimatedWasm(new Uint8Array(buffer)); + if (!result) throw new Error('Decoding error'); + return result; +} + +export async function isAnimated( + buffer: ArrayBuffer, +): Promise { + validateGif(buffer); + await init(); + + return gifIsAnimatedWasm(new Uint8Array(buffer)); +} diff --git a/packages/gif/index.ts b/packages/gif/index.ts new file mode 100644 index 0000000..7cecf23 --- /dev/null +++ b/packages/gif/index.ts @@ -0,0 +1,2 @@ +export { default as decode, decodeAnimated, isAnimated } from './decode.js'; +export type { GIFFrame } from './decode.js'; diff --git a/packages/gif/meta.ts b/packages/gif/meta.ts new file mode 100644 index 0000000..a2c903c --- /dev/null +++ b/packages/gif/meta.ts @@ -0,0 +1,3 @@ +export const label = 'GIF'; +export const mimeType = 'image/gif'; +export const extension = 'gif'; diff --git a/packages/gif/package.json b/packages/gif/package.json new file mode 100644 index 0000000..a6ce46f --- /dev/null +++ b/packages/gif/package.json @@ -0,0 +1,33 @@ +{ + "name": "@jsquash/gif", + "version": "1.0.0", + "main": "index.js", + "description": "Wasm GIF decoder supporting the browser. Decode GIF images to ImageData.", + "repository": "jamsinclair/jSquash", + "author": { + "name": "Jamie Sinclair", + "email": "jamsinclairnz+npm@gmail.com" + }, + "keywords": [ + "image", + "optimisation", + "optimization", + "squoosh", + "wasm", + "webassembly", + "gif" + ], + "license": "Apache-2.0", + "scripts": { + "clean": "rm -rf dist", + "build:codec": "cd codec && npm run build", + "build": "npm run clean && tsc && cp -r codec package.json README.md .npmignore ../../LICENSE dist && cd dist/codec", + "prepublishOnly": "[[ \"$PWD\" == *'/dist' ]] && exit 0 || (echo 'Please run npm publish from the dist directory' && exit 1)" + }, + "devDependencies": { + "@types/node": "^20.9.2", + "typescript": "^4.4.4" + }, + "type": "module", + "sideEffects": false +} diff --git a/packages/gif/tsconfig.json b/packages/gif/tsconfig.json new file mode 100644 index 0000000..b63caba --- /dev/null +++ b/packages/gif/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2019", + "downlevelIteration": true, + "module": "esnext", + "strict": true, + "moduleResolution": "node", + "composite": true, + "declarationMap": true, + "baseUrl": "./", + "rootDir": "./", + "outDir": "dist", + "allowSyntheticDefaultImports": true + } +}