Skip to content

Commit 0e07780

Browse files
matmenclaude
andcommitted
optimize option for the animation encoders
{optimize: false} stores the frames as they are, as before: the file decodes to the same frames, and encoding is faster when little changes from frame to frame. The changelog has the numbers measured on 762 animations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLHc7GW46D6BmwtEM2khgr
1 parent 7fa3110 commit 0e07780

3 files changed

Lines changed: 81 additions & 28 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111
arguments keep working until 4.0.
1212
- **Smaller animations**: `encodeGIF()`, `encodeAPNG()` and `encodeWebP()` store each frame as the area that changed
1313
since the frame before, with unchanged pixels transparent where that compresses better, and merge identical
14-
frames. Re-encoding decoded animations (which have every frame as a full image) typically gives files a third of
15-
the size, mostly static ones (like screen recordings) a tenth or less; encoding is about as fast as before. APNGs
16-
and WebPs show exactly the same animation as before, but don't decode to the same frames anymore.
14+
frames. Re-encoding 762 decoded animations (which have every frame as a full image) took 51% (GIF), 63% (APNG) and
15+
54% (WebP) of the bytes in total; the less moves, the smaller a file gets, ones where everything moves stay about
16+
the same. Large animations encode faster, small ones up to about twice as slow. The files show the same animation as
17+
before, but don't decode to the same frames anymore: `{optimize: false}` stores the frames as they are.
1718
- **Metadata in every format**: `encodeJPEG()`, `encodeWebP()`, `encodeTGA()` and, for animations, `encodeGIF()`,
1819
`encodeAPNG()` and `encodeWebP()` take the same metadata as `encode()` (now called `Metadata`), as the `metadata`
1920
option: `image.encodeJPEG({quality: 80, metadata: {author: 'me'}})`. Each format stores

‎src/animation.ts‎

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export interface AnimationDecodeOptions {
3838
export interface AnimationEncodeOptions {
3939
/** Metadata to include, see {@link Metadata} */
4040
metadata?: Metadata;
41+
/**
42+
* Whether to store each frame as the area that changed since the frame before and merge identical frames, which
43+
* usually makes files a lot smaller. The file shows the same animation either way, but only decodes to the same
44+
* frames without it, which also encodes faster when little changes. Default: `true`
45+
*/
46+
optimize?: boolean;
4147
}
4248

4349
/** Options of {@link Animation.encodeGIF} */
@@ -165,72 +171,105 @@ export class Animation extends Array<Frame> {
165171

166172
/**
167173
* Encodes the animation as GIF. Like all encoders, it stores each frame as the area that changed since the frame
168-
* before and merges identical frames, so the file decodes to what the animation shows, but not necessarily to the
169-
* same frames. Semi-transparent pixels become opaque or transparent after the frames are composited
174+
* before and merges identical frames (unless the `optimize` option is false), so the file decodes to what the
175+
* animation shows, but not necessarily to the same frames. Semi-transparent pixels become opaque or transparent
176+
* after the frames are composited
170177
* @param options See {@link GIFEncodeOptions}
171178
*/
172179
encodeGIF(options?: GIFEncodeOptions): Promise<Uint8Array>;
173180
/**
174181
* Encodes the animation as GIF. Like all encoders, it stores each frame as the area that changed since the frame
175-
* before and merges identical frames, so the file decodes to what the animation shows, but not necessarily to the
176-
* same frames. Semi-transparent pixels become opaque or transparent after the frames are composited
182+
* before and merges identical frames (unless the `optimize` option is false), so the file decodes to what the
183+
* animation shows, but not necessarily to the same frames. Semi-transparent pixels become opaque or transparent
184+
* after the frames are composited
177185
* @param quality 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above)
178186
*/
179187
encodeGIF(quality?: number): Promise<Uint8Array>;
180188
async encodeGIF(qualityOrOptions: number | GIFEncodeOptions = 95): Promise<Uint8Array> {
181-
const { quality = 95, metadata = {} } = isOptions(qualityOrOptions) ? qualityOrOptions : { quality: qualityOrOptions };
189+
const { quality = 95, metadata = {}, optimize = true } = isOptions(qualityOrOptions) ? qualityOrOptions : { quality: qualityOrOptions };
182190
this.__validate__();
183-
const frames = this.__optimized__({ replace: false, even: false, alpha: 'frame', palette: 256, cover: true }).map(f => ({
184-
x: f.x,
185-
y: f.y,
186-
width: f.width,
187-
height: f.height,
188-
// gifs store centiseconds; rounding keeps e.g. 16 ms (60 fps) from becoming 10 ms
189-
delay: Math.round(f.duration / 10),
190-
dispose: f.dispose ? 3 : 1,
191-
pixels: f.pixels
192-
}));
193-
194-
return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames }, quality, gifComments(resolveMetadata(metadata)));
191+
const frames = optimize
192+
? this.__optimized__({ replace: false, even: false, alpha: 'frame', palette: 256, cover: true }).map(f => ({ ...f, dispose: f.dispose ? 3 : 1 }))
193+
: Array.from(this, frame => ({
194+
x: frame.xOffset,
195+
y: frame.yOffset,
196+
width: frame.width,
197+
height: frame.height,
198+
duration: frame.duration,
199+
dispose: frame.__disposal_id__,
200+
pixels: new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength)
201+
}));
202+
// gifs store centiseconds; rounding keeps e.g. 16 ms (60 fps) from becoming 10 ms
203+
const gifFrames = frames.map(({ duration, ...f }) => ({ ...f, delay: Math.round(duration / 10) }));
204+
205+
return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames: gifFrames }, quality, gifComments(resolveMetadata(metadata)));
195206
}
196207

