All options

This commit is contained in:
Jake Archibald
2020-11-11 12:12:05 +00:00
parent 196e6e1aea
commit be4601b93a
34 changed files with 2484 additions and 166 deletions

View File

@@ -7,12 +7,12 @@
"include": [
"src/features/**/client/**/*",
"src/features/**/shared/**/*",
// Not really clean, but we need to access the type of the functions here
"src/features/client-utils/**/*",
"src/shared/**/*",
"src/client/**/*",
// Not really clean, but we need these to access the type of the functions
// for comlink
"src/features/**/worker/**/*",
// And again.
"src/features-worker/**/*",
"src/shared/**/*",
"src/client/**/*"
"src/features-worker/**/*"
]
}

View File

@@ -214,6 +214,11 @@ export default function () {
([path, name]) => `import * as ${name}ProcessorMeta from '${path}';`,
),
`interface Enableable { enabled: boolean; }`,
`export interface ProcessorOptions {`,
processorMetaTsNames.map(
([_, name]) => ` ${name}: ${name}ProcessorMeta.Options;`,
),
`}`,
`export interface ProcessorState {`,
processorMetaTsNames.map(
([_, name]) => ` ${name}: Enableable & ${name}ProcessorMeta.Options;`,

6
package-lock.json generated
View File

@@ -2312,6 +2312,12 @@
"integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=",
"dev": true
},
"linkstate": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/linkstate/-/linkstate-1.1.1.tgz",
"integrity": "sha512-5SICdxQG9FpWk44wSEoM2WOCUNuYfClp10t51XAIV5E7vUILF/dTYxf0vJw6bW2dUd2wcQon+LkNtRijpNLrig==",
"dev": true
},
"lint-staged": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-10.4.0.tgz",

View File

@@ -21,6 +21,7 @@
"file-drop-element": "^1.0.1",
"husky": "^4.3.0",
"idb-keyval": "^3.2.0",
"linkstate": "^1.1.1",
"lint-staged": "^10.4.0",
"lodash.camelcase": "^4.3.0",
"mime-types": "^2.1.27",

View File

@@ -0,0 +1,23 @@
import { h, Component } from 'preact';
import * as style from './style.css';
import 'add-css:./styles.css';
import { UncheckedIcon, CheckedIcon } from '../../../icons';
interface Props extends preact.JSX.HTMLAttributes {}
interface State {}
export default class Checkbox extends Component<Props, State> {
render(props: Props) {
return (
<div class={style.checkbox}>
{props.checked ? (
<CheckedIcon class={`${style.icon} ${style.checked}`} />
) : (
<UncheckedIcon class={style.icon} />
)}
{/* @ts-ignore - TS bug https://github.com/microsoft/TypeScript/issues/16019 */}
<input class={style.realCheckbox} type="checkbox" {...props} />
</div>
);
}
}

View File

@@ -0,0 +1,22 @@
.checkbox {
display: inline-block;
position: relative;
--size: 17px;
}
.real-checkbox {
top: 0;
position: absolute;
opacity: 0;
pointer-events: none;
}
.icon {
display: block;
width: var(--size);
height: var(--size);
}
.checked {
fill: #34b9eb;
}

View File

@@ -0,0 +1,80 @@
import { h, Component, ComponentChild, ComponentChildren } from 'preact';
import * as style from './style.css';
import 'add-css:./styles.css';
import { transitionHeight } from '../../../util';
interface Props {
children: ComponentChildren;
}
interface State {
outgoingChildren: ComponentChild[];
}
export default class Expander extends Component<Props, State> {
state: State = {
outgoingChildren: [],
};
private lastElHeight: number = 0;
componentWillReceiveProps(nextProps: Props) {
const children = this.props.children as ComponentChild[];
const nextChildren = nextProps.children as ComponentChild[];
if (!nextChildren[0] && children[0]) {
// Cache the current children for the shrink animation.
this.setState({ outgoingChildren: children });
}
}
componentWillUpdate(nextProps: Props) {
const children = this.props.children as ComponentChild[];
const nextChildren = nextProps.children as ComponentChild[];
// Only interested if going from empty to not-empty, or not-empty to empty.
if ((children[0] && nextChildren[0]) || (!children[0] && !nextChildren[0]))
return;
this.lastElHeight = (this
.base as HTMLElement).getBoundingClientRect().height;
}
async componentDidUpdate(previousProps: Props) {
const children = this.props.children as ComponentChild[];
const previousChildren = previousProps.children as ComponentChild[];
// Only interested if going from empty to not-empty, or not-empty to empty.
if (
(children[0] && previousChildren[0]) ||
(!children[0] && !previousChildren[0])
)
return;
// What height do we need to transition to?
(this.base as HTMLElement).style.height = '';
(this.base as HTMLElement).style.overflow = 'hidden';
const newHeight = children[0]
? (this.base as HTMLElement).getBoundingClientRect().height
: 0;
await transitionHeight(this.base as HTMLElement, {
duration: 300,
from: this.lastElHeight,
to: newHeight,
});
// Unset the height & overflow, so element changes do the right thing.
(this.base as HTMLElement).style.height = '';
(this.base as HTMLElement).style.overflow = '';
if (this.state.outgoingChildren[0]) this.setState({ outgoingChildren: [] });
}
render(props: Props, { outgoingChildren }: State) {
const children = props.children as ComponentChild[];
const childrenExiting = !children[0] && outgoingChildren[0];
return (
<div class={childrenExiting ? style.childrenExiting : ''}>
{children[0] ? children : outgoingChildren}
</div>
);
}
}

View File

@@ -0,0 +1,5 @@
.children-exiting {
& > * {
pointer-events: none;
}
}

View File

@@ -0,0 +1,165 @@
import PointerTracker from 'pointer-tracker';
import * as style from './style.css';
import 'add-css:./styles.css';
const RETARGETED_EVENTS = ['focus', 'blur'];
const UPDATE_EVENTS = ['input', 'change'];
const REFLECTED_PROPERTIES = [
'name',
'min',
'max',
'step',
'value',
'disabled',
];
const REFLECTED_ATTRIBUTES = [
'name',
'min',
'max',
'step',
'value',
'disabled',
];
function getPrescision(value: string): number {
const afterDecimal = value.split('.')[1];
return afterDecimal ? afterDecimal.length : 0;
}
class RangeInputElement extends HTMLElement {
private _input: HTMLInputElement;
private _valueDisplay?: HTMLDivElement;
private _ignoreChange = false;
static get observedAttributes() {
return REFLECTED_ATTRIBUTES;
}
constructor() {
super();
this._input = document.createElement('input');
this._input.type = 'range';
this._input.className = style.input;
const tracker = new PointerTracker(this._input, {
start: (): boolean => {
if (tracker.currentPointers.length !== 0) return false;
this._input.classList.add(style.touchActive);
return true;
},
end: () => {
this._input.classList.remove(style.touchActive);
},
});
for (const event of RETARGETED_EVENTS) {
this._input.addEventListener(event, this._retargetEvent, true);
}
for (const event of UPDATE_EVENTS) {
this._input.addEventListener(event, this._update, true);
}
}
connectedCallback() {
if (this.contains(this._input)) return;
this.innerHTML =
`<div class="${style.thumbWrapper}">` +
`<div class="${style.thumb}"></div>` +
`<div class="${style.valueDisplay}"></div>` +
'</div>';
this.insertBefore(this._input, this.firstChild);
this._valueDisplay = this.querySelector(
'.' + style.valueDisplay,
) as HTMLDivElement;
// Set inline styles (this is useful when used with frameworks which might clear inline styles)
this._update();
}
get labelPrecision(): string {
return this.getAttribute('label-precision') || '';
}
set labelPrecision(precision: string) {
this.setAttribute('label-precision', precision);
}
attributeChangedCallback(
name: string,
oldValue: string,
newValue: string | null,
) {
if (this._ignoreChange) return;
if (newValue === null) {
this._input.removeAttribute(name);
} else {
this._input.setAttribute(name, newValue);
}
this._reflectAttributes();
this._update();
}
private _retargetEvent = (event: Event) => {
event.stopImmediatePropagation();
const retargetted = new Event(event.type, event);
this.dispatchEvent(retargetted);
};
private _update = () => {
const value = Number(this.value) || 0;
const min = Number(this.min) || 0;
const max = Number(this.max) || 100;
const labelPrecision =
Number(this.labelPrecision) || getPrescision(this.step) || 0;
const percent = (100 * (value - min)) / (max - min);
const displayValue = labelPrecision
? value.toFixed(labelPrecision)
: Math.round(value).toString();
this._valueDisplay!.textContent = displayValue;
this.style.setProperty('--value-percent', percent + '%');
this.style.setProperty('--value-width', '' + displayValue.length);
};
private _reflectAttributes() {
this._ignoreChange = true;
for (const attributeName of REFLECTED_ATTRIBUTES) {
if (this._input.hasAttribute(attributeName)) {
this.setAttribute(
attributeName,
this._input.getAttribute(attributeName)!,
);
} else {
this.removeAttribute(attributeName);
}
}
this._ignoreChange = false;
}
}
interface RangeInputElement {
name: string;
min: string;
max: string;
step: string;
value: string;
disabled: boolean;
}
for (const prop of REFLECTED_PROPERTIES) {
Object.defineProperty(RangeInputElement.prototype, prop, {
get() {
return this._input[prop];
},
set(val) {
this._input[prop] = val;
this._reflectAttributes();
this._update();
},
});
}
export default RangeInputElement;
customElements.define('range-input', RangeInputElement);

