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
+
+
+
+
+
+
+
First Frame — decode()
+
+
+
+
+
+
+
+
Animation — decodeAnimated()
+
+
+
+
+
+
+
+
+
+
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
+ }
+}