Adding resize preprocessor (#152)
* Adding resize preprocessor * Using ! on form * Haha oops * Using createImageBitmapPolyfill * Updating package.json * Oops again * Ooops again
This commit is contained in:
5
package-lock.json
generated
5
package-lock.json
generated
@ -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",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
128
src/codecs/resize/options.tsx
Normal file
128
src/codecs/resize/options.tsx
Normal file
@ -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<Props, State> {
|
||||
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 (
|
||||
<form ref={el => this.form = el}>
|
||||
<label>
|
||||
Method:
|
||||
<select
|
||||
name="resizeMethod"
|
||||
value={options.method}
|
||||
onChange={this.onChange}
|
||||
>
|
||||
<option value="browser-pixelated">Browser pixelated</option>
|
||||
<option value="browser-low">Browser low quality</option>
|
||||
<option value="browser-medium">Browser medium quality</option>
|
||||
<option value="browser-high">Browser high quality</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Width:
|
||||
<input
|
||||
required
|
||||
name="width"
|
||||
type="number"
|
||||
min="1"
|
||||
value={'' + options.width}
|
||||
onChange={this.onChange}
|
||||
onInput={this.onWidthInput}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Height:
|
||||
<input
|
||||
required
|
||||
name="height"
|
||||
type="number"
|
||||
min="1"
|
||||
value={'' + options.height}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
name="maintainAspect"
|
||||
type="checkbox"
|
||||
checked={maintainAspect}
|
||||
onChange={linkState(this, 'maintainAspect')}
|
||||
/>
|
||||
Maintain aspect ratio
|
||||
</label>
|
||||
<label style={{ display: maintainAspect ? 'none' : '' }}>
|
||||
Fit method:
|
||||
<select
|
||||
name="fitMethod"
|
||||
value={options.fitMethod}
|
||||
onChange={this.onChange}
|
||||
>
|
||||
<option value="stretch">Stretch</option>
|
||||
<option value="cover">Cover</option>
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
46
src/codecs/resize/resize.ts
Normal file
46
src/codecs/resize/resize.ts
Normal file
@ -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<ImageData> {
|
||||
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',
|
||||
};
|
@ -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<ImageData> {
|
||||
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<Props, State> {
|
||||
// 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<Props, State> {
|
||||
}
|
||||
|
||||
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 (
|
||||
<file-drop accept="image/*" onfiledrop={this.onFileDrop}>
|
||||
<div id="app" class={`${style.app} ${style[orientation]}`}>
|
||||
{(leftImageBmp && rightImageBmp) ? (
|
||||
{(leftImageBmp && rightImageBmp && source) ? (
|
||||
<Output
|
||||
orientation={orientation}
|
||||
imgWidth={source.bmp.width}
|
||||
imgHeight={source.bmp.height}
|
||||
leftImg={leftImageBmp}
|
||||
rightImg={rightImageBmp}
|
||||
leftImgContain={leftImage.preprocessorState.resize.fitMethod === 'cover'}
|
||||
rightImgContain={rightImage.preprocessorState.resize.fitMethod === 'cover'}
|
||||
/>
|
||||
) : (
|
||||
<div class={style.welcome}>
|
||||
@ -332,9 +352,10 @@ export default class App extends Component<Props, State> {
|
||||
<input type="file" onChange={this.onFileChange} />
|
||||
</div>
|
||||
)}
|
||||
{(leftImageBmp && rightImageBmp) && images.map((image, index) => (
|
||||
{(leftImageBmp && rightImageBmp && source) && images.map((image, index) => (
|
||||
<Options
|
||||
orientation={orientation}
|
||||
sourceAspect={source.bmp.width / source.bmp.height}
|
||||
imageIndex={index}
|
||||
imageFile={image.file}
|
||||
sourceImageFile={source && source.file}
|
||||
|
@ -10,6 +10,7 @@ import WebPEncoderOptions from '../../codecs/webp/options';
|
||||
import BrowserWebPEncoderOptions from '../../codecs/browser-webp/options';
|
||||
|
||||
import QuantizerOptionsComponent from '../../codecs/imagequant/options';
|
||||
import ResizeOptionsComponent from '../../codecs/resize/options';
|
||||
|
||||
import * as identity from '../../codecs/identity/encoder';
|
||||
import * as optiPNG from '../../codecs/optipng/encoder';
|
||||
@ -33,9 +34,8 @@ import {
|
||||
encoderMap,
|
||||
} from '../../codecs/encoders';
|
||||
import { QuantizeOptions } from '../../codecs/imagequant/quantizer';
|
||||
|
||||
import { ResizeOptions } from '../../codecs/resize/resize';
|
||||
import { PreprocessorState } from '../../codecs/preprocessors';
|
||||
|
||||
import FileSize from '../FileSize';
|
||||
import { DownloadIcon } from '../../lib/icons';
|
||||
|
||||
@ -62,6 +62,7 @@ const titles = {
|
||||
|
||||
interface Props {
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
sourceAspect: number;
|
||||
imageIndex: number;
|
||||
sourceImageFile?: File;
|
||||
imageFile?: File;
|
||||
@ -112,9 +113,17 @@ export default class Options extends Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
@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<Props, State> {
|
||||
</section>
|
||||
|
||||
{encoderState.type !== 'identity' && (
|
||||
<div key="quantization" class={style.quantization}>
|
||||
<div key="preprocessors" class={style.preprocessors}>
|
||||
<label class={style.toggle}>
|
||||
<input
|
||||
name="resize.enable"
|
||||
type="checkbox"
|
||||
checked={!!preprocessorState.resize.enabled}
|
||||
onChange={this.onPreprocessorEnabledChange}
|
||||
/>
|
||||
Resize
|
||||
</label>
|
||||
{preprocessorState.resize.enabled &&
|
||||
<ResizeOptionsComponent
|
||||
aspect={sourceAspect}
|
||||
options={preprocessorState.resize}
|
||||
onChange={this.onResizeOptionsChange}
|
||||
/>
|
||||
}
|
||||
<label class={style.toggle}>
|
||||
<input
|
||||
name="quantizer.enable"
|
||||
@ -169,7 +194,7 @@ export default class Options extends Component<Props, State> {
|
||||
checked={!!preprocessorState.quantizer.enabled}
|
||||
onChange={this.onPreprocessorEnabledChange}
|
||||
/>
|
||||
Enable Quantization
|
||||
Quantize
|
||||
</label>
|
||||
{preprocessorState.quantizer.enabled &&
|
||||
<QuantizerOptionsComponent
|
||||
|
@ -153,7 +153,7 @@ Note: These styles are temporary. They will be replaced before going live.
|
||||
}
|
||||
|
||||
|
||||
.quantization {
|
||||
.preprocessors {
|
||||
padding: 5px 0;
|
||||
margin: 5px 0;
|
||||
box-shadow: inset 0 -.5px 0 rgba(0,0,0,0.25), 0 .5px 0 rgba(255,255,255,0.15);
|
||||
|
@ -11,6 +11,10 @@ interface Props {
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
leftImg: ImageBitmap;
|
||||
rightImg: ImageBitmap;
|
||||
imgWidth: number;
|
||||
imgHeight: number;
|
||||
leftImgContain: boolean;
|
||||
rightImgContain: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@ -145,7 +149,7 @@ export default class Output extends Component<Props, State> {
|
||||
}
|
||||
|
||||
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<Props, State> {
|
||||
ref={linkRef(this, 'pinchZoomLeft')}
|
||||
>
|
||||
<canvas
|
||||
class={style.outputCanvas}
|
||||
ref={linkRef(this, 'canvasLeft')}
|
||||
width={leftImg.width}
|
||||
height={leftImg.height}
|
||||
style={{
|
||||
width: imgWidth,
|
||||
height: imgHeight,
|
||||
objectFit: leftImgContain ? 'contain' : '',
|
||||
}}
|
||||
/>
|
||||
</pinch-zoom>
|
||||
<pinch-zoom ref={linkRef(this, 'pinchZoomRight')}>
|
||||
<canvas
|
||||
class={style.outputCanvas}
|
||||
ref={linkRef(this, 'canvasRight')}
|
||||
width={rightImg.width}
|
||||
height={rightImg.height}
|
||||
style={{
|
||||
width: imgWidth,
|
||||
height: imgHeight,
|
||||
objectFit: rightImgContain ? 'contain' : '',
|
||||
}}
|
||||
/>
|
||||
</pinch-zoom>
|
||||
</two-up>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -154,8 +154,35 @@ export async function sniffMimeType(blob: Blob): Promise<string> {
|
||||
return '';
|
||||
}
|
||||
|
||||
export function createImageBitmapPolyfill(blob: Blob): Promise<ImageBitmap> {
|
||||
return createImageBitmap(blob);
|
||||
type CreateImageBitmapInput = HTMLImageElement | SVGImageElement | HTMLVideoElement |
|
||||
HTMLCanvasElement | ImageBitmap | ImageData | Blob;
|
||||
|
||||
export function createImageBitmapPolyfill(
|
||||
image: CreateImageBitmapInput,
|
||||
options?: ImageBitmapOptions,
|
||||
): Promise<ImageBitmap>;
|
||||
export function createImageBitmapPolyfill(
|
||||
image: CreateImageBitmapInput,
|
||||
sx: number,
|
||||
sy: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
options?: ImageBitmapOptions,
|
||||
): Promise<ImageBitmap>;
|
||||
export function createImageBitmapPolyfill(
|
||||
image: CreateImageBitmapInput,
|
||||
sxOrOptions?: number | ImageBitmapOptions,
|
||||
sy?: number,
|
||||
sw?: number,
|
||||
sh?: number,
|
||||
options?: ImageBitmapOptions,
|
||||
): Promise<ImageBitmap> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
Reference in New Issue
Block a user