From 36d23644aa049ba73c4d78a0de7cf6a2616f497f Mon Sep 17 00:00:00 2001 From: Surma Date: Thu, 6 Feb 2020 14:31:27 -0800 Subject: [PATCH] Integrate JXL decoder --- codecs/jxl_dec/jxl_dec.d.ts | 4 +- src/codecs/decoders.ts | 1 + src/codecs/jxl/decoder-meta.ts | 7 + src/codecs/jxl/decoder.ts | 20 ++ src/codecs/jxl/encoder-meta.ts | 72 ++++++ src/codecs/jxl/encoder.ts | 18 ++ src/codecs/jxl/options.tsx | 346 +++++++++++++++++++++++++++ src/codecs/processor-worker/index.ts | 8 + src/codecs/processor.ts | 6 + src/lib/util.ts | 1 + 10 files changed, 481 insertions(+), 2 deletions(-) create mode 100644 src/codecs/jxl/decoder-meta.ts create mode 100644 src/codecs/jxl/decoder.ts create mode 100644 src/codecs/jxl/encoder-meta.ts create mode 100644 src/codecs/jxl/encoder.ts create mode 100644 src/codecs/jxl/options.tsx diff --git a/codecs/jxl_dec/jxl_dec.d.ts b/codecs/jxl_dec/jxl_dec.d.ts index 5f685d06..06b71afe 100644 --- a/codecs/jxl_dec/jxl_dec.d.ts +++ b/codecs/jxl_dec/jxl_dec.d.ts @@ -4,10 +4,10 @@ interface RawImage { height: number; } -interface AVIFModule extends EmscriptenWasm.Module { +interface JXLModule extends EmscriptenWasm.Module { decode(data: BufferSource): RawImage; free_result(): void; } -export default function(opts: EmscriptenWasm.ModuleOpts): AVIFModule; +export default function(opts: EmscriptenWasm.ModuleOpts): JXLModule; diff --git a/src/codecs/decoders.ts b/src/codecs/decoders.ts index b66be104..8401e990 100644 --- a/src/codecs/decoders.ts +++ b/src/codecs/decoders.ts @@ -9,6 +9,7 @@ export async function decodeImage(blob: Blob, processor: Processor): Promise; + +export async function decode(data: ArrayBuffer): Promise { + if (!emscriptenModule) emscriptenModule = initEmscriptenModule(jxl_dec, wasmUrl); + + const module = await emscriptenModule; + const rawImage = module.decode(data); + const result = new ImageData( + new Uint8ClampedArray(rawImage.buffer), + rawImage.width, + rawImage.height, + ); + + module.free_result(); + return result; +} diff --git a/src/codecs/jxl/encoder-meta.ts b/src/codecs/jxl/encoder-meta.ts new file mode 100644 index 00000000..330a2825 --- /dev/null +++ b/src/codecs/jxl/encoder-meta.ts @@ -0,0 +1,72 @@ +export 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). +} + +export interface EncodeOptions { + quality: number; + target_size: number; + target_PSNR: number; + method: number; + sns_strength: number; + filter_strength: number; + filter_sharpness: number; + filter_type: number; + partitions: number; + segments: number; + pass: number; + show_compressed: number; + preprocessing: number; + autofilter: number; + partition_limit: number; + alpha_compression: number; + alpha_filtering: number; + alpha_quality: number; + lossless: number; + exact: number; + image_hint: number; + emulate_jpeg_size: number; + thread_level: number; + low_memory: number; + near_lossless: number; + use_delta_palette: number; + use_sharp_yuv: number; +} +export interface EncoderState { type: typeof type; options: EncodeOptions; } + +export const type = 'webp'; +export const label = 'WebP'; +export const mimeType = 'image/webp'; +export const extension = 'webp'; +// These come from struct WebPConfig in encode.h. +export const defaultOptions: EncodeOptions = { + quality: 75, + target_size: 0, + target_PSNR: 0, + method: 4, + sns_strength: 50, + filter_strength: 60, + filter_sharpness: 0, + filter_type: 1, + partitions: 0, + segments: 4, + pass: 1, + show_compressed: 0, + preprocessing: 0, + autofilter: 0, + partition_limit: 0, + alpha_compression: 1, + alpha_filtering: 1, + alpha_quality: 100, + lossless: 0, + exact: 0, + image_hint: 0, + emulate_jpeg_size: 0, + thread_level: 0, + low_memory: 0, + near_lossless: 100, + use_delta_palette: 0, + use_sharp_yuv: 0, +}; diff --git a/src/codecs/jxl/encoder.ts b/src/codecs/jxl/encoder.ts new file mode 100644 index 00000000..5fafb577 --- /dev/null +++ b/src/codecs/jxl/encoder.ts @@ -0,0 +1,18 @@ +import webp_enc, { WebPModule } from '../../../codecs/webp_enc/webp_enc'; +import wasmUrl from '../../../codecs/webp_enc/webp_enc.wasm'; +import { EncodeOptions } from './encoder-meta'; +import { initEmscriptenModule } from '../util'; + +let emscriptenModule: Promise; + +export async function encode(data: ImageData, options: EncodeOptions): Promise { + if (!emscriptenModule) emscriptenModule = initEmscriptenModule(webp_enc, wasmUrl); + + const module = await emscriptenModule; + const resultView = module.encode(data.data, data.width, data.height, options); + const result = new Uint8Array(resultView); + module.free_result(); + + // wasm can’t run on SharedArrayBuffers, so we hard-cast to ArrayBuffer. + return result.buffer as ArrayBuffer; +} diff --git a/src/codecs/jxl/options.tsx b/src/codecs/jxl/options.tsx new file mode 100644 index 00000000..6d363ad3 --- /dev/null +++ b/src/codecs/jxl/options.tsx @@ -0,0 +1,346 @@ +import { h, Component } from 'preact'; +import { bind } from '../../lib/initial-util'; +import { inputFieldCheckedAsNumber, inputFieldValueAsNumber, preventDefault } from '../../lib/util'; +import { EncodeOptions, WebPImageHint } from './encoder-meta'; +import * as style from '../../components/Options/style.scss'; +import Checkbox from '../../components/checkbox'; +import Expander from '../../components/expander'; +import Select from '../../components/select'; +import Range from '../../components/range'; +import linkState from 'linkstate'; + +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 default class WebPEncoderOptions extends Component { + state: State = { + showAdvanced: false, + }; + + @bind + 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 ( +
+
+ + Effort: + +
+
+ + Slight loss: + +
+ +
+ ); + } + + private _lossySpecificOptions(options: EncodeOptions) { + const { showAdvanced } = this.state; + + return ( +
+
+ + Effort: + +
+
+ + Quality: + +
+ + + {showAdvanced ? +
+ +
+ + Alpha quality: + +
+
+ + Alpha filter quality: + +
+ + + {options.autofilter ? null : +
+ + Filter strength: + +
+ } +
+ +
+ + Filter sharpness: + +
+ +
+ + Passes: + +
+
+ + Spacial noise shaping: + +
+ +
+ + Segments: + +
+
+ + Partitions: + +
+
+ : null + } +
+
+ ); + } + + render({ options }: Props) { + // I'm rendering both lossy and lossless forms, as it becomes much easier when + // gathering the data. + return ( +
+ + {options.lossless + ? this._losslessSpecificOptions(options) + : this._lossySpecificOptions(options) + } + +
+ ); + } +} diff --git a/src/codecs/processor-worker/index.ts b/src/codecs/processor-worker/index.ts index a8701703..d2042f7a 100644 --- a/src/codecs/processor-worker/index.ts +++ b/src/codecs/processor-worker/index.ts @@ -98,6 +98,13 @@ async function avifDecode(data: ArrayBuffer): Promise { return timed('avifDencode', () => decode(data)); } +async function jxlDecode(data: ArrayBuffer): Promise { + const { decode } = await import( + /* webpackChunkName: "process-jxl-dec" */ + '../jxl/decoder'); + return decode(data); +} + const exports = { mozjpegEncode, quantize, @@ -108,6 +115,7 @@ const exports = { webpDecode, avifEncode, avifDecode, + jxlDecode, }; export type ProcessorWorkerApi = typeof exports; diff --git a/src/codecs/processor.ts b/src/codecs/processor.ts index 02ad4c29..d786c989 100644 --- a/src/codecs/processor.ts +++ b/src/codecs/processor.ts @@ -173,6 +173,12 @@ export default class Processor { return this._workerApi!.avifEncode(data, opts); } + @Processor._processingJob({ needsWorker: true }) + async jxlDecode(blob: Blob): Promise { + const data = await blobToArrayBuffer(blob); + return this._workerApi!.jxlDecode(data); + } + // Not-worker jobs: @Processor._processingJob() diff --git a/src/lib/util.ts b/src/lib/util.ts index 23fc0a5b..552d7277 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -130,6 +130,7 @@ const magicNumberToMimeType = new Map([ [/^MM\x00*/, 'image/tiff'], [/^RIFF....WEBPVP8[LX ]/, 'image/webp'], [/^\x00\x00\x00 ftypavif\x00\x00\x00\x00/, 'image/avif'], + [/^\xff\x0a/, 'image/jpegxl'], ]); export async function sniffMimeType(blob: Blob): Promise {