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