@adonis-agora/media 0.20.0 → 0.22.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 (70) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/providers/media_provider.d.ts.map +1 -1
  3. package/dist/providers/media_provider.js +7 -0
  4. package/dist/providers/media_provider.js.map +1 -1
  5. package/dist/src/define_config.d.ts +22 -3
  6. package/dist/src/define_config.d.ts.map +1 -1
  7. package/dist/src/define_config.js +2 -2
  8. package/dist/src/define_config.js.map +1 -1
  9. package/dist/src/delivery.d.ts +46 -1
  10. package/dist/src/delivery.d.ts.map +1 -1
  11. package/dist/src/delivery.js +17 -1
  12. package/dist/src/delivery.js.map +1 -1
  13. package/dist/src/delivery_signing.d.ts +70 -0
  14. package/dist/src/delivery_signing.d.ts.map +1 -0
  15. package/dist/src/delivery_signing.js +82 -0
  16. package/dist/src/delivery_signing.js.map +1 -0
  17. package/dist/src/errors.d.ts +44 -1
  18. package/dist/src/errors.d.ts.map +1 -1
  19. package/dist/src/errors.js +70 -2
  20. package/dist/src/errors.js.map +1 -1
  21. package/dist/src/image_processor.d.ts +57 -1
  22. package/dist/src/image_processor.d.ts.map +1 -1
  23. package/dist/src/image_processor.js +16 -1
  24. package/dist/src/image_processor.js.map +1 -1
  25. package/dist/src/index.d.ts +10 -5
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/index.js +5 -3
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/media_collection.d.ts +27 -0
  30. package/dist/src/media_collection.d.ts.map +1 -1
  31. package/dist/src/media_collection.js +26 -1
  32. package/dist/src/media_collection.js.map +1 -1
  33. package/dist/src/media_library.d.ts +111 -2
  34. package/dist/src/media_library.d.ts.map +1 -1
  35. package/dist/src/media_library.js +361 -16
  36. package/dist/src/media_library.js.map +1 -1
  37. package/dist/src/media_manager.d.ts +8 -1
  38. package/dist/src/media_manager.d.ts.map +1 -1
  39. package/dist/src/media_manager.js +5 -0
  40. package/dist/src/media_manager.js.map +1 -1
  41. package/dist/src/processors/sharp.d.ts +7 -2
  42. package/dist/src/processors/sharp.d.ts.map +1 -1
  43. package/dist/src/processors/sharp.js +105 -6
  44. package/dist/src/processors/sharp.js.map +1 -1
  45. package/dist/src/responsive_image.d.ts +88 -0
  46. package/dist/src/responsive_image.d.ts.map +1 -0
  47. package/dist/src/responsive_image.js +109 -0
  48. package/dist/src/responsive_image.js.map +1 -0
  49. package/dist/src/testing/fake_image_processor.d.ts +3 -2
  50. package/dist/src/testing/fake_image_processor.d.ts.map +1 -1
  51. package/dist/src/testing/fake_image_processor.js +2 -2
  52. package/dist/src/testing/fake_image_processor.js.map +1 -1
  53. package/dist/src/transformers/factory.d.ts +39 -0
  54. package/dist/src/transformers/factory.d.ts.map +1 -1
  55. package/dist/src/transformers/factory.js +20 -0
  56. package/dist/src/transformers/factory.js.map +1 -1
  57. package/dist/src/transformers/ffmpeg.d.ts +104 -0
  58. package/dist/src/transformers/ffmpeg.d.ts.map +1 -0
  59. package/dist/src/transformers/ffmpeg.js +207 -0
  60. package/dist/src/transformers/ffmpeg.js.map +1 -0
  61. package/dist/src/transformers/hls.d.ts +4 -1
  62. package/dist/src/transformers/hls.d.ts.map +1 -1
  63. package/dist/src/transformers/hls.js.map +1 -1
  64. package/dist/src/transformers/placeholder.d.ts +8 -0
  65. package/dist/src/transformers/placeholder.d.ts.map +1 -1
  66. package/dist/src/transformers/placeholder.js +7 -3
  67. package/dist/src/transformers/placeholder.js.map +1 -1
  68. package/dist/src/transformers/probe.d.ts +1 -1
  69. package/dist/src/transformers/probe.d.ts.map +1 -1
  70. package/package.json +3 -3
@@ -1,9 +1,11 @@
1
1
  import type { Readable } from 'node:stream';
2
- import { type DeliveryMode, type DeliveryResult } from './delivery.js';
3
- import type { ImageProcessor } from './image_processor.js';
2
+ import { type DeliveryMode, type DeliveryResult, type OnDemandImageConfig } from './delivery.js';
3
+ import { type DeliverySigningConfig } from './delivery_signing.js';
4
+ import { type FocalPoint, type ImageProcessor } from './image_processor.js';
4
5
  import { type MediaCollectionConfig, MediaCollectionRegistry } from './media_collection.js';
