diff --git a/src/codecs/imagequant/Quantizer.worker.ts b/src/codecs/imagequant/Quantizer.worker.ts new file mode 100644 index 00000000..7295648b --- /dev/null +++ b/src/codecs/imagequant/Quantizer.worker.ts @@ -0,0 +1,78 @@ +import imagequant from '../../../codecs/imagequant/imagequant'; +// Using require() so TypeScript doesn’t complain about this not being a module. +const wasmBinaryUrl = require('../../../codecs/imagequant/imagequant.wasm'); + +// API exposed by wasm module. Details in the codec’s README. +interface ModuleAPI { + version(): number; + create_buffer(width: number, height: number): number; + destroy_buffer(pointer: number): void; + quantize(buffer: number, width: number, height: number, numColors: number, dither: number): void; + free_result(): void; + get_result_pointer(): number; +} + +export default class ImageQuant { + private emscriptenModule: Promise; + private api: Promise; + + constructor() { + this.emscriptenModule = new Promise((resolve) => { + const m = imagequant({ + // Just to be safe, don’t 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); + }, + }); + }); + + this.api = (async () => { + // Not sure why, but TypeScript complains that I am using + // `emscriptenModule` before it’s getting assigned, which is clearly not + // true :shrug: Using `any` + const m = await (this as any).emscriptenModule; + return { + version: m.cwrap('version', 'number', []), + create_buffer: m.cwrap('create_buffer', 'number', ['number', 'number']), + destroy_buffer: m.cwrap('destroy_buffer', '', ['number']), + quantize: m.cwrap('quantize', '', ['number', 'number', 'number', 'number', 'number']), + free_result: m.cwrap('free_result', '', []), + get_result_pointer: m.cwrap('get_result_pointer', 'number', []), + }; + })(); + } + + async quantize(data: ImageData): Promise { + const m = await this.emscriptenModule; + const api = await this.api; + + const p = api.create_buffer(data.width, data.height); + m.HEAP8.set(new Uint8Array(data.data), p); + api.quantize(p, data.width, data.height, 256, 1.0); + const resultPointer = api.get_result_pointer(); + const resultView = new Uint8Array( + m.HEAP8.buffer, + resultPointer, + data.width * data.height * 4, + ); + const result = new Uint8ClampedArray(resultView); + api.free_result(); + api.destroy_buffer(p); + + return new ImageData(result, data.width, data.height); + } +} diff --git a/src/codecs/imagequant/options.tsx b/src/codecs/imagequant/options.tsx new file mode 100644 index 00000000..a4a6c590 --- /dev/null +++ b/src/codecs/imagequant/options.tsx @@ -0,0 +1,330 @@ +// import { h, Component } from 'preact'; +// import { bind } from '../../lib/util'; +// import * as styles from './styles.scss'; + +// type Props = { +// options: EncodeOptions, +// onChange(newOptions: EncodeOptions): void, +// }; + +// // From kLosslessPresets in config_enc.c +// // The format is [method, quality]. +// const losslessPresets:[number, number][] = [ +// [0, 0], [1, 20], [2, 25], [3, 30], [3, 50], +// [4, 50], [4, 75], [4, 90], [5, 90], [6, 100], +// ]; +// const losslessPresetDefault = 6; + +// function determineLosslessQuality(quality: number): number { +// const index = losslessPresets.findIndex(item => item[1] === quality); +// if (index !== -1) return index; +// // Quality doesn't match one of the presets. +// // This can happen when toggling 'lossless'. +// return losslessPresetDefault; +// } + +// /** +// * @param field An HTMLInputElement, but the casting is done here to tidy up onChange. +// */ +// function fieldCheckedAsNumber(field: any): number { +// return Number((field as HTMLInputElement).checked); +// } + +// /** +// * @param field An HTMLInputElement, but the casting is done here to tidy up onChange. +// */ +// function fieldValueAsNumber(field: any): number { +// return Number((field as HTMLInputElement).value); +// } + +// export default class WebPEncoderOptions extends Component { +// @bind +// onChange(event: Event) { +// const form = (event.currentTarget as HTMLInputElement).closest('form') as HTMLFormElement; +// const lossless = fieldCheckedAsNumber(form.lossless); +// const losslessPresetInput = (form.lossless_preset as HTMLInputElement); + +// const options: EncodeOptions = { +// // Copy over options the form doesn't care about, eg emulate_jpeg_size +// ...this.props.options, +// // And now stuff from the form: +// lossless, +// // Special-cased inputs: +// // In lossless mode, the quality is derived from the preset. +// quality: lossless ? +// losslessPresets[Number(losslessPresetInput.value)][1] : +// fieldValueAsNumber(form.quality), +// // In lossless mode, the method is derived from the preset. +// method: lossless ? +// losslessPresets[Number(losslessPresetInput.value)][0] : +// fieldValueAsNumber(form.method_input), +// image_hint: (form.image_hint as HTMLInputElement).checked ? +// WebPImageHint.WEBP_HINT_GRAPH : +// WebPImageHint.WEBP_HINT_DEFAULT, +// // .checked +// exact: fieldCheckedAsNumber(form.exact), +// alpha_compression: fieldCheckedAsNumber(form.alpha_compression), +// autofilter: fieldCheckedAsNumber(form.autofilter), +// filter_type: fieldCheckedAsNumber(form.filter_type), +// use_sharp_yuv: fieldCheckedAsNumber(form.use_sharp_yuv), +// // .value +// near_lossless: fieldValueAsNumber(form.near_lossless), +// alpha_quality: fieldValueAsNumber(form.alpha_quality), +// alpha_filtering: fieldValueAsNumber(form.alpha_filtering), +// sns_strength: fieldValueAsNumber(form.sns_strength), +// filter_strength: fieldValueAsNumber(form.filter_strength), +// filter_sharpness: fieldValueAsNumber(form.filter_sharpness), +// pass: fieldValueAsNumber(form.pass), +// preprocessing: fieldValueAsNumber(form.preprocessing), +// segments: fieldValueAsNumber(form.segments), +// partitions: fieldValueAsNumber(form.partitions), +// }; +// this.props.onChange(options); +// } + +// private _losslessSpecificOptions(options: EncodeOptions) { +// return ( +//
+// +// +// +//
+// ); +// } + +// private _lossySpecificOptions(options: EncodeOptions) { +// return ( +//
+// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +//
+// ); +// } + +// render({ options }: Props) { +// // I'm rendering both lossy and lossless forms, as it becomes much easier when +// // gathering the data. +// return ( +//
+// +//
+// {this._losslessSpecificOptions(options)} +//
+//
+// {this._lossySpecificOptions(options)} +//
+// +//
+// ); +// } +// } diff --git a/src/codecs/imagequant/quantizer.ts b/src/codecs/imagequant/quantizer.ts new file mode 100644 index 00000000..522585b5 --- /dev/null +++ b/src/codecs/imagequant/quantizer.ts @@ -0,0 +1,7 @@ +import QuantizerWorker from './Quantizer.worker'; + +export const name = 'Image Quanitzer'; +export async function quantize(data: ImageData): Promise { + const quantizer = await new QuantizerWorker(); + return quantizer.quantize(data); +} diff --git a/src/codecs/imagequant/styles.scss b/src/codecs/imagequant/styles.scss new file mode 100644 index 00000000..cffbc045 --- /dev/null +++ b/src/codecs/imagequant/styles.scss @@ -0,0 +1,6 @@ +.flip-range { + transform: scaleX(-1); +} +.hide { + display: none; +} diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx index ba53e53f..dd1e94ce 100644 --- a/src/components/App/index.tsx +++ b/src/components/App/index.tsx @@ -8,6 +8,7 @@ import Options from '../Options'; import { FileDropEvent } from './custom-els/FileDrop'; import './custom-els/FileDrop'; +import * as quantizer from '../../codecs/imagequant/quantizer'; import * as mozJPEG from '../../codecs/mozjpeg/encoder'; import * as webP from '../../codecs/webp/encoder'; import * as identity from '../../codecs/identity/encoder'; @@ -64,18 +65,19 @@ async function compressImage( // Special case for identity if (encodeData.type === identity.type) return source.file; + const quantizedSource = await quantizer.quantize(source.data); const compressedData = await (() => { switch (encodeData.type) { - case mozJPEG.type: return mozJPEG.encode(source.data, encodeData.options); - case webP.type: return webP.encode(source.data, encodeData.options); - case browserPNG.type: return browserPNG.encode(source.data, encodeData.options); - case browserJPEG.type: return browserJPEG.encode(source.data, encodeData.options); - case browserWebP.type: return browserWebP.encode(source.data, encodeData.options); - case browserGIF.type: return browserGIF.encode(source.data, encodeData.options); - case browserTIFF.type: return browserTIFF.encode(source.data, encodeData.options); - case browserJP2.type: return browserJP2.encode(source.data, encodeData.options); - case browserBMP.type: return browserBMP.encode(source.data, encodeData.options); - case browserPDF.type: return browserPDF.encode(source.data, encodeData.options); + case mozJPEG.type: return mozJPEG.encode(quantizedSource, encodeData.options); + case webP.type: return webP.encode(quantizedSource, encodeData.options); + case browserPNG.type: return browserPNG.encode(quantizedSource, encodeData.options); + case browserJPEG.type: return browserJPEG.encode(quantizedSource, encodeData.options); + case browserWebP.type: return browserWebP.encode(quantizedSource, encodeData.options); + case browserGIF.type: return browserGIF.encode(quantizedSource, encodeData.options); + case browserTIFF.type: return browserTIFF.encode(quantizedSource, encodeData.options); + case browserJP2.type: return browserJP2.encode(quantizedSource, encodeData.options); + case browserBMP.type: return browserBMP.encode(quantizedSource, encodeData.options); + case browserPDF.type: return browserPDF.encode(quantizedSource, encodeData.options); default: throw Error(`Unexpected encoder ${JSON.stringify(encodeData)}`); } })();