mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-12 16:57:26 +00:00
Rotate (#322)
* 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:
9
src/codecs/input-processors.ts
Normal file
9
src/codecs/input-processors.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
5
src/codecs/rotate/processor-meta.ts
Normal file
5
src/codecs/rotate/processor-meta.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface RotateOptions {
|
||||||
|
rotate: 0 | 90 | 180 | 270;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultOptions: RotateOptions = { rotate: 0 };
|
||||||
80
src/codecs/rotate/processor.ts
Normal file
80
src/codecs/rotate/processor.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
? (
|
? (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user