diff --git a/CHANGELOG.md b/CHANGELOG.md
index 48c1f176..cc942fc9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
# Changelog
+## 3.1.0 (unreleased)
+
+### New
+
+- **APNG** decoding and encoding (#6): `Animation.decode()` reads animated PNGs (broken animations fall back to the
+ default image, like browsers do), `Animation.encodeAPNG()` writes them with full alpha. `Image.decode()` still
+ returns an APNG's default image.
+- **`Animation`**, the base class of `GIF`: an array of frames that decodes GIF and APNG (`Animation.decode()`) and
+ encodes `encodeGIF()` and `encodeAPNG()`. `GIF` keeps its API.
+
+### Deprecated
+
+- **`GIF`**: use `Animation`, which works the same and also handles APNG (`new GIF(frames).encode()` becomes
+ `new Animation(frames).encodeGIF()`, `GIF.decode()` becomes `Animation.decode()`). `GIF` will be removed in 4.0.
+
## 3.0.0
ImageScript is now written in TypeScript and has no WebAssembly or native binaries anymore. Every codec and renderer
diff --git a/README.md b/README.md
index 0a2bf12d..05579bde 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
**ImageScript** is a zero-dependency alternative to common JavaScript bitmap image manipulation tools. Every codec and
renderer is written in TypeScript: no WebAssembly, no native binaries, no npm dependencies, no install scripts. The
-package is about 120 kB (compressed) and runs in Node.js 22.13 or newer (including Alpine/musl, ARM and bundled
+package is about 140 kB (compressed) and runs in Node.js 22.13 or newer (including Alpine/musl, ARM and bundled
serverless functions), Deno, Bun and browsers. It works with both `import` and `require()`, type definitions are included.
```sh
@@ -26,7 +26,8 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
- JPEGs (baseline and progressive; grayscale, RGB, CMYK)
- TIFFs (strips and tiles; uncompressed, LZW, deflate, PackBits and JPEG compressed; BigTIFF)
- JPEGs, TIFFs and PNGs are rotated as their Exif (or, for PNGs, XMP) orientation says
-- [Decoding GIFs](https://imagescript.matmen.dev/classes/GIF.html#decode) with all frames, durations and disposal modes
+- [Decoding animations](https://imagescript.matmen.dev/classes/Animation.html#decode) (GIF and APNG) with all
+ frames, durations and disposal modes
- [Rendering SVGs](https://imagescript.matmen.dev/classes/Image.html#rendersvg) (shapes, paths, gradients, strokes, clip paths,
masks, CSS; no text, images or filters)
- [Rendering vector fonts](https://imagescript.matmen.dev/classes/Image.html#rendertext) (TrueType and OpenType, with kerning and outlines)
@@ -44,7 +45,8 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
, [dominantColor](https://imagescript.matmen.dev/classes/Image.html#dominantcolor), ...)
- Encoding images as [PNGs](https://imagescript.matmen.dev/classes/Image.html#encode) (smallest lossless color type)
, [JPEGs](https://imagescript.matmen.dev/classes/Image.html#encodejpeg)
- and animated [GIFs](https://imagescript.matmen.dev/classes/GIF.html#encode)
+- Encoding animations as [GIF](https://imagescript.matmen.dev/classes/Animation.html#encodegif) and
+ [APNG](https://imagescript.matmen.dev/classes/Animation.html#encodeapng)
---
diff --git a/src/animation.ts b/src/animation.ts
new file mode 100644
index 00000000..5358aa70
--- /dev/null
+++ b/src/animation.ts
@@ -0,0 +1,194 @@
+import * as gifCodec from './codecs/gif.ts';
+import * as png from './codecs/png.ts';
+import { BinaryData, view } from './util/mem.ts';
+import { Image, ResizeMode } from './image.ts';
+import { Frame } from './frame.ts';
+import { ImageType } from './image-type.ts';
+
+// loop counts: ImageScript (like GIF) counts repetitions (-1 forever, 0 play once), APNG counts plays (0 forever)
+const toPlays = (loopCount: number): number => loopCount < 0 ? 0 : loopCount + 1;
+const fromPlays = (plays: number): number => plays === 0 ? -1 : plays - 1;
+
+// frames decoded fully composited, as full canvas frames
+function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uint8Array, duration: number }>): Frame[] {
+ return frames.map(({ pixels, duration }) => {
+ const frame = new Frame(width, height, duration, 0, 0, Frame.DISPOSAL_BACKGROUND);
+ frame.bitmap.set(pixels);
+ return frame;
+ });
+}
+
+/**
+ * An animation: an array of frames that can be decoded from and encoded to GIF and APNG.
+ * Array methods like map and filter return plain arrays.
+ */
+export class Animation extends Array {
+ /** How many times to repeat the animation (-1 = forever, 0 = play once) */
+ loopCount: number;
+
+ /**
+ * Creates a new animation
+ * @param frames The frames to create the animation from
+ * @param loopCount How many times to repeat the animation (-1 = forever, 0 = play once)
+ */
+ constructor(frames: Frame[], loopCount = -1) {
+ super();
+ // push instead of super(...frames), so a single frame isn't taken as a length
+ for (const frame of frames) this.push(frame);
+
+ for (let i = 0; i < this.length; i++)
+ if (!(this[i] instanceof Frame))
+ throw new TypeError(`Frame ${i} is not an instance of Frame`);
+
+ if (loopCount < -1 || isNaN(loopCount))
+ throw new RangeError('Invalid loop count');
+
+ this.loopCount = loopCount;
+ }
+
+ /** @internal */
+ static override get [Symbol.species](): ArrayConstructor {
+ return Array;
+ }
+
+ /** The animation's width */
+ get width(): number {
+ let max = 0;
+ for (const frame of this) max = Math.max(max, frame.width + frame.xOffset);
+ return max;
+ }
+
+ /** The animation's height */
+ get height(): number {
+ let max = 0;
+ for (const frame of this) max = Math.max(max, frame.height + frame.yOffset);
+ return max;
+ }
+
+ /** The animation's duration (in ms) */
+ get duration(): number {
+ let duration = 0;
+ for (const frame of this) duration += frame.duration;
+ return duration;
+ }
+
+ override toString(): string {
+ return `Animation<${this.width}x${this.height}x${this.duration}ms>`;
+ }
+
+ /**
+ * Returns a new animation of the same class containing a range of this one's frames
+ * (same semantics as Array.prototype.slice)
+ */
+ override slice(start?: number, end?: number): this {
+ const Class = this.constructor as new (frames: Frame[], loopCount: number) => this;
+ return new Class(Array.prototype.slice.call(this, start, end) as Frame[], this.loopCount);
+ }
+
+ /**
+ * Resizes the animation to the given dimensions, scaling every frame and its offsets proportionally.
+ * Use {@link Image.RESIZE_AUTO} as either width or height to automatically preserve the aspect ratio.
+ */
+ resize(width: number, height: number, mode: ResizeMode = Image.RESIZE_BILINEAR): this {
+ const originalWidth = this.width, originalHeight = this.height;
+ if (width === Image.RESIZE_AUTO && height === Image.RESIZE_AUTO) throw new Error('RESIZE_AUTO can only be used for either width or height, not for both');
+ if (width === Image.RESIZE_AUTO) width = originalWidth / originalHeight * height;
+ if (height === Image.RESIZE_AUTO) height = originalHeight / originalWidth * width;
+
+ const sx = width / originalWidth, sy = height / originalHeight;
+ for (const frame of this) {
+ // scale both edges so adjacent frames stay aligned
+ const left = Math.round(frame.xOffset * sx), top = Math.round(frame.yOffset * sy);
+ const right = Math.round((frame.xOffset + frame.width) * sx), bottom = Math.round((frame.yOffset + frame.height) * sy);
+ Image.prototype.resize.call(frame, Math.max(1, right - left), Math.max(1, bottom - top), mode);
+ frame.xOffset = left;
+ frame.yOffset = top;
+ }
+
+ return this;
+ }
+
+ /** @internal */
+ protected __validate__(): void {
+ for (const frame of this)
+ if (!(frame instanceof Frame)) throw new Error('Animation contains invalid frames');
+ }
+
+ /**
+ * Encodes the animation as GIF
+ * @param quality 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above)
+ */
+ async encodeGIF(quality = 95): Promise {
+ this.__validate__();
+ const frames: gifCodec.GIFFrame[] = [];
+
+ for (const frame of this)
+ frames.push({
+ x: frame.xOffset,
+ y: frame.yOffset,
+ width: frame.width,
+ height: frame.height,
+ // gifs store centiseconds; rounding keeps e.g. 16 ms (60 fps) from becoming 10 ms
+ delay: Math.round(frame.duration / 10),
+ dispose: frame.__disposal_id__,
+ pixels: new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength)
+ });
+
+ return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames }, quality);
+ }
+
+ /**
+ * Encodes the animation as APNG (animated PNG, lossless and with full alpha). Viewers without APNG support show
+ * the first frame
+ * @param compression The zlib compression level (0-9), higher is smaller but slower
+ */
+ async encodeAPNG(compression = 1): Promise {
+ this.__validate__();
+ if (!this.length) throw new Error('An animation needs at least one frame');
+
+ return png.encodeAnimation(this.__apngFrames__(), this.width, this.height, toPlays(this.loopCount), compression);
+ }
+
+ /** @internal frames in the APNG codec's terms (alpha blended, disposal 0 none, 1 background, 2 previous) */
+ __apngFrames__(): png.APNGFrame[] {
+ const dispose = [0, 0, 2, 1];
+ return Array.from(this, frame => ({
+ x: frame.xOffset,
+ y: frame.yOffset,
+ width: frame.width,
+ height: frame.height,
+ duration: frame.duration,
+ dispose: dispose[frame.__disposal_id__],
+ blend: 1,
+ pixels: new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength)
+ }));
+ }
+
+ /**
+ * Decodes an animated GIF or APNG into fully composited frames (still images become a single frame)
+ * @param input The binary data to decode
+ * @param onlyExtractFirstFrame Whether to stop decoding after the first frame
+ */
+ static async decode(input: BinaryData, onlyExtractFirstFrame = false): Promise {
+ const data = view(input);
+ const type = ImageType.getType(data);
+
+ if (type === 'gif') return Animation.__fromGIF__(data, onlyExtractFirstFrame, Animation);
+
+ if (type === 'png') {
+ const decoded = await png.decodeAnimation(data, onlyExtractFirstFrame);
+ const frames = canvasFrames(decoded.width, decoded.height, png.compositeAnimation(decoded));
+ return new Animation(frames, decoded.frames.length > 1 ? fromPlays(decoded.plays) : -1);
+ }
+
+ if (type) return new Animation([Frame.from(await Image.decode(data), 0)]);
+ throw new Error('Unsupported image type');
+ }
+
+ /** @internal */
+ static __fromGIF__(data: Uint8Array, onlyExtractFirstFrame: boolean, Class: new (frames: Frame[], loopCount: number) => T): T {
+ const decoded = gifCodec.decode(data, onlyExtractFirstFrame);
+ const frames = gifCodec.composite(decoded).map(({ pixels, delay }) => ({ pixels, duration: 10 * delay }));
+ return new Class(canvasFrames(decoded.width, decoded.height, frames), decoded.loopCount);
+ }
+}
diff --git a/src/codecs/png.ts b/src/codecs/png.ts
index 2e7643ac..f1409719 100644
--- a/src/codecs/png.ts
+++ b/src/codecs/png.ts
@@ -14,6 +14,9 @@ const eXIf = 0x65584966;
const tEXt = 0x74455874;
const zTXt = 0x7a545874;
const iTXt = 0x69545874;
+const acTL = 0x6163544c;
+const fcTL = 0x6663544c;
+const fdAT = 0x66644154;
const COLOR_TYPE_CHANNELS: Record = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
const CHANNELS_TO_COLOR_TYPE: Record = { 1: 0, 2: 4, 3: 2, 4: 6 };
@@ -130,37 +133,42 @@ function chunk(type: number, body: Uint8Array): Uint8Array {
return out;
}
+interface PixelFormat {
+ channels: number;
+ depth: number;
+ palette?: Uint8Array;
+ transparency?: Uint8Array;
+ /** converts RGBA pixels to this format's packed rows */
+ convert(rgba: Uint8Array, width: number, height: number): Uint8Array;
+}
+
/**
- * Encodes 8-bit RGBA pixels into a PNG with the smallest lossless color type:
- * a palette for up to 256 colors, otherwise greyscale or RGB, with alpha only if a pixel isn't opaque.
- * Rows of non-palette images are paeth filtered (unless the data isn't compressed), palette indices aren't
- * filtered (like libpng recommends)
+ * Picks the smallest lossless format that fits all the given RGBA images: a palette for up to 256 colors,
+ * otherwise greyscale or RGB, with alpha only if a pixel isn't opaque
*/
-export async function encodeRGBA(rgba: Uint8Array, width: number, height: number, level: number, text?: EncodeOptions['text']): Promise {
- const count = width * height;
- if (rgba.byteOffset % 4) rgba = rgba.slice(0, count * 4);
- const pixels = new Uint32Array(rgba.buffer, rgba.byteOffset, count);
+function pixelFormat(images: Uint8Array[]): PixelFormat {
let opaque = true, grey = true;
// collect up to 257 distinct colors, runs of equal pixels are skipped cheaply
let colors: Map | null = new Map();
- let previous = -1;
- for (let i = 0; i < count; i++) {
- const value = pixels[i];
- if (value === previous) continue;
- previous = value;
-
- const o = i * 4;
- if (rgba[o + 3] !== 255) opaque = false;
- if (rgba[o] !== rgba[o + 1] || rgba[o] !== rgba[o + 2]) grey = false;
- if (colors && !colors.has(value)) {
- if (colors.size === 256) colors = null;
- else colors.set(value, colors.size);
+ for (const rgba of images) {
+ const pixels = new Uint32Array(rgba.buffer, rgba.byteOffset, rgba.length >> 2);
+ let previous = -1;
+ for (let i = 0; i < pixels.length; i++) {
+ const value = pixels[i];
+ if (value === previous) continue;
+ previous = value;
+
+ const o = i * 4;
+ if (rgba[o + 3] !== 255) opaque = false;
+ if (rgba[o] !== rgba[o + 1] || rgba[o] !== rgba[o + 2]) grey = false;
+ if (colors && !colors.has(value)) {
+ if (colors.size === 256) colors = null;
+ else colors.set(value, colors.size);
+ }
}
}
- const options = { width, height, level, text, filter: level > 0 };
-
if (colors) {
// opaque entries are moved to the end, so tRNS can be cut short
const entries = [...colors.keys()].map(value => new Uint8Array(new Uint32Array([value]).buffer));
@@ -172,65 +180,220 @@ export async function encodeRGBA(rgba: Uint8Array, width: number, height: number
entries.forEach((bytes, i) => palette.set(bytes.subarray(0, 3), i * 3));
const translucent = entries.filter(bytes => bytes[3] !== 255).length;
const transparency = translucent ? Uint8Array.from(entries.slice(0, translucent), bytes => bytes[3]) : undefined;
-
const depth = entries.length <= 2 ? 1 : entries.length <= 4 ? 2 : entries.length <= 16 ? 4 : 8;
- const rowBytes = Math.ceil(width * depth / 8);
- const perByte = 8 / depth;
- const data = new Uint8Array(rowBytes * height);
-
- let last = -1, lastIndex = 0;
- for (let y = 0; y < height; y++) {
- for (let x = 0; x < width; x++) {
- const value = pixels[y * width + x];
- if (value !== last) {
- last = value;
- lastIndex = index.get(value) as number;
+
+ return {
+ channels: 1, depth, palette, transparency,
+ convert(rgba, width, height) {
+ const pixels = new Uint32Array(rgba.buffer, rgba.byteOffset, width * height);
+ const rowBytes = Math.ceil(width * depth / 8);
+ const perByte = 8 / depth;
+ const data = new Uint8Array(rowBytes * height);
+
+ let last = -1, lastIndex = 0;
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const value = pixels[y * width + x];
+ if (value !== last) {
+ last = value;
+ lastIndex = index.get(value) as number;
+ }
+ data[y * rowBytes + (x / perByte | 0)] |= lastIndex << (8 - depth - (x % perByte) * depth);
+ }
+ }
+ return data;
+ }
+ };
+ }
+
+ const channels = (grey ? 1 : 3) + (opaque ? 0 : 1);
+ return {
+ channels, depth: 8,
+ convert(rgba, width, height) {
+ if (channels === 4) return rgba;
+ const count = width * height;
+ const data = new Uint8Array(count * channels);
+ for (let i = 0, o = 0; i < count; i++) {
+ const s = i * 4;
+ if (grey) data[o++] = rgba[s];
+ else {
+ data[o++] = rgba[s];
+ data[o++] = rgba[s + 1];
+ data[o++] = rgba[s + 2];
}
- data[y * rowBytes + (x / perByte | 0)] |= lastIndex << (8 - depth - (x % perByte) * depth);
+ if (!opaque) data[o++] = rgba[s + 3];
}
+ return data;
}
+ };
+}
+
+// 4-byte aligned copy if needed (pixel analysis reads 32-bit words)
+const aligned = (rgba: Uint8Array): Uint8Array => rgba.byteOffset % 4 ? rgba.slice() : rgba;
+
+/**
+ * Encodes 8-bit RGBA pixels into a PNG with the smallest lossless color type (see {@link pixelFormat}).
+ * Rows of non-palette images are paeth filtered (unless the data isn't compressed), palette indices aren't
+ * filtered (like libpng recommends)
+ */
+export async function encodeRGBA(rgba: Uint8Array, width: number, height: number, level: number, text?: EncodeOptions['text']): Promise {
+ rgba = aligned(rgba.subarray(0, width * height * 4));
+ const format = pixelFormat([rgba]);
+ const { channels, depth, palette, transparency } = format;
+ return encode(format.convert(rgba, width, height), { width, height, level, text, channels, depth, palette, transparency, filter: level > 0 && !palette });
+}
- return encode(data, { ...options, channels: 1, depth, palette, transparency, filter: false });
+// filters (or just prefixes) the rows and compresses them
+async function compressRows(data: Uint8Array, width: number, height: number, channels: number, depth: number, level: number, filterRows: boolean): Promise {
+ const rowBytes = Math.ceil(width * channels * depth / 8);
+ let filtered: Uint8Array;
+ if (filterRows) filtered = filter(data, rowBytes, height, Math.max(1, channels * depth / 8));
+ else {
+ filtered = new Uint8Array(height * (rowBytes + 1));
+ for (let y = 0; y < height; y++) filtered.set(data.subarray(y * rowBytes, (y + 1) * rowBytes), y * (rowBytes + 1) + 1);
}
+ return compressAsync(filtered, level);
+}
- const channels = (grey ? 1 : 3) + (opaque ? 0 : 1);
- if (channels === 4) return encode(rgba, { ...options, channels });
+export interface APNGEncodeFrame {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ /** milliseconds (stored as a fraction of up to 65535/65535 seconds) */
+ duration: number;
+ /** 0 none, 1 background (transparent), 2 previous */
+ dispose: number;
+ /** RGBA */
+ pixels: Uint8Array;
+}
+
+/**
+ * Encodes frames into an APNG. Frames are alpha blended onto the canvas ("over"); the first frame is also the
+ * default image that viewers without APNG support show
+ * @param plays How often to play the animation, 0 = forever
+ */
+export async function encodeAnimation(frames: APNGEncodeFrame[], width: number, height: number, plays: number, level: number): Promise {
+ if (!frames.length) throw new Error('PNG: an animation needs at least one frame');
+
+ // the default image has to cover the whole canvas: pad the first frame if needed
+ const first = frames[0];
+ if (first.x !== 0 || first.y !== 0 || first.width !== width || first.height !== height) {
+ const pixels = new Uint8Array(width * height * 4);
+ for (let y = 0; y < first.height && first.y + y < height; y++) {
+ const row = first.pixels.subarray(y * first.width * 4, (y * first.width + Math.min(first.width, width - first.x)) * 4);
+ pixels.set(row, ((first.y + y) * width + first.x) * 4);
+ }
+ frames = [{ ...first, x: 0, y: 0, width, height, pixels }, ...frames.slice(1)];
+ }
+
+ for (const f of frames)
+ if (f.x < 0 || f.y < 0 || f.x + f.width > width || f.y + f.height > height || !f.width || !f.height)
+ throw new RangeError('PNG: APNG frames have to lie within the canvas');
+
+ const images = frames.map(f => aligned(f.pixels.subarray(0, f.width * f.height * 4)));
+ const format = pixelFormat(images);
+ const { channels, depth, palette, transparency } = format;
+ const filterRows = level > 0 && !palette;
+
+ const header = new Uint8Array(13);
+ const hv = new DataView(header.buffer);
+ hv.setUint32(0, width);
+ hv.setUint32(4, height);
+ header[8] = depth;
+ header[9] = palette ? 3 : CHANNELS_TO_COLOR_TYPE[channels];
- const data = new Uint8Array(count * channels);
- for (let i = 0, o = 0; i < count; i++) {
- const s = i * 4;
- if (grey) data[o++] = rgba[s];
+ const actl = new Uint8Array(8);
+ new DataView(actl.buffer).setUint32(0, frames.length);
+ new DataView(actl.buffer).setUint32(4, plays);
+
+ const chunks = [chunk(IHDR, header), chunk(acTL, actl)];
+ if (palette) chunks.push(chunk(PLTE, palette));
+ if (transparency) chunks.push(chunk(tRNS, transparency));
+
+ let sequence = 0;
+ for (let i = 0; i < frames.length; i++) {
+ const f = frames[i];
+ const fctl = new Uint8Array(26);
+ const v = new DataView(fctl.buffer);
+ v.setUint32(0, sequence++);
+ v.setUint32(4, f.width);
+ v.setUint32(8, f.height);
+ v.setUint32(12, f.x);
+ v.setUint32(16, f.y);
+ // milliseconds as num/1000 where they fit, otherwise centiseconds
+ const ms = Math.max(0, Math.round(f.duration));
+ if (ms <= 65535) { v.setUint16(20, ms); v.setUint16(22, 1000); }
+ else { v.setUint16(20, Math.min(65535, Math.round(ms / 10))); v.setUint16(22, 100); }
+ fctl[24] = f.dispose;
+ fctl[25] = 1; // APNG_BLEND_OP_OVER
+ chunks.push(chunk(fcTL, fctl));
+
+ const data = await compressRows(format.convert(images[i], f.width, f.height), f.width, f.height, channels, depth, level, filterRows);
+ if (i === 0) chunks.push(chunk(IDAT, data));
else {
- data[o++] = rgba[s];
- data[o++] = rgba[s + 1];
- data[o++] = rgba[s + 2];
+ const body = new Uint8Array(data.length + 4);
+ new DataView(body.buffer).setUint32(0, sequence++);
+ body.set(data, 4);
+ chunks.push(chunk(fdAT, body));
}
- if (!opaque) data[o++] = rgba[s + 3];
}
- return encode(data, { ...options, channels });
+ chunks.push(chunk(IEND, new Uint8Array(0)));
+ return concat([HEAD, ...chunks]);
+}
+
+interface PNGHeader {
+ width: number;
+ height: number;
+ depth: number;
+ colorType: number;
+ interlace: number;
}
-/**
- * Decodes a PNG into 8-bit RGBA pixel data
- * Supports all color types, bit depths, tRNS transparency and adam7 interlacing
- * The orientation comes from the eXIf chunk, or, if there is none, from the Exif text ImageMagick wrote before eXIf
- * existed ("Raw profile type exif") or from the XMP packet
- * @param {Uint8Array} array The PNG data
- * @return {{width: number, height: number, pixels: Uint8Array, orientation: number}}
- */
-export async function decode(array: Uint8Array): Promise {
+interface APNGFrameControl {
+ width: number;
+ height: number;
+ x: number;
+ y: number;
+ /** milliseconds */
+ delay: number;
+ dispose: number;
+ blend: number;
+}
+
+interface Parsed {
+ header: PNGHeader;
+ palette: Uint8Array | null;
+ transparency: Uint8Array | null;
+ idat: Uint8Array[];
+ exif: Uint8Array | null;
+ text: Array<{ type: number, chunk: Uint8Array }>;
+ /** set for APNGs: number of frames and plays (0 = forever) */
+ animation: { frames: number, plays: number } | null;
+ /** APNG frames, with their zlib data; the IDAT is the first frame if it has a fcTL */
+ frames: Array<{ control: APNGFrameControl, data: Uint8Array[] }>;
+ /** set if the animation is broken, which makes decoders fall back to the default image */
+ animationError: boolean;
+}
+
+function parse(array: Uint8Array): Parsed {
const view = new DataView(array.buffer, array.byteOffset, array.byteLength);
for (let i = 0; i < HEAD.length; i++)
if (array[i] !== HEAD[i]) throw new Error('PNG: invalid signature');
- let header: { width: number, height: number, depth: number, colorType: number, interlace: number } | null = null;
+ let header: PNGHeader | null = null;
let palette: Uint8Array | null = null;
let transparency: Uint8Array | null = null;
const idat: Uint8Array[] = [];
let exif: Uint8Array | null = null;
const text: Array<{ type: number, chunk: Uint8Array }> = [];
+ let animation: Parsed['animation'] = null;
+ const frames: Parsed['frames'] = [];
+ let current: Parsed['frames'][number] | null = null;
+ let sequence = 0;
+ let animationError = false;
let offset = 8;
while (offset + 8 <= array.length) {
@@ -265,8 +428,36 @@ export async function decode(array: Uint8Array): Promise {
} else if (type === tRNS) {
if (transparency) throw new Error('PNG: tRNS can only occur once in an image');
transparency = chunk;
- } else if (type === IDAT) idat.push(chunk);
- else if (type === eXIf) exif ??= chunk;
+ } else if (type === IDAT) {
+ idat.push(chunk);
+ // the default image is the first frame if a fcTL came before it
+ if (current && !frames.length) frames.push(current);
+ } else if (type === acTL) {
+ // only one acTL, before the image data
+ if (animation || idat.length || length < 8) animationError = true;
+ else animation = { frames: view.getUint32(start), plays: view.getUint32(start + 4) };
+ } else if (type === fcTL) {
+ // (a fcTL before the acTL is tolerated, like browsers do)
+ if (length < 26 || view.getUint32(start) !== sequence++) animationError = true;
+ if (animationError) { offset = end + 4; continue; }
+ const num = view.getUint16(start + 20), den = view.getUint16(start + 22) || 100;
+ current = {
+ control: {
+ width: view.getUint32(start + 4),
+ height: view.getUint32(start + 8),
+ x: view.getUint32(start + 12),
+ y: view.getUint32(start + 16),
+ delay: num * 1000 / den,
+ dispose: chunk[24],
+ blend: chunk[25]
+ },
+ data: idat.length ? [] : idat
+ };
+ if (idat.length) frames.push(current);
+ } else if (type === fdAT) {
+ if (length < 4 || view.getUint32(start) !== sequence++ || !current || current.data === idat) animationError = true;
+ else current.data.push(chunk.subarray(4));
+ } else if (type === eXIf) exif ??= chunk;
else if (type === tEXt || type === zTXt || type === iTXt) text.push({ type, chunk });
else if (type === IEND) break;
@@ -276,13 +467,25 @@ export async function decode(array: Uint8Array): Promise {
if (!header) throw new Error('PNG: missing IHDR');
if (!idat.length) throw new Error('PNG: missing IDAT');
- const { width, height, depth, colorType, interlace } = header;
+ const { width, height, depth, colorType } = header;
const channels = COLOR_TYPE_CHANNELS[colorType];
if (!channels) throw new Error(`PNG: invalid color type ${colorType}`);
if (!VALID_DEPTHS[colorType].includes(depth)) throw new Error(`PNG: invalid bit depth ${depth} for color type ${colorType}`);
if (width === 0 || height === 0) throw new Error('PNG: invalid dimensions');
if (colorType === 3 && !palette) throw new Error('PNG: indexed color image has no PLTE');
+ // more frames than announced are ignored; fewer (or none) make the animation invalid
+ if (animation) {
+ if (frames.length > animation.frames) frames.length = animation.frames;
+ if (!frames.length || frames.length < animation.frames || frames.some(f => !f.data.length)) animationError = true;
+ }
+ return { header, palette, transparency, idat, exif, text, animation, frames, animationError };
+}
+
+// inflates and converts one image (the default image or an APNG frame) to RGBA
+async function decodeImageData(p: Parsed, chunks: Uint8Array[], width: number, height: number): Promise {
+ const { depth, colorType, interlace } = p.header;
+ const channels = COLOR_TYPE_CHANNELS[colorType];
const bitsPerPixel = channels * depth;
const bpp = Math.max(1, bitsPerPixel >> 3);
const passes = interlace ? ADAM7.map(([xs, ys, dx, dy]) => [xs, ys, dx, dy, Math.ceil((width - xs) / dx), Math.ceil((height - ys) / dy)]) : [[0, 0, 1, 1, width, height]];
@@ -293,11 +496,12 @@ export async function decode(array: Uint8Array): Promise {
if (pw > 0 && ph > 0) expected += ph * (1 + Math.ceil(pw * bitsPerPixel / 8));
}
- const data = await decompressAsync(idat.length === 1 ? idat[0] : concat(idat), expected);
+ if (!chunks.length) throw new Error('PNG: missing image data');
+ const data = await decompressAsync(chunks.length === 1 ? chunks[0] : concat(chunks), expected);
if (data.length < expected) throw new Error('PNG: image data is truncated');
const rgba = new Uint8Array(width * height * 4);
- const toRGBA = createConverter(colorType, depth, palette, transparency);
+ const toRGBA = createConverter(colorType, depth, p.palette, p.transparency);
let dataOffset = 0;
for (const [xs, ys, dx, dy, pw, ph] of passes) {
@@ -311,7 +515,109 @@ export async function decode(array: Uint8Array): Promise {
toRGBA(pixels, rowBytes, pw, ph, rgba, { row: y => ((ys + y * dy) * width + xs) * 4, step: dx * 4 });
}
- return { width, height, pixels: rgba, orientation: exif ? exifOrientation(exif) : await textOrientation(text) };
+ return rgba;
+}
+
+/**
+ * Decodes a PNG into 8-bit RGBA pixel data (for APNGs, the default image)
+ * Supports all color types, bit depths, tRNS transparency and adam7 interlacing
+ * The orientation comes from the eXIf chunk, or, if there is none, from the Exif text ImageMagick wrote before eXIf
+ * existed ("Raw profile type exif") or from the XMP packet
+ */
+export async function decode(array: Uint8Array): Promise {
+ const p = parse(array);
+ const { width, height } = p.header;
+ const pixels = await decodeImageData(p, p.idat, width, height);
+ return { width, height, pixels, orientation: p.exif ? exifOrientation(p.exif) : await textOrientation(p.text) };
+}
+
+export interface APNGFrame {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ /** milliseconds */
+ duration: number;
+ /** 0 none, 1 background (transparent), 2 previous */
+ dispose: number;
+ /** 0 source (replace), 1 over */
+ blend: number;
+ pixels: Uint8Array;
+}
+
+/** Whether the PNG is an APNG (has an acTL chunk) */
+export function isAnimated(array: Uint8Array): boolean {
+ try {
+ const p = parse(array);
+ return p.animation !== null && !p.animationError;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Decodes an APNG (https://wiki.mozilla.org/APNG_Specification) into its (uncomposited) frames; a plain PNG becomes
+ * a single frame
+ * @return plays: 0 = forever
+ */
+export async function decodeAnimation(array: Uint8Array, onlyFirstFrame = false): Promise<{ width: number, height: number, plays: number, frames: APNGFrame[] }> {
+ const p = parse(array);
+ const { width, height } = p.header;
+
+ // broken animations show the default image, like browsers do
+ if (!p.animation || p.animationError) {
+ const pixels = await decodeImageData(p, p.idat, width, height);
+ return { width, height, plays: 0, frames: [{ x: 0, y: 0, width, height, duration: 0, dispose: 0, blend: 0, pixels }] };
+ }
+
+ const frames: APNGFrame[] = [];
+ for (const { control: c, data } of p.frames) {
+ if (!c.width || !c.height || c.x + c.width > width || c.y + c.height > height) throw new Error('PNG: invalid APNG frame region');
+ frames.push({
+ x: c.x, y: c.y, width: c.width, height: c.height, duration: c.delay,
+ // DISPOSE_OP_PREVIOUS on the first frame means background
+ dispose: !frames.length && c.dispose === 2 ? 1 : c.dispose,
+ blend: c.blend,
+ pixels: await decodeImageData(p, data, c.width, c.height)
+ });
+ if (onlyFirstFrame) break;
+ }
+
+ return { width, height, plays: p.animation.plays, frames };
+}
+
+/**
+ * Composites APNG frames onto the canvas, as the APNG specification describes (the integer "over" operation of its
+ * blend_op section, https://wiki.mozilla.org/APNG_Specification)
+ */
+export function compositeAnimation({ width, height, frames }: { width: number, height: number, frames: APNGFrame[] }): Array<{ pixels: Uint8Array, duration: number }> {
+ const canvas = new Uint8Array(width * height * 4);
+ const output = [];
+
+ for (const f of frames) {
+ const previous = f.dispose === 2 ? canvas.slice() : null;
+
+ for (let y = 0; y < f.height; y++)
+ for (let x = 0; x < f.width; x++) {
+ const s = (y * f.width + x) * 4, d = ((f.y + y) * width + f.x + x) * 4;
+ const sa = f.pixels[s + 3], da = canvas[d + 3];
+ if (f.blend === 0 || sa === 255 || da === 0) {
+ if (f.blend === 0 || sa !== 0) canvas.set(f.pixels.subarray(s, s + 4), d);
+ } else if (sa !== 0) {
+ const u = sa * 255, v = (255 - sa) * da, al = u + v;
+ for (let c = 0; c < 3; c++) canvas[d + c] = (f.pixels[s + c] * u + canvas[d + c] * v) / al | 0;
+ canvas[d + 3] = al / 255 | 0;
+ }
+ }
+
+ output.push({ pixels: canvas.slice(), duration: f.duration });
+
+ if (f.dispose === 1)
+ for (let y = 0; y < f.height; y++) canvas.fill(0, ((f.y + y) * width + f.x) * 4, ((f.y + y) * width + f.x + f.width) * 4);
+ else if (previous) canvas.set(previous);
+ }
+
+ return output;
}
const RAW_EXIF = /^Raw profile type (exif|APP1)$/;
diff --git a/src/frame.ts b/src/frame.ts
index 36835c69..60cabf29 100644
--- a/src/frame.ts
+++ b/src/frame.ts
@@ -1,44 +1,44 @@
import { Image, ResizeMode } from './image.ts';
-/** Names of the GIF frame disposal modes, see {@link Frame.disposalMode} */
+/** Names of the frame disposal modes, see {@link Frame.disposalMode} */
export type FrameDisposalModeName = 'any' | 'keep' | 'previous' | 'background';
-/** Ids of the GIF frame disposal modes as stored in the file (0 any, 1 keep, 2 previous, 3 background) */
+/** Ids of the frame disposal modes, as GIFs store them (0 any, 1 keep, 2 previous, 3 background) */
export type FrameDisposalModeId = 0 | 1 | 2 | 3;
const DISPOSAL_MODES: readonly FrameDisposalModeName[] = ['any', 'keep', 'previous', 'background'];
/**
- * Represents a frame in a GIF
+ * Represents a frame of an {@link Animation}
*/
export class Frame extends Image {
/**
- * Milliseconds. GIFs store hundredths of a second, so this is rounded to 10 ms when encoding;
+ * Milliseconds. GIFs store hundredths of a second, so this is rounded to 10 ms when encoding a GIF;
* note that browsers play frames shorter than 20 ms with 100 ms
*/
duration: number;
- /** The frame's horizontal offset in the GIF */
+ /** The frame's horizontal offset in the animation */
xOffset: number;
- /** The frame's vertical offset in the GIF */
+ /** The frame's vertical offset in the animation */
yOffset: number;
/** @internal */
private __disposalMode__: FrameDisposalModeId = 1;
- /** GIF frame disposal mode ANY: the decoder decides (in practice like KEEP). For use with {@link Frame} */
+ /** Frame disposal mode ANY: the decoder decides (in practice like KEEP). For use with {@link Frame} */
static get DISPOSAL_ANY(): 'any' {
return 'any';
}
- /** GIF frame disposal mode KEEP. For use with {@link Frame} */
+ /** Frame disposal mode KEEP. For use with {@link Frame} */
static get DISPOSAL_KEEP(): 'keep' {
return 'keep';
}
- /** GIF frame disposal mode PREVIOUS. For use with {@link Frame} */
+ /** Frame disposal mode PREVIOUS. For use with {@link Frame} */
static get DISPOSAL_PREVIOUS(): 'previous' {
return 'previous';
}
- /** GIF frame disposal mode BACKGROUND. For use with {@link Frame} */
+ /** Frame disposal mode BACKGROUND. For use with {@link Frame} */
static get DISPOSAL_BACKGROUND(): 'background' {
return 'background';
}
diff --git a/src/gif.ts b/src/gif.ts
index 2418c34d..d0cd374e 100644
--- a/src/gif.ts
+++ b/src/gif.ts
@@ -1,138 +1,46 @@
-import * as codec from './codecs/gif.ts';
import { BinaryData, view } from './util/mem.ts';
-import { Image, ResizeMode } from './image.ts';
import { Frame } from './frame.ts';
import { ImageType } from './image-type.ts';
+import { Animation } from './animation.ts';
/**
- * Represents a GIF image as an array of frames.
+ * Represents a GIF image as an array of frames, an {@link Animation} whose `encode()` and `decode()` work with GIFs.
* Array methods like map and filter return plain arrays.
+ * @deprecated Use {@link Animation}, which works the same and also handles APNG: `new Animation(frames, loopCount)`,
+ * {@link Animation.decode} and {@link Animation.encodeGIF}. `GIF` will be removed in 4.0
*/
-export class GIF extends Array {
- /** How many times to repeat the GIF (-1 = forever, 0 = play once) */
- loopCount: number;
-
+export class GIF extends Animation {
/**
* Creates a new GIF image
* @param frames The frames to create the GIF from
* @param loopCount How many times to repeat the GIF (-1 = forever, 0 = play once)
*/
constructor(frames: Frame[], loopCount = -1) {
- super();
- // push instead of super(...frames), so a single frame isn't taken as a length
- for (const frame of frames) this.push(frame);
-
- for (let i = 0; i < this.length; i++)
- if (!(this[i] instanceof Frame))
- throw new TypeError(`Frame ${i} is not an instance of Frame`);
-
- if (loopCount < -1 || isNaN(loopCount))
- throw new RangeError('Invalid loop count');
-
- this.loopCount = loopCount;
- }
-
- /** @internal */
- static override get [Symbol.species](): ArrayConstructor {
- return Array;
- }
-
- /** The GIF's width */
- get width(): number {
- let max = 0;
- for (const frame of this) max = Math.max(max, frame.width + frame.xOffset);
- return max;
- }
-
- /** The GIF's height */
- get height(): number {
- let max = 0;
- for (const frame of this) max = Math.max(max, frame.height + frame.yOffset);
- return max;
- }
-
- /** The GIF's duration (in ms) */
- get duration(): number {
- let duration = 0;
- for (const frame of this) duration += frame.duration;
- return duration;
+ super(frames, loopCount);
}
override toString(): string {
return `GIF<${this.width}x${this.height}x${this.duration}ms>`;
}
- /**
- * Returns a new GIF containing a range of this GIF's frames (same semantics as Array.prototype.slice)
- */
- override slice(start?: number, end?: number): GIF {
- return new GIF(Array.prototype.slice.call(this, start, end) as Frame[], this.loopCount);
- }
-
/**
* Encodes the GIF
* @param quality 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above)
+ * @deprecated Use {@link Animation.encodeGIF}
*/
async encode(quality = 95): Promise {
- const frames: codec.GIFFrame[] = [];
-
- for (const frame of this) {
- if (!(frame instanceof Frame)) throw new Error('GIF contains invalid frames');
-
- frames.push({
- x: frame.xOffset,
- y: frame.yOffset,
- width: frame.width,
- height: frame.height,
- // gifs store centiseconds; rounding keeps e.g. 16 ms (60 fps) from becoming 10 ms
- delay: Math.round(frame.duration / 10),
- dispose: frame.__disposal_id__,
- pixels: new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength)
- });
- }
-
- return codec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames }, quality);
+ return this.encodeGIF(quality);
}
/**
* Decodes a GIF into fully composited frames
* @param input The binary data to decode
* @param onlyExtractFirstFrame Whether to end GIF decoding after the first frame
+ * @deprecated Use {@link Animation.decode}, which decodes APNGs too
*/
- static async decode(input: BinaryData, onlyExtractFirstFrame = false): Promise {
+ static override async decode(input: BinaryData, onlyExtractFirstFrame = false): Promise {
const data = view(input);
if (!ImageType.isGIF(new DataView(data.buffer, data.byteOffset, data.byteLength))) throw new Error('Unsupported image type');
-
- const decoded = codec.decode(data, onlyExtractFirstFrame);
- const frames = codec.composite(decoded).map(({ pixels, delay }) => {
- const frame = new Frame(decoded.width, decoded.height, 10 * delay, 0, 0, Frame.DISPOSAL_BACKGROUND);
- frame.bitmap.set(pixels);
- return frame;
- });
-
- return new GIF(frames, decoded.loopCount);
- }
-
- /**
- * Resizes the GIF to the given dimensions, scaling every frame and its offsets proportionally.
- * Use {@link Image.RESIZE_AUTO} as either width or height to automatically preserve the aspect ratio.
- */
- resize(width: number, height: number, mode: ResizeMode = Image.RESIZE_BILINEAR): this {
- const originalWidth = this.width, originalHeight = this.height;
- if (width === Image.RESIZE_AUTO && height === Image.RESIZE_AUTO) throw new Error('RESIZE_AUTO can only be used for either width or height, not for both');
- if (width === Image.RESIZE_AUTO) width = originalWidth / originalHeight * height;
- if (height === Image.RESIZE_AUTO) height = originalHeight / originalWidth * width;
-
- const sx = width / originalWidth, sy = height / originalHeight;
- for (const frame of this) {
- // scale both edges so adjacent frames stay aligned
- const left = Math.round(frame.xOffset * sx), top = Math.round(frame.yOffset * sy);
- const right = Math.round((frame.xOffset + frame.width) * sx), bottom = Math.round((frame.yOffset + frame.height) * sy);
- Image.prototype.resize.call(frame, Math.max(1, right - left), Math.max(1, bottom - top), mode);
- frame.xOffset = left;
- frame.yOffset = top;
- }
-
- return this;
+ return Animation.__fromGIF__(data, onlyExtractFirstFrame, GIF);
}
}
diff --git a/src/index.ts b/src/index.ts
index 573462e2..dbfba9c2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -8,6 +8,7 @@ export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextS
export { Frame } from './frame.ts';
export type { FrameDisposalModeName, FrameDisposalModeId } from './frame.ts';
export { GIF } from './gif.ts';
+export { Animation } from './animation.ts';
export { TextLayout } from './text-layout.ts';
export type { TextLayoutOptions, WrapStyle, HorizontalAlign, VerticalAlign } from './text-layout.ts';
export { ImageType } from './image-type.ts';
@@ -15,11 +16,13 @@ export type { ImageTypeName } from './image-type.ts';
export type { BinaryData } from './util/mem.ts';
/**
- * Decodes any supported image, GIFs are returned as {@link GIF}
+ * Decodes any supported image: GIFs are returned as {@link GIF} (an {@link Animation}), everything else (including
+ * APNGs, as their default image) as {@link Image}. {@link Animation.decode} decodes the frames of GIFs and APNGs
* @param data The image data
- * @param onlyExtractFirstFrame Whether to end GIF decoding after the first frame
+ * @param onlyExtractFirstFrame Whether to end animation decoding after the first frame
*/
export function decode(data: BinaryData, onlyExtractFirstFrame = false): Promise {
- if (ImageType.getType(data) === 'gif') return GIF.decode(data, onlyExtractFirstFrame);
+ const type = ImageType.getType(data);
+ if (type === 'gif') return GIF.decode(data, onlyExtractFirstFrame);
return Image.decode(data);
}
diff --git a/tests/animation.js b/tests/animation.js
new file mode 100644
index 00000000..b69664b6
--- /dev/null
+++ b/tests/animation.js
@@ -0,0 +1,64 @@
+const {Image, Frame, Animation, GIF} = require('..');
+
+const panic = message => {
+ console.error(message);
+ process.exit(1);
+};
+
+// what the animation looks like, frame by frame: frames are drawn over the canvas, then disposed
+function expected(animation) {
+ const width = animation.width, height = animation.height;
+ let canvas = new Image(width, height);
+ return Array.from(animation, frame => {
+ const previous = canvas.clone();
+ canvas.composite(frame, frame.xOffset, frame.yOffset);
+ const shown = canvas.clone();
+ if (frame.disposalMode === 'background') canvas.drawBox(frame.xOffset, frame.yOffset, frame.width, frame.height, 0);
+ else if (frame.disposalMode === 'previous') canvas = previous;
+ return shown;
+ });
+}
+
+const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 ? 0 : color);
+
+(async () => {
+ for (const variant of [0, 1]) {
+ const animation = new Animation([
+ Frame.from(new Image(40, 30).fill((x, y) => Image.rgbaToColor(x * 6, y * 8, 128, 255)), 100),
+ Frame.from(square(0xff2040ff, 10, 8), 50, variant ? 3 : 4, variant ? 5 : 6, 'background'),
+ Frame.from(square(0x20ff40ff, 12, 10), 70, 10, 10, variant ? 'previous' : 'keep'),
+ Frame.from(square(0xc8374fff, 8, 8), 30, 20, 2, 'keep')
+ ], 2);
+ const reference = expected(animation);
+
+ for (const format of ['APNG']) {
+ const bytes = await animation[`encode${format}`]();
+ const back = await Animation.decode(bytes);
+ if (back.length !== 4 || back.loopCount !== 2) panic(`${format}: wrong frame or loop count`);
+ if (back.map(f => f.duration).join() !== '100,50,70,30') panic(`${format}: wrong durations`);
+ back.forEach((frame, i) => {
+ if (!Buffer.from(frame.bitmap).equals(Buffer.from(reference[i].bitmap))) panic(`${format} variant ${variant}: frame ${i} differs`);
+ });
+ }
+ }
+
+ // a plain PNG's default image is what Image.decode returns for an APNG
+ const animation = new Animation([Frame.from(new Image(8, 8).fill(0xff0000ff), 100), Frame.from(new Image(8, 8).fill(0x00ff00ff), 100)]);
+ const apng = await animation.encodeAPNG();
+ const still = await Image.decode(apng);
+ if (still.getPixelAt(0, 0) !== 0xff0000ff) panic('APNG: the default image should be the first frame');
+
+ // still images decode to a single frame; GIFs stay GIFs
+ const single = await Animation.decode(await still.encode());
+ if (single.length !== 1 || single.width !== 8) panic('Animation.decode should accept still images');
+ const gif = await Animation.decode(await new GIF([Frame.from(still)]).encode());
+ if (!(gif instanceof Animation) || gif.length !== 1) panic('Animation.decode should decode GIFs');
+ if (!(gif.slice(0, 1) instanceof Animation)) panic('slice should keep the class');
+
+ // loop counts: -1 forever, 0 once, n repeats
+ for (const loopCount of [-1, 0, 5]) {
+ const two = new Animation([Frame.from(still), Frame.from(still)], loopCount);
+ for (const format of ['APNG', 'GIF'])
+ if ((await Animation.decode(await two[`encode${format}`]())).loopCount !== loopCount) panic(`${format}: loop count ${loopCount} not kept`);
+ }
+})();