Set up decoder infrastructure

This commit is contained in:
Surma
2018-07-04 15:18:47 +01:00
parent 6e8f8bbe41
commit 790a5b580d
10 changed files with 5450 additions and 3143 deletions

1
codecs/webp_dec/webp_dec.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
export default function(opts: EmscriptenWasm.ModuleOpts): EmscriptenWasm.Module;

8357
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
import { canDecode, fileToBitmap } from '../../lib/util';
export const name = 'Browser JPEG Decoder';
export const supportedExtensions = ['jpg', 'jpeg'];
export const supportedMimeTypes = ['image/jpeg'];
export async function decode(file: File): Promise<ImageBitmap> {
return fileToBitmap(file);
}
// tslint:disable-next-line:max-line-length Its a data URL. Whatcha gonna do?
const jpegFile = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAn/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AJVAA//Z';
export function isSupported(): Promise<boolean> {
return canDecode(jpegFile);
}

View File

@@ -0,0 +1,15 @@
import { canDecode, fileToBitmap } from '../../lib/util';
export const name = 'Browser PNG Decoder';
export const supportedExtensions = ['png'];
export const supportedMimeTypes = ['image/png'];
export async function decode(file: File): Promise<ImageBitmap> {
return fileToBitmap(file);
}
// tslint:disable-next-line:max-line-length Its a data URL. Whatcha gonna do?
const pngFile = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQYV2NgAAIAAAUAAarVyFEAAAAASUVORK5CYII=';
export function isSupported(): Promise<boolean> {
return canDecode(pngFile);
}

View File

@@ -0,0 +1,15 @@
import { canDecode, fileToBitmap } from '../../lib/util';
export const name = 'Browser WebP Decoder';
export const supportedExtensions = ['webp'];
export const supportedMimeTypes = ['image/webp'];
export async function decode(file: File): Promise<ImageBitmap> {
return fileToBitmap(file);
}
// tslint:disable-next-line:max-line-length Its a data URL. Whatcha gonna do?
const webpFile = 'data:image/webp;base64,UklGRkAAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAAAAFZQOCAYAAAAMAEAnQEqAQABAAFAJiWkAANwAP79NmgA';
export function isSupported(): Promise<boolean> {
return canDecode(webpFile);
}

42
src/codecs/decoders.ts Normal file
View File

@@ -0,0 +1,42 @@
import * as browserJPEG from './browser-jpeg/decoder';
import * as browserPNG from './browser-png/decoder';
import * as browserWebP from './browser-webp/decoder';
import * as wasmWebP from './webp/decoder';
export interface Decoder {
name: string;
decode(file: File): Promise<ImageBitmap>;
isSupported(): Promise<boolean>;
supportedMimeTypes: string[];
supportedExtensions: string[];
}
// We load all decoders and filter out the unsupported ones.
export const decodersPromise: Promise<Decoder[]> = Promise.all(
[
browserPNG,
browserJPEG,
browserWebP,
wasmWebP,
]
.map(async (encoder) => {
if (await encoder.isSupported()) {
return encoder;
}
return null;
}),
// TypeScript is not smart enough to realized that Im filtering all the falsy
// values here.
).then(list => list.filter(item => !!item)) as any as Promise<Decoder[]>;
export async function findDecoder(file: File): Promise<Decoder | undefined> {
const decoders = await decodersPromise;
// Prefer a match on mime type over a match on file extension
const decoder = decoders.find(decoder => decoder.supportedMimeTypes.includes(file.type));
if (decoder) {
return decoder;
}
return decoders.find(decoder =>
decoder.supportedExtensions.some(extension =>
file.name.endsWith(`.${extension}`)));
}

View File