5
6
  import type { MediaRecord } from './media_record.js';
6
7
  import type { MediaStore } from './media_store.js';
8
+ import { type ResponsiveImage, type ResponsiveImageOptions } from './responsive_image.js';
7
9
  import type { StorageManager } from './storage_manager.js';
8
10
  import type { SignedUrlOptions } from './types.js';
9
11
  export interface MediaLibraryOptions {
@@ -22,6 +24,12 @@ export interface MediaLibraryOptions {
22
24
  deliveryMode?: DeliveryMode;
23
25
  /** Signed-URL lifetime when delivery resolves to `signed`. Default 300s. */
24
26
  deliverySignedTtlSeconds?: number;
27
+ /** On-demand resizing for `deliver(id, { width })` (`delivery.images`). */
28
+ deliveryImages?: OnDemandImageConfig;
29
+ /** Signed delivery URLs (`delivery.signing`). */
30
+ deliverySigning?: DeliverySigningConfig;
31
+ /** Route the app mounts `MediaDeliveryHandler` on, with `:id` (`delivery.path`). Default `/media/:id`. */
32
+ deliveryPath?: string;
25
33
  }
26
34
  export interface AttachInput {
27
35
  ownerType: string;
@@ -88,6 +96,20 @@ export interface DeliverOptions {
88
96
  mode?: DeliveryMode | undefined;
89
97
  /** Override the configured signed-URL lifetime, when the resolved mode is `signed`. */
90
98
  signedTtlSeconds?: number | undefined;
99
+ /**
100
+ * Deliver the image resized to this width (see {@link MediaLibrary.resized}). Wins over
101
+ * `conversion`. Strings (a query-string value) are parsed and validated.
102
+ */
103
+ width?: number | string | undefined;
104
+ /** Quality of the resized image; defaults to the first of `delivery.images.qualities`. */
105
+ quality?: number | string | undefined;
106
+ /** The request's `Accept` header, to negotiate the resized image's format. */
107
+ accept?: string | undefined;
108
+ /**
109
+ * The request was authorized by a valid delivery signature (`MediaDeliveryHandler` sets this):
110
+ * any `width`/`quality` is served, not just the `delivery.images` whitelist — the app chose it.
111
+ */
112
+ trusted?: boolean | undefined;
91
113
  }
92
114
  /** Options for {@link MediaLibrary.signedUrl}: the disk's response headers, plus a conversion to sign instead of the original. */
93
115
  export interface MediaSignedUrlOptions extends Omit<SignedUrlOptions, 'expiresIn'> {
@@ -125,6 +147,9 @@ export declare class MediaLibrary {
125
147
  private readonly now;
126
148
  private readonly deliveryMode;
127
149
  private readonly deliverySignedTtlSeconds;
150
+ private readonly deliveryImages;
151
+ private readonly deliverySigning;
152
+ private readonly deliveryPath;
128
153
  constructor(options: MediaLibraryOptions);
129
154
  private emit;
130
155
  attach(input: AttachInput): Promise<MediaRecord>;
@@ -151,6 +176,40 @@ export declare class MediaLibrary {
151
176
  attachExisting(input: AttachExistingInput): Promise<MediaRecord>;
152
177
  /** Generate (if absent) and persist a named conversion; returns the updated record. Lazy entry point. */
153
178
  ensureConversion(id: string, conversionName: string): Promise<MediaRecord>;
179
+ /**
180
+ * Set (or clear, with `null`) the part of the image every crop must keep — a face in an avatar,
181
+ * the subject of a banner. Stored as `customProperties.focalPoint` (`{ x, y }`, fractions of the
182
+ * displayed image), handed to the processor for every cropping preset (`fit: 'cover'` with both
183
+ * sides), and returned by {@link image} so CSS crops (`object-fit: cover`) keep it too.
184
+ *
185
+ * The cropping conversions already generated are dropped — the eager ones regenerated at once,
186
+ * the lazy ones on next access — since they were cut around the old point.
187
+ *
188
+ * ```ts
189
+ * await media.library.setFocalPoint(user.avatarMediaId, { x: 0.42, y: 0.31 })
190
+ * ```
191
+ */
192
+ setFocalPoint(id: string, point: FocalPoint | null): Promise<MediaRecord>;
193
+ /**
194
+ * Resize the image to one of `delivery.images.widths`, on demand — the server half of a
195
+ * `next/image`-style loader (`?w=640&q=75`). Generated once and kept as the conversion
196
+ * `@w640q75`, so later requests (and `delete()`) treat it like any other conversion. Never
197
+ * enlarges: past the original's width it is the original's size, re-encoded.
198
+ *
199
+ * Returns the conversion to deliver, or `undefined` for an SVG (served as is: it scales by itself).
200
+ * Throws {@link ImageSizeNotAllowedError} outside the whitelist and {@link NotAnImageError} for a
201
+ * record that is not an image.
202
+ */
203
+ resized(id: string, request: {
204
+ width: number | string;
205
+ quality?: number | string | undefined;
206
+ accept?: string | undefined;
207
+ /** Signed by the app: any width/quality in range, not just the whitelist. */
208
+ trusted?: boolean | undefined;
209
+ }): Promise<{
210
+ record: MediaRecord;
211
+ conversion: string | undefined;
212
+ }>;
154
213
  /**
155
214
  * Run a named {@link Transformer} of the record's collection and persist its output as
156
215
  * `record.conversions[name]`. **Idempotent**: an already-generated conversion returns the record
@@ -188,6 +247,56 @@ export declare class MediaLibrary {
188
247
  * requested and not yet generated, it is produced lazily (and cached) first.
189
248
  */
190
249
  url(id: string, conversion?: string): Promise<string>;
250
+ /**
251
+ * Everything an `<img>` needs to render a stored image like `next/image`: `srcSet` from the
252
+ * collection's presets, the intrinsic `width`/`height` and the blur-up `placeholder` — as plain
253
+ * JSON, for a page prop. Render it with `<MediaImage>` from `@adonis-agora/media-react`.
254
+ *
255
+ * ```ts
256
+ * // config: presets at a few widths (eager, so they exist from the attach on) + the placeholder
257
+ * { name: 'banner', conversions: [{ name: 'sm', width: 640, eager: true }, { name: 'lg', width: 1280, eager: true }],
258
+ * transformers: [transformers.placeholder()] }
259
+ *
260
+ * return inertia.render('courses/show', { banner: await media.library.image(course.bannerMediaId) })
261
+ * ```
262
+ *
263
+ * The intrinsic size and the original-as-candidate come from the placeholder, so without
264
+ * `transformers.placeholder()` the result has no `width`/`height` and only the presets in
265
+ * `srcSet`. Like {@link url}, this answers "how", not "who": authorize the caller first.
266
+ */
267
+ image(id: string, options?: ResponsiveImageOptions): Promise<ResponsiveImage>;
268
+ /**
269
+ * URL of the delivery route (`delivery.path`, default `/media/:id`) for a record — optionally a
270
+ * conversion, or resized (`width`/`quality`). With `delivery.signing` configured it is signed and
271
+ * expires, and `MediaDeliveryHandler` serves exactly what was signed (any width) and nothing else;
272
+ * without it, a plain route URL (widths limited to `delivery.images`).
273
+ *
274
+ * ```ts
275
+ * media.library.deliveryUrl(id) // /media/<id>?exp=…&sig=…
276
+ * media.library.deliveryUrl(id, { width: 640 }) // /media/<id>?w=640&exp=…&sig=…
277
+ * ```
278
+ */
279
+ deliveryUrl(id: string, options?: {
280
+ conversion?: string;
281
+ width?: number;
282
+ quality?: number;
283
+ /** Seconds; overrides `delivery.signing.expiresIn`. */
284
+ expiresIn?: number;
285
+ }): string;
286
+ /**
287
+ * Check a delivery request against `delivery.signing`: `true` when it is signed and valid (serve
288
+ * any size it names), `false` when signing is off or the request is unsigned and that is allowed.
289
+ * Throws {@link DeliverySignatureError} for an invalid or expired signature, or a missing one when
290
+ * `delivery.signing.required` (the default). `MediaDeliveryHandler` calls this for you.
291
+ */
292
+ checkDeliverySignature(request: {
293
+ mediaId: string;
294
+ conversion?: string | undefined;
295
+ width?: number | string | undefined;
296
+ quality?: number | string | undefined;
297
+ expires?: number | string | undefined;
298
+ signature?: string | undefined;
299
+ }): boolean;
191
300
  /**
192
301
  * Resolve how a media record should reach the client, per the configured `delivery.mode` — the
193
302
  * read-side counterpart to `uploads.mode`. Returns either a URL to redirect to (`public`/`signed`)
@@ -1 +1 @@
1
- {"version":3,"file":"media_library.d.ts","sourceRoot":"","sources":["../../src/media_library.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"media_library.d.ts","sourceRoot":"","sources":["../../src/media_library.ts"],"names":[],"mappings":"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"}
@@ -1,13 +1,17 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { SIGNATURE_HEAD_BYTES, verifyContentAgainstWhitelist } from './content_type.js';
3
3
  import { asBuffer, peekContents, peekStream, toBytes } from './contents.js';
4
- import { DEFAULT_DELIVERY_SIGNED_TTL_SECONDS, resolveDeliveryMode, } from './delivery.js';
4
+ import { DEFAULT_DELIVERY_SIGNED_TTL_SECONDS, DEFAULT_IMAGE_QUALITIES, DEFAULT_IMAGE_WIDTHS, resolveDeliveryMode, } from './delivery.js';
5
+ import { DEFAULT_DELIVERY_SIGNING_TTL_SECONDS, deliveryExpiry, deliveryQueryString, signDelivery, verifyDelivery, } from './delivery_signing.js';
5
6
  import { publishMedia } from './diagnostics.js';
6
- import { ContentSignatureUnrecognizedError, ContentTypeMismatchError, ConversionArtifactMissingError, ConversionNotDefinedError, ImageProcessorMissingError, MediaNotFoundError, MediaObjectMissingError, MimeNotAllowedError, TransformerNotDefinedError, TransformerOutputError, TransformNotReadyError, UploadNotSupportedError, } from './errors.js';
7
+ import { ContentSignatureUnrecognizedError, ContentTypeMismatchError, ConversionArtifactMissingError, ConversionNotDefinedError, DeliverySignatureError, ImageProcessorMissingError, ImageSizeNotAllowedError, MediaNotFoundError, MediaObjectMissingError, MimeNotAllowedError, NotAnImageError, TransformerNotDefinedError, TransformerOutputError, TransformNotReadyError, UploadNotSupportedError, } from './errors.js';
7
8
  import { isExtendedDisk } from './extended_disk.js';
8
9
  import { sanitizeFileName } from './file_name.js';
10
+ import { focalPointOf, presetCrops, } from './image_processor.js';
9
11
  import { MediaCollectionRegistry } from './media_collection.js';
10
12
  import { resolveDeclaredMimeType } from './mime_type.js';
13
+ import { BROWSER_IMAGE_TYPES, imageCandidates, } from './responsive_image.js';
14
+ import { placeholderOf } from './transformers/placeholder.js';
11
15
  export class MediaLibrary {
12
16
  /**
13
17
  * The storage façade the library resolves disks through. Public (read-only) so delivery
@@ -29,6 +33,9 @@ export class MediaLibrary {
29
33
  now;
30
34
  deliveryMode;
31
35
  deliverySignedTtlSeconds;
36
+ deliveryImages;
37
+ deliverySigning;
38
+ deliveryPath;
32
39
  constructor(options) {
33
40
  this.storage = options.storage;
34
41
  this.store = options.store;
@@ -40,6 +47,13 @@ export class MediaLibrary {
40
47
  this.deliveryMode = options.deliveryMode ?? 'auto';
41
48
  this.deliverySignedTtlSeconds =
42
49
  options.deliverySignedTtlSeconds ?? DEFAULT_DELIVERY_SIGNED_TTL_SECONDS;
50
+ this.deliveryImages = {
51
+ widths: options.deliveryImages?.widths ?? DEFAULT_IMAGE_WIDTHS,
52
+ qualities: options.deliveryImages?.qualities ?? DEFAULT_IMAGE_QUALITIES,
53
+ formats: options.deliveryImages?.formats ?? ['webp'],
54
+ };
55
+ this.deliverySigning = options.deliverySigning;
56
+ this.deliveryPath = options.deliveryPath ?? '/media/:id';
43
57
  }
44
58
  emit(event, payload) {
45
59
  if (this.emitDiagnostics)
@@ -208,9 +222,13 @@ export class MediaLibrary {
208
222
  const { path } = input;
209
223
  let saved;
210
224
  try {
225
+ const strippedSize = config.stripMetadata
226
+ ? await this.#stripMetadata(target, path, input.mimeType)
227
+ : undefined;
211
228
  // `getMetaData` is a HEAD-shaped metadata read, never a body download — the zero-copy
212
- // guarantee of `attachExisting` holds even when the caller omits `size`.
213
- const size = input.size ?? (await target.getMetaData(path)).contentLength;
229
+ // guarantee of `attachExisting` holds even when the caller omits `size` (and the collection
230
+ // does not `stripMetadata`, which has to read the bytes).
231
+ const size = strippedSize ?? input.size ?? (await target.getMetaData(path)).contentLength;
214
232
  // A single-file collection holds exactly one item, so its order resets to 0 on replace.
215
233
  const order = config.single
216
234
  ? 0
@@ -321,26 +339,148 @@ export class MediaLibrary {
321
339
  }
322
340
  throw new ConversionNotDefinedError(record.collection, conversionName);
323
341
  }
342
+ return this.#generateConversion(record, preset);
343
+ }
344
+ /** Run `preset` over the record's original, store the output and persist it as a conversion. */
345
+ async #generateConversion(record, preset) {
324
346
  if (!this.imageProcessor)
325
347
  throw new ImageProcessorMissingError();
326
348
  const original = asBuffer(await this.storage.disk(record.disk).getBytes(record.path));
327
- const result = await this.imageProcessor.convert(original, preset);
349
+ if (preset.onlyDownscale && preset.width && this.imageProcessor.dimensions) {
350
+ const size = await this.imageProcessor.dimensions(original);
351
+ if (size && size.width <= preset.width) {
352
+ return this.#recordConversion(record, preset.name, {
353
+ meta: { skipped: 'not-smaller-than-original', width: size.width, height: size.height },
354
+ });
355
+ }
356
+ }
357
+ const focalPoint = focalPointOf(record.customProperties);
358
+ const result = await this.imageProcessor.convert(original, preset, focalPoint ? { focalPoint } : {});
328
359
  const dir = record.path.slice(0, record.path.lastIndexOf('/'));
329
- const conversionPath = `${dir}/conversions/${conversionName}.${result.format}`;
360
+ const conversionPath = `${dir}/conversions/${preset.name}.${result.format}`;
330
361
  await this.storage.disk(record.disk).put(conversionPath, result.data, {
331
362
  contentType: result.contentType,
332
363
  });
333
- const updated = await this.store.save({
364
+ const updated = await this.#recordConversion(record, preset.name, {
365
+ path: conversionPath,
366
+ disk: record.disk,
367
+ ...(result.width && result.height
368
+ ? { meta: { width: result.width, height: result.height } }
369
+ : {}),
370
+ });
371
+ this.emit('conversion', { id: record.id, conversion: preset.name, path: conversionPath });
372
+ return updated;
373
+ }
374
+ /**
375
+ * Rewrite the object at `path` without its metadata (`stripMetadata` collections). Returns the
376
+ * new size, or `undefined` when nothing was rewritten (not an image, nothing to strip).
377
+ */
378
+ async #stripMetadata(target, path, mimeType) {
379
+ if (!mimeType.startsWith('image/') || mimeType.startsWith('image/svg'))
380
+ return undefined;
381
+ if (!this.imageProcessor)
382
+ throw new ImageProcessorMissingError();
383
+ if (!this.imageProcessor.stripMetadata)
384
+ return undefined;
385
+ const original = asBuffer(await target.getBytes(path));
386
+ const stripped = await this.imageProcessor.stripMetadata(original);
387
+ if (!stripped)
388
+ return undefined;
389
+ await target.put(path, stripped, { contentType: mimeType });
390
+ return stripped.byteLength;
391
+ }
392
+ /** Persist one conversion entry, re-reading first so a concurrent one is not dropped. */
393
+ async #recordConversion(record, name, conversion) {
394
+ const current = (await this.store.find(record.id)) ?? record;
395
+ return this.store.save({
396
+ ...current,
397
+ conversions: { ...current.conversions, [name]: conversion },
398
+ updatedAt: this.now(),
399
+ });
400
+ }
401
+ /**
402
+ * Set (or clear, with `null`) the part of the image every crop must keep — a face in an avatar,
403
+ * the subject of a banner. Stored as `customProperties.focalPoint` (`{ x, y }`, fractions of the
404
+ * displayed image), handed to the processor for every cropping preset (`fit: 'cover'` with both
405
+ * sides), and returned by {@link image} so CSS crops (`object-fit: cover`) keep it too.
406
+ *
407
+ * The cropping conversions already generated are dropped — the eager ones regenerated at once,
408
+ * the lazy ones on next access — since they were cut around the old point.
409
+ *
410
+ * ```ts
411
+ * await media.library.setFocalPoint(user.avatarMediaId, { x: 0.42, y: 0.31 })
412
+ * ```
413
+ */
414
+ async setFocalPoint(id, point) {
415
+ const record = await this.store.find(id);
416
+ if (!record)
417
+ throw new MediaNotFoundError(id);
418
+ if (point !== null && !focalPointOf({ focalPoint: point })) {
419
+ throw new RangeError(`Focal point must be { x, y } with both between 0 and 1, got ${JSON.stringify(point)}.`);
420
+ }
421
+ const { focalPoint: _previous, ...rest } = record.customProperties;
422
+ const presets = this.collections.get(record.collection).conversions ?? [];
423
+ const cropping = presets.filter((preset) => presetCrops(preset));
424
+ const conversions = { ...record.conversions };
425
+ for (const preset of cropping) {
426
+ const stale = conversions[preset.name];
427
+ if (stale?.path && stale.disk)
428
+ await this.storage.disk(stale.disk).delete(stale.path);
429
+ delete conversions[preset.name];
430
+ }
431
+ let updated = await this.store.save({
334
432
  ...record,
335
- conversions: {
336
- ...record.conversions,
337
- [conversionName]: { path: conversionPath, disk: record.disk },
338
- },
433
+ customProperties: point ? { ...rest, focalPoint: { x: point.x, y: point.y } } : rest,
434
+ conversions,
339
435
  updatedAt: this.now(),
340
436
  });
341
- this.emit('conversion', { id, conversion: conversionName, path: conversionPath });
437
+ for (const preset of cropping) {
438
+ if (preset.eager)
439
+ updated = await this.#generateConversion(updated, preset);
440
+ }
342
441
  return updated;
343
442
  }
