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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
`Image.renderText(font, 32, 'Hi', {color: 0xff0000ff, strokeWidth: 2})`, `new Frame(10, 10, {duration: 50})`,
`animation.encodeGIF({quality: 80})`. Options can be left out or added without counting positions; the positional
arguments keep working until 4.0.
- **Smaller animations**: `encodeGIF()`, `encodeAPNG()` and `encodeWebP()` store each frame as the area that changed
since the frame before, with unchanged pixels transparent where that compresses better, and merge identical
frames. Re-encoding 762 decoded animations (which have every frame as a full image) took 51% (GIF), 63% (APNG) and
54% (WebP) of the bytes in total; the less moves, the smaller a file gets, ones where everything moves stay about
the same. Large animations encode faster, small ones up to about twice as slow. The files show the same animation as
before, but don't decode to the same frames anymore: `{optimize: false}` stores the frames as they are.
- **Metadata in every format**: `encodeJPEG()`, `encodeWebP()`, `encodeTGA()` and, for animations, `encodeGIF()`,
`encodeAPNG()` and `encodeWebP()` take the same metadata as `encode()` (now called `Metadata`), as the `metadata`
option: `image.encodeJPEG({quality: 80, metadata: {author: 'me'}})`. Each format stores
Expand Down
84 changes: 59 additions & 25 deletions src/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BinaryData, view } from './util/mem.ts';
import { Image, ResizeMode, ResizeOptions } from './image.ts';
import { Frame } from './frame.ts';
import { ImageType } from './image-type.ts';
import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts';
import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts';
import { isOptions } from './util/options.ts';

Expand Down Expand Up @@ -37,6 +38,12 @@ export interface AnimationDecodeOptions {
export interface AnimationEncodeOptions {
/** Metadata to include, see {@link Metadata} */
metadata?: Metadata;
/**
* Whether to store each frame as the area that changed since the frame before and merge identical frames, which
* usually makes files a lot smaller. The file shows the same animation either way, but only decodes to the same
* frames without it, which also encodes faster when little changes. Default: `true`
*/
optimize?: boolean;
}

/** Options of {@link Animation.encodeGIF} */
Expand Down Expand Up @@ -163,73 +170,92 @@ export class Animation extends Array<Frame> {
}

/**
* Encodes the animation as GIF
* Encodes the animation as GIF. Like all encoders, it stores each frame as the area that changed since the frame
* before and merges identical frames (unless the `optimize` option is false), so the file decodes to what the
* animation shows, but not necessarily to the same frames. Semi-transparent pixels become opaque or transparent
* after the frames are composited
* @param options See {@link GIFEncodeOptions}
*/
encodeGIF(options?: GIFEncodeOptions): Promise<Uint8Array>;
/**
* Encodes the animation as GIF
* Encodes the animation as GIF. Like all encoders, it stores each frame as the area that changed since the frame
* before and merges identical frames (unless the `optimize` option is false), so the file decodes to what the
* animation shows, but not necessarily to the same frames. Semi-transparent pixels become opaque or transparent
* after the frames are composited
* @param quality 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above)
*/
encodeGIF(quality?: number): Promise<Uint8Array>;
async encodeGIF(qualityOrOptions: number | GIFEncodeOptions = 95): Promise<Uint8Array> {
const { quality = 95, metadata = {} } = isOptions(qualityOrOptions) ? qualityOrOptions : { quality: qualityOrOptions };
const { quality = 95, metadata = {}, optimize = true } = isOptions(qualityOrOptions) ? qualityOrOptions : { quality: qualityOrOptions };
this.__validate__();
const frames: gifCodec.GIFFrame[] = [];

for (const frame of this)
frames.push({
const frames = optimize
? this.__optimized__({ replace: false, even: false, alpha: 'frame', palette: 256, cover: true }).map(f => ({ ...f, dispose: f.dispose ? 3 : 1 }))
: Array.from(this, frame => ({
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),
duration: frame.duration,
dispose: frame.__disposal_id__,
pixels: new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength)
});
}));
// gifs store centiseconds; rounding keeps e.g. 16 ms (60 fps) from becoming 10 ms
const gifFrames = frames.map(({ duration, ...f }) => ({ ...f, delay: Math.round(duration / 10) }));

return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames }, quality, gifComments(resolveMetadata(metadata)));
return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames: gifFrames }, quality, gifComments(resolveMetadata(metadata)));
}

