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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@

### New

- **Options objects**: every method with optional arguments takes them as an object too, e.g.
`image.resize(100, 100, {mode: Image.RESIZE_BICUBIC})`, `image.composite(logo, {x: 10, y: 10, opacity: 0.5})`,
`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.
- **Metadata in every format**: `encodeJPEG()`, `encodeWebP()`, `encodeTGA()` and, for animations, `encodeGIF()`,
`encodeAPNG()` and `encodeWebP()` take the same metadata as `encode()` (now called `Metadata`). Each format stores
`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
the fields it has a place for: Exif and a comment for JPEG, Exif for WebP, comments for GIF, the extension area for
TGA, text chunks for PNG and APNG. Like PNGs, all files name ImageScript and its version as the software and
include the creation time by default; `{software: ''}` leaves the software out.
Expand Down
87 changes: 77 additions & 10 deletions src/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import * as gifCodec from './codecs/gif.ts';
import * as png from './codecs/png.ts';
import * as webp from './codecs/webp.ts';
import { BinaryData, view } from './util/mem.ts';
import { Image, ResizeMode } from './image.ts';
import { Image, ResizeMode, ResizeOptions } from './image.ts';
import { Frame } from './frame.ts';
import { ImageType } from './image-type.ts';
import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts';
import { isOptions } from './util/options.ts';

// loop counts: ImageScript (like GIF) counts repetitions (-1 forever, 0 play once), APNG and WebP count plays (0 forever)
const toPlays = (loopCount: number): number => loopCount < 0 ? 0 : loopCount + 1;
Expand All @@ -20,6 +21,36 @@ function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uin
});
}

/** Options of {@link Animation} */
export interface AnimationOptions {
/** How many times to repeat the animation (-1 = forever, 0 = play once). Default: `-1` */
loopCount?: number;
}

/** Options of {@link Animation.decode} */
export interface AnimationDecodeOptions {
/** Whether to stop decoding after the first frame. Default: `false` */
onlyFirstFrame?: boolean;
}

/** Options of {@link Animation.encodeWebP}, and those all animation encoders have */
export interface AnimationEncodeOptions {
/** Metadata to include, see {@link Metadata} */
metadata?: Metadata;
}

/** Options of {@link Animation.encodeGIF} */
export interface GIFEncodeOptions extends AnimationEncodeOptions {
/** 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above). Default: `95` */
quality?: number;
}

/** Options of {@link Animation.encodeAPNG} */
export interface APNGEncodeOptions extends AnimationEncodeOptions {
/** The zlib compression level (0-9), higher is smaller but slower. Default: `1` */
compression?: number;
}

/**
* An animation: an array of frames that can be decoded from and encoded to GIF, APNG and WebP.
* Array methods like map and filter return plain arrays.
Expand All @@ -28,13 +59,21 @@ 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 options See {@link AnimationOptions}
*/
constructor(frames: Frame[], options?: AnimationOptions);
/**
* 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) {
constructor(frames: Frame[], loopCount?: number);
constructor(frames: Frame[], loopCountOrOptions: number | AnimationOptions = -1) {
super();
const { loopCount = -1 } = isOptions(loopCountOrOptions) ? loopCountOrOptions : { loopCount: loopCountOrOptions };
// push instead of super(...frames), so a single frame isn't taken as a length
for (const frame of frames) this.push(frame);

Expand Down Expand Up @@ -91,7 +130,14 @@ export class Animation extends Array<Frame> {
* 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 {
resize(width: number, height: number, options?: ResizeOptions): this;
/**
* 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): this;
resize(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
const mode = (isOptions(modeOrOptions) ? modeOrOptions.mode : modeOrOptions) ?? Image.RESIZE_BILINEAR;
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;
Expand All @@ -116,12 +162,18 @@ export class Animation extends Array<Frame> {
if (!(frame instanceof Frame)) throw new Error('Animation contains invalid frames');
}

/**
* Encodes the animation as GIF
* @param options See {@link GIFEncodeOptions}
*/
encodeGIF(options?: GIFEncodeOptions): Promise<Uint8Array>;
/**
* Encodes the animation as GIF
* @param quality 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above)
* @param metadata Metadata to include, see {@link Metadata}
*/
async encodeGIF(quality = 95, metadata: Metadata = {}): Promise<Uint8Array> {
encodeGIF(quality?: number): Promise<Uint8Array>;
async encodeGIF(qualityOrOptions: number | GIFEncodeOptions = 95): Promise<Uint8Array> {
const { quality = 95, metadata = {} } = isOptions(qualityOrOptions) ? qualityOrOptions : { quality: qualityOrOptions };
this.__validate__();
const frames: gifCodec.GIFFrame[] = [];

Expand All @@ -140,13 +192,20 @@ export class Animation extends Array<Frame> {
return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames }, quality, gifComments(resolveMetadata(metadata)));
}

