forked from external-repos/squoosh
Rollup build
This commit is contained in:
886
src/client/lazy-app/Compress/index.tsx
Normal file
886
src/client/lazy-app/Compress/index.tsx
Normal file
@@ -0,0 +1,886 @@
|
||||
import { h, Component } from 'preact';
|
||||
|
||||
import * as style from './style.css';
|
||||
import 'add-css:./style.css';
|
||||
import {
|
||||
blobToImg,
|
||||
drawableToImageData,
|
||||
blobToText,
|
||||
builtinDecode,
|
||||
sniffMimeType,
|
||||
canDecodeImageType,
|
||||
abortable,
|
||||
assertSignal,
|
||||
} 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/initial-app/custom-els/snack-bar';
|
||||
import { CopyAcrossIconProps, ExpandIcon } from '../icons';
|
||||
|
||||
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;
|
||||
error?: string;
|
||||
mobileView: boolean;
|
||||
preprocessorState: PreprocessorState;
|
||||
encodedPreprocessorState?: PreprocessorState;
|
||||
}
|
||||
|
||||
interface MainJob {
|
||||
file: File;
|
||||
preprocessorState: PreprocessorState;
|
||||
}
|
||||
|
||||
interface SideJob {
|
||||
processorState: ProcessorState;
|
||||
encoderState?: EncoderState;
|
||||
}
|
||||
|
||||
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/jpegxl') {
|
||||
return await workerBridge.jxlDecode(signal, blob);
|
||||
}
|
||||
if (mimeType === 'image/webp2') {
|
||||
return await workerBridge.wp2Decode(signal, blob);
|
||||
}
|
||||
// If it's not one of those types, fall through and try built-in decoding for a laugh.
|
||||
}
|
||||
return await abortable(signal, builtinDecode(blob));
|
||||
} 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,
|
||||
);
|
||||
|
||||
return new File(
|
||||
[compressedData],
|
||||
sourceFilename.replace(/.[^.]*$/, `.${encoder.meta.extension}`),
|
||||
{ type: encoder.meta.mimeType },
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// These are only used in the mobile view
|
||||
const resultTitles = ['Top', 'Bottom'] as const;
|
||||
// These are only used in the desktop view
|
||||
const buttonPositions = ['download-left', 'download-right'] as const;
|
||||
|
||||
const originalDocumentTitle = document.title;
|
||||
|
||||
function updateDocumentTitle(filename: string = ''): void {
|
||||
document.title = filename
|
||||
? `${filename} - ${originalDocumentTitle}`
|
||||
: originalDocumentTitle;
|
||||
}
|
||||
|
||||
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();
|
||||
this.mainAbortController.abort();
|
||||
for (const controller of this.sideAbortControllers) {
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State): void {
|
||||
this.queueUpdateImage();
|
||||
}
|
||||
|
||||
private async onCopyToOtherClick(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,
|
||||
// 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);
|
||||
updateDocumentTitle(source.file.name);
|
||||
return newState;
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
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.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
|
||||
source={source}
|
||||
mobileView={mobileView}
|
||||
processorState={side.latestSettings.processorState}
|
||||
encoderState={side.latestSettings.encoderState}
|
||||
onEncoderTypeChange={this.onEncoderTypeChange.bind(
|
||||
this,
|
||||
index as 0 | 1,
|
||||
)}
|
||||
onEncoderOptionsChange={this.onEncoderOptionsChange.bind(
|
||||
this,
|
||||
index as 0 | 1,
|
||||
)}
|
||||
onProcessorOptionsChange={this.onProcessorOptionsChange.bind(
|
||||
this,
|
||||
index as 0 | 1,
|
||||
)}
|
||||
/>
|
||||
));
|
||||
|
||||
const copyDirections = (mobileView
|
||||
? ['down', 'up']
|
||||
: ['right', 'left']) as CopyAcrossIconProps['copyDirection'][];
|
||||
|
||||
const results = sides.map((side, index) => (
|
||||
<Results
|
||||
downloadUrl={side.downloadUrl}
|
||||
imageFile={side.file}
|
||||
source={source}
|
||||
loading={loading || side.loading}
|
||||
copyDirection={copyDirections[index]}
|
||||
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index as 0 | 1)}
|
||||
buttonPosition={mobileView ? 'stack-right' : buttonPositions[index]}
|
||||
>
|
||||
{!mobileView
|
||||
? null
|
||||
: [
|
||||
<ExpandIcon class={style.expandIcon} key="expand-icon" />,
|
||||
`${resultTitles[index]} (${
|
||||
side.latestSettings.encoderState
|
||||
? encoderMap[side.latestSettings.encoderState.type].meta.label
|
||||
: 'Original Image'
|
||||
})`,
|
||||
]}
|
||||
</Results>
|
||||
));
|
||||
|
||||
// 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}
|
||||
onBack={onBack}
|
||||
preprocessorState={preprocessorState}
|
||||
onPreprocessorChange={this.onPreprocessorChange}
|
||||
/>
|
||||
{mobileView ? (
|
||||
<div class={style.options}>
|
||||
<multi-panel class={style.multiPanel} open-one-only>
|
||||
{results[0]}
|
||||
{options[0]}
|
||||
{results[1]}
|
||||
{options[1]}
|
||||
</multi-panel>
|
||||
</div>
|
||||
) : (
|
||||
[
|
||||
<div class={style.options} key="options0">
|
||||
{options[0]}
|
||||
{results[0]}
|
||||
</div>,
|
||||
<div class={style.options} key="options1">
|
||||
{options[1]}
|
||||
{results[1]}
|
||||
</div>,
|
||||
]
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user