@zerotal/media 1.3.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.
@@ -0,0 +1,89 @@
1
+ import { Command } from "@zerotal/core";
2
+ import type { FlagDef } from "@zerotal/core";
3
+ import { MediaItem } from "../MediaItem.ts";
4
+ import { MediaLibrary } from "../facades/MediaLibrary.ts";
5
+ import { ownerClassFor } from "../conversions/queueBridge.ts";
6
+ import { hasCollection } from "../collections/resolve.ts";
7
+
8
+ /**
9
+ * Rebuild conversions for existing media — the command you run after widening a
10
+ * thumbnail or adding a conversion to a collection that already has files in it.
11
+ *
12
+ * @example
13
+ * ```bash
14
+ * bun zt media:regenerate # everything
15
+ * bun zt media:regenerate --model=Product # one model
16
+ * bun zt media:regenerate --only=thumb,hero # named conversions
17
+ * bun zt media:regenerate --id=42 # one item
18
+ * ```
19
+ */
20
+ export class MediaRegenerateCommand extends Command {
21
+ static override commandName = "media:regenerate";
22
+ static override description = "Regenerate conversions for existing media";
23
+ static override needsApp = true;
24
+ static override flags: FlagDef[] = [
25
+ { name: "model", type: "string", description: "Only media owned by this model type" },
26
+ { name: "id", type: "string", description: "Only this media id" },
27
+ { name: "only", type: "string", description: "Comma-separated conversion names" },
28
+ ];
29
+
30
+ async run(): Promise<void> {
31
+ const only = this.parseOnly();
32
+ const media = await this.select();
33
+
34
+ if (media.length === 0) {
35
+ this.warn("No media matched.");
36
+ return;
37
+ }
38
+
39
+ let regenerated = 0;
40
+ let skipped = 0;
41
+
42
+ for (const item of media) {
43
+ const ownerClass = ownerClassFor(item.modelType);
44
+
45
+ // A model_type that no longer resolves is not a failure worth stopping
46
+ // for: a renamed or deleted model leaves rows behind, and the run should
47
+ // still process everything else.
48
+ if (ownerClass === null || !hasCollection(ownerClass, item.collectionName)) {
49
+ skipped++;
50
+ continue;
51
+ }
52
+
53
+ const generated = await MediaLibrary.regenerate(item, ownerClass, only);
54
+ if (generated.length > 0) regenerated++;
55
+ }
56
+
57
+ this.info(`Regenerated conversions for ${regenerated} of ${media.length} item(s).`);
58
+ if (skipped > 0) {
59
+ this.warn(
60
+ `${skipped} skipped — their model type or collection is no longer declared. ` +
61
+ "Run `bun zt media:clean` to review them.",
62
+ );
63
+ }
64
+ }
65
+
66
+ private parseOnly(): string[] | undefined {
67
+ const raw = this.flags["only"];
68
+ if (typeof raw !== "string" || raw.trim() === "") return undefined;
69
+ return raw
70
+ .split(",")
71
+ .map((name) => name.trim())
72
+ .filter(Boolean);
73
+ }
74
+
75
+ private async select(): Promise<MediaItem[]> {
76
+ const id = this.flags["id"];
77
+ if (typeof id === "string" && id.trim() !== "") {
78
+ const one = await MediaItem.find(id.trim());
79
+ return one === null ? [] : [one];
80
+ }
81
+
82
+ const model = this.flags["model"];
83
+ if (typeof model === "string" && model.trim() !== "") {
84
+ return MediaItem.query().where("model_type", model.trim()).get();
85
+ }
86
+
87
+ return MediaItem.query().get();
88
+ }
89
+ }
@@ -0,0 +1,2 @@
1
+ export { MediaCleanCommand } from "./MediaCleanCommand.ts";
2
+ export { MediaRegenerateCommand } from "./MediaRegenerateCommand.ts";
package/src/config.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { deepMerge } from "@zerotal/core";
2
+ import type { SafeConversionFormat } from "./types.ts";
3
+
4
+ export interface MediaConfigShape {
5
+ /**
6
+ * Disk originals go to when a collection does not name one.
7
+ * Empty string means "whatever `storage.default` resolves to".
8
+ */
9
+ disk: string;
10
+ /** Disk conversions go to. Empty string means "the same disk as the original". */
11
+ conversionsDisk: string;
12
+ /** Table backing the {@link MediaItem} model. */
13
+ table: string;
14
+ /**
15
+ * Provision the media table at boot instead of requiring a migration.
16
+ * Idempotent; skipped when the table already exists or there is no database.
17
+ */
18
+ autoCreateTable: boolean;
19
+ /** Image driver. `"bun"` needs no dependencies; `"sharp"` adds crop support. */
20
+ driver: "bun" | "sharp";
21
+ /**
22
+ * Run conversions through the queue when one is bound and the conversion asks
23
+ * for it. With no queue bound, conversions always run inline.
24
+ */
25
+ queueConversions: boolean;
26
+ /** Queue name conversion jobs are dispatched to. */
27
+ queue: string;
28
+ /** Default encoder quality (1–100) when a conversion does not set one. */
29
+ quality: number;
30
+ /** Default output format when a conversion does not set one. */
31
+ format: SafeConversionFormat;
32
+ /**
33
+ * Refuse to decode an image larger than this, in bytes.
34
+ *
35
+ * `Bun.Image` has no streaming API — the whole file is buffered to decode it,
36
+ * then again to encode. Without a ceiling one 400 MB upload takes the worker
37
+ * down. Originals above it are still stored; they just get no conversions.
38
+ */
39
+ maxConversionInputSize: number;
40
+ /** Widths generated for `responsive: true` collections. */
41
+ responsiveWidths: number[];
42
+ /** Generate the inline blur placeholder alongside responsive images. */
43
+ responsivePlaceholder: boolean;
44
+ /**
45
+ * Permit AVIF/HEIC conversion targets. Off by default: they depend on OS
46
+ * codecs that are frequently missing, and the failure lands in a background
47
+ * job rather than in the request that configured it.
48
+ */
49
+ allowHostFormats: boolean;
50
+ }
51
+
52
+ const defaults: MediaConfigShape = {
53
+ disk: "",
54
+ conversionsDisk: "",
55
+ table: "media",
56
+ autoCreateTable: true,
57
+ driver: "bun",
58
+ queueConversions: true,
59
+ queue: "default",
60
+ quality: 82,
61
+ format: "webp",
62
+ maxConversionInputSize: 32 * 1024 * 1024,
63
+ responsiveWidths: [320, 640, 960, 1280, 1920],
64
+ responsivePlaceholder: true,
65
+ allowHostFormats: false,
66
+ };
67
+
68
+ /**
69
+ * Create a typed media configuration object with defaults.
70
+ *
71
+ * @example
72
+ * import { MediaConfig } from '@zerotal/media';
73
+ * export default MediaConfig({ disk: 's3', driver: 'sharp' });
74
+ */
75
+ export function MediaConfig(options: Partial<MediaConfigShape> = {}): MediaConfigShape {
76
+ return deepMerge(defaults, options);
77
+ }
78
+
79
+ /** The defaults, for tests and for resolving config in DB-less runtimes. */
80
+ export function mediaDefaults(): MediaConfigShape {
81
+ return { ...defaults, responsiveWidths: [...defaults.responsiveWidths] };
82
+ }
83
+
84
+ // Register this package's config namespace for typed config() dot-paths.
85
+ declare module "@zerotal/core" {
86
+ interface ConfigRegistry {
87
+ media: MediaConfigShape;
88
+ }
89
+ }
@@ -0,0 +1,183 @@
1
+ import {
2
+ FORMAT_MIME,
3
+ type ImageDriver,
4
+ type ImageManipulation,
5
+ type ImageMetadata,
6
+ type ImageResult,
7
+ } from "./ImageDriver.ts";
8
+ import { UnsupportedFormatError, UnsupportedManipulationError } from "../errors.ts";
9
+ import type { ConversionFormat } from "../types.ts";
10
+
11
+ /**
12
+ * Image processing on `Bun.Image` — no native modules, no `sharp`, nothing to
13
+ * install. JPEG, PNG and WebP are statically linked into Bun itself.
14
+ *
15
+ * ## What it cannot do
16
+ *
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`.
23
+ *
24
+ * ## Host-dependent formats
25
+ *
26
+ * `Bun.Image.backend` is `"system"` by default, so AVIF and HEIC encode through
27
+ * OS codecs that are absent on most Linux hosts. {@link canEncode} probes for
28
+ * real rather than assuming, and `MediaProvider` runs that probe once at boot.
29
+ */
30
+ export class BunImageDriver implements ImageDriver {
31
+ readonly name = "BunImageDriver";
32
+ readonly supportsCrop = false;
33
+
34
+ /** Memoised results of {@link canEncode}, which costs a real encode. */
35
+ readonly #encodable = new Map<ConversionFormat, boolean>();
36
+
37
+ constructor(
38
+ /**
39
+ * Cap on input pixels, guarding against decompression bombs: a few-KB file
40
+ * declaring a 50000×50000 canvas would otherwise allocate gigabytes. Checked
41
+ * against the header before any pixel buffer is allocated.
42
+ */
43
+ private readonly maxPixels: number = 0x3fff * 0x3fff,
44
+ ) {}
45
+
46
+ async metadata(bytes: Uint8Array): Promise<ImageMetadata | null> {
47
+ try {
48
+ const meta = await new Bun.Image(bytes, { maxPixels: this.maxPixels }).metadata();
49
+ return { width: meta.width, height: meta.height, format: meta.format };
50
+ } catch {
51
+ // Not an image, or a format this host cannot decode. Callers treat null as
52
+ // "not convertible" and keep the original — never a hard failure.
53
+ return null;
54
+ }
55
+ }
56
+
57
+ 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
+ );
68
+ }
69
+
70
+ let pipeline = new Bun.Image(bytes, { maxPixels: this.maxPixels });
71
+
72
+ if (rotate !== undefined && rotate !== 0) pipeline = pipeline.rotate(rotate);
73
+
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
+ });
84
+ }
85
+
86
+ pipeline = _applyFormat(pipeline, format, quality);
87
+
88
+ let out: Uint8Array;
89
+ try {
90
+ out = await pipeline.bytes();
91
+ } catch (error) {
92
+ if (_codeOf(error) === "ERR_IMAGE_FORMAT_UNSUPPORTED") {
93
+ throw new UnsupportedFormatError(format, await this.encodableFormats());
94
+ }
95
+ throw error;
96
+ }
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
+ }
107
+
108
+ async placeholder(bytes: Uint8Array): Promise<string | null> {
109
+ try {
110
+ // ThumbHash-rendered: a ~32px blur carrying the right average colour and
111
+ // aspect ratio, around 400–700 bytes, ready for `<img src>`.
112
+ return await new Bun.Image(bytes, { maxPixels: this.maxPixels }).placeholder("dataurl");
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ async canEncode(format: ConversionFormat): Promise<boolean> {
119
+ const cached = this.#encodable.get(format);
120
+ if (cached !== undefined) return cached;
121
+
122
+ let ok: boolean;
123
+ try {
124
+ await _applyFormat(new Bun.Image(_PROBE_PNG).resize(2, 2), format, 60).bytes();
125
+ ok = true;
126
+ } catch (error) {
127
+ if (_codeOf(error) === "ERR_IMAGE_FORMAT_UNSUPPORTED") ok = false;
128
+ else throw error;
129
+ }
130
+
131
+ this.#encodable.set(format, ok);
132
+ return ok;
133
+ }
134
+
135
+ /** Every format this host could actually encode, for error messages. */
136
+ async encodableFormats(): Promise<string[]> {
137
+ const all: ConversionFormat[] = ["jpeg", "png", "webp", "avif", "heic"];
138
+ const available: string[] = [];
139
+ for (const format of all) {
140
+ if (await this.canEncode(format)) available.push(format);
141
+ }
142
+ return available;
143
+ }
144
+ }
145
+
146
+ /** Select the output encoder on a pipeline. */
147
+ function _applyFormat(
148
+ pipeline: Bun.Image,
149
+ format: ConversionFormat,
150
+ quality: number | undefined,
151
+ ): Bun.Image {
152
+ switch (format) {
153
+ case "jpeg":
154
+ return quality === undefined ? pipeline.jpeg() : pipeline.jpeg({ quality });
155
+ case "webp":
156
+ return quality === undefined ? pipeline.webp() : pipeline.webp({ quality });
157
+ case "avif":
158
+ return quality === undefined ? pipeline.avif() : pipeline.avif({ quality });
159
+ case "heic":
160
+ return quality === undefined ? pipeline.heic() : pipeline.heic({ quality });
161
+ case "png":
162
+ // PNG is lossless: it takes a zlib level, not a quality. Mapping 1–100
163
+ // onto 0–9 would make `quality: 82` mean something quite different here
164
+ // than it does for JPEG, so the default level is used instead.
165
+ return pipeline.png();
166
+ }
167
+ }
168
+
169
+ /** The stable `error.code` Bun.Image sets, when there is one. */
170
+ function _codeOf(error: unknown): string | undefined {
171
+ if (typeof error !== "object" || error === null) return undefined;
172
+ const code = (error as { code?: unknown }).code;
173
+ return typeof code === "string" ? code : undefined;
174
+ }
175
+
176
+ /** A valid 2×2 PNG, used to probe which encoders this host actually has. */
177
+ const _PROBE_PNG = Uint8Array.from(
178
+ atob(
179
+ "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAD0lEQVR4nGMsY2AoY0AGAA5uAO6e" +
180
+ "5/phAAAAAElFTkSuQmCC",
181
+ ),
182
+ (c) => c.charCodeAt(0),
183
+ );
@@ -0,0 +1,219 @@
1
+ import {
2
+ FORMAT_EXTENSION,
3
+ isConvertible,
4
+ type ImageDriver,
5
+ type ImageManipulation,
6
+ } from "./ImageDriver.ts";
7
+ import { UnsupportedFormatError } from "../errors.ts";
8
+ import { pathGenerator, type MediaItem } from "../MediaItem.ts";
9
+ import { diskFor } from "../support/disks.ts";
10
+ import type { MediaConfigShape } from "../config.ts";
11
+ import type {
12
+ ConversionDefinition,
13
+ ConversionFormat,
14
+ ConversionMap,
15
+ GeneratedConversion,
16
+ ResponsiveImage,
17
+ ResponsiveImageSet,
18
+ SafeConversionFormat,
19
+ } from "../types.ts";
20
+
21
+ /** Formats that encode on every host, whatever OS codecs it has. */
22
+ const PORTABLE: ReadonlySet<string> = new Set<SafeConversionFormat>(["jpeg", "png", "webp"]);
23
+
24
+ /** Which of a collection's conversions run now versus on the queue. */
25
+ export function partitionConversions(
26
+ conversions: ConversionMap | undefined,
27
+ queueAvailable: boolean,
28
+ ): { inline: ConversionMap; queued: ConversionMap } {
29
+ const inline: ConversionMap = {};
30
+ const queued: ConversionMap = {};
31
+
32
+ for (const [name, definition] of Object.entries(conversions ?? {})) {
33
+ // Without a queue bound there is nowhere to defer to, so a conversion marked
34
+ // `queued` still runs — late is better than never, and an app with no queue
35
+ // would otherwise get silently empty thumbnails.
36
+ if (definition.queued === true && queueAvailable) queued[name] = definition;
37
+ else inline[name] = definition;
38
+ }
39
+
40
+ return { inline, queued };
41
+ }
42
+
43
+ /**
44
+ * Generates derived images and writes them next to their original.
45
+ *
46
+ * Failure of a single conversion is contained: the original is already stored
47
+ * and its row already exists, so a codec that chokes on one file should cost
48
+ * that file its thumbnail, not the upload. Failures are collected and returned
49
+ * rather than thrown.
50
+ */
51
+ export class ConversionRunner {
52
+ constructor(
53
+ private readonly driver: ImageDriver,
54
+ private readonly config: MediaConfigShape,
55
+ ) {}
56
+
57
+ /**
58
+ * Run `conversions` against `media` and record what was generated.
59
+ *
60
+ * Mutates and saves the media row's `generated_conversions`. Returns the names
61
+ * that failed, with the reason, so a caller can log or surface them.
62
+ */
63
+ async run(
64
+ media: MediaItem,
65
+ conversions: ConversionMap,
66
+ ): Promise<{ generated: string[]; failed: Array<{ name: string; reason: string }> }> {
67
+ const generated: string[] = [];
68
+ const failed: Array<{ name: string; reason: string }> = [];
69
+
70
+ if (Object.keys(conversions).length === 0) return { generated, failed };
71
+ if (!isConvertible(media.mimeType)) return { generated, failed };
72
+
73
+ const source = await this.readSource(media);
74
+ if (source === null) return { generated, failed };
75
+
76
+ const metadata = await this.driver.metadata(source);
77
+ if (metadata === null) return { generated, failed };
78
+
79
+ const disk = diskFor(media.conversionsDisk ?? media.disk);
80
+ const directory = pathGenerator().forConversions(media);
81
+ const results: Record<string, GeneratedConversion> = { ...(media.generatedConversions ?? {}) };
82
+
83
+ for (const [name, definition] of Object.entries(conversions)) {
84
+ try {
85
+ const format = await this.resolveFormat(definition, metadata.format);
86
+ const manipulation = this.toManipulation(definition, format);
87
+ const result = await this.driver.convert(source, manipulation);
88
+
89
+ const fileName = `${name}.${FORMAT_EXTENSION[format]}`;
90
+ await disk.put(`${directory}/${fileName}`, result.bytes, {
91
+ contentType: result.mimeType,
92
+ });
93
+
94
+ results[name] = {
95
+ fileName,
96
+ size: result.bytes.byteLength,
97
+ mimeType: result.mimeType,
98
+ width: result.width,
99
+ height: result.height,
100
+ generatedAt: new Date().toISOString(),
101
+ };
102
+ generated.push(name);
103
+ } catch (error) {
104
+ failed.push({ name, reason: error instanceof Error ? error.message : String(error) });
105
+ }
106
+ }
107
+
108
+ if (generated.length > 0) {
109
+ media.generatedConversions = results;
110
+ await media.save();
111
+ }
112
+
113
+ return { generated, failed };
114
+ }
115
+
116
+ /**
117
+ * Generate the responsive width ladder plus an inline placeholder.
118
+ *
119
+ * Widths at or above the source's own width are skipped — upscaling produces a
120
+ * bigger file that looks worse, and a `srcset` entry claiming a width the
121
+ * pixels do not support makes the browser pick the wrong candidate.
122
+ */
123
+ async runResponsive(media: MediaItem, widths: number[]): Promise<ResponsiveImageSet | null> {
124
+ if (!isConvertible(media.mimeType)) return null;
125
+
126
+ const source = await this.readSource(media);
127
+ if (source === null) return null;
128
+
129
+ const metadata = await this.driver.metadata(source);
130
+ if (metadata === null) return null;
131
+
132
+ const disk = diskFor(media.conversionsDisk ?? media.disk);
133
+ const directory = pathGenerator().forResponsiveImages(media);
134
+ const format = await this.resolveFormat({}, metadata.format);
135
+ const images: ResponsiveImage[] = [];
136
+
137
+ for (const width of [...widths].sort((a, b) => a - b)) {
138
+ if (width > metadata.width) continue;
139
+ try {
140
+ const result = await this.driver.convert(source, {
141
+ width,
142
+ fit: "inside",
143
+ format,
144
+ quality: this.config.quality,
145
+ withoutEnlargement: true,
146
+ });
147
+ const fileName = `${width}.${FORMAT_EXTENSION[format]}`;
148
+ await disk.put(`${directory}/${fileName}`, result.bytes, {
149
+ contentType: result.mimeType,
150
+ });
151
+ images.push({ fileName, width: result.width, height: result.height });
152
+ } catch {
153
+ // One missing rung does not invalidate the ladder.
154
+ }
155
+ }
156
+
157
+ const set: ResponsiveImageSet = { images };
158
+
159
+ if (this.config.responsivePlaceholder) {
160
+ const placeholder = await this.driver.placeholder(source);
161
+ if (placeholder !== null) set.placeholder = placeholder;
162
+ }
163
+
164
+ if (images.length === 0 && set.placeholder === undefined) return null;
165
+
166
+ media.responsiveImages = set;
167
+ await media.save();
168
+ return set;
169
+ }
170
+
171
+ /** Read the original, refusing anything too large to decode safely. */
172
+ private async readSource(media: MediaItem): Promise<Uint8Array | null> {
173
+ if (media.size > this.config.maxConversionInputSize) return null;
174
+ try {
175
+ return await media.bytes();
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * The output format for one conversion.
183
+ *
184
+ * An explicit `format` wins but is verified against the host — asking for AVIF
185
+ * where no AV1 encoder exists is a configuration error worth reporting, not
186
+ * something to silently substitute. Otherwise the source format is kept when
187
+ * it is portable, and the configured default is used when it is not (a GIF or
188
+ * BMP source has no encoder at all, so it must change).
189
+ */
190
+ private async resolveFormat(
191
+ definition: ConversionDefinition,
192
+ sourceFormat: string,
193
+ ): Promise<ConversionFormat> {
194
+ if (definition.format !== undefined) {
195
+ const requested = definition.format;
196
+ if (!PORTABLE.has(requested) && !(await this.driver.canEncode(requested))) {
197
+ throw new UnsupportedFormatError(requested, [...PORTABLE]);
198
+ }
199
+ return requested;
200
+ }
201
+
202
+ if (PORTABLE.has(sourceFormat)) return sourceFormat as SafeConversionFormat;
203
+ return this.config.format;
204
+ }
205
+
206
+ private toManipulation(
207
+ definition: ConversionDefinition,
208
+ format: ConversionFormat,
209
+ ): ImageManipulation {
210
+ return {
211
+ ...(definition.width !== undefined ? { width: definition.width } : {}),
212
+ ...(definition.height !== undefined ? { height: definition.height } : {}),
213
+ ...(definition.rotate !== undefined ? { rotate: definition.rotate } : {}),
214
+ fit: definition.fit ?? "inside",
215
+ format,
216
+ quality: definition.quality ?? this.config.quality,
217
+ };
218
+ }
219
+ }
@@ -0,0 +1,100 @@
1
+ import type { ConversionFit, ConversionFormat } from "../types.ts";
2
+
3
+ /** What a driver is asked to do to one image. */
4
+ export interface ImageManipulation {
5
+ /** Target width. Omit both dimensions to re-encode without resizing. */
6
+ width?: number;
7
+ /** Target height. */
8
+ height?: number;
9
+ /** How to fit the source into the box. Default `"inside"`. */
10
+ fit?: ConversionFit;
11
+ /** Output format. Always resolved by the caller — drivers never guess. */
12
+ format: ConversionFormat;
13
+ /** Encoder quality 1–100. */
14
+ quality?: number;
15
+ /** Clockwise rotation in degrees, applied before resizing. */
16
+ rotate?: number;
17
+ /** Never scale a source up to meet the target box. Default `true`. */
18
+ withoutEnlargement?: boolean;
19
+ }
20
+
21
+ /** A generated image and what it turned out to be. */
22
+ export interface ImageResult {
23
+ bytes: Uint8Array;
24
+ width: number;
25
+ height: number;
26
+ format: ConversionFormat;
27
+ mimeType: string;
28
+ }
29
+
30
+ /** What an image's header says it is. */
31
+ export interface ImageMetadata {
32
+ width: number;
33
+ height: number;
34
+ format: string;
35
+ }
36
+
37
+ /**
38
+ * The seam between this package and whatever actually manipulates pixels.
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.
44
+ */
45
+ export interface ImageDriver {
46
+ /** Name used in error messages. */
47
+ readonly name: string;
48
+ /** Whether `fit: "cover"` is available. */
49
+ readonly supportsCrop: boolean;
50
+
51
+ /** Read dimensions and format without decoding the whole image. */
52
+ metadata(bytes: Uint8Array): Promise<ImageMetadata | null>;
53
+
54
+ /** Apply a manipulation and return the encoded result. */
55
+ convert(bytes: Uint8Array, manipulation: ImageManipulation): Promise<ImageResult>;
56
+
57
+ /**
58
+ * A tiny inline `data:` URI of the image, for rendering while the real one
59
+ * loads. `null` when the driver cannot produce one.
60
+ */
61
+ placeholder(bytes: Uint8Array): Promise<string | null>;
62
+
63
+ /** Whether this host can encode `format`. */
64
+ canEncode(format: ConversionFormat): Promise<boolean>;
65
+ }
66
+
67
+ /** MIME type for each format a driver may emit. */
68
+ export const FORMAT_MIME: Record<ConversionFormat, string> = {
69
+ jpeg: "image/jpeg",
70
+ png: "image/png",
71
+ webp: "image/webp",
72
+ avif: "image/avif",
73
+ heic: "image/heic",
74
+ };
75
+
76
+ /** File extension for each format. */
77
+ export const FORMAT_EXTENSION: Record<ConversionFormat, string> = {
78
+ jpeg: "jpg",
79
+ png: "png",
80
+ webp: "webp",
81
+ avif: "avif",
82
+ heic: "heic",
83
+ };
84
+
85
+ /** MIME types this package will attempt to convert. */
86
+ export const CONVERTIBLE_MIME_TYPES = new Set([
87
+ "image/jpeg",
88
+ "image/png",
89
+ "image/webp",
90
+ "image/gif",
91
+ "image/avif",
92
+ "image/heic",
93
+ "image/bmp",
94
+ "image/tiff",
95
+ ]);
96
+
97
+ /** Whether a stored file is worth handing to an image driver at all. */
98
+ export function isConvertible(mimeType: string | null | undefined): boolean {
99
+ return mimeType !== null && mimeType !== undefined && CONVERTIBLE_MIME_TYPES.has(mimeType);
100
+ }