Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Load mozjpeg codec and encode image #35

Merged
merged 12 commits into from
May 22, 2018
1 change: 1 addition & 0 deletions codecs/mozjpeg_enc/mozjpeg_enc.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function(opts: EmscriptenWasm.ModuleOpts): EmscriptenWasm.Module;
107 changes: 107 additions & 0 deletions emscripten-wasm.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}

34 changes: 34 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@developit Currently I just have separate npm scripts to build all codecs and assume that these tasks have been run before building the app. WDYT? Do you have a better idea?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH that seems fine to me, unless we think the codecs will be changed as frequently as app code. Otherwise it seems nice to keep those things separate and keep the builds fast. Are the results from these builds committed?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build-speed was the main argument for me as well. And yes, build-artifacts are not commited.

"start": "webpack serve --hot",
"build": "webpack -p",
"lint": "eslint src"
Expand All @@ -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",
Expand All @@ -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",
Expand Down
14 changes: 11 additions & 3 deletions src/components/app/index.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -28,8 +30,13 @@ export default class App extends Component<Props, State> {
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) {
Expand All @@ -47,3 +54,4 @@ export default class App extends Component<Props, State> {
);
}
}

7 changes: 7 additions & 0 deletions src/lib/codec-wrappers/codec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface Encoder {
encode(data: ImageData): Promise<ArrayBuffer>;
}

export interface Decoder {
decode(data: ArrayBuffer): Promise<ImageBitmap>;
}
76 changes: 76 additions & 0 deletions src/lib/codec-wrappers/mozjpeg-enc.ts
Original file line number Diff line number Diff line change
@@ -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<EmscriptenWasm.Module>;
private api: Promise<ModuleAPI>;
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<ArrayBuffer | SharedArrayBuffer> {
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;
}
}
19 changes: 19 additions & 0 deletions src/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ImageData> {
// Make canvas same size as image
// TODO: Move this off-thread if possible with `OffscreenCanvas` or iFrames?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could just cache this for now so it only runs when the source image changes. OffscreenCanvas is still behind a flag and iframe would have to be served from a different origin to have any benefit :(

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);
}

15 changes: 14 additions & 1 deletion webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down