Skip to content

Commit 2dbd9ef

Browse files
committed
remove the onlyFirstFrame option
1 parent 771cdcd commit 2dbd9ef

13 files changed

Lines changed: 40 additions & 46 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,11 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation
3333

3434
- **The positional arguments of `Image.renderSVG()` are removed**: `Image.renderSVG(svg, size, mode)` is `Image.renderSVG(svg, { size, mode })`, which takes the same `fonts` and `images` as before: `Image.renderSVG(svg, 2, 'width')` is now `Image.renderSVG(svg, { size: 2, mode: 'width' })`.
3535

36-
- **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 })`.
36+
- **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)`, or `Image.decode(data)` for the first frame only.
3737

3838
- **`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.
3939

40-
- **`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.
40+
- **`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`). For the first frame alone, `Image.decode()` returns an `Image`.
4141

4242
- **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.
4343

@@ -47,7 +47,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation
4747

4848
- **Text alignments only take the values of their own axis**: `TextLayout`'s `horizontalAlign` takes `'left'`, `'center'` or `'right'` and `verticalAlign` takes `'top'`, `'middle'` or `'bottom'`. 3.x accepted the other axis' values in either field, which now throws a `TypeError`: `new TextLayout({ verticalAlign: 'right' })` is `new TextLayout({ horizontalAlign: 'right' })`.
4949

50-
- **`decode()` takes options only**: `decode(data, true)` is `decode(data, { onlyFirstFrame: true })`, and a boolean throws a `TypeError` that names the options form.
50+
- **`decode()` takes options only**: `decode(data, true)` is `Image.decode(data)` for the first frame, and a boolean throws a `TypeError` that names the options form.
5151

5252
- **`opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()` reject a second argument**: 3.x's `absolute` call like `image.red(0.5, true)` would silently scale by 0.5 now, so it throws a `TypeError` before the image is touched. Use `image.red(0.5)` to scale and `image.recolor()` for a fixed color.
5353

