Skip to content

Commit

Permalink
Adding resize preprocessor (#152)
Browse files Browse the repository at this point in the history
* Adding resize preprocessor

* Using ! on form

* Haha oops

* Using createImageBitmapPolyfill

* Updating package.json

* Oops again

* Ooops again
  • Loading branch information
jakearchibald authored Sep 5, 2018
1 parent 700b1f1 commit ea5d3c2
Show file tree
Hide file tree
Showing 11 changed files with 286 additions and 19 deletions.
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
8 changes: 7 additions & 1 deletion src/codecs/preprocessors.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 128 additions & 0 deletions src/codecs/resize/options.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 46 additions & 0 deletions src/codecs/resize/resize.ts
Original file line number Diff line number Diff line change
@@ -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',
};
29 changes: 25 additions & 4 deletions src/components/App/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -314,27 +329,33 @@ 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}>
<h1>Drop, paste or select an image</h1>
<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}
Expand Down
33 changes: 29 additions & 4 deletions src/components/Options/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand All @@ -62,6 +62,7 @@ const titles = {

interface Props {
orientation: 'horizontal' | 'vertical';
sourceAspect: number;
imageIndex: number;
sourceImageFile?: File;
imageFile?: File;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -161,15 +170,31 @@ 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"
type="checkbox"
checked={!!preprocessorState.quantizer.enabled}
onChange={this.onPreprocessorEnabledChange}
/>
Enable Quantization
Quantize
</label>
{preprocessorState.quantizer.enabled &&
<QuantizerOptionsComponent
Expand Down
2 changes: 1 addition & 1 deletion src/components/Options/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading

0 comments on commit ea5d3c2

Please sign in to comment.