optiq-image 0.0.0-stage → 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.
Files changed (71) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +30 -0
  4. package/README.md +259 -2
  5. package/dist/batch-worker.d.ts +6 -0
  6. package/dist/batch-worker.js +17 -0
  7. package/dist/batch.d.ts +16 -0
  8. package/dist/batch.js +51 -0
  9. package/dist/classify.d.ts +31 -0
  10. package/dist/classify.js +86 -0
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +176 -0
  13. package/dist/client.d.ts +43 -0
  14. package/dist/client.js +195 -0
  15. package/dist/codecs/canvas.d.ts +9 -0
  16. package/dist/codecs/canvas.js +64 -0
  17. package/dist/codecs/fallback.d.ts +11 -0
  18. package/dist/codecs/fallback.js +59 -0
  19. package/dist/codecs/types.d.ts +15 -0
  20. package/dist/codecs/types.js +1 -0
  21. package/dist/codecs/wasm.d.ts +6 -0
  22. package/dist/codecs/wasm.js +125 -0
  23. package/dist/container/bytes.d.ts +17 -0
  24. package/dist/container/bytes.js +87 -0
  25. package/dist/container/exif.d.ts +6 -0
  26. package/dist/container/exif.js +73 -0
  27. package/dist/container/gif.d.ts +14 -0
  28. package/dist/container/gif.js +199 -0
  29. package/dist/container/icc.d.ts +8 -0
  30. package/dist/container/icc.js +35 -0
  31. package/dist/container/isobmff.d.ts +28 -0
  32. package/dist/container/isobmff.js +149 -0
  33. package/dist/container/jpeg.d.ts +46 -0
  34. package/dist/container/jpeg.js +171 -0
  35. package/dist/container/png.d.ts +43 -0
  36. package/dist/container/png.js +139 -0
  37. package/dist/container/webp.d.ts +39 -0
  38. package/dist/container/webp.js +149 -0
  39. package/dist/engine.d.ts +24 -0
  40. package/dist/engine.js +588 -0
  41. package/dist/errors.d.ts +16 -0
  42. package/dist/errors.js +31 -0
  43. package/dist/index.d.ts +13 -0
  44. package/dist/index.js +9 -0
  45. package/dist/node.d.ts +10 -0
  46. package/dist/node.js +68 -0
  47. package/dist/options.d.ts +30 -0
  48. package/dist/options.js +71 -0
  49. package/dist/pixels.d.ts +25 -0
  50. package/dist/pixels.js +124 -0
  51. package/dist/plan.d.ts +35 -0
  52. package/dist/plan.js +77 -0
  53. package/dist/probe.d.ts +29 -0
  54. package/dist/probe.js +118 -0
  55. package/dist/protocol.d.ts +26 -0
  56. package/dist/protocol.js +1 -0
  57. package/dist/quality.d.ts +18 -0
  58. package/dist/quality.js +176 -0
  59. package/dist/react.d.ts +26 -0
  60. package/dist/react.js +35 -0
  61. package/dist/run.d.ts +5 -0
  62. package/dist/run.js +11 -0
  63. package/dist/types.d.ts +134 -0
  64. package/dist/types.js +1 -0
  65. package/dist/upload.d.ts +87 -0
  66. package/dist/upload.js +95 -0
  67. package/dist/vite.d.ts +17 -0
  68. package/dist/vite.js +69 -0
  69. package/dist/worker.d.ts +1 -0
  70. package/dist/worker.js +24 -0
  71. package/package.json +136 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
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
+
12
+ ## 0.1.0-beta.0 (unreleased)
13
+
14
+ Audit fixes (see docs/audit.md):
15
+
16
+ - A perceptual mode could return the original although `lossless` mode found a smaller pixel-exact file (P3 photo
17
+ PNG: 794 KB returned, 472 KB available). Quality ranges now reach JPEG 98 / WebP 99 / AVIF 95, and lossless
18
+ candidates are added when no re-encode meets the threshold for a losslessly stored input.
19
+ - SSIM is now bounded by the alpha plane's own SSIM for images with transparency.
20
+ - Warning when the output is larger than the input because the original was not an allowed result.
21
+ - SSIM 20–54% faster with numerically identical results.
22
+ - Verified with Vite 7 and Vite 8.
23
+
24
+ First public beta.
25
+
26
+ - `compressImage` / `createCompressor`: Web Worker pool, `AbortSignal` cancellation (terminates the worker),
27
+ progress events, typed `OptiqError` codes, canvas fallback when WASM cannot load.
28
+ - Modes: `lossless` (pixel-exact, verified), `visually-lossless` (SSIM ≥ 0.99), `balanced` (SSIM ≥ 0.975).
29
+ - Codecs (via jSquash): MozJPEG, libwebp, libavif, oxipng, Rust `png` and `resize`; own static-GIF decoder.
30
+ - Format sniffing from content; EXIF orientation, ICC, EXIF/XMP preservation; animation never silently flattened.
31
+ - Tile-mosaic quality search for images above 4 MP.
32
+ - `optiq-image/upload`: presigned PUT/POST upload with progress and original-file fallback.
33
+ - `optiq-image/node`: Node.js codec initialisation for tests, benchmarks and server-side use.
34
+
35
+ Tested in Chromium, Firefox and WebKit. Known limitations: no AVIF metadata writer; HEIC unsupported.
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Optiq contributors
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,30 @@
1
+ Optiq
2
+ Copyright 2026 Optiq contributors
3
+
4
+ This product is licensed under the Apache License, Version 2.0 (see LICENSE).
5
+
6
+ Optiq does not bundle codec binaries itself. It depends on the following packages, which an application
7
+ bundle that uses Optiq will include. Their licences permit commercial redistribution provided the notices
8
+ are reproduced; the full texts ship inside each package (LICENSE and codec/LICENSE.codec.md).
9
+
10
+ @jsquash/jpeg, @jsquash/webp, @jsquash/avif, @jsquash/png, @jsquash/oxipng, @jsquash/resize
11
+ Apache-2.0, Copyright 2023 jamsinclair; derived from Squoosh, Copyright 2020 Google Inc.
12
+
13
+ MozJPEG / libjpeg-turbo (in @jsquash/jpeg)
14
+ IJG License, Modified (3-clause) BSD License, zlib License
15
+ "This software is based in part on the work of the Independent JPEG Group."
16
+
17
+ libwebp (in @jsquash/webp)
18
+ BSD-3-Clause, Copyright (c) 2010, Google Inc.
19
+
20
+ libavif and libaom (in @jsquash/avif)
21
+ libavif: BSD-2-Clause, Copyright 2019 Joe Drago and contributors
22
+ libaom: BSD-2-Clause, Copyright (c) 2016, Alliance for Open Media, plus the AOMedia Patent License 1.0
23
+ (@jsquash/avif does not ship these texts; see https://github.com/AOMediaCodec/libavif and
24
+ https://aomedia.googlesource.com/aom/+/refs/heads/main/LICENSE and PATENTS)
25
+
26
+ png crate (in @jsquash/png) MIT OR Apache-2.0, The image-rs Developers
27
+ oxipng (in @jsquash/oxipng) MIT, Copyright (c) 2016 Joshua Holmer
28
+ resize crate (in @jsquash/resize) MIT, Copyright (c) 2015 PistonDevelopers
29
+ hqx (in @jsquash/resize) Apache-2.0
30
+ magic-kernel (in @jsquash/resize) MIT, Copyright (c) 2024 Serhii Tatarintsev
package/README.md CHANGED
@@ -1,3 +1,260 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Baukaalm/optiq/main/brand/logo-white.svg" />
4
+ <img src="https://raw.githubusercontent.com/Baukaalm/optiq/main/brand/logo-black.svg" width="96" height="96" alt="Optiq" />
5
+ </picture>
6
+ </p>
2
7
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
8
+ <h1 align="center">Optiq</h1>
9
+
10
+ **[Try the demo](https://baukaalm.github.io/optiq/try)** · **[Documentation](https://baukaalm.github.io/optiq/)** · [Benchmarks](https://baukaalm.github.io/optiq/benchmarks)
11
+
12
+ Browser-side image optimisation for uploads. For every image Optiq encodes a bounded set of candidates
13
+ (MozJPEG, libwebp, libavif, oxipng — compiled to WebAssembly), **measures** each one, and returns the smallest
14
+ output that satisfies your constraint:
15
+
16
+ - `lossless` — decoded pixels (including alpha) are verified identical to the input;
17
+ - `visually-lossless` — pixels may differ; SSIM ≥ 0.99 against the input (configurable);
18
+ - `balanced` (default) — SSIM ≥ 0.975, smaller search budget, faster AVIF preset.
19
+
20
+ Work runs in a Web Worker; the main thread only posts a `Blob` and receives an `ArrayBuffer`. Results carry the
21
+ chosen codec/settings, every candidate tried, the measured quality, warnings, fallbacks and unmet constraints.
22
+
23
+ > Benchmarks: see [Benchmark results](#benchmark-results) and `bench/results/RESULTS.md`. Optiq is not
24
+ > uniformly better than the alternatives; the trade-offs (size vs. time vs. memory) are listed there per category.
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ pnpm add optiq-image # or npm / yarn
30
+ ```
31
+
32
+ Requirements: an ES2022 browser with module Workers and WebAssembly (Chrome/Edge 80+, Firefox 114+, Safari 15+).
33
+ AVIF *output* is only useful where your audience can decode AVIF (Chrome 85+, Firefox 93+, Safari 16.4+); remove it
34
+ with `autoFormats: ["jpeg", "png", "webp"]` otherwise.
35
+
36
+ ### Bundler setup (Vite 7 and 8)
37
+
38
+ ```ts
39
+ // vite.config.ts
40
+ export default defineConfig({
41
+ worker: { format: "es" }, // codecs are loaded with dynamic import() inside the worker
42
+ optimizeDeps: { // dev only: pre-bundling breaks `new URL("x.wasm", import.meta.url)`
43
+ exclude: ["optiq-image", "@jsquash/jpeg", "@jsquash/png", "@jsquash/webp", "@jsquash/avif", "@jsquash/oxipng", "@jsquash/resize"],
44
+ },
45
+ });
46
+ ```
47
+
48
+ webpack 5 and Parcel 2 understand `new Worker(new URL("./worker.js", import.meta.url), { type: "module" })` and
49
+ `new URL("*.wasm", import.meta.url)` natively. For anything else pass `workerFactory` to `createCompressor`.
50
+ Each codec is a separate chunk, fetched on first use (AVIF ≈ 3.5 MB, others 35–350 KB).
51
+
52
+ ## Usage
53
+
54
+ ```ts
55
+ import { compressImage, isOptiqError } from "optiq-image";
56
+
57
+ const controller = new AbortController();
58
+ try {
59
+ const result = await compressImage(file, {
60
+ mode: "balanced",
61
+ maxWidthOrHeight: 2560,
62
+ signal: controller.signal,
63
+ onProgress: (e) => console.log(e.stage, Math.round(e.progress * 100), e.bestBytes),
64
+ });
65
+ result.blob; // Blob with the right MIME type (image/avif, image/webp, ...)
66
+ result.fileName; // "IMG_0042.webp" — extension matches the encoded bytes
67
+ result.sizeReductionRatio, result.qualityMetric, result.codec, result.warnings;
68
+ if (!result.constraintsMet) console.warn(result.unmetConstraints);
69
+ } catch (e) {
70
+ if (isOptiqError(e)) console.warn(e.code, e.message); // e.g. UNSUPPORTED_FORMAT, ABORTED
71
+ // the original `file` is untouched — upload it instead
72
+ }
73
+ ```
74
+
75
+ React:
76
+
77
+ ```tsx
78
+ function useCompressed(file?: File) {
79
+ const [state, set] = useState<{ result?: CompressionResult; error?: unknown; progress: number }>({ progress: 0 });
80
+ useEffect(() => {
81
+ if (!file) return;
82
+ const ac = new AbortController();
83
+ compressImage(file, { signal: ac.signal, onProgress: (e) => set((s) => ({ ...s, progress: e.progress })) })
84
+ .then((result) => set({ result, progress: 1 }), (error) => !ac.signal.aborted && set({ error, progress: 0 }));
85
+ return () => ac.abort(); // terminates the worker job
86
+ }, [file]);
87
+ return state;
88
+ }
89
+ ```
90
+
91
+ For explicit control over workers use `createCompressor({ maxWorkers, workerFactory, codecFallback, allowMainThread, recycleAfterPixels })`.
92
+
93
+ ### Options
94
+
95
+ | Option | Default | Meaning |
96
+ |---|---|---|
97
+ | `mode` | `"balanced"` | `lossless`, `visually-lossless`, `balanced`, `fast` (no AVIF, ≤ 5 encodes; ~3× faster) |
98
+ | `minQuality` | 0.99 / 0.975 | SSIM threshold (ignored in lossless). Gradients use a stricter value half-way to 1 |
99
+ | `maxOutputBytes` | – | Upper bound. If impossible without violating quality, the result lists it in `unmetConstraints` |
100
+ | `maxWidthOrHeight` | – | Downscale longest side. In `lossless` mode only with `allowLossyResize: true` |
101
+ | `targetFormat` | `"auto"` | `auto`, `original`, `jpeg`, `png`, `webp`, `avif` |
102
+ | `autoFormats` | all four | Formats `auto` may choose |
103
+ | `allowFormatConversion` | `true` | `false` keeps the input format |
104
+ | `maxCandidates` | 10 / 16 / 4 | Encode budget (balanced / visually-lossless / lossless) |
105
+ | `searchStrategy` | `"binary"` | `fixed` = one encode per format at a preset quality |
106
+ | `preserveMetadata` | `true` | Keep EXIF/XMP/ICC. `false` strips EXIF (incl. GPS) and XMP; non-sRGB ICC is kept anyway |
107
+ | `preserveAnimation` | `true` | Animated inputs are only optimised at container level. `false` permits flattening to frame 1 |
108
+ | `minSavingsRatio` | `0.02` | Keep the original unless re-encoding saves at least this |
109
+ | `maxInputBytes` / `maxInputPixels` | 100 MiB / 50 MP | Checked before reading / before decoding |
110
+ | `onUnmetConstraints` | `"best-effort"` | `throw` → `CONSTRAINTS_UNSATISFIABLE` |
111
+
112
+ Progress stages: `reading → probing → decoding → (resizing) → analyzing → encoding … → finalizing → done`.
113
+
114
+ Error codes: `INVALID_INPUT`, `UNSUPPORTED_FORMAT`, `MALFORMED_INPUT`, `INPUT_TOO_LARGE`, `INVALID_OPTIONS`,
115
+ `INCOMPATIBLE_OPTIONS` (e.g. JPEG requested for a transparent image), `CONSTRAINTS_UNSATISFIABLE`,
116
+ `CODEC_UNAVAILABLE`, `UNSUPPORTED_ENVIRONMENT`, `WORKER_ERROR`, `ABORTED` (`name === "AbortError"`).
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
+
136
+ ## Uploading to S3
137
+
138
+ ```ts
139
+ import { compressAndUpload } from "optiq-image/upload";
140
+ const out = await compressAndUpload(file, { requestPresignedUpload: (req) => fetch("/api/presign", …).then((r) => r.json()) });
141
+ ```
142
+
143
+ Compression is optional and never blocks the upload: if it fails or does not help, the original is uploaded.
144
+ Presigned URLs must come from your backend — see [docs/guide/s3.md](https://github.com/Baukaalm/optiq/blob/main/docs/guide/s3.md) for the contract and a backend sketch.
145
+
146
+ ## Supported formats
147
+
148
+ | | Input | Output |
149
+ |---|---|---|
150
+ | JPEG | 8-bit RGB/gray (CMYK, 12-bit: kept as is) | MozJPEG, progressive |
151
+ | PNG | all incl. 16-bit, APNG (container-level only) | oxipng (lossless) |
152
+ | WebP | lossy, lossless, animated (container-level only) | lossy / lossless |
153
+ | AVIF | still (animated / rotated: kept as is) | libavif |
154
+ | GIF | static (converted), animated (kept as is) | – |
155
+ | HEIC/HEIF | detected → `UNSUPPORTED_FORMAT` | – |
156
+ | SVG | detected → returned unchanged, never rasterised | – |
157
+
158
+ Formats are detected from file content. Extensions and MIME types are ignored (a `.jpg` that is a PNG is a PNG).
159
+
160
+ ## Guarantees and limitations
161
+
162
+ - **Lossless** means decoded RGBA equality, verified per candidate, for the decoder Optiq uses. JPEG
163
+ decoders are not bit-exact across implementations, so “lossless” for a JPEG input is only ever a byte-level
164
+ container rewrite (metadata removal), never a re-encode.
165
+ - **Transparency** is never dropped: JPEG is excluded when any pixel has alpha < 255.
166
+ - **Orientation**: EXIF orientation is applied to the pixels of re-encoded outputs (tag reset to 1) and preserved in
167
+ container-level outputs.
168
+ - **Colour**: ICC profiles are carried into JPEG/PNG/WebP outputs. AVIF output is tagged sRGB and is not used for
169
+ images with a non-sRGB profile. sRGB profiles are dropped only with `preserveMetadata: false` (or for AVIF output,
170
+ where the `nclx` sRGB tag is equivalent). No colour conversion is performed.
171
+ - **Metadata**: kept by default. AVIF output is skipped when EXIF/XMP must be preserved (no AVIF metadata writer).
172
+ With `preserveMetadata: false`, EXIF (including GPS), XMP, comments and trailing data are removed.
173
+ - **Animation** is never silently flattened.
174
+ - **SSIM is a proxy for perception**, not perception: it under-weights banding in smooth gradients and chroma
175
+ bleeding. Validate thresholds on your own content.
176
+ - **Memory**: large images need several hundred MB inside the worker (see benchmarks). WASM memory is returned by
177
+ terminating the worker, which happens automatically after jobs above 8 MP.
178
+ - **Speed**: searching costs multiple encodes. Use `balanced`, lower `maxCandidates`, `searchStrategy: "fixed"`, or
179
+ drop AVIF from `autoFormats` when latency matters more than bytes.
180
+
181
+ ## Licensing
182
+
183
+ Optiq: Apache-2.0. Bundled codecs (via jSquash, Apache-2.0 wrappers): MozJPEG (IJG + BSD-3 + zlib),
184
+ libwebp (BSD-3), libavif (BSD-2) with libaom (BSD-2 + AOMedia patent licence), Rust `png`, `oxipng`, `resize`
185
+ (MIT/Apache-2.0). All permit commercial redistribution with notices. No GPL code is included
186
+ (libimagequant was rejected for that reason).
187
+
188
+ ## Benchmark results
189
+
190
+ 24 images (`bench/corpus/manifest.json`): 10 Kodak photos (8 JPEG q92, 2 PNG), 3 phone-style 12 MP JPEGs,
191
+ 2 screenshots, 2 text graphics, 4 transparent illustrations, 3 gradients. Apple M4 Pro (14 cores, 24 GB), macOS arm64, Node 24.15 and
192
+ headless Chromium 156. Every output is decoded with the same decoders and scored with the same SSIM against the
193
+ input. Full tables, per-image rows and the optimisation log: [`bench/results/RESULTS.md`](https://github.com/Baukaalm/optiq/blob/main/bench/results/RESULTS.md).
194
+
195
+ **Perceptual, all 24 images (Node; same encoders as the browser)**
196
+
197
+ | Tool | Total output | Reduction | Meets SSIM ≥ 0.975 | Median time | Median peak RSS |
198
+ |---|---|---|---|---|---|
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 |
201
+ | Optiq `balanced`, `preserveMetadata: false` | 13.25 → 4.73 MB | 64.3% | 24/24 | 1.5 s | 297 MB |
202
+ | Optiq `visually-lossless` | 13.25 → 6.38 MB | 51.8% | 24/24 (≥ 0.99: 24/24) | 2.3 s | 325 MB |
203
+ | libvips WebP q80 | 13.25 → 6.50 MB | 51.0% | 17/24 | 59 ms | 105 MB |
204
+ | libvips MozJPEG q80 (opaque only) | 12.82 → 6.87 MB | 46.4% | 11/20 | 54 ms | 106 MB |
205
+ | libvips AVIF q50 | 13.25 → 3.41 MB | 74.3% | 11/24 (photos: 0/10) | 590 ms | 151 MB |
206
+
207
+ **Browser (Chromium), against browser-image-compression 2.0.2 and canvas**
208
+
209
+ | Tool | Total output | Reduction | Meets SSIM ≥ 0.975 | Median time |
210
+ |---|---|---|---|---|
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 |
213
+ | browser-image-compression `initialQuality: 0.8` | 13.25 → 10.21 MB | 22.9% | 19/24 | 78 ms |
214
+ | browser-image-compression `fileType: webp, 0.8` | 13.25 → 6.52 MB | 50.8% | 18/24 | 37 ms |
215
+ | browser-image-compression `maxSizeMB: 1` | 13.25 → 7.14 MB | 46.1% | 21/24 (3 large photos were downscaled, not scored) | 128 ms |
216
+ | canvas WebP 0.8 | 13.25 → 6.52 MB | 50.8% | 18/24 | 36 ms |
217
+
218
+ What the numbers say:
219
+
220
+ - **Per image, no baseline dominated Optiq `balanced`** (smaller *and* higher SSIM) on any image.
221
+ `balanced` dominated MozJPEG q80 on 11/20, WebP q80 on 10/24, browser-image-compression WebP 0.8 on 10/24 and
222
+ AVIF q50 on 2/24; everything else is a trade-off.
223
+ - **AVIF q50 is much smaller on photos, but at SSIM 0.93–0.97**, below the 0.975 constraint on all 10 Kodak photos.
224
+ Optiq spends bytes to stay above the threshold; whether that threshold is right for you is a product decision.
225
+ - **Category matters.** Screenshots/text: −90.6% / −91.8% vs. −62.8% / −46.6% for WebP q80 (lossless WebP and
226
+ screen-content AVIF win there). Large 12 MP phone photos: −41.5% with metadata kept (AVIF excluded because EXIF
227
+ must be preserved), −47.7% with `preserveMetadata: false`, vs. −30.7% for WebP q80.
228
+ - **Optiq is slow and memory-hungry by comparison.** It runs several encodes plus decodes and SSIM per
229
+ image: 1–4 s typical, 3–4 s for 12 MP photos (8–12 s when AVIF is allowed), 16.6 s for a 5 MP screenshot in
230
+ `balanced` and 38 s in `visually-lossless`; median peak RSS ≈ 300 MB, up to 1.1 GB. Single canvas/libvips encodes
231
+ take 5–800 ms. Work runs in a worker, so the UI stays responsive (max main-thread gap 11 ms during a 12 MP job).
232
+
233
+ **Lossless** (`sc-lossless`, Node): pixel-exact on 24/24 and never larger than the input (total −20.4%).
234
+ PNG photos −27% (oxipng), screenshots −76% and text −91% (about the same as libvips lossless WebP effort 6),
235
+ transparent illustrations −42% and gradients −66%, where libvips PNG max compression reached −60% / −76% but its
236
+ outputs were not pixel-exact against the reference. JPEG inputs: only metadata can be removed losslessly (0% with
237
+ the default `preserveMetadata: true`; no jpegtran in this build).
238
+
239
+ ## Development
240
+
241
+ Toolchain: Node ≥ 20 (tested with 24.15), pnpm 11, Playwright Chromium (`pnpm exec playwright install chromium chromium-headless-shell`).
242
+ No Rust toolchain is required: all WASM comes prebuilt from pinned jSquash packages.
243
+
244
+ ```sh
245
+ pnpm install
246
+ pnpm typecheck
247
+ pnpm test # unit + integration tests in Node (real WASM codecs)
248
+ pnpm test:browser # builds, bundles a consumer page with Vite, runs it in Chromium (incl. CSP/no-WASM fallback)
249
+ pnpm test:demo # demo end-to-end (light/dark/mobile): compress → presign → PUT to mock S3
250
+ pnpm pack:check # pack the tarball, install it in a fresh Vite app, typecheck, build, run (does not publish)
251
+ pnpm demo # React + HeroUI demo with a mock presigned-S3 backend
252
+ pnpm docs:dev # VitePress documentation site
253
+ pnpm bench:corpus # download/render the benchmark corpus (24 images)
254
+ pnpm bench:node # Optiq vs libvips (sharp), with peak RSS per run
255
+ pnpm bench:browser # Optiq vs browser-image-compression and canvas, in Chromium
256
+ pnpm bench:report # writes bench/results/RESULTS.md
257
+ ```
258
+
259
+ Documentation site (guides, API reference generated from the TSDoc comments, benchmarks, architecture):
260
+ `pnpm docs:dev`, or read the Markdown in [`docs/`](https://github.com/Baukaalm/optiq/blob/main/docs/). Design notes: [docs/architecture.md](https://github.com/Baukaalm/optiq/blob/main/docs/architecture.md).
@@ -0,0 +1,6 @@
1
+ import type { OptiqOptions } from "./types.js";
2
+ export interface BatchRequest {
3
+ id: number;
4
+ path: string;
5
+ options: OptiqOptions;
6
+ }
@@ -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
+ });
@@ -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
+ }
@@ -0,0 +1,31 @@
1
+ import type { RawImage } from "./pixels.js";
2
+ import type { ImageCategory } from "./types.js";
3
+ export interface ImageFeatures {
4
+ sampledPixels: number;
5
+ /** Distinct RGBA values among sampled pixels (capped at 4096). */
6
+ uniqueColors: number;
7
+ /** Share of sampled pixels having the single most common colour (background). */
8
+ dominantColorShare: number;
9
+ /** Horizontally/vertically adjacent pairs with identical RGBA. */
10
+ flatShare: number;
11
+ /** Adjacent pairs with luma step > 64 (hard edges: text, UI lines). */
12
+ hardEdgeShare: number;
13
+ /** Adjacent non-identical pairs with luma step <= 3 (smooth ramps). */
14
+ smoothStepShare: number;
15
+ /** Pixels with alpha < 255. */
16
+ transparentShare: number;
17
+ }
18
+ /**
19
+ * Deterministic feature extraction on a grid of up to 24x24 patches of 16x16 contiguous pixels.
20
+ * Adjacency is only evaluated inside patches, so it reflects the full-resolution content without scanning every pixel.
21
+ */
22
+ export declare function extractFeatures(img: RawImage): ImageFeatures;
23
+ /**
24
+ * Heuristic rules with hand-set thresholds, checked against the test fixtures and the benchmark corpus (see docs/architecture.md);
25
+ * the category only steers which codecs/settings are tried first, never the quality decision.
26
+ */
27
+ export declare function classifyFeatures(f: ImageFeatures): ImageCategory;
28
+ export declare function classify(img: RawImage): {
29
+ category: ImageCategory;
30
+ features: ImageFeatures;
31
+ };