mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-11 16:26:20 +00:00
It was decoding transparency incorrectly (multiplication wrong?), and I'd rather not have a different code path there for the sake of it
293 lines
8.4 KiB
TypeScript
293 lines
8.4 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
import { drawableToImageData } from './canvas';
|
|
|
|
/** If render engine is Safari */
|
|
export const isSafari =
|
|
/Safari\//.test(navigator.userAgent) &&
|
|
!/Chrom(e|ium)\//.test(navigator.userAgent);
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
async function decodeImage(url: string): Promise<HTMLImageElement> {
|
|
const img = new Image();
|
|
img.decoding = 'async';
|
|
img.src = url;
|
|
const loaded = new Promise<void>((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 magicNumberMapInput = [
|
|
[/^%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 ]/s, 'image/webp'],
|
|
[/^\xF4\xFF\x6F/, 'image/webp2'],
|
|
[/^\x00\x00\x00 ftypavif\x00\x00\x00\x00/, 'image/avif'],
|
|
[/^\xff\x0a/, 'image/jxl'],
|
|
[/^\x00\x00\x00\x0cJXL \x0d\x0a\x87\x0a/, 'image/jxl'],
|
|
] as const;
|
|
|
|
export type ImageMimeTypes = typeof magicNumberMapInput[number][1];
|
|
|
|
const magicNumberToMimeType = new Map<RegExp, ImageMimeTypes>(
|
|
magicNumberMapInput,
|
|
);
|
|
|
|
export async function sniffMimeType(blob: Blob): Promise<ImageMimeTypes | ''> {
|
|
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);
|
|
}
|
|
}
|
|
|
|
export async function builtinDecode(
|
|
signal: AbortSignal,
|
|
blob: Blob,
|
|
): Promise<ImageData> {
|
|
assertSignal(signal);
|
|
|
|
// Prefer createImageBitmap as it's the off-thread option for Firefox.
|
|
const drawable = await abortable<HTMLImageElement | ImageBitmap>(
|
|
signal,
|
|
'createImageBitmap' in self ? createImageBitmap(blob) : blobToImg(blob),
|
|
);
|
|
return drawableToImageData(drawable);
|
|
}
|
|
|
|
/**
|
|
* @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();
|
|
}
|
|
|
|
/**
|
|
* Throw an abort error if a signal is aborted.
|
|
*/
|
|
export function assertSignal(signal: AbortSignal) {
|
|
if (signal.aborted) throw new DOMException('AbortError', 'AbortError');
|
|
}
|
|
|
|
/**
|
|
* Take a signal and promise, and returns a promise that rejects with an AbortError if the abort is
|
|
* signalled, otherwise resolves with the promise.
|
|
*/
|
|
export async function abortable<T>(
|
|
signal: AbortSignal,
|
|
promise: Promise<T>,
|
|
): Promise<T> {
|
|
assertSignal(signal);
|
|
return Promise.race([
|
|
promise,
|
|
new Promise<T>((_, reject) => {
|
|
signal.addEventListener('abort', () =>
|
|
reject(new DOMException('AbortError', 'AbortError')),
|
|
);
|
|
}),
|
|
]);
|
|
}
|