Skip to content

Commit e236aa1

Browse files
committed
refuse loop counts that don't fit the format
1 parent dfd434b commit e236aa1

5 files changed

Lines changed: 68 additions & 2 deletions

File tree

‎CHANGELOG.md‎

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

3+
## 3.20.2
4+
5+
### Fixes
6+
7+
- Loop counts that don't fit the file format are refused with a `RangeError` naming the format's largest count, instead of wrapping around: a GIF with `loopCount: 65536` was written with 0, which plays forever (GIF and animated WebP store up to 65535 and 65534, APNG up to 2147483646). This applies to `encodeGIF()`, `encodeWebP()` and `encodeAPNG()` of animations and to the streaming encoders.
8+
39
## 3.20.1
410

511
### Performance

‎src/animation-encoder.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Frame, FrameOptions } from './frame.ts';
66
import { Optimizer, Compositor, InputFrame, OptimizeOptions, OptimizedFrame, coverCanvas, fillUnchanged } from './util/optimize.ts';
77
import { compressAsync, decompressAsync } from './codecs/zlib.ts';
88
import { resolveMetadata, pngText, exifData, gifComments } from './metadata.ts';
9-
import { finite, compressionLevel } from './util/options.ts';
9+
import { finite, compressionLevel, fitLoops } from './util/options.ts';
1010
import type { AnimationOptions, AnimationEncodeOptions, WebPAnimationEncodeOptions, GIFEncodeOptions, APNGEncodeOptions } from './animation.ts';
1111

