forked from external-repos/squoosh
Unfortunately, due to a few people leaving the team, and staffing issues resulting from the current economic climate (ugh), I'm deprecating the CLI and libsquoosh parts of Squoosh. The web app will continue to be supported and improved. I know that sucks, but there simply isn't the time & people to work on this. If anyone from the community wants to fork it, you have my blessing.
913 lines
27 KiB
TypeScript
913 lines
27 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 { 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/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);
|
|
} catch (err) {
|
|
if (err instanceof Error && 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]),
|
|
}));
|
|
};
|
|
|
|
/**
|
|
* 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 instanceof Error && 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 instanceof Error && 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 instanceof Error && 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}
|
|
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>
|
|
);
|
|
}
|
|
}
|