‎guides/Migrating to 4.0.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,7 @@ animation. Use `Animation.decode()` for all the frames. An APNG still gives its
4949
const first = await Image.decode(data); // the first frame of an animated GIF or WebP
5050
```
5151

52-
Search your code for: `decode(` where the result is used for `encode`, `.length`, indexing or `instanceof GIF`, and
53-
`onlyFirstFrame`.
52+
Search your code for: `decode(` where the result is used for `encode`, `.length`, indexing or `instanceof GIF`.
5453

5554
### `Animation` holds its frames instead of extending `Array`
5655

@@ -265,8 +264,9 @@ object under the names of their options. The options of each method are on its o
265264
| `new Frame(w, h, duration, x, y, disposal)` | `new Frame(w, h, { duration, xOffset, yOffset, disposalMode })` |
266265
| `Frame.from(image, duration, x, y, disposal)` | `Frame.from(image, { duration, xOffset, yOffset, disposalMode })` |
267266
| `new Animation(frames, loopCount)` | `new Animation(frames, { loopCount })` |
268-
| `Animation.decode(data, true)` | `Animation.decode(data, { onlyFirstFrame: true })` |
269-
| `decode(data, true)` | `decode(data, { onlyFirstFrame: true })` |
267+
| `Animation.decode(data, true)` | `Image.decode(data)` for the first frame, `Animation.decode(data)` for all |
268+
| `decode(data, true)` | `Image.decode(data)` |
269+
| `decode(data, { onlyFirstFrame: true })` | `Image.decode(data)` |
270270

271271
`decode()` and `Animation.decode()` take options only: a boolean throws a `TypeError` that names the options form.
272272

@@ -306,7 +306,7 @@ grep -rnE 'iterateWithColors|for \(const \[[a-z]+, ?[a-z]+\] of' src
306306
grep -rnE 'PNGMetadata|AnimationEncoderOptions|AdjustOptions|TextOptions|FrameDisposalModeId|\bGIF\b' src
307307

308308
# decode and animations
309-
grep -rnE 'decode\(|onlyFirstFrame' src
309+
grep -rnE 'decode\(.*, *(true|false)\)' src
310310
grep -rnE 'loopCount\s*(:|=|===)\s*-1|loopCount\s*<\s*0' src
311311
grep -rnE 'Array\.isArray|\.slice\(|\.frames\b|\.length\s*=' src
312312

‎src/animation.ts‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@ export interface AnimationOptions {
3838

3939
/** Options of {@link Animation.decode} */
4040
export interface AnimationDecodeOptions {
41-
/** Whether to stop decoding after the first frame. Default: `false` */
42-
onlyFirstFrame?: boolean;
4341
/**
4442
* The most pixels to decode, of all frames together (every frame is a full canvas): larger animations throw a
4543
* RangeError before anything that size is allocated, so small files declaring huge animations (decompression
@@ -423,7 +421,7 @@ export class Animation {
423421
static decode(input: BinaryData, options?: AnimationDecodeOptions): Promise<Animation>;
424422
static async decode(input: BinaryData, options: AnimationDecodeOptions = {}): Promise<Animation> {
425423
// (checked outside of decoding(), which reports RangeErrors as damaged data)
426-
checkOptions(options, 'Animation.decode(data, { onlyFirstFrame: true })');
424+
checkOptions(options, 'Animation.decode(data, { maxPixels: 1e8 })');
427425
const { maxPixels = MAX_PIXELS, threads = 1 } = options;
428426
if (!Number.isInteger(threads) || threads < 1) throw new RangeError(`threads must be a positive integer, not ${threads}`);
429427
if (!(Number(maxPixels) > 0)) throw new RangeError(`maxPixels must be a positive number, not ${maxPixels}`);
@@ -437,7 +435,7 @@ export class Animation {
437435
* @ignore
438436
*/
439437
private static async __decode__(input: BinaryData, options: AnimationDecodeOptions): Promise<Animation> {
440-
const { onlyFirstFrame: onlyExtractFirstFrame = false, maxPixels = MAX_PIXELS, applyColorProfile = true, threads = Math.max(1, Math.min(availableThreads(), 8)), applyOrientation = true, toneMapping = 'reinhard', gainMap = true } = options;
438+
const { maxPixels = MAX_PIXELS, applyColorProfile = true, threads = Math.max(1, Math.min(availableThreads(), 8)), applyOrientation = true, toneMapping = 'reinhard', gainMap = true } = options;
441439
const orient = (frames: Frame[], orientation: number): Frame[] => {
442440
if (applyOrientation && orientation > 1) for (const frame of frames) frame.__orient__(orientation);
443441
return frames;
@@ -450,10 +448,10 @@ export class Animation {
450448
const data = view(input);
451449
const type = ImageType.getType(data);
452450

453-
if (type === 'gif') return Animation.__gifFrames__(data, onlyExtractFirstFrame, maxPixels);
451+
if (type === 'gif') return Animation.__gifFrames__(data, maxPixels);
454452

455453
if (type === 'webp') {
456-
const decoded = webp.decodeAnimation(data, onlyExtractFirstFrame, maxPixels);
454+
const decoded = webp.decodeAnimation(data, false, maxPixels);
457455
const frames = orient(profile(canvasFrames(decoded.width, decoded.height, webp.composite(decoded)), decoded.icc), decoded.orientation);
458456
return new Animation(frames, { loopCount: fromPlays(decoded.loopCount) });
459457
}
@@ -464,7 +462,7 @@ export class Animation {
464462
return new Animation(canvasFrames(image.width, image.height, [{ pixels: image.bitmap, duration: 0 }]), { loopCount: Infinity });
465463
}
466464
if (type === 'png') {
467-
const decoded = await png.decodeAnimation(data, onlyExtractFirstFrame, maxPixels);
465+
const decoded = await png.decodeAnimation(data, false, maxPixels);
468466
const frames = orient(profile(canvasFrames(decoded.width, decoded.height, png.compositeAnimation(decoded)), decoded.icc), decoded.orientation);
469467
return new Animation(frames, { loopCount: decoded.frames.length > 1 ? fromPlays(decoded.plays) : Infinity });
470468
}
@@ -481,7 +479,6 @@ export class Animation {
481479
loops = fromPlays(decoded.loopCount);
482480
icc = decoded.icc;
483481
space = decoded.colorSpace;
484-
if (onlyExtractFirstFrame) break;
485482
}
486483
// (a gain map that can't be read leaves the frames without one, it doesn't fail the decoding)
487484
const map = gainMap && data[0] !== 0xff ? await jxl.decodeGainMap(data, maxPixels).catch(() => undefined) : undefined;
@@ -510,8 +507,8 @@ export class Animation {
510507
* @internal
511508
* @ignore
512509
*/
513-
private static __gifFrames__(data: Uint8Array, onlyExtractFirstFrame: boolean, maxPixels: number): Animation {
514-
const decoded = gifCodec.decode(data, onlyExtractFirstFrame, maxPixels);
510+
private static __gifFrames__(data: Uint8Array, maxPixels: number): Animation {
511+
const decoded = gifCodec.decode(data, false, maxPixels);
515512
const frames = gifCodec.composite(decoded).map(({ pixels, delay }) => ({ pixels, duration: 10 * delay }));
516513
return new Animation(canvasFrames(decoded.width, decoded.height, frames), { loopCount: decoded.loopCount });
517514
}

‎src/codecs/gif.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,10 @@ function writeSubBlocks(out: GIFWriter, data: Uint8Array): void {
219219
/**
220220
* Decodes a GIF into its individual (uncomposited) frames, zero-sized images are skipped
221221
* @param {Uint8Array} data
222-
* @param {boolean} [onlyFirstFrame=false]
222+
* @param {boolean} [firstFrameOnly=false]
223223
* @return {{width: number, height: number, loopCount: number, frames: GIFFrame[]}}
224224
*/
225-
export function decode(data: Uint8Array, onlyFirstFrame = false, maxPixels = MAX_PIXELS): DecodedGIF {
225+
export function decode(data: Uint8Array, firstFrameOnly = false, maxPixels = MAX_PIXELS): DecodedGIF {
226226
const reader = new ByteReader(data);
227227

228228
const signature = reader.string(6);
@@ -329,7 +329,7 @@ export function decode(data: Uint8Array, onlyFirstFrame = false, maxPixels = MAX
329329
}
330330

331331
frames.push({ x, y, width: fw, height: fh, delay, dispose, indices: ordered, colors, transparentIndex });
332-
if (onlyFirstFrame) break;
332+
if (firstFrameOnly) break;
333333
}
334334
}
335335
}

‎src/codecs/png.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -718,12 +718,12 @@ export function isAnimated(array: Uint8Array): boolean {
718718
* a single frame
719719
* @return plays: 0 = forever
720720
*/
721-
export async function decodeAnimation(array: Uint8Array, onlyFirstFrame = false, maxPixels = MAX_PIXELS): Promise<{ width: number, height: number, plays: number, frames: APNGFrame[], icc?: Uint8Array, orientation: number }> {
721+
export async function decodeAnimation(array: Uint8Array, firstFrameOnly = false, maxPixels = MAX_PIXELS): Promise<{ width: number, height: number, plays: number, frames: APNGFrame[], icc?: Uint8Array, orientation: number }> {
722722
const p = parse(array);
723723
const { width, height } = p.header;
724724
const orientation = p.exif ? exifOrientation(p.exif) : await textOrientation(p.text);
725725
// every frame is composited onto a canvas of its own
726-
checkPixels(width, height, maxPixels, !p.animation || p.animationError || onlyFirstFrame ? 1 : Math.max(1, p.frames.length));
726+
checkPixels(width, height, maxPixels, !p.animation || p.animationError || firstFrameOnly ? 1 : Math.max(1, p.frames.length));
727727

728728
// broken animations show the default image, like browsers do
729729
if (!p.animation || p.animationError) {
@@ -741,7 +741,7 @@ export async function decodeAnimation(array: Uint8Array, onlyFirstFrame = false,
741741
blend: c.blend,
742742
pixels: await decodeImageData(p, data, c.width, c.height)
743743
});
744-
if (onlyFirstFrame) break;
744+
if (firstFrameOnly) break;
745745
}
746746

747747
return { width, height, plays: p.animation.plays, frames, icc: await profile(p.iccp), orientation };

‎src/codecs/webp.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ export function decode(data: Uint8Array, maxPixels = MAX_PIXELS): { width: numbe
183183
/**
184184
* Decodes an animated WebP into its frames (not composited); still WebPs become a single frame
185185
*/
186-
export function decodeAnimation(data: Uint8Array, onlyFirstFrame = false, maxPixels = MAX_PIXELS): WebPAnimation {
186+
export function decodeAnimation(data: Uint8Array, firstFrameOnly = false, maxPixels = MAX_PIXELS): WebPAnimation {
187187
const list = container(data);
188188
const header = list[0];
189189
if (!header || header.type !== VP8X || !list.some(c => c.type === ANMF)) {
@@ -194,7 +194,7 @@ export function decodeAnimation(data: Uint8Array, onlyFirstFrame = false, maxPix
194194
if (header.data.length < 10) throw new Error('WebP: invalid VP8X chunk');
195195
const width = u24(header.data, 4) + 1, height = u24(header.data, 7) + 1;
196196
// every frame is composited onto a canvas of its own
197-
checkPixels(width, height, maxPixels, onlyFirstFrame ? 1 : list.filter(c => c.type === ANMF).length);
197+
checkPixels(width, height, maxPixels, firstFrameOnly ? 1 : list.filter(c => c.type === ANMF).length);
198198
let loopCount = 0, background = 0;
199199
const frames: WebPFrame[] = [];
200200

@@ -218,7 +218,7 @@ export function decodeAnimation(data: Uint8Array, onlyFirstFrame = false, maxPix
218218
dispose: (d[15] & 1) === 1,
219219
pixels: frame.pixels
220220
});
221-
if (onlyFirstFrame) break;
221+
if (firstFrameOnly) break;
222222
}
223223
}
224224

‎src/index.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,13 @@ export type { Metadata } from './metadata.ts';
3131
* returned as an {@link Animation} of its frames, and any other file as an {@link Image}: a file with a single frame
3232
* (also a GIF, APNG, WebP or JPEG XL with one frame) becomes an Image of that frame, without its offsets and duration.
3333
* Static formats (PNG, JPEG, AVIF, ...) are always Images, AVIF image sequences decode to their first frame, as
34-
* {@link Image.decode} does. The options are passed on to {@link Animation.decode} or {@link Image.decode}. With
35-
* `onlyFirstFrame` the rest of the file isn't decoded, so the frame count isn't known: the first frame is returned as
36-
* an Image.
34+
* {@link Image.decode} does. The options are passed on to {@link Animation.decode} or {@link Image.decode}.
3735
* @param data The image data
3836
* @param options See {@link AnimationDecodeOptions} and {@link DecodeOptions}
3937
*/
4038
export function decode(data: BinaryData, options?: AnimationDecodeOptions & DecodeOptions): Promise<Image | Animation>;
4139
export async function decode(data: BinaryData, options: AnimationDecodeOptions & DecodeOptions = {}): Promise<Image | Animation> {
42-
checkOptions(options, 'decode(data, { onlyFirstFrame: true })');
40+
checkOptions(options, 'decode(data, { maxPixels: 1e8 })');
4341
const bytes = view(data);
4442
const type = ImageType.getType(bytes);
4543
const animated = type === 'gif' || type === 'jxl' || (type === 'webp' && webp.isAnimated(bytes)) || (type === 'png' && png.isAnimated(bytes));

‎tests/decode-frames.js‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,10 @@ const throwsA = async (fn, type, label) => {
6565
if (sha256(image.bitmap) !== sha256(reference.bitmap)) panic(`${name}: pixels differ from Image.decode`);
6666
}
6767

68-
// options pass through: onlyFirstFrame gives the first frame as an Image, maxPixels applies to both kinds
69-
const first = await decode(gif, {onlyFirstFrame: true});
70-
if (first instanceof Animation || !(first instanceof Image)) panic('onlyFirstFrame: expected an Image');
71-
if (sha256(first.bitmap) !== sha256((await Animation.decode(gif, {onlyFirstFrame: true})).frames[0].bitmap)) panic('onlyFirstFrame: wrong frame');
68+
// options pass through: Image.decode gives the first frame as an Image, maxPixels applies to both kinds
69+
const first = await Image.decode(gif);
70+
if (first instanceof Animation || !(first instanceof Image)) panic('Image.decode: expected an Image');
71+
if (sha256(first.bitmap) !== sha256((await Animation.decode(gif)).frames[0].bitmap)) panic('Image.decode: wrong frame');
7272
await throwsA(() => decode(gif, {maxPixels: 100}), RangeError, 'maxPixels on a GIF');
7373
const jpeg = await fs.readFile('./tests/targets/external.jpg');
7474
await throwsA(() => decode(jpeg, {maxPixels: 100}), RangeError, 'maxPixels on a JPEG');

‎tests/gif.js‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const fs = require('fs').promises;
22
const crypto = require('crypto');
3-
const {Frame, FrameDisposalModes, Animation, Colors} = require('..');
3+
const {Image, Frame, FrameDisposalModes, Animation, Colors} = require('..');
44

55
const panic = message => {
66
console.error(message);
@@ -35,9 +35,9 @@ const panic = message => {
3535
panic('decode: incorrect frame bitmap length');
3636
}
3737

38-
const first = await Animation.decode(binary, {onlyFirstFrame: true});
39-
if (first.length !== 1)
40-
panic('decode: onlyExtractFirstFrame returned more than one frame');
38+
const first = await Image.decode(binary);
39+
if (first.constructor !== Image)
40+
panic('decode: Image.decode should return the first frame as an Image');
4141
}
4242

4343
{

‎tests/jxl.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,9 @@ const difference = (bitmap, width, height, reference, step = 1, premultiplied =
5151
}
5252

5353
// the first frame only; Animation.decode of a still image
54-
const first = await Animation.decode(await fs.readFile('./tests/jxl/animation.jxl'), {onlyFirstFrame: true});
55-
if (first.length !== 1) panic(`onlyFirstFrame: ${first.length} frames`);
54+
const animated = await fs.readFile('./tests/jxl/animation.jxl');
55+
const first = await Image.decode(animated);
56+
if (first.constructor !== Image || !Buffer.from(first.bitmap).equals(Buffer.from((await Animation.decode(animated)).frames[0].bitmap))) panic('Image.decode: the first frame of an animated JPEG XL should be Animation.decode\'s first frame');
5657
const still = await Animation.decode(await fs.readFile('./tests/jxl/lossy.jxl'));
5758
if (still.length !== 1 || still.frames[0].width !== 64) panic('a still JPEG XL as an animation');
5859

0 commit comments

Comments
 (0)