Skip to content

Commit 771cdcd

Browse files
committed
Image.decode returns the first frame of animations
1 parent e30f5d0 commit 771cdcd

6 files changed

Lines changed: 114 additions & 17 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation
3535

3636
- **The positional arguments of `new Frame()`, `Frame.from()` and `new Animation()` are removed, and `Animation.decode()` takes no flag**: `new Frame(width, height, duration, xOffset, yOffset, disposalMode)` is `new Frame(width, height, { duration, xOffset, yOffset, disposalMode })`, `Frame.from(image, duration, xOffset, yOffset, disposalMode)` is `Frame.from(image, { duration, xOffset, yOffset, disposalMode })`, `new Animation(frames, loopCount)` is `new Animation(frames, { loopCount })`, and `Animation.decode(data, true)` is `Animation.decode(data, { onlyFirstFrame: true })`.
3737

38+
- **`Image.decode()` returns the first frame of an animation**: a GIF or an animated WebP, which threw in 3.x, is an `Image` of its first frame, composed onto the canvas as `Animation.decode()` composes it, and decoding stops after that frame, so it is fast on long animations. Animated JPEG XLs and AVIF sequences were already their first frame. An APNG is still its default image, what viewers without APNG support show. Use `Animation.decode()` for all the frames.
39+
3840
- **`decode()` returns an `Animation` only for files with several frames**: a GIF, APNG, animated WebP or JPEG XL with more than one frame is an `Animation` (`decode(gif)` used to be a `GIF`), and one with a single frame is an `Image`, like a still image (a one-frame GIF used to be a `GIF`). With `onlyFirstFrame`, the first frame comes back as an `Image`, since the frame count isn't read.
3941

4042
- **The `GIF` class is removed**: `new GIF(frames, loopCount)` is `new Animation(frames, { loopCount })`, `GIF.decode(data)` is `Animation.decode(data)` and `gif.encode(quality)` is `animation.encodeGIF({ quality })`. `Animation` decodes and encodes GIFs the same way it does APNGs and WebPs.

