@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,45 @@
|
|
|
1
|
+
import { Job } from "@zerotal/queue";
|
|
2
|
+
import { performConversions } from "./queueBridge.ts";
|
|
3
|
+
import { mediaState } from "../state.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generates a media item's conversions on a worker instead of in the request.
|
|
7
|
+
*
|
|
8
|
+
* Only reachable when `@zerotal/queue` is installed — `MediaProvider` imports
|
|
9
|
+
* this module lazily, inside the branch that has already found a `queue`
|
|
10
|
+
* binding. Apps without a queue generate every conversion inline and never load
|
|
11
|
+
* this file.
|
|
12
|
+
*/
|
|
13
|
+
export class PerformConversionsJob extends Job {
|
|
14
|
+
override readonly queue: string;
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
readonly mediaId: number,
|
|
18
|
+
readonly conversions: string[],
|
|
19
|
+
queue?: string,
|
|
20
|
+
) {
|
|
21
|
+
super();
|
|
22
|
+
this.queue = queue ?? mediaState().config.queue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
override payload(): Record<string, unknown> {
|
|
26
|
+
return { mediaId: this.mediaId, conversions: this.conversions, queue: this.queue };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static fromPayload(payload: Record<string, unknown>): PerformConversionsJob {
|
|
30
|
+
return new PerformConversionsJob(
|
|
31
|
+
Number(payload["mediaId"]),
|
|
32
|
+
(payload["conversions"] as string[] | undefined) ?? [],
|
|
33
|
+
payload["queue"] as string | undefined,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A media row deleted between dispatch and execution makes this a no-op, not a
|
|
39
|
+
* failure: retrying cannot bring the row back, so throwing would just burn
|
|
40
|
+
* every attempt before landing in the failed queue for no one to act on.
|
|
41
|
+
*/
|
|
42
|
+
async handle(): Promise<void> {
|
|
43
|
+
await performConversions(this.mediaId, this.conversions);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FORMAT_MIME,
|
|
3
|
+
type ImageDriver,
|
|
4
|
+
type ImageManipulation,
|
|
5
|
+
type ImageMetadata,
|
|
6
|
+
type ImageResult,
|
|
7
|
+
} from "./ImageDriver.ts";
|
|
8
|
+
import { MediaError } from "../errors.ts";
|
|
9
|
+
import type { ConversionFormat } from "../types.ts";
|
|
10
|
+
|
|
11
|
+
// ── Minimal structural view of sharp ──────────────────────────────────────────
|
|
12
|
+
// `sharp` is an optional peer: apps that never crop never install it, so we
|
|
13
|
+
// cannot import its types. This is only the slice this driver uses.
|
|
14
|
+
|
|
15
|
+
interface SharpPipeline {
|
|
16
|
+
rotate(degrees?: number): SharpPipeline;
|
|
17
|
+
resize(options: {
|
|
18
|
+
width?: number;
|
|
19
|
+
height?: number;
|
|
20
|
+
fit?: "cover" | "contain" | "fill" | "inside" | "outside";
|
|
21
|
+
withoutEnlargement?: boolean;
|
|
22
|
+
}): SharpPipeline;
|
|
23
|
+
jpeg(options?: { quality?: number }): SharpPipeline;
|
|
24
|
+
png(options?: { quality?: number }): SharpPipeline;
|
|
25
|
+
webp(options?: { quality?: number }): SharpPipeline;
|
|
26
|
+
avif(options?: { quality?: number }): SharpPipeline;
|
|
27
|
+
heif(options?: { quality?: number; compression?: string }): SharpPipeline;
|
|
28
|
+
metadata(): Promise<{ width?: number; height?: number; format?: string }>;
|
|
29
|
+
toBuffer(options: { resolveWithObject: true }): Promise<{
|
|
30
|
+
data: Uint8Array;
|
|
31
|
+
info: { width: number; height: number };
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type SharpFactory = (input: Uint8Array) => SharpPipeline;
|
|
36
|
+
|
|
37
|
+
let _sharp: SharpFactory | null = null;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load `sharp` once, with an error that says what to install if it is absent.
|
|
41
|
+
*
|
|
42
|
+
* The specifier goes through a variable so the compiler does not try to resolve
|
|
43
|
+
* it: `sharp` is genuinely optional, and a literal `import("sharp")` would fail
|
|
44
|
+
* `tsc` in every app that has not installed it — including all the ones using
|
|
45
|
+
* the default driver, which is most of them.
|
|
46
|
+
*/
|
|
47
|
+
async function loadSharp(): Promise<SharpFactory> {
|
|
48
|
+
if (_sharp) return _sharp;
|
|
49
|
+
const specifier = "sharp";
|
|
50
|
+
try {
|
|
51
|
+
const module = (await import(specifier)) as {
|
|
52
|
+
default?: SharpFactory;
|
|
53
|
+
} & SharpFactory;
|
|
54
|
+
_sharp = module.default ?? module;
|
|
55
|
+
return _sharp;
|
|
56
|
+
} catch {
|
|
57
|
+
throw new MediaError(
|
|
58
|
+
'config/media.ts sets driver: "sharp" but the sharp package is not installed.\n' +
|
|
59
|
+
"Fix: `bun add sharp`, or switch back to the built-in driver with " +
|
|
60
|
+
'driver: "bun" (which cannot crop — see fit: "cover").',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Image processing on `sharp`, for apps that need what `Bun.Image` does not do —
|
|
67
|
+
* principally `fit: "cover"`, the centre-crop behind every square thumbnail.
|
|
68
|
+
*
|
|
69
|
+
* Opt in by installing `sharp` and setting `driver: "sharp"` in `config/media.ts`.
|
|
70
|
+
* `sharp` builds against Node-API v9, which Bun implements, so it runs here; it
|
|
71
|
+
* is a native module, so it does add an install step and a platform-specific
|
|
72
|
+
* binary, which is exactly why it is not the default.
|
|
73
|
+
*/
|
|
74
|
+
export class SharpImageDriver implements ImageDriver {
|
|
75
|
+
readonly name = "SharpImageDriver";
|
|
76
|
+
readonly supportsCrop = true;
|
|
77
|
+
|
|
78
|
+
async metadata(bytes: Uint8Array): Promise<ImageMetadata | null> {
|
|
79
|
+
try {
|
|
80
|
+
const sharp = await loadSharp();
|
|
81
|
+
const meta = await sharp(bytes).metadata();
|
|
82
|
+
if (meta.width === undefined || meta.height === undefined) return null;
|
|
83
|
+
return { width: meta.width, height: meta.height, format: meta.format ?? "unknown" };
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async convert(bytes: Uint8Array, manipulation: ImageManipulation): Promise<ImageResult> {
|
|
90
|
+
const { width, height, fit = "inside", format, quality, rotate } = manipulation;
|
|
91
|
+
const sharp = await loadSharp();
|
|
92
|
+
|
|
93
|
+
let pipeline = sharp(bytes);
|
|
94
|
+
|
|
95
|
+
if (rotate !== undefined && rotate !== 0) pipeline = pipeline.rotate(rotate);
|
|
96
|
+
|
|
97
|
+
if (width !== undefined || height !== undefined) {
|
|
98
|
+
pipeline = pipeline.resize({
|
|
99
|
+
...(width !== undefined ? { width } : {}),
|
|
100
|
+
...(height !== undefined ? { height } : {}),
|
|
101
|
+
fit,
|
|
102
|
+
withoutEnlargement: manipulation.withoutEnlargement ?? true,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
pipeline = _applyFormat(pipeline, format, quality);
|
|
107
|
+
|
|
108
|
+
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
bytes: data,
|
|
112
|
+
width: info.width,
|
|
113
|
+
height: info.height,
|
|
114
|
+
format,
|
|
115
|
+
mimeType: FORMAT_MIME[format],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async placeholder(bytes: Uint8Array): Promise<string | null> {
|
|
120
|
+
try {
|
|
121
|
+
const sharp = await loadSharp();
|
|
122
|
+
// No ThumbHash in sharp, so a tiny blurred WebP stands in. Larger than
|
|
123
|
+
// Bun.Image's placeholder, and still small enough to inline.
|
|
124
|
+
const { data } = await sharp(bytes)
|
|
125
|
+
.resize({ width: 32, fit: "inside" })
|
|
126
|
+
.webp({ quality: 40 })
|
|
127
|
+
.toBuffer({ resolveWithObject: true });
|
|
128
|
+
return `data:image/webp;base64,${Buffer.from(data).toString("base64")}`;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async canEncode(format: ConversionFormat): Promise<boolean> {
|
|
135
|
+
try {
|
|
136
|
+
const sharp = await loadSharp();
|
|
137
|
+
await _applyFormat(sharp(_PROBE_PNG).resize({ width: 2 }), format, 60).toBuffer({
|
|
138
|
+
resolveWithObject: true,
|
|
139
|
+
});
|
|
140
|
+
return true;
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function _applyFormat(
|
|
148
|
+
pipeline: SharpPipeline,
|
|
149
|
+
format: ConversionFormat,
|
|
150
|
+
quality: number | undefined,
|
|
151
|
+
): SharpPipeline {
|
|
152
|
+
const options = quality === undefined ? undefined : { quality };
|
|
153
|
+
switch (format) {
|
|
154
|
+
case "jpeg":
|
|
155
|
+
return pipeline.jpeg(options);
|
|
156
|
+
case "png":
|
|
157
|
+
return pipeline.png(options);
|
|
158
|
+
case "webp":
|
|
159
|
+
return pipeline.webp(options);
|
|
160
|
+
case "avif":
|
|
161
|
+
return pipeline.avif(options);
|
|
162
|
+
case "heic":
|
|
163
|
+
return pipeline.heif({ ...(options ?? {}), compression: "hevc" });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A valid 2×2 PNG, used to probe which encoders are available. */
|
|
168
|
+
const _PROBE_PNG = Uint8Array.from(
|
|
169
|
+
atob(
|
|
170
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAD0lEQVR4nGMsY2AoY0AGAA5uAO6e" +
|
|
171
|
+
"5/phAAAAAElFTkSuQmCC",
|
|
172
|
+
),
|
|
173
|
+
(c) => c.charCodeAt(0),
|
|
174
|
+
);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handles a set of conversions off the request path.
|
|
3
|
+
*
|
|
4
|
+
* @param mediaId - Row to convert.
|
|
5
|
+
* @param conversions - Names of the conversions to generate.
|
|
6
|
+
*/
|
|
7
|
+
export type ConversionDispatcher = (mediaId: number, conversions: string[]) => Promise<void>;
|
|
8
|
+
|
|
9
|
+
let _dispatcher: ConversionDispatcher | null = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Install the queue-backed dispatcher.
|
|
13
|
+
*
|
|
14
|
+
* `MediaProvider` calls this only when a `queue` binding exists, which is what
|
|
15
|
+
* keeps `@zerotal/media` free of a hard dependency on `@zerotal/queue`: an app
|
|
16
|
+
* that never queues never pulls the package in, and one that does gets deferred
|
|
17
|
+
* conversions with no extra wiring.
|
|
18
|
+
*/
|
|
19
|
+
export function setConversionDispatcher(dispatcher: ConversionDispatcher | null): void {
|
|
20
|
+
_dispatcher = dispatcher;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether conversions can currently be deferred. */
|
|
24
|
+
export function isQueueAvailable(): boolean {
|
|
25
|
+
return _dispatcher !== null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Hand conversions to the queue.
|
|
30
|
+
*
|
|
31
|
+
* A no-op when nothing is installed — callers check {@link isQueueAvailable}
|
|
32
|
+
* first and run inline instead, so this is only reached if a queue disappeared
|
|
33
|
+
* between the check and the dispatch.
|
|
34
|
+
*/
|
|
35
|
+
export async function dispatchConversions(mediaId: number, conversions: string[]): Promise<void> {
|
|
36
|
+
if (_dispatcher === null) return;
|
|
37
|
+
await _dispatcher(mediaId, conversions);
|
|
38
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { modelByName } from "@zerotal/orm";
|
|
2
|
+
import { MediaItem } from "../MediaItem.ts";
|
|
3
|
+
import { ConversionRunner } from "./ConversionRunner.ts";
|
|
4
|
+
import { resolveCollection, hasCollection, type CollectionHost } from "../collections/resolve.ts";
|
|
5
|
+
import { mediaState } from "../state.ts";
|
|
6
|
+
import type { ConversionMap } from "../types.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Work that a queued conversion job performs, plus the dispatch that puts it
|
|
10
|
+
* there.
|
|
11
|
+
*
|
|
12
|
+
* `@zerotal/queue` is imported dynamically and only from {@link dispatchConversionJob},
|
|
13
|
+
* which is itself only reached when the container already has a `queue` binding.
|
|
14
|
+
* That is what lets `@zerotal/media` list the queue as neither a dependency nor
|
|
15
|
+
* a peer.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Regenerate a named set of conversions for one media row. */
|
|
19
|
+
export async function performConversions(
|
|
20
|
+
mediaId: number,
|
|
21
|
+
conversions: string[],
|
|
22
|
+
): Promise<{ generated: string[]; failed: Array<{ name: string; reason: string }> }> {
|
|
23
|
+
const empty = {
|
|
24
|
+
generated: [] as string[],
|
|
25
|
+
failed: [] as Array<{ name: string; reason: string }>,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const media = await MediaItem.find(mediaId);
|
|
29
|
+
if (media === null) return empty;
|
|
30
|
+
|
|
31
|
+
const ownerClass = ownerClassFor(media.modelType);
|
|
32
|
+
if (ownerClass === null || !hasCollection(ownerClass, media.collectionName)) return empty;
|
|
33
|
+
|
|
34
|
+
const declared = resolveCollection(ownerClass, media.collectionName).conversions ?? {};
|
|
35
|
+
|
|
36
|
+
const wanted: ConversionMap = {};
|
|
37
|
+
for (const name of conversions) {
|
|
38
|
+
const conversion = declared[name];
|
|
39
|
+
if (conversion !== undefined) wanted[name] = conversion;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const state = mediaState();
|
|
43
|
+
return new ConversionRunner(state.driver, state.config).run(media, wanted);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Queue a conversion job.
|
|
48
|
+
*
|
|
49
|
+
* If the dispatch itself fails — no queue driver, a Redis that just went away —
|
|
50
|
+
* the conversion runs inline instead. A thumbnail generated on the request
|
|
51
|
+
* thread is worse than one generated on a worker, and much better than one that
|
|
52
|
+
* never appears at all.
|
|
53
|
+
*/
|
|
54
|
+
export async function dispatchConversionJob(mediaId: number, conversions: string[]): Promise<void> {
|
|
55
|
+
try {
|
|
56
|
+
const [{ PerformConversionsJob }, { Queue, JobRegistry }] = await Promise.all([
|
|
57
|
+
import("./PerformConversionsJob.ts"),
|
|
58
|
+
import("@zerotal/queue"),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
// The worker deserialises by class name, so the class has to be findable
|
|
62
|
+
// before the job is popped — not merely before it is pushed.
|
|
63
|
+
JobRegistry.register(PerformConversionsJob);
|
|
64
|
+
|
|
65
|
+
await Queue.dispatch(new PerformConversionsJob(mediaId, conversions));
|
|
66
|
+
} catch {
|
|
67
|
+
await performConversions(mediaId, conversions);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Look up a model class by the name stored in `model_type`.
|
|
73
|
+
*
|
|
74
|
+
* The ORM registry is populated by model discovery, so this resolves anything
|
|
75
|
+
* under `app/models/`. A type that is not registered — a model defined inline in
|
|
76
|
+
* a test, say — yields null and the job becomes a no-op, rather than a job that
|
|
77
|
+
* can never succeed retrying until it exhausts its attempts.
|
|
78
|
+
*/
|
|
79
|
+
export function ownerClassFor(modelType: string): CollectionHost | null {
|
|
80
|
+
const found = modelByName(modelType);
|
|
81
|
+
return (found as CollectionHost | undefined) ?? null;
|
|
82
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { ZerotalError } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
/** Base class for every error this package throws. */
|
|
4
|
+
export class MediaError extends ZerotalError {
|
|
5
|
+
constructor(message: string, code = "E_MEDIA", status = 500, context?: Record<string, unknown>) {
|
|
6
|
+
super(message, code, status, context);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A collection was referenced that the model never declared. */
|
|
11
|
+
export class UnknownCollectionError extends MediaError {
|
|
12
|
+
constructor(model: string, collection: string, known: string[]) {
|
|
13
|
+
const list = known.length > 0 ? known.map((c) => `"${c}"`).join(", ") : "none declared";
|
|
14
|
+
super(
|
|
15
|
+
`[Zerotal Media] ${model} has no media collection "${collection}". Declared: ${list}.\n` +
|
|
16
|
+
`Fix: add it to the static mediaCollections field on ${model}.`,
|
|
17
|
+
"E_MEDIA_UNKNOWN_COLLECTION",
|
|
18
|
+
500,
|
|
19
|
+
{ model, collection, known },
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The file's sniffed type is not in the collection's `accepts` list.
|
|
26
|
+
*
|
|
27
|
+
* A 422 rather than a 500: the upload was understood and refused, which is the
|
|
28
|
+
* client's problem to fix.
|
|
29
|
+
*/
|
|
30
|
+
export class DisallowedMimeTypeError extends MediaError {
|
|
31
|
+
constructor(collection: string, actual: string, allowed: string[]) {
|
|
32
|
+
super(
|
|
33
|
+
`[Zerotal Media] Collection "${collection}" does not accept ${actual}. ` +
|
|
34
|
+
`Allowed: ${allowed.join(", ")}.\n` +
|
|
35
|
+
"Note: the type is read from the file's own bytes, not from the upload's " +
|
|
36
|
+
"Content-Type header, so a renamed file is still rejected.",
|
|
37
|
+
"E_MEDIA_DISALLOWED_MIME_TYPE",
|
|
38
|
+
422,
|
|
39
|
+
{ collection, actual, allowed },
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The file is larger than the collection's `maxSize`. */
|
|
45
|
+
export class FileTooLargeError extends MediaError {
|
|
46
|
+
constructor(collection: string, actual: number, max: number) {
|
|
47
|
+
super(
|
|
48
|
+
`[Zerotal Media] File is ${actual} bytes; collection "${collection}" allows at most ${max}.\n` +
|
|
49
|
+
"Fix: raise maxSize on the collection, or compress before uploading.",
|
|
50
|
+
"E_MEDIA_FILE_TOO_LARGE",
|
|
51
|
+
413,
|
|
52
|
+
{ collection, actual, max },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A conversion asked for something the active image driver cannot do.
|
|
59
|
+
*
|
|
60
|
+
* Overwhelmingly this is `fit: "cover"` on the default `BunImageDriver`:
|
|
61
|
+
* `Bun.Image.resize()` accepts only `fit: 'fill' | 'inside'` and the class
|
|
62
|
+
* exposes no crop primitive, so a centre-cropped thumbnail is not expressible.
|
|
63
|
+
*/
|
|
64
|
+
export class UnsupportedManipulationError extends MediaError {
|
|
65
|
+
constructor(driver: string, what: string, fix: string) {
|
|
66
|
+
super(
|
|
67
|
+
`[Zerotal Media] ${driver} cannot ${what}.\nFix: ${fix}`,
|
|
68
|
+
"E_MEDIA_UNSUPPORTED_MANIPULATION",
|
|
69
|
+
500,
|
|
70
|
+
{ driver },
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The requested output format is not encodable on this host. */
|
|
76
|
+
export class UnsupportedFormatError extends MediaError {
|
|
77
|
+
constructor(format: string, available: string[]) {
|
|
78
|
+
super(
|
|
79
|
+
`[Zerotal Media] This host cannot encode ${format}. Available: ${available.join(", ")}.\n` +
|
|
80
|
+
"Bun.Image encodes AVIF/HEIC/TIFF through OS codecs, which are absent here. " +
|
|
81
|
+
"Fix: use jpeg, png, or webp — or run on a host with the codec installed.",
|
|
82
|
+
"E_MEDIA_UNSUPPORTED_FORMAT",
|
|
83
|
+
500,
|
|
84
|
+
{ format, available },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A file could not be read back from the disk it was recorded on. */
|
|
90
|
+
export class MediaFileMissingError extends MediaError {
|
|
91
|
+
constructor(path: string, disk: string) {
|
|
92
|
+
super(
|
|
93
|
+
`[Zerotal Media] No file at "${path}" on disk "${disk}", but a media row points at it.\n` +
|
|
94
|
+
"Fix: run `bun zt media:clean` to reconcile rows against disks.",
|
|
95
|
+
"E_MEDIA_FILE_MISSING",
|
|
96
|
+
404,
|
|
97
|
+
{ path, disk },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Media was added to a model that has not been saved yet. */
|
|
103
|
+
export class UnsavedOwnerError extends MediaError {
|
|
104
|
+
constructor(model: string) {
|
|
105
|
+
super(
|
|
106
|
+
`[Zerotal Media] Cannot attach media to an unsaved ${model} — it has no id yet ` +
|
|
107
|
+
"to record in model_id.\n" +
|
|
108
|
+
`Fix: await ${model.toLowerCase()}.save() (or use .create()) before adding media.`,
|
|
109
|
+
"E_MEDIA_UNSAVED_OWNER",
|
|
110
|
+
500,
|
|
111
|
+
{ model },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createFacade } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Facade over the container's `media` binding.
|
|
5
|
+
*
|
|
6
|
+
* Named `MediaLibrary` rather than `Media` because {@link Media} is the model
|
|
7
|
+
* mixin, and an app importing both would otherwise have to rename one at every
|
|
8
|
+
* call site. The facade is the rarer of the two.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { MediaLibrary } from "@zerotal/media";
|
|
12
|
+
*
|
|
13
|
+
* await MediaLibrary.regenerate(media, Product, ["thumb"]);
|
|
14
|
+
* const report = await MediaLibrary.clean({ dryRun: false });
|
|
15
|
+
*/
|
|
16
|
+
export const MediaLibrary = createFacade("media");
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @zerotal/media — public API
|
|
2
|
+
|
|
3
|
+
// Model + model mixin
|
|
4
|
+
// `Media` is the mixin — it reads as `Model.using(Media)`. `MediaItem` is one
|
|
5
|
+
// stored file: a row in the `media` table.
|
|
6
|
+
export { Media } from "./Media.ts";
|
|
7
|
+
export { MediaItem, pathGenerator, setPathGenerator } from "./MediaItem.ts";
|
|
8
|
+
export { MediaAdder } from "./MediaAdder.ts";
|
|
9
|
+
|
|
10
|
+
// Application-level operations
|
|
11
|
+
export { MediaManager } from "./MediaManager.ts";
|
|
12
|
+
export type { CleanReport } from "./MediaManager.ts";
|
|
13
|
+
export { MediaLibrary } from "./facades/MediaLibrary.ts";
|
|
14
|
+
export { MediaProvider } from "./provider/MediaProvider.ts";
|
|
15
|
+
|
|
16
|
+
// Collections
|
|
17
|
+
export { resolveCollection, hasCollection, collectionNames } from "./collections/resolve.ts";
|
|
18
|
+
export type { CollectionHost } from "./collections/resolve.ts";
|
|
19
|
+
export { applyRetentionRules } from "./collections/retention.ts";
|
|
20
|
+
|
|
21
|
+
// Conversions
|
|
22
|
+
export { ConversionRunner, partitionConversions } from "./conversions/ConversionRunner.ts";
|
|
23
|
+
export { BunImageDriver } from "./conversions/BunImageDriver.ts";
|
|
24
|
+
export { SharpImageDriver } from "./conversions/SharpImageDriver.ts";
|
|
25
|
+
export {
|
|
26
|
+
FORMAT_EXTENSION,
|
|
27
|
+
FORMAT_MIME,
|
|
28
|
+
CONVERTIBLE_MIME_TYPES,
|
|
29
|
+
isConvertible,
|
|
30
|
+
} from "./conversions/ImageDriver.ts";
|
|
31
|
+
export type {
|
|
32
|
+
ImageDriver,
|
|
33
|
+
ImageManipulation,
|
|
34
|
+
ImageMetadata,
|
|
35
|
+
ImageResult,
|
|
36
|
+
} from "./conversions/ImageDriver.ts";
|
|
37
|
+
export {
|
|
38
|
+
setConversionDispatcher,
|
|
39
|
+
isQueueAvailable,
|
|
40
|
+
dispatchConversions,
|
|
41
|
+
} from "./conversions/dispatch.ts";
|
|
42
|
+
export type { ConversionDispatcher } from "./conversions/dispatch.ts";
|
|
43
|
+
export { performConversions, ownerClassFor } from "./conversions/queueBridge.ts";
|
|
44
|
+
|
|
45
|
+
// Paths
|
|
46
|
+
export { DefaultPathGenerator } from "./paths/PathGenerator.ts";
|
|
47
|
+
export type { PathGenerator } from "./paths/PathGenerator.ts";
|
|
48
|
+
|
|
49
|
+
// Sources
|
|
50
|
+
export { fromValue, fromUrl, fromDisk, fromPath } from "./sources.ts";
|
|
51
|
+
export type { MediaSource, ResolvedSource, SourceResolver } from "./sources.ts";
|
|
52
|
+
|
|
53
|
+
// Disk resolution (the seam tests use to skip building a container)
|
|
54
|
+
export {
|
|
55
|
+
diskFor,
|
|
56
|
+
diskNameFor,
|
|
57
|
+
defaultDiskName,
|
|
58
|
+
setDiskResolver,
|
|
59
|
+
setDefaultDiskName,
|
|
60
|
+
} from "./support/disks.ts";
|
|
61
|
+
export type { DiskResolver } from "./support/disks.ts";
|
|
62
|
+
|
|
63
|
+
// Schema provisioning
|
|
64
|
+
export { mediaSchemaConcern } from "./mediaSchemaConcern.ts";
|
|
65
|
+
|
|
66
|
+
// Config
|
|
67
|
+
export { MediaConfig, mediaDefaults } from "./config.ts";
|
|
68
|
+
export type { MediaConfigShape } from "./config.ts";
|
|
69
|
+
|
|
70
|
+
// Shared state (mainly for tests and advanced wiring)
|
|
71
|
+
export { mediaState, setMediaState, resetMediaState } from "./state.ts";
|
|
72
|
+
export type { MediaState } from "./state.ts";
|
|
73
|
+
|
|
74
|
+
// Testing
|
|
75
|
+
export { MediaFake } from "./MediaFake.ts";
|
|
76
|
+
|
|
77
|
+
// Types
|
|
78
|
+
export type {
|
|
79
|
+
CollectionDefinition,
|
|
80
|
+
ConversionDefinition,
|
|
81
|
+
ConversionFit,
|
|
82
|
+
ConversionFormat,
|
|
83
|
+
ConversionMap,
|
|
84
|
+
GeneratedConversion,
|
|
85
|
+
MediaCollections,
|
|
86
|
+
MediaOwner,
|
|
87
|
+
PendingMediaMeta,
|
|
88
|
+
ResponsiveImage,
|
|
89
|
+
ResponsiveImageSet,
|
|
90
|
+
SafeConversionFormat,
|
|
91
|
+
} from "./types.ts";
|
|
92
|
+
|
|
93
|
+
// Errors
|
|
94
|
+
export * from "./errors.ts";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ConcernDescriptor } from "@zerotal/core";
|
|
2
|
+
import type { ConfigManager } from "@zerotal/core/config";
|
|
3
|
+
import { Schema } from "@zerotal/orm";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Provisions the `media` table on boot — so apps don't write a migration for it.
|
|
7
|
+
*
|
|
8
|
+
* Runs once after model discovery (order 70), additively and idempotently: it
|
|
9
|
+
* creates the table only when it's missing. Skipped when `media.autoCreateTable`
|
|
10
|
+
* is off and in DB-less runtimes; any DDL/connection error is swallowed so boot
|
|
11
|
+
* never fails because of it.
|
|
12
|
+
*
|
|
13
|
+
* Mirrors `auditSchemaConcern`, and exists for the same reason: the alternative
|
|
14
|
+
* is an app that boots cleanly and then fails on its first upload, in production.
|
|
15
|
+
*/
|
|
16
|
+
export const mediaSchemaConcern: ConcernDescriptor = {
|
|
17
|
+
name: "media-schema",
|
|
18
|
+
order: 70,
|
|
19
|
+
envs: ["web", "worker", "test"],
|
|
20
|
+
async run(ctx) {
|
|
21
|
+
try {
|
|
22
|
+
const config = ctx.resolve<ConfigManager>("config");
|
|
23
|
+
if (config?.get<boolean>("media.autoCreateTable", true) === false) return;
|
|
24
|
+
|
|
25
|
+
const tableName = config?.get<string>("media.table", "media") ?? "media";
|
|
26
|
+
if (await Schema.hasTable(tableName)) return;
|
|
27
|
+
|
|
28
|
+
await Schema.create(tableName, (blueprint) => {
|
|
29
|
+
blueprint.increments("id");
|
|
30
|
+
|
|
31
|
+
// model_id is text, not an integer: apps with UUID primary keys are as
|
|
32
|
+
// entitled to attach media as apps with auto-increment ones.
|
|
33
|
+
blueprint.string("model_type");
|
|
34
|
+
blueprint.string("model_id");
|
|
35
|
+
blueprint.string("uuid").nullable();
|
|
36
|
+
blueprint.string("collection_name");
|
|
37
|
+
blueprint.string("name");
|
|
38
|
+
blueprint.string("file_name");
|
|
39
|
+
blueprint.string("mime_type").nullable();
|
|
40
|
+
blueprint.string("disk");
|
|
41
|
+
blueprint.string("conversions_disk").nullable();
|
|
42
|
+
blueprint.integer("size");
|
|
43
|
+
|
|
44
|
+
// Every JSON column defaults to an object, never a bare scalar — a bare
|
|
45
|
+
// scalar in a json column does not survive the round trip intact.
|
|
46
|
+
blueprint.text("manipulations").nullable();
|
|
47
|
+
blueprint.text("custom_properties").nullable();
|
|
48
|
+
blueprint.text("generated_conversions").nullable();
|
|
49
|
+
blueprint.text("responsive_images").nullable();
|
|
50
|
+
|
|
51
|
+
blueprint.integer("order_column").nullable();
|
|
52
|
+
blueprint.timestamp("created_at").nullable();
|
|
53
|
+
blueprint.timestamp("updated_at").nullable();
|
|
54
|
+
|
|
55
|
+
// The query every read makes: "this model's items in this collection,
|
|
56
|
+
// in order".
|
|
57
|
+
blueprint.index(["model_type", "model_id", "collection_name"], "media_owner_index");
|
|
58
|
+
blueprint.index(["order_column"], "media_order_index");
|
|
59
|
+
blueprint.unique(["uuid"], "media_uuid_unique");
|
|
60
|
+
});
|
|
61
|
+
} catch {
|
|
62
|
+
// No database (or DDL not permitted) in this runtime — skip silently.
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { MediaItem } from "../MediaItem.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Decides where a media item's files live on disk.
|
|
5
|
+
*
|
|
6
|
+
* Every media item gets its own directory. That is what makes deleting one item
|
|
7
|
+
* safe: removing its directory can never take a sibling's file with it, however
|
|
8
|
+
* the two were named.
|
|
9
|
+
*/
|
|
10
|
+
export interface PathGenerator {
|
|
11
|
+
/** Directory for the original, relative to the disk root. No trailing slash. */
|
|
12
|
+
forOriginal(media: MediaItem): string;
|
|
13
|
+
/** Directory for generated conversions. */
|
|
14
|
+
forConversions(media: MediaItem): string;
|
|
15
|
+
/** Directory for responsive image variants. */
|
|
16
|
+
forResponsiveImages(media: MediaItem): string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The default layout:
|
|
21
|
+
*
|
|
22
|
+
* ```text
|
|
23
|
+
* media/<uuid>/original.jpg
|
|
24
|
+
* media/<uuid>/conversions/thumb.webp
|
|
25
|
+
* media/<uuid>/responsive/640.webp
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* Keyed on `uuid` rather than the numeric `id` that Laravel's media library
|
|
29
|
+
* uses. These paths end up in public URLs, and a sequential id in a public URL
|
|
30
|
+
* discloses how many rows the table has — plus it lets anyone walk the range.
|
|
31
|
+
* The uuid costs nothing and leaks nothing.
|
|
32
|
+
*/
|
|
33
|
+
export class DefaultPathGenerator implements PathGenerator {
|
|
34
|
+
constructor(private readonly prefix: string = "media") {}
|
|
35
|
+
|
|
36
|
+
forOriginal(media: MediaItem): string {
|
|
37
|
+
return `${this.prefix}/${media.uuid}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
forConversions(media: MediaItem): string {
|
|
41
|
+
return `${this.forOriginal(media)}/conversions`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
forResponsiveImages(media: MediaItem): string {
|
|
45
|
+
return `${this.forOriginal(media)}/responsive`;
|
|
46
|
+
}
|
|
47
|
+
}
|