squoosh-batch 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +127 -0
  2. package/index.js +273 -0
  3. package/lib/codec-paths.js +62 -0
  4. package/lib/codecs.js +183 -0
  5. package/lib/defaults.js +130 -0
  6. package/lib/pool.js +69 -0
  7. package/lib/wasm.js +90 -0
  8. package/lib/worker.js +74 -0
  9. package/package.json +40 -0
  10. package/vendor/codecs/avif/dec/avif_node_dec.js +16 -0
  11. package/vendor/codecs/avif/dec/avif_node_dec.wasm +0 -0
  12. package/vendor/codecs/avif/enc/avif_enc.js +16 -0
  13. package/vendor/codecs/avif/enc/avif_enc.wasm +0 -0
  14. package/vendor/codecs/jxl/dec/jxl_node_dec.js +16 -0
  15. package/vendor/codecs/jxl/dec/jxl_node_dec.wasm +0 -0
  16. package/vendor/codecs/jxl/enc/jxl_node_enc.js +16 -0
  17. package/vendor/codecs/jxl/enc/jxl_node_enc.wasm +0 -0
  18. package/vendor/codecs/mozjpeg/dec/mozjpeg_node_dec.js +16 -0
  19. package/vendor/codecs/mozjpeg/dec/mozjpeg_node_dec.wasm +0 -0
  20. package/vendor/codecs/mozjpeg/enc/mozjpeg_node_enc.js +16 -0
  21. package/vendor/codecs/mozjpeg/enc/mozjpeg_node_enc.wasm +0 -0
  22. package/vendor/codecs/oxipng/pkg/squoosh_oxipng.js +120 -0
  23. package/vendor/codecs/oxipng/pkg/squoosh_oxipng_bg.wasm +0 -0
  24. package/vendor/codecs/png/pkg/squoosh_png.js +175 -0
  25. package/vendor/codecs/png/pkg/squoosh_png_bg.wasm +0 -0
  26. package/vendor/codecs/qoi/dec/qoi_dec.js +16 -0
  27. package/vendor/codecs/qoi/dec/qoi_dec.wasm +0 -0
  28. package/vendor/codecs/qoi/enc/qoi_enc.js +16 -0
  29. package/vendor/codecs/qoi/enc/qoi_enc.wasm +0 -0
  30. package/vendor/codecs/resize/pkg/squoosh_resize.js +120 -0
  31. package/vendor/codecs/resize/pkg/squoosh_resize_bg.wasm +0 -0
  32. package/vendor/codecs/webp/dec/webp_node_dec.js +16 -0
  33. package/vendor/codecs/webp/dec/webp_node_dec.wasm +0 -0
  34. package/vendor/codecs/webp/enc/webp_node_enc.js +16 -0
  35. package/vendor/codecs/webp/enc/webp_node_enc.wasm +0 -0
  36. package/vendor/codecs/wp2/dec/wp2_node_dec.js +16 -0
  37. package/vendor/codecs/wp2/dec/wp2_node_dec.wasm +0 -0
  38. package/vendor/codecs/wp2/enc/wp2_node_enc.js +16 -0
  39. package/vendor/codecs/wp2/enc/wp2_node_enc.wasm +0 -0
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # Squoosh batch CLI
2
+
3
+ Compress a folder of images without opening the web app. It runs the same
4
+ WebAssembly codecs as batch mode, with the same default settings, so the same
5
+ image and options give you the same file.
6
+
7
+ ```sh
8
+ npm install -g squoosh-batch
9
+ squoosh-batch photos/
10
+ ```
11
+
12
+ No dependencies — the codecs are WebAssembly and ship inside the package.
13
+ Node 18.3 or newer.
14
+
15
+ From a checkout of this repo you can also run it without installing:
16
+
17
+ ```sh
18
+ node cli/index.js photos/
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```
24
+ squoosh-batch [options] <files or directories...>
25
+
26
+ -f, --format <name> Output format, repeatable or comma-separated.
27
+ mozJPEG, webP, avif, jxl, oxiPNG, wp2, qoi
28
+ (default: mozJPEG)
29
+ -o, --out-dir <dir> Where to write results (default: ./squooshed)
30
+ -q, --quality <n> Quality for lossy formats, overriding the default
31
+ --max-width <n> Shrink images to fit this width
32
+ --max-height <n> Shrink images to fit this height
33
+ -c, --concurrency <n> Images to work on at once (default: CPUs - 1, max 4)
34
+ --overwrite Replace existing output files
35
+ -h, --help Show this
36
+ ```
37
+
38
+ Examples:
39
+
40
+ ```sh
41
+ # Everything in a folder, to JPEG
42
+ node cli/index.js photos/
43
+
44
+ # Two formats at once, at quality 60
45
+ node cli/index.js -f webP -f avif -q 60 -o out photos/*.jpg
46
+
47
+ # Shrink oversized images on the way through
48
+ node cli/index.js -f webP --max-width 2000 hero.png
49
+ ```
50
+
51
+ Directories are scanned one level deep for images. Formats are named exactly as
52
+ the web app names them, and matched case-insensitively, so `-f webp` and
53
+ `-f webP` both work. With more than one format, results go into a folder per
54
+ format. Existing files are left alone unless you pass `--overwrite`.
55
+
56
+ Images are decoded and encoded across a pool of worker threads, one image per
57
+ worker, which is the same arrangement batch mode uses in the browser.
58
+
59
+ ## Does it match the web app?
60
+
61
+ For most formats, exactly — same bytes, same checksum. Verified by compressing
62
+ the same images both ways and comparing SHA-256:
63
+
64
+ | Format | Matches the web app | |
65
+ | ------- | ------------------- | ------------------------------------------- |
66
+ | MozJPEG | Yes | byte-identical |
67
+ | WebP | Yes | byte-identical |
68
+ | OxiPNG | Yes | byte-identical |
69
+ | WebP v2 | Yes | byte-identical |
70
+ | QOI | Yes | byte-identical |
71
+ | AVIF | No | a few tenths of a percent different in size |
72
+ | JPEG XL | No | a few tenths of a percent different in size |
73
+
74
+ AVIF and JPEG XL differ because the browser runs **multi-threaded** builds of
75
+ those two codecs, and only single-threaded builds can load under Node. The
76
+ number of threads changes how those encoders divide the work, which changes the
77
+ bytes they emit. Quality is equivalent; the files just aren't identical.
78
+
79
+ Two formats are missing here: **Browser JPEG** and **Browser PNG**. Those encode
80
+ through a `<canvas>`, which only exists in a browser. Use MozJPEG and OxiPNG,
81
+ which are better anyway.
82
+
83
+ ### Reading images
84
+
85
+ The web app asks the browser to decode JPEG and PNG, because browsers already
86
+ know how. Without a browser this uses the same wasm decoders Squoosh ships for
87
+ its other formats. The two agree byte-for-byte on JPEG and on PNGs without
88
+ transparency.
89
+
90
+ PNGs with **semi-transparent** pixels are the one exception. A browser stores
91
+ transparent images with premultiplied alpha and loses a little precision
92
+ converting back, so a pixel the wasm decoder reads as `[136,128,119]` comes back
93
+ from a canvas as `[136,127,119]`. That difference is invisible, but it's enough
94
+ to change the compressed bytes. The CLI's reading is the more accurate of the
95
+ two.
96
+
97
+ ## Publishing a release
98
+
99
+ The package is `cli/`, and it carries its own copy of the codecs so an
100
+ installed copy doesn't need the rest of the repo. `prepack` copies them in, so
101
+ `npm pack` and `npm publish` both pick them up — there's no separate build step
102
+ to remember.
103
+
104
+ ```sh
105
+ cd cli
106
+ npm version patch # or minor / major
107
+ npm publish
108
+ ```
109
+
110
+ The first publish needs `npm login` and, since the name is unscoped, an account
111
+ that can claim `squoosh-batch`. Check what you're about to ship first:
112
+
113
+ ```sh
114
+ npm pack --dry-run
115
+ ```
116
+
117
+ That should list `index.js`, `lib/`, `README.md` and about 30 files under
118
+ `vendor/codecs` — roughly 3 MB compressed.
119
+
120
+ `cli/vendor` is generated and git-ignored; don't commit it.
121
+
122
+ ## Keeping the defaults in step
123
+
124
+ `lib/defaults.js` copies the default options out of the web app's encoder
125
+ metadata (`src/features/encoders/*/shared/meta.ts`), and each block names the
126
+ file it came from. If you change a default in the web app, change it here too,
127
+ or the two will quietly stop producing the same files.
package/index.js ADDED
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Squoosh batch compression from the command line.
4
+ *
5
+ * Runs the same wasm codecs as the web app's batch mode, with the same default
6
+ * options, so the same image and settings give you the same file.
7
+ */
8
+ import { readFile, writeFile, mkdir, stat, readdir } from 'fs/promises';
9
+ import { basename, extname, join, resolve } from 'path';
10
+ import { parseArgs } from 'util';
11
+
12
+ import { encoders } from './lib/codecs.js';
13
+ import { WorkerPool, defaultConcurrency } from './lib/pool.js';
14
+
15
+ const formatNames = Object.keys(encoders);
16
+
17
+ const usage = `
18
+ Usage: squoosh-batch [options] <files or directories...>
19
+
20
+ Options:
21
+ -f, --format <name> Output format, repeatable or comma-separated.
22
+ ${formatNames.join(', ')}
23
+ (default: mozJPEG)
24
+ -o, --out-dir <dir> Where to write results (default: ./squooshed)
25
+ -q, --quality <n> Quality for lossy formats, overriding the default
26
+ --max-width <n> Shrink images to fit this width
27
+ --max-height <n> Shrink images to fit this height
28
+ -c, --concurrency <n> Images to work on at once (default: CPUs - 1, max 4)
29
+ --overwrite Replace existing output files
30
+ -h, --help Show this
31
+
32
+ Examples:
33
+ squoosh-batch photos/
34
+ squoosh-batch -f webP -f avif -q 60 -o out photos/*.jpg
35
+ squoosh-batch -f webP --max-width 2000 hero.png
36
+ `.trim();
37
+
38
+ const imageExtensions = new Set([
39
+ '.jpg',
40
+ '.jpeg',
41
+ '.png',
42
+ '.webp',
43
+ '.avif',
44
+ '.jxl',
45
+ '.wp2',
46
+ '.qoi',
47
+ '.gif',
48
+ '.bmp',
49
+ ]);
50
+
51
+ /** Expand directories into the image files inside them. */
52
+ async function collectFiles(inputs) {
53
+ const files = [];
54
+
55
+ for (const input of inputs) {
56
+ const path = resolve(input);
57
+ let info;
58
+
59
+ try {
60
+ info = await stat(path);
61
+ } catch {
62
+ throw Error(`No such file or directory: ${input}`);
63
+ }
64
+
65
+ if (info.isDirectory()) {
66
+ const entries = await readdir(path, { withFileTypes: true });
67
+ for (const entry of entries) {
68
+ if (!entry.isFile()) continue;
69
+ if (!imageExtensions.has(extname(entry.name).toLowerCase())) continue;
70
+ files.push(join(path, entry.name));
71
+ }
72
+ continue;
73
+ }
74
+
75
+ files.push(path);
76
+ }
77
+
78
+ return files;
79
+ }
80
+
81
+ function parseFormats(values) {
82
+ const names = (values ?? ['mozJPEG'])
83
+ .flatMap((value) => value.split(','))
84
+ .map((value) => value.trim())
85
+ .filter(Boolean);
86
+
87
+ // Accept any capitalisation, since "mozjpeg" is the obvious thing to type.
88
+ return [...new Set(names)].map((name) => {
89
+ const match = formatNames.find(
90
+ (known) => known.toLowerCase() === name.toLowerCase(),
91
+ );
92
+ if (!match) {
93
+ throw Error(
94
+ `Unknown format "${name}". Available: ${formatNames.join(', ')}`,
95
+ );
96
+ }
97
+ return match;
98
+ });
99
+ }
100
+
101
+ function formatSize(bytes) {
102
+ if (bytes < 1000) return `${bytes} B`;
103
+ if (bytes < 1000 * 1000) return `${(bytes / 1000).toFixed(1)} kB`;
104
+ return `${(bytes / 1000 / 1000).toFixed(2)} MB`;
105
+ }
106
+
107
+ function formatDelta(from, to) {
108
+ if (from === 0) return '';
109
+ const change = Math.round(((to - from) / from) * 100);
110
+ return `${change > 0 ? '+' : ''}${change}%`;
111
+ }
112
+
113
+ async function main() {
114
+ let args;
115
+
116
+ try {
117
+ args = parseArgs({
118
+ allowPositionals: true,
119
+ options: {
120
+ format: { type: 'string', short: 'f', multiple: true },
121
+ 'out-dir': { type: 'string', short: 'o' },
122
+ quality: { type: 'string', short: 'q' },
123
+ 'max-width': { type: 'string' },
124
+ 'max-height': { type: 'string' },
125
+ concurrency: { type: 'string', short: 'c' },
126
+ overwrite: { type: 'boolean' },
127
+ help: { type: 'boolean', short: 'h' },
128
+ },
129
+ });
130
+ } catch (err) {
131
+ console.error(`${err.message}\n\n${usage}`);
132
+ process.exit(1);
133
+ }
134
+
135
+ if (args.values.help || args.positionals.length === 0) {
136
+ console.log(usage);
137
+ process.exit(args.values.help ? 0 : 1);
138
+ }
139
+
140
+ const number = (value, name) => {
141
+ if (value === undefined) return undefined;
142
+ const parsed = Number(value);
143
+ if (!Number.isFinite(parsed) || parsed <= 0) {
144
+ throw Error(`--${name} must be a positive number`);
145
+ }
146
+ return parsed;
147
+ };
148
+
149
+ const formats = parseFormats(args.values.format);
150
+ const quality = number(args.values.quality, 'quality');
151
+ const maxWidth = number(args.values['max-width'], 'max-width');
152
+ const maxHeight = number(args.values['max-height'], 'max-height');
153
+ const outDir = resolve(args.values['out-dir'] ?? 'squooshed');
154
+ const files = await collectFiles(args.positionals);
155
+
156
+ if (files.length === 0) throw Error('No images found');
157
+
158
+ await mkdir(outDir, { recursive: true });
159
+ for (const format of formats) {
160
+ if (formats.length > 1) {
161
+ await mkdir(join(outDir, encoders[format].extension), {
162
+ recursive: true,
163
+ });
164
+ }
165
+ }
166
+
167
+ const concurrency = Math.min(
168
+ number(args.values.concurrency, 'concurrency') ?? defaultConcurrency(),
169
+ files.length,
170
+ );
171
+
172
+ console.log(
173
+ `Compressing ${files.length} image${files.length === 1 ? '' : 's'} ` +
174
+ `to ${formats.map((f) => encoders[f].label).join(', ')}` +
175
+ `${concurrency > 1 ? ` (${concurrency} at a time)` : ''}\n`,
176
+ );
177
+
178
+ let sourceTotal = 0;
179
+ const outputTotals = new Map(formats.map((format) => [format, 0]));
180
+ let failures = 0;
181
+ let failedFiles = 0;
182
+ let done = 0;
183
+
184
+ const pool = new WorkerPool(concurrency);
185
+
186
+ // Hand every image to the pool at once and let it meter them out. Results
187
+ // print as they land, so one slow image doesn't hold up the rest.
188
+ const tasks = files.map(async (path) => {
189
+ const name = basename(path);
190
+
191
+ let result;
192
+ try {
193
+ result = await pool.run({ path, formats, quality, maxWidth, maxHeight });
194
+ } catch (err) {
195
+ failures++;
196
+ failedFiles++;
197
+ done++;
198
+ console.log(
199
+ `[${done}/${files.length}] ${name}\n error ${err.message}`,
200
+ );
201
+ return;
202
+ }
203
+
204
+ done++;
205
+ const counter = `[${done}/${files.length}]`;
206
+
207
+ sourceTotal += result.sourceSize;
208
+ console.log(
209
+ `${counter} ${name} ${formatSize(result.sourceSize)} · ` +
210
+ `${result.width}×${result.height}`,
211
+ );
212
+
213
+ for (const { format, data, error } of result.results) {
214
+ const { extension, label } = encoders[format];
215
+
216
+ if (error) {
217
+ failures++;
218
+ console.log(` ${label.padEnd(20)} error ${error}`);
219
+ continue;
220
+ }
221
+
222
+ const outName = name.replace(/\.[^.]*$/, '') + '.' + extension;
223
+ const outPath = join(
224
+ outDir,
225
+ formats.length > 1 ? extension : '',
226
+ outName,
227
+ );
228
+
229
+ if (!args.values.overwrite) {
230
+ try {
231
+ await stat(outPath);
232
+ console.log(` ${label.padEnd(20)} skipped, file exists`);
233
+ continue;
234
+ } catch {
235
+ // Doesn't exist, which is what we want.
236
+ }
237
+ }
238
+
239
+ await writeFile(outPath, data);
240
+ outputTotals.set(format, outputTotals.get(format) + data.length);
241
+ console.log(
242
+ ` ${label.padEnd(20)} ${formatSize(data.length).padStart(9)} ` +
243
+ `${formatDelta(result.sourceSize, data.length)}`,
244
+ );
245
+ }
246
+ });
247
+
248
+ await Promise.all(tasks);
249
+ await pool.close();
250
+
251
+ const compressed = files.length - failedFiles;
252
+ console.log(
253
+ `\n${compressed} image${compressed === 1 ? '' : 's'} · ` +
254
+ `${formatSize(sourceTotal)} original` +
255
+ (failedFiles > 0 ? ` (${failedFiles} failed)` : ''),
256
+ );
257
+ for (const format of formats) {
258
+ const total = outputTotals.get(format);
259
+ console.log(
260
+ ` ${encoders[format].label.padEnd(20)} ${formatSize(total).padStart(
261
+ 9,
262
+ )} ` + `${formatDelta(sourceTotal, total)}`,
263
+ );
264
+ }
265
+ console.log(`\nWritten to ${outDir}`);
266
+
267
+ if (failures > 0) process.exitCode = 1;
268
+ }
269
+
270
+ main().catch((err) => {
271
+ console.error(`\n${err.message}`);
272
+ process.exit(1);
273
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Where each codec lives, and how it's built.
3
+ *
4
+ * This is the single list of what the CLI needs from `codecs/`. The publish
5
+ * step reads it to copy exactly those files into the package, so adding a
6
+ * codec here is all it takes for a release to carry it.
7
+ *
8
+ * `kind` picks the loader: `emscripten` builds sit next to a `.wasm` of the
9
+ * same name, `bindgen` ones name their wasm separately.
10
+ */
11
+ export const decoders = {
12
+ 'image/jpeg': { kind: 'emscripten', path: 'mozjpeg/dec/mozjpeg_node_dec.js' },
13
+ 'image/png': {
14
+ kind: 'bindgen',
15
+ path: 'png/pkg/squoosh_png.js',
16
+ wasm: 'squoosh_png_bg.wasm',
17
+ },
18
+ 'image/webp': { kind: 'emscripten', path: 'webp/dec/webp_node_dec.js' },
19
+ 'image/avif': { kind: 'emscripten', path: 'avif/dec/avif_node_dec.js' },
20
+ 'image/jxl': { kind: 'emscripten', path: 'jxl/dec/jxl_node_dec.js' },
21
+ 'image/webp2': { kind: 'emscripten', path: 'wp2/dec/wp2_node_dec.js' },
22
+ 'image/qoi': { kind: 'emscripten', path: 'qoi/dec/qoi_dec.js' },
23
+ };
24
+
25
+ export const encoderBuilds = {
26
+ mozJPEG: { kind: 'emscripten', path: 'mozjpeg/enc/mozjpeg_node_enc.js' },
27
+ // The browser prefers webp_enc_simd; there's no SIMD build for Node, and
28
+ // both produce byte-identical output.
29
+ webP: { kind: 'emscripten', path: 'webp/enc/webp_node_enc.js' },
30
+ // The Node builds are a newer revision taking different options, so this
31
+ // uses the browser's own single-threaded build. The browser reaches for
32
+ // avif_enc_mt when threads are available, which differs slightly.
33
+ avif: { kind: 'emscripten', path: 'avif/enc/avif_enc.js' },
34
+ // The browser prefers jxl_enc_mt_simd; Node only has the plain build.
35
+ jxl: { kind: 'emscripten', path: 'jxl/enc/jxl_node_enc.js' },
36
+ // The browser uses the parallel build; optimisation is deterministic, so
37
+ // the single-threaded one produces the same file.
38
+ oxiPNG: {
39
+ kind: 'bindgen',
40
+ path: 'oxipng/pkg/squoosh_oxipng.js',
41
+ wasm: 'squoosh_oxipng_bg.wasm',
42
+ },
43
+ // The browser prefers wp2_enc_mt_simd; Node only has the plain build.
44
+ wp2: { kind: 'emscripten', path: 'wp2/enc/wp2_node_enc.js' },
45
+ // Only a browser build exists, and it runs here unchanged.
46
+ qoi: { kind: 'emscripten', path: 'qoi/enc/qoi_enc.js' },
47
+ };
48
+
49
+ export const resizeBuild = {
50
+ kind: 'bindgen',
51
+ path: 'resize/pkg/squoosh_resize.js',
52
+ wasm: 'squoosh_resize_bg.wasm',
53
+ };
54
+
55
+ /** Every build the CLI can reach for. */
56
+ export function allBuilds() {
57
+ return [
58
+ ...Object.values(decoders),
59
+ ...Object.values(encoderBuilds),
60
+ resizeBuild,
61
+ ];
62
+ }
package/lib/codecs.js ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Decoders, encoders and resizing, mirroring what the web app's workers do so
3
+ * the same input and options produce the same bytes.
4
+ *
5
+ * Each entry points at the same wasm the browser runs. Where a codec ships
6
+ * several builds (SIMD, multi-threaded), the note on that entry says which one
7
+ * the browser picks and which one is used here.
8
+ */
9
+ import { existsSync } from 'fs';
10
+ import { fileURLToPath } from 'url';
11
+ import { dirname, join } from 'path';
12
+ import { loadBindgen, loadEmscripten, ImageData } from './wasm.js';
13
+ import {
14
+ decoders as decoderBuilds,
15
+ encoderBuilds,
16
+ resizeBuild,
17
+ } from './codec-paths.js';
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+
21
+ /**
22
+ * Published packages carry their own copy of the codecs; a repo checkout uses
23
+ * the ones already there.
24
+ */
25
+ const vendored = join(here, '..', 'vendor', 'codecs');
26
+ const codecRoot = existsSync(vendored)
27
+ ? vendored
28
+ : join(here, '..', '..', 'codecs');
29
+
30
+ /** Load a build described in codec-paths. */
31
+ function load(build) {
32
+ const path = join(codecRoot, build.path);
33
+ return build.kind === 'bindgen'
34
+ ? loadBindgen(path, build.wasm)
35
+ : loadEmscripten(path);
36
+ }
37
+
38
+ /** Mirrors sniffMimeType in the web app's client utils. */
39
+ const magicNumbers = [
40
+ [/^\x89PNG\x0D\x0A\x1A\x0A/, 'image/png'],
41
+ [/^\xFF\xD8\xFF/, 'image/jpeg'],
42
+ [/^RIFF....WEBPVP8[LX ]/s, 'image/webp'],
43
+ [/^\x00\x00\x00 ftypavif\x00\x00\x00\x00/, 'image/avif'],
44
+ [/^\xff\x0a/, 'image/jxl'],
45
+ [/^\x00\x00\x00\x0cJXL \x0d\x0a\x87\x0a/, 'image/jxl'],
46
+ [/^\xF4\xFF\x6F/, 'image/webp2'],
47
+ [/^qoif/, 'image/qoi'],
48
+ [/^GIF87a/, 'image/gif'],
49
+ [/^GIF89a/, 'image/gif'],
50
+ [/^BM/, 'image/bmp'],
51
+ ];
52
+
53
+ export function sniffMimeType(buffer) {
54
+ const head = Array.from(buffer.subarray(0, 16))
55
+ .map((v) => String.fromCodePoint(v))
56
+ .join('');
57
+
58
+ for (const [pattern, mimeType] of magicNumbers) {
59
+ if (pattern.test(head)) return mimeType;
60
+ }
61
+ return '';
62
+ }
63
+
64
+ const decoders = Object.fromEntries(
65
+ Object.entries(decoderBuilds).map(([mimeType, build]) => [
66
+ mimeType,
67
+ async (data) => (await load(build)).decode(data),
68
+ ]),
69
+ );
70
+
71
+ export function canDecode(mimeType) {
72
+ return mimeType in decoders;
73
+ }
74
+
75
+ export async function decode(data) {
76
+ const mimeType = sniffMimeType(data);
77
+ const decoder = decoders[mimeType];
78
+
79
+ if (!decoder) {
80
+ throw Error(
81
+ mimeType
82
+ ? `Can't decode ${mimeType}`
83
+ : "Doesn't look like an image this tool can read",
84
+ );
85
+ }
86
+
87
+ const result = await decoder(data);
88
+ if (!result) throw Error(`Couldn't decode ${mimeType}`);
89
+ return result;
90
+ }
91
+
92
+ /**
93
+ * Encoders, keyed by the same names the web app uses, so `--format webP`
94
+ * refers to exactly the entry the UI calls "WebP".
95
+ *
96
+ * browserJPEG and browserPNG are deliberately absent: they encode through a
97
+ * canvas, which only exists in a browser.
98
+ */
99
+ const encoderMeta = {
100
+ mozJPEG: { label: 'MozJPEG', extension: 'jpg' },
101
+ webP: { label: 'WebP', extension: 'webp' },
102
+ avif: { label: 'AVIF', extension: 'avif' },
103
+ jxl: { label: 'JPEG XL (beta)', extension: 'jxl' },
104
+ oxiPNG: { label: 'OxiPNG', extension: 'png' },
105
+ wp2: { label: 'WebP v2 (unstable)', extension: 'wp2' },
106
+ qoi: { label: 'QOI', extension: 'qoi' },
107
+ };
108
+
109
+ /**
110
+ * Encoders, keyed by the same names the web app uses, so `--format webP`
111
+ * refers to exactly the entry the UI calls "WebP".
112
+ *
113
+ * browserJPEG and browserPNG are deliberately absent: they encode through a
114
+ * canvas, which only exists in a browser.
115
+ */
116
+ export const encoders = Object.fromEntries(
117
+ Object.entries(encoderMeta).map(([name, meta]) => [
118
+ name,
119
+ {
120
+ ...meta,
121
+ async encode(image, options) {
122
+ const module = await load(encoderBuilds[name]);
123
+
124
+ if (name === 'oxiPNG') {
125
+ return module.optimise(
126
+ image.data,
127
+ image.width,
128
+ image.height,
129
+ options.level,
130
+ options.interlace,
131
+ );
132
+ }
133
+
134
+ const result = module.encode(
135
+ image.data,
136
+ image.width,
137
+ image.height,
138
+ options,
139
+ );
140
+ if (!result) throw Error('Encoding error');
141
+ return result;
142
+ },
143
+ },
144
+ ]),
145
+ );
146
+
147
+ /** Resize methods by index, as the resize worker orders them. */
148
+ const resizeMethods = ['triangle', 'catrom', 'mitchell', 'lanczos3'];
149
+
150
+ /**
151
+ * Scale an image, matching what batch mode asks the resize worker for:
152
+ * lanczos3, stretch, premultiplied, in linear RGB.
153
+ */
154
+ export async function resize(image, width, height) {
155
+ const module = await load(resizeBuild);
156
+
157
+ const result = module.resize(
158
+ new Uint8Array(
159
+ image.data.buffer,
160
+ image.data.byteOffset,
161
+ image.data.byteLength,
162
+ ),
163
+ image.width,
164
+ image.height,
165
+ width,
166
+ height,
167
+ resizeMethods.indexOf('lanczos3'),
168
+ true, // premultiply
169
+ true, // linearRGB
170
+ );
171
+
172
+ return new ImageData(new Uint8ClampedArray(result.buffer), width, height);
173
+ }
174
+
175
+ /** Scale down to fit a box, preserving aspect ratio. Mirrors fitWithin. */
176
+ export function fitWithin(width, height, maxWidth, maxHeight) {
177
+ const scale = Math.min(maxWidth / width, maxHeight / height);
178
+ if (scale >= 1) return undefined;
179
+ return {
180
+ width: Math.max(1, Math.round(width * scale)),
181
+ height: Math.max(1, Math.round(height * scale)),
182
+ };
183
+ }