forked from external-repos/squoosh
There were requests from multiple users that they use squoosh for compression but for each iteration side settings resets to default causing issues and there is no way to save and import side settings. There will be two buttons adjacent to copy-over save side settings : This will save side encoder and latest settings to localstorage of browser import side settings : This will import side encoder and latest settings from localstorage of browser and replace the existing settings Also if there are saved settings in locaStorage then whenever user loads the app it will take that settings and populate the side so user do not have to repeatedly enter same settings for similar compression operation subject to user has saved side settings Update:1 Import settings button remains disabled if there is nothing to import Whenever the side setting is saved there will be event fired and eventually listened to enable import button All 2 operations show notifications now Import notification has undo option
1019 lines
31 KiB
TypeScript
1019 lines
31 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,
|
|
// Tasking catched side settings if available otherwise taking default settings
|
|
sides: [
|
|
localStorage.getItem('leftSideSettings')
|
|
? {
|
|
...JSON.parse(localStorage.getItem('leftSideSettings') as string),
|
|
loading: false,
|
|
}
|
|
: {
|
|
latestSettings: {
|
|
processorState: defaultProcessorState,
|
|
encoderState: undefined,
|
|
},
|
|
loading: false,
|
|
},
|
|
localStorage.getItem('rightSideSettings')
|
|
? {
|
|
...JSON.parse(localStorage.getItem('rightSideSettings') as string),
|
|
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),
|
|
});
|
|
};
|
|
/**
|
|
* This function saves encodedSettings and latestSettings of
|
|
* particular side in browser local storage
|
|
* @param index : (0|1)
|
|
* @returns
|
|
*/
|
|
private onSaveSideSettingsClick = async (index: 0 | 1) => {
|
|
if (index === 0) {
|
|
const leftSideSettings = JSON.stringify({
|
|
encodedSettings: this.state.sides[index].encodedSettings,
|
|
latestSettings: this.state.sides[index].latestSettings,
|
|
});
|
|
localStorage.setItem('leftSideSettings', leftSideSettings);
|
|
// Firing an event when we save side settings in localstorage
|
|
window.dispatchEvent(new CustomEvent('leftSideSettings'));
|
|
await this.props.showSnack('Left side settings saved', {
|
|
timeout: 1500,
|
|
actions: ['dismiss'],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (index === 1) {
|
|
const rightSideSettings = JSON.stringify({
|
|
encodedSettings: this.state.sides[index].encodedSettings,
|
|
latestSettings: this.state.sides[index].latestSettings,
|
|
});
|
|
localStorage.setItem('rightSideSettings', rightSideSettings);
|
|
// Firing an event when we save side settings in localstorage
|
|
window.dispatchEvent(new CustomEvent('rightSideSettings'));
|
|
await this.props.showSnack('Right side settings saved', {
|
|
timeout: 1500,
|
|
actions: ['dismiss'],
|
|
});
|
|
return;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* This function sets the side state with catched localstorage
|
|
* value as per side index provided
|
|
* @param index : (0|1)
|
|
* @returns
|
|
*/
|
|
private onImportSideSettingsClick = async (index: 0 | 1) => {
|
|
const leftSideSettingsString = localStorage.getItem('leftSideSettings');
|
|
const rightSideSettingsString = localStorage.getItem('rightSideSettings');
|
|
|
|
if (index === 0 && leftSideSettingsString) {
|
|
const oldLeftSideSettings = this.state.sides[index];
|
|
const newLeftSideSettings = {
|
|
...this.state.sides[index],
|
|
...JSON.parse(leftSideSettingsString),
|
|
};
|
|
this.setState({
|
|
sides: cleanSet(this.state.sides, index, newLeftSideSettings),
|
|
});
|
|
const result = await this.props.showSnack('Left side settings imported', {
|
|
timeout: 3000,
|
|
actions: ['undo', 'dismiss'],
|
|
});
|
|
if (result === 'undo') {
|
|
this.setState({
|
|
sides: cleanSet(this.state.sides, index, oldLeftSideSettings),
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (index === 1 && rightSideSettingsString) {
|
|
const oldRightSideSettings = this.state.sides[index];
|
|
const newRightSideSettings = {
|
|
...this.state.sides[index],
|
|
...JSON.parse(rightSideSettingsString),
|
|
};
|
|
this.setState({
|
|
sides: cleanSet(this.state.sides, index, newRightSideSettings),
|
|
});
|
|
const result = await this.props.showSnack(
|
|
'Right side settings imported',
|
|
{
|
|
timeout: 3000,
|
|
actions: ['undo', 'dismiss'],
|
|
},
|
|
);
|
|
if (result === 'undo') {
|
|
this.setState({
|
|
sides: cleanSet(this.state.sides, index, oldRightSideSettings),
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
};
|
|
|
|
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}
|
|
onSaveSideSettingsClick={this.onSaveSideSettingsClick}
|
|
onImportSideSettingsClick={this.onImportSideSettingsClick}
|
|
/>
|
|
));
|
|
|
|
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
|
|
: `${side.file ? `${side.file.name}` : '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>
|
|
);
|
|
}
|
|
}
|