@@ -0,0 +1,84 @@
import webp_dec from '../../../codecs/webp_dec/webp_dec';
// Using require() so TypeScript doesnt complain about this not being a module.
const wasmBinaryUrl = require('../../../codecs/webp_dec/webp_dec.wasm');
// API exposed by wasm module. Details in the codecs README.
interface ModuleAPI {
version(): number;
create_buffer(size: number): number;
destroy_buffer(pointer: number): void;
decode(buffer: number, size: number): void;
free_result(): void;
get_result_pointer(): number;
get_result_width(): number;
get_result_height(): number;
}
export default class WebpDecoder {
private emscriptenModule: Promise<EmscriptenWasm.Module>;
private api: Promise<ModuleAPI>;
constructor() {
this.emscriptenModule = new Promise((resolve) => {
const m = webp_dec({
// Just to be safe, dont 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 its 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']),
destroy_buffer: m.cwrap('destroy_buffer', '', ['number']),
decode: m.cwrap('decode', '', ['number', 'number']),
free_result: m.cwrap('free_result', '', []),
get_result_pointer: m.cwrap('get_result_pointer', 'number', []),
get_result_height: m.cwrap('get_result_height', 'number', []),
get_result_width: m.cwrap('get_result_width', 'number', []),
};
})();
}
async decode(data: ArrayBuffer): Promise<ImageData> {
const m = await this.emscriptenModule;
const api = await this.api;
const p = api.create_buffer(data.byteLength);
m.HEAP8.set(new Uint8Array(data), p);
api.decode(p, data.byteLength);
const resultPointer = api.get_result_pointer();
const resultWidth = api.get_result_width();
const resultHeight = api.get_result_height();
const resultView = new Uint8Array(
m.HEAP8.buffer,
resultPointer,
resultWidth * resultHeight * 4,
);
const result = new Uint8ClampedArray(resultView);
api.free_result();
api.destroy_buffer(p);
return new ImageData(result, resultWidth, resultHeight);
}
}

View File

@@ -0,0 +1,16 @@
import { blobToArrayBuffer, imageDataToBitmap } from '../../lib/util';
import DecoderWorker from './DecoderWorker';
export const name = 'WASM WebP Decoder';
export const supportedExtensions = ['webp'];
export const supportedMimeTypes = ['image/webp'];
export async function decode(file: File): Promise<ImageBitmap> {
const decoder = await new DecoderWorker();
const imageData = await decoder.decode(await blobToArrayBuffer(file));
return imageDataToBitmap(imageData);
}
export async function isSupported(): Promise<boolean> {
// TODO(@surma): Should we do wasm detection here or something?
return true;
}

View File

@@ -26,6 +26,8 @@ import {
encoderMap,
} from '../../codecs/encoders';
import { findDecoder } from '../../codecs/decoders';
interface SourceImage {
file: File;
bmp: ImageBitmap;
@@ -176,7 +178,12 @@ export default class App extends Component<Props, State> {
async updateFile(file: File) {
this.setState({ loading: true });
try {
const bmp = await createImageBitmap(file);
const decoder = await findDecoder(file);
if (!decoder) {
throw new Error('Cant find a decoder for the given file');
}
console.log(`Decoding using ${decoder.name}`);
const bmp = await decoder.decode(file);
// compute the corresponding ImageData once since it only changes when the file changes:
const data = await bitmapToImageData(bmp);
@@ -186,6 +193,7 @@ export default class App extends Component<Props, State> {
loading: false,
});
} catch (err) {
console.error(err);
this.setState({ error: 'IMAGE_INVALID', loading: false });
}
}

View File

@@ -59,6 +59,21 @@ export async function bitmapToImageData(bitmap: ImageBitmap): Promise<ImageData>
return ctx.getImageData(0, 0, bitmap.width, bitmap.height);
}
export async function imageDataToBitmap(data: ImageData): Promise<ImageBitmap> {
// Make canvas same size as image
// TODO: Move this off-thread if possible with `OffscreenCanvas` or iFrames?
const canvas = document.createElement('canvas');
canvas.width = data.width;
canvas.height = data.height;
// Draw image onto canvas
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Could not create canvas context');
}
ctx.putImageData(data, 0, 0);
return createImageBitmap(canvas);
}
/** Replace the contents of a canvas with the given bitmap */
export function drawBitmapToCanvas(canvas: HTMLCanvasElement, bitmap: ImageBitmap) {
const ctx = canvas.getContext('2d');
@@ -80,3 +95,26 @@ export async function canvasEncode(data: ImageData, type: string, quality?: numb
if (!blob) throw Error('Encoding failed');
return blob;
}
export function canDecode(data: string): Promise<boolean> {
return new Promise((resolve) => {
const img = document.createElement('img');
img.src = data;
img.onload = _ => resolve(true);
img.onerror = _ => resolve(false);
});
}
export function fileToBitmap(file: File): Promise<ImageBitmap> {
return createImageBitmap(file);
}
export function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
return new Promise((resolve) => {
const fileReader = new FileReader();
fileReader.addEventListener('load', () => {
resolve(fileReader.result);
});
fileReader.readAsArrayBuffer(blob);
});
}