‎guides/Migrating to 4.0.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,13 @@ if (decoded instanceof Animation) {
4141
}
4242
```
4343

44-
With `onlyFirstFrame` on `decode()`, the file isn't read past its first frame, so the frame count is not known. The
45-
first frame comes back as an `Image`.
44+
`Image.decode()` returns the first frame of an animated GIF or WebP, which threw in 3.x. The frame is an `Image` composed
45+
onto the canvas like the first frame of `Animation.decode()`, and decoding stops after it, so it is fast for a long
46+
animation. Use `Animation.decode()` for all the frames. An APNG still gives its default image, as before.
47+
48+
```ts
49+
const first = await Image.decode(data); // the first frame of an animated GIF or WebP
50+
```
4651

4752
Search your code for: `decode(` where the result is used for `encode`, `.length`, indexing or `instanceof GIF`, and
4853
`onlyFirstFrame`.

‎src/image.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2726,9 +2726,10 @@ export class Image {
27262726

27272727
/**
27282728
* Decodes an image (PNG, JPEG, TIFF, WebP, BMP, ICO, CUR, QOI, PBM, PGM, PPM, PAM, PFM, Radiance HDR, TGA, AVIF or
2729-
* JPEG XL). For APNGs this is the default image; animated WebPs and GIFs have no single image, decode those with
2730-
* {@link Animation.decode}; for icons and cursors it's their largest image, for AVIF image sequences and JPEG XL
2731-
* animations their first frame
2729+
* JPEG XL). For animations (GIFs, animated WebPs, AVIF image sequences, JPEG XL animations) this is the first frame,
2730+
* composed onto the canvas as {@link Animation.decode} has it, and decoding stops after it: use
2731+
* {@link Animation.decode} for all frames. For APNGs this is the default image, which is what viewers without APNG
2732+
* support show; for icons and cursors it's their largest image
27322733
* @param input The binary data to decode
27332734
* @param options See {@link DecodeOptions}. JPEGs, TIFFs and PNGs are rotated/mirrored as their orientation tag
27342735
* (Exif, for PNGs also XMP) says by default: photos taken with a rotated camera are stored sideways
@@ -2748,8 +2749,10 @@ export class Image {
27482749
if (ImageType.isJPEG(dataView)) return jpeg.decode(data, { maxPixels });
27492750
if (ImageType.isTIFF(dataView)) return tiff.decode(data, maxPixels);
27502751
if (ImageType.isWebP(dataView)) {
2751-
if (webp.isAnimated(data)) throw new Error('Animated WebPs have to be decoded with Animation.decode()');
2752-
return webp.decode(data, maxPixels);
2752+
if (!webp.isAnimated(data)) return webp.decode(data, maxPixels);
2753+
// the first frame only: the rest of the file isn't decoded
2754+
const animation = webp.decodeAnimation(data, true, maxPixels);
2755+
return { width: animation.width, height: animation.height, pixels: webp.composite(animation)[0].pixels, orientation: animation.orientation, icc: animation.icc };
27532756
}
27542757
// bitmaps can hold a JPEG or PNG, icons PNGs
27552758
const bitmap = async (decoded: bmp.Decoded): Promise<{ width: number, height: number, pixels: Uint8Array }> => {
@@ -2766,7 +2769,13 @@ export class Image {
27662769
if (ImageType.isPNM(dataView)) return netpbm.decode(data, maxPixels, toneMapping);
27672770
if (ImageType.isHDR(dataView)) return hdr.decode(data, maxPixels, toneMapping);
27682771
if (ImageType.isTGA(dataView)) return tga.decode(data, maxPixels);
2769-
if (ImageType.isGIF(dataView)) throw new Error('GIFs have to be decoded with Animation.decode() (or decode(), which returns an image or an animation)');
2772+
if (ImageType.isGIF(dataView)) {
2773+
// the first frame only: the rest of the file isn't decoded
2774+
const gifData = gif.decode(data, true, maxPixels);
2775+
const [first] = gif.composite(gifData);
2776+
if (!first) throw new Error('GIF: no frames');
2777+
return { width: gifData.width, height: gifData.height, pixels: first.pixels };
2778+
}
27702779
throw new Error('Unsupported image type');
27712780
});
27722781

‎tests/decode-frames.js‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,87 @@ const throwsA = async (fn, type, label) => {
144144
const oneImage = await decode(oneApng);
145145
if (oneImage instanceof Animation || !(oneImage instanceof Image)) panic('one-frame APNG with acTL: decode() should give an Image');
146146
if (sha256(oneImage.bitmap) !== sha256(oneAnimation.frames[0].bitmap)) panic('one-frame APNG with acTL: decode() pixels differ from Animation.decode');
147+
148+
// Image.decode of an animation is its first frame, an Image composed like Animation.decode's first frame: frames that
149+
// differ, with offsets and disposal modes, from our encoders (GIF, APNG, animated WebP, lossless and lossy)
150+
const moving = new Animation([
151+
new Frame(12, 8, {duration: 50}).fill((x, y) => Colors.fromRGBA(x * 20, y * 30, 90, 255)),
152+
new Frame(5, 4, {duration: 70, xOffset: 3, yOffset: 2, disposalMode: 'background'}).fill((x, y) => Colors.fromRGBA(255, x * 50, y * 60, 128)),
153+
new Frame(6, 3, {duration: 40, xOffset: 1, yOffset: 4, disposalMode: 'previous'}).fill(0x00ff00ff),
154+
new Frame(4, 4, {duration: 60, xOffset: 8, yOffset: 4}).fill((x, y) => Colors.fromRGBA(y * 90, 0, 200, 255 - x * 60))
155+
]);
156+
const movingFiles = {
157+
GIF: await moving.encodeGIF(),
158+
'GIF without optimization': await moving.encodeGIF({optimize: false}),
159+
APNG: await moving.encodeAPNG(),
160+
'APNG without optimization': await moving.encodeAPNG({optimize: false}),
161+
'animated WebP': await moving.encodeWebP(),
162+
'lossy animated WebP': await moving.encodeWebP({quality: 80})
163+
};
164+
for (const [name, bytes] of Object.entries(movingFiles)) {
165+
const image = await Image.decode(bytes);
166+
if (image.constructor !== Image) panic(`${name}: Image.decode gives ${image.constructor.name}, not an Image`);
167+
const frames = (await Animation.decode(bytes)).frames;
168+
if (frames.length !== 4) panic(`${name}: ${frames.length} frames instead of 4`);
169+
const reference = frames[0];
170+
if (image.width !== reference.width || image.height !== reference.height) panic(`${name}: ${image.width}x${image.height} instead of ${reference.width}x${reference.height}`);
171+
if (sha256(image.bitmap) !== sha256(reference.bitmap)) panic(`${name}: Image.decode is not the first frame of Animation.decode`);
172+
if (sha256(image.bitmap) === sha256(frames[1].bitmap)) panic(`${name}: the frames should differ`);
173+
}
174+
// options apply to the first frame: maxPixels counts the canvas
175+
await throwsA(() => Image.decode(movingFiles.GIF, {maxPixels: 10}), RangeError, 'Image.decode maxPixels of an animated GIF');
176+
if ((await Image.decode(movingFiles.GIF, {maxPixels: 96})).width !== 12) panic('Image.decode: a GIF whose canvas fits maxPixels should decode');
177+
178+
// an APNG whose default image (IDAT) isn't part of the animation: Image.decode gives that image, what viewers without
179+
// APNG support show, and Animation.decode only the frames of the animation
180+
const pngList = bytes => {
181+
const list = [], view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
182+
for (let at = 8; at < bytes.length;) {
183+
const length = view.getUint32(at);
184+
list.push({type: String.fromCharCode(...bytes.subarray(at + 4, at + 8)), data: bytes.subarray(at + 8, at + 8 + length)});
185+
at += 12 + length;
186+
}
187+
return list;
188+
};
189+
// (more colors than a palette holds, so both are truecolor PNGs without a PLTE chunk to copy)
190+
const defaultPNG = new Uint8Array(await new Image(32, 32).fill((x, y) => Colors.fromRGBA(x * 7, y * 7, (x * y) & 255, 255)).encodePNG());
191+
const framePNG = new Uint8Array(await new Image(32, 32).fill((x, y) => Colors.fromRGBA(0, x * 7, y * 7, 255)).encodePNG());
192+
const ihdr = pngList(defaultPNG).find(c => c.type === 'IHDR').data;
193+
const defaultIDAT = pngList(defaultPNG).find(c => c.type === 'IDAT').data;
194+
const frameIDAT = pngList(framePNG).find(c => c.type === 'IDAT').data;
195+
const actl = new Uint8Array(8);
196+
new DataView(actl.buffer).setUint32(0, 1); // one frame in the animation, which the IDAT isn't part of
197+
const fcTL = new Uint8Array(26), fcView = new DataView(fcTL.buffer);
198+
fcView.setUint32(0, 0); // sequence number
199+
fcView.setUint32(4, 32); // width
200+
fcView.setUint32(8, 32); // height
201+
fcView.setUint16(20, 100); // delay: 100/1000 s
202+
fcView.setUint16(22, 1000);
203+
const fdAT = new Uint8Array(4 + frameIDAT.length);
204+
new DataView(fdAT.buffer).setUint32(0, 1); // sequence number
205+
fdAT.set(frameIDAT, 4);
206+
const separate = Buffer.concat([
207+
defaultPNG.subarray(0, 8), pngChunk('IHDR', ihdr), pngChunk('acTL', actl), pngChunk('IDAT', defaultIDAT),
208+
pngChunk('fcTL', fcTL), pngChunk('fdAT', fdAT), pngChunk('IEND', new Uint8Array(0))
209+
]);
210+
const shown = await Image.decode(separate);
211+
if (shown.constructor !== Image || sha256(shown.bitmap) !== sha256((await Image.decode(defaultPNG)).bitmap)) panic('APNG with a separate default image: Image.decode should give the default image');
212+
const animated = await Animation.decode(separate);
213+
if (animated.length !== 1 || sha256(animated.frames[0].bitmap) !== sha256((await Image.decode(framePNG)).bitmap)) panic('APNG with a separate default image: Animation.decode should give only the animation\'s frame');
214+
215+
// a large animation: Image.decode stops after its first frame, so it takes a fraction of the time of all 50 frames
216+
const many = new Animation(Array.from({length: 50}, (_, i) => new Frame(480, 360, {duration: 40}).fill((x, y) => Colors.fromRGBA((x * 7 + i * 13) & 255, (y * 5 + x * i) & 255, (x ^ y) & 255, 255))));
217+
const large = await many.encodeGIF();
218+
const median = async fn => {
219+
const times = [];
220+
for (let i = 0; i < 3; i++) {
221+
const start = process.hrtime.bigint();
222+
await fn();
223+
times.push(Number(process.hrtime.bigint() - start) / 1e6);
224+
}
225+
return times.sort((a, b) => a - b)[1];
226+
};
227+
const firstMs = await median(() => Image.decode(large));
228+
const allMs = await median(() => Animation.decode(large));
229+
if (firstMs * 3 > allMs) panic(`Image.decode of 50 frames took ${firstMs.toFixed(1)} ms, Animation.decode ${allMs.toFixed(1)} ms: not a third`);
147230
})().catch(error => panic(error.stack || String(error)));

‎tests/option-checks.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,10 @@ const rejects = async (name, fn, Type, pattern) => {
6161
}
6262
await rejects('Animation.decode threads 0', () => Animation.decode(png, {threads: 0}), RangeError, /threads must be/);
6363

64-
// GIFs are refused by Image.decode() with a hint at Animation.decode()
64+
// Image.decode takes the options object of a GIF too: maxPixels counts its first frame
6565
const gif = await new Animation([new Frame(4, 4).fill(0xff0000ff)]).encodeGIF();
66-
await rejects('Image.decode GIF', () => Image.decode(gif), Error, /Animation\.decode\(\)/);
66+
await rejects('Image.decode GIF maxPixels', () => Image.decode(gif, {maxPixels: 15}), RangeError, /maxPixels/);
67+
if ((await Image.decode(gif, {maxPixels: 16})).width !== 4) panic('Image.decode GIF: the first frame should fit maxPixels 16');
6768

6869
// valid values keep working
6970
for (const effort of [0, 1, 2]) await image.encodeAVIF({effort});

‎tests/webp.js‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,11 @@ const animated = [
3636
if (sha256(image.bitmap) !== hash) panic(`${name}: decoded pixels don't match libwebp`);
3737
}
3838

39-
// animated WebPs: Image.decode refuses them, Animation.decode composites the frames
39+
// animated WebPs: Image.decode gives the first frame as libwebp composites it, Animation.decode all of them
4040
const data = await fs.readFile('./tests/webp/animated.webp');
41-
try {
42-
await Image.decode(data);
43-
panic('Image.decode should reject animated WebPs');
44-
} catch (err) {
45-
if (!/Animation\.decode/.test(err.message)) panic(`unexpected error ${err.message}`);
46-
}
41+
const firstFrame = await Image.decode(data);
42+
if (firstFrame.constructor !== Image || firstFrame.width !== 96 || firstFrame.height !== 72) panic('animated WebP: Image.decode should give an Image of the canvas');
43+
if (sha256(firstFrame.bitmap) !== animated[0]) panic('animated WebP: Image.decode is not the first frame libwebp composites');
4744
const animation = await Animation.decode(data);
4845
if (!(await decode(data) instanceof Animation)) panic('decode() should return an Animation for animated WebPs');
4946
if (animation.length !== 4 || animation.loopCount !== Infinity) panic('animated WebP: wrong frame or loop count');

0 commit comments

Comments
 (0)