Structure for browser decoders. Some work on decoding.

This commit is contained in:
Jake Archibald
2020-11-06 13:05:12 +00:00
parent 4b8c0178fe
commit ec586bb529
33 changed files with 670 additions and 437 deletions

View File

@@ -72,7 +72,7 @@ export default function () {
await Promise.all([
fsp.writeFile(
path.join(workerBasePath, 'tsconfig.json'),
JSON.stringify(workerTsConfig, null, ' '),
autoGenComment + JSON.stringify(workerTsConfig, null, ' '),
),
fsp.writeFile(path.join(workerBasePath, 'index.ts'), workerFile),
]);

View File

@@ -1,34 +1,18 @@
import { h, Component } from 'preact';
import { bind, Fileish } from '../../lib/initial-util';
import { blobToImg, drawableToImageData, blobToText } from '../../lib/util';
import * as style from './style.scss';
import {
blobToImg,
drawableToImageData,
blobToText,
builtinDecode,
sniffMimeType,
canDecodeImageType,
} from '../util';
import * as style from './style.css';
import 'add-css:./style.css';
import Output from '../Output';
import Options from '../Options';
import ResultCache from './result-cache';
import * as identity from '../../codecs/identity/encoder-meta';
import * as oxiPNG from '../../codecs/oxipng/encoder-meta';
import * as mozJPEG from '../../codecs/mozjpeg/encoder-meta';
import * as webP from '../../codecs/webp/encoder-meta';
import * as avif from '../../codecs/avif/encoder-meta';
import * as browserPNG from '../../codecs/browser-png/encoder-meta';
import * as browserJPEG from '../../codecs/browser-jpeg/encoder-meta';
import * as browserWebP from '../../codecs/browser-webp/encoder-meta';
import * as browserGIF from '../../codecs/browser-gif/encoder-meta';
import * as browserTIFF from '../../codecs/browser-tiff/encoder-meta';
import * as browserJP2 from '../../codecs/browser-jp2/encoder-meta';
import * as browserBMP from '../../codecs/browser-bmp/encoder-meta';
import * as browserPDF from '../../codecs/browser-pdf/encoder-meta';
import {
EncoderState,
EncoderType,
EncoderOptions,
encoderMap,
} from '../../codecs/encoders';
import {
PreprocessorState,
defaultPreprocessorState,
} from '../../codecs/preprocessors';
import { decodeImage } from '../../codecs/decoders';
import { cleanMerge, cleanSet } from '../../lib/clean-modify';
import Processor from '../../codecs/processor';
@@ -46,9 +30,10 @@ import {
InputProcessorState,
defaultInputProcessorState,
} from '../../codecs/input-processors';
import WorkerBridge from '../worker-bridge';
export interface SourceImage {
file: File | Fileish;
file: File;
decoded: ImageData;
processed: ImageData;
vectorImage?: HTMLImageElement;
@@ -62,20 +47,16 @@ interface SideSettings {
interface Side {
preprocessed?: ImageData;
file?: Fileish;
file?: File;
downloadUrl?: string;
data?: ImageData;
latestSettings: SideSettings;
encodedSettings?: SideSettings;
loading: boolean;
/** Counter of the latest bmp currently encoding */
loadingCounter: number;
/** Counter of the latest bmp encoded */
loadedCounter: number;
}
interface Props {
file: File | Fileish;
file: File;
showSnack: SnackBarElement['showSnackbar'];
onBack: () => void;
}
@@ -85,7 +66,6 @@ interface State {
sides: [Side, Side];
/** Source image load */
loading: boolean;
loadingCounter: number;
error?: string;
mobileView: boolean;
}
@@ -94,6 +74,30 @@ interface UpdateImageOptions {
skipPreprocessing?: boolean;
}
async function decodeImage(
signal: AbortSignal,
blob: Blob,
workerBridge: WorkerBridge,
): Promise<ImageData> {
const mimeType = await sniffMimeType(blob);
const canDecode = await canDecodeImageType(mimeType);
try {
if (!canDecode) {
if (mimeType === 'image/avif') {
return await workerBridge.avifDecode(signal, blob);
}
if (mimeType === 'image/webp') {
return await workerBridge.webpDecode(signal, blob);
}
// If it's not one of those types, fall through and try built-in decoding for a laugh.
}
return await builtinDecode(blob);
} catch (err) {
throw Error("Couldn't decode image");
}
}
async function processInput(
data: ImageData,
inputProcessData: InputProcessorState,
@@ -154,7 +158,7 @@ async function compressImage(
encodeData: EncoderState,
sourceFilename: string,
processor: Processor,
): Promise<Fileish> {
): Promise<File> {
const compressedData = await (() => {
switch (encodeData.type) {
case oxiPNG.type:
@@ -188,7 +192,7 @@ async function compressImage(
const encoder = encoderMap[encodeData.type];
return new Fileish(
return new File(
[compressedData],
sourceFilename.replace(/.[^.]*$/, `.${encoder.extension}`),
{ type: encoder.mimeType },
@@ -300,10 +304,9 @@ export default class Compress extends Component<Props, State> {
import('../../lib/sw-bridge').then(({ mainAppLoaded }) => mainAppLoaded());
}
@bind
private onMobileWidthChange() {
private onMobileWidthChange = () => {
this.setState({ mobileView: this.widthQuery.matches });
}
};
private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void {
this.setState({
@@ -412,10 +415,9 @@ export default class Compress extends Component<Props, State> {
});
}
@bind
private async onInputProcessorChange(
private onInputProcessorChange = async (
options: InputProcessorState,
): Promise<void> {
): Promise<void> => {
const source = this.state.source;
if (!source) return;
@@ -470,10 +472,9 @@ export default class Compress extends Component<Props, State> {
this.props.showSnack('Processing error');
this.setState({ loading: false });
}
}
};
@bind
private async updateFile(file: File | Fileish) {
private updateFile = async (file: File) => {
const loadingCounter = this.state.loadingCounter + 1;
// Either processor is good enough here.
const processor = this.leftProcessor;
@@ -545,7 +546,7 @@ export default class Compress extends Component<Props, State> {
this.props.showSnack('Invalid image');
this.setState({ loading: false });
}
}
};
/**
* Debounce the heavy lifting of updateImage.
@@ -589,7 +590,7 @@ export default class Compress extends Component<Props, State> {
const side = sides[index];
const settings = side.latestSettings;
let file: File | Fileish | undefined;
let file: File | undefined;
let preprocessed: ImageData | undefined;
let data: ImageData | undefined;
const cacheResult = this.encodeCache.match(

View File

@@ -1,8 +1,6 @@
import { EncoderState, ProcessorState } from '../feature-meta';
import { shallowEqual } from '../../util';
import * as identity from '../../codecs/identity/encoder-meta';
interface CacheResult {
preprocessed: ImageData;
data: ImageData;
@@ -21,8 +19,9 @@ export default class ResultCache {
private readonly _entries: CacheEntry[] = [];
add(entry: CacheEntry) {
if (entry.encoderState.type === identity.type)
if (entry.encoderState.type === 'identity') {
throw Error('Cannot cache identity encodes');
}
// Add the new entry to the start
this._entries.unshift(entry);
// Remove the last entry if we're now bigger than SIZE
@@ -46,13 +45,15 @@ export default class ResultCache {
(processorState as any)[prop],
(entry.processorState as any)[prop],
)
)
) {
return false;
}
}
// Check detailed encoder options
if (!shallowEqual(encoderState.options, entry.encoderState.options))
if (!shallowEqual(encoderState.options, entry.encoderState.options)) {
return false;
}
return true;
});

View File

@@ -42,7 +42,7 @@
flex-flow: column;
overflow: hidden;
// Reorder so headings appear after content:
/* Reorder so headings appear after content: */
& > :nth-child(1) {
order: 2;
margin-bottom: 10px;
@@ -71,5 +71,5 @@
}
:focus .expand-icon {
fill: #34B9EB;
fill: #34b9eb;
}

View File

@@ -1,3 +1,322 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Compare two objects, returning a boolean indicating if
* they have the same properties and strictly equal values.
*/
export function shallowEqual(one: any, two: any) {
for (const i in one) if (one[i] !== two[i]) return false;
for (const i in two) if (!(i in one)) return false;
return true;
}
/** Replace the contents of a canvas with the given data */
export function drawDataToCanvas(canvas: HTMLCanvasElement, data: ImageData) {
const ctx = canvas.getContext('2d');
if (!ctx) throw Error('Canvas not initialized');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.putImageData(data, 0, 0);
}
/**
* 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');
canvas.width = data.width;
canvas.height = data.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw Error('Canvas not initialized');
ctx.putImageData(data, 0, 0);
let blob: Blob | null;
if ('toBlob' in canvas) {
blob = await new Promise<Blob | null>((r) =>
canvas.toBlob(r, type, quality),
);
} else {
// Welcome to Edge.
// TypeScript thinks `canvas` is 'never', so it needs casting.
const dataUrl = (canvas as HTMLCanvasElement).toDataURL(type, quality);
const result = /data:([^;]+);base64,(.*)$/.exec(dataUrl);
if (!result) throw Error('Data URL reading failed');
const outputType = result[1];
const binaryStr = atob(result[2]);
const data = new Uint8Array(binaryStr.length);
for (let i = 0; i < data.length; i += 1) {
data[i] = binaryStr.charCodeAt(i);
}
blob = new Blob([data], { type: outputType });
}
if (!blob) throw Error('Encoding failed');
return blob;
}
async function decodeImage(url: string): Promise<HTMLImageElement> {
const img = new Image();
img.decoding = 'async';
img.src = url;
const loaded = new Promise((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(Error('Image loading error'));
});
if (img.decode) {
// Nice off-thread way supported in Safari/Chrome.
// Safari throws on decode if the source is SVG.
// https://bugs.webkit.org/show_bug.cgi?id=188347
await img.decode().catch(() => null);
}
// Always await loaded, as we may have bailed due to the Safari bug above.
await loaded;
return img;
}
/** Caches results from canDecodeImageType */
const canDecodeCache = new Map<string, Promise<boolean>>();
/**
* Tests whether the browser supports a particular image mime type.
*
* @param type Mimetype
* @example await canDecodeImageType('image/avif')
*/
export function canDecodeImageType(type: string): Promise<boolean> {
if (!canDecodeCache.has(type)) {
const resultPromise = (async () => {
const picture = document.createElement('picture');
const img = document.createElement('img');
const source = document.createElement('source');
source.srcset = 'data:,x';
source.type = type;
picture.append(source, img);
// Wait a single microtick just for the `img.currentSrc` to get populated.
await 0;
// At this point `img.currentSrc` will contain "data:,x" if format is supported and ""
// otherwise.
return !!img.currentSrc;
})();
canDecodeCache.set(type, resultPromise);
}
return canDecodeCache.get(type)!;
}
export function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
return new Response(blob).arrayBuffer();
}
export function blobToText(blob: Blob): Promise<string> {
return new Response(blob).text();
}
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[LX ]/, 'image/webp'],
[/^\x00\x00\x00 ftypavif\x00\x00\x00\x00/, 'image/avif'],
]);
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 async function blobToImg(blob: Blob): Promise<HTMLImageElement> {
const url = URL.createObjectURL(blob);
try {
return await decodeImage(url);
} finally {
URL.revokeObjectURL(url);
}
}
interface DrawableToImageDataOptions {
width?: number;
height?: number;
sx?: number;
sy?: number;
sw?: number;
sh?: number;
}
export function drawableToImageData(
drawable: ImageBitmap | HTMLImageElement,
opts: DrawableToImageDataOptions = {},
): ImageData {
const {
width = drawable.width,
height = drawable.height,
sx = 0,
sy = 0,
sw = drawable.width,
sh = drawable.height,
} = opts;
// Make canvas same size as image
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
// Draw image onto canvas
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Could not create canvas context');
ctx.drawImage(drawable, sx, sy, sw, sh, 0, 0, width, height);
return ctx.getImageData(0, 0, width, height);
}
export async function builtinDecode(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 BuiltinResizeMethod = 'pixelated' | 'low' | 'medium' | 'high';
export function builtinResize(
data: ImageData,
sx: number,
sy: number,
sw: number,
sh: number,
dw: number,
dh: number,
method: BuiltinResizeMethod,
): 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);
}
/**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/
export function inputFieldValueAsNumber(
field: any,
defaultVal: number = 0,
): number {
if (!field) return defaultVal;
return Number(inputFieldValue(field));
}
/**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/
export function inputFieldCheckedAsNumber(
field: any,
defaultVal: number = 0,
): number {
if (!field) return defaultVal;
return Number(inputFieldChecked(field));
}
/**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/
export function inputFieldChecked(
field: any,
defaultVal: boolean = false,
): boolean {
if (!field) return defaultVal;
return (field as HTMLInputElement).checked;
}
/**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/
export function inputFieldValue(field: any, defaultVal: string = ''): string {
if (!field) return defaultVal;
return (field as HTMLInputElement).value;
}
/**
* Creates a promise that resolves when the user types the konami code.
*/
export function konami(): Promise<void> {
return new Promise((resolve) => {
// Keycodes for: ↑ ↑ ↓ ↓ ← → ← → B A
const expectedPattern = '38384040373937396665';
let rollingPattern = '';
const listener = (event: KeyboardEvent) => {
rollingPattern += event.keyCode;
rollingPattern = rollingPattern.slice(-expectedPattern.length);
if (rollingPattern === expectedPattern) {
window.removeEventListener('keydown', listener);
resolve();
}
};
window.addEventListener('keydown', listener);
});
}
interface TransitionOptions {
from?: number;
to?: number;
@@ -41,6 +360,13 @@ export async function transitionHeight(
});
}
/**
* Simple event listener that prevents the default.
*/
export function preventDefault(event: Event) {
event.preventDefault();
}
/**
* Take a signal and promise, and returns a promise that rejects with an AbortError if the abort is
* signalled, otherwise resolves with the promise.
@@ -61,11 +387,16 @@ export async function abortable<T>(
}
/**
* Compare two objects, returning a boolean indicating if they have the same properties and strictly
* equal values.
* Test whether <canvas> can encode to a particular type.
*/
export function shallowEqual(one: any, two: any) {
for (const i in one) if (one[i] !== two[i]) return false;
for (const i in two) if (!(i in one)) return false;
return true;
export async function canvasEncodeTest(mimeType: string): Promise<boolean> {
try {
const blob = await canvasEncode(new ImageData(1, 1), mimeType);
// According to the spec, the blob should be null if the format isn't supported…
if (!blob) return false;
// …but Safari & Firefox fall back to PNG, so we need to check the mime type.
return blob.type === mimeType;
} catch (err) {
return false;
}
}

View File

@@ -4,5 +4,12 @@
"lib": ["esnext", "dom", "dom.iterable"],
"types": []
},
"references": [{ "path": "../features-worker" }, { "path": "../shared" }]
"references": [
{ "path": "../features-worker" },
{ "path": "../shared" },
{ "path": "../features/encoders/identity/shared" },
{ "path": "../features/encoders/browserGIF/shared" },
{ "path": "../features/encoders/browserJPEG/shared" },
{ "path": "../features/encoders/browserPNG/shared" }
]
}

View File

@@ -1,367 +0,0 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** Compare two objects, returning a boolean indicating if
* they have the same properties and strictly equal values.
*/
export function shallowEqual(one: any, two: any) {
for (const i in one) if (one[i] !== two[i]) return false;
for (const i in two) if (!(i in one)) return false;
return true;
}
/** Replace the contents of a canvas with the given data */
export function drawDataToCanvas(canvas: HTMLCanvasElement, data: ImageData) {
const ctx = canvas.getContext('2d');
if (!ctx) throw Error('Canvas not initialized');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.putImageData(data, 0, 0);
}
/**
* 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');
canvas.width = data.width;
canvas.height = data.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw Error('Canvas not initialized');
ctx.putImageData(data, 0, 0);
let blob: Blob | null;
if ('toBlob' in canvas) {
blob = await new Promise<Blob | null>((r) =>
canvas.toBlob(r, type, quality),
);
} else {
// Welcome to Edge.
// TypeScript thinks `canvas` is 'never', so it needs casting.
const dataUrl = (canvas as HTMLCanvasElement).toDataURL(type, quality);
const result = /data:([^;]+);base64,(.*)$/.exec(dataUrl);
if (!result) throw Error('Data URL reading failed');
const outputType = result[1];
const binaryStr = atob(result[2]);
const data = new Uint8Array(binaryStr.length);
for (let i = 0; i < data.length; i += 1) {
data[i] = binaryStr.charCodeAt(i);
}
blob = new Blob([data], { type: outputType });
}
if (!blob) throw Error('Encoding failed');
return blob;
}
async function decodeImage(url: string): Promise<HTMLImageElement> {
const img = new Image();
img.decoding = 'async';
img.src = url;
const loaded = new Promise((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(Error('Image loading error'));
});
if (img.decode) {
// Nice off-thread way supported in Safari/Chrome.
// Safari throws on decode if the source is SVG.
// https://bugs.webkit.org/show_bug.cgi?id=188347
await img.decode().catch(() => null);
}
// Always await loaded, as we may have bailed due to the Safari bug above.
await loaded;
return img;
}
/** Caches results from canDecodeImageType */
const canDecodeCache = new Map<string, Promise<boolean>>();
/**
* Tests whether the browser supports a particular image mime type.
*
* @param type Mimetype
* @example await canDecodeImageType('image/avif')
*/
export function canDecodeImageType(type: string): Promise<boolean> {
if (!canDecodeCache.has(type)) {
const resultPromise = (async () => {
const picture = document.createElement('picture');
const img = document.createElement('img');
const source = document.createElement('source');
source.srcset = 'data:,x';
source.type = type;
picture.append(source, img);
// Wait a single microtick just for the `img.currentSrc` to get populated.
await 0;
// At this point `img.currentSrc` will contain "data:,x" if format is supported and ""
// otherwise.
return !!img.currentSrc;
})();
canDecodeCache.set(type, resultPromise);
}
return canDecodeCache.get(type)!;
}
export function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
return new Response(blob).arrayBuffer();
}
export function blobToText(blob: Blob): Promise<string> {
return new Response(blob).text();
}
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[LX ]/, 'image/webp'],
[/^\x00\x00\x00 ftypavif\x00\x00\x00\x00/, 'image/avif'],
]);
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 async function blobToImg(blob: Blob): Promise<HTMLImageElement> {
const url = URL.createObjectURL(blob);
try {
return await decodeImage(url);
} finally {
URL.revokeObjectURL(url);
}
}
interface DrawableToImageDataOptions {
width?: number;
height?: number;
sx?: number;
sy?: number;
sw?: number;
sh?: number;
}
export function drawableToImageData(
drawable: ImageBitmap | HTMLImageElement,
opts: DrawableToImageDataOptions = {},
): ImageData {
const {
width = drawable.width,
height = drawable.height,
sx = 0,
sy = 0,
sw = drawable.width,
sh = drawable.height,
} = opts;
// Make canvas same size as image
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
// Draw image onto canvas
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Could not create canvas context');
ctx.drawImage(drawable, sx, sy, sw, sh, 0, 0, width, height);
return ctx.getImageData(0, 0, width, height);
}
export async function builtinDecode(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 BuiltinResizeMethod = 'pixelated' | 'low' | 'medium' | 'high';
export function builtinResize(
data: ImageData,
sx: number,
sy: number,
sw: number,
sh: number,
dw: number,
dh: number,
method: BuiltinResizeMethod,
): 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);
}
/**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/
export function inputFieldValueAsNumber(
field: any,
defaultVal: number = 0,
): number {
if (!field) return defaultVal;
return Number(inputFieldValue(field));
}
/**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/
export function inputFieldCheckedAsNumber(
field: any,
defaultVal: number = 0,
): number {
if (!field) return defaultVal;
return Number(inputFieldChecked(field));
}
/**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/
export function inputFieldChecked(
field: any,
defaultVal: boolean = false,
): boolean {
if (!field) return defaultVal;
return (field as HTMLInputElement).checked;
}
/**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/
export function inputFieldValue(field: any, defaultVal: string = ''): string {
if (!field) return defaultVal;
return (field as HTMLInputElement).value;
}
/**
* Creates a promise that resolves when the user types the konami code.
*/
export function konami(): Promise<void> {
return new Promise((resolve) => {
// Keycodes for: ↑ ↑ ↓ ↓ ← → ← → B A
const expectedPattern = '38384040373937396665';
let rollingPattern = '';
const listener = (event: KeyboardEvent) => {
rollingPattern += event.keyCode;
rollingPattern = rollingPattern.slice(-expectedPattern.length);
if (rollingPattern === expectedPattern) {
window.removeEventListener('keydown', listener);
resolve();
}
};
window.addEventListener('keydown', listener);
});
}
interface TransitionOptions {
from?: number;
to?: number;
duration?: number;
easing?: string;
}
export async function transitionHeight(
el: HTMLElement,
opts: TransitionOptions,
): Promise<void> {
const {
from = el.getBoundingClientRect().height,
to = el.getBoundingClientRect().height,
duration = 1000,
easing = 'ease-in-out',
} = opts;
if (from === to || duration === 0) {
el.style.height = to + 'px';
return;
}
el.style.height = from + 'px';
// Force a style calc so the browser picks up the start value.
getComputedStyle(el).transform;
el.style.transition = `height ${duration}ms ${easing}`;
el.style.height = to + 'px';
return new Promise<void>((resolve) => {
const listener = (event: Event) => {
if (event.target !== el) return;
el.style.transition = '';
el.removeEventListener('transitionend', listener);
el.removeEventListener('transitioncancel', listener);
resolve();
};
el.addEventListener('transitionend', listener);
el.addEventListener('transitioncancel', listener);
});
}
/**
* Simple event listener that prevents the default.
*/
export function preventDefault(event: Event) {
event.preventDefault();
}

