941 lines
28 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
}
|