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.
- package/README.md +127 -0
- package/index.js +273 -0
- package/lib/codec-paths.js +62 -0
- package/lib/codecs.js +183 -0
- package/lib/defaults.js +130 -0
- package/lib/pool.js +69 -0
- package/lib/wasm.js +90 -0
- package/lib/worker.js +74 -0
- package/package.json +40 -0
- package/vendor/codecs/avif/dec/avif_node_dec.js +16 -0
- package/vendor/codecs/avif/dec/avif_node_dec.wasm +0 -0
- package/vendor/codecs/avif/enc/avif_enc.js +16 -0
- package/vendor/codecs/avif/enc/avif_enc.wasm +0 -0
- package/vendor/codecs/jxl/dec/jxl_node_dec.js +16 -0
- package/vendor/codecs/jxl/dec/jxl_node_dec.wasm +0 -0
- package/vendor/codecs/jxl/enc/jxl_node_enc.js +16 -0
- package/vendor/codecs/jxl/enc/jxl_node_enc.wasm +0 -0
- package/vendor/codecs/mozjpeg/dec/mozjpeg_node_dec.js +16 -0
- package/vendor/codecs/mozjpeg/dec/mozjpeg_node_dec.wasm +0 -0
- package/vendor/codecs/mozjpeg/enc/mozjpeg_node_enc.js +16 -0
- package/vendor/codecs/mozjpeg/enc/mozjpeg_node_enc.wasm +0 -0
- package/vendor/codecs/oxipng/pkg/squoosh_oxipng.js +120 -0
- package/vendor/codecs/oxipng/pkg/squoosh_oxipng_bg.wasm +0 -0
- package/vendor/codecs/png/pkg/squoosh_png.js +175 -0
- package/vendor/codecs/png/pkg/squoosh_png_bg.wasm +0 -0
- package/vendor/codecs/qoi/dec/qoi_dec.js +16 -0
- package/vendor/codecs/qoi/dec/qoi_dec.wasm +0 -0
- package/vendor/codecs/qoi/enc/qoi_enc.js +16 -0
- package/vendor/codecs/qoi/enc/qoi_enc.wasm +0 -0
- package/vendor/codecs/resize/pkg/squoosh_resize.js +120 -0
- package/vendor/codecs/resize/pkg/squoosh_resize_bg.wasm +0 -0
- package/vendor/codecs/webp/dec/webp_node_dec.js +16 -0
- package/vendor/codecs/webp/dec/webp_node_dec.wasm +0 -0
- package/vendor/codecs/webp/enc/webp_node_enc.js +16 -0
- package/vendor/codecs/webp/enc/webp_node_enc.wasm +0 -0
- package/vendor/codecs/wp2/dec/wp2_node_dec.js +16 -0
- package/vendor/codecs/wp2/dec/wp2_node_dec.wasm +0 -0
- package/vendor/codecs/wp2/enc/wp2_node_enc.js +16 -0
- 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
|
+
}
|