@lens-image/core 0.1.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 (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +244 -0
  3. package/dist/cjs/adapters/memory.d.ts +55 -0
  4. package/dist/cjs/adapters/memory.d.ts.map +1 -0
  5. package/dist/cjs/adapters/memory.js +84 -0
  6. package/dist/cjs/adapters/memory.js.map +1 -0
  7. package/dist/cjs/browser.d.ts +46 -0
  8. package/dist/cjs/browser.d.ts.map +1 -0
  9. package/dist/cjs/browser.js +79 -0
  10. package/dist/cjs/browser.js.map +1 -0
  11. package/dist/cjs/bytes.d.ts +18 -0
  12. package/dist/cjs/bytes.d.ts.map +1 -0
  13. package/dist/cjs/bytes.js +31 -0
  14. package/dist/cjs/bytes.js.map +1 -0
  15. package/dist/cjs/cache.d.ts +43 -0
  16. package/dist/cjs/cache.d.ts.map +1 -0
  17. package/dist/cjs/cache.js +77 -0
  18. package/dist/cjs/cache.js.map +1 -0
  19. package/dist/cjs/concurrency.d.ts +35 -0
  20. package/dist/cjs/concurrency.d.ts.map +1 -0
  21. package/dist/cjs/concurrency.js +82 -0
  22. package/dist/cjs/concurrency.js.map +1 -0
  23. package/dist/cjs/engines/index.d.ts +20 -0
  24. package/dist/cjs/engines/index.d.ts.map +1 -0
  25. package/dist/cjs/engines/index.js +38 -0
  26. package/dist/cjs/engines/index.js.map +1 -0
  27. package/dist/cjs/engines/passthrough.d.ts +41 -0
  28. package/dist/cjs/engines/passthrough.d.ts.map +1 -0
  29. package/dist/cjs/engines/passthrough.js +66 -0
  30. package/dist/cjs/engines/passthrough.js.map +1 -0
  31. package/dist/cjs/engines/sharp.d.ts +58 -0
  32. package/dist/cjs/engines/sharp.d.ts.map +1 -0
  33. package/dist/cjs/engines/sharp.js +243 -0
  34. package/dist/cjs/engines/sharp.js.map +1 -0
  35. package/dist/cjs/errors.d.ts +90 -0
  36. package/dist/cjs/errors.d.ts.map +1 -0
  37. package/dist/cjs/errors.js +97 -0
  38. package/dist/cjs/errors.js.map +1 -0
  39. package/dist/cjs/format.d.ts +33 -0
  40. package/dist/cjs/format.d.ts.map +1 -0
  41. package/dist/cjs/format.js +102 -0
  42. package/dist/cjs/format.js.map +1 -0
  43. package/dist/cjs/hash.d.ts +19 -0
  44. package/dist/cjs/hash.d.ts.map +1 -0
  45. package/dist/cjs/hash.js +43 -0
  46. package/dist/cjs/hash.js.map +1 -0
  47. package/dist/cjs/index.d.ts +56 -0
  48. package/dist/cjs/index.d.ts.map +1 -0
  49. package/dist/cjs/index.js +110 -0
  50. package/dist/cjs/index.js.map +1 -0
  51. package/dist/cjs/naming.d.ts +48 -0
  52. package/dist/cjs/naming.d.ts.map +1 -0
  53. package/dist/cjs/naming.js +95 -0
  54. package/dist/cjs/naming.js.map +1 -0
  55. package/dist/cjs/optimizer.d.ts +89 -0
  56. package/dist/cjs/optimizer.d.ts.map +1 -0
  57. package/dist/cjs/optimizer.js +667 -0
  58. package/dist/cjs/optimizer.js.map +1 -0
  59. package/dist/cjs/package.json +3 -0
  60. package/dist/cjs/retry.d.ts +55 -0
  61. package/dist/cjs/retry.d.ts.map +1 -0
  62. package/dist/cjs/retry.js +129 -0
  63. package/dist/cjs/retry.js.map +1 -0
  64. package/dist/cjs/server/handler.d.ts +80 -0
  65. package/dist/cjs/server/handler.d.ts.map +1 -0
  66. package/dist/cjs/server/handler.js +155 -0
  67. package/dist/cjs/server/handler.js.map +1 -0
  68. package/dist/cjs/sniff.d.ts +37 -0
  69. package/dist/cjs/sniff.d.ts.map +1 -0
  70. package/dist/cjs/sniff.js +442 -0
  71. package/dist/cjs/sniff.js.map +1 -0
  72. package/dist/cjs/source.d.ts +45 -0
  73. package/dist/cjs/source.d.ts.map +1 -0
  74. package/dist/cjs/source.js +221 -0
  75. package/dist/cjs/source.js.map +1 -0
  76. package/dist/cjs/srcset.d.ts +66 -0
  77. package/dist/cjs/srcset.d.ts.map +1 -0
  78. package/dist/cjs/srcset.js +81 -0
  79. package/dist/cjs/srcset.js.map +1 -0
  80. package/dist/cjs/types.d.ts +441 -0
  81. package/dist/cjs/types.d.ts.map +1 -0
  82. package/dist/cjs/types.js +12 -0
  83. package/dist/cjs/types.js.map +1 -0
  84. package/dist/cjs/validation.d.ts +38 -0
  85. package/dist/cjs/validation.d.ts.map +1 -0
  86. package/dist/cjs/validation.js +79 -0
  87. package/dist/cjs/validation.js.map +1 -0
  88. package/dist/esm/adapters/memory.d.ts +55 -0
  89. package/dist/esm/adapters/memory.d.ts.map +1 -0
  90. package/dist/esm/adapters/memory.js +80 -0
  91. package/dist/esm/adapters/memory.js.map +1 -0
  92. package/dist/esm/browser.d.ts +46 -0
  93. package/dist/esm/browser.d.ts.map +1 -0
  94. package/dist/esm/browser.js +49 -0
  95. package/dist/esm/browser.js.map +1 -0
  96. package/dist/esm/bytes.d.ts +18 -0
  97. package/dist/esm/bytes.d.ts.map +1 -0
  98. package/dist/esm/bytes.js +28 -0
  99. package/dist/esm/bytes.js.map +1 -0
  100. package/dist/esm/cache.d.ts +43 -0
  101. package/dist/esm/cache.d.ts.map +1 -0
  102. package/dist/esm/cache.js +73 -0
  103. package/dist/esm/cache.js.map +1 -0
  104. package/dist/esm/concurrency.d.ts +35 -0
  105. package/dist/esm/concurrency.d.ts.map +1 -0
  106. package/dist/esm/concurrency.js +78 -0
  107. package/dist/esm/concurrency.js.map +1 -0
  108. package/dist/esm/engines/index.d.ts +20 -0
  109. package/dist/esm/engines/index.d.ts.map +1 -0
  110. package/dist/esm/engines/index.js +30 -0
  111. package/dist/esm/engines/index.js.map +1 -0
  112. package/dist/esm/engines/passthrough.d.ts +41 -0
  113. package/dist/esm/engines/passthrough.d.ts.map +1 -0
  114. package/dist/esm/engines/passthrough.js +63 -0
  115. package/dist/esm/engines/passthrough.js.map +1 -0
  116. package/dist/esm/engines/sharp.d.ts +58 -0
  117. package/dist/esm/engines/sharp.d.ts.map +1 -0
  118. package/dist/esm/engines/sharp.js +206 -0
  119. package/dist/esm/engines/sharp.js.map +1 -0
  120. package/dist/esm/errors.d.ts +90 -0
  121. package/dist/esm/errors.d.ts.map +1 -0
  122. package/dist/esm/errors.js +91 -0
  123. package/dist/esm/errors.js.map +1 -0
  124. package/dist/esm/format.d.ts +33 -0
  125. package/dist/esm/format.d.ts.map +1 -0
  126. package/dist/esm/format.js +93 -0
  127. package/dist/esm/format.js.map +1 -0
  128. package/dist/esm/hash.d.ts +19 -0
  129. package/dist/esm/hash.d.ts.map +1 -0
  130. package/dist/esm/hash.js +38 -0
  131. package/dist/esm/hash.js.map +1 -0
  132. package/dist/esm/index.d.ts +56 -0
  133. package/dist/esm/index.d.ts.map +1 -0
  134. package/dist/esm/index.js +54 -0
  135. package/dist/esm/index.js.map +1 -0
  136. package/dist/esm/naming.d.ts +48 -0
  137. package/dist/esm/naming.d.ts.map +1 -0
  138. package/dist/esm/naming.js +88 -0
  139. package/dist/esm/naming.js.map +1 -0
  140. package/dist/esm/optimizer.d.ts +89 -0
  141. package/dist/esm/optimizer.d.ts.map +1 -0
  142. package/dist/esm/optimizer.js +663 -0
  143. package/dist/esm/optimizer.js.map +1 -0
  144. package/dist/esm/package.json +3 -0
  145. package/dist/esm/retry.d.ts +55 -0
  146. package/dist/esm/retry.d.ts.map +1 -0
  147. package/dist/esm/retry.js +123 -0
  148. package/dist/esm/retry.js.map +1 -0
  149. package/dist/esm/server/handler.d.ts +80 -0
  150. package/dist/esm/server/handler.d.ts.map +1 -0
  151. package/dist/esm/server/handler.js +151 -0
  152. package/dist/esm/server/handler.js.map +1 -0
  153. package/dist/esm/sniff.d.ts +37 -0
  154. package/dist/esm/sniff.d.ts.map +1 -0
  155. package/dist/esm/sniff.js +435 -0
  156. package/dist/esm/sniff.js.map +1 -0
  157. package/dist/esm/source.d.ts +45 -0
  158. package/dist/esm/source.d.ts.map +1 -0
  159. package/dist/esm/source.js +183 -0
  160. package/dist/esm/source.js.map +1 -0
  161. package/dist/esm/srcset.d.ts +66 -0
  162. package/dist/esm/srcset.d.ts.map +1 -0
  163. package/dist/esm/srcset.js +76 -0
  164. package/dist/esm/srcset.js.map +1 -0
  165. package/dist/esm/types.d.ts +441 -0
  166. package/dist/esm/types.d.ts.map +1 -0
  167. package/dist/esm/types.js +11 -0
  168. package/dist/esm/types.js.map +1 -0
  169. package/dist/esm/validation.d.ts +38 -0
  170. package/dist/esm/validation.d.ts.map +1 -0
  171. package/dist/esm/validation.js +74 -0
  172. package/dist/esm/validation.js.map +1 -0
  173. package/package.json +77 -0
@@ -0,0 +1,667 @@
1
+ "use strict";
2
+ /**
3
+ * The orchestrator: validate, plan, encode, upload, assemble.
4
+ *
5
+ * Deliberately the only stateful class in the library. Everything it calls -
6
+ * sniffing, validation, key building, retry, the engine, the adapter - is a
7
+ * pure function or an injected interface, which is what makes the whole thing
8
+ * testable without a codec or a network.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.ImageOptimizer = exports.DEFAULT_CACHE_CONTROL = void 0;
12
+ const node_crypto_1 = require("node:crypto");
13
+ const cache_js_1 = require("./cache.js");
14
+ const concurrency_js_1 = require("./concurrency.js");
15
+ const index_js_1 = require("./engines/index.js");
16
+ const errors_js_1 = require("./errors.js");
17
+ const format_js_1 = require("./format.js");
18
+ const hash_js_1 = require("./hash.js");
19
+ const naming_js_1 = require("./naming.js");
20
+ const retry_js_1 = require("./retry.js");
21
+ const sniff_js_1 = require("./sniff.js");
22
+ const source_js_1 = require("./source.js");
23
+ const srcset_js_1 = require("./srcset.js");
24
+ const validation_js_1 = require("./validation.js");
25
+ /** `public, max-age=31536000, immutable` - safe because keys are content-addressed. */
26
+ exports.DEFAULT_CACHE_CONTROL = 'public, max-age=31536000, immutable';
27
+ /**
28
+ * Optimizes images and hands them to a storage backend.
29
+ *
30
+ * Construct one per configuration and reuse it - it memoises engine detection
31
+ * and owns the result cache, so a fresh instance per request throws both away.
32
+ *
33
+ * @example Upload responsive WebP + JPEG to S3
34
+ * ```ts
35
+ * import { ImageOptimizer } from '@lens-image/core';
36
+ * import { S3Adapter } from '@lens-image/adapter-s3';
37
+ *
38
+ * const optimizer = new ImageOptimizer({
39
+ * adapter: new S3Adapter({ bucket: 'my-images', region: 'us-east-1' }),
40
+ * quality: 80,
41
+ * });
42
+ *
43
+ * const result = await optimizer.optimize({
44
+ * source: '/path/to/image.jpg',
45
+ * formats: ['webp', 'jpg'],
46
+ * sizes: [{ width: 1200 }, { width: 600 }, { width: 300 }],
47
+ * });
48
+ *
49
+ * result.formats.webp.urls['600w']; // 'https://my-images.s3.amazonaws.com/…-600w.webp'
50
+ * result.formats.webp.srcset; // ready for <source srcset={…}>
51
+ * ```
52
+ *
53
+ * @example Process without storing
54
+ * ```ts
55
+ * const optimizer = new ImageOptimizer(); // no adapter
56
+ * const { variants } = await optimizer.optimize({ source: bytes, formats: ['webp'] });
57
+ * await writeFile('out.webp', variants[0].data!); // bytes come back on the variant
58
+ * ```
59
+ */
60
+ class ImageOptimizer {
61
+ #options;
62
+ #cacheStore;
63
+ #cacheMode;
64
+ #engine;
65
+ constructor(options = {}) {
66
+ assertValidOptions(options);
67
+ this.#options = options;
68
+ this.#engine = options.engine ?? (0, index_js_1.detectEngine)();
69
+ const cache = options.cache ?? false;
70
+ if (cache === false) {
71
+ this.#cacheMode = 'off';
72
+ this.#cacheStore = cache_js_1.nullCacheStore;
73
+ }
74
+ else if (cache === 'memory' || cache === 'storage') {
75
+ this.#cacheMode = cache;
76
+ this.#cacheStore = new cache_js_1.MemoryCacheStore(options.cacheSize ?? 500);
77
+ }
78
+ else {
79
+ this.#cacheMode = 'custom';
80
+ this.#cacheStore = cache;
81
+ }
82
+ }
83
+ /** The storage backend, or `null` in process-only mode. */
84
+ get adapter() {
85
+ return this.#options.adapter ?? null;
86
+ }
87
+ /** Resolves the codec backend, running detection on first use. */
88
+ async engine() {
89
+ this.#engine = await this.#engine;
90
+ return this.#engine;
91
+ }
92
+ /**
93
+ * Processes one image into every requested format and size.
94
+ *
95
+ * Formats are independent: if AVIF encoding fails, the WebP and JPEG outputs
96
+ * still resolve and the failure is reported on `result.warnings` rather than
97
+ * thrown. Only a total failure - nothing at all was produced - rejects.
98
+ *
99
+ * @throws {LensError} `VALIDATION_FAILED`, `UNSUPPORTED_INPUT`,
100
+ * `ALL_FORMATS_FAILED`, `UPLOAD_FAILED`, `ABORTED`.
101
+ */
102
+ async optimize(options) {
103
+ const started = Date.now();
104
+ const runId = (0, node_crypto_1.randomUUID)();
105
+ const settings = { ...this.#options, ...stripUndefined(options) };
106
+ // Per-call options get the same scrutiny as constructor options; otherwise
107
+ // `optimize({ sizes: [{ width: -1 }] })` would sail past the checks that
108
+ // `new ImageOptimizer({ sizes: [...] })` performs.
109
+ assertValidOptions(settings);
110
+ const warnings = [];
111
+ const warn = (warning) => {
112
+ warnings.push(warning);
113
+ settings.onWarning?.(warning);
114
+ };
115
+ (0, errors_js_1.throwIfAborted)(options.signal, 'optimize');
116
+ // --- 1. Read and validate -------------------------------------------------
117
+ const policy = (0, validation_js_1.resolveValidation)(settings.validate);
118
+ const source = await (0, source_js_1.resolveSource)(options.source, {
119
+ ...(options.filename !== undefined ? { filename: options.filename } : {}),
120
+ ...(this.#options.allowRemote !== undefined ? { allowRemote: this.#options.allowRemote } : {}),
121
+ ...(policy ? { maxBytes: policy.maxBytes } : {}),
122
+ ...(options.signal ? { signal: options.signal } : {}),
123
+ });
124
+ const engine = await this.engine();
125
+ const meta = await this.#describe(source.data, engine, warn);
126
+ (0, validation_js_1.validateImage)(meta, policy, source.filename);
127
+ const checksum = (0, hash_js_1.sha256)(source.data);
128
+ const name = (0, naming_js_1.slugify)((0, source_js_1.stem)(source.filename));
129
+ // --- 2. Plan --------------------------------------------------------------
130
+ const plan = this.#plan(settings, meta, warn);
131
+ const available = await this.#filterSupported(plan, engine, settings, warn);
132
+ if (available.length === 0) {
133
+ throw new errors_js_1.LensError('ALL_FORMATS_FAILED', `No requested output format can be produced by the "${engine.name}" engine. ` +
134
+ (engine.name === 'passthrough'
135
+ ? 'Install sharp (`npm install sharp`) to enable format conversion and resizing.'
136
+ : `Requested: ${plan.map((p) => p.format).join(', ')}.`), { details: { engine: engine.name } });
137
+ }
138
+ // --- 3. Cache lookup ------------------------------------------------------
139
+ const id = this.#cacheId(checksum, settings, available);
140
+ if (!options.force && this.#cacheMode !== 'off') {
141
+ const hit = await this.#lookup(id, options);
142
+ if (hit)
143
+ return { ...hit, runId, cached: true, durationMs: Date.now() - started };
144
+ }
145
+ // --- 4. Encode and upload -------------------------------------------------
146
+ const keepData = options.keepData ?? this.adapter === null;
147
+ const concurrency = settings.concurrency ?? 4;
148
+ const settled = await (0, concurrency_js_1.mapLimit)(available, concurrency, (planned) => this.#produce(planned, {
149
+ bytes: source.data,
150
+ engine,
151
+ settings,
152
+ meta,
153
+ checksum,
154
+ name,
155
+ runId,
156
+ keepData,
157
+ ...(options.metadata ? { metadata: options.metadata } : {}),
158
+ ...(options.signal ? { signal: options.signal } : {}),
159
+ }), options.signal);
160
+ const variants = [];
161
+ const failedFormats = new Set();
162
+ for (const outcome of settled) {
163
+ const planned = available[outcome.index];
164
+ if (outcome.status === 'fulfilled') {
165
+ variants.push(outcome.value);
166
+ }
167
+ else {
168
+ failedFormats.add(planned.format);
169
+ warn({
170
+ code: errors_js_1.LensError.is(outcome.reason) ? outcome.reason.code.toLowerCase() : 'variant_failed',
171
+ message: `Failed to produce ${planned.format} at "${planned.label}": ${errorMessage(outcome.reason)}`,
172
+ format: planned.format,
173
+ label: planned.label,
174
+ cause: outcome.reason,
175
+ });
176
+ }
177
+ }
178
+ // --- 5. Fall back ---------------------------------------------------------
179
+ if (variants.length === 0) {
180
+ const fallback = await this.#tryFallback(settings, available, {
181
+ bytes: source.data,
182
+ engine,
183
+ meta,
184
+ checksum,
185
+ name,
186
+ runId,
187
+ keepData,
188
+ concurrency,
189
+ warn,
190
+ ...(options.metadata ? { metadata: options.metadata } : {}),
191
+ ...(options.signal ? { signal: options.signal } : {}),
192
+ });
193
+ variants.push(...fallback);
194
+ }
195
+ if (variants.length === 0) {
196
+ throw new errors_js_1.LensError('ALL_FORMATS_FAILED', `Every output failed for "${source.filename}". First failure: ${warnings[0]?.message ?? 'unknown'}`, { cause: warnings[0]?.cause, details: { engine: engine.name, warnings: warnings.length } });
197
+ }
198
+ // --- 6. Assemble ----------------------------------------------------------
199
+ const result = this.#assemble({
200
+ id,
201
+ runId,
202
+ engine,
203
+ variants,
204
+ warnings,
205
+ meta,
206
+ checksum,
207
+ filename: source.filename,
208
+ durationMs: Date.now() - started,
209
+ });
210
+ // Never cache a result that is carrying encoded bytes: a 500-entry LRU of
211
+ // results that each hold several megabytes of Uint8Array is a memory leak
212
+ // wearing a performance feature's clothes. The metadata is still cached,
213
+ // and re-encoding is the honest cost of asking for the bytes back.
214
+ if (this.#cacheMode !== 'off' && !keepData)
215
+ await this.#cacheStore.set(id, result);
216
+ return result;
217
+ }
218
+ /**
219
+ * Optimizes several images with the same options.
220
+ *
221
+ * Runs sequentially by design. Each `optimize` call already parallelises its
222
+ * own variants, so overlapping whole images multiplies peak memory by the
223
+ * number of images - and decoded bitmaps, not encoded files, are what fills
224
+ * the heap. Wrap in your own queue if you have the headroom.
225
+ */
226
+ async optimizeMany(sources, options = {}) {
227
+ const results = [];
228
+ for (const source of sources) {
229
+ results.push(await this.optimize({ ...options, source }));
230
+ }
231
+ return results;
232
+ }
233
+ /**
234
+ * Reads an image's metadata without producing anything.
235
+ *
236
+ * Uses the engine when one is available and falls back to header sniffing,
237
+ * so it works with or without sharp installed.
238
+ */
239
+ async inspect(source) {
240
+ const resolved = await (0, source_js_1.resolveSource)(source, {
241
+ ...(this.#options.allowRemote !== undefined ? { allowRemote: this.#options.allowRemote } : {}),
242
+ });
243
+ return this.#describe(resolved.data, await this.engine(), () => { });
244
+ }
245
+ /**
246
+ * Deletes every variant of a previous result from storage.
247
+ *
248
+ * @throws {LensError} `ADAPTER_REQUIRED` with no adapter configured,
249
+ * `ADAPTER_UNSUPPORTED` when the adapter cannot delete.
250
+ */
251
+ async delete(result) {
252
+ const adapter = this.adapter;
253
+ if (!adapter) {
254
+ throw new errors_js_1.LensError('ADAPTER_REQUIRED', 'delete() needs a storage adapter, but none is configured.');
255
+ }
256
+ if (!adapter.remove) {
257
+ throw new errors_js_1.LensError('ADAPTER_UNSUPPORTED', `The "${adapter.name}" adapter does not implement remove(), so delete() is unavailable.`, { details: { adapter: adapter.name } });
258
+ }
259
+ await Promise.all(result.variants.map((variant) => adapter.remove(variant.key)));
260
+ await this.#cacheStore.delete?.(result.id);
261
+ }
262
+ /** Releases adapter resources. Safe to call more than once. */
263
+ async dispose() {
264
+ await this.adapter?.dispose?.();
265
+ }
266
+ // -------------------------------------------------------------------------
267
+ // Internals
268
+ // -------------------------------------------------------------------------
269
+ /** Engine metadata when trustworthy, header sniffing otherwise. */
270
+ async #describe(bytes, engine, warn) {
271
+ const sniffed = (0, sniff_js_1.sniff)(bytes);
272
+ try {
273
+ const probed = await engine.probe(bytes);
274
+ // Trust the sniffer's format when the engine is unsure: sniffing reads the
275
+ // container directly and cannot be confused by a wrong file extension.
276
+ return {
277
+ ...probed,
278
+ format: probed.format === 'unknown' ? sniffed.format : probed.format,
279
+ width: probed.width || sniffed.width,
280
+ height: probed.height || sniffed.height,
281
+ size: bytes.byteLength,
282
+ };
283
+ }
284
+ catch (error) {
285
+ if (sniffed.format === 'unknown') {
286
+ throw (0, errors_js_1.wrapError)('UNSUPPORTED_INPUT', 'Could not identify this image', error);
287
+ }
288
+ warn({
289
+ code: 'probe_failed',
290
+ message: `The ${engine.name} engine could not read metadata; using header sniffing instead.`,
291
+ cause: error,
292
+ });
293
+ return sniffed;
294
+ }
295
+ }
296
+ /** Expands formats x sizes (plus the thumbnail) into a flat work list. */
297
+ #plan(settings, meta, warn) {
298
+ const formats = (0, format_js_1.normalizeFormats)(settings.formats ?? ['webp']);
299
+ const sizes = settings.sizes?.length ? settings.sizes : [{}];
300
+ // Animated sources lose every frame but the first through a plain resize,
301
+ // so unless the caller pinned a size we leave them alone.
302
+ const skipResize = meta.isAnimated === true && !settings.sizes?.length;
303
+ if (meta.isAnimated && settings.sizes?.length) {
304
+ warn({
305
+ code: 'animated_resize',
306
+ message: 'Resizing an animated image. Unless the engine is configured for animation, ' +
307
+ 'only the first frame survives.',
308
+ });
309
+ }
310
+ const planned = [];
311
+ for (const format of formats) {
312
+ for (const size of sizes) {
313
+ const hasDimensions = size.width !== undefined || size.height !== undefined;
314
+ planned.push({
315
+ format,
316
+ label: size.label ?? (0, naming_js_1.defaultLabel)(size.width, size.height),
317
+ quality: clampQuality(size.quality ?? settings.quality ?? (0, format_js_1.defaultQuality)(format)),
318
+ resize: hasDimensions && !skipResize ? toResizeSpec(size) : undefined,
319
+ isThumbnail: false,
320
+ });
321
+ }
322
+ }
323
+ assertUniqueLabels(planned);
324
+ const thumb = resolveThumbnail(settings.thumbnail, formats[0]);
325
+ if (thumb) {
326
+ planned.push({
327
+ format: thumb.format,
328
+ label: thumb.label,
329
+ quality: clampQuality(thumb.quality ?? settings.quality ?? (0, format_js_1.defaultQuality)(thumb.format)),
330
+ resize: thumb.resize,
331
+ isThumbnail: true,
332
+ });
333
+ }
334
+ return planned;
335
+ }
336
+ /** Drops formats the engine cannot encode, recording one warning per format. */
337
+ async #filterSupported(plan, engine, settings, warn) {
338
+ const formats = [...new Set(plan.map((p) => p.format))];
339
+ const support = new Map();
340
+ await Promise.all(formats.map(async (format) => {
341
+ try {
342
+ support.set(format, await engine.supports(format));
343
+ }
344
+ catch {
345
+ support.set(format, false);
346
+ }
347
+ }));
348
+ for (const [format, ok] of support) {
349
+ if (!ok) {
350
+ const fallback = settings.fallbackFormat === null ? null : (0, format_js_1.normalizeFormat)(settings.fallbackFormat ?? 'jpeg');
351
+ warn({
352
+ code: 'format_unsupported',
353
+ message: `The "${engine.name}" engine cannot encode ${format}` +
354
+ (format === 'avif' ? ' (common with prebuilt sharp binaries that ship without AV1)' : '') +
355
+ (fallback ? `; falling back to ${fallback} if nothing else succeeds.` : '.'),
356
+ format,
357
+ });
358
+ }
359
+ }
360
+ return plan.filter((p) => support.get(p.format) === true);
361
+ }
362
+ /** Encodes one variant and stores it. */
363
+ async #produce(planned, ctx, isFallback = false) {
364
+ (0, errors_js_1.throwIfAborted)(ctx.signal, 'encode');
365
+ const op = {
366
+ ...(planned.resize ? { resize: planned.resize } : {}),
367
+ format: planned.format,
368
+ quality: planned.quality,
369
+ preserveMetadata: ctx.settings.preserveMetadata ?? false,
370
+ autoOrient: ctx.settings.autoOrient ?? true,
371
+ ...((0, format_js_1.defaultEffort)(planned.format) !== undefined ? { effort: (0, format_js_1.defaultEffort)(planned.format) } : {}),
372
+ };
373
+ const encoded = await ctx.engine.transform(ctx.bytes, op);
374
+ (0, errors_js_1.throwIfAborted)(ctx.signal, 'upload');
375
+ const keyContext = {
376
+ name: ctx.name,
377
+ hash: (0, hash_js_1.shortHash)(ctx.checksum),
378
+ fullHash: ctx.checksum,
379
+ format: planned.format,
380
+ ext: (0, sniff_js_1.extensionFor)(planned.format),
381
+ width: encoded.width,
382
+ height: encoded.height,
383
+ quality: planned.quality,
384
+ label: planned.label,
385
+ };
386
+ const key = (0, naming_js_1.buildKey)(ctx.settings.key ?? naming_js_1.DEFAULT_KEY_TEMPLATE, keyContext, normalizePrefix(ctx.settings.prefix));
387
+ const base = {
388
+ format: encoded.format,
389
+ label: planned.label,
390
+ width: encoded.width,
391
+ height: encoded.height,
392
+ size: encoded.size,
393
+ quality: planned.quality,
394
+ contentType: (0, sniff_js_1.mimeTypeFor)(encoded.format),
395
+ checksum: (0, hash_js_1.sha256)(encoded.data),
396
+ ...(planned.isThumbnail ? { isThumbnail: true } : {}),
397
+ ...(isFallback ? { isFallback: true } : {}),
398
+ };
399
+ const adapter = this.adapter;
400
+ if (!adapter) {
401
+ // Process-only mode: the caller gets bytes and a key they can use however
402
+ // they like. No URL, because inventing one would be a lie.
403
+ return { ...base, key, url: '', data: encoded.data };
404
+ }
405
+ const file = {
406
+ key,
407
+ data: encoded.data,
408
+ contentType: base.contentType,
409
+ cacheControl: ctx.settings.cacheControl ?? exports.DEFAULT_CACHE_CONTROL,
410
+ checksum: base.checksum,
411
+ ...(ctx.metadata ? { metadata: ctx.metadata } : {}),
412
+ };
413
+ const retry = (0, retry_js_1.resolveRetry)(ctx.settings.retry);
414
+ const stored = await (0, retry_js_1.withRetry)(retry, (attempt) => adapter.upload(file, {
415
+ attempt,
416
+ runId: ctx.runId,
417
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
418
+ }), ctx.signal).catch((error) => {
419
+ throw (0, errors_js_1.wrapError)('UPLOAD_FAILED', `The "${adapter.name}" adapter failed to store "${key}"`, error, {
420
+ adapter: adapter.name,
421
+ key,
422
+ attempts: retry.attempts,
423
+ });
424
+ });
425
+ return {
426
+ ...base,
427
+ key: stored.key,
428
+ url: stored.url,
429
+ size: stored.size || encoded.size,
430
+ ...(stored.etag ? { etag: stored.etag } : {}),
431
+ ...(stored.meta ? { meta: stored.meta } : {}),
432
+ ...(ctx.keepData ? { data: encoded.data } : {}),
433
+ };
434
+ }
435
+ /** Last resort when every planned format failed. */
436
+ async #tryFallback(settings, attempted, ctx) {
437
+ if (settings.fallbackFormat === null)
438
+ return [];
439
+ const fallback = (0, format_js_1.normalizeFormat)(settings.fallbackFormat ?? 'jpeg');
440
+ if (attempted.some((p) => p.format === fallback))
441
+ return []; // Already tried; do not thrash.
442
+ const fallbackSupported = await Promise.resolve(ctx.engine.supports(fallback)).catch(() => false);
443
+ if (!fallbackSupported) {
444
+ ctx.warn({
445
+ code: 'fallback_unavailable',
446
+ message: `The fallback format ${fallback} is also unsupported by the "${ctx.engine.name}" engine.`,
447
+ format: fallback,
448
+ });
449
+ return [];
450
+ }
451
+ ctx.warn({
452
+ code: 'fallback_used',
453
+ message: `Every requested format failed; retrying as ${fallback}.`,
454
+ format: fallback,
455
+ });
456
+ // Preserve the size ladder, swap only the format.
457
+ const retryPlan = dedupeByLabel(attempted).map((p) => ({
458
+ ...p,
459
+ format: fallback,
460
+ quality: clampQuality(settings.quality ?? (0, format_js_1.defaultQuality)(fallback)),
461
+ }));
462
+ const settled = await (0, concurrency_js_1.mapLimit)(retryPlan, ctx.concurrency, (planned) => this.#produce(planned, {
463
+ bytes: ctx.bytes,
464
+ engine: ctx.engine,
465
+ settings,
466
+ meta: ctx.meta,
467
+ checksum: ctx.checksum,
468
+ name: ctx.name,
469
+ runId: ctx.runId,
470
+ keepData: ctx.keepData,
471
+ ...(ctx.metadata ? { metadata: ctx.metadata } : {}),
472
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
473
+ }, true), ctx.signal);
474
+ const produced = [];
475
+ for (const outcome of settled) {
476
+ if (outcome.status === 'fulfilled')
477
+ produced.push(outcome.value);
478
+ else {
479
+ ctx.warn({
480
+ code: 'fallback_failed',
481
+ message: `The ${fallback} fallback also failed: ${errorMessage(outcome.reason)}`,
482
+ format: fallback,
483
+ cause: outcome.reason,
484
+ });
485
+ }
486
+ }
487
+ return produced;
488
+ }
489
+ /** Groups variants and computes the summary numbers. */
490
+ #assemble(input) {
491
+ const thumbnail = input.variants.find((v) => v.isThumbnail === true);
492
+ const main = input.variants.filter((v) => v !== thumbnail);
493
+ const formats = {};
494
+ for (const variant of main) {
495
+ const group = (formats[variant.format] ??= {
496
+ format: variant.format,
497
+ urls: {},
498
+ variants: [],
499
+ size: 0,
500
+ srcset: '',
501
+ largest: variant,
502
+ smallest: variant,
503
+ });
504
+ group.variants.push(variant);
505
+ group.urls[variant.label] = variant.url;
506
+ }
507
+ for (const group of Object.values(formats)) {
508
+ const sorted = [...group.variants].sort((a, b) => a.width - b.width);
509
+ Object.assign(group, {
510
+ size: group.variants.reduce((sum, v) => sum + v.size, 0),
511
+ srcset: (0, srcset_js_1.buildSrcset)(group.variants),
512
+ smallest: sorted[0],
513
+ largest: sorted[sorted.length - 1],
514
+ });
515
+ }
516
+ const totalSize = input.variants.reduce((sum, v) => sum + v.size, 0);
517
+ return {
518
+ id: input.id,
519
+ runId: input.runId,
520
+ source: {
521
+ ...input.meta,
522
+ filename: input.filename,
523
+ checksum: input.checksum,
524
+ },
525
+ formats,
526
+ variants: input.variants,
527
+ ...(thumbnail ? { thumbnail } : {}),
528
+ warnings: input.warnings,
529
+ totalSize,
530
+ // Comparing total output against one input is apples to oranges when you
531
+ // asked for nine variants, so the headline number uses the largest single
532
+ // output - the one that would actually replace the original.
533
+ savings: computeSavings(input.meta.size, main),
534
+ durationMs: input.durationMs,
535
+ cached: false,
536
+ engine: input.engine.name,
537
+ adapter: this.adapter?.name ?? null,
538
+ };
539
+ }
540
+ /** Stable id for a (source, options) pair. */
541
+ #cacheId(checksum, settings, plan) {
542
+ return `${(0, hash_js_1.shortHash)(checksum, 16)}-${(0, hash_js_1.hashOptions)({
543
+ plan: plan.map((p) => [p.format, p.label, p.quality, p.resize]),
544
+ key: typeof settings.key === 'function' ? 'fn' : (settings.key ?? naming_js_1.DEFAULT_KEY_TEMPLATE),
545
+ prefix: settings.prefix ?? '',
546
+ preserveMetadata: settings.preserveMetadata ?? false,
547
+ autoOrient: settings.autoOrient ?? true,
548
+ adapter: this.adapter?.name ?? null,
549
+ })}`;
550
+ }
551
+ /** Cache read, including the optional storage existence check. */
552
+ async #lookup(id, options) {
553
+ const hit = await this.#cacheStore.get(id);
554
+ if (!hit)
555
+ return undefined;
556
+ if (this.#cacheMode === 'storage' && this.adapter?.exists) {
557
+ // A memory hit does not prove the object survived a bucket lifecycle rule
558
+ // or a manual delete, so confirm before handing back stale URLs.
559
+ const checks = await Promise.all(hit.variants.map((v) => this.adapter.exists(v.key, options.signal ? { signal: options.signal } : {}).catch(() => false)));
560
+ if (checks.some((ok) => !ok)) {
561
+ await this.#cacheStore.delete?.(id);
562
+ return undefined;
563
+ }
564
+ }
565
+ return hit;
566
+ }
567
+ }
568
+ exports.ImageOptimizer = ImageOptimizer;
569
+ // ---------------------------------------------------------------------------
570
+ // Helpers
571
+ // ---------------------------------------------------------------------------
572
+ function assertValidOptions(options) {
573
+ if (options.quality !== undefined && (options.quality < 1 || options.quality > 100)) {
574
+ throw new errors_js_1.LensError('INVALID_OPTIONS', `quality must be between 1 and 100, received ${options.quality}.`, { details: { actual: options.quality } });
575
+ }
576
+ if (options.concurrency !== undefined && options.concurrency < 1) {
577
+ throw new errors_js_1.LensError('INVALID_OPTIONS', `concurrency must be at least 1, received ${options.concurrency}.`, { details: { actual: options.concurrency } });
578
+ }
579
+ if (options.formats)
580
+ (0, format_js_1.normalizeFormats)(options.formats); // Throws on an unknown name.
581
+ if (options.fallbackFormat != null)
582
+ (0, format_js_1.normalizeFormat)(options.fallbackFormat);
583
+ for (const size of options.sizes ?? []) {
584
+ if (size.width === undefined && size.height === undefined && size.label === undefined)
585
+ continue;
586
+ for (const dim of ['width', 'height']) {
587
+ const value = size[dim];
588
+ if (value !== undefined && (!Number.isFinite(value) || value <= 0)) {
589
+ throw new errors_js_1.LensError('INVALID_OPTIONS', `sizes[].${dim} must be a positive number, received ${value}.`, { details: { actual: value } });
590
+ }
591
+ }
592
+ }
593
+ }
594
+ /**
595
+ * Refuses two sizes that would produce the same label within one format.
596
+ *
597
+ * They would map to the same output key, so the second silently overwrites the
598
+ * first and `formats[f].urls` quietly ends up one entry short. That is a
599
+ * configuration mistake worth stopping on rather than a degradation to warn
600
+ * about - `sizes: [{ width: 600 }, { width: 600, quality: 60 }]` looks
601
+ * reasonable until you notice only one file exists.
602
+ */
603
+ function assertUniqueLabels(plan) {
604
+ const seen = new Set();
605
+ for (const { format, label } of plan) {
606
+ const id = `${format}:${label}`;
607
+ if (seen.has(id)) {
608
+ throw new errors_js_1.LensError('INVALID_OPTIONS', `Two requested sizes both produce the label "${label}" for ${format}, so they would ` +
609
+ 'overwrite each other. Give one of them an explicit `label`.', { details: { format, label } });
610
+ }
611
+ seen.add(id);
612
+ }
613
+ }
614
+ function toResizeSpec(size) {
615
+ return {
616
+ ...(size.width !== undefined ? { width: Math.round(size.width) } : {}),
617
+ ...(size.height !== undefined ? { height: Math.round(size.height) } : {}),
618
+ fit: size.fit ?? 'cover',
619
+ position: size.position ?? 'center',
620
+ withoutEnlargement: size.withoutEnlargement ?? true,
621
+ ...(size.background ? { background: size.background } : {}),
622
+ };
623
+ }
624
+ function resolveThumbnail(spec, defaultFormat) {
625
+ if (!spec)
626
+ return null;
627
+ const config = spec === true ? {} : spec;
628
+ const width = config.width ?? (config.height ? undefined : 256);
629
+ return {
630
+ format: config.format ? (0, format_js_1.normalizeFormat)(config.format) : (defaultFormat ?? 'webp'),
631
+ label: config.label ?? 'thumb',
632
+ quality: config.quality,
633
+ resize: {
634
+ ...(width !== undefined ? { width } : {}),
635
+ ...(config.height !== undefined ? { height: config.height } : {}),
636
+ fit: config.fit ?? 'cover',
637
+ position: config.position ?? 'center',
638
+ withoutEnlargement: config.withoutEnlargement ?? true,
639
+ ...(config.background ? { background: config.background } : {}),
640
+ },
641
+ };
642
+ }
643
+ /** Keeps one entry per size label, so a fallback retry does not duplicate work. */
644
+ function dedupeByLabel(plan) {
645
+ const seen = new Set();
646
+ return plan.filter((p) => (seen.has(p.label) ? false : (seen.add(p.label), true)));
647
+ }
648
+ function computeSavings(sourceSize, variants) {
649
+ if (sourceSize <= 0 || variants.length === 0)
650
+ return 0;
651
+ const largest = variants.reduce((a, b) => (b.size > a.size ? b : a));
652
+ return Math.max(0, 1 - largest.size / sourceSize);
653
+ }
654
+ function clampQuality(value) {
655
+ return Math.min(100, Math.max(1, Math.round(value)));
656
+ }
657
+ function normalizePrefix(prefix) {
658
+ return prefix ? prefix.replace(/^\/+|\/+$/g, '') : '';
659
+ }
660
+ /** Per-call options must not clobber constructor defaults with `undefined`. */
661
+ function stripUndefined(value) {
662
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
663
+ }
664
+ function errorMessage(error) {
665
+ return error instanceof Error ? error.message : String(error);
666
+ }
667
+ //# sourceMappingURL=optimizer.js.map