optiq-image 0.1.0-beta.0 → 0.2.0-beta.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/CHANGELOG.md +9 -0
- package/README.md +21 -1
- package/dist/batch-worker.d.ts +6 -0
- package/dist/batch-worker.js +17 -0
- package/dist/batch.d.ts +16 -0
- package/dist/batch.js +51 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +176 -0
- package/dist/options.js +5 -2
- package/dist/plan.js +3 -1
- package/dist/react.d.ts +26 -0
- package/dist/react.js +35 -0
- package/dist/types.d.ts +2 -1
- package/dist/vite.d.ts +17 -0
- package/dist/vite.js +69 -0
- package/package.json +32 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0-beta.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
- `mode: "fast"`: SSIM ≥ 0.975 like `balanced`, no AVIF, no oxipng, ≤ 5 encodes. Benchmark: −54.9% total
|
|
6
|
+
(balanced −61.2%), median 405 ms in Chromium (balanced 1.3 s), median peak RSS 184 MB (balanced 304 MB).
|
|
7
|
+
- `optiq-image` CLI (`npx optiq-image <files|folders>`): dry run by default, `--out`, `--in-place`, parallel workers.
|
|
8
|
+
- `optiq-image/vite`: Vite plugin that optimises build output in place (same names and formats).
|
|
9
|
+
- `optiq-image/react`: `useOptiq(file, options)` hook.
|
|
10
|
+
- `optimizeFiles()` batch API for Node (worker_threads pool).
|
|
11
|
+
|
|
3
12
|
## 0.1.0-beta.0 (unreleased)
|
|
4
13
|
|
|
5
14
|
Audit fixes (see docs/audit.md):
|
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ For explicit control over workers use `createCompressor({ maxWorkers, workerFact
|
|
|
94
94
|
|
|
95
95
|
| Option | Default | Meaning |
|
|
96
96
|
|---|---|---|
|
|
97
|
-
| `mode` | `"balanced"` | `lossless`, `visually-lossless`, `balanced` |
|
|
97
|
+
| `mode` | `"balanced"` | `lossless`, `visually-lossless`, `balanced`, `fast` (no AVIF, ≤ 5 encodes; ~3× faster) |
|
|
98
98
|
| `minQuality` | 0.99 / 0.975 | SSIM threshold (ignored in lossless). Gradients use a stricter value half-way to 1 |
|
|
99
99
|
| `maxOutputBytes` | – | Upper bound. If impossible without violating quality, the result lists it in `unmetConstraints` |
|
|
100
100
|
| `maxWidthOrHeight` | – | Downscale longest side. In `lossless` mode only with `allowLossyResize: true` |
|
|
@@ -115,6 +115,24 @@ Error codes: `INVALID_INPUT`, `UNSUPPORTED_FORMAT`, `MALFORMED_INPUT`, `INPUT_TO
|
|
|
115
115
|
`INCOMPATIBLE_OPTIONS` (e.g. JPEG requested for a transparent image), `CONSTRAINTS_UNSATISFIABLE`,
|
|
116
116
|
`CODEC_UNAVAILABLE`, `UNSUPPORTED_ENVIRONMENT`, `WORKER_ERROR`, `ABORTED` (`name === "AbortError"`).
|
|
117
117
|
|
|
118
|
+
## React, CLI, Vite plugin
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
import { useOptiq } from "optiq-image/react";
|
|
122
|
+
const { status, result, progress, cancel } = useOptiq(file, { mode: "fast" });
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
[View example](https://github.com/Baukaalm/optiq/blob/main/docs/examples/PhotoUploadForm.tsx) (upload form with progress, cancel and S3) · [React hook docs](https://baukaalm.github.io/optiq/guide/react)
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
npx optiq-image ./photos --out ./photos-optimized # static images, in parallel worker threads
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { optiq } from "optiq-image/vite"; // optimises build output in place
|
|
133
|
+
export default defineConfig({ plugins: [optiq()] });
|
|
134
|
+
```
|
|
135
|
+
|
|
118
136
|
## Uploading to S3
|
|
119
137
|
|
|
120
138
|
```ts
|
|
@@ -179,6 +197,7 @@ input. Full tables, per-image rows and the optimisation log: [`bench/results/RES
|
|
|
179
197
|
| Tool | Total output | Reduction | Meets SSIM ≥ 0.975 | Median time | Median peak RSS |
|
|
180
198
|
|---|---|---|---|---|---|
|
|
181
199
|
| Optiq `balanced` | 13.25 → 5.15 MB | 61.1% | 24/24 | 1.8 s | 301 MB |
|
|
200
|
+
| Optiq `fast` (no AVIF, ≤ 5 encodes) | 13.25 → 5.97 MB | 54.9% | 24/24 | 521 ms | 184 MB |
|
|
182
201
|
| Optiq `balanced`, `preserveMetadata: false` | 13.25 → 4.73 MB | 64.3% | 24/24 | 1.5 s | 297 MB |
|
|
183
202
|
| Optiq `visually-lossless` | 13.25 → 6.38 MB | 51.8% | 24/24 (≥ 0.99: 24/24) | 2.3 s | 325 MB |
|
|
184
203
|
| libvips WebP q80 | 13.25 → 6.50 MB | 51.0% | 17/24 | 59 ms | 105 MB |
|
|
@@ -190,6 +209,7 @@ input. Full tables, per-image rows and the optimisation log: [`bench/results/RES
|
|
|
190
209
|
| Tool | Total output | Reduction | Meets SSIM ≥ 0.975 | Median time |
|
|
191
210
|
|---|---|---|---|---|
|
|
192
211
|
| Optiq `balanced` | 13.25 → 5.15 MB | 61.1% | 24/24 | 1.4 s |
|
|
212
|
+
| Optiq `fast` | 13.25 → 5.97 MB | 54.9% | 24/24 | 405 ms |
|
|
193
213
|
| browser-image-compression `initialQuality: 0.8` | 13.25 → 10.21 MB | 22.9% | 19/24 | 78 ms |
|
|
194
214
|
| browser-image-compression `fileType: webp, 0.8` | 13.25 → 6.52 MB | 50.8% | 18/24 | 37 ms |
|
|
195
215
|
| browser-image-compression `maxSizeMB: 1` | 13.25 → 7.14 MB | 46.1% | 21/24 (3 large photos were downscaled, not scored) | 128 ms |
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// worker_threads entry for batch optimisation in Node (CLI, Vite plugin). One image at a time per thread:
|
|
2
|
+
// the WASM codecs are single-threaded, so parallelism comes from running several of these.
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { parentPort } from "node:worker_threads";
|
|
5
|
+
import { serializeError } from "./errors.js";
|
|
6
|
+
import { optimizeInNode } from "./node.js";
|
|
7
|
+
parentPort.on("message", async (req) => {
|
|
8
|
+
try {
|
|
9
|
+
const input = new Uint8Array(await readFile(req.path));
|
|
10
|
+
const out = await optimizeInNode(input, req.options);
|
|
11
|
+
const bytes = out.bytes ? out.bytes.slice().buffer : undefined;
|
|
12
|
+
parentPort.postMessage({ id: req.id, ok: true, bytes, report: out.report }, bytes ? [bytes] : []);
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
parentPort.postMessage({ id: req.id, ok: false, error: serializeError(e) });
|
|
16
|
+
}
|
|
17
|
+
});
|
package/dist/batch.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { EngineReport } from "./engine.js";
|
|
2
|
+
import { type OptiqError } from "./errors.js";
|
|
3
|
+
import type { OptiqOptions } from "./types.js";
|
|
4
|
+
export interface BatchItemResult {
|
|
5
|
+
path: string;
|
|
6
|
+
/** Output bytes; undefined when the input was already optimal (report.usedOriginal). */
|
|
7
|
+
bytes?: Uint8Array;
|
|
8
|
+
report?: EngineReport;
|
|
9
|
+
error?: OptiqError;
|
|
10
|
+
}
|
|
11
|
+
export interface BatchOptions {
|
|
12
|
+
/** Worker threads. Default: available cores − 1 (at least 1). Each holds a few copies of one decoded image. */
|
|
13
|
+
concurrency?: number;
|
|
14
|
+
onResult?: (r: BatchItemResult, done: number, total: number) => void;
|
|
15
|
+
}
|
|
16
|
+
export declare function optimizeFiles(paths: string[], options: OptiqOptions, batch?: BatchOptions): Promise<BatchItemResult[]>;
|
package/dist/batch.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch optimisation of files in Node using a pool of worker threads (used by the CLI and the Vite plugin).
|
|
3
|
+
*/
|
|
4
|
+
import { availableParallelism } from "node:os";
|
|
5
|
+
import { Worker } from "node:worker_threads";
|
|
6
|
+
import { deserializeError } from "./errors.js";
|
|
7
|
+
export async function optimizeFiles(paths, options, batch = {}) {
|
|
8
|
+
const n = Math.max(1, Math.min(paths.length, batch.concurrency ?? Math.max(1, availableParallelism() - 1)));
|
|
9
|
+
const results = new Array(paths.length);
|
|
10
|
+
if (!paths.length)
|
|
11
|
+
return results;
|
|
12
|
+
let next = 0;
|
|
13
|
+
let done = 0;
|
|
14
|
+
await new Promise((resolve, reject) => {
|
|
15
|
+
const workers = [];
|
|
16
|
+
const finish = () => {
|
|
17
|
+
for (const w of workers)
|
|
18
|
+
void w.terminate();
|
|
19
|
+
resolve();
|
|
20
|
+
};
|
|
21
|
+
const feed = (w) => {
|
|
22
|
+
if (next >= paths.length)
|
|
23
|
+
return;
|
|
24
|
+
const id = next++;
|
|
25
|
+
w.postMessage({ id, path: paths[id], options });
|
|
26
|
+
};
|
|
27
|
+
for (let i = 0; i < n; i++) {
|
|
28
|
+
const w = new Worker(new URL("./batch-worker.js", import.meta.url));
|
|
29
|
+
workers.push(w);
|
|
30
|
+
w.on("message", (m) => {
|
|
31
|
+
const r = m.ok
|
|
32
|
+
? { path: paths[m.id], bytes: m.bytes ? new Uint8Array(m.bytes) : undefined, report: m.report }
|
|
33
|
+
: { path: paths[m.id], error: deserializeError(m.error) };
|
|
34
|
+
results[m.id] = r;
|
|
35
|
+
done++;
|
|
36
|
+
batch.onResult?.(r, done, paths.length);
|
|
37
|
+
if (done === paths.length)
|
|
38
|
+
finish();
|
|
39
|
+
else
|
|
40
|
+
feed(w);
|
|
41
|
+
});
|
|
42
|
+
w.on("error", (e) => {
|
|
43
|
+
for (const x of workers)
|
|
44
|
+
void x.terminate();
|
|
45
|
+
reject(e);
|
|
46
|
+
});
|
|
47
|
+
feed(w);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
return results;
|
|
51
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `optiq-image` command line: optimise image files or folders with the same engine as the browser library.
|
|
4
|
+
* Default is a dry run that only reports; pass --out or --in-place to write.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdir, readdir, stat, writeFile, rename } from "node:fs/promises";
|
|
7
|
+
import { dirname, extname, join, relative, resolve } from "node:path";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { optimizeFiles } from "./batch.js";
|
|
10
|
+
import { fileNameFor } from "./client.js";
|
|
11
|
+
const HELP = `Usage: optiq-image <files or folders...> [options]
|
|
12
|
+
|
|
13
|
+
Optimise images with Optiq (the same WASM codecs and quality checks as in the browser).
|
|
14
|
+
Without --out or --in-place nothing is written: you get a report (dry run).
|
|
15
|
+
|
|
16
|
+
Options
|
|
17
|
+
--mode <m> fast | balanced (default) | visually-lossless | lossless
|
|
18
|
+
--out <dir> write results to <dir>, mirroring the input folder structure
|
|
19
|
+
--in-place overwrite inputs (keeps each file's format, so references stay valid)
|
|
20
|
+
--keep-format never change the format (implied by --in-place)
|
|
21
|
+
--formats <list> formats auto may choose, e.g. "webp,jpeg" (default: jpeg,png,webp,avif; fast: no avif)
|
|
22
|
+
--max-size <px> downscale the longest side (in lossless mode this also allows the resize)
|
|
23
|
+
--strip-metadata remove EXIF (incl. GPS) and XMP
|
|
24
|
+
--min-quality <ssim> SSIM threshold for perceptual modes (e.g. 0.98)
|
|
25
|
+
--concurrency <n> worker threads (default: CPU cores - 1)
|
|
26
|
+
--json print a JSON report instead of a table
|
|
27
|
+
-h, --help show this help
|
|
28
|
+
|
|
29
|
+
Examples
|
|
30
|
+
npx optiq-image ./photos --out ./photos-optimized
|
|
31
|
+
npx optiq-image public/images --in-place --mode visually-lossless
|
|
32
|
+
npx optiq-image hero.png --formats webp,avif --out dist/img --json
|
|
33
|
+
`;
|
|
34
|
+
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"]);
|
|
35
|
+
async function collect(paths) {
|
|
36
|
+
const out = [];
|
|
37
|
+
async function walk(p, root) {
|
|
38
|
+
const s = await stat(p);
|
|
39
|
+
if (s.isDirectory()) {
|
|
40
|
+
for (const e of await readdir(p))
|
|
41
|
+
if (!e.startsWith("."))
|
|
42
|
+
await walk(join(p, e), root);
|
|
43
|
+
}
|
|
44
|
+
else if (IMAGE_EXT.has(extname(p).toLowerCase()))
|
|
45
|
+
out.push({ file: p, root });
|
|
46
|
+
}
|
|
47
|
+
for (const p of paths) {
|
|
48
|
+
const abs = resolve(p);
|
|
49
|
+
const s = await stat(abs);
|
|
50
|
+
await walk(abs, s.isDirectory() ? abs : dirname(abs));
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
const kb = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(2)} MB`);
|
|
55
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
56
|
+
const { values: a, positionals } = parseArgs({
|
|
57
|
+
args: argv,
|
|
58
|
+
allowPositionals: true,
|
|
59
|
+
options: {
|
|
60
|
+
mode: { type: "string" },
|
|
61
|
+
out: { type: "string" },
|
|
62
|
+
"in-place": { type: "boolean" },
|
|
63
|
+
"keep-format": { type: "boolean" },
|
|
64
|
+
formats: { type: "string" },
|
|
65
|
+
"max-size": { type: "string" },
|
|
66
|
+
"strip-metadata": { type: "boolean" },
|
|
67
|
+
"min-quality": { type: "string" },
|
|
68
|
+
concurrency: { type: "string" },
|
|
69
|
+
json: { type: "boolean" },
|
|
70
|
+
help: { type: "boolean", short: "h" },
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
if (a.help || !positionals.length) {
|
|
74
|
+
process.stdout.write(HELP);
|
|
75
|
+
return a.help ? 0 : 1;
|
|
76
|
+
}
|
|
77
|
+
if (a.out && a["in-place"])
|
|
78
|
+
throw new Error("Use either --out or --in-place, not both");
|
|
79
|
+
const keepFormat = !!(a["keep-format"] || a["in-place"]);
|
|
80
|
+
const maxSize = a["max-size"] ? Number(a["max-size"]) : undefined;
|
|
81
|
+
const options = {
|
|
82
|
+
mode: a.mode ?? "balanced",
|
|
83
|
+
allowFormatConversion: !keepFormat,
|
|
84
|
+
autoFormats: a.formats ? a.formats.split(",").map((s) => s.trim()) : undefined,
|
|
85
|
+
maxWidthOrHeight: maxSize,
|
|
86
|
+
allowLossyResize: maxSize !== undefined,
|
|
87
|
+
preserveMetadata: !a["strip-metadata"],
|
|
88
|
+
minQuality: a["min-quality"] ? Number(a["min-quality"]) : undefined,
|
|
89
|
+
};
|
|
90
|
+
const files = await collect(positionals);
|
|
91
|
+
if (!files.length) {
|
|
92
|
+
process.stderr.write("No images found (jpg, jpeg, png, webp, avif, gif)\n");
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
const t0 = performance.now();
|
|
96
|
+
const results = await optimizeFiles(files.map((f) => f.file), options, {
|
|
97
|
+
concurrency: a.concurrency ? Number(a.concurrency) : undefined,
|
|
98
|
+
onResult: a.json || !process.stderr.isTTY ? undefined : (r, done, total) => process.stderr.write(`\r${done}/${total} ${relative(process.cwd(), r.path)}`.padEnd(80).slice(0, 100)),
|
|
99
|
+
});
|
|
100
|
+
if (!a.json && process.stderr.isTTY)
|
|
101
|
+
process.stderr.write("\r".padEnd(101) + "\r");
|
|
102
|
+
const rows = [];
|
|
103
|
+
let before = 0, after = 0, errors = 0;
|
|
104
|
+
for (let i = 0; i < results.length; i++) {
|
|
105
|
+
const r = results[i];
|
|
106
|
+
const { file, root } = files[i];
|
|
107
|
+
const rel = relative(root, file);
|
|
108
|
+
if (r.error || !r.report) {
|
|
109
|
+
errors++;
|
|
110
|
+
rows.push({ file: relative(process.cwd(), file), error: `${r.error?.code ?? "ERROR"}: ${r.error?.message}` });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const rep = r.report;
|
|
114
|
+
before += rep.originalBytes;
|
|
115
|
+
after += rep.outputBytes;
|
|
116
|
+
let written;
|
|
117
|
+
if (r.bytes && (a.out || a["in-place"])) {
|
|
118
|
+
if (a["in-place"]) {
|
|
119
|
+
// Write next to the file, then rename: never leaves a half-written image behind.
|
|
120
|
+
const tmp = `${file}.optiq-tmp`;
|
|
121
|
+
await writeFile(tmp, r.bytes);
|
|
122
|
+
await rename(tmp, file);
|
|
123
|
+
written = file;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const target = join(resolve(a.out), dirname(rel), fileNameFor(rel.split(/[\\/]/).pop(), rep.outputFormat));
|
|
127
|
+
await mkdir(dirname(target), { recursive: true });
|
|
128
|
+
await writeFile(target, r.bytes);
|
|
129
|
+
written = target;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else if (a.out && !r.bytes) {
|
|
133
|
+
// Already optimal: copy the original so the output folder is complete.
|
|
134
|
+
const { copyFile } = await import("node:fs/promises");
|
|
135
|
+
const target = join(resolve(a.out), rel);
|
|
136
|
+
await mkdir(dirname(target), { recursive: true });
|
|
137
|
+
await copyFile(file, target);
|
|
138
|
+
written = target;
|
|
139
|
+
}
|
|
140
|
+
rows.push({
|
|
141
|
+
file: relative(process.cwd(), file),
|
|
142
|
+
from: rep.originalFormat,
|
|
143
|
+
to: rep.outputFormat,
|
|
144
|
+
before: rep.originalBytes,
|
|
145
|
+
after: rep.outputBytes,
|
|
146
|
+
saved: rep.sizeReductionRatio,
|
|
147
|
+
quality: rep.qualityMetricName === "exact" ? "exact" : rep.qualityMetric,
|
|
148
|
+
usedOriginal: rep.usedOriginal,
|
|
149
|
+
warnings: rep.warnings,
|
|
150
|
+
written: written ? relative(process.cwd(), written) : undefined,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const seconds = (performance.now() - t0) / 1000;
|
|
154
|
+
if (a.json) {
|
|
155
|
+
process.stdout.write(JSON.stringify({ options, files: rows, totals: { before, after, saved: before ? 1 - after / before : 0, errors, seconds } }, null, 2) + "\n");
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
for (const r of rows) {
|
|
159
|
+
if (r.error)
|
|
160
|
+
process.stdout.write(`✗ ${r.file} ${r.error}\n`);
|
|
161
|
+
else
|
|
162
|
+
process.stdout.write(`${r.usedOriginal ? "=" : "✓"} ${String(r.file).padEnd(40)} ${String(r.from).padEnd(4)}→ ${String(r.to).padEnd(4)} ${kb(r.before).padStart(9)} → ${kb(r.after).padStart(9)} ${(r.saved * 100).toFixed(1).padStart(5)}% ${r.quality === "exact" ? "exact" : `SSIM ${r.quality.toFixed(4)}`}\n`);
|
|
163
|
+
}
|
|
164
|
+
process.stdout.write(`\n${results.length} files, ${kb(before)} → ${kb(after)} (${before ? ((1 - after / before) * 100).toFixed(1) : 0}% smaller) in ${seconds.toFixed(1)} s${errors ? `, ${errors} failed` : ""}\n`);
|
|
165
|
+
if (!a.out && !a["in-place"])
|
|
166
|
+
process.stdout.write("Dry run: nothing written. Use --out <dir> or --in-place.\n");
|
|
167
|
+
}
|
|
168
|
+
return errors ? 2 : 0;
|
|
169
|
+
}
|
|
170
|
+
// Run when executed directly (bin), not when imported.
|
|
171
|
+
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("/optiq-image") || process.argv[1]?.endsWith("cli.js")) {
|
|
172
|
+
main().then((code) => process.exit(code), (e) => {
|
|
173
|
+
process.stderr.write(`optiq-image: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
});
|
|
176
|
+
}
|
package/dist/options.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { OptiqError } from "./errors.js";
|
|
2
2
|
export const OUTPUT_FORMATS = ["jpeg", "png", "webp", "avif"];
|
|
3
3
|
const TARGETS = ["auto", "original", ...OUTPUT_FORMATS];
|
|
4
|
-
const MODES = ["lossless", "visually-lossless", "balanced"];
|
|
4
|
+
const MODES = ["lossless", "visually-lossless", "balanced", "fast"];
|
|
5
5
|
export const MODE_DEFAULTS = {
|
|
6
6
|
lossless: { minQuality: 1, maxCandidates: 4, qualityTolerance: 0, avifSpeed: 6 },
|
|
7
7
|
"visually-lossless": { minQuality: 0.99, maxCandidates: 16, qualityTolerance: 3, avifSpeed: 6 },
|
|
8
8
|
// AVIF speed 7, not 8: at speed 8 libaom lost its screen-content gains (5 MP dashboard: 45 KB vs 13 KB at q50).
|
|
9
9
|
balanced: { minQuality: 0.975, maxCandidates: 10, qualityTolerance: 5, avifSpeed: 7 },
|
|
10
|
+
// Same quality bar as balanced; cost is capped instead: AVIF (3.5 MB download, slowest encoder, most memory) is off
|
|
11
|
+
// by default, oxipng is skipped, and the quality search stops early.
|
|
12
|
+
fast: { minQuality: 0.975, maxCandidates: 5, qualityTolerance: 8, avifSpeed: 8 },
|
|
10
13
|
};
|
|
11
14
|
function invalid(message) {
|
|
12
15
|
throw new OptiqError("INVALID_OPTIONS", message);
|
|
@@ -26,7 +29,7 @@ export function resolveOptions(o = {}) {
|
|
|
26
29
|
const targetFormat = o.targetFormat ?? "auto";
|
|
27
30
|
if (!TARGETS.includes(targetFormat))
|
|
28
31
|
invalid(`targetFormat must be one of ${TARGETS.join(", ")}; got "${String(targetFormat)}"`);
|
|
29
|
-
const autoFormats = o.autoFormats ?? [...OUTPUT_FORMATS];
|
|
32
|
+
const autoFormats = o.autoFormats ?? (mode === "fast" ? OUTPUT_FORMATS.filter((f) => f !== "avif") : [...OUTPUT_FORMATS]);
|
|
30
33
|
if (!autoFormats.length || autoFormats.some((f) => !OUTPUT_FORMATS.includes(f)))
|
|
31
34
|
invalid(`autoFormats must be a non-empty subset of ${OUTPUT_FORMATS.join(", ")}`);
|
|
32
35
|
if (o.minQuality !== undefined && (!Number.isFinite(o.minQuality) || o.minQuality <= 0 || o.minQuality > 1)) {
|
package/dist/plan.js
CHANGED
|
@@ -8,12 +8,14 @@ const RANGES = {
|
|
|
8
8
|
const START = {
|
|
9
9
|
"visually-lossless": { jpeg: 86, webp: 86, avif: 72 },
|
|
10
10
|
balanced: { jpeg: 76, webp: 76, avif: 58 },
|
|
11
|
+
fast: { jpeg: 78, webp: 78, avif: 58 },
|
|
11
12
|
};
|
|
12
13
|
const isGraphic = (c) => c === "screenshot" || c === "text-graphic" || c === "illustration" || c === "transparent-illustration";
|
|
13
14
|
export function losslessPlans(input, mode) {
|
|
14
15
|
const plans = [];
|
|
15
16
|
const has = (f) => input.allowed.includes(f);
|
|
16
|
-
|
|
17
|
+
// oxipng costs ~0.3 s per MP and rarely beats lossless WebP on graphics; `fast` only uses it when PNG is required.
|
|
18
|
+
if (has("png") && !(mode === "fast" && has("webp"))) {
|
|
17
19
|
if (input.pngContainerEligible)
|
|
18
20
|
plans.push({ kind: "exact", format: "png", settings: { level: 2 }, fromContainer: true });
|
|
19
21
|
else if (input.pixelsExact)
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type CompressCallOptions, type Compressor } from "./client.js";
|
|
2
|
+
import type { CompressionResult, ProgressEvent } from "./types.js";
|
|
3
|
+
export type UseOptiqStatus = "idle" | "compressing" | "done" | "error" | "cancelled";
|
|
4
|
+
export interface UseOptiqOptions extends Omit<CompressCallOptions, "signal" | "onProgress"> {
|
|
5
|
+
/** Use a specific compressor (worker pool) instead of the shared one. */
|
|
6
|
+
compressor?: Compressor;
|
|
7
|
+
/** Set to false to keep the hook idle. Default true. */
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface UseOptiqState {
|
|
11
|
+
status: UseOptiqStatus;
|
|
12
|
+
result?: CompressionResult;
|
|
13
|
+
/** The thrown value; usually an OptiqError with a `code`. The original file is always still usable. */
|
|
14
|
+
error?: unknown;
|
|
15
|
+
progress?: ProgressEvent;
|
|
16
|
+
/** Abort the running compression (terminates its worker). */
|
|
17
|
+
cancel: () => void;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Compress `file` whenever it (or the options) change. Cancels the previous run automatically, and on unmount.
|
|
21
|
+
*
|
|
22
|
+
* ```tsx
|
|
23
|
+
* const { status, result, progress, cancel } = useOptiq(file, { mode: "balanced" });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export declare function useOptiq(file: Blob | null | undefined, options?: UseOptiqOptions): UseOptiqState;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `optiq-image/react` — React hook around {@link compressImage}. Requires React 18+ (optional peer dependency).
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
7
|
+
import { compressImage } from "./client.js";
|
|
8
|
+
/**
|
|
9
|
+
* Compress `file` whenever it (or the options) change. Cancels the previous run automatically, and on unmount.
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* const { status, result, progress, cancel } = useOptiq(file, { mode: "balanced" });
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export function useOptiq(file, options = {}) {
|
|
16
|
+
const { compressor, enabled = true, ...compressOptions } = options;
|
|
17
|
+
const [state, setState] = useState({ status: "idle" });
|
|
18
|
+
const controller = useRef(undefined);
|
|
19
|
+
// Options are compared by value so inline objects do not restart the job on every render.
|
|
20
|
+
const key = JSON.stringify(compressOptions);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!file || !enabled) {
|
|
23
|
+
setState({ status: "idle" });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const ac = new AbortController();
|
|
27
|
+
controller.current = ac;
|
|
28
|
+
setState({ status: "compressing" });
|
|
29
|
+
const run = compressor ? compressor.compress.bind(compressor) : compressImage;
|
|
30
|
+
run(file, { ...JSON.parse(key), signal: ac.signal, onProgress: (progress) => !ac.signal.aborted && setState({ status: "compressing", progress }) }).then((result) => !ac.signal.aborted && setState({ status: "done", result }), (error) => setState(ac.signal.aborted ? { status: "cancelled" } : { status: "error", error }));
|
|
31
|
+
return () => ac.abort();
|
|
32
|
+
}, [file, enabled, key, compressor]);
|
|
33
|
+
const cancel = useCallback(() => controller.current?.abort(), []);
|
|
34
|
+
return { ...state, cancel };
|
|
35
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -9,8 +9,9 @@ export type TargetFormat = "auto" | "original" | OutputFormat;
|
|
|
9
9
|
* - `lossless`: decoded pixels (incl. alpha) of the output are verified to be identical to the input.
|
|
10
10
|
* - `visually-lossless`: decoded pixels may differ; the perceptual difference (SSIM) is kept above a strict threshold.
|
|
11
11
|
* - `balanced`: lower SSIM threshold and smaller search budget; trades some quality for size and speed.
|
|
12
|
+
* - `fast`: same threshold as `balanced`, but no AVIF, no oxipng and at most 5 encodes; for phones and batches.
|
|
12
13
|
*/
|
|
13
|
-
export type CompressionMode = "lossless" | "visually-lossless" | "balanced";
|
|
14
|
+
export type CompressionMode = "lossless" | "visually-lossless" | "balanced" | "fast";
|
|
14
15
|
export type ImageCategory = "photo" | "screenshot" | "text-graphic" | "illustration" | "transparent-illustration" | "gradient";
|
|
15
16
|
export type SearchStrategy = "binary" | "fixed";
|
|
16
17
|
export interface OptiqOptions {
|
package/dist/vite.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Plugin } from "vite";
|
|
2
|
+
import type { CompressionMode } from "./types.js";
|
|
3
|
+
export interface OptiqViteOptions {
|
|
4
|
+
/** Default `visually-lossless` (SSIM ≥ 0.99, same format). PNG/GIF are only ever optimised losslessly here. */
|
|
5
|
+
mode?: CompressionMode;
|
|
6
|
+
/** Only files whose path (relative to outDir) matches. Default: all jpg/jpeg/png/webp/avif. */
|
|
7
|
+
include?: RegExp;
|
|
8
|
+
exclude?: RegExp;
|
|
9
|
+
/** Keep EXIF/XMP. Default `false` for build output (strips GPS etc. from published images). */
|
|
10
|
+
preserveMetadata?: boolean;
|
|
11
|
+
/** Worker threads. Default: CPU cores − 1. */
|
|
12
|
+
concurrency?: number;
|
|
13
|
+
/** Print a per-file report. Default `true`. */
|
|
14
|
+
verbose?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function optiq(options?: OptiqViteOptions): Plugin;
|
|
17
|
+
export default optiq;
|
package/dist/vite.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `optiq-image/vite` — optimise the images in your Vite build output (bundled assets and `public/` files).
|
|
3
|
+
*
|
|
4
|
+
* Runs after the bundle is written. Every image keeps its file name and format, so references in HTML, CSS and JS
|
|
5
|
+
* stay valid; a file is only replaced when the result is smaller and meets the quality constraint.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
import { readdir, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import { extname, join, relative, resolve } from "node:path";
|
|
11
|
+
import { optimizeFiles } from "./batch.js";
|
|
12
|
+
const EXT = /\.(jpe?g|png|webp|avif)$/i;
|
|
13
|
+
async function walk(dir, out = []) {
|
|
14
|
+
for (const e of await readdir(dir)) {
|
|
15
|
+
const p = join(dir, e);
|
|
16
|
+
if ((await stat(p)).isDirectory())
|
|
17
|
+
await walk(p, out);
|
|
18
|
+
else
|
|
19
|
+
out.push(p);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
export function optiq(options = {}) {
|
|
24
|
+
let config;
|
|
25
|
+
return {
|
|
26
|
+
name: "optiq-image",
|
|
27
|
+
apply: "build",
|
|
28
|
+
enforce: "post",
|
|
29
|
+
configResolved(c) {
|
|
30
|
+
config = c;
|
|
31
|
+
},
|
|
32
|
+
async closeBundle() {
|
|
33
|
+
const outDir = resolve(config.root, config.build.outDir);
|
|
34
|
+
const files = (await walk(outDir)).filter((f) => {
|
|
35
|
+
const rel = relative(outDir, f);
|
|
36
|
+
return (options.include ?? EXT).test(rel) && EXT.test(extname(f)) && !(options.exclude && options.exclude.test(rel));
|
|
37
|
+
});
|
|
38
|
+
if (!files.length)
|
|
39
|
+
return;
|
|
40
|
+
const t0 = performance.now();
|
|
41
|
+
const results = await optimizeFiles(files, {
|
|
42
|
+
mode: options.mode ?? "visually-lossless",
|
|
43
|
+
targetFormat: "original",
|
|
44
|
+
allowFormatConversion: false,
|
|
45
|
+
preserveMetadata: options.preserveMetadata ?? false,
|
|
46
|
+
}, { concurrency: options.concurrency });
|
|
47
|
+
let before = 0;
|
|
48
|
+
let after = 0;
|
|
49
|
+
for (const r of results) {
|
|
50
|
+
const rel = relative(outDir, r.path);
|
|
51
|
+
if (r.error || !r.report) {
|
|
52
|
+
config.logger.warn(`[optiq] ${rel}: ${r.error?.code ?? "error"} ${r.error?.message ?? ""} (left unchanged)`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
before += r.report.originalBytes;
|
|
56
|
+
after += r.report.outputBytes;
|
|
57
|
+
if (r.bytes)
|
|
58
|
+
await writeFile(r.path, r.bytes);
|
|
59
|
+
if (options.verbose !== false && r.bytes) {
|
|
60
|
+
const q = r.report.qualityMetricName === "exact" ? "lossless" : `SSIM ${r.report.qualityMetric?.toFixed(4)}`;
|
|
61
|
+
config.logger.info(`[optiq] ${rel} ${r.report.originalBytes} → ${r.report.outputBytes} B (−${(r.report.sizeReductionRatio * 100).toFixed(1)}%, ${q})`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
config.logger.info(`[optiq] ${files.length} images: ${(before / 1024).toFixed(1)} KB → ${(after / 1024).toFixed(1)} KB ` +
|
|
65
|
+
`(−${before ? ((1 - after / before) * 100).toFixed(1) : 0}%) in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export default optiq;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "optiq-image",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0-beta.0",
|
|
4
4
|
"description": "Browser-side image optimizer: picks the smallest encoding that satisfies lossless or perceptual (SSIM) quality constraints, in a Web Worker.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Bauyrzhan (https://github.com/Baukaalm)",
|
|
@@ -28,10 +28,15 @@
|
|
|
28
28
|
],
|
|
29
29
|
"type": "module",
|
|
30
30
|
"sideEffects": [
|
|
31
|
-
"./dist/worker.js"
|
|
31
|
+
"./dist/worker.js",
|
|
32
|
+
"./dist/batch-worker.js",
|
|
33
|
+
"./dist/cli.js"
|
|
32
34
|
],
|
|
33
35
|
"main": "./dist/index.js",
|
|
34
36
|
"types": "./dist/index.d.ts",
|
|
37
|
+
"bin": {
|
|
38
|
+
"optiq-image": "./dist/cli.js"
|
|
39
|
+
},
|
|
35
40
|
"exports": {
|
|
36
41
|
".": {
|
|
37
42
|
"types": "./dist/index.d.ts",
|
|
@@ -52,6 +57,14 @@
|
|
|
52
57
|
"./node": {
|
|
53
58
|
"types": "./dist/node.d.ts",
|
|
54
59
|
"import": "./dist/node.js"
|
|
60
|
+
},
|
|
61
|
+
"./react": {
|
|
62
|
+
"types": "./dist/react.d.ts",
|
|
63
|
+
"import": "./dist/react.js"
|
|
64
|
+
},
|
|
65
|
+
"./vite": {
|
|
66
|
+
"types": "./dist/vite.d.ts",
|
|
67
|
+
"import": "./dist/vite.js"
|
|
55
68
|
}
|
|
56
69
|
},
|
|
57
70
|
"files": [
|
|
@@ -87,6 +100,18 @@
|
|
|
87
100
|
"site:build": "pnpm build && node scripts/build-site.mjs",
|
|
88
101
|
"test:site": "pnpm site:build && node test/browser/site-e2e.mjs"
|
|
89
102
|
},
|
|
103
|
+
"peerDependencies": {
|
|
104
|
+
"react": ">=18",
|
|
105
|
+
"vite": ">=5"
|
|
106
|
+
},
|
|
107
|
+
"peerDependenciesMeta": {
|
|
108
|
+
"react": {
|
|
109
|
+
"optional": true
|
|
110
|
+
},
|
|
111
|
+
"vite": {
|
|
112
|
+
"optional": true
|
|
113
|
+
}
|
|
114
|
+
},
|
|
90
115
|
"dependencies": {
|
|
91
116
|
"@jsquash/avif": "2.1.1",
|
|
92
117
|
"@jsquash/jpeg": "1.6.0",
|
|
@@ -97,8 +122,13 @@
|
|
|
97
122
|
},
|
|
98
123
|
"devDependencies": {
|
|
99
124
|
"@types/node": "24.19.2",
|
|
125
|
+
"@types/react": "19.3.0",
|
|
126
|
+
"@types/react-dom": "19.3.0",
|
|
100
127
|
"browser-image-compression": "2.0.2",
|
|
128
|
+
"jsdom": "30.1.3",
|
|
101
129
|
"playwright": "1.64.1",
|
|
130
|
+
"react": "19.3.1",
|
|
131
|
+
"react-dom": "19.3.1",
|
|
102
132
|
"sharp": "0.35.5",
|
|
103
133
|
"ssim.js": "3.5.0",
|
|
104
134
|
"typescript": "5.9.3",
|