/**
* Encodes the animation as APNG (animated PNG, lossless and with full alpha). Viewers without APNG support show
* the first frame
* @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
* @param compression The zlib compression level (0-9), higher is smaller but slower
* @param metadata Metadata to include, see {@link Metadata}
*/
async encodeAPNG(compression = 1, metadata: Metadata = {}): Promise<Uint8Array> {
encodeAPNG(compression?: number): Promise<Uint8Array>;
async encodeAPNG(compressionOrOptions: number | APNGEncodeOptions = 1): Promise<Uint8Array> {
const { compression = 1, metadata = {} } = isOptions(compressionOrOptions) ? compressionOrOptions : { compression: compressionOrOptions };
this.__validate__();
if (!this.length) throw new Error('An animation needs at least one frame');

Expand All @@ -155,9 +214,9 @@ export class Animation extends Array<Frame> {

/**
* Encodes the animation as lossless animated WebP
* @param metadata Metadata to include, see {@link Metadata}
* @param options See {@link AnimationEncodeOptions}
*/
async encodeWebP(metadata: Metadata = {}): Promise<Uint8Array> {
async encodeWebP({ metadata = {} }: 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;
Expand Down Expand Up @@ -201,12 +260,20 @@ export class Animation extends Array<Frame> {
}));
}

/**
* Decodes an animated GIF, APNG or WebP into fully composited frames (still images become a single frame)
* @param input The binary data to decode
* @param options See {@link AnimationDecodeOptions}
*/
static decode(input: BinaryData, options?: AnimationDecodeOptions): Promise<Animation>;
/**
* Decodes an animated GIF, APNG or WebP 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> {
static decode(input: BinaryData, onlyExtractFirstFrame?: boolean): Promise<Animation>;
static async decode(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions = false): Promise<Animation> {
const onlyExtractFirstFrame = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions.onlyFirstFrame ?? false : onlyFirstFrameOrOptions;
const data = view(input);
const type = ImageType.getType(data);

Expand Down
49 changes: 39 additions & 10 deletions src/frame.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Image, ResizeMode } from './image.ts';
import { Image, ResizeMode, ResizeOptions } from './image.ts';
import { isOptions } from './util/options.ts';

/** Names of the frame disposal modes, see {@link Frame.disposalMode} */
export type FrameDisposalModeName = 'any' | 'keep' | 'previous' | 'background';
Expand All @@ -7,6 +8,18 @@ export type FrameDisposalModeId = 0 | 1 | 2 | 3;

const DISPOSAL_MODES: readonly FrameDisposalModeName[] = ['any', 'keep', 'previous', 'background'];

/** Options of {@link Frame} and {@link Frame.from} */
export interface FrameOptions {
/** The frame's duration in milliseconds. Default: `100` */
duration?: number;
/** The frame's offset on the x-axis. Default: `0` */
xOffset?: number;
/** The frame's offset on the y-axis. Default: `0` */
yOffset?: number;
/** The frame's disposal mode, see {@link Frame.disposalMode}. Default: `'keep'` */
disposalMode?: FrameDisposalModeName | FrameDisposalModeId;
}

/**
* Represents a frame of an {@link Animation}
*/
Expand Down Expand Up @@ -52,22 +65,30 @@ export class Frame extends Image {
return id as FrameDisposalModeId;
}

/**
* Creates a new, blank frame
* @param options See {@link FrameOptions}
*/
constructor(width: number, height: number, options?: FrameOptions);
/**
* Creates a new, blank frame
* @param duration The frame's duration (in ms)
* @param xOffset The frame's offset on the x-axis
* @param yOffset The frame's offset on the y-axis
* @param disposalMode The frame's disposal mode
*/
constructor(width: number, height: number, duration = 100, xOffset = 0, yOffset = 0, disposalMode: FrameDisposalModeName | FrameDisposalModeId = Frame.DISPOSAL_KEEP) {
constructor(width: number, height: number, duration?: number, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName | FrameDisposalModeId);
constructor(width: number, height: number, durationOrOptions?: number | FrameOptions, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName | FrameDisposalModeId) {
const { duration = 100, xOffset: x = 0, yOffset: y = 0, disposalMode: disposal = Frame.DISPOSAL_KEEP } =
isOptions(durationOrOptions) ? durationOrOptions : { duration: durationOrOptions, xOffset, yOffset, disposalMode };
if (isNaN(duration) || duration < 0)
throw new RangeError('Invalid frame duration');

super(width, height);
this.duration = duration;
this.xOffset = xOffset;
this.yOffset = yOffset;
this.disposalMode = disposalMode;
this.xOffset = x;
this.yOffset = y;
this.disposalMode = disposal;
}

/** The frame's disposal mode (`'any'`, `'keep'`, `'previous'` or `'background'`); also settable by id (0-3) */
Expand All @@ -92,6 +113,12 @@ export class Frame extends Image {
return `Frame<${this.width}x${this.height}x${this.duration}ms>`;
}

/**
* Converts an Image instance to a Frame, cloning it in the process
* @param image The image to create the frame from
* @param options See {@link FrameOptions}
*/
static from(image: Image, options?: FrameOptions): Frame;
/**
* Converts an Image instance to a Frame, cloning it in the process
* @param image The image to create the frame from
Expand All @@ -100,11 +127,13 @@ export class Frame extends Image {
* @param yOffset The frame's offset on the y-axis
* @param disposalMode The frame's disposal mode
*/
static from(image: Image, duration?: number, xOffset?: number, yOffset?: number, disposalMode: FrameDisposalModeName | FrameDisposalModeId = Frame.DISPOSAL_KEEP): Frame {
static from(image: Image, duration?: number, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName | FrameDisposalModeId): Frame;
static from(image: Image, durationOrOptions?: number | FrameOptions, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName | FrameDisposalModeId): Frame {
if (!(image instanceof Image))
throw new TypeError('Invalid image passed');

const frame = new Frame(image.width, image.height, duration, xOffset, yOffset, disposalMode);
const options = isOptions(durationOrOptions) ? durationOrOptions : { duration: durationOrOptions, xOffset, yOffset, disposalMode };
const frame = new Frame(image.width, image.height, options);
frame.bitmap.set(image.bitmap);

return frame;
Expand All @@ -114,17 +143,17 @@ export class Frame extends Image {
* Clones the frame, including its duration, offsets and disposal mode
*/
override clone(): Frame {
return Frame.from(this, this.duration, this.xOffset, this.yOffset, this.disposalMode);
return Frame.from(this, { duration: this.duration, xOffset: this.xOffset, yOffset: this.yOffset, disposalMode: this.disposalMode });
}

/**
* Resizes the frame, scaling its offsets along with it
*/
override resize(width: number, height: number, mode: ResizeMode = Image.RESIZE_BILINEAR): this {
override resize(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
const originalWidth = this.width;
const originalHeight = this.height;

super.resize(width, height, mode);
super.resize(width, height, modeOrOptions as ResizeMode);

this.xOffset = Math.round(this.xOffset * this.width / originalWidth);
this.yOffset = Math.round(this.yOffset * this.height / originalHeight);
Expand Down
25 changes: 14 additions & 11 deletions src/gif.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { BinaryData, view } from './util/mem.ts';
import { Frame } from './frame.ts';
import { ImageType } from './image-type.ts';
import { Animation } from './animation.ts';
import { Metadata } from './metadata.ts';
import { Animation, AnimationDecodeOptions, AnimationOptions, GIFEncodeOptions } from './animation.ts';
import { isOptions } from './util/options.ts';

/**
* Represents a GIF image as an array of frames, an {@link Animation} whose `encode()` and `decode()` work with GIFs.
Expand All @@ -14,10 +14,11 @@ 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)
* @param loopCountOrOptions How many times to repeat the GIF (-1 = forever, 0 = play once), or see
* {@link AnimationOptions}
*/
constructor(frames: Frame[], loopCount = -1) {
super(frames, loopCount);
constructor(frames: Frame[], loopCountOrOptions?: number | AnimationOptions) {
super(frames, loopCountOrOptions as number);
}

override toString(): string {
Expand All @@ -26,21 +27,23 @@ 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)
* @param metadata Metadata to include, see {@link Metadata}
* @param qualityOrOptions 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above), or
* see {@link GIFEncodeOptions}
* @deprecated Use {@link Animation.encodeGIF}
*/
async encode(quality = 95, metadata: Metadata = {}): Promise<Uint8Array> {
return this.encodeGIF(quality, metadata);
async encode(qualityOrOptions?: number | GIFEncodeOptions): Promise<Uint8Array> {
return this.encodeGIF(qualityOrOptions as number);
}

/**
* 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
* @param onlyFirstFrameOrOptions Whether to end GIF decoding after the first frame, or see
* {@link AnimationDecodeOptions}
* @deprecated Use {@link Animation.decode}, which decodes APNGs too
*/
static override async decode(input: BinaryData, onlyExtractFirstFrame = false): Promise<GIF> {
static override async decode(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions = false): Promise<GIF> {
const onlyExtractFirstFrame = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions.onlyFirstFrame ?? false : onlyFirstFrameOrOptions;
const data = view(input);
if (!ImageType.isGIF(new DataView(data.buffer, data.byteOffset, data.byteLength))) throw new Error('Unsupported image type');
return Animation.__fromGIF__(data, onlyExtractFirstFrame, GIF);
Expand Down
Loading
Loading