diff --git a/codecs/mozjpeg_enc/mozjpeg_enc.d.ts b/codecs/mozjpeg_enc/mozjpeg_enc.d.ts new file mode 100644 index 000000000..9f2e9ca16 --- /dev/null +++ b/codecs/mozjpeg_enc/mozjpeg_enc.d.ts @@ -0,0 +1 @@ +export default function(opts: EmscriptenWasm.ModuleOpts): EmscriptenWasm.Module; diff --git a/emscripten-wasm.d.ts b/emscripten-wasm.d.ts new file mode 100644 index 000000000..2d56197e5 --- /dev/null +++ b/emscripten-wasm.d.ts @@ -0,0 +1,107 @@ +// These types roughly model the object that the JS files generated by Emscripten define. Copied from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/emscripten/index.d.ts and turned into a type definition rather than a global to support our way of using Emscripten. +// TODO(@surma): Upstream this? +declare namespace EmscriptenWasm { + type EnvironmentType = "WEB" | "NODE" | "SHELL" | "WORKER"; + + // Options object for modularized Emscripten files. Shoe-horned by @surma. + // FIXME: This an incomplete definition! + interface ModuleOpts { + noInitialRun?: boolean; + locateFile?: (url: string) => string; + onRuntimeInitialized?: () => void; + } + + interface Module { + print(str: string): void; + printErr(str: string): void; + arguments: string[]; + environment: EnvironmentType; + preInit: { (): void }[]; + preRun: { (): void }[]; + postRun: { (): void }[]; + preinitializedWebGLContext: WebGLRenderingContext; + noInitialRun: boolean; + noExitRuntime: boolean; + logReadFiles: boolean; + filePackagePrefixURL: string; + wasmBinary: ArrayBuffer; + + destroy(object: object): void; + getPreloadedPackage(remotePackageName: string, remotePackageSize: number): ArrayBuffer; + instantiateWasm( + imports: WebAssembly.Imports, + successCallback: (module: WebAssembly.Module) => void + ): WebAssembly.Exports; + locateFile(url: string): string; + onCustomMessage(event: MessageEvent): void; + + Runtime: any; + + ccall(ident: string, returnType: string | null, argTypes: string[], args: any[]): any; + cwrap(ident: string, returnType: string | null, argTypes: string[]): any; + + setValue(ptr: number, value: any, type: string, noSafe?: boolean): void; + getValue(ptr: number, type: string, noSafe?: boolean): number; + + ALLOC_NORMAL: number; + ALLOC_STACK: number; + ALLOC_STATIC: number; + ALLOC_DYNAMIC: number; + ALLOC_NONE: number; + + allocate(slab: any, types: string, allocator: number, ptr: number): number; + allocate(slab: any, types: string[], allocator: number, ptr: number): number; + + Pointer_stringify(ptr: number, length?: number): string; + UTF16ToString(ptr: number): string; + stringToUTF16(str: string, outPtr: number): void; + UTF32ToString(ptr: number): string; + stringToUTF32(str: string, outPtr: number): void; + + // USE_TYPED_ARRAYS == 1 + HEAP: Int32Array; + IHEAP: Int32Array; + FHEAP: Float64Array; + + // USE_TYPED_ARRAYS == 2 + HEAP8: Int8Array; + HEAP16: Int16Array; + HEAP32: Int32Array; + HEAPU8: Uint8Array; + HEAPU16: Uint16Array; + HEAPU32: Uint32Array; + HEAPF32: Float32Array; + HEAPF64: Float64Array; + + TOTAL_STACK: number; + TOTAL_MEMORY: number; + FAST_MEMORY: number; + + addOnPreRun(cb: () => any): void; + addOnInit(cb: () => any): void; + addOnPreMain(cb: () => any): void; + addOnExit(cb: () => any): void; + addOnPostRun(cb: () => any): void; + + // Tools + intArrayFromString(stringy: string, dontAddNull?: boolean, length?: number): number[]; + intArrayToString(array: number[]): string; + writeStringToMemory(str: string, buffer: number, dontAddNull: boolean): void; + writeArrayToMemory(array: number[], buffer: number): void; + writeAsciiToMemory(str: string, buffer: number, dontAddNull: boolean): void; + + addRunDependency(id: any): void; + removeRunDependency(id: any): void; + + + preloadedImages: any; + preloadedAudios: any; + + _malloc(size: number): number; + _free(ptr: number): void; + + // Augmentations below by @surma. + onRuntimeInitialized: () => void | null; + } +} + diff --git a/package-lock.json b/package-lock.json index 15be382d9..673baa70e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -385,6 +385,12 @@ "integrity": "sha512-4Ba90mWNx8ddbafuyGGwjkZMigi+AWfYLSDCpovwsE63ia8w93r3oJ8PIAQc3y8U+XHcnMOHPIzNe3o438Ywcw==", "dev": true }, + "@types/webassembly-js-api": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@types/webassembly-js-api/-/webassembly-js-api-0.0.1.tgz", + "integrity": "sha1-YtULIBB319TMEJuxytoi/f1FI/s=", + "dev": true + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -4529,6 +4535,24 @@ "homedir-polyfill": "^1.0.1" } }, + "exports-loader": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/exports-loader/-/exports-loader-0.7.0.tgz", + "integrity": "sha512-RKwCrO4A6IiKm0pG3c9V46JxIHcDplwwGJn6+JJ1RcVnh/WSGJa0xkmk5cRVtgOPzCAtTMGj2F7nluh9L0vpSA==", + "dev": true, + "requires": { + "loader-utils": "^1.1.0", + "source-map": "0.5.0" + }, + "dependencies": { + "source-map": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.0.tgz", + "integrity": "sha1-D+llA6yGpa213mP05BKuSHLNvoY=", + "dev": true + } + } + }, "express": { "version": "4.16.2", "resolved": "https://registry.npmjs.org/express/-/express-4.16.2.tgz", @@ -4712,6 +4736,16 @@ "object-assign": "^4.0.1" } }, + "file-loader": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-1.1.11.tgz", + "integrity": "sha512-TGR4HU7HUsGg6GCOPJnFk06RhWgEWFLAGWiT6rcD+GRC2keU3s9RGJ+b3Z6/U73jwwNb2gKLJ7YCrp+jvU4ALg==", + "dev": true, + "requires": { + "loader-utils": "^1.0.2", + "schema-utils": "^0.4.5" + } + }, "filename-regex": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz", diff --git a/package.json b/package.json index c0a6a53b3..472385fc9 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "version": "0.0.0", "license": "apache-2.0", "scripts": { + "build:mozjpeg_enc": "cd codecs/mozjpeg_enc && npm run build", + "build:codecs": "npm run build:mozjpeg_enc", "start": "webpack serve --hot", "build": "webpack -p", "lint": "eslint src" @@ -30,6 +32,7 @@ ], "devDependencies": { "@types/node": "^9.4.7", + "@types/webassembly-js-api": "0.0.1", "babel-loader": "^7.1.4", "babel-plugin-jsx-pragmatic": "^1.0.2", "babel-plugin-syntax-dynamic-import": "^6.18.0", @@ -52,6 +55,8 @@ "eslint-plugin-promise": "^3.7.0", "eslint-plugin-react": "^7.7.0", "eslint-plugin-standard": "^3.0.1", + "exports-loader": "^0.7.0", + "file-loader": "^1.1.11", "html-webpack-plugin": "^3.0.6", "if-env": "^1.0.4", "loader-utils": "^1.1.0", diff --git a/src/components/app/index.tsx b/src/components/app/index.tsx index a41b8e20b..7fade86cf 100644 --- a/src/components/app/index.tsx +++ b/src/components/app/index.tsx @@ -1,8 +1,10 @@ import { h, Component } from 'preact'; -import { bind } from '../../lib/util'; +import { bind, bitmapToImageData } from '../../lib/util'; import * as style from './style.scss'; import Output from '../output'; +import {MozJpegEncoder} from '../../lib/codec-wrappers/mozjpeg-enc'; + type Props = {}; type State = { @@ -28,8 +30,13 @@ export default class App extends Component { const fileInput = event.target as HTMLInputElement; if (!fileInput.files || !fileInput.files[0]) return; // TODO: handle decode error - const img = await createImageBitmap(fileInput.files[0]); - this.setState({ img }); + const bitmap = await createImageBitmap(fileInput.files[0]); + const data = await bitmapToImageData(bitmap); + const encoder = new MozJpegEncoder(); + const compressedData = await encoder.encode(data); + const blob = new Blob([compressedData], {type: 'image/jpeg'}); + const compressedImage = await createImageBitmap(blob); + this.setState({ img: compressedImage }); } render({ }: Props, { img }: State) { @@ -47,3 +54,4 @@ export default class App extends Component { ); } } + diff --git a/src/lib/codec-wrappers/codec.ts b/src/lib/codec-wrappers/codec.ts new file mode 100644 index 000000000..d0e632fbc --- /dev/null +++ b/src/lib/codec-wrappers/codec.ts @@ -0,0 +1,7 @@ +export interface Encoder { + encode(data: ImageData): Promise; +} + +export interface Decoder { + decode(data: ArrayBuffer): Promise; +} diff --git a/src/lib/codec-wrappers/mozjpeg-enc.ts b/src/lib/codec-wrappers/mozjpeg-enc.ts new file mode 100644 index 000000000..f39fdaa65 --- /dev/null +++ b/src/lib/codec-wrappers/mozjpeg-enc.ts @@ -0,0 +1,76 @@ +import {Encoder} from './codec'; + +import mozjpeg_enc from '../../../codecs/mozjpeg_enc/mozjpeg_enc'; +// Using require() so TypeScript doesn’t complain about this not being a module. +const wasmBinaryUrl = require('../../../codecs/mozjpeg_enc/mozjpeg_enc.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; + encode(buffer: number, width: number, height: number, quality: number): void; + free_result(): void; + get_result_pointer(): number; + get_result_size(): number; +} + +export class MozJpegEncoder implements Encoder { + private emscriptenModule: Promise; + private api: Promise; + constructor() { + this.emscriptenModule = new Promise(resolve => { + const m = mozjpeg_enc({ + // 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. 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); + } + }); + }); + + 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']), + encode: m.cwrap('encode', '', ['number', 'number', 'number', 'number']), + free_result: m.cwrap('free_result', '', []), + get_result_pointer: m.cwrap('get_result_pointer', 'number', []), + get_result_size: m.cwrap('get_result_size', 'number', []), + }; + })(); + } + + async encode(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(data.data, p); + api.encode(p, data.width, data.height, 2); + const resultPointer = api.get_result_pointer(); + const resultSize = api.get_result_size(); + const resultView = new Uint8Array(m.HEAP8.buffer, resultPointer, resultSize); + const result = new Uint8Array(resultView); + api.free_result(); + api.destroy_buffer(p); + + return result.buffer; + } +} diff --git a/src/lib/util.ts b/src/lib/util.ts index 3ceadea51..3d0e09b7f 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -24,3 +24,22 @@ export function bind(target: any, propertyKey: string, descriptor: PropertyDescr } }; } + +/** + * Turns a given `ImageBitmap` into `ImageData`. + */ +export async function bitmapToImageData(bitmap: ImageBitmap): Promise { + // Make canvas same size as image + // TODO: Move this off-thread if possible with `OffscreenCanvas` or iFrames? + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + // Draw image onto canvas + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error("Could not create canvas context"); + } + ctx.drawImage(bitmap, 0, 0); + return ctx.getImageData(0, 0, bitmap.width, bitmap.height); +} + diff --git a/webpack.config.js b/webpack.config.js index a7de24f42..7d0025711 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -107,10 +107,23 @@ module.exports = function (_, env) { loader: 'babel-loader', // Don't respect any Babel RC files found on the filesystem: options: Object.assign(readJson('.babelrc'), { babelrc: false }) + }, + { + // All the codec files define a global with the same name as their file name. `exports-loader` attaches those to `module.exports`. + test: /\/codecs\/.*\.js$/, + loader: 'exports-loader', + }, + { + test: /\/codecs\/.*\.wasm$/, + // This is needed to make webpack NOT process wasm files. + // See https://github.com/webpack/webpack/issues/6725 + type: 'javascript/auto', + loader: 'file-loader', } - ] + ], }, plugins: [ + new webpack.IgnorePlugin(/(fs)/, /\/codecs\//), // Pretty progressbar showing build progress: new ProgressBarPlugin({ format: '\u001b[90m\u001b[44mBuild\u001b[49m\u001b[39m [:bar] \u001b[32m\u001b[1m:percent\u001b[22m\u001b[39m (:elapseds) \u001b[2m:msg\u001b[22m\r',