diff --git a/codecs/hqx/.gitignore b/codecs/hqx/.gitignore new file mode 100644 index 00000000..53f30e50 --- /dev/null +++ b/codecs/hqx/.gitignore @@ -0,0 +1,5 @@ +**/*.rs.bk +target +Cargo.lock +bin/ +pkg/README.md diff --git a/codecs/hqx/Cargo.toml b/codecs/hqx/Cargo.toml new file mode 100644 index 00000000..742d0751 --- /dev/null +++ b/codecs/hqx/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "squooshhqx" +version = "0.1.0" +authors = ["Surma "] + +[lib] +crate-type = ["cdylib"] + +[features] +default = ["console_error_panic_hook", "wee_alloc"] + +[dependencies] +cfg-if = "0.1.2" +wasm-bindgen = "0.2.38" +# lazy_static = "1.0.0" +hqx = {git = "https://github.com/CryZe/wasmboy-rs", tag="v0.1.2"} + +# The `console_error_panic_hook` crate provides better debugging of panics by +# logging them with `console.error`. This is great for development, but requires +# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for +# code size when deploying. +console_error_panic_hook = { version = "0.1.1", optional = true } + +# `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size +# compared to the default allocator's ~10K. It is slower than the default +# allocator, however. +# +# Unfortunately, `wee_alloc` requires nightly Rust when targeting wasm for now. +wee_alloc = { version = "0.4.2", optional = true } + +[dev-dependencies] +wasm-bindgen-test = "0.2" + +[profile.release] +# Tell `rustc` to optimize for small code size. +opt-level = "s" +lto = true diff --git a/codecs/hqx/Dockerfile b/codecs/hqx/Dockerfile new file mode 100644 index 00000000..1142b581 --- /dev/null +++ b/codecs/hqx/Dockerfile @@ -0,0 +1,18 @@ +FROM ubuntu +RUN apt-get update && \ + apt-get install -qqy git build-essential cmake python2.7 +RUN git clone --recursive https://github.com/WebAssembly/wabt /usr/src/wabt +RUN mkdir -p /usr/src/wabt/build +WORKDIR /usr/src/wabt/build +RUN cmake .. -DCMAKE_INSTALL_PREFIX=/opt/wabt && \ + make && \ + make install + +FROM rust +RUN rustup install nightly && \ + rustup target add --toolchain nightly wasm32-unknown-unknown && \ + cargo install wasm-pack + +COPY --from=0 /opt/wabt /opt/wabt +ENV PATH="/opt/wabt/bin:${PATH}" +WORKDIR /src diff --git a/codecs/hqx/build.sh b/codecs/hqx/build.sh new file mode 100755 index 00000000..413db29d --- /dev/null +++ b/codecs/hqx/build.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -e + +echo "=============================================" +echo "Compiling wasm" +echo "=============================================" +( + rustup run nightly \ + wasm-pack build --target no-modules + wasm-strip pkg/squooshhqx_bg.wasm + rm pkg/.gitignore +) +echo "=============================================" +echo "Compiling wasm done" +echo "=============================================" + +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +echo "Did you update your docker image?" +echo "Run \`docker pull ubuntu\`" +echo "Run \`docker pull rust\`" +echo "Run \`docker build -t squoosh-hqx .\`" +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" diff --git a/codecs/hqx/index.html b/codecs/hqx/index.html new file mode 100644 index 00000000..d059e5ea --- /dev/null +++ b/codecs/hqx/index.html @@ -0,0 +1,24 @@ + + + \ No newline at end of file diff --git a/codecs/hqx/package-lock.json b/codecs/hqx/package-lock.json new file mode 100644 index 00000000..e721c3f1 --- /dev/null +++ b/codecs/hqx/package-lock.json @@ -0,0 +1,4 @@ +{ + "name": "hqx", + "lockfileVersion": 1 +} diff --git a/codecs/hqx/package.json b/codecs/hqx/package.json new file mode 100644 index 00000000..9c50eb01 --- /dev/null +++ b/codecs/hqx/package.json @@ -0,0 +1,7 @@ +{ + "name": "hqx", + "scripts": { + "build:image": "docker build -t squoosh-hqx .", + "build": "docker run --rm -v $(pwd):/src squoosh-hqx ./build.sh" + } +} diff --git a/codecs/hqx/pkg/package.json b/codecs/hqx/pkg/package.json new file mode 100644 index 00000000..dca922f5 --- /dev/null +++ b/codecs/hqx/pkg/package.json @@ -0,0 +1,14 @@ +{ + "name": "squooshhqx", + "collaborators": [ + "Surma " + ], + "version": "0.1.0", + "files": [ + "squooshhqx_bg.wasm", + "squooshhqx.js", + "squooshhqx.d.ts" + ], + "browser": "squooshhqx.js", + "types": "squooshhqx.d.ts" +} \ No newline at end of file diff --git a/codecs/hqx/pkg/squooshhqx.d.ts b/codecs/hqx/pkg/squooshhqx.d.ts new file mode 100644 index 00000000..2f6c3755 --- /dev/null +++ b/codecs/hqx/pkg/squooshhqx.d.ts @@ -0,0 +1,20 @@ +/* tslint:disable */ +/** +* @param {Uint32Array} input_image +* @param {number} input_width +* @param {number} input_height +* @param {number} factor +* @returns {Uint32Array} +*/ +export function resize(input_image: Uint32Array, input_width: number, input_height: number, factor: number): Uint32Array; + +/** +* If `module_or_path` is {RequestInfo}, makes a request and +* for everything else, calls `WebAssembly.instantiate` directly. +* +* @param {RequestInfo | BufferSource | WebAssembly.Module} module_or_path +* +* @returns {Promise} +*/ +export default function init (module_or_path: RequestInfo | BufferSource | WebAssembly.Module): Promise; + \ No newline at end of file diff --git a/codecs/hqx/pkg/squooshhqx.js b/codecs/hqx/pkg/squooshhqx.js new file mode 100644 index 00000000..06ab816f --- /dev/null +++ b/codecs/hqx/pkg/squooshhqx.js @@ -0,0 +1,97 @@ +(function() { + const __exports = {}; + let wasm; + + let cachegetUint32Memory = null; + function getUint32Memory() { + if (cachegetUint32Memory === null || cachegetUint32Memory.buffer !== wasm.memory.buffer) { + cachegetUint32Memory = new Uint32Array(wasm.memory.buffer); + } + return cachegetUint32Memory; + } + + let WASM_VECTOR_LEN = 0; + + function passArray32ToWasm(arg) { + const ptr = wasm.__wbindgen_malloc(arg.length * 4); + getUint32Memory().set(arg, ptr / 4); + WASM_VECTOR_LEN = arg.length; + return ptr; + } + + function getArrayU32FromWasm(ptr, len) { + return getUint32Memory().subarray(ptr / 4, ptr / 4 + len); + } + + let cachedGlobalArgumentPtr = null; + function globalArgumentPtr() { + if (cachedGlobalArgumentPtr === null) { + cachedGlobalArgumentPtr = wasm.__wbindgen_global_argument_ptr(); + } + return cachedGlobalArgumentPtr; + } + /** + * @param {Uint32Array} input_image + * @param {number} input_width + * @param {number} input_height + * @param {number} factor + * @returns {Uint32Array} + */ + __exports.resize = function(input_image, input_width, input_height, factor) { + const ptr0 = passArray32ToWasm(input_image); + const len0 = WASM_VECTOR_LEN; + const retptr = globalArgumentPtr(); + wasm.resize(retptr, ptr0, len0, input_width, input_height, factor); + const mem = getUint32Memory(); + const rustptr = mem[retptr / 4]; + const rustlen = mem[retptr / 4 + 1]; + + const realRet = getArrayU32FromWasm(rustptr, rustlen).slice(); + wasm.__wbindgen_free(rustptr, rustlen * 4); + return realRet; + + }; + + function init(module) { + + let result; + const imports = {}; + + if (module instanceof URL || typeof module === 'string' || module instanceof Request) { + + const response = fetch(module); + if (typeof WebAssembly.instantiateStreaming === 'function') { + result = WebAssembly.instantiateStreaming(response, imports) + .catch(e => { + console.warn("`WebAssembly.instantiateStreaming` failed. Assuming this is because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + return response + .then(r => r.arrayBuffer()) + .then(bytes => WebAssembly.instantiate(bytes, imports)); + }); + } else { + result = response + .then(r => r.arrayBuffer()) + .then(bytes => WebAssembly.instantiate(bytes, imports)); + } + } else { + + result = WebAssembly.instantiate(module, imports) + .then(result => { + if (result instanceof WebAssembly.Instance) { + return { instance: result, module }; + } else { + return result; + } + }); + } + return result.then(({instance, module}) => { + wasm = instance.exports; + init.__wbindgen_wasm_module = module; + + return wasm; + }); + } + + self.wasm_bindgen = Object.assign(init, __exports); + +})(); diff --git a/codecs/hqx/pkg/squooshhqx_bg.d.ts b/codecs/hqx/pkg/squooshhqx_bg.d.ts new file mode 100644 index 00000000..7345dbb8 --- /dev/null +++ b/codecs/hqx/pkg/squooshhqx_bg.d.ts @@ -0,0 +1,6 @@ +/* tslint:disable */ +export const memory: WebAssembly.Memory; +export function resize(a: number, b: number, c: number, d: number, e: number, f: number): void; +export function __wbindgen_global_argument_ptr(): number; +export function __wbindgen_malloc(a: number): number; +export function __wbindgen_free(a: number, b: number): void; diff --git a/codecs/hqx/pkg/squooshhqx_bg.wasm b/codecs/hqx/pkg/squooshhqx_bg.wasm new file mode 100644 index 00000000..b180905b Binary files /dev/null and b/codecs/hqx/pkg/squooshhqx_bg.wasm differ diff --git a/codecs/hqx/src/lib.rs b/codecs/hqx/src/lib.rs new file mode 100644 index 00000000..a3f68677 --- /dev/null +++ b/codecs/hqx/src/lib.rs @@ -0,0 +1,55 @@ +extern crate cfg_if; +extern crate hqx; +extern crate wasm_bindgen; + +mod utils; + +use cfg_if::cfg_if; +use wasm_bindgen::prelude::*; + +cfg_if! { + // When the `wee_alloc` feature is enabled, use `wee_alloc` as the global + // allocator. + if #[cfg(feature = "wee_alloc")] { + extern crate wee_alloc; + #[global_allocator] + static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; + } +} + +#[wasm_bindgen] +#[no_mangle] +pub fn resize( + input_image: Vec, + input_width: usize, + input_height: usize, + factor: usize, +) -> Vec { + let num_output_pixels = input_width * input_height * factor * factor; + let mut output_image = Vec::::with_capacity(num_output_pixels * 4); + output_image.resize(num_output_pixels, 0); + + match factor { + 2 => hqx::hq2x( + input_image.as_slice(), + output_image.as_mut_slice(), + input_width, + input_height, + ), + 3 => hqx::hq3x( + input_image.as_slice(), + output_image.as_mut_slice(), + input_width, + input_height, + ), + 4 => hqx::hq4x( + input_image.as_slice(), + output_image.as_mut_slice(), + input_width, + input_height, + ), + _ => unreachable!(), + }; + + return output_image; +} diff --git a/codecs/hqx/src/utils.rs b/codecs/hqx/src/utils.rs new file mode 100644 index 00000000..2ffc954d --- /dev/null +++ b/codecs/hqx/src/utils.rs @@ -0,0 +1,17 @@ +use cfg_if::cfg_if; + +cfg_if! { + // When the `console_error_panic_hook` feature is enabled, we can call the + // `set_panic_hook` function at least once during initialization, and then + // we will get better error messages if our code ever panics. + // + // For more details see + // https://github.com/rustwasm/console_error_panic_hook#readme + if #[cfg(feature = "console_error_panic_hook")] { + extern crate console_error_panic_hook; + pub use self::console_error_panic_hook::set_once as set_panic_hook; + } else { + #[inline] + pub fn set_panic_hook() {} + } +} diff --git a/src/codecs/hqx/processor-meta.ts b/src/codecs/hqx/processor-meta.ts new file mode 100644 index 00000000..29417d71 --- /dev/null +++ b/src/codecs/hqx/processor-meta.ts @@ -0,0 +1,3 @@ +export interface HqxOptions { + factor: 2 | 3 | 4; +} diff --git a/src/codecs/hqx/processor.ts b/src/codecs/hqx/processor.ts new file mode 100644 index 00000000..f17f15a9 --- /dev/null +++ b/src/codecs/hqx/processor.ts @@ -0,0 +1,32 @@ +import wasmUrl from '../../../codecs/hqx/pkg/squooshhqx_bg.wasm'; +import '../../../codecs/hqx/pkg/squooshhqx'; +import { HqxOptions } from './processor-meta'; + +interface WasmBindgenExports { + resize: typeof import('../../../codecs/hqx/pkg/squooshhqx').resize; +} + +type WasmBindgen = ((url: string) => Promise) & WasmBindgenExports; + +declare var wasm_bindgen: WasmBindgen; + +const ready = wasm_bindgen(wasmUrl); + +export async function hqx( + data: ImageData, + opts: HqxOptions, +): Promise { + const input = data; + await ready; + const result = wasm_bindgen.resize( + new Uint32Array(input.data.buffer), + input.width, + input.height, + opts.factor, + ); + return new ImageData( + new Uint8ClampedArray(result.buffer), + data.width * opts.factor, + data.height * opts.factor, + ); +} diff --git a/src/codecs/processor-worker/index.ts b/src/codecs/processor-worker/index.ts index c4eac690..e1c4682b 100644 --- a/src/codecs/processor-worker/index.ts +++ b/src/codecs/processor-worker/index.ts @@ -1,4 +1,6 @@ import { expose } from 'comlink'; +import { isHqx } from '../resize/processor-meta'; +import { clamp } from '../util'; async function mozjpegEncode( data: ImageData, options: import('../mozjpeg/encoder-meta').EncodeOptions, @@ -31,6 +33,18 @@ async function rotate( async function resize( data: ImageData, opts: import('../resize/processor-meta').WorkerResizeOptions, ): Promise { + if (isHqx(opts)) { + const { hqx } = await import( + /* webpackChunkName: "process-hqx" */ + '../hqx/processor'); + + const widthRatio = opts.width / data.width; + const heightRatio = opts.height / data.height; + const ratio = Math.max(widthRatio, heightRatio); + if (ratio <= 1) return data; + const factor = clamp(Math.ceil(ratio), { min: 2, max: 4 }) as 2|3|4; + return hqx(data, { factor }); + } const { resize } = await import( /* webpackChunkName: "process-resize" */ '../resize/processor'); @@ -63,7 +77,15 @@ async function webpDecode(data: ArrayBuffer): Promise { return decode(data); } -const exports = { mozjpegEncode, quantize, rotate, resize, optiPngEncode, webpEncode, webpDecode }; +const exports = { + mozjpegEncode, + quantize, + rotate, + resize, + optiPngEncode, + webpEncode, + webpDecode, +}; export type ProcessorWorkerApi = typeof exports; expose(exports, self); diff --git a/src/codecs/processor.ts b/src/codecs/processor.ts index 68063248..9509656c 100644 --- a/src/codecs/processor.ts +++ b/src/codecs/processor.ts @@ -16,6 +16,7 @@ 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'; +import { bind } from '../lib/initial-util'; type ProcessorWorkerApi = import('./processor-worker').ProcessorWorkerApi; @@ -94,14 +95,7 @@ export default class Processor { if (!this._worker) return; // If the worker is unused for 10 seconds, remove it to save memory. - this._workerTimeoutId = self.setTimeout( - () => { - if (!this._worker) return; - this._worker.terminate(); - this._worker = undefined; - }, - workerTimeout, - ); + this._workerTimeoutId = self.setTimeout(this.terminateWorker, workerTimeout); } /** Abort the current job, if any */ @@ -111,7 +105,11 @@ export default class Processor { this._abortRejector(new DOMException('Aborted', 'AbortError')); this._abortRejector = undefined; this._busy = false; + this.terminateWorker(); + } + @bind + terminateWorker() { if (!this._worker) return; this._worker.terminate(); this._worker = undefined; diff --git a/src/codecs/resize/options.tsx b/src/codecs/resize/options.tsx index 1b856d75..325c30a2 100644 --- a/src/codecs/resize/options.tsx +++ b/src/codecs/resize/options.tsx @@ -12,8 +12,9 @@ import Select from '../../components/select'; interface Props { isVector: Boolean; + inputWidth: number; + inputHeight: number; options: ResizeOptions; - aspect: number; onChange(newOptions: ResizeOptions): void; } @@ -21,12 +22,34 @@ interface State { maintainAspect: boolean; } +const sizePresets = [0.25, 0.3333, 0.5, 1, 2, 3, 4]; + +/** + * Should we allow the user to select hqx? Chrome currently has a wasm bug, so we currently avoid it + * there, unless overridden. + * crbug.com/974804 + */ +const allowHqx: boolean = (() => { + const url = new URL(location.href); + return url.searchParams.has('allow-hqx') + // Yep. UA sniffing. Let's hope we can remove this soon. + // Block browsers with Chrome/, unless they also have Edge/ (since the Edge UA includes Chrome/) + || !navigator.userAgent.includes('Chrome/') || navigator.userAgent.includes('Edge/'); +})(); + export default class ResizerOptions extends Component { state: State = { maintainAspect: true, }; - form?: HTMLFormElement; + private form?: HTMLFormElement; + private presetWidths: { [idx: number]: number } = {}; + private presetHeights: { [idx: number]: number } = {}; + + constructor(props: Props) { + super(props); + this.generatePresetValues(props.inputWidth, props.inputHeight); + } private reportOptions() { const form = this.form!; @@ -53,18 +76,31 @@ export default class ResizerOptions extends Component { this.reportOptions(); } + private getAspect() { + return this.props.inputWidth / this.props.inputHeight; + } + componentDidUpdate(prevProps: Props, prevState: State) { if (!prevState.maintainAspect && this.state.maintainAspect) { - this.form!.height.value = Math.round(Number(this.form!.width.value) / this.props.aspect); + this.form!.height.value = Math.round(Number(this.form!.width.value) / this.getAspect()); this.reportOptions(); } } + componentWillReceiveProps(nextProps: Props) { + if ( + this.props.inputWidth !== nextProps.inputWidth || + this.props.inputHeight !== nextProps.inputHeight + ) { + this.generatePresetValues(nextProps.inputWidth, nextProps.inputHeight); + } + } + @bind private onWidthInput() { if (this.state.maintainAspect) { const width = inputFieldValueAsNumber(this.form!.width); - this.form!.height.value = Math.round(width / this.props.aspect); + this.form!.height.value = Math.round(width / this.getAspect()); } this.reportOptions(); @@ -74,12 +110,44 @@ export default class ResizerOptions extends Component { private onHeightInput() { if (this.state.maintainAspect) { const height = inputFieldValueAsNumber(this.form!.height); - this.form!.width.value = Math.round(height * this.props.aspect); + this.form!.width.value = Math.round(height * this.getAspect()); } this.reportOptions(); } + private generatePresetValues(width: number, height: number) { + for (const preset of sizePresets) { + this.presetWidths[preset] = Math.round(width * preset); + this.presetHeights[preset] = Math.round(height * preset); + } + } + + private getPreset(): number | string { + const { width, height } = this.props.options; + + for (const preset of sizePresets) { + if ( + width === this.presetWidths[preset] && + height === this.presetHeights[preset] + ) return preset; + } + + return 'custom'; + } + + @bind + private onPresetChange(event: Event) { + const select = event.target as HTMLSelectElement; + if (select.value === 'custom') return; + const multiplier = Number(select.value); + (this.form!.width as HTMLInputElement).value = + Math.round(this.props.inputWidth * multiplier) + ''; + (this.form!.height as HTMLInputElement).value = + Math.round(this.props.inputHeight * multiplier) + ''; + this.reportOptions(); + } + render({ options, isVector }: Props, { maintainAspect }: State) { return (
@@ -95,12 +163,22 @@ export default class ResizerOptions extends Component { + {allowHqx && } +