Creating fallbacks for all ImageBitmap usage & refactoring. (#175)

Fixes #173
This commit is contained in:
Jake Archibald
2018-09-27 14:44:54 +01:00
committed by GitHub
parent 1b630a092f
commit c9fe5ffbcf
11 changed files with 2085 additions and 2197 deletions

4003
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -56,7 +56,7 @@
"tslint-react": "^3.6.0", "tslint-react": "^3.6.0",
"typescript": "^2.9.2", "typescript": "^2.9.2",
"typings-for-css-modules-loader": "^1.7.0", "typings-for-css-modules-loader": "^1.7.0",
"webpack": "^4.16.5", "webpack": "^4.19.1",
"webpack-bundle-analyzer": "^2.13.1", "webpack-bundle-analyzer": "^2.13.1",
"webpack-cli": "^2.1.5", "webpack-cli": "^2.1.5",
"webpack-dev-server": "^3.1.5", "webpack-dev-server": "^3.1.5",

View File

@@ -1,8 +1,8 @@
import { canDecodeImage, createImageBitmapPolyfill } from '../../lib/util'; import { canDecodeImage, nativeDecode } from '../../lib/util';
export const name = 'Browser WebP Decoder'; export const name = 'Browser WebP Decoder';
export async function decode(blob: Blob): Promise<ImageBitmap> { export async function decode(blob: Blob): Promise<ImageData> {
return createImageBitmapPolyfill(blob); return nativeDecode(blob);
} }
// tslint:disable-next-line:max-line-length Its a data URL. Whatcha gonna do? // tslint:disable-next-line:max-line-length Its a data URL. Whatcha gonna do?

View File

@@ -1,11 +1,11 @@
import * as wasmWebp from './webp/decoder'; import * as wasmWebp from './webp/decoder';
import * as browserWebp from './webp/decoder'; import * as browserWebp from './webp/decoder';
import { createImageBitmapPolyfill, sniffMimeType } from '../lib/util'; import { nativeDecode, sniffMimeType } from '../lib/util';
export interface Decoder { export interface Decoder {
name: string; name: string;
decode(blob: Blob): Promise<ImageBitmap>; decode(blob: Blob): Promise<ImageData>;
isSupported(): Promise<boolean>; isSupported(): Promise<boolean>;
canHandleMimeType(mimeType: string): boolean; canHandleMimeType(mimeType: string): boolean;
} }
@@ -31,12 +31,12 @@ async function findDecodersByMimeType(mimeType: string): Promise<Decoder[]> {
return decoders.filter(decoder => decoder.canHandleMimeType(mimeType)); return decoders.filter(decoder => decoder.canHandleMimeType(mimeType));
} }
export async function decodeImage(blob: Blob): Promise<ImageBitmap> { export async function decodeImage(blob: Blob): Promise<ImageData> {
const mimeType = await sniffMimeType(blob); const mimeType = await sniffMimeType(blob);
const decoders = await findDecodersByMimeType(mimeType); const decoders = await findDecodersByMimeType(mimeType);
if (decoders.length <= 0) { if (decoders.length <= 0) {
// If we cant find a decoder, hailmary with createImageBitmap // If we cant find a decoder, hailmary with the browser's decoders
return createImageBitmapPolyfill(blob); return nativeDecode(blob);
} }
for (const decoder of decoders) { for (const decoder of decoders) {
try { try {

View File

@@ -1,8 +1,6 @@
import { bitmapToImageData, createImageBitmapPolyfill } from '../../lib/util'; import { nativeResize, NativeResizeMethod } from '../../lib/util';
type CreateImageBitmapResize = 'pixelated' | 'low' | 'medium' | 'high'; export function resize(data: ImageData, opts: ResizeOptions): ImageData {
export async function resize(data: ImageData, opts: ResizeOptions): Promise<ImageData> {
let sx = 0; let sx = 0;
let sy = 0; let sy = 0;
let sw = data.width; let sw = data.width;
@@ -20,13 +18,10 @@ export async function resize(data: ImageData, opts: ResizeOptions): Promise<Imag
} }
} }
const bmp = await createImageBitmapPolyfill(data, sx, sy, sw, sh, { return nativeResize(
resizeQuality: opts.method.slice('browser-'.length) as CreateImageBitmapResize, data, sx, sy, sw, sh, opts.width, opts.height,
resizeWidth: opts.width, opts.method.slice('browser-'.length) as NativeResizeMethod,
resizeHeight: opts.height, );
});
return bitmapToImageData(bmp);
} }
export interface ResizeOptions { export interface ResizeOptions {

View File

@@ -1,11 +1,10 @@
import { blobToArrayBuffer, imageDataToBitmap } from '../../lib/util'; import { blobToArrayBuffer } from '../../lib/util';
import DecoderWorker from './Decoder.worker'; import DecoderWorker from './Decoder.worker';
export const name = 'WASM WebP Decoder'; export const name = 'WASM WebP Decoder';
export async function decode(blob: Blob): Promise<ImageBitmap> { export async function decode(blob: Blob): Promise<ImageData> {
const decoder = await new DecoderWorker(); const decoder = await new DecoderWorker();
const imageData = await decoder.decode(await blobToArrayBuffer(blob)); return decoder.decode(await blobToArrayBuffer(blob));
return imageDataToBitmap(imageData);
} }
export async function isSupported(): Promise<boolean> { export async function isSupported(): Promise<boolean> {

View File

@@ -1,6 +1,6 @@
import { h, Component } from 'preact'; import { h, Component } from 'preact';
import { bind, linkRef, bitmapToImageData } from '../../lib/util'; import { bind, linkRef } from '../../lib/util';
import * as style from './style.scss'; import * as style from './style.scss';
import Output from '../Output'; import Output from '../Output';
import Options from '../Options'; import Options from '../Options';
@@ -43,15 +43,14 @@ type Orientation = 'horizontal' | 'vertical';
export interface SourceImage { export interface SourceImage {
file: File; file: File;
bmp: ImageBitmap;
data: ImageData; data: ImageData;
} }
interface EncodedImage { interface EncodedImage {
preprocessed?: ImageData; preprocessed?: ImageData;
bmp?: ImageBitmap;
file?: File; file?: File;
downloadUrl?: string; downloadUrl?: string;
data?: ImageData;
preprocessorState: PreprocessorState; preprocessorState: PreprocessorState;
encoderState: EncoderState; encoderState: EncoderState;
loading: boolean; loading: boolean;
@@ -81,7 +80,7 @@ async function preprocessImage(
): Promise<ImageData> { ): Promise<ImageData> {
let result = source.data; let result = source.data;
if (preprocessData.resize.enabled) { if (preprocessData.resize.enabled) {
result = await resizer.resize(result, preprocessData.resize); result = resizer.resize(result, preprocessData.resize);
} }
if (preprocessData.quantizer.enabled) { if (preprocessData.quantizer.enabled) {
result = await quantizer.quantize(result, preprocessData.quantizer); result = await quantizer.quantize(result, preprocessData.quantizer);
@@ -196,8 +195,8 @@ export default class App extends Component<Props, State> {
const encoderChanged = image.encoderState !== prevImage.encoderState; const encoderChanged = image.encoderState !== prevImage.encoderState;
const preprocessorChanged = image.preprocessorState !== prevImage.preprocessorState; const preprocessorChanged = image.preprocessorState !== prevImage.preprocessorState;
// The image only needs updated if the encoder settings have changed, or the source has // The image only needs updated if the encoder/preprocessor settings have changed, or the
// changed. // source has changed.
if (sourceChanged || encoderChanged || preprocessorChanged) { if (sourceChanged || encoderChanged || preprocessorChanged) {
if (prevImage.downloadUrl) URL.revokeObjectURL(prevImage.downloadUrl); if (prevImage.downloadUrl) URL.revokeObjectURL(prevImage.downloadUrl);
this.updateImage(i, { this.updateImage(i, {
@@ -235,13 +234,11 @@ 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 decodeImage(file); const data = await decodeImage(file);
// compute the corresponding ImageData once since it only changes when the file changes:
const data = await bitmapToImageData(bmp);
let newState = { let newState = {
...this.state, ...this.state,
source: { data, bmp, file }, source: { data, file },
loading: false, loading: false,
}; };
@@ -256,7 +253,7 @@ export default class App extends Component<Props, State> {
this.setState(newState); this.setState(newState);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
this.showError(`Invalid image`); this.showError('Invalid image');
this.setState({ loading: false }); this.setState({ loading: false });
} }
} }
@@ -278,29 +275,29 @@ export default class App extends Component<Props, State> {
const image = images[index]; const image = images[index];
let file; let file: File | undefined;
let preprocessed; let preprocessed: ImageData | undefined;
let bmp; let data: ImageData | undefined;
const cacheResult = this.encodeCache.match(source, image.preprocessorState, image.encoderState); const cacheResult = this.encodeCache.match(source, image.preprocessorState, image.encoderState);
if (cacheResult) { if (cacheResult) {
({ file, preprocessed, bmp } = cacheResult); ({ file, preprocessed, data } = cacheResult);
} else { } else {
try { try {
// Special case for identity // Special case for identity
if (image.encoderState.type === identity.type) { if (image.encoderState.type === identity.type) {
({ file, bmp } = source); ({ file, data } = source);
} else { } else {
preprocessed = (skipPreprocessing && image.preprocessed) preprocessed = (skipPreprocessing && image.preprocessed)
? image.preprocessed ? image.preprocessed
: await preprocessImage(source, image.preprocessorState); : await preprocessImage(source, image.preprocessorState);
file = await compressImage(preprocessed, image.encoderState, source.file.name); file = await compressImage(preprocessed, image.encoderState, source.file.name);
bmp = await decodeImage(file); data = await decodeImage(file);
this.encodeCache.add({ this.encodeCache.add({
source, source,
bmp, data,
preprocessed, preprocessed,
file, file,
encoderState: image.encoderState, encoderState: image.encoderState,
@@ -321,7 +318,7 @@ export default class App extends Component<Props, State> {
images = cleanMerge(this.state.images, index, { images = cleanMerge(this.state.images, index, {
file, file,
bmp, data,
preprocessed, preprocessed,
downloadUrl: URL.createObjectURL(file), downloadUrl: URL.createObjectURL(file),
loading: images[index].loadingCounter !== loadingCounter, loading: images[index].loadingCounter !== loadingCounter,
@@ -338,19 +335,19 @@ export default class App extends Component<Props, State> {
render({ }: Props, { loading, images, source, orientation }: State) { render({ }: Props, { loading, images, source, orientation }: State) {
const [leftImage, rightImage] = images; const [leftImage, rightImage] = images;
const [leftImageBmp, rightImageBmp] = images.map(i => i.bmp); const [leftImageData, rightImageData] = images.map(i => i.data);
const anyLoading = loading || images.some(image => image.loading); const anyLoading = loading || images.some(image => image.loading);
return ( return (
<file-drop accept="image/*" onfiledrop={this.onFileDrop}> <file-drop accept="image/*" onfiledrop={this.onFileDrop}>
<div id="app" class={`${style.app} ${style[orientation]}`}> <div id="app" class={`${style.app} ${style[orientation]}`}>
{(leftImageBmp && rightImageBmp && source) ? ( {(leftImageData && rightImageData && source) ? (
<Output <Output
orientation={orientation} orientation={orientation}
imgWidth={source.bmp.width} imgWidth={source.data.width}
imgHeight={source.bmp.height} imgHeight={source.data.height}
leftImg={leftImageBmp} leftImg={leftImageData}
rightImg={rightImageBmp} rightImg={rightImageData}
leftImgContain={leftImage.preprocessorState.resize.fitMethod === 'cover'} leftImgContain={leftImage.preprocessorState.resize.fitMethod === 'cover'}
rightImgContain={rightImage.preprocessorState.resize.fitMethod === 'cover'} rightImgContain={rightImage.preprocessorState.resize.fitMethod === 'cover'}
/> />
@@ -360,10 +357,10 @@ export default class App extends Component<Props, State> {
<input type="file" onChange={this.onFileChange} /> <input type="file" onChange={this.onFileChange} />
</div> </div>
)} )}
{(leftImageBmp && rightImageBmp && source) && images.map((image, index) => ( {(leftImageData && rightImageData && source) && images.map((image, index) => (
<Options <Options
orientation={orientation} orientation={orientation}
sourceAspect={source.bmp.width / source.bmp.height} sourceAspect={source.data.width / source.data.height}
imageIndex={index} imageIndex={index}
imageFile={image.file} imageFile={image.file}
sourceImageFile={source && source.file} sourceImageFile={source && source.file}

View File

@@ -7,7 +7,7 @@ import * as identity from '../../codecs/identity/encoder';
interface CacheResult { interface CacheResult {
preprocessed: ImageData; preprocessed: ImageData;
bmp: ImageBitmap; data: ImageData;
file: File; file: File;
} }
@@ -67,7 +67,7 @@ export default class ResultCache {
} }
return { return {
bmp: matchingEntry.bmp, data: matchingEntry.data,
preprocessed: matchingEntry.preprocessed, preprocessed: matchingEntry.preprocessed,
file: matchingEntry.file, file: matchingEntry.file,
}; };

View File

@@ -3,14 +3,14 @@ import PinchZoom, { ScaleToOpts } from './custom-els/PinchZoom';
import './custom-els/PinchZoom'; import './custom-els/PinchZoom';
import './custom-els/TwoUp'; import './custom-els/TwoUp';
import * as style from './style.scss'; import * as style from './style.scss';
import { bind, shallowEqual, drawBitmapToCanvas, linkRef } from '../../lib/util'; import { bind, shallowEqual, drawDataToCanvas, linkRef } from '../../lib/util';
import { ToggleIcon, AddIcon, RemoveIcon } from '../../lib/icons'; import { ToggleIcon, AddIcon, RemoveIcon } from '../../lib/icons';
import { twoUpHandle } from './custom-els/TwoUp/styles.css'; import { twoUpHandle } from './custom-els/TwoUp/styles.css';
interface Props { interface Props {
orientation: 'horizontal' | 'vertical'; orientation: 'horizontal' | 'vertical';
leftImg: ImageBitmap; leftImg: ImageData;
rightImg: ImageBitmap; rightImg: ImageData;
imgWidth: number; imgWidth: number;
imgHeight: number; imgHeight: number;
leftImgContain: boolean; leftImgContain: boolean;
@@ -45,19 +45,19 @@ export default class Output extends Component<Props, State> {
componentDidMount() { componentDidMount() {
if (this.canvasLeft) { if (this.canvasLeft) {
drawBitmapToCanvas(this.canvasLeft, this.props.leftImg); drawDataToCanvas(this.canvasLeft, this.props.leftImg);
} }
if (this.canvasRight) { if (this.canvasRight) {
drawBitmapToCanvas(this.canvasRight, this.props.rightImg); drawDataToCanvas(this.canvasRight, this.props.rightImg);
} }
} }
componentDidUpdate(prevProps: Props, prevState: State) { componentDidUpdate(prevProps: Props, prevState: State) {
if (prevProps.leftImg !== this.props.leftImg && this.canvasLeft) { if (prevProps.leftImg !== this.props.leftImg && this.canvasLeft) {
drawBitmapToCanvas(this.canvasLeft, this.props.leftImg); drawDataToCanvas(this.canvasLeft, this.props.leftImg);
} }
if (prevProps.rightImg !== this.props.rightImg && this.canvasRight) { if (prevProps.rightImg !== this.props.rightImg && this.canvasRight) {
drawBitmapToCanvas(this.canvasRight, this.props.rightImg); drawDataToCanvas(this.canvasRight, this.props.rightImg);
} }
} }
@@ -135,7 +135,7 @@ export default class Output extends Component<Props, State> {
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element'); if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
// If the event is on the handle of the two-up, let it through, // If the event is on the handle of the two-up, let it through,
// unless it's a wheel event, in which case always let it through. // unless it's a wheel event, in which case always let it through.
if (event.type !== 'wheel' && targetEl.closest('.' + twoUpHandle)) return; if (event.type !== 'wheel' && targetEl.closest(`.${twoUpHandle}`)) return;
// If we've already retargeted this event, let it through. // If we've already retargeted this event, let it through.
if (this.retargetedEvents.has(event)) return; if (this.retargetedEvents.has(event)) return;
// Stop the event in its tracks. // Stop the event in its tracks.

7
src/lib/missing-types.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
interface HTMLImageElement {
decode: () => Promise<void> | undefined;
}
interface CanvasRenderingContext2D {
imageSmoothingQuality: 'low' | 'medium' | 'high';
}

View File

@@ -50,48 +50,22 @@ export function linkRef<T>(obj: any, name: string) {
return ref; return ref;
} }
/** /** Replace the contents of a canvas with the given data */
* Turns a given `ImageBitmap` into `ImageData`. export function drawDataToCanvas(canvas: HTMLCanvasElement, data: 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?
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);
}
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'); const ctx = canvas.getContext('2d');
if (!ctx) throw Error('Canvas not initialized'); if (!ctx) throw Error('Canvas not initialized');
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(bitmap, 0, 0); ctx.putImageData(data, 0, 0);
} }
export async function canvasEncode(data: ImageData, type: string, quality?: number) { /**
* Encode some image data in a given format using the browser's encoders
*
* @param {ImageData} data
* @param {string} type A mime type, eg image/jpeg.
* @param {number} [quality] Between 0-1.
*/
export async function canvasEncode(data: ImageData, type: string, quality?: number): Promise<Blob> {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = data.width; canvas.width = data.width;
canvas.height = data.height; canvas.height = data.height;
@@ -105,6 +79,9 @@ export async function canvasEncode(data: ImageData, type: string, quality?: numb
return blob; return blob;
} }
/**
* Attempts to load the given URL as an image.
*/
export function canDecodeImage(data: string): Promise<boolean> { export function canDecodeImage(data: string): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const img = document.createElement('img'); const img = document.createElement('img');
@@ -115,16 +92,7 @@ export function canDecodeImage(data: string): Promise<boolean> {
} }
export function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> { export function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => { return new Response(blob).arrayBuffer();
const fileReader = new FileReader();
fileReader.addEventListener('load', () => {
if (fileReader.result instanceof ArrayBuffer) {
return resolve(fileReader.result);
}
reject(Error('Unexpected return type'));
});
fileReader.readAsArrayBuffer(blob);
});
} }
const magicNumberToMimeType = new Map<RegExp, string>([ const magicNumberToMimeType = new Map<RegExp, string>([
@@ -154,35 +122,78 @@ export async function sniffMimeType(blob: Blob): Promise<string> {
return ''; return '';
} }
type CreateImageBitmapInput = HTMLImageElement | SVGImageElement | HTMLVideoElement | async function blobToImg(blob: Blob): Promise<HTMLImageElement> {
HTMLCanvasElement | ImageBitmap | ImageData | Blob; const url = URL.createObjectURL(blob);
export function createImageBitmapPolyfill( try {
image: CreateImageBitmapInput, const img = new Image();
options?: ImageBitmapOptions, img.decoding = 'async';
): Promise<ImageBitmap>; img.src = url;
export function createImageBitmapPolyfill(
image: CreateImageBitmapInput, if (img.decode) {
sx: number, // Nice off-thread way supported in at least Safari.
sy: number, await img.decode();
sw: number, } else {
sh: number, // Main thread decoding :(
options?: ImageBitmapOptions, await new Promise((resolve, reject) => {
): Promise<ImageBitmap>; img.onload = () => resolve();
export function createImageBitmapPolyfill( img.onerror = () => reject(Error('Image loading error'));
image: CreateImageBitmapInput, });
sxOrOptions?: number | ImageBitmapOptions, }
sy?: number,
sw?: number, return img;
sh?: number, } finally {
options?: ImageBitmapOptions, URL.revokeObjectURL(url);
): Promise<ImageBitmap> {
if (sxOrOptions === undefined || typeof sxOrOptions !== 'number') {
// sxOrOptions is absent or an options object
return createImageBitmap(image, sxOrOptions);
} }
// sxOrOptions is a number }
return createImageBitmap(image, sxOrOptions, sy!, sw!, sh!, options);
function drawableToImageData(drawable: ImageBitmap | HTMLImageElement): ImageData {
// Make canvas same size as image
const canvas = document.createElement('canvas');
canvas.width = drawable.width;
canvas.height = drawable.height;
// Draw image onto canvas
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Could not create canvas context');
ctx.drawImage(drawable, 0, 0);
return ctx.getImageData(0, 0, drawable.width, drawable.height);
}
export async function nativeDecode(blob: Blob): Promise<ImageData> {
// Prefer createImageBitmap as it's the off-thread option for Firefox.
const drawable = 'createImageBitmap' in self ?
await createImageBitmap(blob) : await blobToImg(blob);
return drawableToImageData(drawable);
}
export type NativeResizeMethod = 'pixelated' | 'low' | 'medium' | 'high';
export function nativeResize(
data: ImageData,
sx: number, sy: number, sw: number, sh: number,
dw: number, dh: number,
method: NativeResizeMethod,
): ImageData {
const canvasSource = document.createElement('canvas');
canvasSource.width = data.width;
canvasSource.height = data.height;
drawDataToCanvas(canvasSource, data);
const canvasDest = document.createElement('canvas');
canvasDest.width = dw;
canvasDest.height = dh;
const ctx = canvasDest.getContext('2d');
if (!ctx) throw new Error('Could not create canvas context');
if (method === 'pixelated') {
ctx.imageSmoothingEnabled = false;
} else {
ctx.imageSmoothingQuality = method;
}
ctx.drawImage(canvasSource, sx, sy, sw, sh, 0, 0, dw, dh);
return ctx.getImageData(0, 0, dw, dh);
} }
/** /**