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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 3.3.0

### Features

- `ResizeOptions.fast` (default `true`): bilinear and area resizing (down to about a third of the size, and
enlarging) round to 8 bits between the two passes, so both passes share their multiplications between two channels.
`fast: false` keeps the more precise results of 3.2.1.
- Opaque images: halving a 1920×1080 photo takes 12 instead of 15 ms, doubling it 61 instead of 80 ms. About 10-25%
of the channel values differ from 3.2.1, by at most 1.
- Transparent images are premultiplied to 8 bits, like browsers do: halving takes 24 instead of 43 ms, doubling 119
instead of 260 ms. What shows once they're composited differs from 3.2.1 by up to about 2 steps (alpha by at most 2),
the color of pixels that are almost transparent can differ more.

## 3.2.1

### Performance
Expand Down
3 changes: 1 addition & 2 deletions src/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,6 @@ export class Animation extends Array<Frame> {
*/
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 @@ -179,7 +178,7 @@ export class Animation extends Array<Frame> {
// 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);
Image.prototype.resize.call(frame, Math.max(1, right - left), Math.max(1, bottom - top), modeOrOptions as ResizeMode);
frame.xOffset = left;
frame.yOffset = top;
}
Expand Down
41 changes: 26 additions & 15 deletions src/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ export interface CompositeOptions {
export interface ResizeOptions {
/** The resizing mode. Default: {@link Image.RESIZE_BILINEAR} */
mode?: ResizeMode;
/**
* Whether to round to 8 bits between the two passes, so two channels share the multiplications of both: about
* 15-25% faster for opaque images, with 10-25% of the channel values a step off the precise result (instead of
* about 1%, never more than 1). Transparent images are premultiplied to 8 bits like browsers do, which makes them
* about twice as fast: what shows once they're composited differs by up to about 2 steps, pixels that are almost
* transparent keep less of their color. Only affects {@link Image.RESIZE_BILINEAR} and {@link Image.RESIZE_AREA},
* down to about a third of the size. Default: `true`
*/
fast?: boolean;
}

/**
Expand Down Expand Up @@ -585,15 +594,16 @@ export class Image {
*/
scale(factor: number, mode?: ResizeMode): this;
scale(factor: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
const image = this.__scale__(factor, resizeMode(modeOrOptions));
const { mode, fast } = resizeOptions(modeOrOptions);
const image = this.__scale__(factor, mode, fast);
return this.__apply__(image);
}

/** @private */
/** @internal */
protected __scale__(factor: number, mode: ResizeMode = Image.RESIZE_BILINEAR): Image {
protected __scale__(factor: number, mode: ResizeMode = Image.RESIZE_BILINEAR, fast = true): Image {
if (factor === 1) return this;
return this.__resize__(this.width * factor, this.height * factor, mode);
return this.__resize__(this.width * factor, this.height * factor, mode, fast);
}

/**
Expand All @@ -615,8 +625,8 @@ export class Image {
*/
resize(width: number, height: number, mode?: ResizeMode): this;
resize(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
const mode = resizeMode(modeOrOptions);
const image = this.__resize__(width, height, mode);
const { mode, fast } = resizeOptions(modeOrOptions);
const image = this.__resize__(width, height, mode, fast);
return this.__apply__(image);
}

Expand All @@ -639,12 +649,12 @@ export class Image {
*/
contain(width: number, height: number, mode?: ResizeMode): this;
contain(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
const mode = resizeMode(modeOrOptions);
const options = resizeOptions(modeOrOptions);
width = Math.floor(width);
height = Math.floor(height);
// round instead of flooring the scaled size, so the limiting axis matches the box exactly
const scale = Math.min(width / this.width, height / this.height);
return this.resize(Math.max(1, Math.min(width, Math.round(this.width * scale))), Math.max(1, Math.min(height, Math.round(this.height * scale))), mode);
return this.resize(Math.max(1, Math.min(width, Math.round(this.width * scale))), Math.max(1, Math.min(height, Math.round(this.height * scale))), options);
}

/**
Expand All @@ -666,9 +676,9 @@ export class Image {
*/
fit(width: number, height: number, mode?: ResizeMode): this;
fit(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
const mode = resizeMode(modeOrOptions);
const options = resizeOptions(modeOrOptions);
const result = new Image(width, height);
this.contain(width, height, mode);
this.contain(width, height, options);
result.composite(this, (width - this.width) / 2, (height - this.height) / 2);
return this.__apply__(result);
}
Expand All @@ -692,17 +702,17 @@ export class Image {
*/
cover(width: number, height: number, mode?: ResizeMode): this;
cover(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
const mode = resizeMode(modeOrOptions);
const options = resizeOptions(modeOrOptions);
width = Math.floor(width);
height = Math.floor(height);
const scale = Math.max(width / this.width, height / this.height);
this.resize(Math.max(width, Math.round(this.width * scale)), Math.max(height, Math.round(this.height * scale)), mode);
this.resize(Math.max(width, Math.round(this.width * scale)), Math.max(height, Math.round(this.height * scale)), options);
return this.crop((this.width - width) / 2, (this.height - height) / 2, width, height);
}

/** @private */
/** @internal */
protected __resize__(width: number, height: number, mode: ResizeMode = Image.RESIZE_BILINEAR): Image {
protected __resize__(width: number, height: number, mode: ResizeMode = Image.RESIZE_BILINEAR, fast = true): Image {
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');
else if (width === Image.RESIZE_AUTO) width = Math.max(1, Math.round(this.width / this.height * Math.floor(height)));
else if (height === Image.RESIZE_AUTO) height = Math.max(1, Math.round(this.height / this.width * Math.floor(width)));
Expand All @@ -720,7 +730,7 @@ export class Image {
if (!filter) throw new Error('Invalid resize mode');

const image = new Image(width, height);
resample(this.bitmap, this.width, this.height, width, height, filter, image.bitmap);
resample(this.bitmap, this.width, this.height, width, height, filter, image.bitmap, fast);
return image;
}

Expand Down Expand Up @@ -2129,8 +2139,9 @@ function hue2rgb(p: number, q: number, t: number): number {
}

// the resize mode of a positional argument or options object
function resizeMode(modeOrOptions?: ResizeMode | ResizeOptions): ResizeMode {
return (isOptions(modeOrOptions) ? modeOrOptions.mode : modeOrOptions) ?? Image.RESIZE_BILINEAR;
function resizeOptions(modeOrOptions?: ResizeMode | ResizeOptions): { mode: ResizeMode, fast: boolean } {
const options = isOptions(modeOrOptions) ? modeOrOptions : { mode: modeOrOptions };
return { mode: options.mode ?? Image.RESIZE_BILINEAR, fast: options.fast ?? true };
}

// separable blend functions of normalized channel values (W3C Compositing and Blending Level 1)
Expand Down
Loading
Loading