forked from external-repos/squoosh
Merge pull request #91 from GoogleChromeLabs/webp-decoder
WASM WebpDecoder
This commit is contained in:
1
codecs/webp_dec/webp_dec.d.ts
vendored
Normal file
1
codecs/webp_dec/webp_dec.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default function(opts: EmscriptenWasm.ModuleOpts): EmscriptenWasm.Module;
|
||||||
8357
package-lock.json
generated
8357
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
src/codecs/browser-webp/decoder.ts
Normal file
18
src/codecs/browser-webp/decoder.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { canDecodeImage, createImageBitmapPolyfill } from '../../lib/util';
|
||||||
|
|
||||||
|
export const name = 'Browser WebP Decoder';
|
||||||
|
export async function decode(blob: Blob): Promise<ImageBitmap> {
|
||||||
|
return createImageBitmapPolyfill(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// tslint:disable-next-line:max-line-length It’s a data URL. Whatcha gonna do?
|
||||||
|
const webpFile = 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=';
|
||||||
|
|
||||||
|
export function isSupported(): Promise<boolean> {
|
||||||
|
return canDecodeImage(webpFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportedMimeTypes = ['image/webp'];
|
||||||
|
export function canHandleMimeType(mimeType: string): boolean {
|
||||||
|
return supportedMimeTypes.includes(mimeType);
|
||||||
|
}
|
||||||
47
src/codecs/decoders.ts
Normal file
47
src/codecs/decoders.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import * as wasmWebp from './webp/decoder';
|
||||||
|
import * as browserWebp from './webp/decoder';
|
||||||
|
|
||||||
|
import { createImageBitmapPolyfill, sniffMimeType } from '../lib/util';
|
||||||
|
|
||||||
|
export interface Decoder {
|
||||||
|
name: string;
|
||||||
|
decode(blob: Blob): Promise<ImageBitmap>;
|
||||||
|
isSupported(): Promise<boolean>;
|
||||||
|
canHandleMimeType(mimeType: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We load all decoders and filter out the unsupported ones.
|
||||||
|
export const decodersPromise: Promise<Decoder[]> = Promise.all(
|
||||||
|
[
|
||||||
|
browserWebp,
|
||||||
|
wasmWebp,
|
||||||
|
]
|
||||||
|
.map(async (decoder) => {
|
||||||
|
if (await decoder.isSupported()) {
|
||||||
|
return decoder;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
// TypeScript is not smart enough to realized that I’m filtering all the falsy
|
||||||
|
// values here.
|
||||||
|
).then(list => list.filter(item => !!item)) as any as Promise<Decoder[]>;
|
||||||
|
|
||||||
|
async function findDecodersByMimeType(mimeType: string): Promise<Decoder[]> {
|
||||||
|
const decoders = await decodersPromise;
|
||||||
|
return decoders.filter(decoder => decoder.canHandleMimeType(mimeType));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decodeImage(blob: Blob): Promise<ImageBitmap> {
|
||||||
|
const mimeType = await sniffMimeType(blob);
|
||||||
|
const decoders = await findDecodersByMimeType(mimeType);
|
||||||
|
if (decoders.length <= 0) {
|
||||||
|
// If we can’t find a decoder, hailmary with createImageBitmap
|
||||||
|
return createImageBitmapPolyfill(blob);
|
||||||
|
}
|
||||||
|
for (const decoder of decoders) {
|
||||||
|
try {
|
||||||
|
return await decoder.decode(blob);
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
throw new Error('No decoder could decode image');
|
||||||
|
}
|
||||||
84
src/codecs/webp/Decoder.worker.ts
Normal file
84
src/codecs/webp/Decoder.worker.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import webp_dec from '../../../codecs/webp_dec/webp_dec';
|
||||||
|
// Using require() so TypeScript doesn’t complain about this not being a module.
|
||||||
|
const wasmBinaryUrl = require('../../../codecs/webp_dec/webp_dec.wasm');
|
||||||
|
|
||||||
|
// API exposed by wasm module. Details in the codec’s 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, 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. Deleting 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']),
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/codecs/webp/decoder.ts
Normal file
18
src/codecs/webp/decoder.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { blobToArrayBuffer, imageDataToBitmap } from '../../lib/util';
|
||||||
|
import DecoderWorker from './Decoder.worker';
|
||||||
|
|
||||||
|
export const name = 'WASM WebP Decoder';
|
||||||
|
export async function decode(blob: Blob): Promise<ImageBitmap> {
|
||||||
|
const decoder = await new DecoderWorker();
|
||||||
|
const imageData = await decoder.decode(await blobToArrayBuffer(blob));
|
||||||
|
return imageDataToBitmap(imageData);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isSupported(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportedMimeTypes = ['image/webp'];
|
||||||
|
export function canHandleMimeType(mimeType: string): boolean {
|
||||||
|
return supportedMimeTypes.includes(mimeType);
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
encoderMap,
|
encoderMap,
|
||||||
} from '../../codecs/encoders';
|
} from '../../codecs/encoders';
|
||||||
|
|
||||||
|
import { decodeImage } from '../../codecs/decoders';
|
||||||
|
|
||||||
interface SourceImage {
|
interface SourceImage {
|
||||||
file: File;
|
file: File;
|
||||||
bmp: ImageBitmap;
|
bmp: ImageBitmap;
|
||||||
@@ -153,7 +155,9 @@ export default class App extends Component<Props, State> {
|
|||||||
// changed.
|
// changed.
|
||||||
if (source !== prevState.source || image.encoderState !== prevImage.encoderState) {
|
if (source !== prevState.source || image.encoderState !== prevImage.encoderState) {
|
||||||
if (prevImage.downloadUrl) URL.revokeObjectURL(prevImage.downloadUrl);
|
if (prevImage.downloadUrl) URL.revokeObjectURL(prevImage.downloadUrl);
|
||||||
this.updateImage(i);
|
this.updateImage(i).catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,7 +180,7 @@ export default class App extends Component<Props, State> {
|
|||||||
async updateFile(file: File) {
|
async updateFile(file: File) {
|
||||||
this.setState({ loading: true });
|
this.setState({ loading: true });
|
||||||
try {
|
try {
|
||||||
const bmp = await createImageBitmap(file);
|
const bmp = await decodeImage(file);
|
||||||
// compute the corresponding ImageData once since it only changes when the file changes:
|
// compute the corresponding ImageData once since it only changes when the file changes:
|
||||||
const data = await bitmapToImageData(bmp);
|
const data = await bitmapToImageData(bmp);
|
||||||
|
|
||||||
@@ -186,6 +190,7 @@ export default class App extends Component<Props, State> {
|
|||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
this.setState({ error: 'IMAGE_INVALID', loading: false });
|
this.setState({ error: 'IMAGE_INVALID', loading: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,7 +227,13 @@ export default class App extends Component<Props, State> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bmp = await createImageBitmap(file);
|
let bmp;
|
||||||
|
try {
|
||||||
|
bmp = await createImageBitmap(file);
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ error: `Encoding error (type=${image.encoderState.type}): ${err}` });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
images = this.state.images.slice() as [EncodedImage, EncodedImage];
|
images = this.state.images.slice() as [EncodedImage, EncodedImage];
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,21 @@ export async function bitmapToImageData(bitmap: ImageBitmap): Promise<ImageData>
|
|||||||
return ctx.getImageData(0, 0, bitmap.width, bitmap.height);
|
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 */
|
/** Replace the contents of a canvas with the given bitmap */
|
||||||
export function drawBitmapToCanvas(canvas: HTMLCanvasElement, bitmap: ImageBitmap) {
|
export function drawBitmapToCanvas(canvas: HTMLCanvasElement, bitmap: ImageBitmap) {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
@@ -80,3 +95,53 @@ export async function canvasEncode(data: ImageData, type: string, quality?: numb
|
|||||||
if (!blob) throw Error('Encoding failed');
|
if (!blob) throw Error('Encoding failed');
|
||||||
return blob;
|
return blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canDecodeImage(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 blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const fileReader = new FileReader();
|
||||||
|
fileReader.addEventListener('load', () => {
|
||||||
|
resolve(fileReader.result);
|
||||||
|
});
|
||||||
|
fileReader.readAsArrayBuffer(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const magicNumberToMimeType = new Map<RegExp, string>([
|
||||||
|
[/^%PDF-/, 'application/pdf'],
|
||||||
|
[/^GIF87a/, 'image/gif'],
|
||||||
|
[/^GIF89a/, 'image/gif'],
|
||||||
|
[/^\x89PNG\x0D\x0A\x1A\x0A/, 'image/png'],
|
||||||
|
[/^\xFF\xD8\xFF/, 'image/jpeg'],
|
||||||
|
[/^BM/, 'image/bmp'],
|
||||||
|
[/^I I/, 'image/tiff'],
|
||||||
|
[/^II*/, 'image/tiff'],
|
||||||
|
[/^MM\x00*/, 'image/tiff'],
|
||||||
|
[/^RIFF....WEBPVP8 /, 'image/webp'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function sniffMimeType(blob: Blob): Promise<string> {
|
||||||
|
const firstChunk = await blobToArrayBuffer(blob.slice(0, 16));
|
||||||
|
const firstChunkString =
|
||||||
|
Array.from(new Uint8Array(firstChunk))
|
||||||
|
.map(v => String.fromCodePoint(v))
|
||||||
|
.join('');
|
||||||
|
for (const [detector, mimeType] of magicNumberToMimeType) {
|
||||||
|
if (detector.test(firstChunkString)) {
|
||||||
|
return mimeType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createImageBitmapPolyfill(blob: Blob): Promise<ImageBitmap> {
|
||||||
|
return createImageBitmap(blob);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user