* Basic rotate & flip

* Flipping resize when orientation changes

* Hack around critters issue.

* Removing generator. Huge perf boost.

* Stable positioning

* Creating input processors

* Allowing rotation to be changed

* Reverting old change

* Adding tooltips

* No more flip

* Removing need for wrapper element boxing

* Adding comment

* Addressing nits

* Bleh
This commit is contained in:
Jake Archibald
2018-11-30 11:00:25 +00:00
committed by GitHub
parent 7723bd3b5f
commit 1b693fb57a
15 changed files with 447 additions and 153 deletions

View File

@@ -0,0 +1,9 @@
import { defaultOptions as rotateDefaultOptions } from './rotate/processor-meta';
export interface InputProcessorState {
rotate: import('./rotate/processor-meta').RotateOptions;
}
export const defaultInputProcessorState: InputProcessorState = {
rotate: rotateDefaultOptions,
};

View File

@@ -1,11 +1,7 @@
import { expose } from 'comlink'; import { expose } from 'comlink';
import { EncodeOptions as MozJPEGEncoderOptions } from '../mozjpeg/encoder-meta';
import { QuantizeOptions } from '../imagequant/processor-meta';
import { EncodeOptions as OptiPNGEncoderOptions } from '../optipng/encoder-meta';
import { EncodeOptions as WebPEncoderOptions } from '../webp/encoder-meta';
async function mozjpegEncode( async function mozjpegEncode(
data: ImageData, options: MozJPEGEncoderOptions, data: ImageData, options: import('../mozjpeg/encoder-meta').EncodeOptions,
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const { encode } = await import( const { encode } = await import(
/* webpackChunkName: "process-mozjpeg-enc" */ /* webpackChunkName: "process-mozjpeg-enc" */
@@ -14,7 +10,9 @@ async function mozjpegEncode(
return encode(data, options); return encode(data, options);
} }
async function quantize(data: ImageData, opts: QuantizeOptions): Promise<ImageData> { async function quantize(
data: ImageData, opts: import('../imagequant/processor-meta').QuantizeOptions,
): Promise<ImageData> {
const { process } = await import( const { process } = await import(
/* webpackChunkName: "process-imagequant" */ /* webpackChunkName: "process-imagequant" */
'../imagequant/processor', '../imagequant/processor',
@@ -22,8 +20,19 @@ async function quantize(data: ImageData, opts: QuantizeOptions): Promise<ImageDa
return process(data, opts); return process(data, opts);
} }
async function rotate(
data: ImageData, opts: import('../rotate/processor-meta').RotateOptions,
): Promise<ImageData> {
const { rotate } = await import(
/* webpackChunkName: "process-rotate" */
'../rotate/processor',
);
return rotate(data, opts);
}
async function optiPngEncode( async function optiPngEncode(
data: BufferSource, options: OptiPNGEncoderOptions, data: BufferSource, options: import('../optipng/encoder-meta').EncodeOptions,
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const { compress } = await import( const { compress } = await import(
/* webpackChunkName: "process-optipng" */ /* webpackChunkName: "process-optipng" */
@@ -33,7 +42,7 @@ async function optiPngEncode(
} }
async function webpEncode( async function webpEncode(
data: ImageData, options: WebPEncoderOptions, data: ImageData, options: import('../webp/encoder-meta').EncodeOptions,
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const { encode } = await import( const { encode } = await import(
/* webpackChunkName: "process-webp-enc" */ /* webpackChunkName: "process-webp-enc" */
@@ -50,7 +59,7 @@ async function webpDecode(data: ArrayBuffer): Promise<ImageData> {
return decode(data); return decode(data);
} }
const exports = { mozjpegEncode, quantize, optiPngEncode, webpEncode, webpDecode }; const exports = { mozjpegEncode, quantize, rotate, optiPngEncode, webpEncode, webpDecode };
export type ProcessorWorkerApi = typeof exports; export type ProcessorWorkerApi = typeof exports;
expose(exports, self); expose(exports, self);

View File

@@ -118,12 +118,18 @@ export default class Processor {
} }
// Off main thread jobs: // Off main thread jobs:
@Processor._processingJob({ needsWorker: true }) @Processor._processingJob({ needsWorker: true })
imageQuant(data: ImageData, opts: QuantizeOptions): Promise<ImageData> { imageQuant(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
return this._workerApi!.quantize(data, opts); return this._workerApi!.quantize(data, opts);
} }
@Processor._processingJob({ needsWorker: true })
rotate(
data: ImageData, opts: import('./rotate/processor-meta').RotateOptions,
): Promise<ImageData> {
return this._workerApi!.rotate(data, opts);
}
@Processor._processingJob({ needsWorker: true }) @Processor._processingJob({ needsWorker: true })
mozjpegEncode( mozjpegEncode(
data: ImageData, opts: MozJPEGEncoderOptions, data: ImageData, opts: MozJPEGEncoderOptions,

View File

@@ -135,7 +135,7 @@ export default class ResizerOptions extends Component<Props, State> {
onChange={this.onChange} onChange={this.onChange}
> >
<option value="stretch">Stretch</option> <option value="stretch">Stretch</option>
<option value="cover">Cover</option> <option value="contain">Contain</option>
</Select> </Select>
</label> </label>
} }

View File

@@ -4,7 +4,7 @@ export interface ResizeOptions {
width: number; width: number;
height: number; height: number;
method: 'vector' | BitmapResizeMethods; method: 'vector' | BitmapResizeMethods;
fitMethod: 'stretch' | 'cover'; fitMethod: 'stretch' | 'contain';
} }
export interface BitmapResizeOptions extends ResizeOptions { export interface BitmapResizeOptions extends ResizeOptions {

View File

@@ -1,7 +1,7 @@
import { nativeResize, NativeResizeMethod, drawableToImageData } from '../../lib/util'; import { nativeResize, NativeResizeMethod, drawableToImageData } from '../../lib/util';
import { BitmapResizeOptions, VectorResizeOptions } from './processor-meta'; import { BitmapResizeOptions, VectorResizeOptions } from './processor-meta';
function getCoverOffsets(sw: number, sh: number, dw: number, dh: number) { function getContainOffsets(sw: number, sh: number, dw: number, dh: number) {
const currentAspect = sw / sh; const currentAspect = sw / sh;
const endAspect = dw / dh; const endAspect = dw / dh;
@@ -22,8 +22,8 @@ export function resize(data: ImageData, opts: BitmapResizeOptions): ImageData {
let sw = data.width; let sw = data.width;
let sh = data.height; let sh = data.height;
if (opts.fitMethod === 'cover') { if (opts.fitMethod === 'contain') {
({ sx, sy, sw, sh } = getCoverOffsets(sw, sh, opts.width, opts.height)); ({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height));
} }
return nativeResize( return nativeResize(
@@ -38,8 +38,8 @@ export function vectorResize(data: HTMLImageElement, opts: VectorResizeOptions):
let sw = data.width; let sw = data.width;
let sh = data.height; let sh = data.height;
if (opts.fitMethod === 'cover') { if (opts.fitMethod === 'contain') {
({ sx, sy, sw, sh } = getCoverOffsets(sw, sh, opts.width, opts.height)); ({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height));
} }
return drawableToImageData(data, { return drawableToImageData(data, {

View File

@@ -0,0 +1,5 @@
export interface RotateOptions {
rotate: 0 | 90 | 180 | 270;
}
export const defaultOptions: RotateOptions = { rotate: 0 };

View File

@@ -0,0 +1,80 @@
import { RotateOptions } from './processor-meta';
const bpp = 4;
export function rotate(data: ImageData, opts: RotateOptions): ImageData {
const { rotate } = opts;
// Early exit if there's no transform.
if (rotate === 0) return data;
const flipDimensions = rotate % 180 !== 0;
const { width: inputWidth, height: inputHeight } = data;
const outputWidth = flipDimensions ? inputHeight : inputWidth;
const outputHeight = flipDimensions ? inputWidth : inputHeight;
const out = new ImageData(outputWidth, outputHeight);
let i = 0;
// In the straight-copy case, d1 is x, d2 is y.
// x starts at 0 and increases.
// y starts at 0 and increases.
let d1Start = 0;
let d1Limit = inputWidth;
let d1Advance = 1;
let d1Multiplier = 1;
let d2Start = 0;
let d2Limit = inputHeight;
let d2Advance = 1;
let d2Multiplier = inputWidth;
if (rotate === 90) {
// d1 is y, d2 is x.
// y starts at its max value and decreases.
// x starts at 0 and increases.
d1Start = inputHeight - 1;
d1Limit = inputHeight;
d1Advance = -1;
d1Multiplier = inputWidth;
d2Start = 0;
d2Limit = inputWidth;
d2Advance = 1;
d2Multiplier = 1;
} else if (rotate === 180) {
// d1 is x, d2 is y.
// x starts at its max and decreases.
// y starts at its max and decreases.
d1Start = inputWidth - 1;
d1Limit = inputWidth;
d1Advance = -1;
d1Multiplier = 1;
d2Start = inputHeight - 1;
d2Limit = inputHeight;
d2Advance = -1;
d2Multiplier = inputWidth;
} else if (rotate === 270) {
// d1 is y, d2 is x.
// y starts at 0 and increases.
// x starts at its max and decreases.
d1Start = 0;
d1Limit = inputHeight;
d1Advance = 1;
d1Multiplier = inputWidth;
d2Start = inputWidth - 1;
d2Limit = inputWidth;
d2Advance = -1;
d2Multiplier = 1;
}
for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
// Iterate over channels:
const start = ((d1 * d1Multiplier) + (d2 * d2Multiplier)) * bpp;
for (let j = 0; j < bpp; j += 1) {
out.data[i] = data.data[start + j];
i += 1;
}
}
}
return out;
}

View File

@@ -18,12 +18,6 @@ const offlinerPromise = import(
'../../lib/offliner', '../../lib/offliner',
); );
export interface SourceImage {
file: File | Fileish;
data: ImageData;
vectorImage?: HTMLImageElement;
}
interface Props {} interface Props {}
interface State { interface State {

View File

@@ -35,7 +35,7 @@ import {
import { QuantizeOptions } from '../../codecs/imagequant/processor-meta'; import { QuantizeOptions } from '../../codecs/imagequant/processor-meta';
import { ResizeOptions } from '../../codecs/resize/processor-meta'; import { ResizeOptions } from '../../codecs/resize/processor-meta';
import { PreprocessorState } from '../../codecs/preprocessors'; import { PreprocessorState } from '../../codecs/preprocessors';
import { SourceImage } from '../App'; import { SourceImage } from '../compress';
import Checkbox from '../checkbox'; import Checkbox from '../checkbox';
import Expander from '../expander'; import Expander from '../expander';
import Select from '../select'; import Select from '../select';
@@ -81,7 +81,7 @@ export default class Options extends Component<Props, State> {
} }
@bind @bind
onEncoderTypeChange(event: Event) { private onEncoderTypeChange(event: Event) {
const el = event.currentTarget as HTMLSelectElement; const el = event.currentTarget as HTMLSelectElement;
// The select element only has values matching encoder types, // The select element only has values matching encoder types,
@@ -91,7 +91,7 @@ export default class Options extends Component<Props, State> {
} }
@bind @bind
onPreprocessorEnabledChange(event: Event) { private onPreprocessorEnabledChange(event: Event) {
const el = event.currentTarget as HTMLInputElement; const el = event.currentTarget as HTMLInputElement;
const preprocessor = el.name.split('.')[0] as keyof PreprocessorState; const preprocessor = el.name.split('.')[0] as keyof PreprocessorState;
@@ -101,14 +101,14 @@ export default class Options extends Component<Props, State> {
} }
@bind @bind
onQuantizerOptionsChange(opts: QuantizeOptions) { private onQuantizerOptionsChange(opts: QuantizeOptions) {
this.props.onPreprocessorOptionsChange( this.props.onPreprocessorOptionsChange(
cleanMerge(this.props.preprocessorState, 'quantizer', opts), cleanMerge(this.props.preprocessorState, 'quantizer', opts),
); );
} }
@bind @bind
onResizeOptionsChange(opts: ResizeOptions) { private onResizeOptionsChange(opts: ResizeOptions) {
this.props.onPreprocessorOptionsChange( this.props.onPreprocessorOptionsChange(
cleanMerge(this.props.preprocessorState, 'resize', opts), cleanMerge(this.props.preprocessorState, 'resize', opts),
); );
@@ -144,7 +144,7 @@ export default class Options extends Component<Props, State> {
{preprocessorState.resize.enabled ? {preprocessorState.resize.enabled ?
<ResizeOptionsComponent <ResizeOptionsComponent
isVector={Boolean(source && source.vectorImage)} isVector={Boolean(source && source.vectorImage)}
aspect={source ? (source.data.width / source.data.height) : 1} aspect={source ? source.processed.width / source.processed.height : 1}
options={preprocessorState.resize} options={preprocessorState.resize}
onChange={this.onResizeOptionsChange} onChange={this.onResizeOptionsChange}
/> />

View File

@@ -5,17 +5,29 @@ import './custom-els/TwoUp';
import * as style from './style.scss'; import * as style from './style.scss';
import { bind, linkRef } from '../../lib/initial-util'; import { bind, linkRef } from '../../lib/initial-util';
import { shallowEqual, drawDataToCanvas } from '../../lib/util'; import { shallowEqual, drawDataToCanvas } from '../../lib/util';
import { ToggleIcon, AddIcon, RemoveIcon, BackIcon } from '../../lib/icons'; import {
ToggleBackgroundIcon,
AddIcon,
RemoveIcon,
BackIcon,
ToggleBackgroundActiveIcon,
RotateIcon,
} from '../../lib/icons';
import { twoUpHandle } from './custom-els/TwoUp/styles.css'; import { twoUpHandle } from './custom-els/TwoUp/styles.css';
import { InputProcessorState } from '../../codecs/input-processors';
import { cleanSet } from '../../lib/clean-modify';
import { SourceImage } from '../compress';
interface Props { interface Props {
originalImage?: ImageData; source?: SourceImage;
inputProcessorState?: InputProcessorState;
mobileView: boolean; mobileView: boolean;
leftCompressed?: ImageData; leftCompressed?: ImageData;
rightCompressed?: ImageData; rightCompressed?: ImageData;
leftImgContain: boolean; leftImgContain: boolean;
rightImgContain: boolean; rightImgContain: boolean;
onBack: () => void; onBack: () => void;
onInputProcessorChange: (newState: InputProcessorState) => void;
} }
interface State { interface State {
@@ -70,6 +82,38 @@ export default class Output extends Component<Props, State> {
const prevRightDraw = this.rightDrawable(prevProps); const prevRightDraw = this.rightDrawable(prevProps);
const leftDraw = this.leftDrawable(); const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable(); const rightDraw = this.rightDrawable();
const sourceFileChanged =
// Has the value become (un)defined?
(!!this.props.source !== !!prevProps.source) ||
// Or has the file changed?
(this.props.source && prevProps.source && this.props.source.file !== prevProps.source.file);
const oldSourceData = prevProps.source && prevProps.source.processed;
const newSourceData = this.props.source && this.props.source.processed;
const pinchZoom = this.pinchZoomLeft!;
if (sourceFileChanged) {
// New image? Reset the pinch-zoom.
pinchZoom.setTransform({
allowChangeEvent: true,
x: 0,
y: 0,
scale: 1,
});
} else if (oldSourceData && newSourceData && oldSourceData !== newSourceData) {
// Since the pinch zoom transform origin is the top-left of the content, we need to flip
// things around a bit when the content size changes, so the new content appears as if it were
// central to the previous content.
const scaleChange = 1 - pinchZoom.scale;
const oldXScaleOffset = oldSourceData.width / 2 * scaleChange;
const oldYScaleOffset = oldSourceData.height / 2 * scaleChange;
pinchZoom.setTransform({
allowChangeEvent: true,
x: pinchZoom.x - oldXScaleOffset + oldYScaleOffset,
y: pinchZoom.y - oldYScaleOffset + oldXScaleOffset,
});
}
if (leftDraw && leftDraw !== prevLeftDraw && this.canvasLeft) { if (leftDraw && leftDraw !== prevLeftDraw && this.canvasLeft) {
drawDataToCanvas(this.canvasLeft, leftDraw); drawDataToCanvas(this.canvasLeft, leftDraw);
@@ -77,16 +121,6 @@ export default class Output extends Component<Props, State> {
if (rightDraw && rightDraw !== prevRightDraw && this.canvasRight) { if (rightDraw && rightDraw !== prevRightDraw && this.canvasRight) {
drawDataToCanvas(this.canvasRight, rightDraw); drawDataToCanvas(this.canvasRight, rightDraw);
} }
if (this.props.originalImage !== prevProps.originalImage && this.pinchZoomLeft) {
// New image? Reset the pinch-zoom.
this.pinchZoomLeft.setTransform({
allowChangeEvent: true,
x: 0,
y: 0,
scale: 1,
});
}
} }
shouldComponentUpdate(nextProps: Props, nextState: State) { shouldComponentUpdate(nextProps: Props, nextState: State) {
@@ -94,11 +128,11 @@ export default class Output extends Component<Props, State> {
} }
private leftDrawable(props: Props = this.props): ImageData | undefined { private leftDrawable(props: Props = this.props): ImageData | undefined {
return props.leftCompressed || props.originalImage; return props.leftCompressed || (props.source && props.source.processed);
} }
private rightDrawable(props: Props = this.props): ImageData | undefined { private rightDrawable(props: Props = this.props): ImageData | undefined {
return props.rightCompressed || props.originalImage; return props.rightCompressed || (props.source && props.source.processed);
} }
@bind @bind
@@ -122,6 +156,20 @@ export default class Output extends Component<Props, State> {
this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts); this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts);
} }
@bind
private onRotateClick() {
const { inputProcessorState } = this.props;
if (!inputProcessorState) return;
const newState = cleanSet(
inputProcessorState,
'rotate.rotate',
(inputProcessorState.rotate.rotate + 90) % 360,
);
this.props.onInputProcessorChange(newState);
}
@bind @bind
private onScaleValueFocus() { private onScaleValueFocus() {
this.setState({ editingScale: true }, () => { this.setState({ editingScale: true }, () => {
@@ -201,11 +249,13 @@ export default class Output extends Component<Props, State> {
} }
render( render(
{ mobileView, leftImgContain, rightImgContain, originalImage, onBack }: Props, { mobileView, leftImgContain, rightImgContain, source, onBack }: Props,
{ scale, editingScale, altBackground }: State, { scale, editingScale, altBackground }: State,
) { ) {
const leftDraw = this.leftDrawable(); const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable(); const rightDraw = this.rightDrawable();
// To keep position stable, the output is put in a square using the longest dimension.
const originalImage = source && source.processed;
return ( return (
<div class={`${style.output} ${altBackground ? style.altBackground : ''}`}> <div class={`${style.output} ${altBackground ? style.altBackground : ''}`}>
@@ -227,7 +277,7 @@ export default class Output extends Component<Props, State> {
ref={linkRef(this, 'pinchZoomLeft')} ref={linkRef(this, 'pinchZoomLeft')}
> >
<canvas <canvas
class={style.outputCanvas} class={style.pinchTarget}
ref={linkRef(this, 'canvasLeft')} ref={linkRef(this, 'canvasLeft')}
width={leftDraw && leftDraw.width} width={leftDraw && leftDraw.width}
height={leftDraw && leftDraw.height} height={leftDraw && leftDraw.height}
@@ -240,7 +290,7 @@ export default class Output extends Component<Props, State> {
</pinch-zoom> </pinch-zoom>
<pinch-zoom class={style.pinchZoom} ref={linkRef(this, 'pinchZoomRight')}> <pinch-zoom class={style.pinchZoom} ref={linkRef(this, 'pinchZoomRight')}>
<canvas <canvas
class={style.outputCanvas} class={style.pinchTarget}
ref={linkRef(this, 'canvasRight')} ref={linkRef(this, 'canvasRight')}
width={rightDraw && rightDraw.width} width={rightDraw && rightDraw.width}
height={rightDraw && rightDraw.height} height={rightDraw && rightDraw.height}
@@ -286,9 +336,18 @@ export default class Output extends Component<Props, State> {
<AddIcon /> <AddIcon />
</button> </button>
</div> </div>
<button class={style.button} onClick={this.toggleBackground}> <button class={style.button} onClick={this.onRotateClick} title="Rotate image">
<ToggleIcon /> <RotateIcon />
Toggle Background </button>
<button
class={`${style.button} ${altBackground ? style.active : ''}`}
onClick={this.toggleBackground}
title="Change canvas color"
>
{altBackground
? <ToggleBackgroundActiveIcon />
: <ToggleBackgroundIcon />
}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -31,6 +31,13 @@
align-items: center; align-items: center;
} }
.pinch-target {
// This fixes a severe painting bug in Chrome.
// We should try to remove this once the issue is fixed.
// https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10
will-change: auto;
}
.controls { .controls {
position: absolute; position: absolute;
display: flex; display: flex;
@@ -87,6 +94,7 @@
white-space: nowrap; white-space: nowrap;
height: 36px; height: 36px;
padding: 0 8px; padding: 0 8px;
cursor: pointer;
@media (min-width: 600px) { @media (min-width: 600px) {
height: 48px; height: 48px;
@@ -101,15 +109,20 @@
} }
.button { .button {
text-transform: uppercase;
color: var(--button-fg); color: var(--button-fg);
cursor: pointer;
text-indent: 6px;
font-size: 110%;
&:hover { &:hover {
background-color: #eee; background-color: #eee;
} }
&.active {
background: #34B9EB;
color: #fff;
&:hover {
background: #32a3ce;
}
}
} }
.zoom { .zoom {
@@ -133,14 +146,6 @@
border-bottom: 1px dashed #999; border-bottom: 1px dashed #999;
} }
.output-canvas {
flex-shrink: 0;
// This fixes a severe painting bug in Chrome.
// We should try to remove this once the issue is fixed.
// https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10
will-change: auto;
}
.back { .back {
position: absolute; position: absolute;
top: 0; top: 0;

View File

@@ -35,21 +35,29 @@ import { VectorResizeOptions, BitmapResizeOptions } from '../../codecs/resize/pr
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 'src/lib/SnackBar'; import SnackBarElement from '../../lib/SnackBar';
import { InputProcessorState, defaultInputProcessorState } from '../../codecs/input-processors';
export interface SourceImage { export interface SourceImage {
file: File | Fileish; file: File | Fileish;
data: ImageData; decoded: ImageData;
processed: ImageData;
vectorImage?: HTMLImageElement; vectorImage?: HTMLImageElement;
inputProcessorState: InputProcessorState;
} }
interface EncodedImage { interface SideSettings {
preprocessorState: PreprocessorState;
encoderState: EncoderState;
}
interface Side {
preprocessed?: ImageData; preprocessed?: ImageData;
file?: Fileish; file?: Fileish;
downloadUrl?: string; downloadUrl?: string;
data?: ImageData; data?: ImageData;
preprocessorState: PreprocessorState; latestSettings: SideSettings;
encoderState: EncoderState; encodedSettings?: SideSettings;
loading: boolean; loading: boolean;
/** Counter of the latest bmp currently encoding */ /** Counter of the latest bmp currently encoding */
loadingCounter: number; loadingCounter: number;
@@ -65,7 +73,7 @@ interface Props {
interface State { interface State {
source?: SourceImage; source?: SourceImage;
images: [EncodedImage, EncodedImage]; sides: [Side, Side];
/** Source image load */ /** Source image load */
loading: boolean; loading: boolean;
loadingCounter: number; loadingCounter: number;
@@ -77,12 +85,21 @@ interface UpdateImageOptions {
skipPreprocessing?: boolean; skipPreprocessing?: boolean;
} }
function processInput(
data: ImageData,
inputProcessData: InputProcessorState,
processor: Processor,
) {
return processor.rotate(data, inputProcessData.rotate);
}
async function preprocessImage( async function preprocessImage(
source: SourceImage, source: SourceImage,
preprocessData: PreprocessorState, preprocessData: PreprocessorState,
processor: Processor, processor: Processor,
): Promise<ImageData> { ): Promise<ImageData> {
let result = source.data; let result = source.processed;
if (preprocessData.resize.enabled) { if (preprocessData.resize.enabled) {
if (preprocessData.resize.method === 'vector' && source.vectorImage) { if (preprocessData.resize.method === 'vector' && source.vectorImage) {
result = processor.vectorResize( result = processor.vectorResize(
@@ -131,6 +148,26 @@ async function compressImage(
); );
} }
function stateForNewSourceData(state: State, newSource: SourceImage): 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(blob: Blob): Promise<HTMLImageElement> { async function processSvg(blob: Blob): Promise<HTMLImageElement> {
// Firefox throws if you try to draw an SVG to canvas that doesn't have width/height. // 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. // In Chrome it loads, but drawImage behaves weirdly.
@@ -171,17 +208,21 @@ export default class Compress extends Component<Props, State> {
source: undefined, source: undefined,
loading: false, loading: false,
loadingCounter: 0, loadingCounter: 0,
images: [ sides: [
{ {
preprocessorState: defaultPreprocessorState, latestSettings: {
encoderState: { type: identity.type, options: identity.defaultOptions }, preprocessorState: defaultPreprocessorState,
encoderState: { type: identity.type, options: identity.defaultOptions },
},
loadingCounter: 0, loadingCounter: 0,
loadedCounter: 0, loadedCounter: 0,
loading: false, loading: false,
}, },
{ {
preprocessorState: defaultPreprocessorState, latestSettings: {
encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions }, preprocessorState: defaultPreprocessorState,
encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions },
},
loadingCounter: 0, loadingCounter: 0,
loadedCounter: 0, loadedCounter: 0,
loading: false, loading: false,
@@ -209,7 +250,7 @@ export default class Compress extends Component<Props, State> {
private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void { private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void {
this.setState({ this.setState({
images: cleanSet(this.state.images, `${index}.encoderState`, { sides: cleanSet(this.state.sides, `${index}.latestSettings.encoderState`, {
type: newType, type: newType,
options: encoderMap[newType].defaultOptions, options: encoderMap[newType].defaultOptions,
}), }),
@@ -218,20 +259,18 @@ export default class Compress extends Component<Props, State> {
private onPreprocessorOptionsChange(index: 0 | 1, options: PreprocessorState): void { private onPreprocessorOptionsChange(index: 0 | 1, options: PreprocessorState): void {
this.setState({ this.setState({
images: cleanSet(this.state.images, `${index}.preprocessorState`, options), sides: cleanSet(this.state.sides, `${index}.latestSettings.preprocessorState`, options),
}); });
} }
private onEncoderOptionsChange(index: 0 | 1, options: EncoderOptions): void { private onEncoderOptionsChange(index: 0 | 1, options: EncoderOptions): void {
this.setState({ this.setState({
images: cleanSet(this.state.images, `${index}.encoderState.options`, options), sides: cleanSet(this.state.sides, `${index}.latestSettings.encoderState.options`, options),
}); });
} }
private updateDocumentTitle(filename: string = '') { private updateDocumentTitle(filename: string = ''): void {
const newTitle: string = filename ? `${filename} - ${originalDocumentTitle}` : originalDocumentTitle; document.title = filename ? `${filename} - ${originalDocumentTitle}` : originalDocumentTitle;
document.title = newTitle;
} }
componentWillReceiveProps(nextProps: Props): void { componentWillReceiveProps(nextProps: Props): void {
@@ -245,20 +284,25 @@ export default class Compress extends Component<Props, State> {
} }
componentDidUpdate(prevProps: Props, prevState: State): void { componentDidUpdate(prevProps: Props, prevState: State): void {
const { source, images } = this.state; const { source, sides } = this.state;
for (const [i, image] of images.entries()) { const sourceDataChanged =
const prevImage = prevState.images[i]; // Has the source object become set/unset?
const sourceChanged = source !== prevState.source; !!source !== !!prevState.source ||
const encoderChanged = image.encoderState !== prevImage.encoderState; // Or has the processed data changed?
const preprocessorChanged = image.preprocessorState !== prevImage.preprocessorState; (source && prevState.source && source.processed !== prevState.source.processed);
for (const [i, side] of sides.entries()) {
const prevSettings = prevState.sides[i].latestSettings;
const encoderChanged = side.latestSettings.encoderState !== prevSettings.encoderState;
const preprocessorChanged =
side.latestSettings.preprocessorState !== 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.
if (sourceChanged || encoderChanged || preprocessorChanged) { if (sourceDataChanged || encoderChanged || preprocessorChanged) {
if (prevImage.downloadUrl) URL.revokeObjectURL(prevImage.downloadUrl);
this.updateImage(i, { this.updateImage(i, {
skipPreprocessing: !sourceChanged && !preprocessorChanged, skipPreprocessing: !sourceDataChanged && !preprocessorChanged,
}).catch((err) => { }).catch((err) => {
console.error(err); console.error(err);
}); });
@@ -268,10 +312,10 @@ export default class Compress extends Component<Props, State> {
private async onCopyToOtherClick(index: 0 | 1) { private async onCopyToOtherClick(index: 0 | 1) {
const otherIndex = (index + 1) % 2; const otherIndex = (index + 1) % 2;
const oldSettings = this.state.images[otherIndex]; const oldSettings = this.state.sides[otherIndex];
this.setState({ this.setState({
images: cleanSet(this.state.images, otherIndex, this.state.images[index]), sides: cleanSet(this.state.sides, otherIndex, this.state.sides[index]),
}); });
const result = await this.props.showSnack('Settings copied across', { const result = await this.props.showSnack('Settings copied across', {
@@ -282,13 +326,67 @@ export default class Compress extends Component<Props, State> {
if (result !== 'undo') return; if (result !== 'undo') return;
this.setState({ this.setState({
images: cleanSet(this.state.images, otherIndex, oldSettings), sides: cleanSet(this.state.sides, otherIndex, oldSettings),
}); });
} }
@bind
private async onInputProcessorChange(options: InputProcessorState): Promise<void> {
const source = this.state.source;
if (!source) return;
const oldRotate = source.inputProcessorState.rotate.rotate;
const newRotate = options.rotate.rotate;
const orientationChanged = oldRotate % 180 !== newRotate % 180;
const loadingCounter = this.state.loadingCounter + 1;
// Either processor is good enough here.
const processor = this.leftProcessor;
this.setState({
loadingCounter, loading: true,
source: cleanSet(source, 'inputProcessorState', options),
});
// Abort any current encode jobs, as they're redundant now.
this.leftProcessor.abortCurrent();
this.rightProcessor.abortCurrent();
try {
const processed = await processInput(source.decoded, options, processor);
// Another file has been opened/processed before this one processed.
if (this.state.loadingCounter !== loadingCounter) return;
let newState = { ...this.state, loading: false };
newState = cleanSet(newState, 'source.processed', processed);
newState = stateForNewSourceData(newState, newState.source!);
if (orientationChanged) {
// If orientation has changed, we should flip the resize values.
for (const i of [0, 1]) {
const resizeSettings = newState.sides[i].latestSettings.preprocessorState.resize;
newState = cleanMerge(newState, `sides.${i}.latestSettings.preprocessorState.resize`, {
width: resizeSettings.height,
height: resizeSettings.width,
});
}
}
this.setState(newState);
} catch (err) {
if (err.name === 'AbortError') return;
console.error(err);
// Another file has been opened/processed before this one processed.
if (this.state.loadingCounter !== loadingCounter) return;
this.props.showSnack('Processing error');
this.setState({ loading: false });
}
}
@bind @bind
private async updateFile(file: File | Fileish) { private async updateFile(file: File | Fileish) {
const loadingCounter = this.state.loadingCounter + 1; const loadingCounter = this.state.loadingCounter + 1;
// Either processor is good enough here.
const processor = this.leftProcessor;
this.setState({ loadingCounter, loading: true }); this.setState({ loadingCounter, loading: true });
@@ -297,7 +395,7 @@ export default class Compress extends Component<Props, State> {
this.rightProcessor.abortCurrent(); this.rightProcessor.abortCurrent();
try { try {
let data: ImageData; let decoded: ImageData;
let vectorImage: HTMLImageElement | undefined; let vectorImage: HTMLImageElement | undefined;
// Special-case SVG. We need to avoid createImageBitmap because of // Special-case SVG. We need to avoid createImageBitmap because of
@@ -305,37 +403,33 @@ export default class Compress extends Component<Props, State> {
// Also, we cache the HTMLImageElement so we can perform vector resizing later. // Also, we cache the HTMLImageElement so we can perform vector resizing later.
if (file.type.startsWith('image/svg+xml')) { if (file.type.startsWith('image/svg+xml')) {
vectorImage = await processSvg(file); vectorImage = await processSvg(file);
data = drawableToImageData(vectorImage); decoded = drawableToImageData(vectorImage);
} else { } else {
// Either processor is good enough here. // Either processor is good enough here.
data = await decodeImage(file, this.leftProcessor); decoded = await decodeImage(file, processor);
} }
// Another file has been opened before this one processed. const processed = await processInput(decoded, defaultInputProcessorState, processor);
// Another file has been opened/processed before this one processed.
if (this.state.loadingCounter !== loadingCounter) return; if (this.state.loadingCounter !== loadingCounter) return;
let newState: State = { let newState: State = {
...this.state, ...this.state,
source: { data, file, vectorImage }, source: {
decoded, file, vectorImage, processed,
inputProcessorState: defaultInputProcessorState,
},
loading: false, loading: false,
}; };
newState = stateForNewSourceData(newState, newState.source!);
for (const i of [0, 1]) { for (const i of [0, 1]) {
// Ditch previous encodings
const downloadUrl = this.state.images[i].downloadUrl;
if (downloadUrl) URL.revokeObjectURL(downloadUrl!);
newState = cleanMerge(newState, `images.${i}`, {
preprocessed: undefined,
file: undefined,
downloadUrl: undefined,
data: undefined,
});
// Default resize values come from the image: // Default resize values come from the image:
newState = cleanMerge(newState, `images.${i}.preprocessorState.resize`, { newState = cleanMerge(newState, `sides.${i}.latestSettings.preprocessorState.resize`, {
width: data.width, width: processed.width,
height: data.height, height: processed.height,
method: vectorImage ? 'vector' : 'browser-high', method: vectorImage ? 'vector' : 'browser-high',
}); });
} }
@@ -345,7 +439,7 @@ export default class Compress extends Component<Props, State> {
} catch (err) { } catch (err) {
if (err.name === 'AbortError') return; if (err.name === 'AbortError') return;
console.error(err); console.error(err);
// Another file has been opened 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;
this.props.showSnack('Invalid image'); this.props.showSnack('Invalid image');
this.setState({ loading: false }); this.setState({ loading: false });
@@ -353,26 +447,31 @@ export default class Compress extends Component<Props, State> {
} }
private async updateImage(index: number, options: UpdateImageOptions = {}): Promise<void> { private async updateImage(index: number, options: UpdateImageOptions = {}): Promise<void> {
const { skipPreprocessing = false } = options; const {
skipPreprocessing = false,
} = options;
const { source } = this.state; const { source } = this.state;
if (!source) return; if (!source) return;
// Each time we trigger an async encode, the counter changes. // Each time we trigger an async encode, the counter changes.
const loadingCounter = this.state.images[index].loadingCounter + 1; const loadingCounter = this.state.sides[index].loadingCounter + 1;
let images = cleanMerge(this.state.images, index, { let sides = cleanMerge(this.state.sides, index, {
loadingCounter, loadingCounter,
loading: true, loading: true,
}); });
this.setState({ images }); this.setState({ sides });
const image = images[index]; const side = sides[index];
const settings = side.latestSettings;
let file: File | Fileish | undefined; let file: File | Fileish | undefined;
let preprocessed: ImageData | undefined; let preprocessed: ImageData | undefined;
let data: ImageData | undefined; let data: ImageData | undefined;
const cacheResult = this.encodeCache.match(source, image.preprocessorState, image.encoderState); const cacheResult = this.encodeCache.match(
source.processed, settings.preprocessorState, settings.encoderState,
);
const processor = (index === 0) ? this.leftProcessor : this.rightProcessor; const processor = (index === 0) ? this.leftProcessor : this.rightProcessor;
// Abort anything the processor is currently doing. // Abort anything the processor is currently doing.
@@ -385,60 +484,66 @@ export default class Compress extends Component<Props, State> {
} else { } else {
try { try {
// Special case for identity // Special case for identity
if (image.encoderState.type === identity.type) { if (settings.encoderState.type === identity.type) {
({ file, data } = source); file = source.file;
data = source.processed;
} else { } else {
preprocessed = (skipPreprocessing && image.preprocessed) preprocessed = (skipPreprocessing && side.preprocessed)
? image.preprocessed ? side.preprocessed
: await preprocessImage(source, image.preprocessorState, processor); : await preprocessImage(source, settings.preprocessorState, processor);
file = await compressImage(preprocessed, image.encoderState, source.file.name, processor); file = await compressImage(
preprocessed, settings.encoderState, source.file.name, processor,
);
data = await decodeImage(file, processor); data = await decodeImage(file, processor);
this.encodeCache.add({ this.encodeCache.add({
source,
data, data,
preprocessed, preprocessed,
file, file,
encoderState: image.encoderState, sourceData: source.processed,
preprocessorState: image.preprocessorState, encoderState: settings.encoderState,
preprocessorState: settings.preprocessorState,
}); });
} }
} catch (err) { } catch (err) {
if (err.name === 'AbortError') return; if (err.name === 'AbortError') return;
this.props.showSnack(`Processing error (type=${image.encoderState.type}): ${err}`); this.props.showSnack(`Processing error (type=${settings.encoderState.type}): ${err}`);
throw err; throw err;
} }
} }
const latestImage = this.state.images[index]; const latestData = this.state.sides[index];
// If a later encode has landed before this one, return. // If a later encode has landed before this one, return.
if (loadingCounter < latestImage.loadedCounter) { if (loadingCounter < latestData.loadedCounter) {
return; return;
} }
images = cleanMerge(this.state.images, index, { if (latestData.downloadUrl) URL.revokeObjectURL(latestData.downloadUrl);
sides = cleanMerge(this.state.sides, index, {
file, file,
data, data,
preprocessed, preprocessed,
downloadUrl: URL.createObjectURL(file), downloadUrl: URL.createObjectURL(file),
loading: images[index].loadingCounter !== loadingCounter, loading: sides[index].loadingCounter !== loadingCounter,
loadedCounter: loadingCounter, loadedCounter: loadingCounter,
encodedSettings: settings,
}); });
this.setState({ images }); this.setState({ sides });
} }
render({ onBack }: Props, { loading, images, source, mobileView }: State) { render({ onBack }: Props, { loading, sides, source, mobileView }: State) {
const [leftImage, rightImage] = images; const [leftSide, rightSide] = sides;
const [leftImageData, rightImageData] = images.map(i => i.data); const [leftImageData, rightImageData] = sides.map(i => i.data);
const options = images.map((image, index) => ( const options = sides.map((side, index) => (
<Options <Options
source={source} source={source}
mobileView={mobileView} mobileView={mobileView}
preprocessorState={image.preprocessorState} preprocessorState={side.latestSettings.preprocessorState}
encoderState={image.encoderState} encoderState={side.latestSettings.encoderState}
onEncoderTypeChange={this.onEncoderTypeChange.bind(this, index)} onEncoderTypeChange={this.onEncoderTypeChange.bind(this, index)}
onEncoderOptionsChange={this.onEncoderOptionsChange.bind(this, index)} onEncoderOptionsChange={this.onEncoderOptionsChange.bind(this, index)}
onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(this, index)} onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(this, index)}
@@ -448,33 +553,44 @@ export default class Compress extends Component<Props, State> {
const copyDirections = const copyDirections =
(mobileView ? ['down', 'up'] : ['right', 'left']) as CopyAcrossIconProps['copyDirection'][]; (mobileView ? ['down', 'up'] : ['right', 'left']) as CopyAcrossIconProps['copyDirection'][];
const results = images.map((image, index) => ( const results = sides.map((side, index) => (
<Results <Results
downloadUrl={image.downloadUrl} downloadUrl={side.downloadUrl}
imageFile={image.file} imageFile={side.file}
source={source} source={source}
loading={loading || image.loading} loading={loading || side.loading}
copyDirection={copyDirections[index]} copyDirection={copyDirections[index]}
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index)} onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index)}
buttonPosition={mobileView ? 'stack-right' : buttonPositions[index]} buttonPosition={mobileView ? 'stack-right' : buttonPositions[index]}
> >
{!mobileView ? null : [ {!mobileView ? null : [
<ExpandIcon class={style.expandIcon} key="expand-icon"/>, <ExpandIcon class={style.expandIcon} key="expand-icon"/>,
`${resultTitles[index]} (${encoderMap[image.encoderState.type].label})`, `${resultTitles[index]} (${encoderMap[side.latestSettings.encoderState.type].label})`,
]} ]}
</Results> </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.preprocessorState.resize.enabled &&
leftDisplaySettings.preprocessorState.resize.fitMethod === 'contain';
const rightImgContain = rightDisplaySettings.preprocessorState.resize.enabled &&
rightDisplaySettings.preprocessorState.resize.fitMethod === 'contain';
return ( return (
<div class={style.compress}> <div class={style.compress}>
<Output <Output
originalImage={source && source.data} source={source}
mobileView={mobileView} mobileView={mobileView}
leftCompressed={leftImageData} leftCompressed={leftImageData}
rightCompressed={rightImageData} rightCompressed={rightImageData}
leftImgContain={leftImage.preprocessorState.resize.fitMethod === 'cover'} leftImgContain={leftImgContain}
rightImgContain={rightImage.preprocessorState.resize.fitMethod === 'cover'} rightImgContain={rightImgContain}
onBack={onBack} onBack={onBack}
inputProcessorState={source && source.inputProcessorState}
onInputProcessorChange={this.onInputProcessorChange}
/> />
{mobileView {mobileView
? ( ? (

View File

@@ -1,7 +1,6 @@
import { EncoderState } from '../../codecs/encoders'; import { EncoderState } from '../../codecs/encoders';
import { Fileish } from '../../lib/initial-util'; import { Fileish } from '../../lib/initial-util';
import { shallowEqual } from '../../lib/util'; import { shallowEqual } from '../../lib/util';
import { SourceImage } from '.';
import { PreprocessorState } from '../../codecs/preprocessors'; import { PreprocessorState } from '../../codecs/preprocessors';
import * as identity from '../../codecs/identity/encoder-meta'; import * as identity from '../../codecs/identity/encoder-meta';
@@ -15,7 +14,7 @@ interface CacheResult {
interface CacheEntry extends CacheResult { interface CacheEntry extends CacheResult {
preprocessorState: PreprocessorState; preprocessorState: PreprocessorState;
encoderState: EncoderState; encoderState: EncoderState;
source: SourceImage; sourceData: ImageData;
} }
const SIZE = 5; const SIZE = 5;
@@ -32,13 +31,13 @@ export default class ResultCache {
} }
match( match(
source: SourceImage, sourceData: ImageData,
preprocessorState: PreprocessorState, preprocessorState: PreprocessorState,
encoderState: EncoderState, encoderState: EncoderState,
): CacheResult | undefined { ): CacheResult | undefined {
const matchingIndex = this._entries.findIndex((entry) => { const matchingIndex = this._entries.findIndex((entry) => {
// Check for quick exits: // Check for quick exits:
if (entry.source !== source) return false; if (entry.sourceData !== sourceData) return false;
if (entry.encoderState.type !== encoderState.type) return false; if (entry.encoderState.type !== encoderState.type) return false;
// Check that each set of options in the preprocessor are the same // Check that each set of options in the preprocessor are the same

View File

@@ -12,9 +12,21 @@ export const DownloadIcon = (props: JSX.HTMLAttributes) => (
</Icon> </Icon>
); );
export const ToggleIcon = (props: JSX.HTMLAttributes) => ( export const ToggleBackgroundIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}> <Icon {...props}>
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm2 4v-2H3c0 1.1.89 2 2 2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-18H9c-1.11 0-2 .9-2 2v10c0 1.1.89 2 2 2h10c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H9V5h10v10zm-8 6h2v-2h-2v2zm-4 0h2v-2H7v2z" /> <path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm2 4v-2H3c0 1.1.9 2 2 2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-18H9a2 2 0 0 0-2 2v10c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 12H9V5h10v10zm-8 6h2v-2h-2v2zm-4 0h2v-2H7v2z"/>
</Icon>
);
export const ToggleBackgroundActiveIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M9 7H7v2h2V7zm0 4H7v2h2v-2zm0-8a2 2 0 0 0-2 2h2V3zm4 12h-2v2h2v-2zm6-12v2h2a2 2 0 0 0-2-2zm-6 0h-2v2h2V3zM9 17v-2H7c0 1.1.9 2 2 2zm10-4h2v-2h-2v2zm0-4h2V7h-2v2zm0 8a2 2 0 0 0 2-2h-2v2zM5 7H3v12c0 1.1.9 2 2 2h12v-2H5V7zm10-2h2V3h-2v2zm0 12h2v-2h-2v2z"/>
</Icon>
);
export const RotateIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M15.6 5.5L11 1v3a8 8 0 0 0 0 16v-2a6 6 0 0 1 0-12v4l4.5-4.5zm4.3 5.5a8 8 0 0 0-1.6-3.9L17 8.5c.5.8.9 1.6 1 2.5h2zM13 17.9v2a8 8 0 0 0 3.9-1.6L15.5 17c-.8.5-1.6.9-2.5 1zm3.9-2.4l1.4 1.4A8 8 0 0 0 20 13h-2c-.1.9-.5 1.7-1 2.5z"/>
</Icon> </Icon>
); );