View File

@@ -0,0 +1,9 @@
declare module 'preact' {
namespace createElement.JSX {
interface IntrinsicElements {
'range-input': HTMLAttributes;
}
}
}
export {};

View File

@@ -0,0 +1,98 @@
range-input {
position: relative;
display: flex;
height: 18px;
width: 130px;
margin: 2px;
font: inherit;
line-height: 16px;
overflow: visible;
}
/* Disabled inputs are greyed out */
range-input[disabled] {
filter: grayscale(1);
}
range-input::before {
content: '';
display: block;
position: absolute;
top: 8px;
left: 0;
width: 100%;
height: 2px;
border-radius: 1px;
box-shadow: 0 -0.5px 0 rgba(0, 0, 0, 0.3),
inset 0 0.5px 0 rgba(255, 255, 255, 0.2), 0 0.5px 0 rgba(255, 255, 255, 0.3);
background: linear-gradient(#34b9eb, #218ab1) 0 / var(--value-percent, 0%)
100% no-repeat #eee;
}
.input {
position: relative;
width: 100%;
padding: 0;
margin: 0;
opacity: 0;
}
.thumb {
pointer-events: none;
position: absolute;
bottom: 3px;
left: var(--value-percent, 0%);
margin-left: -6px;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><circle cx="5" cy="5" r="1" fill="%235D509E" /></svg>')
center no-repeat #34b9eb;
border-radius: 50%;
width: 12px;
height: 12px;
box-shadow: 0 0.5px 2px rgba(0, 0, 0, 0.3);
}
.thumb-wrapper {
position: absolute;
left: 6px;
right: 6px;
bottom: 0;
height: 0;
overflow: visible;
}
.value-display {
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="32" height="62" fill="none"><path fill="%2334B9EB" d="M27.3 27.3C25 29.6 17 35.8 17 43v3c0 3 2.5 5 3.2 5.8a6 6 0 1 1-8.5 0C12.6 51 15 49 15 46v-3c0-7.2-8-13.4-10.3-15.7A16 16 0 0 1 16 0a16 16 0 0 1 11.3 27.3z"/><circle cx="16" cy="56" r="1" fill="%235D509E"/></svg>')
top center no-repeat;
position: absolute;
box-sizing: border-box;
left: var(--value-percent, 0%);
bottom: 3px;
width: 32px;
height: 62px;
text-align: center;
padding: 8px 3px 0;
margin: 0 0 0 -16px;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.3));
transform-origin: 50% 90%;
opacity: 0.0001;
transform: scale(0.2);
color: #fff;
font: inherit;
font-size: calc(100% - var(--value-width, 3) / 5 * 0.2em);
text-overflow: clip;
text-shadow: 0 -0.5px 0 rgba(0, 0, 0, 0.4);
transition: all 200ms ease;
transition-property: opacity, transform;
will-change: transform;
pointer-events: none;
overflow: hidden;
}
.touch-active + .thumb-wrapper .value-display {
opacity: 1;
transform: scale(1);
}
.touch-active + .thumb-wrapper .thumb {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}

View File

@@ -0,0 +1,55 @@
import { h, Component } from 'preact';
import * as style from './style.css';
import 'add-css:./styles.css';
import RangeInputElement from './custom-els/RangeInput';
import '../../custom-els/RangeInput';
import { linkRef } from 'shared/initial-app/util';
interface Props extends preact.JSX.HTMLAttributes {}
interface State {}
export default class Range extends Component<Props, State> {
rangeWc?: RangeInputElement;
private onTextInput = (event: Event) => {
const input = event.target as HTMLInputElement;
const value = input.value.trim();
if (!value) return;
this.rangeWc!.value = input.value;
this.rangeWc!.dispatchEvent(
new InputEvent('input', {
bubbles: event.bubbles,
}),
);
};
render(props: Props) {
const { children, ...otherProps } = props;
const { value, min, max, step } = props;
return (
<label class={style.range}>
<span class={style.labelText}>{children}</span>
{/* On interaction, Safari gives focus to the first element in the label, so the
<range-input> is deliberately first. */}
<div class={style.rangeWcContainer}>
<range-input
ref={linkRef(this, 'rangeWc')}
class={style.rangeWc}
{...otherProps}
/>
</div>
<input
type="number"
class={style.textInput}
value={value}
min={min}
max={max}
step={step}
onInput={this.onTextInput}
/>
</label>
);
}
}

View File

@@ -0,0 +1,55 @@
.range {
position: relative;
z-index: 0;
display: grid;
grid-template-columns: 1fr auto;
}
.label-text {
color: #fff; /* TEMP */
}
.range-wc-container {
position: relative;
z-index: 1;
grid-row: 2 / 3;
grid-column: 1 / 3;
}
.range-wc {
width: 100%;
}
.text-input {
grid-row: 1 / 2;
grid-column: 2 / 3;
text-align: right;
background: transparent;
color: inherit;
font: inherit;
border: none;
padding: 2px 5px;
box-sizing: border-box;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-position: under;
width: 48px;
position: relative;
left: 5px;
&:focus {
background: #fff;
color: #000;
}
// Remove the number controls
-moz-appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-moz-appearance: none;
-webkit-appearance: none;
margin: 0;
}
}

View File

@@ -0,0 +1,27 @@
import { h, Component } from 'preact';
import * as style from './style.css';
import 'add-css:./styles.css';
interface Props extends preact.JSX.HTMLAttributes {
large?: boolean;
}
interface State {}
export default class Select extends Component<Props, State> {
render(props: Props) {
const { large, ...otherProps } = props;
return (
<div class={style.select}>
{/* @ts-ignore - TS bug https://github.com/microsoft/TypeScript/issues/16019 */}
<select
class={`${style.builtinSelect} ${large ? style.large : ''}`}
{...otherProps}
/>
<svg class={style.arrow} viewBox="0 0 10 5">
<path d="M0 0l5 5 5-5z" />
</svg>
</div>
);
}
}

View File

@@ -0,0 +1,33 @@
.select {
position: relative;
}
.builtin-select {
background: #2f2f2f;
border-radius: 4px;
font: inherit;
padding: 4px 25px 4px 10px;
-webkit-appearance: none;
-moz-appearance: none;
border: none;
color: #fff;
width: 100%;
}
.arrow {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
fill: #fff;
width: 10px;
}
.large {
padding: 10px 35px 10px 10px;
background: #151515;
& .arrow {
right: 13px;
}
}

View File

