Two workers & worker termination (#198)

* Refactoring codecs

* Plugging in new processor

* Fixing decorator

* MozJPEG free issue

* Better worker aborting, and terminate workers that aren't used for 10 seconds

* Better comment

* Ooops, half-typed comment

* Uncommenting problematic line

* Surma fixed it!

* Abstracting WASM initialisation

* Better comment

* Don't need this.

* Adding ticket

* noInitalRun

* Reverting MozJPEG issue demo

* Making a const for worker timeout

* Inline docs

* Bail early rather than nesting

* Addressing nits
This commit is contained in:
Jake Archibald
2018-10-28 09:17:43 +00:00
committed by GitHub
parent 02b0c022ca
commit 43def798e1
56 changed files with 778 additions and 688 deletions

View File

@@ -0,0 +1,11 @@
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions { }
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-bmp';
export const label = 'Browser BMP';
export const mimeType = 'image/bmp';
export const extension = 'bmp';
export const defaultOptions: EncodeOptions = {};
export const featureTest = () => canvasEncodeTest(mimeType);

View File

@@ -1,16 +1,6 @@
import { mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions { }
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-bmp';
export const label = 'Browser BMP';
export const mimeType = 'image/bmp';
export const extension = 'bmp';
export const defaultOptions: EncodeOptions = {};
export const featureTest = () => canvasEncodeTest(mimeType);
export function encode(data: ImageData, options: EncodeOptions) {
export function encode(data: ImageData) {
return canvasEncode(data, mimeType);
}

View File

@@ -0,0 +1,11 @@
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions {}
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-gif';
export const label = 'Browser GIF';
export const mimeType = 'image/gif';
export const extension = 'gif';
export const defaultOptions: EncodeOptions = {};
export const featureTest = () => canvasEncodeTest(mimeType);

View File

@@ -1,16 +1,6 @@
import { mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions {}
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-gif';
export const label = 'Browser GIF';
export const mimeType = 'image/gif';
export const extension = 'gif';
export const defaultOptions: EncodeOptions = {};
export const featureTest = () => canvasEncodeTest(mimeType);
export function encode(data: ImageData, options: EncodeOptions) {
export function encode(data: ImageData) {
return canvasEncode(data, mimeType);
}

View File

@@ -0,0 +1,11 @@
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions { }
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-jp2';
export const label = 'Browser JPEG 2000';
export const mimeType = 'image/jp2';
export const extension = 'jp2';
export const defaultOptions: EncodeOptions = {};
export const featureTest = () => canvasEncodeTest(mimeType);

View File

@@ -1,16 +1,6 @@
import { mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions { }
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-jp2';
export const label = 'Browser JPEG 2000';
export const mimeType = 'image/jp2';
export const extension = 'jp2';
export const defaultOptions: EncodeOptions = {};
export const featureTest = () => canvasEncodeTest(mimeType);
export function encode(data: ImageData, options: EncodeOptions) {
export function encode(data: ImageData) {
return canvasEncode(data, mimeType);
}

View File

@@ -0,0 +1,8 @@
export interface EncodeOptions { quality: number; }
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-jpeg';
export const label = 'Browser JPEG';
export const mimeType = 'image/jpeg';
export const extension = 'jpg';
export const defaultOptions: EncodeOptions = { quality: 0.5 };

View File

@@ -1,14 +1,6 @@
import { EncodeOptions, mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
export interface EncodeOptions { quality: number; }
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-jpeg';
export const label = 'Browser JPEG';
export const mimeType = 'image/jpeg';
export const extension = 'jpg';
export const defaultOptions: EncodeOptions = { quality: 0.5 };
export function encode(data: ImageData, { quality }: EncodeOptions) {
return canvasEncode(data, mimeType, quality);
}

View File

@@ -0,0 +1,11 @@
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions { }
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-pdf';
export const label = 'Browser PDF';
export const mimeType = 'application/pdf';
export const extension = 'pdf';
export const defaultOptions: EncodeOptions = {};
export const featureTest = () => canvasEncodeTest(mimeType);

View File

@@ -1,16 +1,6 @@
import { mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions { }
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-pdf';
export const label = 'Browser PDF';
export const mimeType = 'application/pdf';
export const extension = 'pdf';
export const defaultOptions: EncodeOptions = {};
export const featureTest = () => canvasEncodeTest(mimeType);
export function encode(data: ImageData, options: EncodeOptions) {
export function encode(data: ImageData) {
return canvasEncode(data, mimeType);
}

View File

@@ -0,0 +1,8 @@
export interface EncodeOptions {}
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-png';
export const label = 'Browser PNG';
export const mimeType = 'image/png';
export const extension = 'png';
export const defaultOptions: EncodeOptions = {};

View File

@@ -1,14 +1,6 @@
import { mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
export interface EncodeOptions {}
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-png';
export const label = 'Browser PNG';
export const mimeType = 'image/png';
export const extension = 'png';
export const defaultOptions: EncodeOptions = {};
export function encode(data: ImageData, options: EncodeOptions) {
export function encode(data: ImageData) {
return canvasEncode(data, mimeType);
}

View File

@@ -0,0 +1,11 @@
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions { }
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-tiff';
export const label = 'Browser TIFF';
export const mimeType = 'image/tiff';
export const extension = 'tiff';
export const defaultOptions: EncodeOptions = {};
export const featureTest = () => canvasEncodeTest(mimeType);

View File

@@ -1,16 +1,6 @@
import { mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions { }
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-tiff';
export const label = 'Browser TIFF';
export const mimeType = 'image/tiff';
export const extension = 'tiff';
export const defaultOptions: EncodeOptions = {};
export const featureTest = () => canvasEncodeTest(mimeType);
export function encode(data: ImageData, options: EncodeOptions) {
export function encode(data: ImageData) {
return canvasEncode(data, mimeType);
}

View File

@@ -1,18 +0,0 @@
import { canDecodeImage, nativeDecode } from '../../lib/util';
export const name = 'Browser WebP Decoder';
export async function decode(blob: Blob): Promise<ImageData> {
return nativeDecode(blob);
}
// tslint:disable-next-line:max-line-length Its a data URL. Whatcha gonna do?
const webpFile = 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=';
export function isSupported(): Promise<boolean> {
return canDecodeImage(webpFile);
}
const supportedMimeTypes = ['image/webp'];
export function canHandleMimeType(mimeType: string): boolean {
return supportedMimeTypes.includes(mimeType);
}

View File

@@ -0,0 +1,11 @@
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions { quality: number; }
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-webp';
export const label = 'Browser WebP';
export const mimeType = 'image/webp';
export const extension = 'webp';
export const defaultOptions: EncodeOptions = { quality: 0.5 };
export const featureTest = () => canvasEncodeTest(mimeType);

View File

@@ -1,15 +1,5 @@
import { EncodeOptions, mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions { quality: number; }
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'browser-webp';
export const label = 'Browser WebP';
export const mimeType = 'image/webp';
export const extension = 'webp';
export const defaultOptions: EncodeOptions = { quality: 0.5 };
export const featureTest = () => canvasEncodeTest(mimeType);
export function encode(data: ImageData, { quality }: EncodeOptions) {
return canvasEncode(data, mimeType, quality);

View File

@@ -1,47 +1,21 @@
import * as wasmWebp from './webp/decoder';
import * as browserWebp from './webp/decoder';
import { nativeDecode, sniffMimeType, canDecodeImage } from '../lib/util';
import Processor from './processor';
import { nativeDecode, sniffMimeType } from '../lib/util';
// tslint:disable-next-line:max-line-length Its a data URL. Whatcha gonna do?
const webpFile = 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=';
const nativeWebPSupported = canDecodeImage(webpFile);
export interface Decoder {
name: string;
decode(blob: Blob): Promise<ImageData>;
isSupported(): Promise<boolean>;
canHandleMimeType(mimeType: string): boolean;
}
// We load all decoders and filter out the unsupported ones.
export const decodersPromise: Promise<Decoder[]> = Promise.all(
[
browserWebp,
wasmWebp,
]
.map(async (decoder) => {
if (await decoder.isSupported()) {
return decoder;
}
return null;
}),
// TypeScript is not smart enough to realized that Im filtering all the falsy
// values here.
).then(list => list.filter(item => !!item)) as any as Promise<Decoder[]>;
async function findDecodersByMimeType(mimeType: string): Promise<Decoder[]> {
const decoders = await decodersPromise;
return decoders.filter(decoder => decoder.canHandleMimeType(mimeType));
}
export async function decodeImage(blob: Blob): Promise<ImageData> {
export async function decodeImage(blob: Blob, processor: Processor): Promise<ImageData> {
const mimeType = await sniffMimeType(blob);
const decoders = await findDecodersByMimeType(mimeType);
if (decoders.length <= 0) {
// If we cant find a decoder, hailmary with the browser's decoders
return nativeDecode(blob);
try {
if (mimeType === 'image/webp' && !(await nativeWebPSupported)) {
return await processor.webpDecode(blob);
}
// Otherwise, just throw it at the browser's decoder.
return await nativeDecode(blob);
} catch (err) {
throw Error("Couldn't decode image");
}
for (const decoder of decoders) {
try {
return await decoder.decode(blob);
} catch { }
}
throw new Error('No decoder could decode image');
}

View File

@@ -1,15 +1,15 @@
import * as identity from './identity/encoder';
import * as optiPNG from './optipng/encoder';
import * as mozJPEG from './mozjpeg/encoder';
import * as webP from './webp/encoder';
import * as browserPNG from './browser-png/encoder';
import * as browserJPEG from './browser-jpeg/encoder';
import * as browserWebP from './browser-webp/encoder';
import * as browserGIF from './browser-gif/encoder';
import * as browserTIFF from './browser-tiff/encoder';
import * as browserJP2 from './browser-jp2/encoder';
import * as browserBMP from './browser-bmp/encoder';
import * as browserPDF from './browser-pdf/encoder';
import * as identity from './identity/encoder-meta';
import * as optiPNG from './optipng/encoder-meta';
import * as mozJPEG from './mozjpeg/encoder-meta';
import * as webP from './webp/encoder-meta';
import * as browserPNG from './browser-png/encoder-meta';
import * as browserJPEG from './browser-jpeg/encoder-meta';
import * as browserWebP from './browser-webp/encoder-meta';
import * as browserGIF from './browser-gif/encoder-meta';
import * as browserTIFF from './browser-tiff/encoder-meta';
import * as browserJP2 from './browser-jp2/encoder-meta';
import * as browserBMP from './browser-bmp/encoder-meta';
import * as browserPDF from './browser-pdf/encoder-meta';
export interface EncoderSupportMap {
[key: string]: boolean;

View File

@@ -1,46 +0,0 @@
import { QuantizeOptions } from './quantizer';
import imagequant, { QuantizerModule } from '../../../codecs/imagequant/imagequant';
// Using require() so TypeScript doesnt complain about this not being a module.
const wasmBinaryUrl = require('../../../codecs/imagequant/imagequant.wasm');
export default class ImageQuant {
private emscriptenModule: Promise<QuantizerModule>;
constructor() {
this.emscriptenModule = new Promise((resolve) => {
const m = imagequant({
// Just to be safe, dont automatically invoke any wasm functions
noInitialRun: false,
locateFile(url: string): string {
// Redirect the request for the wasm binary to whatever webpack gave us.
if (url.endsWith('.wasm')) {
return wasmBinaryUrl;
}
return url;
},
onRuntimeInitialized() {
// An Emscripten is a then-able that, for some reason, `then()`s itself,
// causing an infite loop when you wrap it in a real promise. Deleting the `then`
// prop solves this for now.
// See: https://github.com/kripken/emscripten/blob/incoming/src/postamble.js#L129
// TODO(surma@): File a bug with Emscripten on this.
delete (m as any).then;
resolve(m);
},
});
});
}
async quantize(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
const m = await this.emscriptenModule;
const result = opts.zx ?
m.zx_quantize(data.data, data.width, data.height, opts.dither)
:
m.quantize(data.data, data.width, data.height, opts.maxNumColors, opts.dither);
m.free_result();
return new ImageData(new Uint8ClampedArray(result.buffer), result.width, result.height);
}
}

View File

@@ -1,7 +1,7 @@
import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util';
import { inputFieldValueAsNumber, konami } from '../../lib/util';
import { QuantizeOptions } from './quantizer';
import { QuantizeOptions } from './processor-meta';
const konamiPromise = konami();

View File

@@ -0,0 +1,11 @@
export interface QuantizeOptions {
zx: number;
maxNumColors: number;
dither: number;
}
export const defaultOptions: QuantizeOptions = {
zx: 0,
maxNumColors: 256,
dither: 1.0,
};

View File

@@ -0,0 +1,21 @@
import imagequant, { QuantizerModule } from '../../../codecs/imagequant/imagequant';
import wasmUrl from '../../../codecs/imagequant/imagequant.wasm';
import { QuantizeOptions } from './processor-meta';
import { initWasmModule } from '../util';
let emscriptenModule: Promise<QuantizerModule>;
export async function process(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
if (!emscriptenModule) emscriptenModule = initWasmModule(imagequant, wasmUrl);
const module = await emscriptenModule;
const result = opts.zx ?
module.zx_quantize(data.data, data.width, data.height, opts.dither)
:
module.quantize(data.data, data.width, data.height, opts.maxNumColors, opts.dither);
module.free_result();
return new ImageData(new Uint8ClampedArray(result.buffer), result.width, result.height);
}

View File

@@ -1,18 +0,0 @@
import QuantizerWorker from './Quantizer.worker';
export async function quantize(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
const quantizer = await new QuantizerWorker();
return quantizer.quantize(data, opts);
}
export interface QuantizeOptions {
zx: number;
maxNumColors: number;
dither: number;
}
export const defaultOptions: QuantizeOptions = {
zx: 0,
maxNumColors: 256,
dither: 1.0,
};

View File

@@ -1,43 +0,0 @@
import mozjpeg_enc, { MozJPEGModule } from '../../../codecs/mozjpeg_enc/mozjpeg_enc';
// Using require() so TypeScript doesnt complain about this not being a module.
import { EncodeOptions } from './encoder';
const wasmBinaryUrl = require('../../../codecs/mozjpeg_enc/mozjpeg_enc.wasm');
export default class MozJpegEncoder {
private emscriptenModule: Promise<MozJPEGModule>;
constructor() {
this.emscriptenModule = new Promise((resolve) => {
const m = mozjpeg_enc({
// Just to be safe, dont automatically invoke any wasm functions
noInitialRun: false,
locateFile(url: string): string {
// Redirect the request for the wasm binary to whatever webpack gave us.
if (url.endsWith('.wasm')) {
return wasmBinaryUrl;
}
return url;
},
onRuntimeInitialized() {
// An Emscripten is a then-able that, for some reason, `then()`s itself,
// causing an infite loop when you wrap it in a real promise. Deleten the `then`
// prop solves this for now.
// See: https://github.com/kripken/emscripten/blob/incoming/src/postamble.js#L129
// TODO(surma@): File a bug with Emscripten on this.
delete (m as any).then;
resolve(m);
},
});
});
}
async encode(data: ImageData, options: EncodeOptions): Promise<ArrayBuffer> {
const module = await this.emscriptenModule;
const resultView = module.encode(data.data, data.width, data.height, options);
const result = new Uint8Array(resultView);
module.free_result();
// wasm cant run on SharedArrayBuffers, so we hard-cast to ArrayBuffer.
return result.buffer as ArrayBuffer;
}
}

View File

@@ -0,0 +1,41 @@
export enum MozJpegColorSpace {
GRAYSCALE = 1,
RGB,
YCbCr,
}
export interface EncodeOptions {
quality: number;
baseline: boolean;
arithmetic: boolean;
progressive: boolean;
optimize_coding: boolean;
smoothing: number;
color_space: MozJpegColorSpace;
quant_table: number;
trellis_multipass: boolean;
trellis_opt_zero: boolean;
trellis_opt_table: boolean;
trellis_loops: number;
}
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'mozjpeg';
export const label = 'MozJPEG';
export const mimeType = 'image/jpeg';
export const extension = 'jpg';
export const defaultOptions: EncodeOptions = {
quality: 75,
baseline: false,
arithmetic: false,
progressive: true,
optimize_coding: true,
smoothing: 0,
color_space: MozJpegColorSpace.YCbCr,
quant_table: 3,
trellis_multipass: false,
trellis_opt_zero: false,
trellis_opt_table: false,
trellis_loops: 1,
};

View File

@@ -1,49 +1,18 @@
import EncoderWorker from './Encoder.worker';
import mozjpeg_enc, { MozJPEGModule } from '../../../codecs/mozjpeg_enc/mozjpeg_enc';
import wasmUrl from '../../../codecs/mozjpeg_enc/mozjpeg_enc.wasm';
import { EncodeOptions } from './encoder-meta';
import { initWasmModule } from '../util';
export enum MozJpegColorSpace {
GRAYSCALE = 1,
RGB,
YCbCr,
}
export interface EncodeOptions {
quality: number;
baseline: boolean;
arithmetic: boolean;
progressive: boolean;
optimize_coding: boolean;
smoothing: number;
color_space: MozJpegColorSpace;
quant_table: number;
trellis_multipass: boolean;
trellis_opt_zero: boolean;
trellis_opt_table: boolean;
trellis_loops: number;
}
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'mozjpeg';
export const label = 'MozJPEG';
export const mimeType = 'image/jpeg';
export const extension = 'jpg';
export const defaultOptions: EncodeOptions = {
quality: 75,
baseline: false,
arithmetic: false,
progressive: true,
optimize_coding: true,
smoothing: 0,
color_space: MozJpegColorSpace.YCbCr,
quant_table: 3,
trellis_multipass: false,
trellis_opt_zero: false,
trellis_opt_table: false,
trellis_loops: 1,
};
export async function encode(data: ImageData, options: EncodeOptions) {
// We need to await this because it's been comlinked.
const encoder = await new EncoderWorker();
return encoder.encode(data, options);
let emscriptenModule: Promise<MozJPEGModule>;
export async function encode(data: ImageData, options: EncodeOptions): Promise<ArrayBuffer> {
if (!emscriptenModule) emscriptenModule = initWasmModule(mozjpeg_enc, wasmUrl);
const module = await emscriptenModule;
const resultView = module.encode(data.data, data.width, data.height, options);
const result = new Uint8Array(resultView);
module.free_result();
// wasm cant run on SharedArrayBuffers, so we hard-cast to ArrayBuffer.
return result.buffer as ArrayBuffer;
}

View File

@@ -1,7 +1,7 @@
import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util';
import { inputFieldChecked, inputFieldValueAsNumber } from '../../lib/util';
import { EncodeOptions, MozJpegColorSpace } from './encoder';
import { EncodeOptions, MozJpegColorSpace } from './encoder-meta';
import '../../custom-els/RangeInput';
type Props = {

View File

@@ -1,41 +0,0 @@
import optipng, { OptiPngModule } from '../../../codecs/optipng/optipng';
// Using require() so TypeScript doesnt complain about this not being a module.
import { EncodeOptions } from './encoder';
const wasmBinaryUrl = require('../../../codecs/optipng/optipng.wasm');
export default class OptiPng {
private emscriptenModule: Promise<OptiPngModule>;
constructor() {
this.emscriptenModule = new Promise((resolve) => {
const m = optipng({
// Just to be safe, dont automatically invoke any wasm functions
noInitialRun: false,
locateFile(url: string): string {
// Redirect the request for the wasm binary to whatever webpack gave us.
if (url.endsWith('.wasm')) {
return wasmBinaryUrl;
}
return url;
},
onRuntimeInitialized() {
// An Emscripten is a then-able that, for some reason, `then()`s itself,
// causing an infite loop when you wrap it in a real promise. Deleting the `then`
// prop solves this for now.
// See: https://github.com/kripken/emscripten/blob/incoming/src/postamble.js#L129
// TODO(surma@): File a bug with Emscripten on this.
delete (m as any).then;
resolve(m);
},
});
});
}
async compress(data: BufferSource, opts: EncodeOptions): Promise<ArrayBuffer> {
const m = await this.emscriptenModule;
const result = m.compress(data, opts);
const copy = new Uint8Array(result).buffer as ArrayBuffer;
m.free_result();
return copy;
}
}

View File

@@ -0,0 +1,13 @@
export interface EncodeOptions {
level: number;
}
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'png';
export const label = 'OptiPNG';
export const mimeType = 'image/png';
export const extension = 'png';
export const defaultOptions: EncodeOptions = {
level: 2,
};

View File

@@ -1,23 +1,18 @@
import { canvasEncode, blobToArrayBuffer } from '../../lib/util';
import EncodeWorker from './Encoder.worker';
import optipng, { OptiPngModule } from '../../../codecs/optipng/optipng';
import wasmUrl from '../../../codecs/optipng/optipng.wasm';
import { EncodeOptions } from './encoder-meta';
import { initWasmModule } from '../util';
export interface EncodeOptions {
level: number;
}
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'png';
export const label = 'OptiPNG';
export const mimeType = 'image/png';
export const extension = 'png';
export const defaultOptions: EncodeOptions = {
level: 2,
};
export async function encode(data: ImageData, opts: EncodeOptions): Promise<ArrayBuffer> {
const pngBlob = await canvasEncode(data, mimeType);
const pngBuffer = await blobToArrayBuffer(pngBlob);
const encodeWorker = await new EncodeWorker();
return encodeWorker.compress(pngBuffer, opts);
let emscriptenModule: Promise<OptiPngModule>;
export async function compress(data: BufferSource, options: EncodeOptions): Promise<ArrayBuffer> {
if (!emscriptenModule) emscriptenModule = initWasmModule(optipng, wasmUrl);
const module = await emscriptenModule;
const resultView = module.compress(data, options);
const result = new Uint8Array(resultView);
module.free_result();
// wasm cant run on SharedArrayBuffers, so we hard-cast to ArrayBuffer.
return result.buffer as ArrayBuffer;
}

View File

@@ -1,7 +1,7 @@
import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util';
import { inputFieldValueAsNumber } from '../../lib/util';
import { EncodeOptions } from './encoder';
import { EncodeOptions } from './encoder-meta';
type Props = {
options: EncodeOptions;

View File

@@ -1,5 +1,7 @@
import { QuantizeOptions, defaultOptions as quantizerDefaultOptions } from './imagequant/quantizer';
import { ResizeOptions, defaultOptions as resizeDefaultOptions } from './resize/resize';
import {
QuantizeOptions, defaultOptions as quantizerDefaultOptions,
} from './imagequant/processor-meta';
import { ResizeOptions, defaultOptions as resizeDefaultOptions } from './resize/processor-meta';
interface Enableable {
enabled: boolean;

View File

@@ -0,0 +1,41 @@
import { expose } from 'comlink';
import { EncodeOptions as MozJPEGEncoderOptions } from './mozjpeg/encoder-meta';
import { QuantizeOptions } from './imagequant/processor-meta';
import { EncodeOptions as OptiPNGEncoderOptions } from './optipng/encoder-meta';
import { EncodeOptions as WebPEncoderOptions } from './webp/encoder-meta';
async function mozjpegEncode(
data: ImageData, options: MozJPEGEncoderOptions,
): Promise<ArrayBuffer> {
const { encode } = await import('./mozjpeg/encoder');
return encode(data, options);
}
async function quantize(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
const { process } = await import('./imagequant/processor');
return process(data, opts);
}
async function optiPngEncode(
data: BufferSource, options: OptiPNGEncoderOptions,
): Promise<ArrayBuffer> {
const { compress } = await import('./optipng/encoder');
return compress(data, options);
}
async function webpEncode(
data: ImageData, options: WebPEncoderOptions,
): Promise<ArrayBuffer> {
const { encode } = await import('./webp/encoder');
return encode(data, options);
}
async function webpDecode(data: ArrayBuffer): Promise<ImageData> {
const { decode } = await import('./webp/decoder');
return decode(data);
}
const exports = { mozjpegEncode, quantize, optiPngEncode, webpEncode, webpDecode };
export type ProcessorWorkerApi = typeof exports;
expose(exports, self);

205
src/codecs/processor.ts Normal file
View File

@@ -0,0 +1,205 @@
import { proxy } from 'comlink';
import { QuantizeOptions } from './imagequant/processor-meta';
import { ProcessorWorkerApi } from './processor-worker';
import { canvasEncode, blobToArrayBuffer } from '../lib/util';
import { EncodeOptions as MozJPEGEncoderOptions } from './mozjpeg/encoder-meta';
import { EncodeOptions as OptiPNGEncoderOptions } from './optipng/encoder-meta';
import { EncodeOptions as WebPEncoderOptions } from './webp/encoder-meta';
import { EncodeOptions as BrowserJPEGOptions } from './browser-jpeg/encoder-meta';
import { EncodeOptions as BrowserWebpEncodeOptions } from './browser-webp/encoder-meta';
import { BitmapResizeOptions, VectorResizeOptions } from './resize/processor-meta';
import { resize, vectorResize } from './resize/processor';
import * as browserBMP from './browser-bmp/encoder';
import * as browserPNG from './browser-png/encoder';
import * as browserJPEG from './browser-jpeg/encoder';
import * as browserWebP from './browser-webp/encoder';
import * as browserGIF from './browser-gif/encoder';
import * as browserTIFF from './browser-tiff/encoder';
import * as browserJP2 from './browser-jp2/encoder';
import * as browserPDF from './browser-pdf/encoder';
/** How long the worker should be idle before terminating. */
const workerTimeout = 1000;
interface ProcessingJobOptions {
needsWorker?: boolean;
}
export default class Processor {
/** Worker instance associated with this processor. */
private _worker?: Worker;
/** Comlinked worker API. */
private _workerApi?: ProcessorWorkerApi;
/** Rejector for a pending promise. */
private _abortRejector?: (err: Error) => void;
/** Is work currently happening? */
private _busy = false;
/** Incementing ID so we can tell if a job has been superseded. */
private _latestJobId: number = 0;
/** setTimeout ID for killing the worker when idle. */
private _workerTimeoutId: number = 0;
/**
* Decorator that manages the (re)starting of the worker and aborting existing jobs. Not all
* processing jobs require a worker (e.g. the main thread canvas encodes), use the needsWorker
* option to control this.
*/
private static _processingJob(options: ProcessingJobOptions = {}) {
const { needsWorker = false } = options;
return (target: Processor, propertyKey: string, descriptor: PropertyDescriptor): void => {
const processingFunc = descriptor.value;
descriptor.value = async function (this: Processor, ...args: any[]) {
this._latestJobId += 1;
const jobId = this._latestJobId;
this.abortCurrent();
if (needsWorker) self.clearTimeout(this._workerTimeoutId);
if (!this._worker && needsWorker) {
// worker-loader does magic here.
// @ts-ignore - Typescript doesn't know about the 2nd param to new Worker, and the
// definition can't be overwritten.
this._worker = new Worker('./processor-worker.ts', { type: 'module' }) as Worker;
// Need to do some TypeScript trickery to make the type match.
this._workerApi = proxy(this._worker) as any as ProcessorWorkerApi;
}
this._busy = true;
const returnVal = Promise.race([
processingFunc.call(this, ...args),
new Promise((_, reject) => { this._abortRejector = reject; }),
]);
// Wait for the operation to settle.
await returnVal.catch(() => {});
// If no other jobs are happening, cleanup.
if (jobId === this._latestJobId) this._jobCleanup();
return returnVal;
};
};
}
private _jobCleanup(): void {
this._busy = false;
if (!this._worker) return;
// If the worker is unused for 10 seconds, remove it to save memory.
this._workerTimeoutId = self.setTimeout(
() => {
if (this._busy) throw Error("Worker shouldn't be busy");
if (!this._worker) return;
this._worker.terminate();
this._worker = undefined;
},
workerTimeout,
);
}
/** Abort the current job, if any */
abortCurrent() {
if (!this._busy) return;
if (!this._worker || !this._abortRejector) {
throw Error("There must be a worker/rejector if it's busy");
}
this._abortRejector(new DOMException('Aborted', 'AbortError'));
this._worker.terminate();
this._worker = undefined;
this._abortRejector = undefined;
this._busy = false;
}
// Off main thread jobs:
@Processor._processingJob({ needsWorker: true })
imageQuant(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
return this._workerApi!.quantize(data, opts);
}
@Processor._processingJob({ needsWorker: true })
mozjpegEncode(
data: ImageData, opts: MozJPEGEncoderOptions,
): Promise<ArrayBuffer> {
return this._workerApi!.mozjpegEncode(data, opts);
}
@Processor._processingJob({ needsWorker: true })
async optiPngEncode(
data: ImageData, opts: OptiPNGEncoderOptions,
): Promise<ArrayBuffer> {
// OptiPNG expects PNG input.
const pngBlob = await canvasEncode(data, 'image/png');
const pngBuffer = await blobToArrayBuffer(pngBlob);
return this._workerApi!.optiPngEncode(pngBuffer, opts);
}
@Processor._processingJob({ needsWorker: true })
webpEncode(data: ImageData, opts: WebPEncoderOptions): Promise<ArrayBuffer> {
return this._workerApi!.webpEncode(data, opts);
}
@Processor._processingJob({ needsWorker: true })
async webpDecode(blob: Blob): Promise<ImageData> {
const data = await blobToArrayBuffer(blob);
return this._workerApi!.webpDecode(data);
}
// Not-worker jobs:
@Processor._processingJob()
browserBmpEncode(data: ImageData): Promise<Blob> {
return browserBMP.encode(data);
}
@Processor._processingJob()
browserPngEncode(data: ImageData): Promise<Blob> {
return browserPNG.encode(data);
}
@Processor._processingJob()
browserJpegEncode(data: ImageData, opts: BrowserJPEGOptions): Promise<Blob> {
return browserJPEG.encode(data, opts);
}
@Processor._processingJob()
browserWebpEncode(data: ImageData, opts: BrowserWebpEncodeOptions): Promise<Blob> {
return browserWebP.encode(data, opts);
}
@Processor._processingJob()
browserGifEncode(data: ImageData): Promise<Blob> {
return browserGIF.encode(data);
}
@Processor._processingJob()
browserTiffEncode(data: ImageData): Promise<Blob> {
return browserTIFF.encode(data);
}
@Processor._processingJob()
browserJp2Encode(data: ImageData): Promise<Blob> {
return browserJP2.encode(data);
}
@Processor._processingJob()
browserPdfEncode(data: ImageData): Promise<Blob> {
return browserPDF.encode(data);
}
// Synchronous jobs
resize(data: ImageData, opts: BitmapResizeOptions) {
this.abortCurrent();
return resize(data, opts);
}
vectorResize(data: HTMLImageElement, opts: VectorResizeOptions) {
this.abortCurrent();
return vectorResize(data, opts);
}
}

View File

@@ -2,7 +2,7 @@ import { h, Component } from 'preact';
import linkState from 'linkstate';
import { bind } from '../../lib/initial-util';
import { inputFieldValueAsNumber } from '../../lib/util';
import { ResizeOptions } from './resize';
import { ResizeOptions } from './processor-meta';
interface Props {
isVector: Boolean;

View File

@@ -0,0 +1,26 @@
type BitmapResizeMethods = 'browser-pixelated' | 'browser-low' | 'browser-medium' | 'browser-high';
export interface ResizeOptions {
width: number;
height: number;
method: 'vector' | BitmapResizeMethods;
fitMethod: 'stretch' | 'cover';
}
export interface BitmapResizeOptions extends ResizeOptions {
method: BitmapResizeMethods;
}
export interface VectorResizeOptions extends ResizeOptions {
method: 'vector';
}
export const defaultOptions: ResizeOptions = {
// Width and height will always default to the image size.
// This is set elsewhere.
width: 1,
height: 1,
// This will be set to 'vector' if the input is SVG.
method: 'browser-high',
fitMethod: 'stretch',
};

View File

@@ -1,4 +1,5 @@
import { nativeResize, NativeResizeMethod, drawableToImageData } from '../../lib/util';
import { BitmapResizeOptions, VectorResizeOptions } from './processor-meta';
function getCoverOffsets(sw: number, sh: number, dw: number, dh: number) {
const currentAspect = sw / sh;
@@ -46,30 +47,3 @@ export function vectorResize(data: HTMLImageElement, opts: VectorResizeOptions):
width: opts.width, height: opts.height,
});
}
type BitmapResizeMethods = 'browser-pixelated' | 'browser-low' | 'browser-medium' | 'browser-high';
export interface ResizeOptions {
width: number;
height: number;
method: 'vector' | BitmapResizeMethods;
fitMethod: 'stretch' | 'cover';
}
export interface BitmapResizeOptions extends ResizeOptions {
method: BitmapResizeMethods;
}
export interface VectorResizeOptions extends ResizeOptions {
method: 'vector';
}
export const defaultOptions: ResizeOptions = {
// Width and height will always default to the image size.
// This is set elsewhere.
width: 1,
height: 1,
// This will be set to 'vector' if the input is SVG.
method: 'browser-high',
fitMethod: 'stretch',
};

27
src/codecs/util.ts Normal file
View File

@@ -0,0 +1,27 @@
type ModuleFactory<M extends EmscriptenWasm.Module> = (
opts: EmscriptenWasm.ModuleOpts,
) => M;
export function initWasmModule<T extends EmscriptenWasm.Module>(
moduleFactory: ModuleFactory<T>,
wasmUrl: string,
): Promise<T> {
return new Promise((resolve) => {
const module = moduleFactory({
// Just to be safe, don't automatically invoke any wasm functions
noInitialRun: true,
locateFile(url: string): string {
// Redirect the request for the wasm binary to whatever webpack gave us.
if (url.endsWith('.wasm')) return wasmUrl;
return url;
},
onRuntimeInitialized() {
// An Emscripten is a then-able that resolves with itself, causing an infite loop when you
// wrap it in a real promise. Delete the `then` prop solves this for now.
// https://github.com/kripken/emscripten/issues/5820
delete (module as any).then;
resolve(module);
},
});
});
}

View File

@@ -1,46 +0,0 @@
import webp_dec, { WebPModule } from '../../../codecs/webp_dec/webp_dec';
// Using require() so TypeScript doesnt complain about this not being a module.
const wasmBinaryUrl = require('../../../codecs/webp_dec/webp_dec.wasm');
// API exposed by wasm module. Details in the codecs README.
export default class WebpDecoder {
private emscriptenModule: Promise<WebPModule>;
constructor() {
this.emscriptenModule = new Promise((resolve) => {
const m = webp_dec({
// Just to be safe, dont automatically invoke any wasm functions
noInitialRun: false,
locateFile(url: string): string {
// Redirect the request for the wasm binary to whatever webpack gave us.
if (url.endsWith('.wasm')) {
return wasmBinaryUrl;
}
return url;
},
onRuntimeInitialized() {
// An Emscripten is a then-able that, for some reason, `then()`s itself,
// causing an infite loop when you wrap it in a real promise. Deleting the `then`
// prop solves this for now.
// See: https://github.com/kripken/emscripten/blob/incoming/src/postamble.js#L129
// TODO(surma@): File a bug with Emscripten on this.
delete (m as any).then;
resolve(m);
},
});
});
}
async decode(data: ArrayBuffer): Promise<ImageData> {
const m = await this.emscriptenModule;
const rawImage = m.decode(data);
m.free_result();
return new ImageData(
new Uint8ClampedArray(rawImage.buffer),
rawImage.width,
rawImage.height,
);
}
}

View File

@@ -1,43 +0,0 @@
import webp_enc, { WebPModule } from '../../../codecs/webp_enc/webp_enc';
// Using require() so TypeScript doesnt complain about this not being a module.
import { EncodeOptions } from './encoder';
const wasmBinaryUrl = require('../../../codecs/webp_enc/webp_enc.wasm');
export default class WebPEncoder {
private emscriptenModule: Promise<WebPModule>;
constructor() {
this.emscriptenModule = new Promise((resolve) => {
const m = webp_enc({
// Just to be safe, dont automatically invoke any wasm functions
noInitialRun: false,
locateFile(url: string): string {
// Redirect the request for the wasm binary to whatever webpack gave us.
if (url.endsWith('.wasm')) {
return wasmBinaryUrl;
}
return url;
},
onRuntimeInitialized() {
// An Emscripten is a then-able that, for some reason, `then()`s itself,
// causing an infite loop when you wrap it in a real promise. Deleten the `then`
// prop solves this for now.
// See: https://github.com/kripken/emscripten/blob/incoming/src/postamble.js#L129
// TODO(surma@): File a bug with Emscripten on this.
delete (m as any).then;
resolve(m);
},
});
});
}
async encode(data: ImageData, options: EncodeOptions): Promise<ArrayBuffer> {
const module = await this.emscriptenModule;
const resultView = module.encode(data.data, data.width, data.height, options);
const result = new Uint8Array(resultView);
module.free_result();
return result.buffer as ArrayBuffer;
}
}

View File

@@ -0,0 +1,7 @@
export const name = 'WASM WebP Decoder';
const supportedMimeTypes = ['image/webp'];
export function canHandleMimeType(mimeType: string): boolean {
return supportedMimeTypes.includes(mimeType);
}

View File

@@ -1,17 +1,20 @@
import { blobToArrayBuffer } from '../../lib/util';
import DecoderWorker from './Decoder.worker';
import webp_dec, { WebPModule } from '../../../codecs/webp_dec/webp_dec';
import wasmUrl from '../../../codecs/webp_dec/webp_dec.wasm';
import { initWasmModule } from '../util';
export const name = 'WASM WebP Decoder';
export async function decode(blob: Blob): Promise<ImageData> {
const decoder = await new DecoderWorker();
return decoder.decode(await blobToArrayBuffer(blob));
}
let emscriptenModule: Promise<WebPModule>;
export async function isSupported(): Promise<boolean> {
return true;
}
export async function decode(data: ArrayBuffer): Promise<ImageData> {
if (!emscriptenModule) emscriptenModule = initWasmModule(webp_dec, wasmUrl);
const supportedMimeTypes = ['image/webp'];
export function canHandleMimeType(mimeType: string): boolean {
return supportedMimeTypes.includes(mimeType);
const module = await emscriptenModule;
const rawImage = module.decode(data);
const result = new ImageData(
new Uint8ClampedArray(rawImage.buffer),
rawImage.width,
rawImage.height,
);
module.free_result();
return result;
}

View File

@@ -0,0 +1,72 @@
export enum WebPImageHint {
WEBP_HINT_DEFAULT, // default preset.
WEBP_HINT_PICTURE, // digital picture, like portrait, inner shot
WEBP_HINT_PHOTO, // outdoor photograph, with natural lighting
WEBP_HINT_GRAPH, // Discrete tone image (graph, map-tile etc).
}
export interface EncodeOptions {
quality: number;
target_size: number;
target_PSNR: number;
method: number;
sns_strength: number;
filter_strength: number;
filter_sharpness: number;
filter_type: number;
partitions: number;
segments: number;
pass: number;
show_compressed: number;
preprocessing: number;
autofilter: number;
partition_limit: number;
alpha_compression: number;
alpha_filtering: number;
alpha_quality: number;
lossless: number;
exact: number;
image_hint: number;
emulate_jpeg_size: number;
thread_level: number;
low_memory: number;
near_lossless: number;
use_delta_palette: number;
use_sharp_yuv: number;
}
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'webp';
export const label = 'WebP';
export const mimeType = 'image/webp';
export const extension = 'webp';
// These come from struct WebPConfig in encode.h.
export const defaultOptions: EncodeOptions = {
quality: 75,
target_size: 0,
target_PSNR: 0,
method: 4,
sns_strength: 50,
filter_strength: 60,
filter_sharpness: 0,
filter_type: 1,
partitions: 0,
segments: 4,
pass: 1,
show_compressed: 0,
preprocessing: 0,
autofilter: 0,
partition_limit: 0,
alpha_compression: 1,
alpha_filtering: 1,
alpha_quality: 100,
lossless: 0,
exact: 0,
image_hint: 0,
emulate_jpeg_size: 0,
thread_level: 0,
low_memory: 0,
near_lossless: 100,
use_delta_palette: 0,
use_sharp_yuv: 0,
};

View File

@@ -1,80 +1,18 @@
import EncoderWorker from './Encoder.worker';
import webp_enc, { WebPModule } from '../../../codecs/webp_enc/webp_enc';
import wasmUrl from '../../../codecs/webp_enc/webp_enc.wasm';
import { EncodeOptions } from './encoder-meta';
import { initWasmModule } from '../util';
export enum WebPImageHint {
WEBP_HINT_DEFAULT, // default preset.
WEBP_HINT_PICTURE, // digital picture, like portrait, inner shot
WEBP_HINT_PHOTO, // outdoor photograph, with natural lighting
WEBP_HINT_GRAPH, // Discrete tone image (graph, map-tile etc).
}
export interface EncodeOptions {
quality: number;
target_size: number;
target_PSNR: number;
method: number;
sns_strength: number;
filter_strength: number;
filter_sharpness: number;
filter_type: number;
partitions: number;
segments: number;
pass: number;
show_compressed: number;
preprocessing: number;
autofilter: number;
partition_limit: number;
alpha_compression: number;
alpha_filtering: number;
alpha_quality: number;
lossless: number;
exact: number;
image_hint: number;
emulate_jpeg_size: number;
thread_level: number;
low_memory: number;
near_lossless: number;
use_delta_palette: number;
use_sharp_yuv: number;
}
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'webp';
export const label = 'WebP';
export const mimeType = 'image/webp';
export const extension = 'webp';
// These come from struct WebPConfig in encode.h.
export const defaultOptions: EncodeOptions = {
quality: 75,
target_size: 0,
target_PSNR: 0,
method: 4,
sns_strength: 50,
filter_strength: 60,
filter_sharpness: 0,
filter_type: 1,
partitions: 0,
segments: 4,
pass: 1,
show_compressed: 0,
preprocessing: 0,
autofilter: 0,
partition_limit: 0,
alpha_compression: 1,
alpha_filtering: 1,
alpha_quality: 100,
lossless: 0,
exact: 0,
image_hint: 0,
emulate_jpeg_size: 0,
thread_level: 0,
low_memory: 0,
near_lossless: 100,
use_delta_palette: 0,
use_sharp_yuv: 0,
};
export async function encode(data: ImageData, options: EncodeOptions) {
// We need to await this because it's been comlinked.
const encoder = await new EncoderWorker();
return encoder.encode(data, options);
let emscriptenModule: Promise<WebPModule>;
export async function encode(data: ImageData, options: EncodeOptions): Promise<ArrayBuffer> {
if (!emscriptenModule) emscriptenModule = initWasmModule(webp_enc, wasmUrl);
const module = await emscriptenModule;
const resultView = module.encode(data.data, data.width, data.height, options);
const result = new Uint8Array(resultView);
module.free_result();
// wasm cant run on SharedArrayBuffers, so we hard-cast to ArrayBuffer.
return result.buffer as ArrayBuffer;
}

View File

@@ -1,7 +1,7 @@
import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util';
import { inputFieldCheckedAsNumber, inputFieldValueAsNumber } from '../../lib/util';
import { EncodeOptions, WebPImageHint } from './encoder';
import { EncodeOptions, WebPImageHint } from './encoder-meta';
import * as styles from './styles.scss';
import '../../custom-els/RangeInput';