diff --git a/generic-tsconfig.json b/generic-tsconfig.json index 9b13e884..1b41eb10 100644 --- a/generic-tsconfig.json +++ b/generic-tsconfig.json @@ -15,6 +15,7 @@ "allowSyntheticDefaultImports": true, "paths": { "static-build/*": ["src/static-build/*"], + "client/*": ["src/client/*"], "features/*": ["src/features/*"] } } diff --git a/src/client/util.ts b/src/client/util.ts new file mode 100644 index 00000000..c49f80f3 --- /dev/null +++ b/src/client/util.ts @@ -0,0 +1,367 @@ +/** + * 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 { + 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((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 { + 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>(); + +/** + * Tests whether the browser supports a particular image mime type. + * + * @param type Mimetype + * @example await canDecodeImageType('image/avif') + */ +export function canDecodeImageType(type: string): Promise { + 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 { + return new Response(blob).arrayBuffer(); +} + +export function blobToText(blob: Blob): Promise { + return new Response(blob).text(); +} + +const magicNumberToMimeType = new Map([ + [/^%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 { + 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 { + 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 { + // 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 { + 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 { + 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((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(); +} diff --git a/src/features/processors/resize/client/index.ts b/src/features/processors/resize/client/index.ts new file mode 100644 index 00000000..523ef508 --- /dev/null +++ b/src/features/processors/resize/client/index.ts @@ -0,0 +1,55 @@ +import { + builtinResize, + BuiltinResizeMethod, + drawableToImageData, +} from 'client/util'; +import { BrowserResizeOptions, VectorResizeOptions } from '../shared'; +import { getContainOffsets } from '../shared/util'; + +export function browserResize( + data: ImageData, + opts: BrowserResizeOptions, +): ImageData { + let sx = 0; + let sy = 0; + let sw = data.width; + let sh = data.height; + + if (opts.fitMethod === 'contain') { + ({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height)); + } + + return builtinResize( + data, + sx, + sy, + sw, + sh, + opts.width, + opts.height, + opts.method.slice('browser-'.length) as BuiltinResizeMethod, + ); +} + +export function vectorResize( + data: HTMLImageElement, + opts: VectorResizeOptions, +): ImageData { + let sx = 0; + let sy = 0; + let sw = data.width; + let sh = data.height; + + if (opts.fitMethod === 'contain') { + ({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height)); + } + + return drawableToImageData(data, { + sx, + sy, + sw, + sh, + width: opts.width, + height: opts.height, + }); +} diff --git a/src/features/processors/resize/client/missing-types.d.ts b/src/features/processors/resize/client/missing-types.d.ts new file mode 100644 index 00000000..c729fd74 --- /dev/null +++ b/src/features/processors/resize/client/missing-types.d.ts @@ -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. + */ +/// diff --git a/src/features/processors/resize/client/tsconfig.json b/src/features/processors/resize/client/tsconfig.json new file mode 100644 index 00000000..4da1791e --- /dev/null +++ b/src/features/processors/resize/client/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../../generic-tsconfig.json", + "compilerOptions": { + "lib": ["esnext", "dom", "dom.iterable"], + "types": [] + }, + "include": ["./*.ts", "../../../../client/util.ts", "../shared/*.ts"], + "references": [{ "path": "../shared" }] +} diff --git a/src/features/processors/resize/shared/index.ts b/src/features/processors/resize/shared/index.ts new file mode 100644 index 00000000..f142d218 --- /dev/null +++ b/src/features/processors/resize/shared/index.ts @@ -0,0 +1,56 @@ +/** + * 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. + */ + +type BrowserResizeMethods = + | 'browser-pixelated' + | 'browser-low' + | 'browser-medium' + | 'browser-high'; +type WorkerResizeMethods = + | 'triangle' + | 'catrom' + | 'mitchell' + | 'lanczos3' + | 'hqx'; +const workerResizeMethods: WorkerResizeMethods[] = [ + 'triangle', + 'catrom', + 'mitchell', + 'lanczos3', + 'hqx', +]; + +export type ResizeOptions = + | BrowserResizeOptions + | WorkerResizeOptions + | VectorResizeOptions; + +export interface ResizeOptionsCommon { + width: number; + height: number; + fitMethod: 'stretch' | 'contain'; +} + +export interface BrowserResizeOptions extends ResizeOptionsCommon { + method: BrowserResizeMethods; +} + +export interface WorkerResizeOptions extends ResizeOptionsCommon { + method: WorkerResizeMethods; + premultiply: boolean; + linearRGB: boolean; +} + +export interface VectorResizeOptions extends ResizeOptionsCommon { + method: 'vector'; +} diff --git a/src/features/processors/resize/shared/missing-types.d.ts b/src/features/processors/resize/shared/missing-types.d.ts new file mode 100644 index 00000000..c729fd74 --- /dev/null +++ b/src/features/processors/resize/shared/missing-types.d.ts @@ -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. + */ +/// diff --git a/src/features/processors/resize/shared/tsconfig.json b/src/features/processors/resize/shared/tsconfig.json new file mode 100644 index 00000000..bea39d16 --- /dev/null +++ b/src/features/processors/resize/shared/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../../generic-tsconfig.json", + "compilerOptions": { + "lib": ["webworker", "esnext"] + }, + "references": [{ "path": "../../../" }] +} diff --git a/src/features/processors/resize/shared/util.ts b/src/features/processors/resize/shared/util.ts new file mode 100644 index 00000000..163b5ebd --- /dev/null +++ b/src/features/processors/resize/shared/util.ts @@ -0,0 +1,32 @@ +/** + * 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 function getContainOffsets( + sw: number, + sh: number, + dw: number, + dh: number, +) { + const currentAspect = sw / sh; + const endAspect = dw / dh; + + if (endAspect > currentAspect) { + const newSh = sw / endAspect; + const newSy = (sh - newSh) / 2; + return { sw, sh: newSh, sx: 0, sy: newSy }; + } + + const newSw = sh * endAspect; + const newSx = (sw - newSw) / 2; + return { sh, sw: newSw, sx: newSx, sy: 0 }; +} diff --git a/src/features/processors/resize/worker/missing-types.d.ts b/src/features/processors/resize/worker/missing-types.d.ts new file mode 100644 index 00000000..c729fd74 --- /dev/null +++ b/src/features/processors/resize/worker/missing-types.d.ts @@ -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. + */ +/// diff --git a/src/features/processors/resize/worker/resize.ts b/src/features/processors/resize/worker/resize.ts new file mode 100644 index 00000000..aed8d267 --- /dev/null +++ b/src/features/processors/resize/worker/resize.ts @@ -0,0 +1,73 @@ +import { WorkerResizeOptions } from '../shared'; +import { getContainOffsets } from '../shared/util'; +import { resize as codecResize } from 'codecs/resize/pkg'; + +function crop( + data: ImageData, + sx: number, + sy: number, + sw: number, + sh: number, +): ImageData { + const inputPixels = new Uint32Array(data.data.buffer); + + // Copy within the same buffer for speed and memory efficiency. + for (let y = 0; y < sh; y += 1) { + const start = (y + sy) * data.width + sx; + inputPixels.copyWithin(y * sw, start, start + sw); + } + + return new ImageData( + new Uint8ClampedArray(inputPixels.buffer.slice(0, sw * sh * 4)), + sw, + sh, + ); +} + +/** Resize methods by index */ +const resizeMethods: WorkerResizeOptions['method'][] = [ + 'triangle', + 'catrom', + 'mitchell', + 'lanczos3', +]; + +export default async function resize( + data: ImageData, + opts: WorkerResizeOptions, +): Promise { + let input = data; + + if (opts.fitMethod === 'contain') { + const { sx, sy, sw, sh } = getContainOffsets( + data.width, + data.height, + opts.width, + opts.height, + ); + input = crop( + input, + Math.round(sx), + Math.round(sy), + Math.round(sw), + Math.round(sh), + ); + } + + const result = codecResize( + new Uint8Array(input.data.buffer), + input.width, + input.height, + opts.width, + opts.height, + resizeMethods.indexOf(opts.method), + opts.premultiply, + opts.linearRGB, + ); + + return new ImageData( + new Uint8ClampedArray(result.buffer), + opts.width, + opts.height, + ); +} diff --git a/src/features/processors/resize/worker/tsconfig.json b/src/features/processors/resize/worker/tsconfig.json new file mode 100644 index 00000000..959fe910 --- /dev/null +++ b/src/features/processors/resize/worker/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../../generic-tsconfig.json", + "compilerOptions": { + "lib": ["webworker", "esnext"] + }, + "references": [{ "path": "../../../" }, { "path": "../shared" }] +}