197208
/**
198209
* Encodes the animation as APNG (animated PNG, lossless and with full alpha). Viewers without APNG support show
199210
* the first frame. Each frame is stored as the area that changed since the frame before, identical frames are
200-
* merged: the file shows exactly the same animation, but doesn't necessarily decode to the same frames
211+
* merged (unless the `optimize` option is false): the file shows exactly the same animation, but doesn't
212+
* necessarily decode to the same frames
201213
* @param options See {@link APNGEncodeOptions}
202214
*/
203215
encodeAPNG(options?: APNGEncodeOptions): Promise<Uint8Array>;
204216
/**
205217
* Encodes the animation as APNG (animated PNG, lossless and with full alpha). Viewers without APNG support show
206218
* the first frame. Each frame is stored as the area that changed since the frame before, identical frames are
207-
* merged: the file shows exactly the same animation, but doesn't necessarily decode to the same frames
219+
* merged (unless the `optimize` option is false): the file shows exactly the same animation, but doesn't
220+
* necessarily decode to the same frames
208221
* @param compression The zlib compression level (0-9), higher is smaller but slower
209222
*/
210223
encodeAPNG(compression?: number): Promise<Uint8Array>;
211224
async encodeAPNG(compressionOrOptions: number | APNGEncodeOptions = 1): Promise<Uint8Array> {
212-
const { compression = 1, metadata = {} } = isOptions(compressionOrOptions) ? compressionOrOptions : { compression: compressionOrOptions };
225+
const { compression = 1, metadata = {}, optimize = true } = isOptions(compressionOrOptions) ? compressionOrOptions : { compression: compressionOrOptions };
213226
this.__validate__();
214227
if (!this.length) throw new Error('An animation needs at least one frame');
215228

216-
const frames = this.__optimized__({ replace: true, even: false, alpha: frames => png.alphaCost(frames.map(f => f.pixels)) }).map(f => ({ ...f, dispose: f.dispose ? 1 : 0 }));
229+
const frames = optimize
230+
? this.__optimized__({ replace: true, even: false, alpha: frames => png.alphaCost(frames.map(f => f.pixels)) }).map(f => ({ ...f, dispose: f.dispose ? 1 : 0 }))
231+
: this.__apngFrames__().map(({ blend: _, ...f }) => f); // blended, the encoder's default
217232
return png.encodeAnimation(frames, this.width, this.height, toPlays(this.loopCount), compression, pngText(resolveMetadata(metadata)));
218233
}
219234

