From 8c0a821646b111f053b5c461b6a275d6f44a64a8 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Fri, 16 Oct 2026 09:47:01 +0000 Subject: [PATCH] faster GIF animation decoding Decoding wrote every frame as RGBA, compositing read it back (checking each pixel's alpha byte) and copied the canvas, and the Frame copied it once more into a zeroed bitmap. Frames now keep their indices (in display order) and a table of their colors: compositing writes the colors straight onto the canvas, skipping the transparent index, and the frames take the composited copies as their bitmaps. The benchmark's 101 frame GIF decodes in 83 instead of 134 ms (omggif: 123 ms). Bit-identical: decoding and re-encoding checked against master on 1593 images. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ --- CHANGELOG.md | 2 ++ src/animation.ts | 3 ++- src/codecs/gif.ts | 66 ++++++++++++++++++++++++++++++++--------------- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 252441ce..7fb6a599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ 32-bit words, two channels per sum. Results differ from 3.2.0 by at most 1 per channel (2 for radii above 10). Transparent images are blurred premultiplied in 8 bits, like browsers do: the color of pixels that are almost transparent can differ more, what shows when they're composited stays within 2 steps. +- Decoding GIF animations is about 35% faster, with identical results: frames are composited from their color + indices instead of an RGBA copy of each, and the composited frames aren't copied again. ### Fixes diff --git a/src/animation.ts b/src/animation.ts index a3c0b2c1..78b9abea 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -17,7 +17,8 @@ const fromPlays = (plays: number): number => plays === 0 ? -1 : plays - 1; function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uint8Array, duration: number }>): Frame[] { return frames.map(({ pixels, duration }) => { const frame = new Frame(width, height, duration, 0, 0, Frame.DISPOSAL_BACKGROUND); - frame.bitmap.set(pixels); + // the composited frames are copies already, the frame can keep them + frame.bitmap = pixels; return frame; }); } diff --git a/src/codecs/gif.ts b/src/codecs/gif.ts index 44a7f98c..da35c295 100644 --- a/src/codecs/gif.ts +++ b/src/codecs/gif.ts @@ -28,6 +28,30 @@ export interface GIFData { frames: GIFFrame[]; } +/** A decoded frame: color indices (in display order) and what they stand for */ +export interface DecodedGIFFrame { + x: number; + y: number; + width: number; + height: number; + /** Frame delay in centiseconds */ + delay: number; + /** ImageScript disposal mode (0 any, 1 keep, 2 previous, 3 background) */ + dispose: number; + indices: Uint8Array; + /** RGBA of every index as a 32-bit word (indices past the color table are opaque black) */ + colors: Uint32Array; + /** -1 if none */ + transparentIndex: number; +} + +export interface DecodedGIF { + width: number; + height: number; + loopCount: number; + frames: DecodedGIFFrame[]; +} + interface GraphicControl { dispose: number; delay: number; @@ -182,7 +206,7 @@ function writeSubBlocks(out: ByteWriter, data: Uint8Array): void { * @param {boolean} [onlyFirstFrame=false] * @return {{width: number, height: number, loopCount: number, frames: GIFFrame[]}} */ -export function decode(data: Uint8Array, onlyFirstFrame = false): GIFData { +export function decode(data: Uint8Array, onlyFirstFrame = false): DecodedGIF { const reader = new ByteReader(data); const signature = reader.string(6); @@ -195,7 +219,7 @@ export function decode(data: Uint8Array, onlyFirstFrame = false): GIFData { const globalTable = flags & 0x80 ? reader.slice(3 << (flags & 7) + 1) : null; - const frames: GIFFrame[] = []; + const frames: DecodedGIFFrame[] = []; let loopCount = 0; let control: GraphicControl | null = null; @@ -269,24 +293,22 @@ export function decode(data: Uint8Array, onlyFirstFrame = false): GIFData { control = null; if (!fw || !fh) continue; - const pixels = new Uint8Array(fw * fh * 4); - const rows = interlaced ? interlacedRows(fh) : null; - - // the RGBA value of every index: transparent stays 0, indices past the color table are opaque black - const colors = new Uint8Array(256 * 4); + // the RGBA value of every index: indices past the color table are opaque black (the transparent one is + // skipped when compositing) + const colors = new Uint32Array(256), bytes = new Uint8Array(colors.buffer); for (let index = 0; index < 256; index++) { - if (index === transparentIndex) continue; - if (table && index * 3 + 2 < table.length) colors.set(table.subarray(index * 3, index * 3 + 3), index * 4); - colors[index * 4 + 3] = 0xff; + if (table && index * 3 + 2 < table.length) bytes.set(table.subarray(index * 3, index * 3 + 3), index * 4); + bytes[index * 4 + 3] = 0xff; } - const lookup = new Uint32Array(colors.buffer), out = new Uint32Array(pixels.buffer); - for (let row = 0; row < fh; row++) { - const target = (rows ? rows[row] : row) * fw, source = row * fw; - for (let column = 0; column < fw; column++) out[target + column] = lookup[indices[source + column]]; + let ordered = indices; + if (interlaced) { + const rows = interlacedRows(fh); + ordered = new Uint8Array(indices.length); + for (let row = 0; row < fh; row++) ordered.set(indices.subarray(row * fw, (row + 1) * fw), rows[row] * fw); } - frames.push({ x, y, width: fw, height: fh, delay, dispose, pixels }); + frames.push({ x, y, width: fw, height: fh, delay, dispose, indices: ordered, colors, transparentIndex }); if (onlyFirstFrame) break; } } @@ -294,16 +316,15 @@ export function decode(data: Uint8Array, onlyFirstFrame = false): GIFData { /** * Composites decoded frames onto the logical screen, applying their disposal modes - * @param {{width: number, height: number, frames: GIFFrame[]}} gif output of {@link decode} - * @return {Array<{pixels: Uint8Array, delay: number}>} full screen RGBA frames + * @return full screen RGBA frames */ -export function composite({ width, height, frames }: GIFData): Array<{ pixels: Uint8Array, delay: number }> { +export function composite({ width, height, frames }: DecodedGIF): Array<{ pixels: Uint8Array, delay: number }> { const canvas = new Uint32Array(width * height); const output = []; for (const frame of frames) { const previous = frame.dispose === 2 ? canvas.slice() : null; - const src = new Uint32Array(frame.pixels.buffer, frame.pixels.byteOffset, frame.width * frame.height); + const { indices, colors, transparentIndex } = frame; // clip the frame to the logical screen const x0 = Math.min(frame.x, width), x1 = Math.min(frame.x + frame.width, width); @@ -313,8 +334,11 @@ export function composite({ width, height, frames }: GIFData): Array<{ pixels: U const srcRow = (y - frame.y) * frame.width - frame.x; const dstRow = y * width; - for (let x = x0; x < x1; x++) - if (frame.pixels[(srcRow + x) * 4 + 3] !== 0) canvas[dstRow + x] = src[srcRow + x]; + if (transparentIndex < 0) for (let x = x0; x < x1; x++) canvas[dstRow + x] = colors[indices[srcRow + x]]; + else for (let x = x0; x < x1; x++) { + const index = indices[srcRow + x]; + if (index !== transparentIndex) canvas[dstRow + x] = colors[index]; + } } output.push({ pixels: new Uint8Array(canvas.slice().buffer), delay: frame.delay });