443
+ /**
444
+ * Resize the image to one of `delivery.images.widths`, on demand — the server half of a
445
+ * `next/image`-style loader (`?w=640&q=75`). Generated once and kept as the conversion
446
+ * `@w640q75`, so later requests (and `delete()`) treat it like any other conversion. Never
447
+ * enlarges: past the original's width it is the original's size, re-encoded.
448
+ *
449
+ * Returns the conversion to deliver, or `undefined` for an SVG (served as is: it scales by itself).
450
+ * Throws {@link ImageSizeNotAllowedError} outside the whitelist and {@link NotAnImageError} for a
451
+ * record that is not an image.
452
+ */
453
+ async resized(id, request) {
454
+ const { widths, qualities, formats } = this.deliveryImages;
455
+ const width = request.trusted
456
+ ? numberInRange('width', request.width, 1, MAX_SIGNED_WIDTH)
457
+ : allowedNumber('width', request.width, widths);
458
+ const quality = request.quality === undefined || request.quality === ''
459
+ ? qualities[0]
460
+ : request.trusted
461
+ ? numberInRange('quality', request.quality, 1, 100)
462
+ : allowedNumber('quality', request.quality, qualities);
463
+ const record = await this.store.find(id);
464
+ if (!record)
465
+ throw new MediaNotFoundError(id);
466
+ if (record.mimeType.startsWith('image/svg'))
467
+ return { record, conversion: undefined };
468
+ if (!record.mimeType.startsWith('image/'))
469
+ throw new NotAnImageError(id, record.mimeType);
470
+ const format = negotiateFormat(request.accept, formats, record.mimeType);
471
+ const name = `@w${width}q${quality}-${format}`;
472
+ if (record.conversions[name])
473
+ return { record, conversion: name };
474
+ const updated = await this.#generateConversion(record, {
475
+ name,
476
+ width,
477
+ quality,
478
+ format,
479
+ fit: 'inside',
480
+ withoutEnlargement: true,
481
+ });
482
+ return { record: updated, conversion: name };
483
+ }
344
484
  /**
345
485
  * Run a named {@link Transformer} of the record's collection and persist its output as
346
486
  * `record.conversions[name]`. **Idempotent**: an already-generated conversion returns the record
@@ -589,6 +729,160 @@ export class MediaLibrary {
589
729
  const target = await this.#resolveTarget(id, conversion);
590
730
  return this.storage.disk(target.disk).getUrl(target.path);
591
731
  }
732
+ /**
733
+ * Everything an `<img>` needs to render a stored image like `next/image`: `srcSet` from the
734
+ * collection's presets, the intrinsic `width`/`height` and the blur-up `placeholder` — as plain
735
+ * JSON, for a page prop. Render it with `<MediaImage>` from `@adonis-agora/media-react`.
736
+ *
737
+ * ```ts
738
+ * // config: presets at a few widths (eager, so they exist from the attach on) + the placeholder
739
+ * { name: 'banner', conversions: [{ name: 'sm', width: 640, eager: true }, { name: 'lg', width: 1280, eager: true }],
740
+ * transformers: [transformers.placeholder()] }
741
+ *
742
+ * return inertia.render('courses/show', { banner: await media.library.image(course.bannerMediaId) })
743
+ * ```
744
+ *
745
+ * The intrinsic size and the original-as-candidate come from the placeholder, so without
746
+ * `transformers.placeholder()` the result has no `width`/`height` and only the presets in
747
+ * `srcSet`. Like {@link url}, this answers "how", not "who": authorize the caller first.
748
+ */
749
+ async image(id, options = {}) {
750
+ let record = await this.store.find(id);
751
+ if (!record)
752
+ throw new MediaNotFoundError(id);
753
+ if (options.onDemand)
754
+ return this.#onDemandImage(record, options.onDemand);
755
+ const presets = this.collections.get(record.collection).conversions ?? [];
756
+ if (options.generate) {
757
+ for (const preset of presets) {
758
+ if (options.conversions && !options.conversions.includes(preset.name))
759
+ continue;
760
+ if (!preset.blur && !record.conversions[preset.name]) {
761
+ record = await this.ensureConversion(id, preset.name);
762
+ }
763
+ }
764
+ }
765
+ const urlOf = (conversion) => {
766
+ if (options.url)
767
+ return options.url(conversion);
768
+ if (options.expiresIn !== undefined) {
769
+ return this.signedUrl(id, options.expiresIn, conversion ? { conversion } : {});
770
+ }
771
+ // With signing configured, nothing goes out as a storage URL: everything through the route.
772
+ if (this.deliverySigning)
773
+ return this.deliveryUrl(id, conversion ? { conversion } : {});
774
+ return this.url(id, conversion);
775
+ };
776
+ const candidates = imageCandidates(record, presets, options.conversions);
777
+ const urls = await Promise.all(candidates.map((c) => urlOf(c.conversion)));
778
+ const widest = urls.at(-1);
779
+ const placeholder = placeholderOf(record);
780
+ // An explicit group (an art-directed `<source>`) reports its own size — a square crop is not
781
+ // the original's shape.
782
+ const groupSize = options.conversions
783
+ ? [...candidates].reverse().find((c) => c.height !== undefined)
784
+ : undefined;
785
+ const size = groupSize
786
+ ? { width: groupSize.width, height: groupSize.height }
787
+ : placeholder
788
+ ? { width: placeholder.width, height: placeholder.height }
789
+ : undefined;
790
+ return {
791
+ src: widest ?? (await urlOf(undefined)),
792
+ ...(candidates.length > 0
793
+ ? { srcSet: candidates.map((c, i) => `${urls[i]} ${c.width}w`).join(', ') }
794
+ : {}),
795
+ ...(size ?? {}),
796
+ ...(placeholder
797
+ ? { placeholder: { dataUrl: placeholder.dataUrl, color: placeholder.color } }
798
+ : {}),
799
+ ...focalPointProp(record),
800
+ };
801
+ }
802
+ /** {@link image} with `onDemand`: candidates are delivery-route URLs resized per width. */
803
+ #onDemandImage(record, onDemand) {
804
+ const settings = onDemand === true ? {} : onDemand;
805
+ const placeholder = placeholderOf(record);
806
+ const extra = {
807
+ ...(placeholder
808
+ ? {
809
+ width: placeholder.width,
810
+ height: placeholder.height,
811
+ placeholder: { dataUrl: placeholder.dataUrl, color: placeholder.color },
812
+ }
813
+ : {}),
814
+ ...focalPointProp(record),
815
+ };
816
+ const original = this.deliveryUrl(record.id);
817
+ // Not resizable (an SVG scales itself; a non-image has no widths): the route serves it as is.
818
+ if (!record.mimeType.startsWith('image/') || record.mimeType.startsWith('image/svg')) {
819
+ return { src: original, ...extra };
820
+ }
821
+ const originalWidth = placeholder?.width;
822
+ const widths = [...(settings.widths ?? this.deliveryImages.widths)]
823
+ .filter((w) => (originalWidth === undefined ? w >= 256 : w < originalWidth))
824
+ .sort((a, b) => a - b);
825
+ const candidates = widths.map((width) => ({
826
+ url: this.deliveryUrl(record.id, {
827
+ width,
828
+ ...(settings.quality !== undefined ? { quality: settings.quality } : {}),
829
+ }),
830
+ width,
831
+ }));
832
+ // The original, full size, as the widest — when its width is known and browsers decode it.
833
+ if (originalWidth !== undefined && BROWSER_IMAGE_TYPES.has(record.mimeType)) {
834
+ candidates.push({ url: original, width: originalWidth });
835
+ }
836
+ if (candidates.length === 0)
837
+ return { src: original, ...extra };
838
+ return {
839
+ src: candidates.at(-1).url,
840
+ srcSet: candidates.map((c) => `${c.url} ${c.width}w`).join(', '),
841
+ ...extra,
842
+ };
843
+ }
844
+ /**
845
+ * URL of the delivery route (`delivery.path`, default `/media/:id`) for a record — optionally a
846
+ * conversion, or resized (`width`/`quality`). With `delivery.signing` configured it is signed and
847
+ * expires, and `MediaDeliveryHandler` serves exactly what was signed (any width) and nothing else;
848
+ * without it, a plain route URL (widths limited to `delivery.images`).
849
+ *
850
+ * ```ts
851
+ * media.library.deliveryUrl(id) // /media/<id>?exp=…&sig=…
852
+ * media.library.deliveryUrl(id, { width: 640 }) // /media/<id>?w=640&exp=…&sig=…
853
+ * ```
854
+ */
855
+ deliveryUrl(id, options = {}) {
856
+ const path = this.deliveryPath.replace(':id', encodeURIComponent(id));
857
+ const { conversion, width, quality } = options;
858
+ if (!this.deliverySigning)
859
+ return path + deliveryQueryString({ conversion, width, quality });
860
+ const ttl = options.expiresIn ?? this.deliverySigning.expiresIn ?? DEFAULT_DELIVERY_SIGNING_TTL_SECONDS;
861
+ const expires = deliveryExpiry(this.now().getTime(), ttl);
862
+ const signature = signDelivery(this.deliverySigning.secret, {
863
+ mediaId: id,
864
+ conversion,
865
+ width,
866
+ quality,
867
+ expires,
868
+ });
869
+ return path + deliveryQueryString({ conversion, width, quality, expires, signature });
870
+ }
871
+ /**
872
+ * Check a delivery request against `delivery.signing`: `true` when it is signed and valid (serve
873
+ * any size it names), `false` when signing is off or the request is unsigned and that is allowed.
874
+ * Throws {@link DeliverySignatureError} for an invalid or expired signature, or a missing one when
875
+ * `delivery.signing.required` (the default). `MediaDeliveryHandler` calls this for you.
876
+ */
877
+ checkDeliverySignature(request) {
878
+ if (!this.deliverySigning)
879
+ return false;
880
+ const signed = verifyDelivery(this.deliverySigning.secret, request, this.now().getTime());
881
+ if (!signed && (this.deliverySigning.required ?? true)) {
882
+ throw new DeliverySignatureError('missing');
883
+ }
884
+ return signed;
885
+ }
592
886
  /**
593
887
  * Resolve how a media record should reach the client, per the configured `delivery.mode` — the
594
888
  * read-side counterpart to `uploads.mode`. Returns either a URL to redirect to (`public`/`signed`)
@@ -606,14 +900,29 @@ export class MediaLibrary {
606
900
  * ```
607
901
  */
