Skip to content

Commit 425b4e9

Browse files
matmenclaude
andcommitted
smaller animations: keep GIF frames lossless, faster APNG decision
A sparse GIF frame needs a palette entry for its unchanged pixels: if its colors fill the palette already, the quantizer had to merge colors, and a GIF that was lossless before wasn't anymore. Such frames stay dense when that fits the palette. The APNG pixel format check stops at the 257th color when the frames aren't grey (truecolor either way), and filling sparse frames in again copies frames that replace their area by rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLHc7GW46D6BmwtEM2khgr
1 parent f0301df commit 425b4e9

4 files changed

Lines changed: 56 additions & 9 deletions

File tree

‎src/animation.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export class Animation extends Array<Frame> {
126126
*/
127127
async encodeGIF(quality = 95, metadata: Metadata = {}): Promise<Uint8Array> {
128128
this.__validate__();
129-
const frames = this.__optimized__({ replace: false, even: false, alpha: 'frame' }).map(f => ({
129+
const frames = this.__optimized__({ replace: false, even: false, alpha: 'frame', palette: 256 }).map(f => ({
130130
x: f.x,
131131
y: f.y,
132132
width: f.width,

‎src/codecs/png.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ interface PixelFormat {
164164
export function alphaCost(images: Uint8Array[]): number {
165165
let grey = true;
166166
const colors = new Set<number>();
167-
for (const rgba of images) {
167+
// more than 256 colors and not grey: truecolor either way, nothing else can change
168+
scan: for (const rgba of images) {
168169
const pixels = new Uint32Array(rgba.buffer, rgba.byteOffset, rgba.length >> 2);
169170
let previous = -1;
170171
for (let i = 0; i < pixels.length; i++) {
@@ -175,6 +176,7 @@ export function alphaCost(images: Uint8Array[]): number {
175176
if (rgba[o + 3] === 0) continue;
176177
if (rgba[o] !== rgba[o + 1] || rgba[o] !== rgba[o + 2]) grey = false;
177178
if (colors.size <= 256) colors.add(value);
179+
else if (!grey) break scan;
178180
}
179181
}
180182
// bytes per pixel: palette 1/8 to 1 (by its bit depth), grey 1 (with alpha 2), truecolor 3 (with alpha 4)

‎src/util/optimize.ts‎

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ export interface OptimizeOptions {
4848
* (the transparent pixels are the sparse frames' unchanged ones) than without the transparent pixels
4949
*/
5050
alpha: 'frame' | ((frames: OptimizedFrame[]) => number);
51+
/**
52+
* The palette size if each frame has one (GIF: 256): a frame stays dense if its colors fit, but its sparse pixels
53+
* (the transparent ones are a color more) don't, so they'd lose colors
54+
*/
55+
palette?: number;
5156
}
5257

5358
// what transparent pixels make an opaque frame cost: an alpha channel, 4 channels instead of 3 (or a palette entry
@@ -59,7 +64,7 @@ const ALPHA_COST = 4 / 3;
5964
* @param width The canvas width
6065
* @param height The canvas height
6166
*/
62-
export function optimize(frames: InputFrame[], width: number, height: number, { replace, even, alpha }: OptimizeOptions): OptimizedFrame[] {
67+
export function optimize(frames: InputFrame[], width: number, height: number, { replace, even, alpha, palette }: OptimizeOptions): OptimizedFrame[] {
6368
const size = width * height * 4;
6469
// the input animation's canvas, and what the optimized animation shows after its last frame (and its disposal)
6570
const canvas = new Uint8Array(size), canvas32 = new Int32Array(canvas.buffer);
@@ -111,7 +116,9 @@ export function optimize(frames: InputFrame[], width: number, height: number, {
111116
if (!output.length && (w !== width || h !== height)) opaque = false;
112117
if (!area.denseOpaque) opaque = false;
113118
const sparseCost = alpha === 'frame' && area.denseOpaque ? area.sparseChanges * ALPHA_COST : area.sparseChanges;
114-
const dense = !blend || area.denseChanges < sparseCost;
119+
const dense = !blend || area.denseChanges < sparseCost
120+
|| (palette !== undefined && colors(new Int32Array(pixels.buffer), w, { x: 0, y: 0, width: w, height: h }, palette + 1) > palette
121+
&& colors(canvas32, width, changed, palette + 1) <= palette);
115122
denseTotal += area.denseChanges;
116123
chosenTotal += dense ? area.denseChanges : area.sparseChanges;
117124
if (dense) {
@@ -147,18 +154,38 @@ export function optimize(frames: InputFrame[], width: number, height: number, {
147154
shown.fill(0);
148155
for (const f of output) {
149156
const pixels32 = new Int32Array(f.pixels.buffer, f.pixels.byteOffset, f.width * f.height);
150-
for (let row = 0, k = 0; row < f.height; row++)
151-
for (let p = (f.y + row) * width + f.x, end = p + f.width; p < end; p++, k++) {
152-
if (f.blend && (pixels32[k] & ALPHA) === 0) pixels32[k] = shown32[p];
153-
else shown32[p] = pixels32[k];
154-
}
157+
for (let row = 0, k = 0; row < f.height; row++, k += f.width) {
158+
const start = (f.y + row) * width + f.x;
159+
if (!f.blend) shown32.set(pixels32.subarray(k, k + f.width), start);
160+
else
161+
for (let i = k, p = start, end = k + f.width; i < end; i++, p++) {
162+
if ((pixels32[i] & ALPHA) === 0) pixels32[i] = shown32[p];
163+
else shown32[p] = pixels32[i];
164+
}
165+
}
155166
f.blend = false;
156167
}
157168
}
158169

159170
return output;
160171
}
161172

173+
// how many colors an area of an image has, counted up to a limit
174+
function colors(image32: Int32Array, width: number, { x, y, width: w, height: h }: Rect, limit: number): number {
175+
const seen = new Set<number>();
176+
for (let row = y; row < y + h; row++) {
177+
let previous: number | undefined;
178+
for (let p = row * width + x, end = p + w; p < end; p++) {
179+
const value = image32[p];
180+
if (value === previous) continue;
181+
previous = value;
182+
seen.add(value);
183+
if (seen.size >= limit) return seen.size;
184+
}
185+
}
186+
return seen.size;
187+
}
188+
162189
// a frame that doesn't change anything
163190
const empty = (duration: number): OptimizedFrame => ({ x: 0, y: 0, width: 1, height: 1, duration, dispose: false, blend: true, pixels: new Uint8Array(4) });
164191

‎tests/animation-optimize.js‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,24 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => {
118118
if (animation.__optimized__({replace: true, even: false, alpha: () => 4 / 3}).some(f => f.blend)) panic('opaque animation: frames weren\'t filled in');
119119
}
120120

121+
// a GIF frame stays dense when its colors fill the palette and its sparse pixels wouldn't fit (they'd need one more
122+
// color for the unchanged pixels): it stays lossless. The blocks around the center change color, the center doesn't
123+
{
124+
const color = index => Image.rgbaToColor(index, 255 - index, index * 37 & 255, 255);
125+
// 24×24 blocks: the ring around the center has all 256 colors in turn, the center some of them
126+
const ring = (i, j) => i < 4 || i > 19 || j < 4 || j > 19;
127+
const grid = shift => {
128+
const blocks = [];
129+
for (let j = 0, k = 0; j < 24; j++) for (let i = 0; i < 24; i++) blocks.push(ring(i, j) ? (k++ + shift) & 255 : (i * 7 + j * 13) & 255);
130+
return new Image(96, 96).fill((x, y) => color(blocks[(y >> 2) * 24 + (x >> 2)]));
131+
};
132+
const animation = new Animation([Frame.from(grid(0), 100), Frame.from(grid(1), 100)]);
133+
const decoded = await Animation.decode(await animation.encodeGIF());
134+
decoded.forEach((frame, i) => {
135+
if (frame.bitmap.some((v, k) => v !== animation[i].bitmap[k])) panic(`palette: GIF frame ${i} lost colors`);
136+
});
137+
}
138+
121139
// a mostly static animation (like a screen recording) only stores what changes
122140
{
123141
const background = new Image(160, 120).fill((x, y) => Image.rgbaToColor(x * 7 ^ y * 13, x * y, y * 5, 255));

0 commit comments

Comments
 (0)