@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.
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/package.json +69 -0
- package/src/Media.ts +278 -0
- package/src/MediaAdder.ts +185 -0
- package/src/MediaFake.ts +99 -0
- package/src/MediaItem.ts +282 -0
- package/src/MediaManager.ts +111 -0
- package/src/collections/resolve.ts +39 -0
- package/src/collections/retention.ts +47 -0
- package/src/commands/MediaCleanCommand.ts +62 -0
- package/src/commands/MediaRegenerateCommand.ts +89 -0
- package/src/commands/index.ts +2 -0
- package/src/config.ts +89 -0
- package/src/conversions/BunImageDriver.ts +183 -0
- package/src/conversions/ConversionRunner.ts +219 -0
- package/src/conversions/ImageDriver.ts +100 -0
- package/src/conversions/PerformConversionsJob.ts +45 -0
- package/src/conversions/SharpImageDriver.ts +174 -0
- package/src/conversions/dispatch.ts +38 -0
- package/src/conversions/queueBridge.ts +82 -0
- package/src/errors.ts +114 -0
- package/src/facades/MediaLibrary.ts +16 -0
- package/src/index.ts +94 -0
- package/src/mediaSchemaConcern.ts +65 -0
- package/src/paths/PathGenerator.ts +47 -0
- package/src/provider/MediaProvider.ts +143 -0
- package/src/sources.ts +137 -0
- package/src/state.ts +36 -0
- package/src/support/disks.ts +78 -0
- package/src/types.ts +139 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { ServiceProvider } from "@zerotal/core";
|
|
2
|
+
import type { AppEnvironment } from "@zerotal/core";
|
|
3
|
+
import type { ConfigManager } from "@zerotal/core/config";
|
|
4
|
+
import { MediaManager } from "../MediaManager.ts";
|
|
5
|
+
import { BunImageDriver } from "../conversions/BunImageDriver.ts";
|
|
6
|
+
import { SharpImageDriver } from "../conversions/SharpImageDriver.ts";
|
|
7
|
+
import { setConversionDispatcher } from "../conversions/dispatch.ts";
|
|
8
|
+
import { mediaDefaults, type MediaConfigShape } from "../config.ts";
|
|
9
|
+
import { mediaSchemaConcern } from "../mediaSchemaConcern.ts";
|
|
10
|
+
import { setMediaState } from "../state.ts";
|
|
11
|
+
import { setPathGenerator } from "../MediaItem.ts";
|
|
12
|
+
import { DefaultPathGenerator } from "../paths/PathGenerator.ts";
|
|
13
|
+
import type { ImageDriver } from "../conversions/ImageDriver.ts";
|
|
14
|
+
|
|
15
|
+
declare module "@zerotal/core" {
|
|
16
|
+
interface ContainerBindings {
|
|
17
|
+
media: MediaManager;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Registers the media system with the application.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* // bootstrap/providers.ts
|
|
26
|
+
* import { MediaProvider } from "@zerotal/media";
|
|
27
|
+
*
|
|
28
|
+
* export default [
|
|
29
|
+
* DatabaseProvider,
|
|
30
|
+
* StorageProvider, // media writes through disks — it needs this
|
|
31
|
+
* MediaProvider,
|
|
32
|
+
* ];
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* // config/media.ts
|
|
36
|
+
* import { MediaConfig } from "@zerotal/media";
|
|
37
|
+
* export default MediaConfig({ disk: "s3", driver: "sharp" });
|
|
38
|
+
*/
|
|
39
|
+
export class MediaProvider extends ServiceProvider {
|
|
40
|
+
static override provides = ["media"] as const;
|
|
41
|
+
static override environments: AppEnvironment[] = ["web", "console", "worker", "test", "repl"];
|
|
42
|
+
|
|
43
|
+
override onRegister(): void {
|
|
44
|
+
// Provision the media table on boot, after model discovery — no migration
|
|
45
|
+
// required. Idempotent, and skipped when autoCreateTable is off.
|
|
46
|
+
this.app.registerConcern?.(mediaSchemaConcern);
|
|
47
|
+
|
|
48
|
+
this.app.container.singleton("media", () => {
|
|
49
|
+
const config = this.resolveConfig();
|
|
50
|
+
const driver = _buildDriver(config);
|
|
51
|
+
|
|
52
|
+
setMediaState({ config, driver });
|
|
53
|
+
setPathGenerator(new DefaultPathGenerator());
|
|
54
|
+
|
|
55
|
+
return new MediaManager(config, driver);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
override async onBooted(): Promise<void> {
|
|
60
|
+
// Pre-resolve so the MediaLibrary facade (makeSync) works after boot, and so the
|
|
61
|
+
// shared state is installed before the first request rather than on it.
|
|
62
|
+
const manager = (await this.app.container.make("media")) as MediaManager;
|
|
63
|
+
|
|
64
|
+
this.installQueueDispatcher();
|
|
65
|
+
await this.probeCodecs(manager);
|
|
66
|
+
this.registerCommands();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
override async onStopping(): Promise<void> {
|
|
70
|
+
setConversionDispatcher(null);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Read `config/media.ts`, falling back to defaults field by field. */
|
|
74
|
+
private resolveConfig(): MediaConfigShape {
|
|
75
|
+
const config = this.app.container.tryMake("config") as ConfigManager | null;
|
|
76
|
+
const declared = config?.get<Partial<MediaConfigShape>>("media") ?? {};
|
|
77
|
+
return { ...mediaDefaults(), ...declared };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Wire queued conversions, but only when a queue actually exists.
|
|
82
|
+
*
|
|
83
|
+
* This is what keeps `@zerotal/media` free of a dependency on
|
|
84
|
+
* `@zerotal/queue`: the job class is imported lazily, inside the branch that
|
|
85
|
+
* already knows the binding is there.
|
|
86
|
+
*/
|
|
87
|
+
private installQueueDispatcher(): void {
|
|
88
|
+
const queue = this.app.container.tryMake("queue");
|
|
89
|
+
if (!queue) {
|
|
90
|
+
setConversionDispatcher(null);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setConversionDispatcher(async (mediaId, conversions) => {
|
|
95
|
+
const { dispatchConversionJob } = await import("../conversions/queueBridge.ts");
|
|
96
|
+
await dispatchConversionJob(mediaId, conversions);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check once, at boot, which encoders this host actually has.
|
|
102
|
+
*
|
|
103
|
+
* `Bun.Image` encodes AVIF and HEIC through OS codecs that are missing on most
|
|
104
|
+
* Linux hosts. Without this the first sign of trouble is a queued job failing
|
|
105
|
+
* days later; with it, the boot log names the problem while someone is still
|
|
106
|
+
* looking at the deploy.
|
|
107
|
+
*/
|
|
108
|
+
private async probeCodecs(manager: MediaManager): Promise<void> {
|
|
109
|
+
if (!manager.config.allowHostFormats) return;
|
|
110
|
+
|
|
111
|
+
const logger = this.app.container.tryMake("log") as
|
|
112
|
+
{ warn(message: string): void } | null | undefined;
|
|
113
|
+
const missing: string[] = [];
|
|
114
|
+
|
|
115
|
+
for (const format of ["avif", "heic"] as const) {
|
|
116
|
+
if (!(await manager.driver.canEncode(format))) missing.push(format);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (missing.length > 0) {
|
|
120
|
+
logger?.warn(
|
|
121
|
+
`[media] This host cannot encode ${missing.join(", ")} — conversions ` +
|
|
122
|
+
`targeting them will fail. Use jpeg, png or webp, or install the OS codec.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private registerCommands(): void {
|
|
128
|
+
const runner = this.app.container.tryMake("commands");
|
|
129
|
+
if (!runner) return;
|
|
130
|
+
|
|
131
|
+
runner.registerLazy("media:clean", () =>
|
|
132
|
+
import("../commands/MediaCleanCommand.ts").then((m) => m.MediaCleanCommand),
|
|
133
|
+
);
|
|
134
|
+
runner.registerLazy("media:regenerate", () =>
|
|
135
|
+
import("../commands/MediaRegenerateCommand.ts").then((m) => m.MediaRegenerateCommand),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Build the configured image driver. */
|
|
141
|
+
function _buildDriver(config: MediaConfigShape): ImageDriver {
|
|
142
|
+
return config.driver === "sharp" ? new SharpImageDriver() : new BunImageDriver();
|
|
143
|
+
}
|
package/src/sources.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { UploadedFile } from "@zerotal/core/http";
|
|
2
|
+
import { MediaError } from "./errors.ts";
|
|
3
|
+
import { diskFor } from "./support/disks.ts";
|
|
4
|
+
|
|
5
|
+
/** A file's bytes plus the name it arrived under. */
|
|
6
|
+
export interface ResolvedSource {
|
|
7
|
+
bytes: Uint8Array;
|
|
8
|
+
/** Original filename, used to derive the default media name. */
|
|
9
|
+
originalName: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Anything that can be resolved to bytes on demand. */
|
|
13
|
+
export type SourceResolver = () => Promise<ResolvedSource>;
|
|
14
|
+
|
|
15
|
+
/** Things `addMedia()` accepts directly. */
|
|
16
|
+
export type MediaSource = UploadedFile | File | Blob | Uint8Array | ArrayBuffer;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Turn an in-memory source into a lazy resolver.
|
|
20
|
+
*
|
|
21
|
+
* Resolution is deferred so a rule that can reject on metadata alone — a
|
|
22
|
+
* collection that accepts only PDFs, say — does not have to buffer the file
|
|
23
|
+
* first.
|
|
24
|
+
*/
|
|
25
|
+
export function fromValue(source: MediaSource, fileName?: string): SourceResolver {
|
|
26
|
+
if (source instanceof UploadedFile) {
|
|
27
|
+
return async () => ({
|
|
28
|
+
bytes: await source.bytes(),
|
|
29
|
+
originalName: fileName ?? source.originalName,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (source instanceof File) {
|
|
34
|
+
return async () => ({
|
|
35
|
+
bytes: new Uint8Array(await source.arrayBuffer()),
|
|
36
|
+
originalName: fileName ?? source.name,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (source instanceof Blob) {
|
|
41
|
+
return async () => ({
|
|
42
|
+
bytes: new Uint8Array(await source.arrayBuffer()),
|
|
43
|
+
originalName: fileName ?? "file",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (source instanceof ArrayBuffer) {
|
|
48
|
+
return async () => ({ bytes: new Uint8Array(source), originalName: fileName ?? "file" });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (source instanceof Uint8Array) {
|
|
52
|
+
return async () => ({ bytes: source, originalName: fileName ?? "file" });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
throw new MediaError(
|
|
56
|
+
"addMedia() takes an UploadedFile, File, Blob, Uint8Array or ArrayBuffer. " +
|
|
57
|
+
"For a URL use addMediaFromUrl(), for a stored file addMediaFromDisk(), " +
|
|
58
|
+
"for a local path addMediaFromPath().",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Fetch a remote file.
|
|
64
|
+
*
|
|
65
|
+
* @param maxBytes - Refuse a response larger than this. A URL is attacker-supplied
|
|
66
|
+
* often enough that downloading whatever arrives is how one request exhausts the
|
|
67
|
+
* heap; the limit is checked against `Content-Length` first and again against
|
|
68
|
+
* what actually arrived, since the header is a claim.
|
|
69
|
+
*/
|
|
70
|
+
export function fromUrl(url: string, maxBytes: number): SourceResolver {
|
|
71
|
+
return async () => {
|
|
72
|
+
let parsed: URL;
|
|
73
|
+
try {
|
|
74
|
+
parsed = new URL(url);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new MediaError(`addMediaFromUrl() needs an absolute URL; got "${url}".`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
80
|
+
throw new MediaError(
|
|
81
|
+
`addMediaFromUrl() supports http and https; got "${parsed.protocol}".\n` +
|
|
82
|
+
"Fix: use addMediaFromPath() for a local file, addMediaFromDisk() for a stored one.",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const response = await fetch(parsed);
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
throw new MediaError(`Could not fetch ${url} — the server answered ${response.status}.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const declared = Number(response.headers.get("content-length") ?? "0");
|
|
92
|
+
if (declared > maxBytes) {
|
|
93
|
+
throw new MediaError(
|
|
94
|
+
`${url} declares ${declared} bytes, over the ${maxBytes}-byte limit. ` +
|
|
95
|
+
"Fix: raise media.maxConversionInputSize, or fetch and store it yourself.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
100
|
+
if (bytes.byteLength > maxBytes) {
|
|
101
|
+
throw new MediaError(
|
|
102
|
+
`${url} returned ${bytes.byteLength} bytes, over the ${maxBytes}-byte limit.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { bytes, originalName: _nameFromUrl(parsed) };
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Read a file already sitting on one of the app's storage disks. */
|
|
111
|
+
export function fromDisk(path: string, disk?: string): SourceResolver {
|
|
112
|
+
return async () => {
|
|
113
|
+
const bytes = await diskFor(disk).getBuffer(path);
|
|
114
|
+
if (bytes === null) {
|
|
115
|
+
throw new MediaError(`No file at "${path}" on disk "${disk ?? "default"}".`);
|
|
116
|
+
}
|
|
117
|
+
return { bytes, originalName: path.split("/").pop() ?? "file" };
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Read a file from the local filesystem. */
|
|
122
|
+
export function fromPath(path: string): SourceResolver {
|
|
123
|
+
return async () => {
|
|
124
|
+
const file = Bun.file(path);
|
|
125
|
+
if (!(await file.exists())) throw new MediaError(`No file at "${path}".`);
|
|
126
|
+
return {
|
|
127
|
+
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
128
|
+
originalName: path.split(/[/\\]/).pop() ?? "file",
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The filename a URL implies, ignoring query and fragment. */
|
|
134
|
+
function _nameFromUrl(url: URL): string {
|
|
135
|
+
const last = url.pathname.split("/").filter(Boolean).pop();
|
|
136
|
+
return last !== undefined && last !== "" ? decodeURIComponent(last) : "file";
|
|
137
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { BunImageDriver } from "./conversions/BunImageDriver.ts";
|
|
2
|
+
import { mediaDefaults, type MediaConfigShape } from "./config.ts";
|
|
3
|
+
import type { ImageDriver } from "./conversions/ImageDriver.ts";
|
|
4
|
+
|
|
5
|
+
/** The resolved config and image driver the package operates with. */
|
|
6
|
+
export interface MediaState {
|
|
7
|
+
config: MediaConfigShape;
|
|
8
|
+
driver: ImageDriver;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let _state: MediaState | null = null;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The active media state.
|
|
15
|
+
*
|
|
16
|
+
* `MediaProvider` installs the real one at boot. Falling back to defaults rather
|
|
17
|
+
* than throwing keeps the model usable in a unit test that never builds an
|
|
18
|
+
* application — which is most tests that touch a model.
|
|
19
|
+
*/
|
|
20
|
+
export function mediaState(): MediaState {
|
|
21
|
+
if (_state === null) {
|
|
22
|
+
const config = mediaDefaults();
|
|
23
|
+
_state = { config, driver: new BunImageDriver() };
|
|
24
|
+
}
|
|
25
|
+
return _state;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Install the resolved state. Called by `MediaProvider`. */
|
|
29
|
+
export function setMediaState(state: MediaState): void {
|
|
30
|
+
_state = state;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Drop the state so the next read rebuilds it from defaults. For tests. */
|
|
34
|
+
export function resetMediaState(): void {
|
|
35
|
+
_state = null;
|
|
36
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { tryCurrentApp } from "@zerotal/core";
|
|
2
|
+
import { Storage } from "@zerotal/core/storage";
|
|
3
|
+
import type { StorageDriver } from "@zerotal/core/storage";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How this package gets hold of a disk.
|
|
7
|
+
*
|
|
8
|
+
* `undefined` means "the default disk", matching `StorageManager.disk()`.
|
|
9
|
+
*/
|
|
10
|
+
export type DiskResolver = (name?: string) => StorageDriver;
|
|
11
|
+
|
|
12
|
+
let _resolver: DiskResolver | null = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Override how disks are resolved.
|
|
16
|
+
*
|
|
17
|
+
* The default path goes through the `Storage` facade, which needs an ambient
|
|
18
|
+
* application. That is right in an app and awkward in a unit test, which has a
|
|
19
|
+
* `StorageManager` in hand and no reason to build a container around it — so
|
|
20
|
+
* this is the seam. Pass `null` to restore facade resolution.
|
|
21
|
+
*
|
|
22
|
+
* `Storage.fake()` still works either way: it swaps the driver inside the
|
|
23
|
+
* manager, so whichever manager is resolved is the one that was faked.
|
|
24
|
+
*/
|
|
25
|
+
export function setDiskResolver(resolver: DiskResolver | null): void {
|
|
26
|
+
_resolver = resolver;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a disk by name, treating empty/absent as "the configured default".
|
|
31
|
+
*
|
|
32
|
+
* `StorageManager.disk()` falls back to the default only for `undefined` — an
|
|
33
|
+
* empty string is looked up literally and throws `DiskNotConfiguredError`. Media
|
|
34
|
+
* config uses `""` to mean "inherit the default", so every lookup goes through
|
|
35
|
+
* here rather than reaching for `Storage.disk()` directly.
|
|
36
|
+
*/
|
|
37
|
+
export function diskFor(name?: string | null): StorageDriver {
|
|
38
|
+
const trimmed = name?.trim();
|
|
39
|
+
const resolved = trimmed !== undefined && trimmed !== "" ? trimmed : undefined;
|
|
40
|
+
|
|
41
|
+
if (_resolver !== null) return _resolver(resolved);
|
|
42
|
+
return resolved === undefined ? Storage.disk() : Storage.disk(resolved);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The name a disk reference resolves to, for storing in the `disk` column. */
|
|
46
|
+
export function diskNameFor(name: string | null | undefined, fallback: string): string {
|
|
47
|
+
const trimmed = name?.trim();
|
|
48
|
+
return trimmed !== undefined && trimmed !== "" ? trimmed : fallback;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let _defaultName: string | null = null;
|
|
52
|
+
|
|
53
|
+
/** Override the recorded default disk name. Pass `null` to read it from config. */
|
|
54
|
+
export function setDefaultDiskName(name: string | null): void {
|
|
55
|
+
_defaultName = name;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The concrete name of the default disk, e.g. `"local"`.
|
|
60
|
+
*
|
|
61
|
+
* Media rows record the disk they were written to by name rather than storing
|
|
62
|
+
* `""` and re-resolving on read. An app that later flips `storage.default` from
|
|
63
|
+
* `local` to `s3` would otherwise find every existing media row suddenly
|
|
64
|
+
* claiming to live on S3, where none of the files are.
|
|
65
|
+
*
|
|
66
|
+
* Falls back to `""` when there is no app or no storage config to ask — the read
|
|
67
|
+
* path treats that as "the default", which is the best answer available.
|
|
68
|
+
*/
|
|
69
|
+
export function defaultDiskName(): string {
|
|
70
|
+
if (_defaultName !== null) return _defaultName;
|
|
71
|
+
try {
|
|
72
|
+
const config = tryCurrentApp()?.container.tryMake("config") as
|
|
73
|
+
{ get<T>(path: string, fallback?: T): T | undefined } | null | undefined;
|
|
74
|
+
return config?.get<string>("storage.default", "") ?? "";
|
|
75
|
+
} catch {
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// ── Public types for @zerotal/media ──────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Output formats a conversion may target.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately narrower than what {@link https://bun.sh/docs/api/image | Bun.Image}
|
|
7
|
+
* can emit. `Bun.Image` reports `backend: "system"`, meaning AVIF/HEIC/TIFF encode
|
|
8
|
+
* through OS codecs that are simply absent on many hosts — a conversion that works
|
|
9
|
+
* on a developer's laptop then fails inside a queued job on an Alpine container.
|
|
10
|
+
* These three are available everywhere.
|
|
11
|
+
*
|
|
12
|
+
* Need AVIF? Set `allowHostFormats` in config and use {@link ConversionFormat} —
|
|
13
|
+
* the boot probe will tell you whether this host can actually do it.
|
|
14
|
+
*/
|
|
15
|
+
export type SafeConversionFormat = "jpeg" | "png" | "webp";
|
|
16
|
+
|
|
17
|
+
/** Every format the image driver may be asked for, including host-dependent ones. */
|
|
18
|
+
export type ConversionFormat = SafeConversionFormat | "avif" | "heic";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How a source image is fitted into the target box.
|
|
22
|
+
*
|
|
23
|
+
* - `inside` — scale down to fit within `width` × `height`, preserving aspect
|
|
24
|
+
* ratio. The result may be smaller than the box in one dimension.
|
|
25
|
+
* - `fill` — stretch to exactly `width` × `height`, ignoring aspect ratio.
|
|
26
|
+
* - `cover` — scale and centre-crop to exactly fill the box, preserving aspect
|
|
27
|
+
* ratio. **Requires an image driver that can crop**; the default
|
|
28
|
+
* `BunImageDriver` cannot, and says so with a named error.
|
|
29
|
+
*/
|
|
30
|
+
export type ConversionFit = "inside" | "fill" | "cover";
|
|
31
|
+
|
|
32
|
+
/** A single derived image generated from an original. */
|
|
33
|
+
export interface ConversionDefinition {
|
|
34
|
+
/** Target width in pixels. At least one of `width`/`height` is required. */
|
|
35
|
+
width?: number;
|
|
36
|
+
/** Target height in pixels. */
|
|
37
|
+
height?: number;
|
|
38
|
+
/** How to fit the source into the box. Default: `"inside"`. */
|
|
39
|
+
fit?: ConversionFit;
|
|
40
|
+
/** Output format. Default: the original's format when safe, else `"jpeg"`. */
|
|
41
|
+
format?: ConversionFormat;
|
|
42
|
+
/** Encoder quality, 1–100. Default: 82. */
|
|
43
|
+
quality?: number;
|
|
44
|
+
/** Clockwise rotation in degrees applied before resizing. */
|
|
45
|
+
rotate?: number;
|
|
46
|
+
/** Generate this conversion on the queue instead of inline. Default: `false`. */
|
|
47
|
+
queued?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A named set of conversions. */
|
|
51
|
+
export type ConversionMap = Record<string, ConversionDefinition>;
|
|
52
|
+
|
|
53
|
+
/** Declarative rules for one media collection. */
|
|
54
|
+
export interface CollectionDefinition {
|
|
55
|
+
/** Disk originals are written to. Default: the configured default disk. */
|
|
56
|
+
disk?: string;
|
|
57
|
+
/** Disk conversions are written to. Default: whatever `disk` resolves to. */
|
|
58
|
+
conversionsDisk?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Allowed MIME types. Checked against the type **sniffed from the file's own
|
|
61
|
+
* bytes**, never the client-supplied one, so renaming `payload.html` to
|
|
62
|
+
* `photo.jpg` does not get past it.
|
|
63
|
+
*/
|
|
64
|
+
accepts?: string[];
|
|
65
|
+
/** Maximum accepted size in bytes for a single file. */
|
|
66
|
+
maxSize?: number;
|
|
67
|
+
/** Keep only the most recently added file; adding a second removes the first. */
|
|
68
|
+
single?: boolean;
|
|
69
|
+
/** Keep only the `n` most recent files, removing older ones as they fall out. */
|
|
70
|
+
onlyKeepLatest?: number;
|
|
71
|
+
/** URL returned by `getFirstMediaUrl()` when the collection is empty. */
|
|
72
|
+
fallbackUrl?: string;
|
|
73
|
+
/** Path returned by `getFirstMediaPath()` when the collection is empty. */
|
|
74
|
+
fallbackPath?: string;
|
|
75
|
+
/** Conversions generated for files in this collection. */
|
|
76
|
+
conversions?: ConversionMap;
|
|
77
|
+
/**
|
|
78
|
+
* Generate a responsive width ladder plus an inline blur placeholder.
|
|
79
|
+
* `true` uses the configured default widths; an array pins them explicitly.
|
|
80
|
+
*/
|
|
81
|
+
responsive?: boolean | number[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The collections a model declares, as the static `mediaCollections` field.
|
|
86
|
+
*
|
|
87
|
+
* A function is accepted for values only known at runtime (a per-tenant disk,
|
|
88
|
+
* say); it is called once per operation, not cached.
|
|
89
|
+
*/
|
|
90
|
+
export type MediaCollections = Record<string, CollectionDefinition | (() => CollectionDefinition)>;
|
|
91
|
+
|
|
92
|
+
/** What a generated conversion records in the `generated_conversions` column. */
|
|
93
|
+
export interface GeneratedConversion {
|
|
94
|
+
/** File name on the conversions disk, e.g. `thumb.webp`. */
|
|
95
|
+
fileName: string;
|
|
96
|
+
/** Bytes written. */
|
|
97
|
+
size: number;
|
|
98
|
+
/** MIME type of the generated file. */
|
|
99
|
+
mimeType: string;
|
|
100
|
+
width: number;
|
|
101
|
+
height: number;
|
|
102
|
+
/** ISO-8601 timestamp of generation. */
|
|
103
|
+
generatedAt: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One entry in the responsive width ladder. */
|
|
107
|
+
export interface ResponsiveImage {
|
|
108
|
+
fileName: string;
|
|
109
|
+
width: number;
|
|
110
|
+
height: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The `responsive_images` column payload. */
|
|
114
|
+
export interface ResponsiveImageSet {
|
|
115
|
+
/** Generated widths, ascending. */
|
|
116
|
+
images: ResponsiveImage[];
|
|
117
|
+
/** A `data:image/png;base64,…` low-quality placeholder, when one was produced. */
|
|
118
|
+
placeholder?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Metadata carried alongside a file being added to a collection. */
|
|
122
|
+
export interface PendingMediaMeta {
|
|
123
|
+
/** Human-facing label. Defaults to the original filename without extension. */
|
|
124
|
+
name?: string;
|
|
125
|
+
/** Arbitrary JSON stored in `custom_properties`. */
|
|
126
|
+
customProperties?: Record<string, unknown>;
|
|
127
|
+
/** Explicit sort position. Defaults to the end of the collection. */
|
|
128
|
+
order?: number;
|
|
129
|
+
/** Override the collection's disk for this one file. */
|
|
130
|
+
disk?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The minimum a model must expose for media to attach to it. */
|
|
134
|
+
export interface MediaOwner {
|
|
135
|
+
/** Primary key. */
|
|
136
|
+
id: number | string;
|
|
137
|
+
/** Discriminator written to `model_type`. */
|
|
138
|
+
readonly constructor: { name: string };
|
|
139
|
+
}
|