@@ -0,0 +1,190 @@
import { h, Component } from 'preact';
import * as style from './style.css';
import 'add-css:./styles.css';
import { cleanSet, cleanMerge } from '../../util/clean-modify';
import type { SourceImage, OutputType } from '..';
import {
EncoderOptions,
EncoderState,
ProcessorState,
ProcessorOptions,
encoderMap,
} from '../../feature-meta';
import Expander from './Expander';
import Checkbox from './Checkbox';
import Select from './Select';
import { Options as QuantOptionsComponent } from 'features/processors/quantize/client';
import { Options as ResizeOptionsComponent } from 'features/processors/resize/client';
interface Props {
mobileView: boolean;
source?: SourceImage;
encoderState?: EncoderState;
processorState: ProcessorState;
onEncoderTypeChange(newType: OutputType): void;
onEncoderOptionsChange(newOptions: EncoderOptions): void;
onProcessorOptionsChange(newOptions: ProcessorState): void;
}
interface State {
supportedEncoderMap?: PartialButNotUndefined<typeof encoderMap>;
}
type PartialButNotUndefined<T> = {
[P in keyof T]: T[P];
};
const supportedEncoderMapP: Promise<PartialButNotUndefined<
typeof encoderMap
>> = (async () => {
const supportedEncoderMap: PartialButNotUndefined<typeof encoderMap> = {
...encoderMap,
};
// Filter out entries where the feature test fails
await Promise.all(
Object.entries(encoderMap).map(async ([encoderName, details]) => {
if ('featureTest' in details && !(await details.featureTest())) {
delete supportedEncoderMap[encoderName as keyof typeof encoderMap];
}
}),
);
return supportedEncoderMap;
})();
export default class Options extends Component<Props, State> {
state: State = {
supportedEncoderMap: undefined,
};
constructor() {
super();
supportedEncoderMapP.then((supportedEncoderMap) =>
this.setState({ supportedEncoderMap }),
);
}
private onEncoderTypeChange = (event: Event) => {
const el = event.currentTarget as HTMLSelectElement;
// The select element only has values matching encoder types,
// so 'as' is safe here.
const type = el.value as OutputType;
this.props.onEncoderTypeChange(type);
};
private onProcessorEnabledChange = (event: Event) => {
const el = event.currentTarget as HTMLInputElement;
const processor = el.name.split('.')[0] as keyof ProcessorState;
this.props.onProcessorOptionsChange(
cleanSet(this.props.processorState, `${processor}.enabled`, el.checked),
);
};
private onQuantizerOptionsChange = (opts: ProcessorOptions['quantize']) => {
this.props.onProcessorOptionsChange(
cleanMerge(this.props.processorState, 'quantizer', opts),
);
};
private onResizeOptionsChange = (opts: ProcessorOptions['resize']) => {
this.props.onProcessorOptionsChange(
cleanMerge(this.props.processorState, 'resize', opts),
);
};
render(
{ source, encoderState, processorState, onEncoderOptionsChange }: Props,
{ supportedEncoderMap }: State,
) {
const encoder = encoderState && encoderMap[encoderState.type];
const EncoderOptionComponent =
encoder && 'Options' in encoder ? encoder.Options : undefined;
return (
<div class={style.optionsScroller}>
<Expander>
{!encoderState ? null : (
<div>
<h3 class={style.optionsTitle}>Edit</h3>
<label class={style.sectionEnabler}>
<Checkbox
name="resize.enable"
checked={!!processorState.resize.enabled}
onChange={this.onProcessorEnabledChange}
/>
Resize
</label>
<Expander>
{processorState.resize.enabled ? (
<ResizeOptionsComponent
isVector={Boolean(source && source.vectorImage)}
inputWidth={source ? source.preprocessed.width : 1}
inputHeight={source ? source.preprocessed.height : 1}
options={processorState.resize}
onChange={this.onResizeOptionsChange}
/>
) : null}
</Expander>
<label class={style.sectionEnabler}>
<Checkbox
name="quantizer.enable"
checked={!!processorState.quantize.enabled}
onChange={this.onProcessorEnabledChange}
/>
Reduce palette
</label>
<Expander>
{processorState.quantize.enabled ? (
<QuantOptionsComponent
options={processorState.quantize}
onChange={this.onQuantizerOptionsChange}
/>
) : null}
</Expander>
</div>
)}
</Expander>
<h3 class={style.optionsTitle}>Compress</h3>
<section class={`${style.optionOneCell} ${style.optionsSection}`}>
{supportedEncoderMap ? (
<Select
value={encoderState ? encoderState.type : ''}
onChange={this.onEncoderTypeChange}
large
>
<option value="">Original Image</option>
{Object.entries(supportedEncoderMap).map(([type, encoder]) => (
<option value={type}>{encoder.meta.label}</option>
))}
</Select>
) : (
<Select large>
<option>Loading</option>
</Select>
)}
</section>
<Expander>
{EncoderOptionComponent && encoderState && (
<EncoderOptionComponent
options={
// Casting options, as encoderOptionsComponentMap[encodeData.type] ensures
// the correct type, but typescript isn't smart enough.
encoderState.options as any
}
onChange={onEncoderOptionsChange}
/>
)}
</Expander>
</div>
);
}
}

View File

@@ -0,0 +1,58 @@
.options-scroller {
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
--horizontal-padding: 15px;
}
.options-title {
background: rgba(0, 0, 0, 0.9);
margin: 0;
padding: 10px var(--horizontal-padding);
font-weight: normal;
font-size: 1.4rem;
border-bottom: 1px solid #000;
}
.option-text-first {
display: grid;
align-items: center;
grid-template-columns: 87px 1fr;
grid-gap: 0.7em;
padding: 10px var(--horizontal-padding);
}
.option-one-cell {
display: grid;
grid-template-columns: 1fr;
padding: 10px var(--horizontal-padding);
}
.option-input-first,
.section-enabler {
cursor: pointer;
display: grid;
align-items: center;
grid-template-columns: auto 1fr;
grid-gap: 0.7em;
padding: 10px var(--horizontal-padding);
}
.section-enabler {
background: rgba(0, 0, 0, 0.8);
}
.options-section {
background: rgba(0, 0, 0, 0.7);
}
.text-field {
background: #fff;
color: #000;
font: inherit;
border: none;
padding: 2px 0 2px 10px;
width: 100%;
box-sizing: border-box;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.5);
}

View File

