From ea5d3c2d781690ccdd71262f72573947d644848e Mon Sep 17 00:00:00 2001 From: Jake Archibald Date: Wed, 5 Sep 2018 15:46:26 +0100 Subject: [PATCH] Adding resize preprocessor (#152) * Adding resize preprocessor * Using ! on form * Haha oops * Using createImageBitmapPolyfill * Updating package.json * Oops again * Ooops again --- package-lock.json | 5 ++ package.json | 1 + src/codecs/preprocessors.ts | 8 +- src/codecs/resize/options.tsx | 128 ++++++++++++++++++++++++++++++ src/codecs/resize/resize.ts | 46 +++++++++++ src/components/App/index.tsx | 29 ++++++- src/components/Options/index.tsx | 33 +++++++- src/components/Options/style.scss | 2 +- src/components/Output/index.tsx | 18 ++++- src/components/Output/style.scss | 4 - src/lib/util.ts | 31 +++++++- 11 files changed, 286 insertions(+), 19 deletions(-) create mode 100644 src/codecs/resize/options.tsx create mode 100644 src/codecs/resize/resize.ts diff --git a/package-lock.json b/package-lock.json index f1a28dd14..0a313b8d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6681,6 +6681,11 @@ "invert-kv": "^1.0.0" } }, + "linkstate": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/linkstate/-/linkstate-1.1.1.tgz", + "integrity": "sha512-5SICdxQG9FpWk44wSEoM2WOCUNuYfClp10t51XAIV5E7vUILF/dTYxf0vJw6bW2dUd2wcQon+LkNtRijpNLrig==" + }, "listr": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/listr/-/listr-0.14.1.tgz", diff --git a/package.json b/package.json index c71c2fab9..95032d521 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "comlink": "^3.0.3", "comlink-loader": "^1.0.0", "preact": "^8.3.1", + "linkstate": "^1.1.1", "pretty-bytes": "^5.1.0" } } diff --git a/src/codecs/preprocessors.ts b/src/codecs/preprocessors.ts index c614aa50f..878ff7c46 100644 --- a/src/codecs/preprocessors.ts +++ b/src/codecs/preprocessors.ts @@ -1,15 +1,21 @@ import { QuantizeOptions, defaultOptions as quantizerDefaultOptions } from './imagequant/quantizer'; +import { ResizeOptions, defaultOptions as resizeDefaultOptions } from './resize/resize'; interface Enableable { enabled: boolean; } export interface PreprocessorState { quantizer: Enableable & QuantizeOptions; + resize: Enableable & ResizeOptions; } -export const defaultPreprocessorState = { +export const defaultPreprocessorState: PreprocessorState = { quantizer: { enabled: false, ...quantizerDefaultOptions, }, + resize: { + enabled: false, + ...resizeDefaultOptions, + }, }; diff --git a/src/codecs/resize/options.tsx b/src/codecs/resize/options.tsx new file mode 100644 index 000000000..261326ffd --- /dev/null +++ b/src/codecs/resize/options.tsx @@ -0,0 +1,128 @@ +import { h, Component } from 'preact'; +import linkState from 'linkstate'; +import { bind, inputFieldValueAsNumber } from '../../lib/util'; +import { ResizeOptions } from './resize'; + +interface Props { + options: ResizeOptions; + aspect: number; + onChange(newOptions: ResizeOptions): void; +} + +interface State { + maintainAspect: boolean; +} + +export default class ResizerOptions extends Component { + state: State = { + maintainAspect: true, + }; + + form?: HTMLFormElement; + + reportOptions() { + const width = this.form!.width as HTMLInputElement; + const height = this.form!.height as HTMLInputElement; + + if (!width.checkValidity() || !height.checkValidity()) return; + + const options: ResizeOptions = { + width: inputFieldValueAsNumber(width), + height: inputFieldValueAsNumber(height), + method: this.form!.resizeMethod.value, + fitMethod: this.form!.fitMethod.value, + }; + this.props.onChange(options); + } + + @bind + onChange(event: Event) { + this.reportOptions(); + } + + componentDidUpdate(prevProps: Props, prevState: State) { + if (!prevState.maintainAspect && this.state.maintainAspect) { + this.form!.height.value = Math.round(Number(this.form!.width.value) / this.props.aspect); + this.reportOptions(); + } + } + + @bind + onWidthInput(event: Event) { + if (!this.state.maintainAspect) return; + + const width = inputFieldValueAsNumber(this.form!.width); + this.form!.height.value = Math.round(width / this.props.aspect); + } + + @bind + onHeightInput(event: Event) { + if (!this.state.maintainAspect) return; + + const height = inputFieldValueAsNumber(this.form!.height); + this.form!.width.value = Math.round(height * this.props.aspect); + } + + render({ options, aspect }: Props, { maintainAspect }: State) { + return ( +
this.form = el}> + + + + + +
+ ); + } +} diff --git a/src/codecs/resize/resize.ts b/src/codecs/resize/resize.ts new file mode 100644 index 000000000..a59e81bb9 --- /dev/null +++ b/src/codecs/resize/resize.ts @@ -0,0 +1,46 @@ +import { bitmapToImageData, createImageBitmapPolyfill } from '../../lib/util'; + +type CreateImageBitmapResize = 'pixelated' | 'low' | 'medium' | 'high'; + +export async function resize(data: ImageData, opts: ResizeOptions): Promise { + let sx = 0; + let sy = 0; + let sw = data.width; + let sh = data.height; + + if (opts.fitMethod === 'cover') { + const currentAspect = data.width / data.height; + const endAspect = opts.width / opts.height; + if (endAspect > currentAspect) { + sh = opts.height / (opts.width / data.width); + sy = (data.height - sh) / 2; + } else { + sw = opts.width / (opts.height / data.height); + sx = (data.width - sw) / 2; + } + } + + const bmp = await createImageBitmapPolyfill(data, sx, sy, sw, sh, { + resizeQuality: opts.method.slice('browser-'.length) as CreateImageBitmapResize, + resizeWidth: opts.width, + resizeHeight: opts.height, + }); + + return bitmapToImageData(bmp); +} + +export interface ResizeOptions { + width: number; + height: number; + method: 'browser-pixelated' | 'browser-low' | 'browser-medium' | 'browser-high'; + fitMethod: 'stretch' | 'cover'; +} + +export const defaultOptions: ResizeOptions = { + // Width and height will always default to the image size. + // This is set elsewhere. + width: 1, + height: 1, + method: 'browser-high', + fitMethod: 'stretch', +}; diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx index 97ff87570..7f4474427 100644 --- a/src/components/App/index.tsx +++ b/src/components/App/index.tsx @@ -10,6 +10,7 @@ import ResultCache from './result-cache'; import * as quantizer from '../../codecs/imagequant/quantizer'; import * as optiPNG from '../../codecs/optipng/encoder'; +import * as resizer from '../../codecs/resize/resize'; import * as mozJPEG from '../../codecs/mozjpeg/encoder'; import * as webP from '../../codecs/webp/encoder'; import * as identity from '../../codecs/identity/encoder'; @@ -79,6 +80,9 @@ async function preprocessImage( preprocessData: PreprocessorState, ): Promise { let result = source.data; + if (preprocessData.resize.enabled) { + result = await resizer.resize(result, preprocessData.resize); + } if (preprocessData.quantizer.enabled) { result = await quantizer.quantize(result, preprocessData.quantizer); } @@ -227,10 +231,21 @@ export default class App extends Component { // compute the corresponding ImageData once since it only changes when the file changes: const data = await bitmapToImageData(bmp); - this.setState({ + let newState = { + ...this.state, source: { data, bmp, file }, loading: false, - }); + }; + + // Default resize values come from the image: + for (const i of [0, 1]) { + newState = cleanMerge(newState, `images.${i}.preprocessorState.resize`, { + width: data.width, + height: data.height, + }); + } + + this.setState(newState); } catch (err) { console.error(err); this.showError(`Invalid image`); @@ -314,17 +329,22 @@ export default class App extends Component { } render({ }: Props, { loading, images, source, orientation }: State) { + const [leftImage, rightImage] = images; const [leftImageBmp, rightImageBmp] = images.map(i => i.bmp); const anyLoading = loading || images.some(image => image.loading); return (
- {(leftImageBmp && rightImageBmp) ? ( + {(leftImageBmp && rightImageBmp && source) ? ( ) : (
@@ -332,9 +352,10 @@ export default class App extends Component {
)} - {(leftImageBmp && rightImageBmp) && images.map((image, index) => ( + {(leftImageBmp && rightImageBmp && source) && images.map((image, index) => ( { ); } + @bind + onResizeOptionsChange(opts: ResizeOptions) { + this.props.onPreprocessorOptionsChange( + cleanMerge(this.props.preprocessorState, 'resize', opts), + ); + } + render( { sourceImageFile, + sourceAspect, imageIndex, imageFile, downloadUrl, @@ -161,7 +170,23 @@ export default class Options extends Component { {encoderState.type !== 'identity' && ( -
+
+ + {preprocessorState.resize.enabled && + + } {preprocessorState.quantizer.enabled && { } render( - { orientation, leftImg, rightImg }: Props, + { orientation, leftImg, rightImg, imgWidth, imgHeight, leftImgContain, rightImgContain }: Props, { scale, editingScale, altBackground }: State, ) { return ( @@ -165,18 +169,26 @@ export default class Output extends Component { ref={linkRef(this, 'pinchZoomLeft')} > diff --git a/src/components/Output/style.scss b/src/components/Output/style.scss index d59f36055..0908d838a 100644 --- a/src/components/Output/style.scss +++ b/src/components/Output/style.scss @@ -134,7 +134,3 @@ Note: These styles are temporary. They will be replaced before going live. border-bottom-right-radius: 0; } } - -.outputCanvas { - image-rendering: pixelated; -} diff --git a/src/lib/util.ts b/src/lib/util.ts index ff270862f..cb6c99c97 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -154,8 +154,35 @@ export async function sniffMimeType(blob: Blob): Promise { return ''; } -export function createImageBitmapPolyfill(blob: Blob): Promise { - return createImageBitmap(blob); +type CreateImageBitmapInput = HTMLImageElement | SVGImageElement | HTMLVideoElement | + HTMLCanvasElement | ImageBitmap | ImageData | Blob; + +export function createImageBitmapPolyfill( + image: CreateImageBitmapInput, + options?: ImageBitmapOptions, +): Promise; +export function createImageBitmapPolyfill( + image: CreateImageBitmapInput, + sx: number, + sy: number, + sw: number, + sh: number, + options?: ImageBitmapOptions, +): Promise; +export function createImageBitmapPolyfill( + image: CreateImageBitmapInput, + sxOrOptions?: number | ImageBitmapOptions, + sy?: number, + sw?: number, + sh?: number, + options?: ImageBitmapOptions, +): Promise { + if (sxOrOptions === undefined || typeof sxOrOptions !== 'number') { + // sxOrOptions is absent or an options object + return createImageBitmap(image, sxOrOptions); + } + // sxOrOptions is a number + return createImageBitmap(image, sxOrOptions, sy!, sw!, sh!, options); } /**