mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-18 19:49:04 +00:00
Merge pull request #115 from GoogleChromeLabs/imagequant
Implement image quantization via libimagequant
This commit is contained in:
38
codecs/imagequant/README.md
Normal file
38
codecs/imagequant/README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# ImageQuant
|
||||||
|
|
||||||
|
- Source: <https://github.com/ImageOptim/libimagequant>
|
||||||
|
- Version: v2.12.1
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Docker
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
See `example.html`
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `int version()`
|
||||||
|
|
||||||
|
Returns the version of libimagequant as a number. va.b.c is encoded as 0x0a0b0c
|
||||||
|
|
||||||
|
### `uint8_t* create_buffer(int width, int height)`
|
||||||
|
|
||||||
|
Allocates an RGBA buffer for an image with the given dimension.
|
||||||
|
|
||||||
|
### `void destroy_buffer(uint8_t* p)`
|
||||||
|
|
||||||
|
Frees a buffer created with `create_buffer`.
|
||||||
|
|
||||||
|
### `void quantize(uint8_t* image_buffer, int image_width, int image_height, int numColors, float dithering)`
|
||||||
|
|
||||||
|
Quantizes the given images, using at most `numColors`, a value between 2 and 256. `dithering` is a value between 0 and 1 controlling the amount of dithering.
|
||||||
|
|
||||||
|
### `void free_result()`
|
||||||
|
|
||||||
|
Frees the result created by `encode()`.
|
||||||
|
|
||||||
|
### `int get_result_pointer()`
|
||||||
|
|
||||||
|
Returns the pointer to the start of the buffer holding the encoded data. It has the same size as the input image buffer.
|
||||||
48
codecs/imagequant/example.html
Normal file
48
codecs/imagequant/example.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<script src='imagequant.js'></script>
|
||||||
|
<script>
|
||||||
|
const Module = imagequant();
|
||||||
|
|
||||||
|
async function loadImage(src) {
|
||||||
|
// Load image
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = src;
|
||||||
|
await new Promise(resolve => img.onload = resolve);
|
||||||
|
// Make canvas same size as image
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
[canvas.width, canvas.height] = [img.width, img.height];
|
||||||
|
// Draw image onto canvas
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
return ctx.getImageData(0, 0, img.width, img.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
Module.onRuntimeInitialized = async _ => {
|
||||||
|
const api = {
|
||||||
|
version: Module.cwrap('version', 'number', []),
|
||||||
|
create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
|
||||||
|
destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
|
||||||
|
quantize: Module.cwrap('quantize', '', ['number', 'number', 'number', 'number', 'number']),
|
||||||
|
free_result: Module.cwrap('free_result', '', ['number']),
|
||||||
|
get_result_pointer: Module.cwrap('get_result_pointer', 'number', []),
|
||||||
|
};
|
||||||
|
console.log('Version:', api.version().toString(16));
|
||||||
|
const image = await loadImage('../example.png');
|
||||||
|
const p = api.create_buffer(image.width, image.height);
|
||||||
|
Module.HEAP8.set(image.data, p);
|
||||||
|
api.quantize(p, image.width, image.height, 16, 1.0);
|
||||||
|
const resultPointer = api.get_result_pointer();
|
||||||
|
const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, image.width * image.height * 4);
|
||||||
|
const result = new Uint8ClampedArray(resultView);
|
||||||
|
api.free_result();
|
||||||
|
api.destroy_buffer(p);
|
||||||
|
|
||||||
|
const imageData = new ImageData(result, image.width, image.height);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = image.width;
|
||||||
|
canvas.height = image.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
document.body.appendChild(canvas);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
61
codecs/imagequant/imagequant.c
Normal file
61
codecs/imagequant/imagequant.c
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#include "emscripten.h"
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <inttypes.h>
|
||||||
|
|
||||||
|
#include "libimagequant.h"
|
||||||
|
|
||||||
|
EMSCRIPTEN_KEEPALIVE
|
||||||
|
int version() {
|
||||||
|
return (((LIQ_VERSION/10000) % 100) << 16) |
|
||||||
|
(((LIQ_VERSION/100 ) % 100) << 8) |
|
||||||
|
(((LIQ_VERSION/1 ) % 100) << 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
EMSCRIPTEN_KEEPALIVE
|
||||||
|
uint8_t* create_buffer(int width, int height) {
|
||||||
|
return malloc(width * height * 4 * sizeof(uint8_t));
|
||||||
|
}
|
||||||
|
|
||||||
|
EMSCRIPTEN_KEEPALIVE
|
||||||
|
void destroy_buffer(uint8_t* p) {
|
||||||
|
free(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
liq_attr *attr;
|
||||||
|
liq_image *image;
|
||||||
|
liq_result *res;
|
||||||
|
int result;
|
||||||
|
EMSCRIPTEN_KEEPALIVE
|
||||||
|
void quantize(uint8_t* image_buffer, int image_width, int image_height, int num_colors, float dithering) {
|
||||||
|
int size = image_width * image_height;
|
||||||
|
attr = liq_attr_create();
|
||||||
|
image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
|
||||||
|
liq_set_max_colors(attr, num_colors);
|
||||||
|
liq_image_quantize(image, attr, &res);
|
||||||
|
liq_set_dithering_level(res, dithering);
|
||||||
|
uint8_t* image8bit = (uint8_t*) malloc(size);
|
||||||
|
result = (int) malloc(size * 4);
|
||||||
|
liq_write_remapped_image(res, image, image8bit, size);
|
||||||
|
const liq_palette *pal = liq_get_palette(res);
|
||||||
|
// Turn palletted image back into an RGBA image
|
||||||
|
for(int i = 0; i < size; i++) {
|
||||||
|
((uint8_t*)result)[i * 4 + 0] = pal->entries[image8bit[i]].r;
|
||||||
|
((uint8_t*)result)[i * 4 + 1] = pal->entries[image8bit[i]].g;
|
||||||
|
((uint8_t*)result)[i * 4 + 2] = pal->entries[image8bit[i]].b;
|
||||||
|
((uint8_t*)result)[i * 4 + 3] = pal->entries[image8bit[i]].a;
|
||||||
|
}
|
||||||
|
free(image8bit);
|
||||||
|
liq_result_destroy(res);
|
||||||
|
liq_image_destroy(image);
|
||||||
|
liq_attr_destroy(attr);
|
||||||
|
}
|
||||||
|
|
||||||
|
EMSCRIPTEN_KEEPALIVE
|
||||||
|
void free_result() {
|
||||||
|
free(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
EMSCRIPTEN_KEEPALIVE
|
||||||
|
int get_result_pointer() {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
1
codecs/imagequant/imagequant.d.ts
vendored
Normal file
1
codecs/imagequant/imagequant.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default function(opts: EmscriptenWasm.ModuleOpts): EmscriptenWasm.Module;
|
||||||
17
codecs/imagequant/imagequant.js
Normal file
17
codecs/imagequant/imagequant.js
Normal file
File diff suppressed because one or more lines are too long
BIN
codecs/imagequant/imagequant.wasm
Normal file
BIN
codecs/imagequant/imagequant.wasm
Normal file
Binary file not shown.
1147
codecs/imagequant/package-lock.json
generated
Normal file
1147
codecs/imagequant/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
codecs/imagequant/package.json
Normal file
14
codecs/imagequant/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "imagequant",
|
||||||
|
"scripts": {
|
||||||
|
"install": "napa",
|
||||||
|
"build": "npm run build:wasm",
|
||||||
|
"build:wasm": "docker run --rm -v $(pwd):/src trzeci/emscripten emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='[\"cwrap\"]' -s ALLOW_MEMORY_GROWTH=1 -s MODULARIZE=1 -s 'EXPORT_NAME=\"imagequant\"' -I node_modules/libimagequant -o ./imagequant.js imagequant.c node_modules/libimagequant/{libimagequant,pam,mediancut,blur,mempool,kmeans,nearest}.c"
|
||||||
|
},
|
||||||
|
"napa": {
|
||||||
|
"libimagequant": "ImageOptim/libimagequant#2.12.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"napa": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/codecs/imagequant/Quantizer.worker.ts
Normal file
79
src/codecs/imagequant/Quantizer.worker.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { QuantizeOptions } from './quantizer';
|
||||||
|
import imagequant from '../../../codecs/imagequant/imagequant';
|
||||||
|
// Using require() so TypeScript doesn’t complain about this not being a module.
|
||||||
|
const wasmBinaryUrl = require('../../../codecs/imagequant/imagequant.wasm');
|
||||||
|
|
||||||
|
// API exposed by wasm module. Details in the codec’s README.
|
||||||
|
interface ModuleAPI {
|
||||||
|
version(): number;
|
||||||
|
create_buffer(width: number, height: number): number;
|
||||||
|
destroy_buffer(pointer: number): void;
|
||||||
|
quantize(buffer: number, width: number, height: number, numColors: number, dither: number): void;
|
||||||
|
free_result(): void;
|
||||||
|
get_result_pointer(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ImageQuant {
|
||||||
|
private emscriptenModule: Promise<EmscriptenWasm.Module>;
|
||||||
|
private api: Promise<ModuleAPI>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.emscriptenModule = new Promise((resolve) => {
|
||||||
|
const m = imagequant({
|
||||||
|
// Just to be safe, don’t automatically invoke any wasm functions
|
||||||
|
noInitialRun: false,
|
||||||
|
locateFile(url: string): string {
|
||||||
|
// Redirect the request for the wasm binary to whatever webpack gave us.
|
||||||
|
if (url.endsWith('.wasm')) {
|
||||||
|
return wasmBinaryUrl;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
},
|
||||||
|
onRuntimeInitialized() {
|
||||||
|
// An Emscripten is a then-able that, for some reason, `then()`s itself,
|
||||||
|
// causing an infite loop when you wrap it in a real promise. Deleting the `then`
|
||||||
|
// prop solves this for now.
|
||||||
|
// See: https://github.com/kripken/emscripten/blob/incoming/src/postamble.js#L129
|
||||||
|
// TODO(surma@): File a bug with Emscripten on this.
|
||||||
|
delete (m as any).then;
|
||||||
|
resolve(m);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.api = (async () => {
|
||||||
|
// Not sure why, but TypeScript complains that I am using
|
||||||
|
// `emscriptenModule` before it’s getting assigned, which is clearly not
|
||||||
|
// true :shrug: Using `any`
|
||||||
|
const m = await (this as any).emscriptenModule;
|
||||||
|
return {
|
||||||
|
version: m.cwrap('version', 'number', []),
|
||||||
|
create_buffer: m.cwrap('create_buffer', 'number', ['number', 'number']),
|
||||||
|
destroy_buffer: m.cwrap('destroy_buffer', '', ['number']),
|
||||||
|
quantize: m.cwrap('quantize', '', ['number', 'number', 'number', 'number', 'number']),
|
||||||
|
free_result: m.cwrap('free_result', '', []),
|
||||||
|
get_result_pointer: m.cwrap('get_result_pointer', 'number', []),
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
async quantize(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
|
||||||
|
const m = await this.emscriptenModule;
|
||||||
|
const api = await this.api;
|
||||||
|
|
||||||
|
const p = api.create_buffer(data.width, data.height);
|
||||||
|
m.HEAP8.set(new Uint8Array(data.data), p);
|
||||||
|
api.quantize(p, data.width, data.height, opts.maxNumColors, opts.dither);
|
||||||
|
const resultPointer = api.get_result_pointer();
|
||||||
|
const resultView = new Uint8Array(
|
||||||
|
m.HEAP8.buffer,
|
||||||
|
resultPointer,
|
||||||
|
data.width * data.height * 4,
|
||||||
|
);
|
||||||
|
const result = new Uint8ClampedArray(resultView);
|
||||||
|
api.free_result();
|
||||||
|
api.destroy_buffer(p);
|
||||||
|
|
||||||
|
return new ImageData(result, data.width, data.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/codecs/imagequant/options.tsx
Normal file
51
src/codecs/imagequant/options.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { h, Component } from 'preact';
|
||||||
|
import { bind, inputFieldValueAsNumber } from '../../lib/util';
|
||||||
|
import { QuantizeOptions } from './quantizer';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
options: QuantizeOptions;
|
||||||
|
onChange(newOptions: QuantizeOptions): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class QuantizerOptions extends Component<Props, {}> {
|
||||||
|
@bind
|
||||||
|
onChange(event: Event) {
|
||||||
|
const form = (event.currentTarget as HTMLInputElement).closest('form') as HTMLFormElement;
|
||||||
|
|
||||||
|
const options: QuantizeOptions = {
|
||||||
|
maxNumColors: inputFieldValueAsNumber(form.maxNumColors),
|
||||||
|
dither: inputFieldValueAsNumber(form.dither),
|
||||||
|
};
|
||||||
|
this.props.onChange(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
render({ options }: Props) {
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<label>
|
||||||
|
Pallette Colors:
|
||||||
|
<input
|
||||||
|
name="maxNumColors"
|
||||||
|
type="range"
|
||||||
|
min="2"
|
||||||
|
max="256"
|
||||||
|
value={'' + options.maxNumColors}
|
||||||
|
onChange={this.onChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Dithering:
|
||||||
|
<input
|
||||||
|
name="dither"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={'' + options.dither}
|
||||||
|
onChange={this.onChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/codecs/imagequant/quantizer.ts
Normal file
16
src/codecs/imagequant/quantizer.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import QuantizerWorker from './Quantizer.worker';
|
||||||
|
|
||||||
|
export async function quantize(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
|
||||||
|
const quantizer = await new QuantizerWorker();
|
||||||
|
return quantizer.quantize(data, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuantizeOptions {
|
||||||
|
maxNumColors: number;
|
||||||
|
dither: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultOptions: QuantizeOptions = {
|
||||||
|
maxNumColors: 256,
|
||||||
|
dither: 1.0,
|
||||||
|
};
|
||||||
15
src/codecs/preprocessors.ts
Normal file
15
src/codecs/preprocessors.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { QuantizeOptions, defaultOptions as quantizerDefaultOptions } from './imagequant/quantizer';
|
||||||
|
|
||||||
|
interface Enableable {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
export interface PreprocessorState {
|
||||||
|
quantizer: Enableable & QuantizeOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultPreprocessorState = {
|
||||||
|
quantizer: {
|
||||||
|
enabled: false,
|
||||||
|
...quantizerDefaultOptions,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { h, Component } from 'preact';
|
import { h, Component } from 'preact';
|
||||||
import { bind } from '../../lib/util';
|
import { bind, inputFieldCheckedAsNumber, inputFieldValueAsNumber } from '../../lib/util';
|
||||||
import { EncodeOptions, WebPImageHint } from './encoder';
|
import { EncodeOptions, WebPImageHint } from './encoder';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
|
|
||||||
@@ -24,25 +24,11 @@ function determineLosslessQuality(quality: number): number {
|
|||||||
return losslessPresetDefault;
|
return losslessPresetDefault;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
|
|
||||||
*/
|
|
||||||
function fieldCheckedAsNumber(field: any): number {
|
|
||||||
return Number((field as HTMLInputElement).checked);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
|
|
||||||
*/
|
|
||||||
function fieldValueAsNumber(field: any): number {
|
|
||||||
return Number((field as HTMLInputElement).value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class WebPEncoderOptions extends Component<Props, {}> {
|
export default class WebPEncoderOptions extends Component<Props, {}> {
|
||||||
@bind
|
@bind
|
||||||
onChange(event: Event) {
|
onChange(event: Event) {
|
||||||
const form = (event.currentTarget as HTMLInputElement).closest('form') as HTMLFormElement;
|
const form = (event.currentTarget as HTMLInputElement).closest('form') as HTMLFormElement;
|
||||||
const lossless = fieldCheckedAsNumber(form.lossless);
|
const lossless = inputFieldCheckedAsNumber(form.lossless);
|
||||||
const losslessPresetInput = (form.lossless_preset as HTMLInputElement);
|
const losslessPresetInput = (form.lossless_preset as HTMLInputElement);
|
||||||
|
|
||||||
const options: EncodeOptions = {
|
const options: EncodeOptions = {
|
||||||
@@ -54,31 +40,31 @@ export default class WebPEncoderOptions extends Component<Props, {}> {
|
|||||||
// In lossless mode, the quality is derived from the preset.
|
// In lossless mode, the quality is derived from the preset.
|
||||||
quality: lossless ?
|
quality: lossless ?
|
||||||
losslessPresets[Number(losslessPresetInput.value)][1] :
|
losslessPresets[Number(losslessPresetInput.value)][1] :
|
||||||
fieldValueAsNumber(form.quality),
|
inputFieldValueAsNumber(form.quality),
|
||||||
// In lossless mode, the method is derived from the preset.
|
// In lossless mode, the method is derived from the preset.
|
||||||
method: lossless ?
|
method: lossless ?
|
||||||
losslessPresets[Number(losslessPresetInput.value)][0] :
|
losslessPresets[Number(losslessPresetInput.value)][0] :
|
||||||
fieldValueAsNumber(form.method_input),
|
inputFieldValueAsNumber(form.method_input),
|
||||||
image_hint: (form.image_hint as HTMLInputElement).checked ?
|
image_hint: (form.image_hint as HTMLInputElement).checked ?
|
||||||
WebPImageHint.WEBP_HINT_GRAPH :
|
WebPImageHint.WEBP_HINT_GRAPH :
|
||||||
WebPImageHint.WEBP_HINT_DEFAULT,
|
WebPImageHint.WEBP_HINT_DEFAULT,
|
||||||
// .checked
|
// .checked
|
||||||
exact: fieldCheckedAsNumber(form.exact),
|
exact: inputFieldCheckedAsNumber(form.exact),
|
||||||
alpha_compression: fieldCheckedAsNumber(form.alpha_compression),
|
alpha_compression: inputFieldCheckedAsNumber(form.alpha_compression),
|
||||||
autofilter: fieldCheckedAsNumber(form.autofilter),
|
autofilter: inputFieldCheckedAsNumber(form.autofilter),
|
||||||
filter_type: fieldCheckedAsNumber(form.filter_type),
|
filter_type: inputFieldCheckedAsNumber(form.filter_type),
|
||||||
use_sharp_yuv: fieldCheckedAsNumber(form.use_sharp_yuv),
|
use_sharp_yuv: inputFieldCheckedAsNumber(form.use_sharp_yuv),
|
||||||
// .value
|
// .value
|
||||||
near_lossless: fieldValueAsNumber(form.near_lossless),
|
near_lossless: inputFieldValueAsNumber(form.near_lossless),
|
||||||
alpha_quality: fieldValueAsNumber(form.alpha_quality),
|
alpha_quality: inputFieldValueAsNumber(form.alpha_quality),
|
||||||
alpha_filtering: fieldValueAsNumber(form.alpha_filtering),
|
alpha_filtering: inputFieldValueAsNumber(form.alpha_filtering),
|
||||||
sns_strength: fieldValueAsNumber(form.sns_strength),
|
sns_strength: inputFieldValueAsNumber(form.sns_strength),
|
||||||
filter_strength: fieldValueAsNumber(form.filter_strength),
|
filter_strength: inputFieldValueAsNumber(form.filter_strength),
|
||||||
filter_sharpness: fieldValueAsNumber(form.filter_sharpness),
|
filter_sharpness: inputFieldValueAsNumber(form.filter_sharpness),
|
||||||
pass: fieldValueAsNumber(form.pass),
|
pass: inputFieldValueAsNumber(form.pass),
|
||||||
preprocessing: fieldValueAsNumber(form.preprocessing),
|
preprocessing: inputFieldValueAsNumber(form.preprocessing),
|
||||||
segments: fieldValueAsNumber(form.segments),
|
segments: inputFieldValueAsNumber(form.segments),
|
||||||
partitions: fieldValueAsNumber(form.partitions),
|
partitions: inputFieldValueAsNumber(form.partitions),
|
||||||
};
|
};
|
||||||
this.props.onChange(options);
|
this.props.onChange(options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Options from '../Options';
|
|||||||
import { FileDropEvent } from './custom-els/FileDrop';
|
import { FileDropEvent } from './custom-els/FileDrop';
|
||||||
import './custom-els/FileDrop';
|
import './custom-els/FileDrop';
|
||||||
|
|
||||||
|
import * as quantizer from '../../codecs/imagequant/quantizer';
|
||||||
import * as mozJPEG from '../../codecs/mozjpeg/encoder';
|
import * as mozJPEG from '../../codecs/mozjpeg/encoder';
|
||||||
import * as webP from '../../codecs/webp/encoder';
|
import * as webP from '../../codecs/webp/encoder';
|
||||||
import * as identity from '../../codecs/identity/encoder';
|
import * as identity from '../../codecs/identity/encoder';
|
||||||
@@ -26,18 +27,25 @@ import {
|
|||||||
encoderMap,
|
encoderMap,
|
||||||
} from '../../codecs/encoders';
|
} from '../../codecs/encoders';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PreprocessorState,
|
||||||
|
defaultPreprocessorState,
|
||||||
|
} from '../../codecs/preprocessors';
|
||||||
|
|
||||||
import { decodeImage } from '../../codecs/decoders';
|
import { decodeImage } from '../../codecs/decoders';
|
||||||
|
|
||||||
interface SourceImage {
|
interface SourceImage {
|
||||||
file: File;
|
file: File;
|
||||||
bmp: ImageBitmap;
|
bmp: ImageBitmap;
|
||||||
data: ImageData;
|
data: ImageData;
|
||||||
|
preprocessed?: ImageData;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EncodedImage {
|
interface EncodedImage {
|
||||||
bmp?: ImageBitmap;
|
bmp?: ImageBitmap;
|
||||||
file?: File;
|
file?: File;
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
|
preprocessorState: PreprocessorState;
|
||||||
encoderState: EncoderState;
|
encoderState: EncoderState;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
/** Counter of the latest bmp currently encoding */
|
/** Counter of the latest bmp currently encoding */
|
||||||
@@ -57,6 +65,16 @@ interface State {
|
|||||||
|
|
||||||
const filesize = partial({});
|
const filesize = partial({});
|
||||||
|
|
||||||
|
async function preprocessImage(
|
||||||
|
source: SourceImage,
|
||||||
|
preprocessData: PreprocessorState,
|
||||||
|
): Promise<ImageData> {
|
||||||
|
let result = source.data;
|
||||||
|
if (preprocessData.quantizer.enabled) {
|
||||||
|
result = await quantizer.quantize(result, preprocessData.quantizer);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
async function compressImage(
|
async function compressImage(
|
||||||
source: SourceImage,
|
source: SourceImage,
|
||||||
encodeData: EncoderState,
|
encodeData: EncoderState,
|
||||||
@@ -64,18 +82,22 @@ async function compressImage(
|
|||||||
// Special case for identity
|
// Special case for identity
|
||||||
if (encodeData.type === identity.type) return source.file;
|
if (encodeData.type === identity.type) return source.file;
|
||||||
|
|
||||||
|
let sourceData = source.data;
|
||||||
|
if (source.preprocessed) {
|
||||||
|
sourceData = source.preprocessed;
|
||||||
|
}
|
||||||
const compressedData = await (() => {
|
const compressedData = await (() => {
|
||||||
switch (encodeData.type) {
|
switch (encodeData.type) {
|
||||||
case mozJPEG.type: return mozJPEG.encode(source.data, encodeData.options);
|
case mozJPEG.type: return mozJPEG.encode(sourceData, encodeData.options);
|
||||||
case webP.type: return webP.encode(source.data, encodeData.options);
|
case webP.type: return webP.encode(sourceData, encodeData.options);
|
||||||
case browserPNG.type: return browserPNG.encode(source.data, encodeData.options);
|
case browserPNG.type: return browserPNG.encode(sourceData, encodeData.options);
|
||||||
case browserJPEG.type: return browserJPEG.encode(source.data, encodeData.options);
|
case browserJPEG.type: return browserJPEG.encode(sourceData, encodeData.options);
|
||||||
case browserWebP.type: return browserWebP.encode(source.data, encodeData.options);
|
case browserWebP.type: return browserWebP.encode(sourceData, encodeData.options);
|
||||||
case browserGIF.type: return browserGIF.encode(source.data, encodeData.options);
|
case browserGIF.type: return browserGIF.encode(sourceData, encodeData.options);
|
||||||
case browserTIFF.type: return browserTIFF.encode(source.data, encodeData.options);
|
case browserTIFF.type: return browserTIFF.encode(sourceData, encodeData.options);
|
||||||
case browserJP2.type: return browserJP2.encode(source.data, encodeData.options);
|
case browserJP2.type: return browserJP2.encode(sourceData, encodeData.options);
|
||||||
case browserBMP.type: return browserBMP.encode(source.data, encodeData.options);
|
case browserBMP.type: return browserBMP.encode(sourceData, encodeData.options);
|
||||||
case browserPDF.type: return browserPDF.encode(source.data, encodeData.options);
|
case browserPDF.type: return browserPDF.encode(sourceData, encodeData.options);
|
||||||
default: throw Error(`Unexpected encoder ${JSON.stringify(encodeData)}`);
|
default: throw Error(`Unexpected encoder ${JSON.stringify(encodeData)}`);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -94,12 +116,14 @@ export default class App extends Component<Props, State> {
|
|||||||
loading: false,
|
loading: false,
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
|
preprocessorState: defaultPreprocessorState,
|
||||||
encoderState: { type: identity.type, options: identity.defaultOptions },
|
encoderState: { type: identity.type, options: identity.defaultOptions },
|
||||||
loadingCounter: 0,
|
loadingCounter: 0,
|
||||||
loadedCounter: 0,
|
loadedCounter: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
preprocessorState: defaultPreprocessorState,
|
||||||
encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions },
|
encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions },
|
||||||
loadingCounter: 0,
|
loadingCounter: 0,
|
||||||
loadedCounter: 0,
|
loadedCounter: 0,
|
||||||
@@ -121,7 +145,12 @@ export default class App extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onEncoderChange(index: 0 | 1, type: EncoderType, options?: EncoderOptions): void {
|
onChange(
|
||||||
|
index: 0 | 1,
|
||||||
|
preprocessorState: PreprocessorState,
|
||||||
|
type: EncoderType,
|
||||||
|
options?: EncoderOptions,
|
||||||
|
): void {
|
||||||
const images = this.state.images.slice() as [EncodedImage, EncodedImage];
|
const images = this.state.images.slice() as [EncodedImage, EncodedImage];
|
||||||
const oldImage = images[index];
|
const oldImage = images[index];
|
||||||
|
|
||||||
@@ -136,13 +165,32 @@ export default class App extends Component<Props, State> {
|
|||||||
images[index] = {
|
images[index] = {
|
||||||
...oldImage,
|
...oldImage,
|
||||||
encoderState,
|
encoderState,
|
||||||
|
preprocessorState,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.setState({ images });
|
this.setState({ images });
|
||||||
}
|
}
|
||||||
|
|
||||||
onOptionsChange(index: 0 | 1, options: EncoderOptions): void {
|
onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void {
|
||||||
this.onEncoderChange(index, this.state.images[index].encoderState.type, options);
|
this.onChange(index, this.state.images[index].preprocessorState, newType);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPreprocessorOptionsChange(index: 0 | 1, options: PreprocessorState): void {
|
||||||
|
this.onChange(
|
||||||
|
index,
|
||||||
|
options,
|
||||||
|
this.state.images[index].encoderState.type,
|
||||||
|
this.state.images[index].encoderState.options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onEncoderOptionsChange(index: 0 | 1, options: EncoderOptions): void {
|
||||||
|
this.onChange(
|
||||||
|
index,
|
||||||
|
this.state.images[index].preprocessorState,
|
||||||
|
this.state.images[index].encoderState.type,
|
||||||
|
options,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps: Props, prevState: State): void {
|
componentDidUpdate(prevProps: Props, prevState: State): void {
|
||||||
@@ -212,8 +260,8 @@ export default class App extends Component<Props, State> {
|
|||||||
this.setState({ images });
|
this.setState({ images });
|
||||||
|
|
||||||
let file;
|
let file;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
source.preprocessed = await preprocessImage(source, image.preprocessorState);
|
||||||
file = await compressImage(source, image.encoderState);
|
file = await compressImage(source, image.encoderState);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ error: `Encoding error (type=${image.encoderState.type}): ${err}` });
|
this.setState({ error: `Encoding error (type=${image.encoderState.type}): ${err}` });
|
||||||
@@ -276,9 +324,11 @@ export default class App extends Component<Props, State> {
|
|||||||
{images.map((image, index) => (
|
{images.map((image, index) => (
|
||||||
<Options
|
<Options
|
||||||
class={index ? style.rightOptions : style.leftOptions}
|
class={index ? style.rightOptions : style.leftOptions}
|
||||||
|
preprocessorState={image.preprocessorState}
|
||||||
encoderState={image.encoderState}
|
encoderState={image.encoderState}
|
||||||
onTypeChange={this.onEncoderChange.bind(this, index)}
|
onEncoderTypeChange={this.onEncoderTypeChange.bind(this, index)}
|
||||||
onOptionsChange={this.onOptionsChange.bind(this, index)}
|
onEncoderOptionsChange={this.onEncoderOptionsChange.bind(this, index)}
|
||||||
|
onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(this, index)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{anyLoading && <span style={{ position: 'fixed', top: 0, left: 0 }}>Loading...</span>}
|
{anyLoading && <span style={{ position: 'fixed', top: 0, left: 0 }}>Loading...</span>}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import BrowserJPEGEncoderOptions from '../../codecs/browser-jpeg/options';
|
|||||||
import WebPEncoderOptions from '../../codecs/webp/options';
|
import WebPEncoderOptions from '../../codecs/webp/options';
|
||||||
import BrowserWebPEncoderOptions from '../../codecs/browser-webp/options';
|
import BrowserWebPEncoderOptions from '../../codecs/browser-webp/options';
|
||||||
|
|
||||||
|
import QuantizerOptionsComponent from '../../codecs/imagequant/options';
|
||||||
|
|
||||||
import * as identity from '../../codecs/identity/encoder';
|
import * as identity from '../../codecs/identity/encoder';
|
||||||
import * as mozJPEG from '../../codecs/mozjpeg/encoder';
|
import * as mozJPEG from '../../codecs/mozjpeg/encoder';
|
||||||
import * as webP from '../../codecs/webp/encoder';
|
import * as webP from '../../codecs/webp/encoder';
|
||||||
@@ -25,6 +27,9 @@ import {
|
|||||||
encodersSupported,
|
encodersSupported,
|
||||||
EncoderSupportMap,
|
EncoderSupportMap,
|
||||||
} from '../../codecs/encoders';
|
} from '../../codecs/encoders';
|
||||||
|
import { QuantizeOptions } from '../../codecs/imagequant/quantizer';
|
||||||
|
|
||||||
|
import { PreprocessorState } from '../../codecs/preprocessors';
|
||||||
|
|
||||||
const encoderOptionsComponentMap = {
|
const encoderOptionsComponentMap = {
|
||||||
[identity.type]: undefined,
|
[identity.type]: undefined,
|
||||||
@@ -44,8 +49,10 @@ const encoderOptionsComponentMap = {
|
|||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
encoderState: EncoderState;
|
encoderState: EncoderState;
|
||||||
onTypeChange(newType: EncoderType): void;
|
preprocessorState: PreprocessorState;
|
||||||
onOptionsChange(newOptions: EncoderOptions): void;
|
onEncoderTypeChange(newType: EncoderType): void;
|
||||||
|
onEncoderOptionsChange(newOptions: EncoderOptions): void;
|
||||||
|
onPreprocessorOptionsChange(newOptions: PreprocessorState): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@@ -61,25 +68,68 @@ export default class Options extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
onTypeChange(event: Event) {
|
onEncoderTypeChange(event: Event) {
|
||||||
const el = event.currentTarget as HTMLSelectElement;
|
const el = event.currentTarget as HTMLSelectElement;
|
||||||
|
|
||||||
// The select element only has values matching encoder types,
|
// The select element only has values matching encoder types,
|
||||||
// so 'as' is safe here.
|
// so 'as' is safe here.
|
||||||
const type = el.value as EncoderType;
|
const type = el.value as EncoderType;
|
||||||
this.props.onTypeChange(type);
|
this.props.onEncoderTypeChange(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
render({ class: className, encoderState, onOptionsChange }: Props, { encoderSupportMap }: State) {
|
@bind
|
||||||
|
onPreprocessorEnabledChange(event: Event) {
|
||||||
|
const el = event.currentTarget as HTMLInputElement;
|
||||||
|
|
||||||
|
const preprocessorState = this.props.preprocessorState;
|
||||||
|
const preprocessor = el.name.split('.')[0] as keyof typeof preprocessorState;
|
||||||
|
preprocessorState[preprocessor].enabled = el.checked;
|
||||||
|
this.props.onPreprocessorOptionsChange(preprocessorState);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
onQuantizerOptionsChange(opts: QuantizeOptions) {
|
||||||
|
this.props.onPreprocessorOptionsChange({
|
||||||
|
...this.props.preprocessorState,
|
||||||
|
quantizer: {
|
||||||
|
...opts,
|
||||||
|
enabled: this.props.preprocessorState.quantizer.enabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
{ class: className, encoderState, preprocessorState, onEncoderOptionsChange }: Props,
|
||||||
|
{ encoderSupportMap }: State,
|
||||||
|
) {
|
||||||
// tslint:disable variable-name
|
// tslint:disable variable-name
|
||||||
const EncoderOptionComponent = encoderOptionsComponentMap[encoderState.type];
|
const EncoderOptionComponent = encoderOptionsComponentMap[encoderState.type];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`${style.options}${className ? (' ' + className) : ''}`}>
|
<div class={`${style.options}${className ? (' ' + className) : ''}`}>
|
||||||
|
<p>Quantization</p>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
name="quantizer.enable"
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!preprocessorState.quantizer.enabled}
|
||||||
|
onChange={this.onPreprocessorEnabledChange}
|
||||||
|
/>
|
||||||
|
Enable
|
||||||
|
</label>
|
||||||
|
{preprocessorState.quantizer.enabled ? (
|
||||||
|
<QuantizerOptionsComponent
|
||||||
|
options={preprocessorState.quantizer}
|
||||||
|
onChange={this.onQuantizerOptionsChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div/>
|
||||||
|
)}
|
||||||
|
<hr/>
|
||||||
<label>
|
<label>
|
||||||
Mode:
|
Mode:
|
||||||
{encoderSupportMap ?
|
{encoderSupportMap ?
|
||||||
<select value={encoderState.type} onChange={this.onTypeChange}>
|
<select value={encoderState.type} onChange={this.onEncoderTypeChange}>
|
||||||
{encoders.filter(encoder => encoderSupportMap[encoder.type]).map(encoder => (
|
{encoders.filter(encoder => encoderSupportMap[encoder.type]).map(encoder => (
|
||||||
<option value={encoder.type}>{encoder.label}</option>
|
<option value={encoder.type}>{encoder.label}</option>
|
||||||
))}
|
))}
|
||||||
@@ -95,7 +145,7 @@ export default class Options extends Component<Props, State> {
|
|||||||
// type, but typescript isn't smart enough.
|
// type, but typescript isn't smart enough.
|
||||||
encoderState.options as typeof EncoderOptionComponent['prototype']['props']['options']
|
encoderState.options as typeof EncoderOptionComponent['prototype']['props']['options']
|
||||||
}
|
}
|
||||||
onChange={onOptionsChange}
|
onChange={onEncoderOptionsChange}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -145,3 +145,17 @@ export async function sniffMimeType(blob: Blob): Promise<string> {
|
|||||||
export function createImageBitmapPolyfill(blob: Blob): Promise<ImageBitmap> {
|
export function createImageBitmapPolyfill(blob: Blob): Promise<ImageBitmap> {
|
||||||
return createImageBitmap(blob);
|
return createImageBitmap(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
|
||||||
|
*/
|
||||||
|
export function inputFieldValueAsNumber(field: any): number {
|
||||||
|
return Number((field as HTMLInputElement).value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
|
||||||
|
*/
|
||||||
|
export function inputFieldCheckedAsNumber(field: any): number {
|
||||||
|
return Number((field as HTMLInputElement).checked);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user