Refactor resize

This commit is contained in:
Jake Archibald
2020-11-06 16:47:37 +00:00
parent ec586bb529
commit 1cb1c16fa2
12 changed files with 197 additions and 101 deletions

View File

@@ -145,21 +145,24 @@ export default function () {
} }
async function generateFeatureMeta() { async function generateFeatureMeta() {
const encoderMetas = ( const getTsFiles = (glob) =>
await globP('src/features/encoders/*/shared/meta.ts', { globP(glob, {
absolute: true, absolute: true,
}) }).then((paths) =>
) paths
.filter((tsFile) => !tsFile.endsWith('.d.ts')) .filter((tsFile) => !tsFile.endsWith('.d.ts'))
.map((tsFile) => tsFile.slice(0, -'.ts'.length)); .map((tsFile) => tsFile.slice(0, -'.ts'.length)),
);
const processorMetas = ( const metas = await Promise.all(
await globP('src/features/processors/*/shared/meta.ts', { [
absolute: true, 'src/features/encoders/*/shared/meta.ts',
}) 'src/features/processors/*/shared/meta.ts',
) 'src/features/preprocessors/*/shared/meta.ts',
.filter((tsFile) => !tsFile.endsWith('.d.ts')) ].map((glob) => getTsFiles(glob)),
.map((tsFile) => tsFile.slice(0, -'.ts'.length)); );
const [encoderMetas, processorMetas, preprocessorMetas] = metas;
const featureMetaBasePath = path.join( const featureMetaBasePath = path.join(
process.cwd(), process.cwd(),
@@ -169,7 +172,7 @@ export default function () {
'feature-meta', 'feature-meta',
); );
const joinedMetas = [...encoderMetas, ...processorMetas].join(); const joinedMetas = metas.flat().join();
// Avoid regenerating if nothing's changed. // Avoid regenerating if nothing's changed.
// This also prevents an infinite loop in the watcher. // This also prevents an infinite loop in the watcher.
@@ -181,8 +184,15 @@ export default function () {
path.basename(tsImport.slice(0, -'/shared/meta'.length)), path.basename(tsImport.slice(0, -'/shared/meta'.length)),
]; ];
const encoderMetaTsNames = encoderMetas.map(getTsName); const encoderMetaTsNames = encoderMetas.map((tsImport) =>
const processorMetaTsNames = processorMetas.map(getTsName); getTsName(tsImport),
);
const processorMetaTsNames = processorMetas.map((tsImport) =>
getTsName(tsImport),
);
const preprocessorMetaTsNames = preprocessorMetas.map((tsImport) =>
getTsName(tsImport),
);
const featureMeta = [ const featureMeta = [
autoGenComment, autoGenComment,
@@ -222,6 +232,20 @@ export default function () {
` ${name}: { enabled: false, ...${name}ProcessorMeta.defaultOptions },`, ` ${name}: { enabled: false, ...${name}ProcessorMeta.defaultOptions },`,
), ),
`}`, `}`,
// Preprocessor stuff
preprocessorMetaTsNames.map(
([path, name]) => `import * as ${name}PreprocessorMeta from '${path}';`,
),
`export interface PreprocessorState {`,
preprocessorMetaTsNames.map(
([_, name]) => ` ${name}: ${name}PreprocessorMeta.Options,`,
),
`}`,
`export const defaultPreprocessorState: PreprocessorState = {`,
preprocessorMetaTsNames.map(
([_, name]) => ` ${name}: ${name}PreprocessorMeta.defaultOptions,`,
),
`};`,
] ]
.flat(Infinity) .flat(Infinity)
.join('\n'); .join('\n');

View File

@@ -1,5 +1,7 @@
import { h, Component } from 'preact'; import { h, Component } from 'preact';
import * as style from './style.css';
import 'add-css:./style.css';
import { import {
blobToImg, blobToImg,
drawableToImageData, drawableToImageData,
@@ -7,41 +9,35 @@ import {
builtinDecode, builtinDecode,
sniffMimeType, sniffMimeType,
canDecodeImageType, canDecodeImageType,
abortable,
assertSignal,
} from '../util'; } from '../util';
import * as style from './style.css'; import {
import 'add-css:./style.css'; PreprocessorState,
ProcessorState,
EncoderState,
} from '../feature-meta';
import Output from '../Output'; import Output from '../Output';
import Options from '../Options'; import Options from '../Options';
import ResultCache from './result-cache'; import ResultCache from './result-cache';
import { decodeImage } from '../../codecs/decoders';
import { cleanMerge, cleanSet } from '../../lib/clean-modify'; import { cleanMerge, cleanSet } from '../../lib/clean-modify';
import Processor from '../../codecs/processor';
import {
BrowserResizeOptions,
isWorkerOptions as isWorkerResizeOptions,
isHqx,
WorkerResizeOptions,
} from '../../codecs/resize/processor-meta';
import './custom-els/MultiPanel'; import './custom-els/MultiPanel';
import Results from '../results'; import Results from '../results';
import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons'; import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons';
import SnackBarElement from '../../lib/SnackBar'; import SnackBarElement from '../../lib/SnackBar';
import {
InputProcessorState,
defaultInputProcessorState,
} from '../../codecs/input-processors';
import WorkerBridge from '../worker-bridge'; import WorkerBridge from '../worker-bridge';
import { resize } from 'features/processors/resize/client';
export interface SourceImage { export interface SourceImage {
file: File; file: File;
decoded: ImageData; decoded: ImageData;
processed: ImageData; processed: ImageData;
vectorImage?: HTMLImageElement; vectorImage?: HTMLImageElement;
inputProcessorState: InputProcessorState; preprocessorState: PreprocessorState;
} }
interface SideSettings { interface SideSettings {
preprocessorState: PreprocessorState; processorState: ProcessorState;
encoderState: EncoderState; encoderState: EncoderState;
} }
@@ -79,8 +75,9 @@ async function decodeImage(
blob: Blob, blob: Blob,
workerBridge: WorkerBridge, workerBridge: WorkerBridge,
): Promise<ImageData> { ): Promise<ImageData> {
const mimeType = await sniffMimeType(blob); assertSignal(signal);
const canDecode = await canDecodeImageType(mimeType); const mimeType = await abortable(signal, sniffMimeType(blob));
const canDecode = await abortable(signal, canDecodeImageType(mimeType));
try { try {
if (!canDecode) { if (!canDecode) {
@@ -92,63 +89,52 @@ async function decodeImage(
} }
// If it's not one of those types, fall through and try built-in decoding for a laugh. // If it's not one of those types, fall through and try built-in decoding for a laugh.
} }
return await builtinDecode(blob); return await abortable(signal, builtinDecode(blob));
} catch (err) { } catch (err) {
if (err.name === 'AbortError') throw err;
console.log(err);
throw Error("Couldn't decode image"); throw Error("Couldn't decode image");
} }
} }
async function processInput( async function preprocessImage(
signal: AbortSignal,
data: ImageData, data: ImageData,
inputProcessData: InputProcessorState, preprocessorState: PreprocessorState,
processor: Processor, workerBridge: WorkerBridge,
) { ): Promise<ImageData> {
assertSignal(signal);
let processedData = data; let processedData = data;
if (inputProcessData.rotate.rotate !== 0) { if (preprocessorState.rotate.rotate !== 0) {
processedData = await processor.rotate( processedData = await workerBridge.rotate(
signal,
processedData, processedData,
inputProcessData.rotate, preprocessorState.rotate,
); );
} }
return processedData; return processedData;
} }
async function preprocessImage( async function processImage(
signal: AbortSignal,
source: SourceImage, source: SourceImage,
preprocessData: PreprocessorState, processorState: ProcessorState,
processor: Processor, workerBridge: WorkerBridge,
): Promise<ImageData> { ): Promise<ImageData> {
assertSignal(signal);
let result = source.processed; let result = source.processed;
if (preprocessData.resize.enabled) { if (processorState.resize.enabled) {
if (preprocessData.resize.method === 'vector' && source.vectorImage) { result = await resize(signal, source, processorState.resize, workerBridge);
result = processor.vectorResize(
source.vectorImage,
preprocessData.resize,
);
} else if (isHqx(preprocessData.resize)) {
// Hqx can only do x2, x3 or x4.
result = await processor.workerResize(result, preprocessData.resize);
// If the target size is not a clean x2, x3 or x4, use Catmull-Rom
// for the remaining scaling.
const pixelOpts = { ...preprocessData.resize, method: 'catrom' };
result = await processor.workerResize(
result,
pixelOpts as WorkerResizeOptions,
);
} else if (isWorkerResizeOptions(preprocessData.resize)) {
result = await processor.workerResize(result, preprocessData.resize);
} else {
result = processor.resize(
result,
preprocessData.resize as BrowserResizeOptions,
);
}
} }
if (preprocessData.quantizer.enabled) { if (processorState.quantizer.enabled) {
result = await processor.imageQuant(result, preprocessData.quantizer); result = await workerBridge.imageQuant(
signal,
result,
processorState.quantizer,
);
} }
return result; return result;
} }
@@ -264,7 +250,7 @@ export default class Compress extends Component<Props, State> {
sides: [ sides: [
{ {
latestSettings: { latestSettings: {
preprocessorState: defaultPreprocessorState, processorState: defaultPreprocessorState,
encoderState: { encoderState: {
type: identity.type, type: identity.type,
options: identity.defaultOptions, options: identity.defaultOptions,
@@ -276,7 +262,7 @@ export default class Compress extends Component<Props, State> {
}, },
{ {
latestSettings: { latestSettings: {
preprocessorState: defaultPreprocessorState, processorState: defaultPreprocessorState,
encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions }, encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions },
}, },
loadingCounter: 0, loadingCounter: 0,
@@ -376,8 +362,7 @@ export default class Compress extends Component<Props, State> {
const encoderChanged = const encoderChanged =
side.latestSettings.encoderState !== prevSettings.encoderState; side.latestSettings.encoderState !== prevSettings.encoderState;
const preprocessorChanged = const preprocessorChanged =
side.latestSettings.preprocessorState !== side.latestSettings.processorState !== prevSettings.processorState;
prevSettings.preprocessorState;
// The image only needs updated if the encoder/preprocessor settings have changed, or the // The image only needs updated if the encoder/preprocessor settings have changed, or the
// source has changed. // source has changed.
@@ -421,7 +406,7 @@ export default class Compress extends Component<Props, State> {
const source = this.state.source; const source = this.state.source;
if (!source) return; if (!source) return;
const oldRotate = source.inputProcessorState.rotate.rotate; const oldRotate = source.preprocessorState.rotate.rotate;
const newRotate = options.rotate.rotate; const newRotate = options.rotate.rotate;
const orientationChanged = oldRotate % 180 !== newRotate % 180; const orientationChanged = oldRotate % 180 !== newRotate % 180;
const loadingCounter = this.state.loadingCounter + 1; const loadingCounter = this.state.loadingCounter + 1;
@@ -439,7 +424,11 @@ export default class Compress extends Component<Props, State> {
this.rightProcessor.abortCurrent(); this.rightProcessor.abortCurrent();
try { try {
const processed = await processInput(source.decoded, options, processor); const processed = await preprocessImage(
source.decoded,
options,
processor,
);
// Another file has been opened/processed before this one processed. // Another file has been opened/processed before this one processed.
if (this.state.loadingCounter !== loadingCounter) return; if (this.state.loadingCounter !== loadingCounter) return;
@@ -452,7 +441,7 @@ export default class Compress extends Component<Props, State> {
// If orientation has changed, we should flip the resize values. // If orientation has changed, we should flip the resize values.
for (const i of [0, 1]) { for (const i of [0, 1]) {
const resizeSettings = const resizeSettings =
newState.sides[i].latestSettings.preprocessorState.resize; newState.sides[i].latestSettings.processorState.resize;
newState = cleanMerge( newState = cleanMerge(
newState, newState,
`sides.${i}.latestSettings.preprocessorState.resize`, `sides.${i}.latestSettings.preprocessorState.resize`,
@@ -500,7 +489,7 @@ export default class Compress extends Component<Props, State> {
decoded = await decodeImage(file, processor); decoded = await decodeImage(file, processor);
} }
const processed = await processInput( const processed = await preprocessImage(
decoded, decoded,
defaultInputProcessorState, defaultInputProcessorState,
processor, processor,
@@ -516,7 +505,7 @@ export default class Compress extends Component<Props, State> {
file, file,
vectorImage, vectorImage,
processed, processed,
inputProcessorState: defaultInputProcessorState, preprocessorState: defaultInputProcessorState,
}, },
loading: false, loading: false,
}; };
@@ -617,7 +606,7 @@ export default class Compress extends Component<Props, State> {
preprocessed = preprocessed =
skipPreprocessing && side.preprocessed skipPreprocessing && side.preprocessed
? side.preprocessed ? side.preprocessed
: await preprocessImage( : await processImage(
source, source,
settings.preprocessorState, settings.preprocessorState,
processor, processor,
@@ -679,7 +668,7 @@ export default class Compress extends Component<Props, State> {
<Options <Options
source={source} source={source}
mobileView={mobileView} mobileView={mobileView}
preprocessorState={side.latestSettings.preprocessorState} preprocessorState={side.latestSettings.processorState}
encoderState={side.latestSettings.encoderState} encoderState={side.latestSettings.encoderState}
onEncoderTypeChange={this.onEncoderTypeChange.bind( onEncoderTypeChange={this.onEncoderTypeChange.bind(
this, this,
@@ -729,11 +718,11 @@ export default class Compress extends Component<Props, State> {
const rightDisplaySettings = const rightDisplaySettings =
rightSide.encodedSettings || rightSide.latestSettings; rightSide.encodedSettings || rightSide.latestSettings;
const leftImgContain = const leftImgContain =
leftDisplaySettings.preprocessorState.resize.enabled && leftDisplaySettings.processorState.resize.enabled &&
leftDisplaySettings.preprocessorState.resize.fitMethod === 'contain'; leftDisplaySettings.processorState.resize.fitMethod === 'contain';
const rightImgContain = const rightImgContain =
rightDisplaySettings.preprocessorState.resize.enabled && rightDisplaySettings.processorState.resize.enabled &&
rightDisplaySettings.preprocessorState.resize.fitMethod === 'contain'; rightDisplaySettings.processorState.resize.fitMethod === 'contain';
return ( return (
<div class={style.compress}> <div class={style.compress}>
@@ -745,7 +734,7 @@ export default class Compress extends Component<Props, State> {
leftImgContain={leftImgContain} leftImgContain={leftImgContain}
rightImgContain={rightImgContain} rightImgContain={rightImgContain}
onBack={onBack} onBack={onBack}
inputProcessorState={source && source.inputProcessorState} inputProcessorState={source && source.preprocessorState}
onInputProcessorChange={this.onInputProcessorChange} onInputProcessorChange={this.onInputProcessorChange}
/> />
{mobileView ? ( {mobileView ? (

View File

@@ -367,6 +367,13 @@ export function preventDefault(event: Event) {
event.preventDefault(); event.preventDefault();
} }
/**
* Throw an abort error if a signal is aborted.
*/
export function assertSignal(signal: AbortSignal) {
if (signal.aborted) throw new DOMException('AbortError', 'AbortError');
}
/** /**
* Take a signal and promise, and returns a promise that rejects with an AbortError if the abort is * Take a signal and promise, and returns a promise that rejects with an AbortError if the abort is
* signalled, otherwise resolves with the promise. * signalled, otherwise resolves with the promise.
@@ -375,7 +382,7 @@ export async function abortable<T>(
signal: AbortSignal, signal: AbortSignal,
promise: Promise<T>, promise: Promise<T>,
): Promise<T> { ): Promise<T> {
if (signal.aborted) throw new DOMException('AbortError', 'AbortError'); assertSignal(signal);
return Promise.race([ return Promise.race([
promise, promise,
new Promise<T>((_, reject) => { new Promise<T>((_, reject) => {

View File

@@ -10,6 +10,7 @@
{ "path": "../features/encoders/identity/shared" }, { "path": "../features/encoders/identity/shared" },
{ "path": "../features/encoders/browserGIF/shared" }, { "path": "../features/encoders/browserGIF/shared" },
{ "path": "../features/encoders/browserJPEG/shared" }, { "path": "../features/encoders/browserJPEG/shared" },
{ "path": "../features/encoders/browserPNG/shared" } { "path": "../features/encoders/browserPNG/shared" },
{ "path": "../features/processors/resize/client" }
] ]
} }

View File

@@ -0,0 +1,19 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface Options {
rotate: 0 | 90 | 180 | 270;
}
export const defaultOptions: Options = {
rotate: 0,
};

View File

@@ -0,0 +1,13 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference path="../../../../../missing-types.d.ts" />

View File

@@ -0,0 +1,7 @@
{
"extends": "../../../../../generic-tsconfig.json",
"compilerOptions": {
"lib": ["webworker", "esnext"]
},
"references": [{ "path": "../../../" }]
}

View File

@@ -11,10 +11,7 @@
* limitations under the License. * limitations under the License.
*/ */
import wasmUrl from 'url:codecs/rotate/rotate.wasm'; import wasmUrl from 'url:codecs/rotate/rotate.wasm';
import { Options } from '../shared/meta';
export interface RotateOptions {
rotate: 0 | 90 | 180 | 270;
}
export interface RotateModuleInstance { export interface RotateModuleInstance {
exports: { exports: {
@@ -33,7 +30,7 @@ const instancePromise = fetch(wasmUrl)
export default async function rotate( export default async function rotate(
data: ImageData, data: ImageData,
opts: RotateOptions, opts: Options,
): Promise<ImageData> { ): Promise<ImageData> {
const instance = (await instancePromise).instance as RotateModuleInstance; const instance = (await instancePromise).instance as RotateModuleInstance;

View File

@@ -3,5 +3,5 @@
"compilerOptions": { "compilerOptions": {
"lib": ["webworker", "esnext"] "lib": ["webworker", "esnext"]
}, },
"references": [{ "path": "../../../" }] "references": [{ "path": "../../../" }, { "path": "../shared" }]
} }

View File

@@ -3,13 +3,27 @@ import {
BuiltinResizeMethod, BuiltinResizeMethod,
drawableToImageData, drawableToImageData,
} from 'client/lazy-app/util'; } from 'client/lazy-app/util';
import { BrowserResizeOptions, VectorResizeOptions } from '../shared/meta'; import {
BrowserResizeOptions,
VectorResizeOptions,
WorkerResizeOptions,
Options,
workerResizeMethods,
} from '../shared/meta';
import { getContainOffsets } from '../shared/util'; import { getContainOffsets } from '../shared/util';
import type { SourceImage } from 'client/lazy-app/Compress';
import type WorkerBridge from 'client/lazy-app/worker-bridge';
export function browserResize( /**
data: ImageData, * Return whether a set of options are worker resize options.
opts: BrowserResizeOptions, *
): ImageData { * @param opts
*/
function isWorkerOptions(opts: Options): opts is WorkerResizeOptions {
return (workerResizeMethods as string[]).includes(opts.method);
}
function browserResize(data: ImageData, opts: BrowserResizeOptions): ImageData {
let sx = 0; let sx = 0;
let sy = 0; let sy = 0;
let sw = data.width; let sw = data.width;
@@ -31,7 +45,7 @@ export function browserResize(
); );
} }
export function vectorResize( function vectorResize(
data: HTMLImageElement, data: HTMLImageElement,
opts: VectorResizeOptions, opts: VectorResizeOptions,
): ImageData { ): ImageData {
@@ -53,3 +67,19 @@ export function vectorResize(
height: opts.height, height: opts.height,
}); });
} }
export async function resize(
signal: AbortSignal,
source: SourceImage,
options: Options,
workerBridge: WorkerBridge,
) {
if (options.method === 'vector') {
if (!source.vectorImage) throw Error('No vector image available');
return vectorResize(source.vectorImage, options);
}
if (isWorkerOptions(options)) {
return workerBridge.resize(signal, source.processed, options);
}
return browserResize(source.processed, options);
}

View File

@@ -7,6 +7,7 @@
"include": [ "include": [
"./*.ts", "./*.ts",
"../../../../client/lazy-app/util.ts", "../../../../client/lazy-app/util.ts",
"../../../../client/lazy-app/Compress/index.tsx",
"../shared/*.ts" "../shared/*.ts"
], ],
"references": [{ "path": "../shared" }] "references": [{ "path": "../shared" }]

View File

@@ -23,6 +23,14 @@ type WorkerResizeMethods =
| 'lanczos3' | 'lanczos3'
| 'hqx'; | 'hqx';
export const workerResizeMethods: WorkerResizeMethods[] = [
'triangle',
'catrom',
'mitchell',
'lanczos3',
'hqx',
];
export type Options = export type Options =
| BrowserResizeOptions | BrowserResizeOptions
| WorkerResizeOptions | WorkerResizeOptions