220235
/**
221236
* Encodes the animation as lossless animated WebP. Each frame is stored as the area that changed since the frame
222-
* before, identical frames are merged: the file shows exactly the same animation, but doesn't necessarily decode
223-
* to the same frames
237+
* before, identical frames are merged (unless the `optimize` option is false): the file shows exactly the same
238+
* animation, but doesn't necessarily decode to the same frames
224239
* @param options See {@link AnimationEncodeOptions}
225240
*/
226-
async encodeWebP({ metadata = {} }: AnimationEncodeOptions = {}): Promise<Uint8Array> {
241+
async encodeWebP({ metadata = {}, optimize = true }: AnimationEncodeOptions = {}): Promise<Uint8Array> {
227242
this.__validate__();
228243
if (!this.length) throw new Error('An animation needs at least one frame');
229244

230-
const frames = this.__optimized__({ replace: true, even: true, alpha: 'frame' });
245+
const frames = optimize ? this.__optimized__({ replace: true, even: true, alpha: 'frame' }) : this.__webpFrames__();
231246
return webp.encodeAnimation(frames, this.width, this.height, toPlays(this.loopCount), exifData(resolveMetadata(metadata)));
232247
}
233248

249+
/** @internal the frames as they are, in the WebP codec's terms */
250+
__webpFrames__(): webp.WebPEncodeFrame[] {
251+
const width = this.width, height = this.height;
252+
// WebP knows no "previous" disposal and stores offsets halved: such animations are stored as full frames
253+
const fullFrames = this.some(f => f.disposalMode === 'previous' || (f.disposalMode === 'background' && (f.xOffset & 1 || f.yOffset & 1)));
254+
if (fullFrames)
255+
return png.compositeAnimation({ width, height, frames: this.__apngFrames__() })
256+
.map(c => ({ x: 0, y: 0, width, height, duration: c.duration, dispose: true, pixels: c.pixels }));
257+
258+
return Array.from(this, frame => {
259+
let { xOffset: x, yOffset: y, width: w, height: h } = frame;
260+
let pixels = new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength);
261+
// odd offsets: one transparent pixel more on the left/top (blending makes it invisible)
262+
if (x & 1 || y & 1) {
263+
const dx = x & 1, dy = y & 1;
264+
const padded = new Uint8Array((w + dx) * (h + dy) * 4);
265+
for (let row = 0; row < h; row++) padded.set(pixels.subarray(row * w * 4, (row + 1) * w * 4), ((row + dy) * (w + dx) + dx) * 4);
266+
pixels = padded;
267+
x -= dx; y -= dy; w += dx; h += dy;
268+
}
269+
return { x, y, width: w, height: h, duration: frame.duration, dispose: frame.disposalMode === 'background', pixels };
270+
});
271+
}
272+
234273
/**
235274
* The frames as the encoders store them: each frame only covers what changed since the one before (see
236275
* util/optimize.ts)

‎tests/animation-optimize.js‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,19 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => {
136136
});
137137
}
138138

139+
// optimize: false stores the frames as they are: same animation, but no frames merged or cut
140+
{
141+
const dot = Frame.from(new Image(10, 10).setPixelAt(5, 5, 0xffffffff), 100);
142+
const animation = new Animation([new Frame(10, 10, 40), dot, dot.clone(), new Frame(4, 4, {duration: 30, xOffset: 3, yOffset: 1}).fill(0xff0000ff)]);
143+
for (const format of ['GIF', 'APNG', 'WebP']) {
144+
const plain = await Animation.decode(await animation[`encode${format}`]({optimize: false}));
145+
if (plain.length !== 4) panic(`optimize false: ${format} has ${plain.length} frames instead of 4`);
146+
const expected = await composite(animation);
147+
if (format === 'APNG' && plain.some((f, i) => f.bitmap.some((v, k) => v !== expected[i].bitmap[k]))) panic('optimize false: APNG frames differ');
148+
}
149+
await check('optimize false', animation);
150+
}
151+
139152
// a mostly static animation (like a screen recording) only stores what changes
140153
{
141154
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)