View File

@@ -12,16 +12,20 @@
*/
import avifDecoder, { AVIFModule } from 'codecs/avif/dec/avif_dec';
import wasmUrl from 'url:codecs/avif/dec/avif_dec.wasm';
import { initEmscriptenModule } from 'features/util';
import { initEmscriptenModule, blobToArrayBuffer } from 'features/util';
let emscriptenModule: Promise<AVIFModule>;
export default async function decode(data: ArrayBuffer): Promise<ImageData> {
export default async function decode(blob: Blob): Promise<ImageData> {
if (!emscriptenModule) {
emscriptenModule = initEmscriptenModule(avifDecoder, wasmUrl);
}
const module = await emscriptenModule;
const [module, data] = await Promise.all([
emscriptenModule,
blobToArrayBuffer(blob),
]);
const result = module.decode(data);
if (!result) throw new Error('Decoding error');
return result;

View File

@@ -12,16 +12,20 @@
*/
import webpDecoder, { WebPModule } from 'codecs/webp/dec/webp_dec';
import wasmUrl from 'url:codecs/webp/dec/webp_dec.wasm';
import { initEmscriptenModule } from 'features/util';
import { initEmscriptenModule, blobToArrayBuffer } from 'features/util';
let emscriptenModule: Promise<WebPModule>;
export default async function decode(data: ArrayBuffer): Promise<ImageData> {
export default async function decode(blob: Blob): Promise<ImageData> {
if (!emscriptenModule) {
emscriptenModule = initEmscriptenModule(webpDecoder, wasmUrl);
}
const module = await emscriptenModule;
const [module, data] = await Promise.all([
emscriptenModule,
blobToArrayBuffer(blob),
]);
const result = module.decode(data);
if (!result) throw new Error('Decoding error');
return result;

View File

@@ -0,0 +1,5 @@
import { canvasEncodeTest, canvasEncode } from 'client/lazy-app/util';
import { mimeType } from '../shared/meta';
export const featureTest = () => canvasEncodeTest(mimeType);
export const encode = (data: ImageData) => canvasEncode(data, mimeType);

View File

@@ -0,0 +1,13 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference path="../../../../../missing-types.d.ts" />

View File

@@ -0,0 +1,13 @@
{
"extends": "../../../../../generic-tsconfig.json",
"compilerOptions": {
"lib": ["esnext", "dom", "dom.iterable"],
"types": []
},
"include": [
"./*.ts",
"../../../../client/lazy-app/util.ts",
"../shared/*.ts"
],
"references": [{ "path": "../shared" }]
}

View File

@@ -0,0 +1,18 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface EncodeOptions {}
export const label = 'Browser GIF';
export const mimeType = 'image/gif';
export const extension = 'gif';
export const defaultOptions: EncodeOptions = {};

View File

@@ -0,0 +1,13 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference path="../../../../../missing-types.d.ts" />

View File

@@ -0,0 +1,7 @@
{
"extends": "../../../../../generic-tsconfig.json",
"compilerOptions": {
"lib": ["webworker", "esnext"]
},
"references": [{ "path": "../../../" }]
}

View File

@@ -0,0 +1,6 @@
import { canvasEncode } from 'client/lazy-app/util';
import { mimeType, EncodeOptions } from '../shared/meta';
export function encode(data: ImageData, { quality }: EncodeOptions) {
return canvasEncode(data, mimeType, quality);
}

View File

@@ -0,0 +1,13 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference path="../../../../../missing-types.d.ts" />

View File

@@ -0,0 +1,13 @@
{
"extends": "../../../../../generic-tsconfig.json",
"compilerOptions": {
"lib": ["esnext", "dom", "dom.iterable"],
"types": []
},
"include": [
"./*.ts",
"../../../../client/lazy-app/util.ts",
"../shared/*.ts"
],
"references": [{ "path": "../shared" }]
}

View File

@@ -0,0 +1,20 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface EncodeOptions {
quality: number;
}
export const label = 'Browser JPEG';
export const mimeType = 'image/jpeg';
export const extension = 'jpg';
export const defaultOptions: EncodeOptions = { quality: 0.75 };

View File

@@ -0,0 +1,13 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference path="../../../../../missing-types.d.ts" />

View File

@@ -0,0 +1,7 @@
{
"extends": "../../../../../generic-tsconfig.json",
"compilerOptions": {
"lib": ["webworker", "esnext"]
},
"references": [{ "path": "../../../" }]
}

View File

@@ -0,0 +1,4 @@
import { canvasEncode } from 'client/lazy-app/util';
import { mimeType } from '../shared/meta';
export const encode = (data: ImageData) => canvasEncode(data, mimeType);

View File

@@ -0,0 +1,13 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference path="../../../../../missing-types.d.ts" />

View File

@@ -0,0 +1,13 @@
{
"extends": "../../../../../generic-tsconfig.json",
"compilerOptions": {
"lib": ["esnext", "dom", "dom.iterable"],
"types": []
},
"include": [
"./*.ts",
"../../../../client/lazy-app/util.ts",
"../shared/*.ts"
],
"references": [{ "path": "../shared" }]
}

View File

@@ -0,0 +1,18 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface EncodeOptions {}
export const label = 'Browser PNG';
export const mimeType = 'image/png';
export const extension = 'png';
export const defaultOptions: EncodeOptions = {};

View File

@@ -0,0 +1,13 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference path="../../../../../missing-types.d.ts" />

View File

@@ -0,0 +1,7 @@
{
"extends": "../../../../../generic-tsconfig.json",
"compilerOptions": {
"lib": ["webworker", "esnext"]
},
"references": [{ "path": "../../../" }]
}

View File

@@ -0,0 +1,15 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface EncodeOptions {}
export const label = 'Original image';
export const defaultOptions: EncodeOptions = {};

View File

@@ -0,0 +1,13 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference path="../../../../../missing-types.d.ts" />

View File

@@ -0,0 +1,7 @@
{
"extends": "../../../../../generic-tsconfig.json",
"compilerOptions": {
"lib": ["webworker", "esnext"]
},
"references": [{ "path": "../../../" }]
}

View File

@@ -2,7 +2,7 @@ import {
builtinResize,
BuiltinResizeMethod,
drawableToImageData,
} from 'client/util';
} from 'client/lazy-app/util';
import { BrowserResizeOptions, VectorResizeOptions } from '../shared/meta';
import { getContainOffsets } from '../shared/util';

View File

@@ -4,6 +4,10 @@
"lib": ["esnext", "dom", "dom.iterable"],
"types": []
},
"include": ["./*.ts", "../../../../client/util.ts", "../shared/*.ts"],
"include": [
"./*.ts",
"../../../../client/lazy-app/util.ts",
"../shared/*.ts"
],
"references": [{ "path": "../shared" }]
}

View File

@@ -20,3 +20,7 @@ export function initEmscriptenModule<T extends EmscriptenWasm.Module>(
locateFile: () => wasmUrl,
});
}
export function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
return new Response(blob).arrayBuffer();
}