diff --git a/lib/feature-plugin.js b/lib/feature-plugin.js index a976a246..91198641 100644 --- a/lib/feature-plugin.js +++ b/lib/feature-plugin.js @@ -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), ]); diff --git a/src/client/lazy-app/Compress/index.tsx b/src/client/lazy-app/Compress/index.tsx index e332d5b9..0f39dc14 100644 --- a/src/client/lazy-app/Compress/index.tsx +++ b/src/client/lazy-app/Compress/index.tsx @@ -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 { + 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 { +): Promise { 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 { 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 { }); } - @bind - private async onInputProcessorChange( + private onInputProcessorChange = async ( options: InputProcessorState, - ): Promise { + ): Promise => { const source = this.state.source; if (!source) return; @@ -470,10 +472,9 @@ export default class Compress extends Component { 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 { 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 { 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( diff --git a/src/client/lazy-app/Compress/result-cache.ts b/src/client/lazy-app/Compress/result-cache.ts index bfb110dd..f5718fcb 100644 --- a/src/client/lazy-app/Compress/result-cache.ts +++ b/src/client/lazy-app/Compress/result-cache.ts @@ -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; }); diff --git a/src/client/lazy-app/Compress/style.scss b/src/client/lazy-app/Compress/style.css similarity index 94% rename from src/client/lazy-app/Compress/style.scss rename to src/client/lazy-app/Compress/style.css index 3afe8eda..545a91eb 100644 --- a/src/client/lazy-app/Compress/style.scss +++ b/src/client/lazy-app/Compress/style.css @@ -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; } diff --git a/src/client/lazy-app/util.ts b/src/client/lazy-app/util.ts index b583f016..6e3a84e9 100644 --- a/src/client/lazy-app/util.ts +++ b/src/client/lazy-app/util.ts @@ -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 { + 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; @@ -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( } /** - * Compare two objects, returning a boolean indicating if they have the same properties and strictly - * equal values. + * Test whether 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 { + 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; + } } diff --git a/src/client/tsconfig.json b/src/client/tsconfig.json index d2b51875..322480a8 100644 --- a/src/client/tsconfig.json +++ b/src/client/tsconfig.json @@ -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" } + ] } diff --git a/src/client/util.ts b/src/client/util.ts deleted file mode 100644 index c49f80f3..00000000 --- a/src/client/util.ts +++ /dev/null @@ -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 { - 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/decoders/avif/worker/avifDecode.ts b/src/features/decoders/avif/worker/avifDecode.ts index 68809b2b..f53b0cef 100644 --- a/src/features/decoders/avif/worker/avifDecode.ts +++ b/src/features/decoders/avif/worker/avifDecode.ts @@ -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; -export default async function decode(data: ArrayBuffer): Promise { +export default async function decode(blob: Blob): Promise { 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; diff --git a/src/features/decoders/webp/worker/webpDecode.ts b/src/features/decoders/webp/worker/webpDecode.ts index 8fa97993..adf54f58 100644 --- a/src/features/decoders/webp/worker/webpDecode.ts +++ b/src/features/decoders/webp/worker/webpDecode.ts @@ -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; -export default async function decode(data: ArrayBuffer): Promise { +export default async function decode(blob: Blob): Promise { 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; diff --git a/src/features/encoders/browserGIF/client/index.ts b/src/features/encoders/browserGIF/client/index.ts new file mode 100644 index 00000000..e0710da4 --- /dev/null +++ b/src/features/encoders/browserGIF/client/index.ts @@ -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); diff --git a/src/features/encoders/browserGIF/client/missing-types.d.ts b/src/features/encoders/browserGIF/client/missing-types.d.ts new file mode 100644 index 00000000..c729fd74 --- /dev/null +++ b/src/features/encoders/browserGIF/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/encoders/browserGIF/client/tsconfig.json b/src/features/encoders/browserGIF/client/tsconfig.json new file mode 100644 index 00000000..cb9a599c --- /dev/null +++ b/src/features/encoders/browserGIF/client/tsconfig.json @@ -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" }] +} diff --git a/src/features/encoders/browserGIF/shared/meta.ts b/src/features/encoders/browserGIF/shared/meta.ts new file mode 100644 index 00000000..d14040ab --- /dev/null +++ b/src/features/encoders/browserGIF/shared/meta.ts @@ -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 = {}; diff --git a/src/features/encoders/browserGIF/shared/missing-types.d.ts b/src/features/encoders/browserGIF/shared/missing-types.d.ts new file mode 100644 index 00000000..c729fd74 --- /dev/null +++ b/src/features/encoders/browserGIF/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/encoders/browserGIF/shared/tsconfig.json b/src/features/encoders/browserGIF/shared/tsconfig.json new file mode 100644 index 00000000..bea39d16 --- /dev/null +++ b/src/features/encoders/browserGIF/shared/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../../generic-tsconfig.json", + "compilerOptions": { + "lib": ["webworker", "esnext"] + }, + "references": [{ "path": "../../../" }] +} diff --git a/src/features/encoders/browserJPEG/client/index.ts b/src/features/encoders/browserJPEG/client/index.ts new file mode 100644 index 00000000..d4d34eb2 --- /dev/null +++ b/src/features/encoders/browserJPEG/client/index.ts @@ -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); +} diff --git a/src/features/encoders/browserJPEG/client/missing-types.d.ts b/src/features/encoders/browserJPEG/client/missing-types.d.ts new file mode 100644 index 00000000..c729fd74 --- /dev/null +++ b/src/features/encoders/browserJPEG/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/encoders/browserJPEG/client/tsconfig.json b/src/features/encoders/browserJPEG/client/tsconfig.json new file mode 100644 index 00000000..cb9a599c --- /dev/null +++ b/src/features/encoders/browserJPEG/client/tsconfig.json @@ -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" }] +} diff --git a/src/features/encoders/browserJPEG/shared/meta.ts b/src/features/encoders/browserJPEG/shared/meta.ts new file mode 100644 index 00000000..bf218680 --- /dev/null +++ b/src/features/encoders/browserJPEG/shared/meta.ts @@ -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 }; diff --git a/src/features/encoders/browserJPEG/shared/missing-types.d.ts b/src/features/encoders/browserJPEG/shared/missing-types.d.ts new file mode 100644 index 00000000..c729fd74 --- /dev/null +++ b/src/features/encoders/browserJPEG/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/encoders/browserJPEG/shared/tsconfig.json b/src/features/encoders/browserJPEG/shared/tsconfig.json new file mode 100644 index 00000000..bea39d16 --- /dev/null +++ b/src/features/encoders/browserJPEG/shared/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../../generic-tsconfig.json", + "compilerOptions": { + "lib": ["webworker", "esnext"] + }, + "references": [{ "path": "../../../" }] +} diff --git a/src/features/encoders/browserPNG/client/index.ts b/src/features/encoders/browserPNG/client/index.ts new file mode 100644 index 00000000..48ba7149 --- /dev/null +++ b/src/features/encoders/browserPNG/client/index.ts @@ -0,0 +1,4 @@ +import { canvasEncode } from 'client/lazy-app/util'; +import { mimeType } from '../shared/meta'; + +export const encode = (data: ImageData) => canvasEncode(data, mimeType); diff --git a/src/features/encoders/browserPNG/client/missing-types.d.ts b/src/features/encoders/browserPNG/client/missing-types.d.ts new file mode 100644 index 00000000..c729fd74 --- /dev/null +++ b/src/features/encoders/browserPNG/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/encoders/browserPNG/client/tsconfig.json b/src/features/encoders/browserPNG/client/tsconfig.json new file mode 100644 index 00000000..cb9a599c --- /dev/null +++ b/src/features/encoders/browserPNG/client/tsconfig.json @@ -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" }] +} diff --git a/src/features/encoders/browserPNG/shared/meta.ts b/src/features/encoders/browserPNG/shared/meta.ts new file mode 100644 index 00000000..6f73a5a4 --- /dev/null +++ b/src/features/encoders/browserPNG/shared/meta.ts @@ -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 = {}; diff --git a/src/features/encoders/browserPNG/shared/missing-types.d.ts b/src/features/encoders/browserPNG/shared/missing-types.d.ts new file mode 100644 index 00000000..c729fd74 --- /dev/null +++ b/src/features/encoders/browserPNG/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/encoders/browserPNG/shared/tsconfig.json b/src/features/encoders/browserPNG/shared/tsconfig.json new file mode 100644 index 00000000..bea39d16 --- /dev/null +++ b/src/features/encoders/browserPNG/shared/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../../generic-tsconfig.json", + "compilerOptions": { + "lib": ["webworker", "esnext"] + }, + "references": [{ "path": "../../../" }] +} diff --git a/src/features/encoders/identity/shared/meta.ts b/src/features/encoders/identity/shared/meta.ts new file mode 100644 index 00000000..d551c0e0 --- /dev/null +++ b/src/features/encoders/identity/shared/meta.ts @@ -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 = {}; diff --git a/src/features/encoders/identity/shared/missing-types.d.ts b/src/features/encoders/identity/shared/missing-types.d.ts new file mode 100644 index 00000000..c729fd74 --- /dev/null +++ b/src/features/encoders/identity/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/encoders/identity/shared/tsconfig.json b/src/features/encoders/identity/shared/tsconfig.json new file mode 100644 index 00000000..bea39d16 --- /dev/null +++ b/src/features/encoders/identity/shared/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../../generic-tsconfig.json", + "compilerOptions": { + "lib": ["webworker", "esnext"] + }, + "references": [{ "path": "../../../" }] +} diff --git a/src/features/processors/resize/client/index.ts b/src/features/processors/resize/client/index.ts index c8a554c3..0a9f519a 100644 --- a/src/features/processors/resize/client/index.ts +++ b/src/features/processors/resize/client/index.ts @@ -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'; diff --git a/src/features/processors/resize/client/tsconfig.json b/src/features/processors/resize/client/tsconfig.json index 4da1791e..cb9a599c 100644 --- a/src/features/processors/resize/client/tsconfig.json +++ b/src/features/processors/resize/client/tsconfig.json @@ -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" }] } diff --git a/src/features/util.ts b/src/features/util.ts index a87169a2..6c5e50f5 100644 --- a/src/features/util.ts +++ b/src/features/util.ts @@ -20,3 +20,7 @@ export function initEmscriptenModule( locateFile: () => wasmUrl, }); } + +export function blobToArrayBuffer(blob: Blob): Promise { + return new Response(blob).arrayBuffer(); +}