608
902
  async deliver(id, options = {}) {
609
- const target = await this.#resolveTarget(id, options.conversion);
903
+ const resizing = options.width !== undefined && options.width !== '';
904
+ const conversion = resizing
905
+ ? (await this.resized(id, {
906
+ width: options.width,
907
+ quality: options.quality,
908
+ accept: options.accept,
909
+ trusted: options.trusted,
910
+ })).conversion
911
+ : options.conversion;
912
+ const vary = resizing && options.accept !== undefined ? { vary: 'Accept' } : {};
913
+ const target = await this.#resolveTarget(id, conversion);
610
914
  const disk = this.storage.disk(target.disk);
611
915
  const mode = await resolveDeliveryMode(options.mode ?? this.deliveryMode, disk, target.path);
612
- if (mode === 'public')
613
- return { kind: 'redirect', url: await disk.getUrl(target.path) };
916
+ if (mode === 'public') {
917
+ return { kind: 'redirect', url: await disk.getUrl(target.path), ...vary };
918
+ }
614
919
  if (mode === 'signed') {
615
920
  const expiresIn = options.signedTtlSeconds ?? this.deliverySignedTtlSeconds;
616
- return { kind: 'redirect', url: await disk.getSignedUrl(target.path, { expiresIn }) };
921
+ return {
922
+ kind: 'redirect',
923
+ url: await disk.getSignedUrl(target.path, { expiresIn }),
924
+ ...vary,
925
+ };
617
926
  }
618
927
  // Prefer the disk's own metadata over the record's: for a conversion the record describes the
619
928
  // ORIGINAL (a `thumb` of a PNG may well be a WEBP), and its `size` is the original's too.
@@ -624,6 +933,7 @@ export class MediaLibrary {
624
933
  mimeType: metadata.contentType ?? target.record.mimeType,
625
934
  size: metadata.contentLength,
626
935
  fileName: target.fileName,
936
+ ...vary,
627
937
  };
628
938
  }
