@zerotal/media 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,10 +4,90 @@ All notable changes to this package are documented here. The format is
4
4
  based on [Keep a Changelog](https://keepachangelog.com/); this package
5
5
  follows the Zerotal monorepo's unified versioning.
6
6
 
7
- **Maturity: `experimental`**
7
+ **Maturity: `stable`** — the public API follows SemVer strictly: anything
8
+ importable without an `@internal` marker keeps its shape for the rest of the 1.x
9
+ line, and `api-surface.md` is diffed by CI on every change.
8
10
 
9
11
  ## [Unreleased]
10
12
 
13
+ ## [1.5.0] — 2026-08-15
14
+
15
+ ### Changed
16
+
17
+ - **`@zerotal/media` is `stable`.** The blocker was never the feature set — it was
18
+ that the default driver could not centre-crop, so freezing `ImageDriver` would
19
+ have frozen the workaround with it. `fit: "cover"` on `BunImageDriver` removed
20
+ that, and nothing reopened the promotion afterwards, which is the only reason
21
+ this package was still beta.
22
+
23
+ Two things had to happen first, and both are the promotion rather than
24
+ paperwork around it:
25
+
26
+ - **The surface was triaged.** It listed 79 exports and carried exactly one
27
+ `@internal` marker — plumbing that had leaked out of a module because
28
+ something else in the package needed it. Collection resolution, retention,
29
+ conversion dispatch, source resolvers, disk resolution, schema provisioning
30
+ and the config defaults are now marked `@internal`. Narrowing a surface after
31
+ `stable` is itself a breaking change, so an export that shipped stable by
32
+ accident would have been stuck for the rest of 1.x.
33
+ - **What survived is documented.** All 47 promised exports appear in
34
+ `docs/media.md`, and `bun run docs:coverage` now fails CI if that stops being
35
+ true. A SemVer promise over a surface nobody wrote down is not a promise
36
+ anyone can use.
37
+
38
+ Nothing is removed and nothing is renamed: an `@internal` export still imports
39
+ and still works. It is a statement about what the guarantee covers.
40
+
41
+ - **`ImageDriver` is frozen, and its growth rule is written down.** It is the one
42
+ type in this package a third party implements, so a new _required_ member would
43
+ break code this repository cannot see. New members arrive optional, with the
44
+ package supplying the fallback; `ImageManipulation` may gain optional fields;
45
+ `ImageResult` and `ImageMetadata` may not gain required ones, because drivers
46
+ produce them. Buffers rather than streams stays: media is read from and written
47
+ to a storage disk, and both hand over whole objects.
48
+
49
+ ### Added
50
+
51
+ - **`fit: "cover"` works on the default driver.** No `sharp`, no native module. `Bun.Image`
52
+ still exposes no crop primitive, so the centre window is taken through a lossless PNG
53
+ round-trip on the already-downscaled image — scaling stays native, and the extra cost is
54
+ roughly 40 ms on an 8 MB photo, off the request path via the queue. This removes the one
55
+ blocker that sat outside the repo: `sharp` is now a throughput and codec-coverage choice
56
+ rather than a feature one.
57
+ - **`allowEnlargement` on a conversion.** Off by default, as before. Worth setting when the
58
+ exact box matters more than fidelity — a `cover` thumbnail for a fixed-size grid slot
59
+ would otherwise come back undersized for small sources.
60
+ - **`@zerotal/media/testing`** — the state-swapping and disk-resolver seams, moved out of the
61
+ main entry point so it can be frozen while these stay free to change.
62
+
63
+ ### Fixed
64
+
65
+ - **`fit: "inside"` returned dimensions that were off, sometimes short of the box asked
66
+ for.** `Bun.Image` floors internally, so a 400×300 source asked for `width: 150` came back
67
+ **149** px wide, and derived heights sat a pixel below what `sharp` produced. Both drivers
68
+ now resolve exact dimensions in shared code before touching their backend. Affects the
69
+ responsive ladder, which records these widths into `srcset`.
70
+
71
+ ### Changed
72
+
73
+ - **Both shipped drivers are held to one parity suite**, over a sweep of sizes, fits and
74
+ enlargement settings. `BunImageDriver.supportsCrop` is now `true`; the flag stays on
75
+ `ImageDriver` for third-party drivers that genuinely cannot crop.
76
+ - **`fit: "fill"` with a single dimension now behaves as `inside`** in both drivers. `sharp`
77
+ would stretch that one axis and leave the other at source size, which is almost never
78
+ what a caller meant.
79
+ - `FORMAT_MIME`, `FORMAT_EXTENSION` and `CONVERTIBLE_MIME_TYPES` are frozen — they are
80
+ shared module state, and mutating one changed conversion behaviour process-wide.
81
+ - `UnsupportedManipulationError` is no longer thrown for `cover`. It remains exported as
82
+ the way any driver reports a manipulation it cannot express.
83
+
84
+ ### Removed
85
+
86
+ - Test seams from the package root: `mediaState`, `setMediaState`, `resetMediaState`,
87
+ `setDiskResolver`, `setDefaultDiskName`, `diskNameFor`, `setConversionDispatcher`,
88
+ `performConversions`, `ownerClassFor`, `partitionConversions`. All still available from
89
+ `@zerotal/media/testing`; update the import path.
90
+
11
91
  ## [1.3.0] — 2026-08-09
12
92
 
13
93
  ### Added
package/README.md CHANGED
@@ -3,11 +3,13 @@
3
3
  > Attach files to models — collections, image conversions, responsive images, and ordering.
4
4
 
5
5
  Associate uploads with any model, store them on any disk, and generate derived
6
- images without installing a native module. Zerotal's answer to
7
- `spatie/laravel-medialibrary`.
6
+ images without installing a native module.
8
7
 
9
8
  Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
10
9
 
10
+ **Maturity: `stable`** — anything importable without an `@internal` marker keeps
11
+ its shape for the rest of the 1.x line.
12
+
11
13
  ## Installation
12
14
 
13
15
  ```bash
@@ -65,10 +67,12 @@ await product.clearMediaCollection("images");
65
67
  - **Types come from bytes.** `accepts` is checked against the type sniffed from
66
68
  the file's own contents, never the filename or the upload's `Content-Type`.
67
69
  - **No native dependency.** Conversions run on `Bun.Image`, built into the
68
- runtime. JPEG, PNG and WebP work on every host.
69
- - **`Bun.Image` cannot crop.** `fit: "cover"` throws rather than silently
70
- returning a stretched image. Install `sharp` and set `driver: "sharp"` if you
71
- need centre-cropped thumbnails.
70
+ runtime. JPEG, PNG and WebP work on every host — including `fit: "cover"`,
71
+ the centre-crop behind square thumbnails. `sharp` is optional, and a shared
72
+ parity suite holds both drivers to the same output dimensions.
73
+ - **Nothing is upscaled by default.** A source too small to fill the box comes
74
+ back at the largest size it can supply, rather than blurred up to fit. Set
75
+ `allowEnlargement: true` on a conversion to opt in.
72
76
  - **Deleting a model deletes its files** — unless it soft-deletes, in which case
73
77
  they wait for `forceDelete()`.
74
78
  - **Paths are keyed on uuid,** not the numeric id, so a public URL discloses
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "@zerotal/media",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "license": "MIT",
5
- "maturity": "experimental",
5
+ "maturity": "stable",
6
6
  "private": false,
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./src/index.ts",
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
- "./commands": "./src/commands/index.ts"
12
+ "./commands": "./src/commands/index.ts",
13
+ "./testing": "./src/testing.ts"
13
14
  },
14
15
  "files": [
15
16
  "CHANGELOG.md",
@@ -30,8 +31,8 @@
30
31
  "typecheck": "tsc --noEmit"
31
32
  },
32
33
  "dependencies": {
33
- "@zerotal/core": "1.4.0",
34
- "@zerotal/orm": "1.4.0"
34
+ "@zerotal/core": "1.5.0",
35
+ "@zerotal/orm": "1.5.0"
35
36
  },
36
37
  "peerDependencies": {
37
38
  "@zerotal/queue": "^1.0.0",
@@ -46,7 +47,8 @@
46
47
  }
47
48
  },
48
49
  "devDependencies": {
49
- "@zerotal/queue": "1.4.0",
50
+ "@zerotal/queue": "1.5.0",
51
+ "sharp": "^0.34.0",
50
52
  "typescript": "^5.8.0"
51
53
  },
52
54
  "description": "Attach files to models: media collections, image conversions, responsive images, and ordering — on any Zerotal storage disk.",
package/src/Media.ts CHANGED
@@ -34,9 +34,8 @@ function ownedQuery(self: MediaHost): ModelQueryBuilder<MediaItem> {
34
34
  * Adds media handling to a model.
35
35
  *
36
36
  * Compose it with `Model.using(...)` and declare the collections the model
37
- * owns in a static `mediaCollections` field. Zerotal's equivalent of Laravel's
38
- * `HasMedia` interface plus `InteractsWithMedia` trait, in one piece named for
39
- * how it reads at the call site: `Model.using(Media)`.
37
+ * owns in a static `mediaCollections` field. The interface and the behaviour are
38
+ * one piece here, named for how it reads at the call site: `Model.using(Media)`.
40
39
  *
41
40
  * ## Deleting
42
41
  *
package/src/MediaItem.ts CHANGED
@@ -14,6 +14,7 @@ export function setPathGenerator(generator: PathGenerator): void {
14
14
  }
15
15
 
16
16
  /** The path generator currently in force. */
17
+ /** @internal — reads the active generator; `setPathGenerator` is the public seam. */
17
18
  export function pathGenerator(): PathGenerator {
18
19
  return _pathGenerator;
19
20
  }
@@ -2,6 +2,7 @@ import { UnknownCollectionError } from "../errors.ts";
2
2
  import type { CollectionDefinition, MediaCollections } from "../types.ts";
3
3
 
4
4
  /** A model class that may declare media collections. */
5
+ /** @internal — the structural shape `resolveCollection` reads off a model. */
5
6
  export interface CollectionHost {
6
7
  name: string;
7
8
  mediaCollections?: MediaCollections;
@@ -16,6 +17,8 @@ export interface CollectionHost {
16
17
  *
17
18
  * @throws {UnknownCollectionError} when the model declares no such collection —
18
19
  * listing the ones it does declare, because the mistake is nearly always a typo.
20
+ *
21
+ * @internal — collection lookup; apps declare `mediaCollections` and never call this.
19
22
  */
20
23
  export function resolveCollection(host: CollectionHost, name: string): CollectionDefinition {
21
24
  const declared = host.mediaCollections ?? {};
@@ -29,11 +32,13 @@ export function resolveCollection(host: CollectionHost, name: string): Collectio
29
32
  }
30
33
 
31
34
  /** Whether a model declares a collection under `name`. */
35
+ /** @internal — collection lookup; apps declare `mediaCollections` and never call this. */
32
36
  export function hasCollection(host: CollectionHost, name: string): boolean {
33
37
  return (host.mediaCollections ?? {})[name] !== undefined;
34
38
  }
35
39
 
36
40
  /** Every collection name the model declares. */
41
+ /** @internal — collection lookup; apps declare `mediaCollections` and never call this. */
37
42
  export function collectionNames(host: CollectionHost): string[] {
38
43
  return Object.keys(host.mediaCollections ?? {});
39
44
  }
@@ -13,6 +13,8 @@ import type { CollectionDefinition } from "../types.ts";
13
13
  *
14
14
  * @param justAdded - The item that triggered the trim; never removed, even if
15
15
  * its sort position would otherwise put it outside the window.
16
+ *
17
+ * @internal — `single` / `onlyKeepLatest` enforcement, run by MediaAdder.
16
18
  */
17
19
  export async function applyRetentionRules(
18
20
  modelType: string,
package/src/config.ts CHANGED
@@ -77,6 +77,7 @@ export function MediaConfig(options: Partial<MediaConfigShape> = {}): MediaConfi
77
77
  }
78
78
 
79
79
  /** The defaults, for tests and for resolving config in DB-less runtimes. */
80
+ /** @internal — the defaults `MediaConfig()` merges onto. */
80
81
  export function mediaDefaults(): MediaConfigShape {
81
82
  return { ...defaults, responsiveWidths: [...defaults.responsiveWidths] };
82
83
  }
@@ -5,21 +5,28 @@ import {
5
5
  type ImageMetadata,
6
6
  type ImageResult,
7
7
  } from "./ImageDriver.ts";
8
- import { UnsupportedFormatError, UnsupportedManipulationError } from "../errors.ts";
8
+ import { RasterFormatError, UnsupportedFormatError } from "../errors.ts";
9
+ import { cropCentre, decodePng, encodePng, readPngHeader, resolveGeometry } from "./raster.ts";
10
+ import type { ResolvedGeometry } from "./raster.ts";
9
11
  import type { ConversionFormat } from "../types.ts";
10
12
 
11
13
  /**
12
14
  * Image processing on `Bun.Image` — no native modules, no `sharp`, nothing to
13
15
  * install. JPEG, PNG and WebP are statically linked into Bun itself.
14
16
  *
15
- * ## What it cannot do
17
+ * ## How cropping works
16
18
  *
17
- * `Bun.Image.resize()` takes `fit: "fill" | "inside"` and the class exposes no
18
- * crop, extract or composite primitive. A centre-cropped thumbnail a square
19
- * from a 3:2 photo is therefore not expressible, and `fit: "cover"` throws
20
- * {@link UnsupportedManipulationError} naming the fix rather than silently
21
- * returning a stretched image. Apps that need crop install `sharp` and switch to
22
- * `SharpImageDriver` via `media.driver`.
19
+ * `Bun.Image.resize()` takes only `fit: "fill" | "inside"` and the class exposes
20
+ * no crop, extract or composite primitive, so `fit: "cover"` is not expressible
21
+ * through its API alone. It is instead assembled here: scale so the image
22
+ * overflows the target box in at most one axis, then take the centre window out
23
+ * of the result through a lossless PNG round-trip (see `raster.ts`). Scaling
24
+ * stays native; only the window copy is ours, and it runs on the already-scaled
25
+ * image rather than the original.
26
+ *
27
+ * The upshot is that this driver crops, and `sharp` is no longer required for a
28
+ * cover-fit thumbnail. `SharpImageDriver` remains worth choosing for throughput
29
+ * on large batches and for formats Bun's `system` backend lacks.
23
30
  *
24
31
  * ## Host-dependent formats
25
32
  *
@@ -29,7 +36,7 @@ import type { ConversionFormat } from "../types.ts";
29
36
  */
30
37
  export class BunImageDriver implements ImageDriver {
31
38
  readonly name = "BunImageDriver";
32
- readonly supportsCrop = false;
39
+ readonly supportsCrop = true;
33
40
 
34
41
  /** Memoised results of {@link canEncode}, which costs a real encode. */
35
42
  readonly #encodable = new Map<ConversionFormat, boolean>();
@@ -41,6 +48,16 @@ export class BunImageDriver implements ImageDriver {
41
48
  * against the header before any pixel buffer is allocated.
42
49
  */
43
50
  private readonly maxPixels: number = 0x3fff * 0x3fff,
51
+ /**
52
+ * Cap on the intermediate buffer the crop path decodes, in pixels.
53
+ *
54
+ * Separate from {@link maxPixels}, and much lower, because this one bounds an
55
+ * allocation *we* make rather than one Bun makes: the intermediate is
56
+ * uncompressed RGBA, so the default 40 MP is already ~160 MB. Conversions
57
+ * are thumbnail-sized in practice, so this only ever catches a conversion
58
+ * defined with implausible dimensions.
59
+ */
60
+ private readonly maxCropPixels: number = 40_000_000,
44
61
  ) {}
45
62
 
46
63
  async metadata(bytes: Uint8Array): Promise<ImageMetadata | null> {
@@ -55,54 +72,145 @@ export class BunImageDriver implements ImageDriver {
55
72
  }
56
73
 
57
74
  async convert(bytes: Uint8Array, manipulation: ImageManipulation): Promise<ImageResult> {
58
- const { width, height, fit = "inside", format, quality, rotate } = manipulation;
59
-
60
- if (fit === "cover") {
61
- throw new UnsupportedManipulationError(
62
- this.name,
63
- 'centre-crop (fit: "cover") — Bun.Image supports only fit: "fill" | "inside" ' +
64
- "and exposes no crop primitive",
65
- 'either use fit: "inside" (scale to fit, preserves aspect ratio) or ' +
66
- 'install sharp and set `driver: "sharp"` in config/media.ts.',
67
- );
75
+ const { format, quality, rotate } = manipulation;
76
+
77
+ // Rotation changes which dimension is which, so the geometry has to be
78
+ // computed against the post-rotation shape. Rotating first — into a PNG, so
79
+ // nothing is lost — lets the dimensions simply be read back, rather than
80
+ // re-deriving Bun's canvas expansion for non-right angles.
81
+ const rotated = rotate !== undefined && rotate !== 0;
82
+ let source = bytes;
83
+ let sourceWidth: number;
84
+ let sourceHeight: number;
85
+
86
+ if (rotated) {
87
+ source = await new Bun.Image(bytes, { maxPixels: this.maxPixels })
88
+ .rotate(rotate)
89
+ .png()
90
+ .bytes();
91
+ const header = readPngHeader(source);
92
+ if (header === null) throw new RasterFormatError("Bun.Image.rotate() did not return a PNG");
93
+ sourceWidth = header.width;
94
+ sourceHeight = header.height;
95
+ } else {
96
+ const meta = await this.metadata(bytes);
97
+ if (meta === null) throw new RasterFormatError("the source image could not be read");
98
+ sourceWidth = meta.width;
99
+ sourceHeight = meta.height;
68
100
  }
69
101
 
70
- let pipeline = new Bun.Image(bytes, { maxPixels: this.maxPixels });
102
+ const geometry = resolveGeometry({
103
+ sourceWidth,
104
+ sourceHeight,
105
+ targetWidth: manipulation.width,
106
+ targetHeight: manipulation.height,
107
+ fit: manipulation.fit ?? "inside",
108
+ // Upscaling a 200px source to fill a 1920px slot produces a blurry file
109
+ // larger than the original. Never worth it by default.
110
+ withoutEnlargement: manipulation.withoutEnlargement ?? true,
111
+ });
112
+
113
+ const needsCrop =
114
+ geometry.cropWidth !== undefined &&
115
+ geometry.cropHeight !== undefined &&
116
+ (geometry.cropWidth !== geometry.resizeWidth ||
117
+ geometry.cropHeight !== geometry.resizeHeight);
71
118
 
72
- if (rotate !== undefined && rotate !== 0) pipeline = pipeline.rotate(rotate);
119
+ // Without a crop the whole thing stays inside Bun: one pipeline, no
120
+ // round-trip. This is the overwhelmingly common path.
121
+ if (!needsCrop) {
122
+ let pipeline = new Bun.Image(source, { maxPixels: this.maxPixels });
123
+ if (!geometry.resizeIsNoop) {
124
+ pipeline = pipeline.resize(geometry.resizeWidth, geometry.resizeHeight, {
125
+ // Exact dimensions are already resolved, so `fill` is what we want:
126
+ // it asks Bun for precisely these numbers instead of letting it
127
+ // re-derive (and re-round) them from the caller's box.
128
+ fit: "fill",
129
+ withoutEnlargement: false,
130
+ });
131
+ }
132
+ pipeline = _applyFormat(pipeline, format, quality);
73
133
 
74
- if (width !== undefined || height !== undefined) {
75
- // Bun.Image needs a width; when only a height is given, pass the height
76
- // through as the bound and let `inside` preserve the aspect ratio.
77
- const targetWidth = width ?? height!;
78
- pipeline = pipeline.resize(targetWidth, height, {
79
- fit,
80
- // Upscaling a 200px source to fill a 1920px slot produces a blurry file
81
- // larger than the original. Never worth it by default.
82
- withoutEnlargement: manipulation.withoutEnlargement ?? true,
83
- });
134
+ return {
135
+ bytes: await this.#encode(pipeline, format),
136
+ width: geometry.resizeWidth,
137
+ height: geometry.resizeHeight,
138
+ format,
139
+ mimeType: FORMAT_MIME[format],
140
+ };
84
141
  }
85
142
 
86
- pipeline = _applyFormat(pipeline, format, quality);
143
+ return await this.#convertCropped(source, geometry, rotated, format, quality);
144
+ }
145
+
146
+ /**
147
+ * The `fit: "cover"` path: scale so the image overflows the box in at most one
148
+ * axis, then keep the centre.
149
+ *
150
+ * `Bun.Image` has no crop primitive, so the centre window is taken here —
151
+ * through a lossless PNG round-trip, on the already-scaled image. The scaling
152
+ * itself never leaves Bun.
153
+ */
154
+ async #convertCropped(
155
+ source: Uint8Array,
156
+ geometry: ResolvedGeometry,
157
+ sourceIsPng: boolean,
158
+ format: ConversionFormat,
159
+ quality: number | undefined,
160
+ ): Promise<ImageResult> {
161
+ // A no-op resize on a source that is already PNG has nothing to do.
162
+ const scaled =
163
+ geometry.resizeIsNoop && sourceIsPng
164
+ ? source
165
+ : await this.#toPng(source, geometry.resizeIsNoop ? null : geometry);
166
+
167
+ const cropped = cropCentre(
168
+ decodePng(scaled, this.maxCropPixels),
169
+ geometry.cropWidth!,
170
+ geometry.cropHeight!,
171
+ );
87
172
 
88
- let out: Uint8Array;
173
+ const pipeline = _applyFormat(
174
+ new Bun.Image(encodePng(cropped), { maxPixels: this.maxPixels }),
175
+ format,
176
+ quality,
177
+ );
178
+
179
+ return {
180
+ bytes: await this.#encode(pipeline, format),
181
+ // Known exactly from the crop — no need to ask the pipeline.
182
+ width: cropped.width,
183
+ height: cropped.height,
184
+ format,
185
+ mimeType: FORMAT_MIME[format],
186
+ };
187
+ }
188
+
189
+ /** Re-encode as PNG, optionally resizing on the way through. */
190
+ async #toPng(bytes: Uint8Array, geometry: ResolvedGeometry | null): Promise<Uint8Array> {
191
+ const image = new Bun.Image(bytes, { maxPixels: this.maxPixels });
192
+ const pipeline =
193
+ geometry === null
194
+ ? image
195
+ : image.resize(geometry.resizeWidth, geometry.resizeHeight, {
196
+ // Safe despite the name: both dimensions were scaled by one factor,
197
+ // so this fills a box that already has the source's aspect ratio.
198
+ fit: "fill",
199
+ withoutEnlargement: false,
200
+ });
201
+ return await pipeline.png().bytes();
202
+ }
203
+
204
+ /** Await a pipeline, translating a missing codec into a named error. */
205
+ async #encode(pipeline: Bun.Image, format: ConversionFormat): Promise<Uint8Array> {
89
206
  try {
90
- out = await pipeline.bytes();
207
+ return await pipeline.bytes();
91
208
  } catch (error) {
92
209
  if (_codeOf(error) === "ERR_IMAGE_FORMAT_UNSUPPORTED") {
93
210
  throw new UnsupportedFormatError(format, await this.encodableFormats());
94
211
  }
95
212
  throw error;
96
213
  }
97
-
98
- return {
99
- bytes: out,
100
- // Populated once a terminal has been awaited; -1 before that.
101
- width: pipeline.width,
102
- height: pipeline.height,
103
- format,
104
- mimeType: FORMAT_MIME[format],
105
- };
106
214
  }
107
215
 
108
216
  async placeholder(bytes: Uint8Array): Promise<string | null> {
@@ -47,6 +47,8 @@ export function partitionConversions(
47
47
  * and its row already exists, so a codec that chokes on one file should cost
48
48
  * that file its thumbnail, not the upload. Failures are collected and returned
49
49
  * rather than thrown.
50
+ *
51
+ * @internal — the conversion engine, constructed by MediaProvider.
50
52
  */
51
53
  export class ConversionRunner {
52
54
  constructor(
@@ -214,6 +216,9 @@ export class ConversionRunner {
214
216
  fit: definition.fit ?? "inside",
215
217
  format,
216
218
  quality: definition.quality ?? this.config.quality,
219
+ // Inverted at the boundary: the collection API reads better as an opt-in
220
+ // ("allow this"), the driver contract as a constraint ("do not do this").
221
+ withoutEnlargement: definition.allowEnlargement !== true,
217
222
  };
218
223
  }
219
224
  }
@@ -37,15 +37,46 @@ export interface ImageMetadata {
37
37
  /**
38
38
  * The seam between this package and whatever actually manipulates pixels.
39
39
  *
40
- * Two implementations ship: {@link BunImageDriver} (the default — no
41
- * dependencies, no crop) and `SharpImageDriver` (opt-in, adds crop). Keeping
42
- * both behind one interface is also what makes `Bun.Image` — which is a few
43
- * weeks old a safe thing to depend on: if its API moves, one file changes.
40
+ * Two implementations ship: `BunImageDriver` (the default — no dependencies)
41
+ * and `SharpImageDriver` (opt-in, a native module). Both support every
42
+ * manipulation in {@link ImageManipulation}, and a shared parity suite holds
43
+ * them to the same output dimensions, so `media.driver` is a throughput and
44
+ * codec-coverage choice rather than a feature one.
45
+ *
46
+ * Keeping both behind one interface is also what makes `Bun.Image` — which is
47
+ * young — a safe thing to depend on: if its API moves, one file changes.
48
+ *
49
+ * ## How this interface may grow
50
+ *
51
+ * This is the one type in the package a third-party implements, so a new
52
+ * **required** member is a breaking change for code this repository cannot see.
53
+ * The policy, decided when the package was promoted to `stable`:
54
+ *
55
+ * - **New members arrive optional**, and the package supplies the fallback —
56
+ * the same way `supportsCrop` lets a driver decline `fit: "cover"` instead of
57
+ * failing at conversion time. A capability nobody implements must degrade to
58
+ * the behaviour drivers already have.
59
+ * - **{@link ImageManipulation} may gain optional fields** (a background colour,
60
+ * a device-pixel-ratio multiplier, EXIF stripping). Drivers ignore what they
61
+ * do not understand, so this stays additive — but a field that *changes*
62
+ * existing output is a new field, never a new default.
63
+ * - **{@link ImageResult} and {@link ImageMetadata} may not gain required
64
+ * fields**, because drivers produce them. Optional only.
65
+ *
66
+ * Buffers rather than streams is deliberate and does not change: media is read
67
+ * from and written to a storage disk, both of which hand over whole objects, so
68
+ * a streaming seam here would only re-buffer at each end.
44
69
  */
45
70
  export interface ImageDriver {
46
71
  /** Name used in error messages. */
47
72
  readonly name: string;
48
- /** Whether `fit: "cover"` is available. */
73
+ /**
74
+ * Whether `fit: "cover"` is available.
75
+ *
76
+ * Both shipped drivers report `true`. It stays part of the interface for
77
+ * third-party drivers wrapping a backend that genuinely cannot crop, which
78
+ * need a way to say so other than failing at conversion time.
79
+ */
49
80
  readonly supportsCrop: boolean;
50
81
 
51
82
  /** Read dimensions and format without decoding the whole image. */
@@ -64,26 +95,31 @@ export interface ImageDriver {
64
95
  canEncode(format: ConversionFormat): Promise<boolean>;
65
96
  }
66
97
 
98
+ // These three are exported for reading — to label a download, or to check a type
99
+ // before offering an upload. They are frozen because they are shared module
100
+ // state: an app that mutated one would change how conversions behave for every
101
+ // other caller in the process, including ones it does not own.
102
+
67
103
  /** MIME type for each format a driver may emit. */
68
- export const FORMAT_MIME: Record<ConversionFormat, string> = {
104
+ export const FORMAT_MIME: Readonly<Record<ConversionFormat, string>> = Object.freeze({
69
105
  jpeg: "image/jpeg",
70
106
  png: "image/png",
71
107
  webp: "image/webp",
72
108
  avif: "image/avif",
73
109
  heic: "image/heic",
74
- };
110
+ });
75
111
 
76
112
  /** File extension for each format. */
77
- export const FORMAT_EXTENSION: Record<ConversionFormat, string> = {
113
+ export const FORMAT_EXTENSION: Readonly<Record<ConversionFormat, string>> = Object.freeze({
78
114
  jpeg: "jpg",
79
115
  png: "png",
80
116
  webp: "webp",
81
117
  avif: "avif",
82
118
  heic: "heic",
83
- };
119
+ });
84
120
 
85
121
  /** MIME types this package will attempt to convert. */
86
- export const CONVERTIBLE_MIME_TYPES = new Set([
122
+ export const CONVERTIBLE_MIME_TYPES: ReadonlySet<string> = new Set([
87
123
  "image/jpeg",
88
124
  "image/png",
89
125
  "image/webp",