-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
Options UI #39
Options UI #39
Changes from 36 commits
a2bec39
837e38a
64eb3f9
22ef181
8b73be0
25c0805
59a3517
06e4483
bb4ac4c
4d4d9b1
70d2927
e3ca13c
b069c7f
d4c4ae4
7d136db
11e6197
13cac89
9776670
820d6f5
8e7d468
148faa8
c21413a
8e27c5e
e83628a
e7fce7d
b4ea56f
d85218a
984b5fe
62fa05d
9fffc2a
f1cbd26
453e315
da6d1c4
a7f53ed
d618310
3ee32c9
3d3034f
d0ad2b2
c94e6dc
cbd84d3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import * as mozJPEG from './mozjpeg/encoder'; | ||
import { EncoderState as MozJPEGEncodeData, EncodeOptions as MozJPEGEncodeOptions } from './mozjpeg/encoder'; | ||
import * as identity from './identity/encoder'; | ||
import { EncoderState as IdentityEncodeData, EncodeOptions as IdentityEncodeOptions } from './identity/encoder'; | ||
|
||
export type EncoderState = IdentityEncodeData | MozJPEGEncodeData; | ||
export type EncoderOptions = IdentityEncodeOptions | MozJPEGEncodeOptions; | ||
export type EncoderType = keyof typeof encoderMap; | ||
|
||
export const encoderMap = { | ||
[identity.type]: identity, | ||
[mozJPEG.type]: mozJPEG | ||
}; | ||
|
||
export const encoders = Array.from(Object.values(encoderMap)); | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
export interface EncodeOptions {} | ||
export interface EncoderState { type: typeof type; options: EncodeOptions; } | ||
|
||
export const type = 'identity'; | ||
export const label = 'Original image'; | ||
export const defaultOptions: EncodeOptions = {}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import Encoder from './encoder.worker'; | ||
|
||
export interface EncodeOptions { quality?: number; } | ||
export interface EncoderState { type: typeof type; options: EncodeOptions; } | ||
|
||
export const type = 'mozjpeg'; | ||
export const label = 'MozJPEG'; | ||
export const mimeType = 'image/jpeg'; | ||
export const extension = 'jpg'; | ||
export const defaultOptions: EncodeOptions = { quality: 7 }; | ||
|
||
export async function encode(data: ImageData, options: EncodeOptions = {}) { | ||
// This is horrible because TypeScript doesn't realise that | ||
// Encoder has been comlinked. | ||
const encoder = new Encoder() as any as Promise<Encoder>; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah yeah that sucks. I had wondered about having encoders export an async factory function instead just to make the worker boundary transparent. |
||
return (await encoder).encode(data, options); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All encoders will export these things, except identity which doesn't encode, or have a static extensions / mime type etc. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All encoders will export these things, except identity which doesn't encode, or have a static extensions / mime type etc. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import { h, Component } from 'preact'; | ||
import { EncodeOptions } from './encoder'; | ||
import { bind } from '../../lib/util'; | ||
|
||
type Props = { | ||
options: EncodeOptions, | ||
onChange(newOptions: EncodeOptions): void | ||
}; | ||
|
||
export default class MozJpegCodecOptions extends Component<Props, {}> { | ||
@bind | ||
onChange(event: Event) { | ||
const el = event.currentTarget as HTMLInputElement; | ||
this.props.onChange({ quality: Number(el.value) }); | ||
} | ||
|
||
render({ options }: Props) { | ||
return ( | ||
<div> | ||
<label> | ||
Quality: | ||
<input | ||
name="quality" | ||
type="range" | ||
min="1" | ||
max="100" | ||
step="1" | ||
value={'' + options.quality} | ||
onChange={this.onChange} | ||
/> | ||
</label> | ||
</div> | ||
); | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I removed state from this component. The form elements are still controlled, but by the app. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,56 +2,192 @@ import { h, Component } from 'preact'; | |
import { bind, bitmapToImageData } from '../../lib/util'; | ||
import * as style from './style.scss'; | ||
import Output from '../output'; | ||
import Options from '../options'; | ||
|
||
import {MozJpegEncoder} from '../../lib/codec-wrappers/mozjpeg-enc'; | ||
import * as mozJPEG from '../../codecs/mozjpeg/encoder'; | ||
import * as identity from '../../codecs/identity/encoder'; | ||
import { EncoderState, EncoderType, EncoderOptions, encoderMap } from '../../codecs/encoders'; | ||
|
||
type Props = {}; | ||
interface SourceImage { | ||
file: File; | ||
bmp: ImageBitmap; | ||
data: ImageData; | ||
} | ||
|
||
interface EncodedImage { | ||
encoderState: EncoderState; | ||
bmp?: ImageBitmap; | ||
counter: number; | ||
loading: boolean; | ||
} | ||
|
||
type State = { | ||
img?: ImageBitmap | ||
}; | ||
interface Props {} | ||
|
||
interface State { | ||
source?: SourceImage; | ||
images: [EncodedImage, EncodedImage]; | ||
loading: boolean; | ||
error?: string; | ||
} | ||
|
||
export default class App extends Component<Props, State> { | ||
state: State = {}; | ||
state: State = { | ||
loading: false, | ||
images: [ | ||
{ | ||
encoderState: { type: identity.type, options: identity.defaultOptions }, | ||
counter: 0, | ||
loading: false | ||
}, | ||
{ | ||
encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions }, | ||
counter: 0, | ||
loading: false | ||
} | ||
] | ||
}; | ||
|
||
constructor() { | ||
super(); | ||
// In development, persist application state across hot reloads: | ||
if (process.env.NODE_ENV === 'development') { | ||
this.setState(window.STATE); | ||
this.componentDidUpdate = () => { | ||
let oldCDU = this.componentDidUpdate; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Care to explain? 😅 |
||
this.componentDidUpdate = (props, state) => { | ||
if (oldCDU) oldCDU.call(this, props, state); | ||
window.STATE = this.state; | ||
}; | ||
} | ||
} | ||
|
||
onEncoderChange(index: 0 | 1, type: EncoderType, options?: EncoderOptions): void { | ||
const images = this.state.images.slice() as [EncodedImage, EncodedImage]; | ||
const image = images[index]; | ||
|
||
// Some type cheating here. | ||
// encoderMap[type].defaultOptions is always safe. | ||
// options should always be correct for the type, but TypeScript isn't smart enough. | ||
const encoderState: EncoderState = { | ||
type, | ||
options: options ? options : encoderMap[type].defaultOptions | ||
} as EncoderState; | ||
|
||
images[index] = { | ||
...image, | ||
encoderState, | ||
loading: true, | ||
counter: image.counter++ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch. Will fix. |
||
}; | ||
|
||
this.setState({ images }); | ||
} | ||
|
||
onOptionsChange(index: 0 | 1, options: EncoderOptions): void { | ||
this.onEncoderChange(index, this.state.images[index].encoderState.type, options); | ||
} | ||
|
||
componentDidUpdate(prevProps: Props, prevState: State): void { | ||
const { source, images } = this.state; | ||
|
||
for (const [i, image] of images.entries()) { | ||
if (source !== prevState.source || image !== prevState.images[i]) { | ||
this.updateImage(i); | ||
} | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd like us to have a think about the best kind of debouncing to use in these cases, but not needed for this PR.
|
||
|
||
@bind | ||
async onFileChange(event: Event) { | ||
async onFileChange(event: Event): Promise<void> { | ||
const fileInput = event.target as HTMLInputElement; | ||
if (!fileInput.files || !fileInput.files[0]) return; | ||
// TODO: handle decode error | ||
const bitmap = await createImageBitmap(fileInput.files[0]); | ||
const data = await bitmapToImageData(bitmap); | ||
const encoder = new MozJpegEncoder(); | ||
const compressedData = await encoder.encode(data); | ||
const blob = new Blob([compressedData], {type: 'image/jpeg'}); | ||
const compressedImage = await createImageBitmap(blob); | ||
this.setState({ img: compressedImage }); | ||
} | ||
|
||
render({ }: Props, { img }: State) { | ||
const file = fileInput.files && fileInput.files[0]; | ||
if (!file) return; | ||
this.setState({ loading: true }); | ||
try { | ||
const bmp = await createImageBitmap(file); | ||
// compute the corresponding ImageData once since it only changes when the file changes: | ||
const data = await bitmapToImageData(bmp); | ||
this.setState({ | ||
source: { data, bmp, file }, | ||
error: undefined, | ||
loading: false | ||
}); | ||
} catch (err) { | ||
this.setState({ error: 'IMAGE_INVALID', loading: false }); | ||
} | ||
} | ||
|
||
async updateImage(index: number): Promise<void> { | ||
const { source, images } = this.state; | ||
if (!source) return; | ||
let image = images[index]; | ||
|
||
// Each time we trigger an async encode, the ID changes. | ||
const id = ++image.counter; | ||
image.loading = true; | ||
this.setState({ }); | ||
const result = await this.updateCompressedImage(source, image.encoderState); | ||
image = this.state.images[index]; | ||
// If another encode has been initiated since we started, ignore this one. | ||
if (image.counter !== id) return; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe I’m misunderstanding, but if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. I messed up the counter pretty well. Will fix. |
||
image.bmp = result; | ||
image.loading = false; | ||
this.setState({ }); | ||
} | ||
|
||
async updateCompressedImage(source: SourceImage, encodeData: EncoderState): Promise<ImageBitmap> { | ||
// Special case for identity | ||
if (encodeData.type === identity.type) return source.bmp; | ||
|
||
try { | ||
const compressedData = await (() => { | ||
switch (encodeData.type) { | ||
case mozJPEG.type: return mozJPEG.encode(source.data, encodeData.options); | ||
default: throw Error(`Unexpected encoder name`); | ||
} | ||
})(); | ||
|
||
const blob = new Blob([compressedData], { | ||
type: encoderMap[encodeData.type].mimeType | ||
}); | ||
|
||
const bitmap = await createImageBitmap(blob); | ||
this.setState({ error: '' }); | ||
return bitmap; | ||
} catch (err) { | ||
this.setState({ error: `Encoding error (type=${encodeData.type}): ${err}` }); | ||
throw err; | ||
} | ||
} | ||
|
||
render({ }: Props, { loading, error, images, source }: State) { | ||
const [leftImageBmp, rightImageBmp] = images.map(i => i.bmp); | ||
|
||
loading = loading || images.some(image => image.loading); | ||
|
||
return ( | ||
<div id="app" class={style.app}> | ||
{img ? | ||
<Output img={img} /> | ||
: | ||
<div> | ||
{(leftImageBmp && rightImageBmp) ? ( | ||
<Output leftImg={leftImageBmp} rightImg={rightImageBmp} /> | ||
) : ( | ||
<div class={style.welcome}> | ||
<h1>Select an image</h1> | ||
<input type="file" onChange={this.onFileChange} /> | ||
</div> | ||
} | ||
)} | ||
{images.map((image, index) => ( | ||
<span class={index ? style.rightLabel : style.leftLabel}>{encoderMap[image.encoderState.type].label}</span> | ||
))} | ||
{images.map((image, index) => ( | ||
<Options | ||
class={index ? style.rightOptions : style.leftOptions} | ||
encoderState={image.encoderState} | ||
onTypeChange={this.onEncoderChange.bind(this, index)} | ||
onOptionsChange={this.onOptionsChange.bind(this, index)} | ||
/> | ||
))} | ||
{loading && <span style={{ position: 'fixed', top: 0, left: 0 }}>Loading...</span>} | ||
{error && <span style={{ position: 'fixed', top: 0, left: 0 }}>Error: {error}</span>} | ||
</div> | ||
); | ||
} | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We'll need to edit this file as we add new encoders. It isn't as DRY as I'd like, but I couldn't get TypeScript to do better.