Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding resize preprocessor #152

Merged
merged 7 commits into from
Sep 5, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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