/**
* Encodes the animation as APNG (animated PNG, lossless and with full alpha). Viewers without APNG support show
* the first frame
* the first frame. Each frame is stored as the area that changed since the frame before, identical frames are
* merged (unless the `optimize` option is false): the file shows exactly the same animation, but doesn't
* necessarily decode to the same frames
* @param options See {@link APNGEncodeOptions}
*/
encodeAPNG(options?: APNGEncodeOptions): Promise<Uint8Array>;
/**
* Encodes the animation as APNG (animated PNG, lossless and with full alpha). Viewers without APNG support show
* the first frame
* the first frame. Each frame is stored as the area that changed since the frame before, identical frames are
* merged (unless the `optimize` option is false): the file shows exactly the same animation, but doesn't
* necessarily decode to the same frames
* @param compression The zlib compression level (0-9), higher is smaller but slower
*/
encodeAPNG(compression?: number): Promise<Uint8Array>;
async encodeAPNG(compressionOrOptions: number | APNGEncodeOptions = 1): Promise<Uint8Array> {
const { compression = 1, metadata = {} } = isOptions(compressionOrOptions) ? compressionOrOptions : { compression: compressionOrOptions };
const { compression = 1, metadata = {}, optimize = true } = isOptions(compressionOrOptions) ? compressionOrOptions : { compression: compressionOrOptions };
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, pngText(resolveMetadata(metadata)));
const frames = optimize
? this.__optimized__({ replace: true, even: false, alpha: frames => png.alphaCost(frames.map(f => f.pixels)) }).map(f => ({ ...f, dispose: f.dispose ? 1 : 0 }))
: this.__apngFrames__().map(({ blend: _, ...f }) => f); // blended, the encoder's default
return png.encodeAnimation(frames, this.width, this.height, toPlays(this.loopCount), compression, pngText(resolveMetadata(metadata)));
}