@@ -1,5 +1,5 @@
.output {
composes: abs-fill from '../../../../shared/initial-app/util.scss';
composes: abs-fill from '../../../../shared/initial-app/util.css';
&::before {
content: '';
@@ -19,12 +19,12 @@
}
.two-up {
composes: abs-fill from '../../../../shared/initial-app/util.scss';
composes: abs-fill from '../../../../shared/initial-app/util.css';
--accent-color: var(--button-fg);
}
.pinch-zoom {
composes: abs-fill from '../../../../shared/initial-app/util.scss';
composes: abs-fill from '../../../../shared/initial-app/util.css';
outline: none;
display: flex;
justify-content: center;
@@ -32,11 +32,11 @@
}
.pinch-target {
// This fixes a severe painting bug in Chrome.
// We should try to remove this once the issue is fixed.
// https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10
/* This fixes a severe painting bug in Chrome.
* We should try to remove this once the issue is fixed.
* https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10 */
will-change: auto;
// Prevent the image becoming misshapen due to default flexbox layout.
/* Prevent the image becoming misshapen due to default flexbox layout. */
flex-shrink: 0;
}
@@ -52,7 +52,7 @@
flex-wrap: wrap;
contain: content;
// Allow clicks to fall through to the pinch zoom area
/* Allow clicks to fall through to the pinch zoom area */
pointer-events: none;
& > * {
pointer-events: auto;

View File

@@ -27,13 +27,14 @@ import Options from './Options';
import ResultCache from './result-cache';
import { cleanMerge, cleanSet } from '../util/clean-modify';
import './custom-els/MultiPanel';
// TODO: you are here
import Results from '../results';
import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons';
import SnackBarElement from '../../lib/SnackBar';
import WorkerBridge from '../worker-bridge';
import { resize } from 'features/processors/resize/client';
import type SnackBarElement from 'shared/initial-app/custom-els/snack-bar';
import { CopyAcrossIconProps, ExpandIcon } from '../icons';
type OutputType = EncoderType | 'identity';
export type OutputType = EncoderType | 'identity';
export interface SourceImage {
file: File;
@@ -761,7 +762,7 @@ export default class Compress extends Component<Props, State> {
<Options
source={source}
mobileView={mobileView}
preprocessorState={side.latestSettings.processorState}
processorState={side.latestSettings.processorState}
encoderState={side.latestSettings.encoderState}
onEncoderTypeChange={this.onEncoderTypeChange.bind(
this,
@@ -771,7 +772,7 @@ export default class Compress extends Component<Props, State> {
this,
index as 0 | 1,
)}
onPreprocessorOptionsChange={this.onProcessorOptionsChange.bind(
onProcessorOptionsChange={this.onProcessorOptionsChange.bind(
this,
index as 0 | 1,
)}

View File

@@ -41,9 +41,9 @@ Encoders must have the following:
- `EncodeOptions` - An interface for the codec's options.
- `defaultOptions` - An object of type `EncodeOptions`.
`client/index.ts` which exposes the following.
`client/index.ts` which exposes the following:
- `encode` - A method which takes args
- `encode` - A method which takes args:
- `AbortSignal`
- `WorkerBridge`
- `ImageData`
@@ -51,4 +51,15 @@ Encoders must have the following:
And returns (a promise for) an `ArrayBuffer`.
Optionally it may include a method `featureTest`, which returns a boolean indicating support for this decoder.
Optionally it may export a method `featureTest`, which returns a boolean indicating support for this decoder.
Optionally it may export a component, `Options`, with the following props:
```ts
interface Props {
options: EncodeOptions;
onChange(newOptions: EncodeOptions): void;
}
```
…where `EncodeOptions` are the options for that encoder.

View File

@@ -0,0 +1,58 @@
import { h, Component } from 'preact';
import * as style from 'client/lazy-app/Compress/Options/style.css';
import Range from 'client/lazy-app/Compress/Options/Range';
interface EncodeOptions {
quality: number;
}
interface Props {
options: EncodeOptions;
onChange(newOptions: EncodeOptions): void;
}
interface QualityOptionArg {
min?: number;
max?: number;
step?: number;
}
type Constructor<T extends {} = {}> = new (...args: any[]) => T;
// TypeScript requires an exported type for returned classes. This serves as the
// type for the class returned by `qualityOption`.
export interface QualityOptionsInterface extends Component<Props, {}> {}
export function qualityOption(
opts: QualityOptionArg = {},
): Constructor<QualityOptionsInterface> {
const { min = 0, max = 100, step = 1 } = opts;
class QualityOptions extends Component<Props, {}> {
onChange = (event: Event) => {
const el = event.currentTarget as HTMLInputElement;
this.props.onChange({ quality: Number(el.value) });
};
render({ options }: Props) {
return (
<div class={style.optionsSection}>
<div class={style.optionOneCell}>
<Range
name="quality"
min={min}
max={max}
step={step || 'any'}
value={options.quality}
onInput={this.onChange}
>
Quality:
</Range>
</div>
</div>
);
}
}
return QualityOptions;
}

View File

@@ -1,9 +0,0 @@
import { EncodeOptions } from '../shared/meta';
import type WorkerBridge from 'client/lazy-app/worker-bridge';
export const encode = (
signal: AbortSignal,
workerBridge: WorkerBridge,
imageData: ImageData,
options: EncodeOptions,
) => workerBridge.avifEncode(signal, imageData, options);

View File

@@ -0,0 +1,361 @@
import { EncodeOptions, defaultOptions } from '../shared/meta';
import type WorkerBridge from 'client/lazy-app/worker-bridge';
import { h, Component } from 'preact';
import { preventDefault, shallowEqual } from 'client/lazy-app/util';
import * as style from 'client/lazy-app/Compress/Options/style.css';
import Checkbox from 'client/lazy-app/Compress/Options/Checkbox';
import Expander from 'client/lazy-app/Compress/Options/Expander';
import Select from 'client/lazy-app/Compress/Options/Select';
import Range from 'client/lazy-app/Compress/Options/Range';
import linkState from 'linkstate';
export const encode = (
signal: AbortSignal,
workerBridge: WorkerBridge,
imageData: ImageData,
options: EncodeOptions,
) => workerBridge.avifEncode(signal, imageData, options);
interface Props {
options: EncodeOptions;
onChange(newOptions: EncodeOptions): void;
}
interface State {
options: EncodeOptions;
lossless: boolean;
maxQuality: number;
minQuality: number;
separateAlpha: boolean;
losslessAlpha: boolean;
maxAlphaQuality: number;
minAlphaQuality: number;
showAdvanced: boolean;
grayscale: boolean;
subsample: number;
tileRows: number;
tileCols: number;
effort: number;
}
const maxQuant = 63;
const maxSpeed = 10;
export class Options extends Component<Props, State> {
static getDerivedStateFromProps(
props: Props,
state: State,
): Partial<State> | null {
if (state.options && shallowEqual(state.options, props.options)) {
return null;
}
const { options } = props;
const lossless = options.maxQuantizer === 0 && options.minQuantizer === 0;
const minQuantizerValue = lossless
? defaultOptions.minQuantizer
: options.minQuantizer;
const maxQuantizerValue = lossless
? defaultOptions.maxQuantizer
: options.maxQuantizer;
const losslessAlpha =
options.maxQuantizerAlpha === 0 && options.minQuantizerAlpha === 0;
const minQuantizerAlphaValue = losslessAlpha
? defaultOptions.minQuantizerAlpha
: options.minQuantizerAlpha;
const maxQuantizerAlphaValue = losslessAlpha
? defaultOptions.maxQuantizerAlpha
: options.maxQuantizerAlpha;
// Create default form state from options
return {
options,
lossless,
losslessAlpha,
maxQuality: maxQuant - minQuantizerValue,
minQuality: maxQuant - maxQuantizerValue,
separateAlpha:
options.maxQuantizer !== options.maxQuantizerAlpha ||
options.minQuantizer !== options.minQuantizerAlpha,
maxAlphaQuality: maxQuant - minQuantizerAlphaValue,
minAlphaQuality: maxQuant - maxQuantizerAlphaValue,
grayscale: options.subsample === 0,
subsample:
options.subsample === 0 || lossless
? defaultOptions.subsample
: options.subsample,
tileRows: options.tileRowsLog2,
tileCols: options.tileColsLog2,
effort: maxSpeed - options.speed,
};
}
// The rest of the defaults are set in getDerivedStateFromProps
state: State = {
showAdvanced: false,
} as State;
private _inputChangeCallbacks = new Map<string, (event: Event) => void>();
private _inputChange = (prop: keyof State, type: 'number' | 'boolean') => {
// Cache the callback for performance
if (!this._inputChangeCallbacks.has(prop)) {
this._inputChangeCallbacks.set(prop, (event: Event) => {
const formEl = event.target as HTMLInputElement | HTMLSelectElement;
const newVal =
type === 'boolean'
? 'checked' in formEl
? formEl.checked
: !!formEl.value
: Number(formEl.value);
const newState: Partial<State> = {
[prop]: newVal,
};
// Ensure that min cannot be greater than max
switch (prop) {
case 'maxQuality':
if (newVal < this.state.minQuality) {
newState.minQuality = newVal as number;
}
break;
case 'minQuality':
if (newVal > this.state.maxQuality) {
newState.maxQuality = newVal as number;
}
break;
case 'maxAlphaQuality':
if (newVal < this.state.minAlphaQuality) {
newState.minAlphaQuality = newVal as number;
}
break;
case 'minAlphaQuality':
if (newVal > this.state.maxAlphaQuality) {
newState.maxAlphaQuality = newVal as number;
}
break;
}
const optionState = {
...this.state,
...newState,
};
const maxQuantizer = optionState.lossless
? 0
: maxQuant - optionState.minQuality;
const minQuantizer = optionState.lossless
? 0
: maxQuant - optionState.maxQuality;
const newOptions: EncodeOptions = {
maxQuantizer,
minQuantizer,
maxQuantizerAlpha: optionState.separateAlpha
? optionState.losslessAlpha
? 0
: maxQuant - optionState.minAlphaQuality
: maxQuantizer,
minQuantizerAlpha: optionState.separateAlpha
? optionState.losslessAlpha
? 0
: maxQuant - optionState.maxAlphaQuality
: minQuantizer,
// Always set to 4:4:4 if lossless
subsample: optionState.grayscale
? 0
: optionState.lossless
? 3
: optionState.subsample,
tileColsLog2: optionState.tileCols,
tileRowsLog2: optionState.tileRows,
speed: maxSpeed - optionState.effort,
};
// Updating options, so we don't recalculate in getDerivedStateFromProps.
newState.options = newOptions;
this.setState(
// It isn't clear to me why I have to cast this :)
newState as State,
);
this.props.onChange(newOptions);
});
}
return this._inputChangeCallbacks.get(prop)!;
};
render(
_: Props,
{
effort,
grayscale,
lossless,
losslessAlpha,
maxAlphaQuality,
maxQuality,
minAlphaQuality,
minQuality,
separateAlpha,
showAdvanced,
subsample,
tileCols,
tileRows,
}: State,
) {
return (
<form class={style.optionsSection} onSubmit={preventDefault}>
<label class={style.optionInputFirst}>
<Checkbox
checked={lossless}
onChange={this._inputChange('lossless', 'boolean')}
/>
Lossless
</label>
<Expander>
{!lossless && (
<div>
<div class={style.optionOneCell}>
<Range
min="0"
max="62"
value={maxQuality}
onInput={this._inputChange('maxQuality', 'number')}
>
Max quality:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
min="0"
max="62"
value={minQuality}
onInput={this._inputChange('minQuality', 'number')}
>
Min quality:
</Range>
</div>
</div>
)}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
checked={separateAlpha}
onChange={this._inputChange('separateAlpha', 'boolean')}
/>
Separate alpha quality
</label>
<Expander>
{separateAlpha && (
<div>
<label class={style.optionInputFirst}>
<Checkbox
checked={losslessAlpha}
onChange={this._inputChange('losslessAlpha', 'boolean')}
/>
Lossless alpha
</label>
<Expander>
{!losslessAlpha && (
<div>
<div class={style.optionOneCell}>
<Range
min="0"
max="62"
value={maxAlphaQuality}
onInput={this._inputChange('maxAlphaQuality', 'number')}
>
Max alpha quality:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
min="0"
max="62"
value={minAlphaQuality}
onInput={this._inputChange('minAlphaQuality', 'number')}
>
Min alpha quality:
</Range>
</div>
</div>
)}
</Expander>
</div>
)}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
checked={showAdvanced}
onChange={linkState(this, 'showAdvanced')}
/>
Show advanced settings
</label>
<Expander>
{showAdvanced && (
<div>
{/*<label class={style.optionInputFirst}>
<Checkbox
data-set-state="grayscale"
checked={grayscale}
onChange={this._inputChange('grayscale', 'boolean')}
/>
Grayscale
</label>*/}
<Expander>
{!grayscale && !lossless && (
<label class={style.optionTextFirst}>
Subsample chroma:
<Select
data-set-state="subsample"
value={subsample}
onChange={this._inputChange('subsample', 'number')}
>
<option value="1">4:2:0</option>
{/*<option value="2">4:2:2</option>*/}
<option value="3">4:4:4</option>
</Select>
</label>
)}
</Expander>
<div class={style.optionOneCell}>
<Range
min="0"
max="6"
value={tileRows}
onInput={this._inputChange('tileRows', 'number')}
>
Log2 of tile rows:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
min="0"
max="6"
value={tileCols}
onInput={this._inputChange('tileCols', 'number')}
>
Log2 of tile cols:
</Range>
</div>
</div>
)}
</Expander>
<div class={style.optionOneCell}>
<Range
min="0"
max="10"
value={effort}
onInput={this._inputChange('effort', 'number')}
>
Effort:
</Range>
</div>
</form>
);
}
}

View File

@@ -1,13 +0,0 @@
/**
* Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference path="../../../../../missing-types.d.ts" />

View File

@@ -1,5 +1,6 @@
import { canvasEncode } from 'client/lazy-app/util';
import WorkerBridge from 'client/lazy-app/worker-bridge';
import { qualityOption } from 'features/client-utils';
import { mimeType, EncodeOptions } from '../shared/meta';
export const encode = (
@@ -8,3 +9,5 @@ export const encode = (
imageData: ImageData,
options: EncodeOptions,
) => canvasEncode(imageData, mimeType, options.quality);
export const Options = qualityOption({ min: 0, max: 1, step: 0.01 });

View File

@@ -1,11 +0,0 @@
import { EncodeOptions } from '../shared/meta';
import type WorkerBridge from 'client/lazy-app/worker-bridge';
export function encode(
signal: AbortSignal,
workerBridge: WorkerBridge,
imageData: ImageData,
options: EncodeOptions,
) {
return workerBridge.mozjpegEncode(signal, imageData, options);
}

View File

@@ -0,0 +1,299 @@
import { EncodeOptions, MozJpegColorSpace } from '../shared/meta';
import type WorkerBridge from 'client/lazy-app/worker-bridge';
import { h, Component } from 'preact';
import {
inputFieldChecked,
inputFieldValueAsNumber,
preventDefault,
} from 'client/lazy-app/util';
import * as style from 'client/lazy-app/Compress/Options/style.css';
import linkState from 'linkstate';
import Range from 'client/lazy-app/Compress/Options/Range';
import Checkbox from 'client/lazy-app/Compress/Options/Checkbox';
import Expander from 'client/lazy-app/Compress/Options/Expander';
import Select from 'client/lazy-app/Compress/Options/Select';
export function encode(
signal: AbortSignal,
workerBridge: WorkerBridge,
imageData: ImageData,
options: EncodeOptions,
) {
return workerBridge.mozjpegEncode(signal, imageData, options);
}
interface Props {
options: EncodeOptions;
onChange(newOptions: EncodeOptions): void;
}
interface State {
showAdvanced: boolean;
}
export class Options extends Component<Props, State> {
state: State = {
showAdvanced: false,
};
onChange = (event: Event) => {
const form = (event.currentTarget as HTMLInputElement).closest(
'form',
) as HTMLFormElement;
const { options } = this.props;
const newOptions: EncodeOptions = {
// Copy over options the form doesn't currently care about, eg arithmetic
...this.props.options,
// And now stuff from the form:
// .checked
baseline: inputFieldChecked(form.baseline, options.baseline),
progressive: inputFieldChecked(form.progressive, options.progressive),
optimize_coding: inputFieldChecked(
form.optimize_coding,
options.optimize_coding,
),
trellis_multipass: inputFieldChecked(
form.trellis_multipass,
options.trellis_multipass,
),
trellis_opt_zero: inputFieldChecked(
form.trellis_opt_zero,
options.trellis_opt_zero,
),
trellis_opt_table: inputFieldChecked(
form.trellis_opt_table,
options.trellis_opt_table,
),
auto_subsample: inputFieldChecked(
form.auto_subsample,
options.auto_subsample,
),
separate_chroma_quality: inputFieldChecked(
form.separate_chroma_quality,
options.separate_chroma_quality,
),
// .value
quality: inputFieldValueAsNumber(form.quality, options.quality),
chroma_quality: inputFieldValueAsNumber(
form.chroma_quality,
options.chroma_quality,
),
chroma_subsample: inputFieldValueAsNumber(
form.chroma_subsample,
options.chroma_subsample,
),
smoothing: inputFieldValueAsNumber(form.smoothing, options.smoothing),
color_space: inputFieldValueAsNumber(
form.color_space,
options.color_space,
),
quant_table: inputFieldValueAsNumber(
form.quant_table,
options.quant_table,
),
trellis_loops: inputFieldValueAsNumber(
form.trellis_loops,
options.trellis_loops,
),
};
this.props.onChange(newOptions);
};
render({ options }: Props, { showAdvanced }: State) {
// I'm rendering both lossy and lossless forms, as it becomes much easier when
// gathering the data.
return (
<form class={style.optionsSection} onSubmit={preventDefault}>
<div class={style.optionOneCell}>
<Range
name="quality"
min="0"
max="100"
value={options.quality}
onInput={this.onChange}
>
Quality:
</Range>
</div>
<label class={style.optionInputFirst}>
<Checkbox
checked={showAdvanced}
onChange={linkState(this, 'showAdvanced')}
/>
Show advanced settings
</label>
<Expander>
{showAdvanced ? (
<div>
<label class={style.optionTextFirst}>
Channels:
<Select
name="color_space"
value={options.color_space}
onChange={this.onChange}
>
<option value={MozJpegColorSpace.GRAYSCALE}>Grayscale</option>
<option value={MozJpegColorSpace.RGB}>RGB</option>
<option value={MozJpegColorSpace.YCbCr}>YCbCr</option>
</Select>
</label>
<Expander>
{options.color_space === MozJpegColorSpace.YCbCr ? (
<div>
<label class={style.optionInputFirst}>
<Checkbox
name="auto_subsample"
checked={options.auto_subsample}
onChange={this.onChange}
/>
Auto subsample chroma
</label>
<Expander>
{options.auto_subsample ? null : (
<div class={style.optionOneCell}>
<Range
name="chroma_subsample"
min="1"
max="4"
value={options.chroma_subsample}
onInput={this.onChange}
>
Subsample chroma by:
</Range>
</div>
)}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="separate_chroma_quality"
checked={options.separate_chroma_quality}
onChange={this.onChange}
/>
Separate chroma quality
</label>
<Expander>
{options.separate_chroma_quality ? (
<div class={style.optionOneCell}>
<Range
name="chroma_quality"
min="0"
max="100"
value={options.chroma_quality}
onInput={this.onChange}
>
Chroma quality:
</Range>
</div>
) : null}
</Expander>
</div>
) : null}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="baseline"
checked={options.baseline}
onChange={this.onChange}
/>
Pointless spec compliance
</label>
<Expander>
{options.baseline ? null : (
<label class={style.optionInputFirst}>
<Checkbox
name="progressive"
checked={options.progressive}
onChange={this.onChange}
/>
Progressive rendering
</label>
)}
</Expander>
<Expander>
{options.baseline ? (
<label class={style.optionInputFirst}>
<Checkbox
name="optimize_coding"
checked={options.optimize_coding}
onChange={this.onChange}
/>
Optimize Huffman table
</label>
) : null}
</Expander>
<div class={style.optionOneCell}>
<Range
name="smoothing"
min="0"
max="100"
value={options.smoothing}
onInput={this.onChange}
>
Smoothing:
</Range>
</div>
<label class={style.optionTextFirst}>
Quantization:
<Select
name="quant_table"
value={options.quant_table}
onChange={this.onChange}
>
<option value="0">JPEG Annex K</option>
<option value="1">Flat</option>
<option value="2">MSSIM-tuned Kodak</option>
<option value="3">ImageMagick</option>
<option value="4">PSNR-HVS-M-tuned Kodak</option>
<option value="5">Klein et al</option>
<option value="6">Watson et al</option>
<option value="7">Ahumada et al</option>
<option value="8">Peterson et al</option>
</Select>
</label>
<label class={style.optionInputFirst}>
<Checkbox
name="trellis_multipass"
checked={options.trellis_multipass}
onChange={this.onChange}
/>
Trellis multipass
</label>
<Expander>
{options.trellis_multipass ? (
<label class={style.optionInputFirst}>
<Checkbox
name="trellis_opt_zero"
checked={options.trellis_opt_zero}
onChange={this.onChange}
/>
Optimize zero block runs
</label>
) : null}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="trellis_opt_table"
checked={options.trellis_opt_table}
onChange={this.onChange}
/>
Optimize after trellis quantization
</label>
<div class={style.optionOneCell}>
<Range
name="trellis_loops"
min="1"
max="50"
value={options.trellis_loops}
onInput={this.onChange}
>
Trellis quantization passes:
</Range>
</div>
</div>
) : null}
</Expander>
</form>
);
}
}

View File

@@ -1,18 +0,0 @@
import {
canvasEncode,
abortable,
blobToArrayBuffer,
} from 'client/lazy-app/util';
import { EncodeOptions } from '../shared/meta';
import type WorkerBridge from 'client/lazy-app/worker-bridge';
export async function encode(
signal: AbortSignal,
workerBridge: WorkerBridge,
imageData: ImageData,
options: EncodeOptions,
) {
const pngBlob = await abortable(signal, canvasEncode(imageData, 'image/png'));
const pngBuffer = await abortable(signal, blobToArrayBuffer(pngBlob));
return workerBridge.oxipngEncode(signal, pngBuffer, options);
}

View File

@@ -0,0 +1,59 @@
import {
canvasEncode,
abortable,
blobToArrayBuffer,
} from 'client/lazy-app/util';
import { EncodeOptions } from '../shared/meta';
import type WorkerBridge from 'client/lazy-app/worker-bridge';
import { h, Component } from 'preact';
import { inputFieldValueAsNumber, preventDefault } from 'client/lazy-app/util';
import * as style from 'client/lazy-app/Compress/Options/style.css';
import Range from 'client/lazy-app/Compress/Options/Range';
export async function encode(
signal: AbortSignal,
workerBridge: WorkerBridge,
imageData: ImageData,
options: EncodeOptions,
) {
const pngBlob = await abortable(signal, canvasEncode(imageData, 'image/png'));
const pngBuffer = await abortable(signal, blobToArrayBuffer(pngBlob));
return workerBridge.oxipngEncode(signal, pngBuffer, options);
}
type Props = {
options: EncodeOptions;
onChange(newOptions: EncodeOptions): void;
};
export class Options extends Component<Props, {}> {
onChange = (event: Event) => {
const form = (event.currentTarget as HTMLInputElement).closest(
'form',
) as HTMLFormElement;
const options: EncodeOptions = {
level: inputFieldValueAsNumber(form.level),
};
this.props.onChange(options);
};
render({ options }: Props) {
return (
<form class={style.optionsSection} onSubmit={preventDefault}>
<div class={style.optionOneCell}>
<Range
name="level"
min="0"
max="3"
step="1"
value={options.level}
onInput={this.onChange}
>
Effort:
</Range>
</div>
</form>
);
}
}

View File

@@ -1,9 +0,0 @@
import { EncodeOptions } from '../shared/meta';
import type WorkerBridge from 'client/lazy-app/worker-bridge';
export const encode = (
signal: AbortSignal,
workerBridge: WorkerBridge,
imageData: ImageData,
options: EncodeOptions,
) => workerBridge.webpEncode(signal, imageData, options);

View File

@@ -0,0 +1,407 @@
import { EncodeOptions } from '../shared/meta';
import type WorkerBridge from 'client/lazy-app/worker-bridge';
import { h, Component } from 'preact';
import {
inputFieldCheckedAsNumber,
inputFieldValueAsNumber,
preventDefault,
} from 'client/lazy-app/util';
import * as style from 'client/lazy-app/Compress/Options/style.css';
import linkState from 'linkstate';
import Range from 'client/lazy-app/Compress/Options/Range';
import Checkbox from 'client/lazy-app/Compress/Options/Checkbox';
import Expander from 'client/lazy-app/Compress/Options/Expander';
import Select from 'client/lazy-app/Compress/Options/Select';
export const encode = (
signal: AbortSignal,
workerBridge: WorkerBridge,
imageData: ImageData,
options: EncodeOptions,
) => workerBridge.webpEncode(signal, imageData, options);
const enum WebPImageHint {
WEBP_HINT_DEFAULT, // default preset.
WEBP_HINT_PICTURE, // digital picture, like portrait, inner shot
WEBP_HINT_PHOTO, // outdoor photograph, with natural lighting
WEBP_HINT_GRAPH, // Discrete tone image (graph, map-tile etc).
}
interface Props {
options: EncodeOptions;
onChange(newOptions: EncodeOptions): void;
}
interface State {
showAdvanced: boolean;
}
// From kLosslessPresets in config_enc.c
// The format is [method, quality].
const losslessPresets: [number, number][] = [
[0, 0],
[1, 20],
[2, 25],
[3, 30],
[3, 50],
[4, 50],
[4, 75],
[4, 90],
[5, 90],
[6, 100],
];
const losslessPresetDefault = 6;
function determineLosslessQuality(quality: number, method: number): number {
const index = losslessPresets.findIndex(
([presetMethod, presetQuality]) =>
presetMethod === method && presetQuality === quality,
);
if (index !== -1) return index;
// Quality doesn't match one of the presets.
// This can happen when toggling 'lossless'.
return losslessPresetDefault;
}
export class Options extends Component<Props, State> {
state: State = {
showAdvanced: false,
};
onChange = (event: Event) => {
const form = (event.currentTarget as HTMLInputElement).closest(
'form',
) as HTMLFormElement;
const lossless = inputFieldCheckedAsNumber(form.lossless);
const { options } = this.props;
const losslessPresetValue = inputFieldValueAsNumber(
form.lossless_preset,
determineLosslessQuality(options.quality, options.method),
);
const newOptions: EncodeOptions = {
// Copy over options the form doesn't care about, eg emulate_jpeg_size
...options,
// And now stuff from the form:
lossless,
// Special-cased inputs:
// In lossless mode, the quality is derived from the preset.
quality: lossless
? losslessPresets[losslessPresetValue][1]
: inputFieldValueAsNumber(form.quality, options.quality),
// In lossless mode, the method is derived from the preset.
method: lossless
? losslessPresets[losslessPresetValue][0]
: inputFieldValueAsNumber(form.method_input, options.method),
image_hint: inputFieldCheckedAsNumber(form.image_hint, options.image_hint)
? WebPImageHint.WEBP_HINT_GRAPH
: WebPImageHint.WEBP_HINT_DEFAULT,
// .checked
exact: inputFieldCheckedAsNumber(form.exact, options.exact),
alpha_compression: inputFieldCheckedAsNumber(
form.alpha_compression,
options.alpha_compression,
),
autofilter: inputFieldCheckedAsNumber(
form.autofilter,
options.autofilter,
),
filter_type: inputFieldCheckedAsNumber(
form.filter_type,
options.filter_type,
),
use_sharp_yuv: inputFieldCheckedAsNumber(
form.use_sharp_yuv,
options.use_sharp_yuv,
),
// .value
near_lossless:
100 -
inputFieldValueAsNumber(
form.near_lossless,
100 - options.near_lossless,
),
alpha_quality: inputFieldValueAsNumber(
form.alpha_quality,
options.alpha_quality,
),
alpha_filtering: inputFieldValueAsNumber(
form.alpha_filtering,
options.alpha_filtering,
),
sns_strength: inputFieldValueAsNumber(
form.sns_strength,
options.sns_strength,
),
filter_strength: inputFieldValueAsNumber(
form.filter_strength,
options.filter_strength,
),
filter_sharpness:
7 -
inputFieldValueAsNumber(
form.filter_sharpness,
7 - options.filter_sharpness,
),
pass: inputFieldValueAsNumber(form.pass, options.pass),
preprocessing: inputFieldValueAsNumber(
form.preprocessing,
options.preprocessing,
),
segments: inputFieldValueAsNumber(form.segments, options.segments),
partitions: inputFieldValueAsNumber(form.partitions, options.partitions),
};
this.props.onChange(newOptions);
};
private _losslessSpecificOptions(options: EncodeOptions) {
return (
<div key="lossless">
<div class={style.optionOneCell}>
<Range
name="lossless_preset"
min="0"
max="9"
value={determineLosslessQuality(options.quality, options.method)}
onInput={this.onChange}
>
Effort:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
name="near_lossless"
min="0"
max="100"
value={'' + (100 - options.near_lossless)}
onInput={this.onChange}
>
Slight loss:
</Range>
</div>
<label class={style.optionInputFirst}>
{/*
Although there are 3 different kinds of image hint, webp only
seems to do something with the 'graph' type, and I don't really
understand what it does.
*/}
<Checkbox
name="image_hint"
checked={options.image_hint === WebPImageHint.WEBP_HINT_GRAPH}
onChange={this.onChange}
/>
Discrete tone image
</label>
</div>
);
}
private _lossySpecificOptions(options: EncodeOptions) {
const { showAdvanced } = this.state;
return (
<div key="lossy">
<div class={style.optionOneCell}>
<Range
name="method_input"
min="0"
max="6"
value={options.method}
onInput={this.onChange}
>
Effort:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
name="quality"
min="0"
max="100"
step="0.1"
value={options.quality}
onInput={this.onChange}
>
Quality:
</Range>
</div>
<label class={style.optionInputFirst}>
<Checkbox
checked={showAdvanced}
onChange={linkState(this, 'showAdvanced')}
/>
Show advanced settings
</label>
<Expander>
{showAdvanced ? (
<div>
<label class={style.optionInputFirst}>
<Checkbox
name="alpha_compression"
checked={!!options.alpha_compression}
onChange={this.onChange}
/>
Compress alpha
</label>
<div class={style.optionOneCell}>
<Range
name="alpha_quality"
min="0"
max="100"
value={options.alpha_quality}
onInput={this.onChange}
>
Alpha quality:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
name="alpha_filtering"
min="0"
max="2"
value={options.alpha_filtering}
onInput={this.onChange}
>
Alpha filter quality:
</Range>
</div>
<label class={style.optionInputFirst}>
<Checkbox
name="autofilter"
checked={!!options.autofilter}
onChange={this.onChange}
/>
Auto adjust filter strength
</label>
<Expander>
{options.autofilter ? null : (
<div class={style.optionOneCell}>
<Range
name="filter_strength"
min="0"
max="100"
value={options.filter_strength}
onInput={this.onChange}
>
Filter strength:
</Range>
</div>
)}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="filter_type"
checked={!!options.filter_type}
onChange={this.onChange}
/>
Strong filter
</label>
<div class={style.optionOneCell}>
<Range
name="filter_sharpness"
min="0"
max="7"
value={7 - options.filter_sharpness}
onInput={this.onChange}
>
Filter sharpness:
</Range>
</div>
<label class={style.optionInputFirst}>
<Checkbox
name="use_sharp_yuv"
checked={!!options.use_sharp_yuv}
onChange={this.onChange}
/>
Sharp RGBYUV conversion
</label>
<div class={style.optionOneCell}>
<Range
name="pass"
min="1"
max="10"
value={options.pass}
onInput={this.onChange}
>
Passes:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
name="sns_strength"
min="0"
max="100"
value={options.sns_strength}
onInput={this.onChange}
>
Spatial noise shaping:
</Range>
</div>
<label class={style.optionTextFirst}>
Preprocess:
<Select
name="preprocessing"
value={options.preprocessing}
onChange={this.onChange}
>
<option value="0">None</option>
<option value="1">Segment smooth</option>
<option value="2">Pseudo-random dithering</option>
</Select>
</label>
<div class={style.optionOneCell}>
<Range
name="segments"
min="1"
max="4"
value={options.segments}
onInput={this.onChange}
>
Segments:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
name="partitions"
min="0"
max="3"
value={options.partitions}
onInput={this.onChange}
>
Partitions:
</Range>
</div>
</div>
) : null}
</Expander>
</div>
);
}
render({ options }: Props) {
// I'm rendering both lossy and lossless forms, as it becomes much easier when
// gathering the data.
return (
<form class={style.optionsSection} onSubmit={preventDefault}>
<label class={style.optionInputFirst}>
<Checkbox
name="lossless"
checked={!!options.lossless}
onChange={this.onChange}
/>
Lossless
</label>
{options.lossless
? this._losslessSpecificOptions(options)
: this._lossySpecificOptions(options)}
<label class={style.optionInputFirst}>
<Checkbox
name="exact"
checked={!!options.exact}
onChange={this.onChange}
/>
Preserve transparent data
</label>
</form>
);
}
}

View File

@@ -0,0 +1,98 @@
import { h, Component } from 'preact';
import { Options as QuantizeOptions } from '../shared/meta';
import * as style from 'client/lazy-app/Compress/Options/style.css';
import {
inputFieldValueAsNumber,
konami,
preventDefault,
} from 'client/lazy-app/util';
import Expander from 'client/lazy-app/Compress/Options/Expander';
import Select from 'client/lazy-app/Compress/Options/Select';
import Range from 'client/lazy-app/Compress/Options/Range';
const konamiPromise = konami();
interface Props {
options: QuantizeOptions;
onChange(newOptions: QuantizeOptions): void;
}
interface State {
extendedSettings: boolean;
}
export class Options extends Component<Props, State> {
state: State = { extendedSettings: false };
componentDidMount() {
konamiPromise.then(() => {
this.setState({ extendedSettings: true });
});
}
onChange = (event: Event) => {
const form = (event.currentTarget as HTMLInputElement).closest(
'form',
) as HTMLFormElement;
const { options } = this.props;
const newOptions: QuantizeOptions = {
zx: inputFieldValueAsNumber(form.zx, options.zx),
maxNumColors: inputFieldValueAsNumber(
form.maxNumColors,
options.maxNumColors,
),
dither: inputFieldValueAsNumber(form.dither),
};
this.props.onChange(newOptions);
};
render({ options }: Props, { extendedSettings }: State) {
return (
<form class={style.optionsSection} onSubmit={preventDefault}>
<Expander>
{extendedSettings ? (
<label class={style.optionTextFirst}>
Type:
<Select
name="zx"
value={'' + options.zx}
onChange={this.onChange}
>
<option value="0">Standard</option>
<option value="1">ZX</option>
</Select>
</label>
) : null}
</Expander>
<Expander>
{options.zx ? null : (
<div class={style.optionOneCell}>
<Range
name="maxNumColors"
min="2"
max="256"
value={options.maxNumColors}
onInput={this.onChange}
>
Colors:
</Range>
</div>
)}
</Expander>
<div class={style.optionOneCell}>
<Range
name="dither"
min="0"
max="1"
step="0.01"
value={options.dither}
onInput={this.onChange}
>
Dithering:
</Range>
</div>
</form>
);
}
}

View File

@@ -1,85 +0,0 @@
import {
builtinResize,
BuiltinResizeMethod,
drawableToImageData,
} from 'client/lazy-app/util';
import {
BrowserResizeOptions,
VectorResizeOptions,
WorkerResizeOptions,
Options,
workerResizeMethods,
} from '../shared/meta';
import { getContainOffsets } from '../shared/util';
import type { SourceImage } from 'client/lazy-app/Compress';
import type WorkerBridge from 'client/lazy-app/worker-bridge';
/**
* Return whether a set of options are worker resize options.
*
* @param opts
*/
function isWorkerOptions(opts: Options): opts is WorkerResizeOptions {
return (workerResizeMethods as string[]).includes(opts.method);
}
function browserResize(data: ImageData, opts: BrowserResizeOptions): ImageData {
let sx = 0;
let sy = 0;
let sw = data.width;
let sh = data.height;
if (opts.fitMethod === 'contain') {
({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height));
}
return builtinResize(
data,
sx,
sy,
sw,
sh,
opts.width,
opts.height,
opts.method.slice('browser-'.length) as BuiltinResizeMethod,
);
}
function vectorResize(
data: HTMLImageElement,
opts: VectorResizeOptions,
): ImageData {
let sx = 0;
let sy = 0;
let sw = data.width;
let sh = data.height;
if (opts.fitMethod === 'contain') {
({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height));
}
return drawableToImageData(data, {
sx,
sy,
sw,
sh,
width: opts.width,
height: opts.height,
});
}
export async function resize(
signal: AbortSignal,
source: SourceImage,
options: Options,
workerBridge: WorkerBridge,
) {
if (options.method === 'vector') {
if (!source.vectorImage) throw Error('No vector image available');
return vectorResize(source.vectorImage, options);
}
if (isWorkerOptions(options)) {
return workerBridge.resize(signal, source.preprocessed, options);
}
return browserResize(source.preprocessed, options);
}

View File

@@ -0,0 +1,334 @@
import {
builtinResize,
BuiltinResizeMethod,
drawableToImageData,
} from 'client/lazy-app/util';
import {
BrowserResizeOptions,
VectorResizeOptions,
WorkerResizeOptions,
Options as ResizeOptions,
workerResizeMethods,
} from '../shared/meta';
import { getContainOffsets } from '../shared/util';
import type { SourceImage } from 'client/lazy-app/Compress';
import type WorkerBridge from 'client/lazy-app/worker-bridge';
import { h, Component } from 'preact';
import linkState from 'linkstate';
import {
inputFieldValueAsNumber,
inputFieldValue,
preventDefault,
inputFieldChecked,
} from 'client/lazy-app/util';
import * as style from 'client/lazy-app/Compress/Options/style.css';
import { linkRef } from 'shared/initial-app/util';
import Select from 'client/lazy-app/Compress/Options/Select';
import Expander from 'client/lazy-app/Compress/Options/Expander';
import Checkbox from 'client/lazy-app/Compress/Options/Checkbox';
/**
* Return whether a set of options are worker resize options.
*
* @param opts
*/
function isWorkerOptions(opts: ResizeOptions): opts is WorkerResizeOptions {
return (workerResizeMethods as string[]).includes(opts.method);
}
function browserResize(data: ImageData, opts: BrowserResizeOptions): ImageData {
let sx = 0;
let sy = 0;
let sw = data.width;
let sh = data.height;
if (opts.fitMethod === 'contain') {
({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height));
}
return builtinResize(
data,
sx,
sy,
sw,
sh,
opts.width,
opts.height,
opts.method.slice('browser-'.length) as BuiltinResizeMethod,
);
}
function vectorResize(
data: HTMLImageElement,
opts: VectorResizeOptions,
): ImageData {
let sx = 0;
let sy = 0;
let sw = data.width;
let sh = data.height;
if (opts.fitMethod === 'contain') {
({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height));
}
return drawableToImageData(data, {
sx,
sy,
sw,
sh,
width: opts.width,
height: opts.height,
});
}
export async function resize(
signal: AbortSignal,
source: SourceImage,
options: ResizeOptions,
workerBridge: WorkerBridge,
) {
if (options.method === 'vector') {
if (!source.vectorImage) throw Error('No vector image available');
return vectorResize(source.vectorImage, options);
}
if (isWorkerOptions(options)) {
return workerBridge.resize(signal, source.preprocessed, options);
}
return browserResize(source.preprocessed, options);
}
interface Props {
isVector: Boolean;
inputWidth: number;
inputHeight: number;
options: ResizeOptions;
onChange(newOptions: ResizeOptions): void;
}
interface State {
maintainAspect: boolean;
}
const sizePresets = [0.25, 0.3333, 0.5, 1, 2, 3, 4];
export class Options extends Component<Props, State> {
state: State = {
maintainAspect: true,
};
private form?: HTMLFormElement;
private presetWidths: { [idx: number]: number } = {};
private presetHeights: { [idx: number]: number } = {};
constructor(props: Props) {
super(props);
this.generatePresetValues(props.inputWidth, props.inputHeight);
}
private reportOptions() {
const form = this.form!;
const width = form.width as HTMLInputElement;
const height = form.height as HTMLInputElement;
const { options } = this.props;
if (!width.checkValidity() || !height.checkValidity()) return;
const newOptions: ResizeOptions = {
width: inputFieldValueAsNumber(width),
height: inputFieldValueAsNumber(height),
method: form.resizeMethod.value,
premultiply: inputFieldChecked(form.premultiply, true),
linearRGB: inputFieldChecked(form.linearRGB, true),
// Casting, as the formfield only returns the correct values.
fitMethod: inputFieldValue(
form.fitMethod,
options.fitMethod,
) as ResizeOptions['fitMethod'],
};
this.props.onChange(newOptions);
}
private onChange = () => {
this.reportOptions();
};
private getAspect() {
return this.props.inputWidth / this.props.inputHeight;
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (!prevState.maintainAspect && this.state.maintainAspect) {
this.form!.height.value = Math.round(
Number(this.form!.width.value) / this.getAspect(),
);
this.reportOptions();
}
}
componentWillReceiveProps(nextProps: Props) {
if (
this.props.inputWidth !== nextProps.inputWidth ||
this.props.inputHeight !== nextProps.inputHeight
) {
this.generatePresetValues(nextProps.inputWidth, nextProps.inputHeight);
}
}
private onWidthInput = () => {
if (this.state.maintainAspect) {
const width = inputFieldValueAsNumber(this.form!.width);
this.form!.height.value = Math.round(width / this.getAspect());
}
this.reportOptions();
};
private onHeightInput = () => {
if (this.state.maintainAspect) {
const height = inputFieldValueAsNumber(this.form!.height);
this.form!.width.value = Math.round(height * this.getAspect());
}
this.reportOptions();
};
private generatePresetValues(width: number, height: number) {
for (const preset of sizePresets) {
this.presetWidths[preset] = Math.round(width * preset);
this.presetHeights[preset] = Math.round(height * preset);
}
}
private getPreset(): number | string {
const { width, height } = this.props.options;
for (const preset of sizePresets) {
if (
width === this.presetWidths[preset] &&
height === this.presetHeights[preset]
)
return preset;
}
return 'custom';
}
private onPresetChange = (event: Event) => {
const select = event.target as HTMLSelectElement;
if (select.value === 'custom') return;
const multiplier = Number(select.value);
(this.form!.width as HTMLInputElement).value =
Math.round(this.props.inputWidth * multiplier) + '';
(this.form!.height as HTMLInputElement).value =
Math.round(this.props.inputHeight * multiplier) + '';
this.reportOptions();
};
render({ options, isVector }: Props, { maintainAspect }: State) {
return (
<form
ref={linkRef(this, 'form')}
class={style.optionsSection}
onSubmit={preventDefault}
>
<label class={style.optionTextFirst}>
Method:
<Select
name="resizeMethod"
value={options.method}
onChange={this.onChange}
>
{isVector && <option value="vector">Vector</option>}
<option value="lanczos3">Lanczos3</option>
<option value="mitchell">Mitchell</option>
<option value="catrom">Catmull-Rom</option>
<option value="triangle">Triangle (bilinear)</option>
<option value="hqx">hqx (pixel art)</option>
<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 class={style.optionTextFirst}>
Preset:
<Select value={this.getPreset()} onChange={this.onPresetChange}>
{sizePresets.map((preset) => (
<option value={preset}>{preset * 100}%</option>
))}
<option value="custom">Custom</option>
</Select>
</label>
<label class={style.optionTextFirst}>
Width:
<input
required
class={style.textField}
name="width"
type="number"
min="1"
value={'' + options.width}
onInput={this.onWidthInput}
/>
</label>
<label class={style.optionTextFirst}>
Height:
<input
required
class={style.textField}
name="height"
type="number"
min="1"
value={'' + options.height}
onInput={this.onHeightInput}
/>
</label>
<Expander>
{isWorkerOptions(options) ? (
<label class={style.optionInputFirst}>
<Checkbox
name="premultiply"
checked={options.premultiply}
onChange={this.onChange}
/>
Premultiply alpha channel
</label>
) : null}
{isWorkerOptions(options) ? (
<label class={style.optionInputFirst}>
<Checkbox
name="linearRGB"
checked={options.linearRGB}
onChange={this.onChange}
/>
Linear RGB
</label>
) : null}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="maintainAspect"
checked={maintainAspect}
onChange={linkState(this, 'maintainAspect')}
/>
Maintain aspect ratio
</label>
<Expander>
{maintainAspect ? null : (
<label class={style.optionTextFirst}>
Fit method:
<Select
name="fitMethod"
value={options.fitMethod}
onChange={this.onChange}
>
<option value="stretch">Stretch</option>
<option value="contain">Contain</option>
</Select>
</label>
)}
</Expander>
</form>
);
}
}