Skip to content

Commit 6ee695e

Browse files
matmenclaude
andcommitted
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
1 parent bae2d54 commit 6ee695e

3 files changed

Lines changed: 54 additions & 22 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 3.2.1
4+
5+
### Performance
6+
7+
- Decoding GIF animations is about 35% faster, with identical results: frames are composited from their color
8+
indices instead of an RGBA copy of each, and the composited frames aren't copied again.
9+
310
## 3.2.0
411

512
### New

‎src/animation.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ const fromPlays = (plays: number): number => plays === 0 ? -1 : plays - 1;
1717
function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uint8Array, duration: number }>): Frame[] {
1818
return frames.map(({ pixels, duration }) => {
1919
const frame = new Frame(width, height, duration, 0, 0, Frame.DISPOSAL_BACKGROUND);
20-
frame.bitmap.set(pixels);
20+
// the composited frames are copies already, the frame can keep them
21+
frame.bitmap = pixels;
2122
return frame;
2223
});
2324
}

‎src/codecs/gif.ts‎

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,30 @@ export interface GIFData {
2828
frames: GIFFrame[];
2929
}
3030

31+
/** A decoded frame: color indices (in display order) and what they stand for */
32+
export interface DecodedGIFFrame {
33+
x: number;
34+
y: number;
35+
width: number;
36+
height: number;
37+
/** Frame delay in centiseconds */
38+
delay: number;
39+
/** ImageScript disposal mode (0 any, 1 keep, 2 previous, 3 background) */
40+
dispose: number;
41+
indices: Uint8Array;
42+
/** RGBA of every index as a 32-bit word (indices past the color table are opaque black) */
43+
colors: Uint32Array;
44+
/** -1 if none */
45+
transparentIndex: number;
46+
}
47+
48+
export interface DecodedGIF {
49+
width: number;
50+
height: number;
51+
loopCount: number;
52+
frames: DecodedGIFFrame[];
53+
}
54+
3155
interface GraphicControl {
3256
dispose: number;
3357
delay: number;
@@ -182,7 +206,7 @@ function writeSubBlocks(out: ByteWriter, data: Uint8Array): void {
182206
* @param {boolean} [onlyFirstFrame=false]
183207
* @return {{width: number, height: number, loopCount: number, frames: GIFFrame[]}}
184208
*/
185-
export function decode(data: Uint8Array, onlyFirstFrame = false): GIFData {
209+
export function decode(data: Uint8Array, onlyFirstFrame = false): DecodedGIF {
186210
const reader = new ByteReader(data);
187211

188212
const signature = reader.string(6);
@@ -195,7 +219,7 @@ export function decode(data: Uint8Array, onlyFirstFrame = false): GIFData {
195219

196220
const globalTable = flags & 0x80 ? reader.slice(3 << (flags & 7) + 1) : null;
197221

198-
const frames: GIFFrame[] = [];
222+
const frames: DecodedGIFFrame[] = [];
199223
let loopCount = 0;
200224
let control: GraphicControl | null = null;
201225

@@ -269,41 +293,38 @@ export function decode(data: Uint8Array, onlyFirstFrame = false): GIFData {
269293
control = null;
270294
if (!fw || !fh) continue;
271295

272-
const pixels = new Uint8Array(fw * fh * 4);
273-
const rows = interlaced ? interlacedRows(fh) : null;
274-
275-
// the RGBA value of every index: transparent stays 0, indices past the color table are opaque black
276-
const colors = new Uint8Array(256 * 4);
296+
// the RGBA value of every index: indices past the color table are opaque black (the transparent one is
297+
// skipped when compositing)
298+
const colors = new Uint32Array(256), bytes = new Uint8Array(colors.buffer);
277299
for (let index = 0; index < 256; index++) {
278-
if (index === transparentIndex) continue;
279-
if (table && index * 3 + 2 < table.length) colors.set(table.subarray(index * 3, index * 3 + 3), index * 4);
280-
colors[index * 4 + 3] = 0xff;
300+
if (table && index * 3 + 2 < table.length) bytes.set(table.subarray(index * 3, index * 3 + 3), index * 4);
301+
bytes[index * 4 + 3] = 0xff;
281302
}
282-
const lookup = new Uint32Array(colors.buffer), out = new Uint32Array(pixels.buffer);
283303

284-
for (let row = 0; row < fh; row++) {
285-
const target = (rows ? rows[row] : row) * fw, source = row * fw;
286-
for (let column = 0; column < fw; column++) out[target + column] = lookup[indices[source + column]];
304+
let ordered = indices;
305+
if (interlaced) {
306+
const rows = interlacedRows(fh);
307+
ordered = new Uint8Array(indices.length);
308+
for (let row = 0; row < fh; row++) ordered.set(indices.subarray(row * fw, (row + 1) * fw), rows[row] * fw);
287309
}
288310

289-
frames.push({ x, y, width: fw, height: fh, delay, dispose, pixels });
311+
frames.push({ x, y, width: fw, height: fh, delay, dispose, indices: ordered, colors, transparentIndex });
290312
if (onlyFirstFrame) break;
291313
}
292314
}
293315
}
294316

295317
/**
296318
* Composites decoded frames onto the logical screen, applying their disposal modes
297-
* @param {{width: number, height: number, frames: GIFFrame[]}} gif output of {@link decode}
298-
* @return {Array<{pixels: Uint8Array, delay: number}>} full screen RGBA frames
319+
* @return full screen RGBA frames
299320
*/
300-
export function composite({ width, height, frames }: GIFData): Array<{ pixels: Uint8Array, delay: number }> {
321+
export function composite({ width, height, frames }: DecodedGIF): Array<{ pixels: Uint8Array, delay: number }> {
301322
const canvas = new Uint32Array(width * height);
302323
const output = [];
303324

304325
for (const frame of frames) {
305326
const previous = frame.dispose === 2 ? canvas.slice() : null;
306-
const src = new Uint32Array(frame.pixels.buffer, frame.pixels.byteOffset, frame.width * frame.height);
327+
const { indices, colors, transparentIndex } = frame;
307328

308329
// clip the frame to the logical screen
309330
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
313334
const srcRow = (y - frame.y) * frame.width - frame.x;
314335
const dstRow = y * width;
315336

316-
for (let x = x0; x < x1; x++)
317-
if (frame.pixels[(srcRow + x) * 4 + 3] !== 0) canvas[dstRow + x] = src[srcRow + x];
337+
if (transparentIndex < 0) for (let x = x0; x < x1; x++) canvas[dstRow + x] = colors[indices[srcRow + x]];
338+
else for (let x = x0; x < x1; x++) {
339+
const index = indices[srcRow + x];
340+
if (index !== transparentIndex) canvas[dstRow + x] = colors[index];
341+
}
318342
}
319343

320344
output.push({ pixels: new Uint8Array(canvas.slice().buffer), delay: frame.delay });

0 commit comments

Comments
 (0)