/**
* Encodes the animation as lossless animated WebP
* Encodes the animation as lossless animated WebP. Each frame is stored as the area that changed since the frame
* before, identical frames are merged (unless the `optimize` option is false): the file shows exactly the same
* animation, but doesn't necessarily decode to the same frames
* @param options See {@link AnimationEncodeOptions}
*/
async encodeWebP({ metadata = {} }: AnimationEncodeOptions = {}): Promise<Uint8Array> {
async encodeWebP({ metadata = {}, optimize = true }: AnimationEncodeOptions = {}): Promise<Uint8Array> {
this.__validate__();
if (!this.length) throw new Error('An animation needs at least one frame');
const width = this.width, height = this.height;
const exif = exifData(resolveMetadata(metadata));

const frames = optimize ? this.__optimized__({ replace: true, even: true, alpha: 'frame' }) : this.__webpFrames__();
return webp.encodeAnimation(frames, this.width, this.height, toPlays(this.loopCount), exifData(resolveMetadata(metadata)));
}

/** @internal the frames as they are, in the WebP codec's terms */
__webpFrames__(): webp.WebPEncodeFrame[] {
const width = this.width, height = this.height;
// WebP knows no "previous" disposal and stores offsets halved: such animations are stored as full frames
const fullFrames = this.some(f => f.disposalMode === 'previous' || (f.disposalMode === 'background' && (f.xOffset & 1 || f.yOffset & 1)));
if (fullFrames) {
const canvases = png.compositeAnimation({ width, height, frames: this.__apngFrames__() });
return webp.encodeAnimation(canvases.map(c => ({ x: 0, y: 0, width, height, duration: c.duration, dispose: true, pixels: c.pixels })), width, height, toPlays(this.loopCount), exif);
}
if (fullFrames)
return png.compositeAnimation({ width, height, frames: this.__apngFrames__() })
.map(c => ({ x: 0, y: 0, width, height, duration: c.duration, dispose: true, pixels: c.pixels }));

const frames = Array.from(this, frame => {
return Array.from(this, frame => {
let { xOffset: x, yOffset: y, width: w, height: h } = frame;
let pixels = new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength);
// odd offsets: one transparent pixel more on the left/top (blending makes it invisible)
Expand All @@ -242,7 +268,15 @@ export class Animation extends Array<Frame> {
}
return { x, y, width: w, height: h, duration: frame.duration, dispose: frame.disposalMode === 'background', pixels };
});
return webp.encodeAnimation(frames, width, height, toPlays(this.loopCount), exif);
}

/**
* The frames as the encoders store them: each frame only covers what changed since the one before (see
* util/optimize.ts)
* @internal
*/
__optimized__(options: OptimizeOptions): OptimizedFrame[] {
return optimize(this.__apngFrames__(), this.width, this.height, options);
}

/** @internal frames in the APNG codec's terms (alpha blended, disposal 0 none, 1 background, 2 previous) */
Expand Down
9 changes: 7 additions & 2 deletions src/codecs/gif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,13 @@ export function encode({ width, height, loopCount = -1, frames }: Omit<GIFData,
writeSubBlocks(out, comment);
}

for (const frame of frames) {
const { palette, indices, transparentIndex } = quantize(frame.pixels, frame.width, { quality });
// frames that restore the background have a transparent index: decoders like Pillow and ffmpeg restore to it
// (browsers to transparent), otherwise to the background color. So does the first frame then: Pillow decides by it
// whether the animation has transparency at all
const restores = frames.some(frame => frame.dispose === 3);
for (const [i, frame] of frames.entries()) {
const transparent = frame.dispose === 3 || (i === 0 && restores);
const { palette, indices, transparentIndex } = quantize(frame.pixels, frame.width, { quality, transparent });
const entries = palette.length / 3;
const bits = Math.max(1, Math.ceil(Math.log2(entries)));

Expand Down
35 changes: 34 additions & 1 deletion src/codecs/png.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,37 @@ interface PixelFormat {
* 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
*/
/**
* How much more an opaque image costs per pixel with transparent pixels than without them, given the pixel formats
* (palette, grey or truecolor) both get: e.g. 4/3 for RGBA instead of RGB, 4 for RGBA instead of a palette when the
* transparent pixels are the 257th color, 2 for 8 instead of 4 bit palette indices when they're the 17th
* @param images RGBA, only the transparent pixels aren't opaque
*/
export function alphaCost(images: Uint8Array[]): number {
let grey = true;
const colors = new Set<number>();
// more than 256 colors and not grey: truecolor either way, nothing else can change
scan: 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] === 0) continue;
if (rgba[o] !== rgba[o + 1] || rgba[o] !== rgba[o + 2]) grey = false;
if (colors.size <= 256) colors.add(value);
else if (!grey) break scan;
}
}
// bytes per pixel: palette 1/8 to 1 (by its bit depth), grey 1 (with alpha 2), truecolor 3 (with alpha 4)
const palette = (entries: number): number => (entries <= 2 ? 1 : entries <= 4 ? 2 : entries <= 16 ? 4 : 8) / 8;
const transparent = colors.size < 256 ? palette(colors.size + 1) : grey ? 2 : 4;
const opaque = colors.size <= 256 ? palette(colors.size) : grey ? 1 : 3;
return transparent / opaque;
}

function pixelFormat(images: Uint8Array[]): PixelFormat {
let opaque = true, grey = true;

Expand Down Expand Up @@ -273,6 +304,8 @@ export interface APNGEncodeFrame {
duration: number;
/** 0 none, 1 background (transparent), 2 previous */
dispose: number;
/** whether the frame is alpha blended onto the canvas, otherwise it replaces its area. Default: true */
blend?: boolean;
/** RGBA */
pixels: Uint8Array;
}
Expand Down Expand Up @@ -337,7 +370,7 @@ export async function encodeAnimation(frames: APNGEncodeFrame[], width: number,
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
fctl[25] = f.blend === false ? 0 : 1; // APNG_BLEND_OP_SOURCE or 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);
Expand Down
6 changes: 4 additions & 2 deletions src/codecs/quantize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const WR = 3, WG = 4, WB = 2;
export interface QuantizeOptions {
maxColors?: number;
quality?: number;
/** whether to have a transparent index even if no pixel is transparent */
transparent?: boolean;
}

export interface Quantized {
Expand Down Expand Up @@ -59,9 +61,9 @@ interface Box {
error: number;
}

export function quantize(pixels: Uint8Array, width: number, { maxColors = 256, quality = 95 }: QuantizeOptions = {}): Quantized {
export function quantize(pixels: Uint8Array, width: number, { maxColors = 256, quality = 95, transparent = false }: QuantizeOptions = {}): Quantized {
const length = pixels.length >> 2;
let hasTransparency = false;
let hasTransparency = transparent;
for (let i = 3; i < pixels.length; i += 4)
if (pixels[i] < 128) {
hasTransparency = true;
Expand Down
12 changes: 9 additions & 3 deletions src/codecs/webp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,9 @@ export interface WebPEncodeFrame {
duration: number;
/** whether the frame's area is cleared after it was shown */
dispose: boolean;
/** RGBA, alpha blended onto the canvas */
/** whether the frame is alpha blended onto the canvas, otherwise it replaces its area. Default: true */
blend?: boolean;
/** RGBA */
pixels: Uint8Array;
}

Expand All @@ -340,7 +342,11 @@ export function encodeAnimation(frames: WebPEncodeFrame[], width: number, height
if (!frames.length) throw new Error('WebP: an animation needs at least one frame');
if (width > 1 << 24 || height > 1 << 24) throw new RangeError('WebP: canvas too large');

const alpha = frames.some(f => hasAlpha(f.pixels));
// transparency also comes from the canvas: where the first frame doesn't cover it, and where frames are disposed
// (decoders like libwebp take the canvas as opaque without the flag)
const first = frames[0];
const alpha = first.x > 0 || first.y > 0 || first.width < width || first.height < height
|| frames.some(f => f.dispose || hasAlpha(f.pixels));

const vp8x = new Uint8Array(10);
vp8x[0] = 2 | (alpha ? 16 : 0) | (exif ? 8 : 0); // animation, alpha, exif
Expand All @@ -361,7 +367,7 @@ export function encodeAnimation(frames: WebPEncodeFrame[], width: number, height
set24(header, 6, f.width - 1);
set24(header, 9, f.height - 1);
set24(header, 12, Math.min(0xffffff, Math.max(0, Math.round(f.duration))));
header[15] = f.dispose ? 1 : 0; // blending (bit 1 unset) and disposal
header[15] = (f.blend === false ? 2 : 0) | (f.dispose ? 1 : 0); // no blending, disposal
const body = new Uint8Array(16 + bitstream.length);
body.set(header);
body.set(bitstream, 16);
Expand Down
Loading
Loading