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.Module;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Don't we need to give types to the options?

99 changes: 99 additions & 0 deletions emscripten-wasm.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// 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";

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
25 changes: 23 additions & 2 deletions src/components/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { bind } 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 @@ -23,13 +25,31 @@ export default class App extends Component<Props, State> {
}
}

private async getImageData(bitmap: ImageBitmap): Promise<ImageData> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This doesn't read/write any class state, so it'd be better as a function.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Right, good call. I should probably start a imageutils file or something. We are gonna write a lot more of these I reckon.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also, we should do this on the worker side if we can (will be fun to play with offscreen canvas)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

OffscreenCanvas isn't stable anywhere tho.

Copy link
Collaborator Author

@surma surma May 21, 2018

Choose a reason for hiding this comment

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

... could we build an "offthread" canvas with an iframe? Cc @developit

Copy link
Collaborator

Choose a reason for hiding this comment

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

I guess we should see how much jank it causes us first.

The iframe idea is interesting! OOPIFs are a thing now, so it could work. I wonder if transferrables lose some of their benefit when jumping across processes.

// Make canvas same size as image
const canvas = document.createElement('canvas');
[canvas.width, canvas.height] = [bitmap.width, bitmap.height];
Copy link
Collaborator

Choose a reason for hiding this comment

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

I still think this technique is smarter than it is good, and it'd be better as two lines. Happy to be overridden by @developit or @kosamari though.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Nah, let’s keep it explicit. I don’t mind. I copy-pasted this from the example.html that I copied from my article :3

// Draw image onto canvas
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error("Could not create canvas contex");
Copy link
Collaborator

Choose a reason for hiding this comment

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

"context"

}
ctx.drawImage(bitmap, 0, 0);
return ctx.getImageData(0, 0, bitmap.width, bitmap.height);
}

@bind
async onFileChange(event: Event) {
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 this.getImageData(bitmap);
const encoder = new MozJpegEncoder();
Copy link
Collaborator Author

@surma surma May 17, 2018

Choose a reason for hiding this comment

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

The way it’s currently written, we should just be able to load these encoders in a worker and invoke encode via Comlink/workerize.

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 +67,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 | SharedArrayBuffer>;
Copy link
Collaborator

Choose a reason for hiding this comment

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

What causes the return type to be ArrayBuffer or SharedArrayBuffer?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also, if this going to be used by anything? Won't they all have different options?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I did this because I thought it would be nice to have a consistent encode() call and rather have a stateful encoder with individual calls to set options.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I’ll re-check ArrayBuffer vs SharedArrayBuffer, but I think TS was complaining about something (and was, strictly speaking, correct).

Copy link
Collaborator

Choose a reason for hiding this comment

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

I did this because I thought it would be nice to have a consistent encode() call and rather have a stateful encoder with individual calls to set options.

Yeah, fair enough!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, SharedArrayBuffer was a left-over. I just removed it :)

}

export interface Decoder {
decode(data: ArrayBuffer): Promise<ImageBitmap>;
}
72 changes: 72 additions & 0 deletions src/lib/codec-wrappers/mozjpeg-enc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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() {
// This is a bug I discovered. To be filed.
Copy link
Collaborator

Choose a reason for hiding this comment

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

More info 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;
}
}
23 changes: 23 additions & 0 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ module.exports = function (_, env) {
}
},
module: {
// This is needed to make webpack NOT process wasm files.
// See https://github.com/webpack/webpack/issues/6725
defaultRules: [
{
type: "javascript/auto",
resolve: {}
},
{
test: /\.json$/i,
type: "json"
},
],
rules: [
{
test: /\.(scss|sass)$/,
Expand Down Expand Up @@ -107,10 +119,21 @@ 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$/,
loader: 'file-loader',
}
]
},
plugins: [
// Ignore some of the native Node modules for any of the codecs. These files are generated by Emscripten and are supposed to also work in Node, which we don‘t care about.
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