Files
squoosh/src/client/lazy-app/Compress/index.tsx
2021-08-26 14:39:59 +01:00

941 lines
28 KiB
TypeScript

import { h, Component } from 'preact';
import * as style from './style.css';
import 'add-css:./style.css';
import {
blobToImg,
blobToText,
builtinDecode,
sniffMimeType,
canDecodeImageType,
abortable,
assertSignal,
ImageMimeTypes,
} from '../util';
import {
PreprocessorState,
ProcessorState,
EncoderState,
encoderMap,
defaultPreprocessorState,
defaultProcessorState,
EncoderType,
EncoderOptions,
} from '../feature-meta';
import Output from './Output';
import Options from './Options';
import ResultCache from './result-cache';
import { cleanMerge, cleanSet } from '../util/clean-modify';
import './custom-els/MultiPanel';
import Results from './Results';
import WorkerBridge from '../worker-bridge';
import { resize } from 'features/processors/resize/client';
import type SnackBarElement from 'shared/custom-els/snack-bar';
import { generateCliInvocation } from '../util/cli';
import { drawableToImageData } from '../util/canvas';
export type OutputType = EncoderType | 'identity';
export interface SourceImage {
file: File;
decoded: ImageData;
preprocessed: ImageData;
vectorImage?: HTMLImageElement;
}
interface SideSettings {
processorState: ProcessorState;
encoderState?: EncoderState;
}
interface Side {
processed?: ImageData;
file?: File;
downloadUrl?: string;
data?: ImageData;
latestSettings: SideSettings;
encodedSettings?: SideSettings;
loading: boolean;
}
interface Props {
file: File;
showSnack: SnackBarElement['showSnackbar'];
onBack: () => void;
}
interface State {
source?: SourceImage;
sides: [Side, Side];
/** Source image load */
loading: boolean;
mobileView: boolean;
preprocessorState: PreprocessorState;
encodedPreprocessorState?: PreprocessorState;
}
interface MainJob {
file: File;
preprocessorState: PreprocessorState;
}
interface SideJob {
processorState: ProcessorState;
encoderState?: EncoderState;
}
interface LoadingFileInfo {
loading: boolean;
filename?: string;
}
async function decodeImage(
signal: AbortSignal,
blob: Blob,
workerBridge: WorkerBridge,
): Promise<ImageData> {
assertSignal(signal);
const mimeType = await abortable(signal, sniffMimeType(blob));
const canDecode = await abortable(signal, canDecodeImageType(mimeType));
try {
if (!canDecode) {
if (mimeType === 'image/ktx2') {
return await workerBridge.basisDecode(signal, blob);
}
if (mimeType === 'image/avif') {
return await workerBridge.avifDecode(signal, blob);
}
if (mimeType === 'image/webp') {
return await workerBridge.webpDecode(signal, blob);
}
if (mimeType === 'image/jxl') {
return await workerBridge.jxlDecode(signal, blob);
}
if (mimeType === 'image/webp2') {
return await workerBridge.wp2Decode(signal, blob);
}
}
// Otherwise fall through and try built-in decoding for a laugh.
return await builtinDecode(signal, blob, mimeType);
} catch (err) {
if (err.name === 'AbortError') throw err;
console.log(err);
throw Error("Couldn't decode image");
}
}
async function preprocessImage(
signal: AbortSignal,
data: ImageData,
preprocessorState: PreprocessorState,
workerBridge: WorkerBridge,
): Promise<ImageData> {
assertSignal(signal);
let processedData = data;
if (preprocessorState.rotate.rotate !== 0) {
processedData = await workerBridge.rotate(
signal,
processedData,
preprocessorState.rotate,
);
}
return processedData;
}
async function processImage(
signal: AbortSignal,
source: SourceImage,
processorState: ProcessorState,
workerBridge: WorkerBridge,
): Promise<ImageData> {
assertSignal(signal);
let result = source.preprocessed;
if (processorState.resize.enabled) {
result = await resize(signal, source, processorState.resize, workerBridge);
}
if (processorState.quantize.enabled) {
result = await workerBridge.quantize(
signal,
result,
processorState.quantize,
);
}
return result;
}
async function compressImage(
signal: AbortSignal,
image: ImageData,
encodeData: EncoderState,
sourceFilename: string,
workerBridge: WorkerBridge,
): Promise<File> {
assertSignal(signal);
const encoder = encoderMap[encodeData.type];
const compressedData = await encoder.encode(
signal,
workerBridge,
image,
// The type of encodeData.options is enforced via the previous line
encodeData.options as any,
);
// This type ensures the image mimetype is consistent with our mimetype sniffer
const type: ImageMimeTypes = encoder.meta.mimeType;
return new File(
[compressedData],
sourceFilename.replace(/.[^.]*$/, `.${encoder.meta.extension}`),
{ type },
);
}
function stateForNewSourceData(state: State): State {
let newState = { ...state };
for (const i of [0, 1]) {
// Ditch previous encodings
const downloadUrl = state.sides[i].downloadUrl;
if (downloadUrl) URL.revokeObjectURL(downloadUrl);
newState = cleanMerge(state, `sides.${i}`, {
preprocessed: undefined,
file: undefined,
downloadUrl: undefined,
data: undefined,
encodedSettings: undefined,
});
}
return newState;
}
async function processSvg(
signal: AbortSignal,
blob: Blob,
): Promise<HTMLImageElement> {
assertSignal(signal);
// Firefox throws if you try to draw an SVG to canvas that doesn't have width/height.
// In Chrome it loads, but drawImage behaves weirdly.
// This function sets width/height if it isn't already set.
const parser = new DOMParser();
const text = await abortable(signal, blobToText(blob));
const document = parser.parseFromString(text, 'image/svg+xml');
const svg = document.documentElement!;
if (svg.hasAttribute('width') && svg.hasAttribute('height')) {
return blobToImg(blob);
}
const viewBox = svg.getAttribute('viewBox');
if (viewBox === null) throw Error('SVG must have width/height or viewBox');
const viewboxParts = viewBox.split(/\s+/);
svg.setAttribute('width', viewboxParts[2]);
svg.setAttribute('height', viewboxParts[3]);
const serializer = new XMLSerializer();
const newSource = serializer.serializeToString(document);
return abortable(
signal,
blobToImg(new Blob([newSource], { type: 'image/svg+xml' })),
);
}
/**
* If two processors are disabled, they're considered equivalent, otherwise
* equivalence is based on ===
*/
function processorStateEquivalent(a: ProcessorState, b: ProcessorState) {
// Quick exit
if (a === b) return true;
// All processors have the same keys
for (const key of Object.keys(a) as Array<keyof ProcessorState>) {
// If both processors are disabled, they're the same.
if (!a[key].enabled && !b[key].enabled) continue;
if (a !== b) return false;
}
return true;
}
const loadingIndicator = '⏳ ';
const originalDocumentTitle = document.title;
function updateDocumentTitle(loadingFileInfo: LoadingFileInfo): void {
const { loading, filename } = loadingFileInfo;
let title = '';
if (loading) title += loadingIndicator;
if (filename) title += filename + ' - ';
title += originalDocumentTitle;
document.title = title;
}
export default class Compress extends Component<Props, State> {
widthQuery = window.matchMedia('(max-width: 599px)');
state: State = {
source: undefined,
loading: false,
preprocessorState: defaultPreprocessorState,
sides: [
{
latestSettings: {
processorState: defaultProcessorState,
encoderState: undefined,
},
loading: false,
},
{
latestSettings: {
processorState: defaultProcessorState,
encoderState: {
type: 'mozJPEG',
options: encoderMap.mozJPEG.meta.defaultOptions,
},
},
loading: false,
},
],
mobileView: this.widthQuery.matches,
};
private readonly encodeCache = new ResultCache();
// One for each side
private readonly workerBridges = [new WorkerBridge(), new WorkerBridge()];
/** Abort controller for actions that impact both sites, like source image decoding and preprocessing */
private mainAbortController = new AbortController();
// And again one for each side
private sideAbortControllers = [new AbortController(), new AbortController()];
/** For debouncing calls to updateImage for each side. */
private updateImageTimeout?: number;
constructor(props: Props) {
super(props);
this.widthQuery.addListener(this.onMobileWidthChange);
this.sourceFile = props.file;
this.queueUpdateImage({ immediate: true });
import('../sw-bridge').then(({ mainAppLoaded }) => mainAppLoaded());
}
private onMobileWidthChange = () => {
this.setState({ mobileView: this.widthQuery.matches });
};
private onEncoderTypeChange = (index: 0 | 1, newType: OutputType): void => {
this.setState({
sides: cleanSet(
this.state.sides,
`${index}.latestSettings.encoderState`,
newType === 'identity'
? undefined
: {
type: newType,
options: encoderMap[newType].meta.defaultOptions,
},
),
});
};
private onProcessorOptionsChange = (
index: 0 | 1,
options: ProcessorState,
): void => {
this.setState({
sides: cleanSet(
this.state.sides,
`${index}.latestSettings.processorState`,
options,
),
});
};
private onEncoderOptionsChange = (
index: 0 | 1,
options: EncoderOptions,
): void => {
this.setState({
sides: cleanSet(
this.state.sides,
`${index}.latestSettings.encoderState.options`,
options,
),
});
};
componentWillReceiveProps(nextProps: Props): void {
if (nextProps.file !== this.props.file) {
this.sourceFile = nextProps.file;
this.queueUpdateImage({ immediate: true });
}
}
componentWillUnmount(): void {
updateDocumentTitle({ loading: false });
this.widthQuery.removeListener(this.onMobileWidthChange);
this.mainAbortController.abort();
for (const controller of this.sideAbortControllers) {
controller.abort();
}
}
componentDidUpdate(prevProps: Props, prevState: State): void {
const wasLoading =
prevState.loading ||
prevState.sides[0].loading ||
prevState.sides[1].loading;
const isLoading =
this.state.loading ||
this.state.sides[0].loading ||
this.state.sides[1].loading;
const sourceChanged = prevState.source !== this.state.source;
if (wasLoading !== isLoading || sourceChanged) {
updateDocumentTitle({
loading: isLoading,
filename: this.state.source?.file.name,
});
}
this.queueUpdateImage();
}
private onCopyToOtherClick = async (index: 0 | 1) => {
const otherIndex = index ? 0 : 1;
const oldSettings = this.state.sides[otherIndex];
const newSettings = { ...this.state.sides[index] };
// Create a new object URL for the new settings. This avoids both sides sharing a URL, which
// means it can be safely revoked without impacting the other side.
if (newSettings.file) {
newSettings.downloadUrl = URL.createObjectURL(newSettings.file);
}
this.setState({
sides: cleanSet(this.state.sides, otherIndex, newSettings),
});
const result = await this.props.showSnack('Settings copied across', {
timeout: 5000,
actions: ['undo', 'dismiss'],
});
if (result !== 'undo') return;
this.setState({
sides: cleanSet(this.state.sides, otherIndex, oldSettings),
});
};
private onPreprocessorChange = async (
preprocessorState: PreprocessorState,
): Promise<void> => {
const source = this.state.source;
if (!source) return;
const oldRotate = this.state.preprocessorState.rotate.rotate;
const newRotate = preprocessorState.rotate.rotate;
const orientationChanged = oldRotate % 180 !== newRotate % 180;
this.setState((state) => ({
loading: true,
preprocessorState,
// Flip resize values if orientation has changed
sides: !orientationChanged
? state.sides
: (state.sides.map((side) => {
const currentResizeSettings =
side.latestSettings.processorState.resize;
const resizeSettings: Partial<ProcessorState['resize']> = {
width: currentResizeSettings.height,
height: currentResizeSettings.width,
};
return cleanMerge(
side,
'latestSettings.processorState.resize',
resizeSettings,
);
}) as [Side, Side]),
}));
};
private onCopyCliClick = async (index: 0 | 1) => {
try {
const cliInvocation = generateCliInvocation(
this.state.sides[index].latestSettings.encoderState!,
this.state.sides[index].latestSettings.processorState,
);
await navigator.clipboard.writeText(cliInvocation);
const result = await this.props.showSnack(
'CLI command copied to clipboard',
{
timeout: 8000,
actions: ['usage', 'dismiss'],
},
);
if (result === 'usage') {
open('https://github.com/GoogleChromeLabs/squoosh/tree/dev/cli');
}
} catch (e) {
this.props.showSnack(e);
}
};
/**
* Debounce the heavy lifting of updateImage.
* Otherwise, the thrashing causes jank, and sometimes crashes iOS Safari.
*/
private queueUpdateImage({ immediate }: { immediate?: boolean } = {}): void {
// Call updateImage after this delay, unless queueUpdateImage is called
// again, in which case the timeout is reset.
const delay = 100;
clearTimeout(this.updateImageTimeout);
if (immediate) {
this.updateImage();
} else {
this.updateImageTimeout = setTimeout(() => this.updateImage(), delay);
}
}
private sourceFile: File;
/** The in-progress job for decoding and preprocessing */
private activeMainJob?: MainJob;
/** The in-progress job for each side (processing and encoding) */
private activeSideJobs: [SideJob?, SideJob?] = [undefined, undefined];
/**
* Perform image processing.
*
* This function is a monster, but I didn't want to break it up, because it
* never gets partially called. Instead, it looks at the current state, and
* decides which steps can be skipped, and which can be cached.
*/
private async updateImage() {
const currentState = this.state;
// State of the last completed job, or ongoing job
const latestMainJobState: Partial<MainJob> = this.activeMainJob || {
file: currentState.source && currentState.source.file,
preprocessorState: currentState.encodedPreprocessorState,
};
const latestSideJobStates: Partial<SideJob>[] = currentState.sides.map(
(side, i) =>
this.activeSideJobs[i] || {
processorState:
side.encodedSettings && side.encodedSettings.processorState,
encoderState:
side.encodedSettings && side.encodedSettings.encoderState,
},
);
// State for this job
const mainJobState: MainJob = {
file: this.sourceFile,
preprocessorState: currentState.preprocessorState,
};
const sideJobStates: SideJob[] = currentState.sides.map((side) => ({
// If there isn't an encoder selected, we don't process either
processorState: side.latestSettings.encoderState
? side.latestSettings.processorState
: defaultProcessorState,
encoderState: side.latestSettings.encoderState,
}));
// Figure out what needs doing:
const needsDecoding = latestMainJobState.file != mainJobState.file;
const needsPreprocessing =
needsDecoding ||
latestMainJobState.preprocessorState !== mainJobState.preprocessorState;
const sideWorksNeeded = latestSideJobStates.map((latestSideJob, i) => {
const needsProcessing =
needsPreprocessing ||
!latestSideJob.processorState ||
// If we're going to or from 'original image' we should reprocess
!!latestSideJob.encoderState !== !!sideJobStates[i].encoderState ||
!processorStateEquivalent(
latestSideJob.processorState,
sideJobStates[i].processorState,
);
return {
processing: needsProcessing,
encoding:
needsProcessing ||
latestSideJob.encoderState !== sideJobStates[i].encoderState,
};
});
let jobNeeded = false;
// Abort running tasks & cycle the controllers
if (needsDecoding || needsPreprocessing) {
this.mainAbortController.abort();
this.mainAbortController = new AbortController();
jobNeeded = true;
this.activeMainJob = mainJobState;
}
for (const [i, sideWorkNeeded] of sideWorksNeeded.entries()) {
if (sideWorkNeeded.processing || sideWorkNeeded.encoding) {
this.sideAbortControllers[i].abort();
this.sideAbortControllers[i] = new AbortController();
jobNeeded = true;
this.activeSideJobs[i] = sideJobStates[i];
}
}
if (!jobNeeded) return;
const mainSignal = this.mainAbortController.signal;
const sideSignals = this.sideAbortControllers.map((ac) => ac.signal);
let decoded: ImageData;
let vectorImage: HTMLImageElement | undefined;
// Handle decoding
if (needsDecoding) {
try {
assertSignal(mainSignal);
this.setState({
source: undefined,
loading: true,
});
// Special-case SVG. We need to avoid createImageBitmap because of
// https://bugs.chromium.org/p/chromium/issues/detail?id=606319.
// Also, we cache the HTMLImageElement so we can perform vector resizing later.
if (mainJobState.file.type.startsWith('image/svg+xml')) {
vectorImage = await processSvg(mainSignal, mainJobState.file);
decoded = drawableToImageData(vectorImage);
} else {
decoded = await decodeImage(
mainSignal,
mainJobState.file,
// Either worker is good enough here.
this.workerBridges[0],
);
}
// Set default resize values
this.setState((currentState) => {
if (mainSignal.aborted) return {};
const sides = currentState.sides.map((side) => {
const resizeState: Partial<ProcessorState['resize']> = {
width: decoded.width,
height: decoded.height,
method: vectorImage ? 'vector' : 'lanczos3',
// Disable resizing, to make it clearer to the user that something changed here
enabled: false,
};
return cleanMerge(
side,
'latestSettings.processorState.resize',
resizeState,
);
}) as [Side, Side];
return { sides };
});
} catch (err) {
if (err.name === 'AbortError') return;
this.props.showSnack(`Source decoding error: ${err}`);
throw err;
}
} else {
({ decoded, vectorImage } = currentState.source!);
}
let source: SourceImage;
// Handle preprocessing
if (needsPreprocessing) {
try {
assertSignal(mainSignal);
this.setState({
loading: true,
});
const preprocessed = await preprocessImage(
mainSignal,
decoded,
mainJobState.preprocessorState,
// Either worker is good enough here.
this.workerBridges[0],
);
source = {
decoded,
vectorImage,
preprocessed,
file: mainJobState.file,
};
// Update state for process completion, including intermediate render
this.setState((currentState) => {
if (mainSignal.aborted) return {};
let newState: State = {
...currentState,
loading: false,
source,
encodedPreprocessorState: mainJobState.preprocessorState,
sides: currentState.sides.map((side) => {
if (side.downloadUrl) URL.revokeObjectURL(side.downloadUrl);
const newSide: Side = {
...side,
// Intermediate render
data: preprocessed,
processed: undefined,
encodedSettings: undefined,
};
return newSide;
}) as [Side, Side],
};
newState = stateForNewSourceData(newState);
return newState;
});
} catch (err) {
if (err.name === 'AbortError') return;
this.setState({ loading: false });
this.props.showSnack(`Preprocessing error: ${err}`);
throw err;
}
} else {
source = currentState.source!;
}
// That's the main part of the job done.
this.activeMainJob = undefined;
// Allow side jobs to happen in parallel
sideWorksNeeded.forEach(async (sideWorkNeeded, sideIndex) => {
try {
// If processing is true, encoding is always true.
if (!sideWorkNeeded.encoding) return;
const signal = sideSignals[sideIndex];
const jobState = sideJobStates[sideIndex];
const workerBridge = this.workerBridges[sideIndex];
let file: File;
let data: ImageData;
let processed: ImageData | undefined = undefined;
// If there's no encoder state, this is "original image", which also
// doesn't allow processing.
if (!jobState.encoderState) {
file = source.file;
data = source.preprocessed;
} else {
const cacheResult = this.encodeCache.match(
source.preprocessed,
jobState.processorState,
jobState.encoderState,
);
if (cacheResult) {
({ file, processed, data } = cacheResult);
} else {
// Set loading state for this side
this.setState((currentState) => {
if (signal.aborted) return {};
const sides = cleanMerge(currentState.sides, sideIndex, {
loading: true,
});
return { sides };
});
if (sideWorkNeeded.processing) {
processed = await processImage(
signal,
source,
jobState.processorState,
workerBridge,
);
// Update state for process completion, including intermediate render
this.setState((currentState) => {
if (signal.aborted) return {};
const currentSide = currentState.sides[sideIndex];
const side: Side = {
...currentSide,
processed,
// Intermediate render
data: processed,
encodedSettings: {
...currentSide.encodedSettings,
processorState: jobState.processorState,
},
};
const sides = cleanSet(currentState.sides, sideIndex, side);
return { sides };
});
} else {
processed = currentState.sides[sideIndex].processed!;
}
file = await compressImage(
signal,
processed,
jobState.encoderState,
source.file.name,
workerBridge,
);
data = await decodeImage(signal, file, workerBridge);
this.encodeCache.add({
data,
processed,
file,
preprocessed: source.preprocessed,
encoderState: jobState.encoderState,
processorState: jobState.processorState,
});
}
}
this.setState((currentState) => {
if (signal.aborted) return {};
const currentSide = currentState.sides[sideIndex];
if (currentSide.downloadUrl) {
URL.revokeObjectURL(currentSide.downloadUrl);
}
const side: Side = {
...currentSide,
data,
file,
downloadUrl: URL.createObjectURL(file),
loading: false,
processed,
encodedSettings: {
processorState: jobState.processorState,
encoderState: jobState.encoderState,
},
};
const sides = cleanSet(currentState.sides, sideIndex, side);
return { sides };
});
this.activeSideJobs[sideIndex] = undefined;
} catch (err) {
if (err.name === 'AbortError') return;
this.setState((currentState) => {
const sides = cleanMerge(currentState.sides, sideIndex, {
loading: false,
});
return { sides };
});
this.props.showSnack(`Processing error: ${err}`);
throw err;
}
});
}
render(
{ onBack }: Props,
{ loading, sides, source, mobileView, preprocessorState }: State,
) {
const [leftSide, rightSide] = sides;
const [leftImageData, rightImageData] = sides.map((i) => i.data);
const options = sides.map((side, index) => (
<Options
index={index as 0 | 1}
source={source}
mobileView={mobileView}
processorState={side.latestSettings.processorState}
encoderState={side.latestSettings.encoderState}
onEncoderTypeChange={this.onEncoderTypeChange}
onEncoderOptionsChange={this.onEncoderOptionsChange}
onProcessorOptionsChange={this.onProcessorOptionsChange}
onCopyCliClick={this.onCopyCliClick}
onCopyToOtherSideClick={this.onCopyToOtherClick}
/>
));
const results = sides.map((side, index) => (
<Results
downloadUrl={side.downloadUrl}
imageFile={side.file}
source={source}
loading={loading || side.loading}
flipSide={mobileView || index === 1}
typeLabel={
side.latestSettings.encoderState
? encoderMap[side.latestSettings.encoderState.type].meta.label
: 'Original Image'
}
/>
));
// For rendering, we ideally want the settings that were used to create the
// data, not the latest settings.
const leftDisplaySettings =
leftSide.encodedSettings || leftSide.latestSettings;
const rightDisplaySettings =
rightSide.encodedSettings || rightSide.latestSettings;
const leftImgContain =
leftDisplaySettings.processorState.resize.enabled &&
leftDisplaySettings.processorState.resize.fitMethod === 'contain';
const rightImgContain =
rightDisplaySettings.processorState.resize.enabled &&
rightDisplaySettings.processorState.resize.fitMethod === 'contain';
return (
<div class={style.compress}>
<Output
source={source}
mobileView={mobileView}
leftCompressed={leftImageData}
rightCompressed={rightImageData}
leftImgContain={leftImgContain}
rightImgContain={rightImgContain}
preprocessorState={preprocessorState}
onPreprocessorChange={this.onPreprocessorChange}
/>
<button class={style.back} onClick={onBack}>
<svg viewBox="0 0 61 53.3">
<title>Back</title>
<path
class={style.backBlob}
d="M0 25.6c-.5-7.1 4.1-14.5 10-19.1S23.4.1 32.2 0c8.8 0 19 1.6 24.4 8s5.6 17.8 1.7 27a29.7 29.7 0 01-20.5 18c-8.4 1.5-17.3-2.6-24.5-8S.5 32.6.1 25.6z"
/>
<path
class={style.backX}
d="M41.6 17.1l-2-2.1-8.3 8.2-8.2-8.2-2 2 8.2 8.3-8.3 8.2 2.1 2 8.2-8.1 8.3 8.2 2-2-8.2-8.3z"
/>
</svg>
</button>
{mobileView ? (
<div class={style.options}>
<multi-panel class={style.multiPanel} open-one-only>
<div class={style.options1Theme}>{results[0]}</div>
<div class={style.options1Theme}>{options[0]}</div>
<div class={style.options2Theme}>{results[1]}</div>
<div class={style.options2Theme}>{options[1]}</div>
</multi-panel>
</div>
) : (
[
<div class={style.options1} key="options1">
{options[0]}
{results[0]}
</div>,
<div class={style.options2} key="options2">
{options[1]}
{results[1]}
</div>,
]
)}
</div>
);
}
}