From 1071d0a908611062561892a9a492c41bcbd9bc5c Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Thu, 15 Oct 2026 07:50:57 +0000 Subject: [PATCH 1/3] apng decoding and encoding, Animation class (#6) APNG: decoding (broken animations fall back to the default image, like browsers do) and encoding with full alpha; the frame compositing follows the APNG spec's integer "over" operation. Matches Pillow on its APNG test files, except where Pillow blends semi-transparent pixels differently from the spec. Animation is the new base class of GIF: Animation.decode() reads GIF and APNG (still images become one frame), encodeGIF() and encodeAPNG() write them. GIF keeps its API, Image.decode() still returns an APNG's default image. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J --- CHANGELOG.md | 10 ++ README.md | 8 +- src/animation.ts | 194 ++++++++++++++++++++ src/codecs/png.ts | 430 ++++++++++++++++++++++++++++++++++++++------- src/gif.ts | 110 +----------- src/index.ts | 9 +- tests/animation.js | 64 +++++++ 7 files changed, 653 insertions(+), 172 deletions(-) create mode 100644 src/animation.ts create mode 100644 tests/animation.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c1f176..f56d298a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # 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. + ## 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..3fada6e5 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,107 @@ 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 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 (its integer "over" operation) + */ +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/gif.ts b/src/gif.ts index 2418c34d..9ea3489d 100644 --- a/src/gif.ts +++ b/src/gif.ts @@ -1,97 +1,32 @@ -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. */ -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) */ 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); } /** @@ -99,40 +34,9 @@ export class GIF extends Array { * @param input The binary data to decode * @param onlyExtractFirstFrame Whether to end GIF decoding after the first frame */ - 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..0bb0c644 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}, everything else (including APNGs, as their default + * image) as {@link Image}. {@link Animation.decode} decodes the frames of 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`); + } +})(); From 35f3f221cdd68d248e2b1b652ae2bd0afe6cea5c Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Thu, 15 Oct 2026 07:58:25 +0000 Subject: [PATCH 2/3] deprecate GIF in favor of Animation GIF only adds encode() (encodeGIF), a decode() that accepts nothing but GIFs (Animation.decode) and its toString. It stays fully working in 3.x, editors show it as deprecated with the replacement; removal is planned for 4.0. Frame's documentation now talks about animations instead of GIFs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J --- CHANGELOG.md | 5 +++++ src/frame.ts | 20 ++++++++++---------- src/gif.ts | 4 ++++ src/index.ts | 4 ++-- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f56d298a..cc942fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ - **`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/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 9ea3489d..d0cd374e 100644 --- a/src/gif.ts +++ b/src/gif.ts @@ -6,6 +6,8 @@ import { Animation } from './animation.ts'; /** * 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 Animation { /** @@ -24,6 +26,7 @@ export class GIF extends Animation { /** * 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 { return this.encodeGIF(quality); @@ -33,6 +36,7 @@ export class GIF extends Animation { * 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 override async decode(input: BinaryData, onlyExtractFirstFrame = false): Promise { const data = view(input); diff --git a/src/index.ts b/src/index.ts index 0bb0c644..dbfba9c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,8 +16,8 @@ export type { ImageTypeName } from './image-type.ts'; export type { BinaryData } from './util/mem.ts'; /** - * Decodes any supported image: GIFs are returned as {@link GIF}, everything else (including APNGs, as their default - * image) as {@link Image}. {@link Animation.decode} decodes the frames of APNGs + * 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 animation decoding after the first frame */ From f7969ee08e6d243ad89d8851a90c1c978701b040 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Thu, 15 Oct 2026 08:19:03 +0000 Subject: [PATCH 3/3] reference the APNG specification Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J --- src/codecs/png.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/codecs/png.ts b/src/codecs/png.ts index 3fada6e5..f1409719 100644 --- a/src/codecs/png.ts +++ b/src/codecs/png.ts @@ -556,7 +556,8 @@ export function isAnimated(array: Uint8Array): boolean { } /** - * Decodes an APNG into its (uncomposited) frames; a plain PNG becomes a single frame + * 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[] }> { @@ -586,7 +587,8 @@ export async function decodeAnimation(array: Uint8Array, onlyFirstFrame = false) } /** - * Composites APNG frames onto the canvas, as the APNG specification describes (its integer "over" operation) + * 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);