diff --git a/libsquoosh/rollup.config.js b/libsquoosh/rollup.config.js index 3ca3a7e2..8a941ea6 100644 --- a/libsquoosh/rollup.config.js +++ b/libsquoosh/rollup.config.js @@ -10,7 +10,7 @@ import { builtinModules } from 'module'; /** @type {import('rollup').RollupOptions} */ export default { - input: 'src/index.js', + input: 'src/index.ts', output: { dir: 'build', format: 'cjs', diff --git a/libsquoosh/src/codecs.ts b/libsquoosh/src/codecs.ts index 868a889b..98052cd6 100644 --- a/libsquoosh/src/codecs.ts +++ b/libsquoosh/src/codecs.ts @@ -9,6 +9,12 @@ import { cpus } from 'os'; hardwareConcurrency: cpus().length, }; +interface DecodeModule extends EmscriptenWasm.Module { + decode: (data: Uint8Array) => ImageData; +} + +type DecodeModuleFactory = EmscriptenWasm.ModuleFactory; + interface RotateModuleInstance { exports: { memory: WebAssembly.Memory; @@ -33,7 +39,7 @@ interface ResizeInstantiateOptions { declare global { // Needed for being able to use ImageData as type in codec types - type ImageData = typeof import('./image_data.js'); + type ImageData = import('./image_data.js').default; // Needed for being able to assign to `globalThis.ImageData` var ImageData: ImageData['constructor']; } @@ -41,18 +47,21 @@ declare global { import type { QuantizerModule } from '../../codecs/imagequant/imagequant.js'; // MozJPEG +import type { MozJPEGModule as MozJPEGEncodeModule } from '../../codecs/mozjpeg/enc/mozjpeg_enc'; import mozEnc from '../../codecs/mozjpeg/enc/mozjpeg_node_enc.js'; import mozEncWasm from 'asset-url:../../codecs/mozjpeg/enc/mozjpeg_node_enc.wasm'; import mozDec from '../../codecs/mozjpeg/dec/mozjpeg_node_dec.js'; import mozDecWasm from 'asset-url:../../codecs/mozjpeg/dec/mozjpeg_node_dec.wasm'; // WebP +import type { WebPModule as WebPEncodeModule } from '../../codecs/webp/enc/webp_enc'; import webpEnc from '../../codecs/webp/enc/webp_node_enc.js'; import webpEncWasm from 'asset-url:../../codecs/webp/enc/webp_node_enc.wasm'; import webpDec from '../../codecs/webp/dec/webp_node_dec.js'; import webpDecWasm from 'asset-url:../../codecs/webp/dec/webp_node_dec.wasm'; // AVIF +import type { AVIFModule as AVIFEncodeModule } from '../../codecs/avif/enc/avif_enc'; import avifEnc from '../../codecs/avif/enc/avif_node_enc.js'; import avifEncWasm from 'asset-url:../../codecs/avif/enc/avif_node_enc.wasm'; import avifEncMt from '../../codecs/avif/enc/avif_node_enc_mt.js'; @@ -62,12 +71,14 @@ import avifDec from '../../codecs/avif/dec/avif_node_dec.js'; import avifDecWasm from 'asset-url:../../codecs/avif/dec/avif_node_dec.wasm'; // JXL +import type { JXLModule as JXLEncodeModule } from '../../codecs/jxl/enc/jxl_enc'; import jxlEnc from '../../codecs/jxl/enc/jxl_node_enc.js'; import jxlEncWasm from 'asset-url:../../codecs/jxl/enc/jxl_node_enc.wasm'; import jxlDec from '../../codecs/jxl/dec/jxl_node_dec.js'; import jxlDecWasm from 'asset-url:../../codecs/jxl/dec/jxl_node_dec.wasm'; // WP2 +import type { WP2Module as WP2EncodeModule } from '../../codecs/wp2/enc/wp2_enc'; import wp2Enc from '../../codecs/wp2/enc/wp2_node_enc.js'; import wp2EncWasm from 'asset-url:../../codecs/wp2/enc/wp2_node_enc.wasm'; import wp2Dec from '../../codecs/wp2/dec/wp2_node_dec.js'; @@ -257,15 +268,20 @@ export const preprocessors = { numRotations: 0, }, }, -}; +} as const; export const codecs = { mozjpeg: { name: 'MozJPEG', extension: 'jpg', detectors: [/^\xFF\xD8\xFF/], - dec: () => instantiateEmscriptenWasm(mozDec, mozDecWasm), - enc: () => instantiateEmscriptenWasm(mozEnc, mozEncWasm), + dec: () => + instantiateEmscriptenWasm(mozDec as DecodeModuleFactory, mozDecWasm), + enc: () => + instantiateEmscriptenWasm( + mozEnc as EmscriptenWasm.ModuleFactory, + mozEncWasm, + ), defaultEncoderOptions: { quality: 75, baseline: false, @@ -294,8 +310,13 @@ export const codecs = { name: 'WebP', extension: 'webp', detectors: [/^RIFF....WEBPVP8[LX ]/s], - dec: () => instantiateEmscriptenWasm(webpDec, webpDecWasm), - enc: () => instantiateEmscriptenWasm(webpEnc, webpEncWasm), + dec: () => + instantiateEmscriptenWasm(webpDec as DecodeModuleFactory, webpDecWasm), + enc: () => + instantiateEmscriptenWasm( + webpEnc as EmscriptenWasm.ModuleFactory, + webpEncWasm, + ), defaultEncoderOptions: { quality: 75, target_size: 0, @@ -335,16 +356,20 @@ export const codecs = { name: 'AVIF', extension: 'avif', detectors: [/^\x00\x00\x00 ftypavif\x00\x00\x00\x00/], - dec: () => instantiateEmscriptenWasm(avifDec, avifDecWasm), + dec: () => + instantiateEmscriptenWasm(avifDec as DecodeModuleFactory, avifDecWasm), enc: async () => { if (await threads()) { return instantiateEmscriptenWasm( - avifEncMt, + avifEncMt as EmscriptenWasm.ModuleFactory, avifEncMtWasm, avifEncMtWorker, ); } - return instantiateEmscriptenWasm(avifEnc, avifEncWasm); + return instantiateEmscriptenWasm( + avifEnc as EmscriptenWasm.ModuleFactory, + avifEncWasm, + ); }, defaultEncoderOptions: { cqLevel: 33, @@ -368,8 +393,13 @@ export const codecs = { name: 'JPEG-XL', extension: 'jxl', detectors: [/^\xff\x0a/], - dec: () => instantiateEmscriptenWasm(jxlDec, jxlDecWasm), - enc: () => instantiateEmscriptenWasm(jxlEnc, jxlEncWasm), + dec: () => + instantiateEmscriptenWasm(jxlDec as DecodeModuleFactory, jxlDecWasm), + enc: () => + instantiateEmscriptenWasm( + jxlEnc as EmscriptenWasm.ModuleFactory, + jxlEncWasm, + ), defaultEncoderOptions: { speed: 4, quality: 75, @@ -389,8 +419,13 @@ export const codecs = { name: 'WebP2', extension: 'wp2', detectors: [/^\xF4\xFF\x6F/], - dec: () => instantiateEmscriptenWasm(wp2Dec, wp2DecWasm), - enc: () => instantiateEmscriptenWasm(wp2Enc, wp2EncWasm), + dec: () => + instantiateEmscriptenWasm(wp2Dec as DecodeModuleFactory, wp2DecWasm), + enc: () => + instantiateEmscriptenWasm( + wp2Enc as EmscriptenWasm.ModuleFactory, + wp2EncWasm, + ), defaultEncoderOptions: { quality: 75, alpha_quality: 75, @@ -421,7 +456,7 @@ export const codecs = { await oxipngPromise; return { encode: ( - buffer: Uint8Array, + buffer: Uint8ClampedArray | ArrayBuffer, width: number, height: number, opts: { level: number }, @@ -444,4 +479,4 @@ export const codecs = { max: 1, }, }, -}; +} as const; diff --git a/libsquoosh/src/index.js b/libsquoosh/src/index.ts similarity index 61% rename from libsquoosh/src/index.js rename to libsquoosh/src/index.ts index c627e9c4..48c15091 100644 --- a/libsquoosh/src/index.js +++ b/libsquoosh/src/index.ts @@ -5,10 +5,18 @@ import { promises as fsp } from 'fs'; import { codecs as encoders, preprocessors } from './codecs.js'; import WorkerPool from './worker_pool.js'; import { autoOptimize } from './auto-optimizer.js'; +import type ImageData from './image_data'; export { ImagePool, encoders, preprocessors }; +type EncoderKey = keyof typeof encoders; +type PreprocessorKey = keyof typeof preprocessors; +type FileLike = Buffer | ArrayBuffer | string | ArrayBufferView; -async function decodeFile({ file }) { +async function decodeFile({ + file, +}: { + file: FileLike; +}): Promise<{ bitmap: ImageData; size: number }> { let buffer; if (ArrayBuffer.isView(file)) { buffer = Buffer.from(file.buffer); @@ -16,8 +24,9 @@ async function decodeFile({ file }) { } else if (file instanceof ArrayBuffer) { buffer = Buffer.from(file); file = 'Binary blob'; - } else if (file instanceof Buffer) { - buffer = file; + } else if ((file as unknown) instanceof Buffer) { + // TODO: Check why we need type assertions here. + buffer = (file as unknown) as Buffer; file = 'Binary blob'; } else if (typeof file === 'string') { buffer = await fsp.readFile(file); @@ -28,23 +37,33 @@ async function decodeFile({ file }) { const firstChunkString = Array.from(firstChunk) .map((v) => String.fromCodePoint(v)) .join(''); - const key = Object.entries(encoders).find(([name, { detectors }]) => + const key = Object.entries(encoders).find(([_name, { detectors }]) => detectors.some((detector) => detector.exec(firstChunkString)), - )?.[0]; + )?.[0] as EncoderKey | undefined; if (!key) { throw Error(`${file} has an unsupported format`); } - const rgba = (await encoders[key].dec()).decode(new Uint8Array(buffer)); + const encoder = encoders[key]; + const mod = await encoder.dec(); + const rgba = mod.decode(new Uint8Array(buffer)); return { bitmap: rgba, size: buffer.length, }; } -async function preprocessImage({ preprocessorName, options, image }) { +async function preprocessImage({ + preprocessorName, + options, + image, +}: { + preprocessorName: PreprocessorKey; + options: any; + image: { bitmap: ImageData }; +}) { const preprocessor = await preprocessors[preprocessorName].instantiate(); image.bitmap = await preprocessor( - image.bitmap.data, + Uint8Array.from(image.bitmap.data), image.bitmap.width, image.bitmap.height, options, @@ -58,26 +77,39 @@ async function encodeImage({ encConfig, optimizerButteraugliTarget, maxOptimizerRounds, +}: { + bitmap: ImageData; + encName: EncoderKey; + encConfig: any; + optimizerButteraugliTarget: number; + maxOptimizerRounds: number; }) { - let binary; + let binary: Uint8Array; let optionsUsed = encConfig; const encoder = await encoders[encName].enc(); if (encConfig === 'auto') { const optionToOptimize = encoders[encName].autoOptimize.option; const decoder = await encoders[encName].dec(); - const encode = (bitmapIn, quality) => + const encode = (bitmapIn: ImageData, quality: number) => encoder.encode( bitmapIn.data, bitmapIn.width, bitmapIn.height, - Object.assign({}, encoders[encName].defaultEncoderOptions, { + Object.assign({}, encoders[encName].defaultEncoderOptions as any, { [optionToOptimize]: quality, }), ); - const decode = (binary) => decoder.decode(binary); + const decode = (binary: Uint8Array) => decoder.decode(binary); + const nonNullEncode = (bitmap: ImageData, quality: number): Uint8Array => { + const result = encode(bitmap, quality); + if (!result) { + throw new Error('There was an error while encoding'); + } + return result; + }; const { binary: optimizedBinary, quality } = await autoOptimize( bitmapIn, - encode, + nonNullEncode, decode, { min: encoders[encName].autoOptimize.min, @@ -92,12 +124,18 @@ async function encodeImage({ [optionToOptimize]: Math.round(quality * 10000) / 10000, }; } else { - binary = encoder.encode( + const result = encoder.encode( bitmapIn.data.buffer, bitmapIn.width, bitmapIn.height, encConfig, ); + + if (!result) { + throw new Error('There was an error while encoding'); + } + + binary = result; } return { optionsUsed, @@ -107,10 +145,15 @@ async function encodeImage({ }; } -// both decoding and encoding go through the worker pool -function handleJob(params) { - const { operation } = params; - switch (operation) { +type EncodeParams = { operation: 'encode' } & Parameters[0]; +type DecodeParams = { operation: 'decode' } & Parameters[0]; +type PreprocessParams = { operation: 'preprocess' } & Parameters< + typeof preprocessImage +>[0]; +type JobMessage = EncodeParams | DecodeParams | PreprocessParams; + +function handleJob(params: JobMessage) { + switch (params.operation) { case 'encode': return encodeImage(params); case 'decode': @@ -118,7 +161,7 @@ function handleJob(params) { case 'preprocess': return preprocessImage(params); default: - throw Error(`Invalid job "${operation}"`); + throw Error(`Invalid job "${(params as any).operation}"`); } } @@ -126,7 +169,12 @@ function handleJob(params) { * Represents an ingested image. */ class Image { - constructor(workerPool, file) { + public file: FileLike; + public workerPool: WorkerPool; + public decoded: Promise<{ bitmap: ImageData }>; + public encodedWith: { [key: string]: any }; + + constructor(workerPool: WorkerPool, file: FileLike) { this.file = file; this.workerPool = workerPool; this.decoded = workerPool.dispatchJob({ operation: 'decode', file }); @@ -143,14 +191,15 @@ class Image { if (!Object.keys(preprocessors).includes(name)) { throw Error(`Invalid preprocessor "${name}"`); } + const preprocessorName = name as PreprocessorKey; const preprocessorOptions = Object.assign( {}, - preprocessors[name].defaultOptions, + preprocessors[preprocessorName].defaultOptions, options, ); this.decoded = this.workerPool.dispatchJob({ operation: 'preprocess', - preprocessorName: name, + preprocessorName, image: await this.decoded, options: preprocessorOptions, }); @@ -161,14 +210,22 @@ class Image { /** * Define one or several encoders to use on the image. * @param {object} encodeOptions - An object with encoders to use, and their settings. - * @returns {Promise} - A promise that resolves when the image has been encoded with all the specified encoders. + * @returns {Promise} - A promise that resolves when the image has been encoded with all the specified encoders. */ - async encode(encodeOptions = {}) { + async encode( + encodeOptions: { + optimizerButteraugliTarget?: number; + maxOptimizerRounds?: number; + } & { + [key in EncoderKey]?: any; // any is okay for now + } = {}, + ): Promise { const { bitmap } = await this.decoded; - for (const [encName, options] of Object.entries(encodeOptions)) { - if (!Object.keys(encoders).includes(encName)) { + for (const [name, options] of Object.entries(encodeOptions)) { + if (!Object.keys(encoders).includes(name)) { continue; } + const encName = name as EncoderKey; const encRef = encoders[encName]; const encConfig = typeof options === 'string' @@ -193,28 +250,30 @@ class Image { * A pool where images can be ingested and squooshed. */ class ImagePool { + public workerPool: WorkerPool; + /** * Create a new pool. * @param {number} [threads] - Number of concurrent image processes to run in the pool. Defaults to the number of CPU cores in the system. */ - constructor(threads) { + constructor(threads: number) { this.workerPool = new WorkerPool(threads || cpus().length, __filename); } /** * Ingest an image into the image pool. - * @param {string | Buffer | URL | object} image - The image or path to the image that should be ingested and decoded. + * @param {FileLike} image - The image or path to the image that should be ingested and decoded. * @returns {Image} - A custom class reference to the decoded image. */ - ingestImage(image) { + ingestImage(image: FileLike): Image { return new Image(this.workerPool, image); } /** * Closes the underlying image processing pipeline. The already processed images will still be there, but no new processing can start. - * @returns {Promise} - A promise that resolves when the underlying pipeline has closed. + * @returns {Promise} - A promise that resolves when the underlying pipeline has closed. */ - async close() { + async close(): Promise { await this.workerPool.join(); } } diff --git a/libsquoosh/src/worker_pool.js b/libsquoosh/src/worker_pool.ts similarity index 62% rename from libsquoosh/src/worker_pool.js rename to libsquoosh/src/worker_pool.ts index 646d3870..336d69fc 100644 --- a/libsquoosh/src/worker_pool.js +++ b/libsquoosh/src/worker_pool.ts @@ -7,26 +7,19 @@ function uuid() { ).join(''); } -function jobPromise(worker, msg) { - return new Promise((resolve, reject) => { - const id = uuid(); - worker.postMessage({ msg, id }); - worker.on('message', function f({ error, result, id: rid }) { - if (rid !== id) { - return; - } - if (error) { - reject(error); - return; - } - worker.off('message', f); - resolve(result); - }); - }); +interface Job { + msg: I; + resolve: Function; + reject: Function; } -export default class WorkerPool { - constructor(numWorkers, workerFile) { +export default class WorkerPool { + public numWorkers: number; + public jobQueue: TransformStream, Job>; + public workerQueue: TransformStream; + public done: Promise; + + constructor(numWorkers: number, workerFile: string) { this.numWorkers = numWorkers; this.jobQueue = new TransformStream(); this.workerQueue = new TransformStream(); @@ -48,9 +41,14 @@ export default class WorkerPool { await this._terminateAll(); return; } + + if (!value) { + throw new Error('Reader did not return any value'); + } + const { msg, resolve, reject } = value; const worker = await this._nextWorker(); - jobPromise(worker, msg) + this.jobPromise(worker, msg) .then((result) => resolve(result)) .catch((reason) => reject(reason)) .finally(() => { @@ -66,6 +64,10 @@ export default class WorkerPool { const reader = this.workerQueue.readable.getReader(); const { value } = await reader.read(); reader.releaseLock(); + if (!value) { + throw new Error('No worker left'); + } + return value; } @@ -82,7 +84,7 @@ export default class WorkerPool { await this.done; } - dispatchJob(msg) { + dispatchJob(msg: I): Promise { return new Promise((resolve, reject) => { const writer = this.jobQueue.writable.getWriter(); writer.write({ msg, resolve, reject }); @@ -90,14 +92,32 @@ export default class WorkerPool { }); } - static useThisThreadAsWorker(cb) { - parentPort.on('message', async (data) => { + private jobPromise(worker: Worker, msg: I) { + return new Promise((resolve, reject) => { + const id = uuid(); + worker.postMessage({ msg, id }); + worker.on('message', function f({ error, result, id: rid }) { + if (rid !== id) { + return; + } + if (error) { + reject(error); + return; + } + worker.off('message', f); + resolve(result); + }); + }); + } + + static useThisThreadAsWorker(cb: (msg: I) => O) { + parentPort!.on('message', async (data) => { const { msg, id } = data; try { const result = await cb(msg); - parentPort.postMessage({ result, id }); + parentPort!.postMessage({ result, id }); } catch (e) { - parentPort.postMessage({ error: e.message, id }); + parentPort!.postMessage({ error: e.message, id }); } }); }