1212
// (ImageScript, like GIF, counts repetitions: -1 forever, 0 play once; APNG and WebP count plays, 0 forever)
@@ -134,6 +134,7 @@ export class AnimationEncoder {
134134
/** @internal */
135135
static __webp__(width: number, height: number, { loopCount = -1, metadata = {}, optimize = true, quality, effort = 2, sharpYuv = false, alphaQuality = 100 }: WebPAnimationEncodeOptions & AnimationOptions): AnimationEncoder {
136136
canvas(width, height, loopCount);
137+
fitLoops(loopCount, 'WebP');
137138
if (width > 1 << 24 || height > 1 << 24) throw new RangeError('WebP: canvas too large');
138139
const lossy = webp.lossyOptions(quality, effort, sharpYuv, alphaQuality);
139140
const resolved = resolveMetadata(metadata), chunks: Uint8Array[] = [];
@@ -164,6 +165,7 @@ export class AnimationEncoder {
164165
static __gif__(width: number, height: number, { loopCount = -1, metadata = {}, optimize = true, quality: qualityOption = 95 }: GIFEncodeOptions & AnimationOptions): AnimationEncoder {
165166
const quality = finite(qualityOption, 'quality');
166167
canvas(width, height, loopCount);
168+
fitLoops(loopCount, 'GIF');
167169
const comments = gifComments(resolveMetadata(metadata));
168170
// (the first frame is encoded last: it needs a transparent index if any frame restores the background)
169171
let first: gifCodec.GIFFrame | null = null, restores = false;
@@ -191,6 +193,7 @@ export class AnimationEncoder {
191193
static __apng__(width: number, height: number, { loopCount = -1, metadata = {}, optimize = true, compression: compressionOption = 4 }: APNGEncodeOptions & AnimationOptions): AnimationEncoder {
192194
const compression = compressionLevel(compressionOption);
193195
canvas(width, height, loopCount);
196+
fitLoops(loopCount, 'APNG');
194197
const text = pngText(resolveMetadata(metadata));
195198
// the frames are kept compressed (quickly): the pixel format, and whether opaque animations are stored without
196199
// transparency, depend on all of them; then they are converted and compressed one at a time, like encodeAPNG

‎src/animation.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { Frame, FrameDisposalModes } from './frame.ts';
1212
import { ImageType } from './image-type.ts';
1313
import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts';
1414
import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts';
15-
import { isOptions, finite, compressionLevel } from './util/options.ts';
15+
import { isOptions, finite, compressionLevel, fitLoops } from './util/options.ts';
1616
import { MAX_PIXELS, decoding } from './util/limits.ts';
1717
import { AnimationEncoder } from './animation-encoder.ts';
1818

@@ -297,6 +297,7 @@ export class Animation extends Array<Frame> {
297297
encodeGIF(quality?: number): Promise<Uint8Array>;
298298
async encodeGIF(qualityOrOptions: number | GIFEncodeOptions = 95): Promise<Uint8Array> {
299299
const { quality = 95, metadata = {}, optimize = true } = isOptions(qualityOrOptions) ? qualityOrOptions : { quality: qualityOrOptions };
300+
fitLoops(this.loopCount, 'GIF');
300301
this.__validate__();
301302
const frames = optimize
302303
? this.__optimized__({ replace: false, even: false, alpha: 'frame', palette: 256, cover: true }).map(f => ({ ...f, dispose: f.dispose ? 3 : 1 }))
@@ -334,6 +335,7 @@ export class Animation extends Array<Frame> {
334335
encodeAPNG(compression?: number): Promise<Uint8Array>;
335336
async encodeAPNG(compressionOrOptions: number | APNGEncodeOptions = 4): Promise<Uint8Array> {
336337
const { compression = 4, metadata = {}, optimize = true } = isOptions(compressionOrOptions) ? compressionOrOptions : { compression: compressionOrOptions };
338+
fitLoops(this.loopCount, 'APNG');
337339
this.__validate__();
338340
if (!this.length) throw new Error('An animation needs at least one frame');
339341

@@ -350,6 +352,7 @@ export class Animation extends Array<Frame> {
350352
* @param options See {@link WebPAnimationEncodeOptions}
351353
*/
352354
async encodeWebP({ metadata = {}, optimize = true, quality, effort = 2, sharpYuv = false, alphaQuality = 100 }: WebPAnimationEncodeOptions = {}): Promise<Uint8Array> {
355+
fitLoops(this.loopCount, 'WebP');
353356
this.__validate__();
354357
if (!this.length) throw new Error('An animation needs at least one frame');
355358
const lossy = webp.lossyOptions(quality, effort, sharpYuv, alphaQuality);

‎src/util/options.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,17 @@ export const compressionLevel = (value: number): number => Math.min(9, Math.max(
3131

3232
/** A color argument: a finite number (as RGBA, 0xrrggbbaa) */
3333
export const color = (value: number, name = 'color'): number => finite(value, name);
34+
35+
// the largest finite loop count each format stores: GIF 16 bits of repeats, WebP 16 bits of plays (count + 1), APNG 31
36+
// bits of plays
37+
const LOOP_LIMITS = { GIF: 65535, WebP: 65534, APNG: 2 ** 31 - 2 };
38+
39+
/**
40+
* Checks that a finite loop count fits the format's field (a larger one used to wrap around, e.g. to 0, which plays
41+
* forever): throws a RangeError naming the format's largest count. Forever (-1 or Infinity) always fits
42+
*/
43+
export function fitLoops(loopCount: number, format: keyof typeof LOOP_LIMITS): number {
44+
if (loopCount !== Infinity && loopCount > LOOP_LIMITS[format])
45+
throw new RangeError(`${format} stores loop counts up to ${LOOP_LIMITS[format]} (or Infinity to loop forever), not ${loopCount}`);
46+
return loopCount;
47+
}

‎tests/loop-limits.js‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
const {Animation, Frame} = require('..');
2+
3+
const panic = message => {
4+
console.error(message);
5+
process.exit(1);
6+
};
7+
8+
// loop counts are stored in fixed-size fields: a count that doesn't fit used to wrap around (65536 repeats became 0, which
9+
// plays forever), now it's refused; the largest count of each format round trips
10+
(async () => {
11+
const frames = () => [0xff0000ff, 0x00ff00ff].map(color => Frame.from(new Animation([new Frame(4, 4)])[0].fill(color), {duration: 50}));
12+
const limits = {GIF: 65535, WebP: 65534, APNG: 2 ** 31 - 2};
13+
const encode = {GIF: a => a.encodeGIF(), WebP: a => a.encodeWebP(), APNG: a => a.encodeAPNG()};
14+
const factory = {GIF: Animation.gifEncoder, WebP: Animation.webpEncoder, APNG: Animation.apngEncoder};
15+
16+
for (const [format, max] of Object.entries(limits)) {
17+
const decoded = await Animation.decode(await encode[format](new Animation(frames(), {loopCount: max})));
18+
if (decoded.loopCount !== max) panic(`${format}: loop count ${max} read back as ${decoded.loopCount}`);
19+
for (const forever of [-1, Infinity]) {
20+
const again = await Animation.decode(await encode[format](new Animation(frames(), {loopCount: forever})));
21+
if (again.loopCount !== -1 && again.loopCount !== Infinity) panic(`${format}: loop count ${forever} read back as ${again.loopCount}`);
22+
}
23+
24+
for (const count of [max + 1, max + 1000, 2 ** 40]) {
25+
let error = null;
26+
try { await encode[format](new Animation(frames(), {loopCount: count})); } catch (e) { error = e; }
27+
if (!(error instanceof RangeError) || !error.message.includes(String(max))) panic(`${format}: loop count ${count} should be refused with the largest count, got ${error}`);
28+
29+
error = null;
30+
try { factory[format].call(Animation, 4, 4, {loopCount: count}); } catch (e) { error = e; }
31+
if (!(error instanceof RangeError)) panic(`${format} encoder: loop count ${count} should be refused, got ${error}`);
32+
}
33+
34+
// the streaming encoder writes the same count
35+
const encoder = factory[format].call(Animation, 4, 4, {loopCount: max});
36+
for (const frame of frames()) await encoder.add(frame);
37+
const streamed = await Animation.decode(await encoder.finish());
38+
if (streamed.loopCount !== max) panic(`${format} encoder: loop count ${max} read back as ${streamed.loopCount}`);
39+
}
40+
})().catch(error => panic(error.stack || String(error)));

0 commit comments

Comments
 (0)