629
939
  /**
@@ -661,4 +971,39 @@ function assertSafeArtifactPath(transformer, relativePath) {
661
971
  }
662
972
  return normalized;
663
973
  }
974
+ /** Widest image a signed URL may ask for — a sanity bound; the original's width still caps it. */
975
+ const MAX_SIGNED_WIDTH = 8192;
976
+ /** Parse a requested width/quality and check it is within `[min, max]` (signed requests). */
977
+ function numberInRange(field, value, min, max) {
978
+ const parsed = typeof value === 'number' ? value : /^\d+$/.test(value) ? Number(value) : Number.NaN;
979
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
980
+ throw new ImageSizeNotAllowedError(field, value, [min, max]);
981
+ }
982
+ return parsed;
983
+ }
984
+ /** Parse a requested width/quality (a query-string value, possibly) and check it is whitelisted. */
985
+ function allowedNumber(field, value, allowed) {
986
+ const parsed = typeof value === 'number' ? value : /^\d+$/.test(value) ? Number(value) : Number.NaN;
987
+ if (!allowed.includes(parsed))
988
+ throw new ImageSizeNotAllowedError(field, value, allowed);
989
+ return parsed;
990
+ }
991
+ /**
992
+ * The resized image's format: the first configured modern format the browser accepts. A browser
993
+ * that accepts none gets a format every browser decodes: PNG for an original that may be
994
+ * transparent, JPEG otherwise. No `Accept` at all (a server-side caller) ⇒ the first configured.
995
+ */
996
+ function negotiateFormat(accept, formats, originalMimeType) {
997
+ if (accept === undefined)
998
+ return formats[0] ?? 'webp';
999
+ const accepted = accept.toLowerCase();
1000
+ const modern = formats.find((format) => accepted.includes(`image/${format}`));
1001
+ if (modern)
1002
+ return modern;
1003
+ return originalMimeType === 'image/png' || originalMimeType === 'image/gif' ? 'png' : 'jpeg';
1004
+ }
1005
+ function focalPointProp(record) {
1006
+ const focalPoint = focalPointOf(record.customProperties);
1007
+ return focalPoint ? { focalPoint } : {};
1008
+ }
664
1009
  //# sourceMappingURL=media_library.js.map