Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)

---

Expand Down
194 changes: 194 additions & 0 deletions src/animation.ts
Original file line number Diff line number Diff line change
@@ -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<Frame> {
/** 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<Uint8Array> {
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<Uint8Array> {
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<Animation> {
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__<T extends Animation>(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);
}
}
Loading
Loading