@holo-js/media 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +8214 -0
  2. package/dist/index.mjs +1520 -0
  3. package/package.json +35 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,1520 @@
1
+ // src/model/entity.ts
2
+ import { Entity as Entity2 } from "@holo-js/db";
3
+
4
+ // src/registry.ts
5
+ import { Entity } from "@holo-js/db";
6
+
7
+ // src/runtime/binary.ts
8
+ import { basename, extname } from "path";
9
+ var MIME_TYPES = Object.freeze({
10
+ avif: "image/avif",
11
+ gif: "image/gif",
12
+ jpeg: "image/jpeg",
13
+ jpg: "image/jpeg",
14
+ json: "application/json",
15
+ pdf: "application/pdf",
16
+ png: "image/png",
17
+ svg: "image/svg+xml",
18
+ txt: "text/plain",
19
+ webp: "image/webp"
20
+ });
21
+ var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
22
+ "avif",
23
+ "gif",
24
+ "heic",
25
+ "heif",
26
+ "jpeg",
27
+ "jpg",
28
+ "png",
29
+ "svg",
30
+ "tif",
31
+ "tiff",
32
+ "webp"
33
+ ]);
34
+ var SHARP_OUTPUT_FORMATS = /* @__PURE__ */ new Set([
35
+ "avif",
36
+ "jpeg",
37
+ "jpg",
38
+ "png",
39
+ "webp"
40
+ ]);
41
+ function sanitizeFileName(value) {
42
+ const sanitized = value.trim().replace(/\\/g, "/").split("/").filter(Boolean).join("-");
43
+ return sanitized || "media.bin";
44
+ }
45
+ function inferMimeType(fileName, explicit) {
46
+ if (explicit?.trim()) {
47
+ return explicit.trim();
48
+ }
49
+ const extension = extname(fileName).replace(/^\./, "").toLowerCase();
50
+ return MIME_TYPES[extension];
51
+ }
52
+ function getExtension(fileName) {
53
+ const extension = extname(fileName).replace(/^\./, "").toLowerCase();
54
+ return extension || void 0;
55
+ }
56
+ function getDisplayName(fileName, explicit) {
57
+ if (explicit?.trim()) {
58
+ return explicit.trim();
59
+ }
60
+ return basename(fileName, extname(fileName));
61
+ }
62
+ async function toBinaryContent(value) {
63
+ if (value instanceof Blob) {
64
+ return new Uint8Array(await value.arrayBuffer());
65
+ }
66
+ if (value instanceof ArrayBuffer) {
67
+ return new Uint8Array(value);
68
+ }
69
+ return value;
70
+ }
71
+ async function toBuffer(value) {
72
+ const content = await toBinaryContent(value);
73
+ return Buffer.isBuffer(content) ? content : Buffer.from(content);
74
+ }
75
+ function getContentSize(value) {
76
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
77
+ return value.byteLength;
78
+ }
79
+ return value.size;
80
+ }
81
+ function isImageSource(mimeType, extension) {
82
+ if (mimeType?.trim().toLowerCase().startsWith("image/")) {
83
+ return true;
84
+ }
85
+ return extension ? IMAGE_EXTENSIONS.has(extension.trim().toLowerCase()) : false;
86
+ }
87
+ function normalizeOutputFormat(extension, conversionFormat) {
88
+ if (conversionFormat) {
89
+ return conversionFormat;
90
+ }
91
+ const normalized = extension?.trim().toLowerCase();
92
+ if (normalized && SHARP_OUTPUT_FORMATS.has(normalized)) {
93
+ return normalized;
94
+ }
95
+ return "png";
96
+ }
97
+ function replaceFileExtension(fileName, extension) {
98
+ const baseName = basename(fileName, extname(fileName)) || "media";
99
+ return `${baseName}.${extension}`;
100
+ }
101
+
102
+ // src/runtime/image.ts
103
+ var sharpFactoryPromise;
104
+ async function loadSharp() {
105
+ sharpFactoryPromise ??= import("sharp").then((module) => {
106
+ const resolved = module;
107
+ return resolved.default ?? resolved;
108
+ }).catch((error) => {
109
+ sharpFactoryPromise = void 0;
110
+ throw error;
111
+ });
112
+ return sharpFactoryPromise;
113
+ }
114
+ function resolveFit(mode) {
115
+ switch (mode) {
116
+ case "contain":
117
+ case "cover":
118
+ case "fill":
119
+ case "inside":
120
+ case "outside":
121
+ return mode;
122
+ default:
123
+ return "cover";
124
+ }
125
+ }
126
+ function createDefaultMediaConversionExecutor() {
127
+ return {
128
+ async generate({ source, conversion: conversion2 }) {
129
+ if (!isImageSource(source.mimeType, source.extension)) {
130
+ return null;
131
+ }
132
+ const outputFormat = normalizeOutputFormat(source.extension, conversion2.format);
133
+ try {
134
+ const sharp = await loadSharp();
135
+ let pipeline = sharp(await toBuffer(source.contents), {
136
+ animated: true,
137
+ failOn: "warning"
138
+ }).rotate();
139
+ if (conversion2.width || conversion2.height) {
140
+ pipeline = pipeline.resize({
141
+ width: conversion2.width,
142
+ height: conversion2.height,
143
+ fit: resolveFit(conversion2.fit),
144
+ withoutEnlargement: true
145
+ });
146
+ }
147
+ switch (outputFormat) {
148
+ case "avif":
149
+ pipeline = pipeline.avif({
150
+ quality: conversion2.quality ?? 80,
151
+ effort: 4
152
+ });
153
+ break;
154
+ case "jpeg":
155
+ case "jpg":
156
+ pipeline = pipeline.jpeg({
157
+ quality: conversion2.quality ?? 82,
158
+ mozjpeg: true
159
+ });
160
+ break;
161
+ case "png":
162
+ pipeline = pipeline.png({
163
+ compressionLevel: 9,
164
+ quality: conversion2.quality
165
+ });
166
+ break;
167
+ case "webp":
168
+ pipeline = pipeline.webp({
169
+ quality: conversion2.quality ?? 82,
170
+ effort: 4
171
+ });
172
+ break;
173
+ }
174
+ const contents = await pipeline.toBuffer();
175
+ const fileName = replaceFileExtension(source.fileName, outputFormat);
176
+ return {
177
+ contents,
178
+ fileName,
179
+ mimeType: inferMimeType(fileName)
180
+ };
181
+ } catch (error) {
182
+ throw new Error(
183
+ `[Holo Media] Failed to generate conversion "${conversion2.name}" for "${source.fileName}": ${error.message}`
184
+ );
185
+ }
186
+ }
187
+ };
188
+ }
189
+ var defaultMediaConversionExecutor = createDefaultMediaConversionExecutor();
190
+
191
+ // src/registry.ts
192
+ var mediaDefinitionRegistry = /* @__PURE__ */ new WeakMap();
193
+ var mediaDefinitionMorphClassRegistry = /* @__PURE__ */ new Map();
194
+ function sanitizePathSegment(value) {
195
+ return value.trim().replace(/\\/g, "/").split("/").filter(Boolean).join("-");
196
+ }
197
+ function resolveConversionExtension(fileName, conversionFormat) {
198
+ if (conversionFormat) {
199
+ return conversionFormat === "jpg" ? "jpg" : conversionFormat;
200
+ }
201
+ const extension = fileName.split(".").pop()?.trim().toLowerCase();
202
+ return extension || "bin";
203
+ }
204
+ var defaultMediaPathGenerator = Object.freeze({
205
+ originalPath({
206
+ uuid,
207
+ fileName
208
+ }) {
209
+ return `media/${sanitizePathSegment(uuid)}/original/${sanitizePathSegment(fileName)}`;
210
+ },
211
+ conversionPath({
212
+ uuid,
213
+ conversion: conversion2,
214
+ generatedFileName,
215
+ fileName
216
+ }) {
217
+ const extension = resolveConversionExtension(generatedFileName ?? fileName, conversion2.format);
218
+ const safeName = sanitizePathSegment(conversion2.name);
219
+ return `media/${sanitizePathSegment(uuid)}/conversions/${safeName}.${extension}`;
220
+ }
221
+ });
222
+ var mediaPathGenerator = defaultMediaPathGenerator;
223
+ var mediaConversionExecutor;
224
+ function resolveDefinition(target) {
225
+ if (target instanceof Entity) {
226
+ return target.getRepository().definition;
227
+ }
228
+ return target.definition;
229
+ }
230
+ function registerMediaDefinition(target, definition) {
231
+ const resolvedTarget = resolveDefinition(target);
232
+ mediaDefinitionRegistry.set(resolvedTarget, definition);
233
+ mediaDefinitionMorphClassRegistry.set(resolvedTarget.morphClass ?? resolvedTarget.name, definition);
234
+ return definition;
235
+ }
236
+ function getMediaDefinition(target) {
237
+ return mediaDefinitionRegistry.get(resolveDefinition(target));
238
+ }
239
+ function requireMediaDefinition(target) {
240
+ const definition = getMediaDefinition(target);
241
+ if (!definition) {
242
+ const model = resolveDefinition(target);
243
+ throw new Error(
244
+ `[Holo Media] Model "${model.name}" is not configured for media. Wrap it with defineMediaModel().`
245
+ );
246
+ }
247
+ return definition;
248
+ }
249
+ function getMediaDefinitionForMorphClass(morphClass) {
250
+ return mediaDefinitionMorphClassRegistry.get(morphClass);
251
+ }
252
+ function requireMediaDefinitionForMorphClass(morphClass) {
253
+ const definition = getMediaDefinitionForMorphClass(morphClass);
254
+ if (!definition) {
255
+ throw new Error(
256
+ `[Holo Media] Model "${morphClass}" is not configured for media. Wrap it with defineMediaModel().`
257
+ );
258
+ }
259
+ return definition;
260
+ }
261
+ function resolveMediaCollection(target, collectionName = "default") {
262
+ const definition = getMediaDefinition(target);
263
+ if (!definition) {
264
+ return Object.freeze({
265
+ kind: "collection",
266
+ name: collectionName,
267
+ singleFile: false,
268
+ acceptedMimeTypes: Object.freeze([]),
269
+ acceptedExtensions: Object.freeze([])
270
+ });
271
+ }
272
+ return definition.collectionsByName[collectionName] ?? Object.freeze({
273
+ kind: "collection",
274
+ name: collectionName,
275
+ singleFile: false,
276
+ acceptedMimeTypes: Object.freeze([]),
277
+ acceptedExtensions: Object.freeze([])
278
+ });
279
+ }
280
+ function resolveMediaConversion(target, conversionName) {
281
+ return getMediaDefinition(target)?.conversionsByName[conversionName];
282
+ }
283
+ function setMediaPathGenerator(generator) {
284
+ mediaPathGenerator = generator;
285
+ }
286
+ function resetMediaPathGenerator() {
287
+ mediaPathGenerator = defaultMediaPathGenerator;
288
+ }
289
+ function getMediaPathGenerator() {
290
+ return mediaPathGenerator;
291
+ }
292
+ function setMediaConversionExecutor(executor) {
293
+ mediaConversionExecutor = executor;
294
+ }
295
+ function getMediaConversionExecutor() {
296
+ return mediaConversionExecutor ?? defaultMediaConversionExecutor;
297
+ }
298
+ function resetMediaRuntime() {
299
+ mediaPathGenerator = defaultMediaPathGenerator;
300
+ mediaConversionExecutor = void 0;
301
+ }
302
+
303
+ // src/model/Media.ts
304
+ import { column, defineGeneratedTable, defineModel, morphTo } from "@holo-js/db";
305
+ var mediaTable = defineGeneratedTable("media", {
306
+ id: column.id(),
307
+ uuid: column.uuid().unique(),
308
+ model_type: column.string(),
309
+ model_id: column.string(),
310
+ collection_name: column.string().default("default"),
311
+ name: column.string(),
312
+ file_name: column.string(),
313
+ disk: column.string(),
314
+ conversions_disk: column.string().nullable(),
315
+ mime_type: column.string().nullable(),
316
+ extension: column.string().nullable(),
317
+ size: column.bigInteger(),
318
+ path: column.string(),
319
+ generated_conversions: column.json("generated_conversions").default({}),
320
+ order_column: column.integer().default(1),
321
+ created_at: column.timestamp().defaultNow(),
322
+ updated_at: column.timestamp().defaultNow()
323
+ }, {
324
+ indexes: [
325
+ { columns: ["model_type", "model_id"], unique: false },
326
+ { columns: ["model_type", "model_id", "collection_name"], unique: false }
327
+ ]
328
+ });
329
+ var Media = defineModel(mediaTable, {
330
+ fillable: [
331
+ "uuid",
332
+ "model_type",
333
+ "model_id",
334
+ "collection_name",
335
+ "name",
336
+ "file_name",
337
+ "disk",
338
+ "conversions_disk",
339
+ "mime_type",
340
+ "extension",
341
+ "size",
342
+ "path",
343
+ "generated_conversions",
344
+ "order_column"
345
+ ],
346
+ casts: {
347
+ generated_conversions: "json"
348
+ },
349
+ relations: {
350
+ model: morphTo("model")
351
+ }
352
+ });
353
+
354
+ // src/model/adder.ts
355
+ import { basename as basename2 } from "path";
356
+ import { readFile } from "fs/promises";
357
+ import { randomUUID } from "crypto";
358
+ import { Storage as Storage3 } from "@holo-js/storage/runtime";
359
+
360
+ // src/queue.ts
361
+ import {
362
+ dispatch,
363
+ defineJob,
364
+ getRegisteredQueueJob,
365
+ registerQueueJob
366
+ } from "@holo-js/queue";
367
+ import { connectionAsyncContext } from "@holo-js/db";
368
+
369
+ // src/model/conversions.ts
370
+ import { Storage } from "@holo-js/storage/runtime";
371
+ function fallbackCollectionDefinition(collectionName) {
372
+ return Object.freeze({
373
+ kind: "collection",
374
+ name: collectionName,
375
+ singleFile: collectionName === "default",
376
+ acceptedMimeTypes: Object.freeze([]),
377
+ acceptedExtensions: Object.freeze([])
378
+ });
379
+ }
380
+ function normalizeRequestedConversions(requested) {
381
+ if (typeof requested === "string") {
382
+ return Object.freeze([requested]);
383
+ }
384
+ if (!requested || requested.length === 0) {
385
+ return void 0;
386
+ }
387
+ return Object.freeze([...new Set(requested.filter(Boolean))]);
388
+ }
389
+ function resolveCollectionDefinition(definition, collectionName) {
390
+ return definition.collectionsByName[collectionName] ?? fallbackCollectionDefinition(collectionName);
391
+ }
392
+ function resolveMatchingConversions(definition, collectionName, requested, options = {}) {
393
+ if (requested?.length) {
394
+ const unknown = requested.filter((name) => {
395
+ return !definition.conversionsByName[name];
396
+ });
397
+ if (unknown.length > 0) {
398
+ throw new Error(`[Holo Media] Unknown media conversion "${unknown[0]}".`);
399
+ }
400
+ }
401
+ const matching = definition.conversions.filter((conversion2) => {
402
+ const appliesToCollection = conversion2.collections.length === 0 || conversion2.collections.includes(collectionName);
403
+ if (!appliesToCollection) {
404
+ return false;
405
+ }
406
+ if (!requested?.length) {
407
+ return true;
408
+ }
409
+ return requested.includes(conversion2.name);
410
+ });
411
+ if (requested?.length) {
412
+ const missing = requested.filter((name) => {
413
+ return !matching.some((conversion2) => conversion2.name === name);
414
+ });
415
+ if (missing.length > 0) {
416
+ throw new Error(
417
+ `[Holo Media] Conversion "${missing[0]}" is not registered for collection "${collectionName}".`
418
+ );
419
+ }
420
+ }
421
+ if (options.includeQueued) {
422
+ return matching;
423
+ }
424
+ return matching.filter((conversion2) => !conversion2.queued);
425
+ }
426
+ function resolveQueuedConversionNames(options) {
427
+ const requested = normalizeRequestedConversions(options.requestedConversions);
428
+ return Object.freeze(
429
+ resolveMatchingConversions(
430
+ options.definition,
431
+ options.collectionName,
432
+ requested,
433
+ { includeQueued: true }
434
+ ).filter((conversion2) => conversion2.queued).map((conversion2) => conversion2.name)
435
+ );
436
+ }
437
+ async function generateStoredConversions(options) {
438
+ const requested = normalizeRequestedConversions(options.requestedConversions);
439
+ const matchingConversions = resolveMatchingConversions(
440
+ options.definition,
441
+ options.collection.name,
442
+ requested,
443
+ { includeQueued: options.includeQueued === true }
444
+ );
445
+ if (matchingConversions.length === 0) {
446
+ return Object.freeze({});
447
+ }
448
+ const generatedConversions = /* @__PURE__ */ Object.create(null);
449
+ const executor = getMediaConversionExecutor();
450
+ try {
451
+ for (const conversion2 of matchingConversions) {
452
+ const generated = await executor.generate({
453
+ source: options.source,
454
+ collection: options.collection,
455
+ conversion: conversion2
456
+ });
457
+ if (!generated) {
458
+ continue;
459
+ }
460
+ const generatedFileName = sanitizeFileName(generated.fileName ?? options.source.fileName);
461
+ const conversionPath = getMediaPathGenerator().conversionPath({
462
+ uuid: options.source.uuid,
463
+ fileName: options.source.fileName,
464
+ extension: options.source.extension,
465
+ collection: options.collection,
466
+ conversion: conversion2,
467
+ generatedFileName
468
+ });
469
+ const targetDisk = generated.disk ?? options.conversionsDisk;
470
+ const conversionMimeType = inferMimeType(generatedFileName, generated.mimeType);
471
+ const conversionContents = await toBinaryContent(generated.contents);
472
+ await Storage.disk(targetDisk).put(conversionPath, conversionContents);
473
+ generatedConversions[conversion2.name] = Object.freeze({
474
+ path: conversionPath,
475
+ disk: targetDisk,
476
+ fileName: generatedFileName,
477
+ mimeType: conversionMimeType,
478
+ size: getContentSize(conversionContents)
479
+ });
480
+ }
481
+ } catch (error) {
482
+ await deleteStoredConversions(
483
+ generatedConversions,
484
+ options.conversionsDisk
485
+ );
486
+ throw error;
487
+ }
488
+ return Object.freeze(generatedConversions);
489
+ }
490
+ async function deleteStoredConversions(conversions, _fallbackDisk) {
491
+ for (const conversion2 of Object.values(conversions)) {
492
+ await Storage.disk(conversion2.disk).delete(conversion2.path);
493
+ }
494
+ }
495
+ async function deleteObsoleteConversions(current, next, fallbackDisk, requested) {
496
+ for (const [name, conversion2] of Object.entries(current)) {
497
+ if (requested?.length && !requested.includes(name)) {
498
+ continue;
499
+ }
500
+ if (!conversion2?.path) {
501
+ continue;
502
+ }
503
+ const nextConversion = next[name];
504
+ if (nextConversion?.path === conversion2.path && nextConversion.disk === (conversion2.disk ?? fallbackDisk)) {
505
+ continue;
506
+ }
507
+ await Storage.disk(conversion2.disk ?? fallbackDisk).delete(conversion2.path);
508
+ }
509
+ }
510
+ async function regenerateMediaEntityConversions(options) {
511
+ const media = options.media;
512
+ const modelType = String(media.get("model_type"));
513
+ const definition = options.owner ? requireMediaDefinition(options.owner) : requireMediaDefinitionForMorphClass(modelType);
514
+ const collectionName = String(media.get("collection_name"));
515
+ const collection2 = resolveCollectionDefinition(definition, collectionName);
516
+ const requested = normalizeRequestedConversions(options.conversions);
517
+ const current = media.get("generated_conversions") ?? {};
518
+ const conversionsDisk = String(media.get("conversions_disk") ?? media.get("disk"));
519
+ resolveMatchingConversions(definition, collectionName, requested, {
520
+ includeQueued: options.includeQueued === true
521
+ });
522
+ const sourceContents = await Storage.disk(String(media.get("disk"))).getBytes(String(media.get("path")));
523
+ if (!sourceContents) {
524
+ throw new Error(
525
+ `[Holo Media] Cannot regenerate conversions for media "${media.get("uuid")}" because the original file is missing.`
526
+ );
527
+ }
528
+ const nextBase = requested?.length ? Object.fromEntries(
529
+ Object.entries(current).filter(([name]) => !requested.includes(name))
530
+ ) : {};
531
+ const regenerated = await generateStoredConversions({
532
+ definition,
533
+ collection: collection2,
534
+ conversionsDisk,
535
+ requestedConversions: requested,
536
+ includeQueued: options.includeQueued === true,
537
+ source: {
538
+ uuid: String(media.get("uuid")),
539
+ fileName: String(media.get("file_name")),
540
+ extension: media.get("extension") ?? void 0,
541
+ mimeType: media.get("mime_type") ?? void 0,
542
+ size: sourceContents.byteLength,
543
+ contents: sourceContents
544
+ }
545
+ });
546
+ const generatedConversions = Object.freeze({
547
+ ...nextBase,
548
+ ...regenerated
549
+ });
550
+ media.forceFill({
551
+ generated_conversions: generatedConversions
552
+ });
553
+ await media.save();
554
+ await deleteObsoleteConversions(current, generatedConversions, conversionsDisk, requested);
555
+ options.owner?.forgetRelation("media");
556
+ return generatedConversions;
557
+ }
558
+
559
+ // src/queue.ts
560
+ var MEDIA_GENERATE_CONVERSIONS_JOB = "media.generate-conversions";
561
+ function normalizeConversionNames(conversionNames) {
562
+ if (!conversionNames || conversionNames.length === 0) {
563
+ return void 0;
564
+ }
565
+ const normalized = [...new Set(
566
+ conversionNames.map((name) => name.trim()).filter(Boolean)
567
+ )];
568
+ return normalized.length > 0 ? Object.freeze(normalized) : void 0;
569
+ }
570
+ function normalizeMediaIdentifier(mediaId) {
571
+ if (typeof mediaId === "number") {
572
+ if (!Number.isFinite(mediaId)) {
573
+ throw new Error("[Holo Media] Queued media conversion jobs require a finite media identifier.");
574
+ }
575
+ return mediaId;
576
+ }
577
+ const normalized = mediaId.trim();
578
+ if (!normalized) {
579
+ throw new Error("[Holo Media] Queued media conversion jobs require a non-empty media identifier.");
580
+ }
581
+ return normalized;
582
+ }
583
+ function normalizePayload(payload) {
584
+ return Object.freeze({
585
+ mediaId: normalizeMediaIdentifier(payload.mediaId),
586
+ conversionNames: normalizeConversionNames(payload.conversionNames) ?? Object.freeze([])
587
+ });
588
+ }
589
+ async function runMediaGenerateConversionsJob(payload) {
590
+ const normalized = normalizePayload(payload);
591
+ const media = await Media.find(normalized.mediaId);
592
+ if (!media) {
593
+ return Object.freeze({
594
+ status: "missing-media",
595
+ conversionNames: Object.freeze([])
596
+ });
597
+ }
598
+ await regenerateMediaEntityConversions({
599
+ media,
600
+ conversions: normalized.conversionNames,
601
+ includeQueued: true
602
+ });
603
+ return Object.freeze({
604
+ status: "processed",
605
+ conversionNames: normalized.conversionNames
606
+ });
607
+ }
608
+ function ensureMediaQueueJobRegistered() {
609
+ if (getRegisteredQueueJob(MEDIA_GENERATE_CONVERSIONS_JOB)) {
610
+ return;
611
+ }
612
+ registerQueueJob(defineJob({
613
+ queue: "media",
614
+ async handle(payload) {
615
+ return runMediaGenerateConversionsJob(payload);
616
+ }
617
+ }), {
618
+ name: MEDIA_GENERATE_CONVERSIONS_JOB
619
+ });
620
+ }
621
+ function getActiveTransaction() {
622
+ const active = connectionAsyncContext.getActive()?.connection;
623
+ if (!active || active.getScope().kind === "root") {
624
+ return void 0;
625
+ }
626
+ return active;
627
+ }
628
+ async function executeQueuedMediaDispatch(payload, onSynchronousDispatch) {
629
+ const dispatched = await dispatch(MEDIA_GENERATE_CONVERSIONS_JOB, payload);
630
+ if (dispatched.synchronous) {
631
+ await onSynchronousDispatch?.();
632
+ }
633
+ }
634
+ async function dispatchQueuedMediaConversions(payload) {
635
+ const normalized = normalizePayload(payload);
636
+ if (normalized.conversionNames.length === 0) {
637
+ return;
638
+ }
639
+ ensureMediaQueueJobRegistered();
640
+ const activeTransaction = getActiveTransaction();
641
+ if (activeTransaction) {
642
+ activeTransaction.afterCommit(() => executeQueuedMediaDispatch(normalized));
643
+ return;
644
+ }
645
+ await executeQueuedMediaDispatch(normalized);
646
+ }
647
+ async function dispatchQueuedMediaConversionsForModel(payload, onSynchronousDispatch) {
648
+ const normalized = normalizePayload(payload);
649
+ if (normalized.conversionNames.length === 0) {
650
+ return;
651
+ }
652
+ ensureMediaQueueJobRegistered();
653
+ const activeTransaction = getActiveTransaction();
654
+ if (activeTransaction) {
655
+ activeTransaction.afterCommit(() => executeQueuedMediaDispatch(normalized, onSynchronousDispatch));
656
+ return;
657
+ }
658
+ await executeQueuedMediaDispatch(normalized, onSynchronousDispatch);
659
+ }
660
+
661
+ // src/model/item.ts
662
+ import { Storage as Storage2 } from "@holo-js/storage/runtime";
663
+ var MediaItem = class {
664
+ constructor(entity, owner) {
665
+ this.entity = entity;
666
+ this.owner = owner;
667
+ }
668
+ get record() {
669
+ return this.entity.toAttributes();
670
+ }
671
+ get collectionName() {
672
+ return this.entity.get("collection_name");
673
+ }
674
+ get fileName() {
675
+ return this.entity.get("file_name");
676
+ }
677
+ get mimeType() {
678
+ return this.entity.get("mime_type");
679
+ }
680
+ get size() {
681
+ return this.entity.get("size");
682
+ }
683
+ getEntity() {
684
+ return this.entity;
685
+ }
686
+ getAvailableConversions() {
687
+ const conversions = this.entity.get("generated_conversions");
688
+ return Object.freeze(
689
+ Object.keys(conversions ?? {})
690
+ );
691
+ }
692
+ getPath(conversion2) {
693
+ const variant = this.resolveVariant(conversion2);
694
+ if (!variant) {
695
+ return null;
696
+ }
697
+ try {
698
+ return Storage2.disk(variant.disk).path(variant.path);
699
+ } catch {
700
+ return null;
701
+ }
702
+ }
703
+ getUrl(conversion2) {
704
+ const variant = this.resolveVariant(conversion2);
705
+ if (!variant) {
706
+ return null;
707
+ }
708
+ try {
709
+ return Storage2.disk(variant.disk).url(variant.path);
710
+ } catch {
711
+ return null;
712
+ }
713
+ }
714
+ getTemporaryUrl(conversion2, options) {
715
+ const variant = this.resolveVariant(conversion2);
716
+ if (!variant) {
717
+ return null;
718
+ }
719
+ try {
720
+ return Storage2.disk(variant.disk).temporaryUrl(variant.path, options);
721
+ } catch {
722
+ return null;
723
+ }
724
+ }
725
+ async delete() {
726
+ const deletedSnapshots = [];
727
+ try {
728
+ for (const variant of this.resolveStoredVariants()) {
729
+ const disk = Storage2.disk(variant.disk);
730
+ const contents = await disk.getBytes(variant.path);
731
+ await disk.delete(variant.path);
732
+ if (contents) {
733
+ deletedSnapshots.push({
734
+ ...variant,
735
+ contents
736
+ });
737
+ }
738
+ }
739
+ await this.entity.delete();
740
+ this.owner?.forgetRelation("media");
741
+ } catch (error) {
742
+ for (const snapshot of deletedSnapshots.reverse()) {
743
+ await Storage2.disk(snapshot.disk).put(snapshot.path, snapshot.contents).catch(() => void 0);
744
+ }
745
+ throw error;
746
+ }
747
+ }
748
+ async regenerate(conversions) {
749
+ await regenerateMediaEntityConversions({
750
+ media: this.entity,
751
+ owner: this.owner,
752
+ conversions
753
+ });
754
+ const definition = this.owner ? requireMediaDefinition(this.owner) : requireMediaDefinitionForMorphClass(String(this.entity.get("model_type")));
755
+ const queuedConversions = resolveQueuedConversionNames({
756
+ definition,
757
+ collectionName: String(this.entity.get("collection_name")),
758
+ requestedConversions: conversions
759
+ });
760
+ await dispatchQueuedMediaConversionsForModel({
761
+ mediaId: this.entity.get("id"),
762
+ conversionNames: queuedConversions
763
+ }, async () => {
764
+ await this.entity.refresh();
765
+ });
766
+ return this;
767
+ }
768
+ toJSON() {
769
+ return this.entity.toJSON();
770
+ }
771
+ resolveVariant(conversion2) {
772
+ if (!conversion2) {
773
+ return {
774
+ path: this.entity.get("path"),
775
+ disk: this.entity.get("disk")
776
+ };
777
+ }
778
+ const conversions = this.entity.get("generated_conversions");
779
+ const variant = conversions?.[conversion2];
780
+ if (!variant?.path) {
781
+ return null;
782
+ }
783
+ return {
784
+ path: variant.path,
785
+ disk: variant.disk ?? this.entity.get("conversions_disk") ?? this.entity.get("disk")
786
+ };
787
+ }
788
+ resolveStoredVariants() {
789
+ const variants = [{
790
+ path: this.entity.get("path"),
791
+ disk: this.entity.get("disk")
792
+ }];
793
+ const fallbackDisk = this.entity.get("conversions_disk") ?? this.entity.get("disk");
794
+ const conversions = this.entity.get("generated_conversions") ?? {};
795
+ for (const value of Object.values(conversions)) {
796
+ if (!value?.path) {
797
+ continue;
798
+ }
799
+ variants.push({
800
+ path: value.path,
801
+ disk: value.disk ?? fallbackDisk
802
+ });
803
+ }
804
+ return variants;
805
+ }
806
+ };
807
+
808
+ // src/model/adder.ts
809
+ function resolveImplicitDiskName() {
810
+ const defaultDisk = Storage3.disk();
811
+ if (defaultDisk.visibility === "public") {
812
+ return defaultDisk.name;
813
+ }
814
+ try {
815
+ const publicDisk = Storage3.disk("public");
816
+ if (publicDisk.visibility === "public") {
817
+ return publicDisk.name;
818
+ }
819
+ } catch {
820
+ }
821
+ return defaultDisk.name;
822
+ }
823
+ function parseRemoteFileName(url) {
824
+ try {
825
+ const parsedUrl = new URL(url);
826
+ return basename2(parsedUrl.pathname) || "media.bin";
827
+ } catch {
828
+ return "media.bin";
829
+ }
830
+ }
831
+ function createMaxSizeError(fileName, collectionName) {
832
+ return new Error(
833
+ `[Holo Media] File "${fileName}" exceeds the max size for collection "${collectionName}".`
834
+ );
835
+ }
836
+ async function readRemoteMediaContents(response, fileName, maxSize, collectionName) {
837
+ if (typeof maxSize !== "number" || !response.body) {
838
+ return new Uint8Array(await response.arrayBuffer());
839
+ }
840
+ const reader = response.body.getReader();
841
+ const chunks = [];
842
+ let totalSize = 0;
843
+ try {
844
+ while (true) {
845
+ const { done, value } = await reader.read();
846
+ if (done) {
847
+ break;
848
+ }
849
+ if (!value) {
850
+ continue;
851
+ }
852
+ totalSize += value.byteLength;
853
+ if (totalSize > maxSize) {
854
+ await reader.cancel().catch(() => void 0);
855
+ throw createMaxSizeError(fileName, collectionName);
856
+ }
857
+ chunks.push(value);
858
+ }
859
+ } finally {
860
+ reader.releaseLock();
861
+ }
862
+ const contents = new Uint8Array(totalSize);
863
+ let offset = 0;
864
+ for (const chunk of chunks) {
865
+ contents.set(chunk, offset);
866
+ offset += chunk.byteLength;
867
+ }
868
+ return contents;
869
+ }
870
+ async function resolveMediaSource(input, overrideFileName, overrideName, options) {
871
+ if (typeof input === "string" && /^https?:\/\//i.test(input)) {
872
+ return resolveRemoteMediaSource(
873
+ { url: input },
874
+ overrideFileName,
875
+ overrideName,
876
+ options?.maxSize,
877
+ options?.collectionName
878
+ );
879
+ }
880
+ if (typeof input !== "string" && isUrlInput(input)) {
881
+ return resolveRemoteMediaSource(
882
+ input,
883
+ overrideFileName,
884
+ overrideName,
885
+ options?.maxSize,
886
+ options?.collectionName
887
+ );
888
+ }
889
+ if (typeof input === "string" || isPathInput(input)) {
890
+ const path = typeof input === "string" ? input : input.path;
891
+ const contents2 = await readFile(path);
892
+ const fileName2 = sanitizeFileName(overrideFileName ?? basename2(path));
893
+ return {
894
+ contents: contents2,
895
+ fileName: fileName2,
896
+ mimeType: inferMimeType(fileName2),
897
+ extension: getExtension(fileName2),
898
+ size: contents2.byteLength,
899
+ name: getDisplayName(fileName2, overrideName)
900
+ };
901
+ }
902
+ if ("contents" in input) {
903
+ const contents2 = await toBinaryContent(input.contents);
904
+ const fileName2 = sanitizeFileName(overrideFileName ?? input.fileName ?? "media.bin");
905
+ return {
906
+ contents: contents2,
907
+ fileName: fileName2,
908
+ mimeType: inferMimeType(fileName2, input.mimeType),
909
+ extension: getExtension(fileName2),
910
+ size: getContentSize(contents2),
911
+ name: getDisplayName(fileName2, overrideName ?? input.name)
912
+ };
913
+ }
914
+ const contents = await toBinaryContent(input);
915
+ const fileName = sanitizeFileName(overrideFileName ?? "media.bin");
916
+ return {
917
+ contents,
918
+ fileName,
919
+ mimeType: inferMimeType(fileName),
920
+ extension: getExtension(fileName),
921
+ size: getContentSize(contents),
922
+ name: getDisplayName(fileName, overrideName)
923
+ };
924
+ }
925
+ function isPathInput(input) {
926
+ return typeof input === "object" && input !== null && "path" in input;
927
+ }
928
+ function isUrlInput(input) {
929
+ return typeof input === "object" && input !== null && "url" in input;
930
+ }
931
+ async function resolveRemoteMediaSource(input, overrideFileName, overrideName, maxSize, collectionName) {
932
+ const response = await fetch(input.url);
933
+ if (!response.ok) {
934
+ throw new Error(
935
+ `[Holo Media] Failed to download media from "${input.url}" (${response.status} ${response.statusText}).`
936
+ );
937
+ }
938
+ const fileName = sanitizeFileName(
939
+ overrideFileName ?? input.fileName ?? parseRemoteFileName(input.url)
940
+ );
941
+ const responseMimeType = response.headers.get("content-type")?.split(";")[0];
942
+ const contentLengthHeader = response.headers.get("content-length");
943
+ const contentLength = contentLengthHeader?.trim() ? Number(contentLengthHeader) : Number.NaN;
944
+ if (typeof maxSize === "number" && Number.isFinite(contentLength) && contentLength > maxSize) {
945
+ throw createMaxSizeError(fileName, collectionName);
946
+ }
947
+ const contents = await readRemoteMediaContents(
948
+ response,
949
+ fileName,
950
+ maxSize,
951
+ collectionName
952
+ );
953
+ return {
954
+ contents,
955
+ fileName,
956
+ mimeType: inferMimeType(fileName, input.mimeType ?? responseMimeType ?? void 0),
957
+ extension: getExtension(fileName),
958
+ size: contents.byteLength,
959
+ name: getDisplayName(fileName, overrideName ?? input.name)
960
+ };
961
+ }
962
+ function validateSource(collection2, source) {
963
+ if (typeof collection2.maxSize === "number" && source.size > collection2.maxSize) {
964
+ throw new Error(
965
+ `[Holo Media] File "${source.fileName}" exceeds the max size for collection "${collection2.name}".`
966
+ );
967
+ }
968
+ if (collection2.acceptedMimeTypes.length > 0) {
969
+ const mimeType = source.mimeType?.trim().toLowerCase();
970
+ if (!mimeType || !collection2.acceptedMimeTypes.includes(mimeType)) {
971
+ throw new Error(
972
+ `[Holo Media] File "${source.fileName}" is not an accepted MIME type for collection "${collection2.name}".`
973
+ );
974
+ }
975
+ }
976
+ if (collection2.acceptedExtensions.length > 0) {
977
+ if (!source.extension || !collection2.acceptedExtensions.includes(source.extension)) {
978
+ throw new Error(
979
+ `[Holo Media] File "${source.fileName}" is not an accepted extension for collection "${collection2.name}".`
980
+ );
981
+ }
982
+ }
983
+ }
984
+ async function resolveNextOrderColumn(modelType, modelId, collectionName) {
985
+ const max = await Media.query().where("model_type", modelType).where("model_id", modelId).where("collection_name", collectionName).max("order_column");
986
+ return (max ?? 0) + 1;
987
+ }
988
+ async function deleteMediaItemsWithRollback(items) {
989
+ const deletedSnapshots = [];
990
+ try {
991
+ for (const item of items) {
992
+ const snapshot = await snapshotDeletedMediaItem(item);
993
+ await item.delete();
994
+ deletedSnapshots.push(snapshot);
995
+ }
996
+ } catch (error) {
997
+ for (const snapshot of deletedSnapshots.reverse()) {
998
+ await restoreDeletedMediaSnapshot(snapshot).catch(() => void 0);
999
+ }
1000
+ throw error;
1001
+ }
1002
+ }
1003
+ async function deleteOverflowItems(items, limit) {
1004
+ if (items.length <= limit) {
1005
+ return;
1006
+ }
1007
+ await deleteMediaItemsWithRollback(items.slice(0, items.length - limit));
1008
+ }
1009
+ async function cleanupGeneratedConversions(conversions, _fallbackDisk) {
1010
+ for (const conversion2 of Object.values(conversions)) {
1011
+ await Storage3.disk(conversion2.disk).delete(conversion2.path).catch(() => void 0);
1012
+ }
1013
+ }
1014
+ async function snapshotDeletedMediaItem(item) {
1015
+ const record = item.record;
1016
+ const fileTargets = [{
1017
+ disk: record.disk,
1018
+ path: record.path
1019
+ }];
1020
+ const fallbackDisk = record.conversions_disk ?? record.disk;
1021
+ for (const conversion2 of Object.values(record.generated_conversions ?? {})) {
1022
+ if (!conversion2?.path) {
1023
+ continue;
1024
+ }
1025
+ fileTargets.push({
1026
+ disk: conversion2.disk ?? fallbackDisk,
1027
+ path: conversion2.path
1028
+ });
1029
+ }
1030
+ const files = (await Promise.all(fileTargets.map(async (file) => {
1031
+ const contents = await Storage3.disk(file.disk).getBytes(file.path);
1032
+ return contents ? {
1033
+ ...file,
1034
+ contents
1035
+ } : null;
1036
+ }))).filter((file) => Boolean(file));
1037
+ return {
1038
+ record: {
1039
+ uuid: record.uuid,
1040
+ model_type: record.model_type,
1041
+ model_id: record.model_id,
1042
+ collection_name: record.collection_name,
1043
+ name: record.name,
1044
+ file_name: record.file_name,
1045
+ disk: record.disk,
1046
+ conversions_disk: record.conversions_disk,
1047
+ mime_type: record.mime_type,
1048
+ extension: record.extension,
1049
+ size: record.size,
1050
+ path: record.path,
1051
+ generated_conversions: record.generated_conversions,
1052
+ order_column: record.order_column
1053
+ },
1054
+ files
1055
+ };
1056
+ }
1057
+ async function restoreDeletedMediaSnapshot(snapshot) {
1058
+ for (const file of snapshot.files) {
1059
+ await Storage3.disk(file.disk).put(file.path, file.contents);
1060
+ }
1061
+ await Media.create(snapshot.record);
1062
+ }
1063
+ var MediaAdder = class _MediaAdder {
1064
+ constructor(entity, source, state = {}) {
1065
+ this.entity = entity;
1066
+ this.source = source;
1067
+ this.state = state;
1068
+ }
1069
+ usingName(name) {
1070
+ return new _MediaAdder(this.entity, this.source, {
1071
+ ...this.state,
1072
+ name
1073
+ });
1074
+ }
1075
+ usingFileName(fileName) {
1076
+ return new _MediaAdder(this.entity, this.source, {
1077
+ ...this.state,
1078
+ fileName
1079
+ });
1080
+ }
1081
+ onDisk(disk) {
1082
+ return new _MediaAdder(this.entity, this.source, {
1083
+ ...this.state,
1084
+ disk
1085
+ });
1086
+ }
1087
+ async toMediaCollection(collectionName = "default") {
1088
+ const mediaDefinition = requireMediaDefinition(this.entity);
1089
+ const ownerDefinition = this.entity.getRepository().definition;
1090
+ const ownerId = this.entity.get(ownerDefinition.primaryKey);
1091
+ if (ownerId === null || typeof ownerId === "undefined") {
1092
+ throw new Error(
1093
+ `[Holo Media] Cannot attach media to "${ownerDefinition.name}" before it has a persisted primary key.`
1094
+ );
1095
+ }
1096
+ const collection2 = resolveMediaCollection(this.entity, collectionName);
1097
+ const source = await resolveMediaSource(
1098
+ this.source,
1099
+ this.state.fileName,
1100
+ this.state.name,
1101
+ {
1102
+ maxSize: collection2.maxSize,
1103
+ collectionName: collection2.name
1104
+ }
1105
+ );
1106
+ validateSource(collection2, source);
1107
+ const existing = collection2.singleFile ? await this.entity.getMedia(collectionName) : [];
1108
+ const uuid = randomUUID();
1109
+ const diskName = this.state.disk ?? collection2.disk ?? resolveImplicitDiskName();
1110
+ const conversionsDisk = collection2.conversionsDisk ?? diskName;
1111
+ const originalPath = getMediaPathGenerator().originalPath({
1112
+ uuid,
1113
+ fileName: source.fileName,
1114
+ extension: source.extension,
1115
+ collection: collection2
1116
+ });
1117
+ let originalStored = false;
1118
+ let createdMedia;
1119
+ let generatedConversions = Object.freeze({});
1120
+ try {
1121
+ await Storage3.disk(diskName).put(originalPath, source.contents);
1122
+ originalStored = true;
1123
+ generatedConversions = await generateStoredConversions({
1124
+ definition: mediaDefinition,
1125
+ collection: collection2,
1126
+ conversionsDisk,
1127
+ source: {
1128
+ uuid,
1129
+ fileName: source.fileName,
1130
+ extension: source.extension,
1131
+ mimeType: source.mimeType,
1132
+ size: source.size,
1133
+ contents: source.contents
1134
+ }
1135
+ });
1136
+ const media = await Media.create({
1137
+ uuid,
1138
+ model_type: ownerDefinition.morphClass,
1139
+ model_id: String(ownerId),
1140
+ collection_name: collectionName,
1141
+ name: source.name,
1142
+ file_name: source.fileName,
1143
+ disk: diskName,
1144
+ conversions_disk: conversionsDisk,
1145
+ mime_type: source.mimeType ?? null,
1146
+ extension: source.extension ?? null,
1147
+ size: source.size,
1148
+ path: originalPath,
1149
+ generated_conversions: generatedConversions,
1150
+ order_column: await resolveNextOrderColumn(
1151
+ ownerDefinition.morphClass,
1152
+ String(ownerId),
1153
+ collectionName
1154
+ )
1155
+ });
1156
+ createdMedia = media;
1157
+ this.entity.forgetRelation("media");
1158
+ await deleteMediaItemsWithRollback(existing);
1159
+ if (typeof collection2.onlyKeepLatest === "number") {
1160
+ const items = await this.entity.getMedia(collectionName);
1161
+ await deleteOverflowItems(items, collection2.onlyKeepLatest);
1162
+ }
1163
+ const queuedConversions = resolveQueuedConversionNames({
1164
+ definition: mediaDefinition,
1165
+ collectionName: collection2.name
1166
+ });
1167
+ await dispatchQueuedMediaConversionsForModel({
1168
+ mediaId: media.get("id"),
1169
+ conversionNames: queuedConversions
1170
+ }, async () => {
1171
+ await media.refresh();
1172
+ });
1173
+ return new MediaItem(media, this.entity);
1174
+ } catch (error) {
1175
+ await cleanupGeneratedConversions(generatedConversions, conversionsDisk).catch(() => void 0);
1176
+ if (originalStored) {
1177
+ await Storage3.disk(diskName).delete(originalPath).catch(() => void 0);
1178
+ }
1179
+ if (createdMedia) {
1180
+ await createdMedia.refresh().catch(() => void 0);
1181
+ await createdMedia.delete().catch(() => void 0);
1182
+ }
1183
+ this.entity.forgetRelation("media");
1184
+ throw error;
1185
+ }
1186
+ }
1187
+ };
1188
+
1189
+ // src/model/entity.ts
1190
+ function getOwnerKey(entity) {
1191
+ const ownerDefinition = entity.getRepository().definition;
1192
+ const ownerId = entity.get(ownerDefinition.primaryKey);
1193
+ if (ownerId === null || typeof ownerId === "undefined") {
1194
+ return null;
1195
+ }
1196
+ return String(ownerId);
1197
+ }
1198
+ function createQuery(entity, collectionName) {
1199
+ const mediaDefinition = getMediaDefinition(entity);
1200
+ if (!mediaDefinition) {
1201
+ return null;
1202
+ }
1203
+ const ownerDefinition = entity.getRepository().definition;
1204
+ const ownerId = getOwnerKey(entity);
1205
+ if (!ownerId) {
1206
+ return null;
1207
+ }
1208
+ let query = Media.query().where("model_type", ownerDefinition.morphClass).where("model_id", ownerId);
1209
+ if (collectionName) {
1210
+ query = query.where("collection_name", collectionName);
1211
+ }
1212
+ return query.orderBy("order_column").orderBy("id");
1213
+ }
1214
+ function installEntityMediaMethods() {
1215
+ const prototype = Entity2.prototype;
1216
+ if (prototype.__holoMediaInstalled__) {
1217
+ return;
1218
+ }
1219
+ prototype.__holoMediaInstalled__ = true;
1220
+ prototype.addMedia = function addMedia(source) {
1221
+ return new MediaAdder(this, source);
1222
+ };
1223
+ prototype.addMediaFromUrl = function addMediaFromUrl(url) {
1224
+ return new MediaAdder(this, { url });
1225
+ };
1226
+ prototype.getMedia = async function getMedia(collectionName) {
1227
+ const query = createQuery(this, collectionName);
1228
+ if (!query) {
1229
+ return [];
1230
+ }
1231
+ const rows = await query.get();
1232
+ return rows.map((row) => {
1233
+ return new MediaItem(row, this);
1234
+ });
1235
+ };
1236
+ prototype.getMediaUrls = async function getMediaUrls(collectionName, conversionName) {
1237
+ const items = await this.getMedia(collectionName);
1238
+ return items.map((item) => item.getUrl(conversionName)).filter((value) => typeof value === "string");
1239
+ };
1240
+ prototype.getMediaPaths = async function getMediaPaths(collectionName, conversionName) {
1241
+ const items = await this.getMedia(collectionName);
1242
+ return items.map((item) => item.getPath(conversionName)).filter((value) => typeof value === "string");
1243
+ };
1244
+ prototype.getFirstMedia = async function getFirstMedia(collectionName) {
1245
+ const query = createQuery(this, collectionName);
1246
+ if (!query) {
1247
+ return null;
1248
+ }
1249
+ const row = await query.first();
1250
+ return row ? new MediaItem(row, this) : null;
1251
+ };
1252
+ prototype.getFirstMediaUrl = async function getFirstMediaUrl(collectionName, conversionName) {
1253
+ const item = await this.getFirstMedia(collectionName);
1254
+ return item?.getUrl(conversionName) ?? null;
1255
+ };
1256
+ prototype.getFirstMediaPath = async function getFirstMediaPath(collectionName, conversionName) {
1257
+ const item = await this.getFirstMedia(collectionName);
1258
+ return item?.getPath(conversionName) ?? null;
1259
+ };
1260
+ prototype.getFirstTemporaryUrl = async function getFirstTemporaryUrl(collectionName, conversionName, options) {
1261
+ const item = await this.getFirstMedia(collectionName);
1262
+ return item?.getTemporaryUrl(conversionName, options) ?? null;
1263
+ };
1264
+ prototype.hasMedia = async function hasMedia(collectionName) {
1265
+ const query = createQuery(this, collectionName);
1266
+ if (!query) {
1267
+ return false;
1268
+ }
1269
+ return query.exists();
1270
+ };
1271
+ prototype.clearMediaCollection = async function clearMediaCollection(collectionName) {
1272
+ const items = await this.getMedia(collectionName);
1273
+ for (const item of items) {
1274
+ await item.delete();
1275
+ }
1276
+ };
1277
+ prototype.regenerateMedia = async function regenerateMedia(collectionName, conversions) {
1278
+ const items = await this.getMedia(collectionName);
1279
+ for (const item of items) {
1280
+ await item.regenerate(conversions);
1281
+ }
1282
+ };
1283
+ }
1284
+
1285
+ // src/defineMediaModel.ts
1286
+ import { morphMany } from "@holo-js/db";
1287
+
1288
+ // src/definitions/collections.ts
1289
+ function decorateCollection(definition) {
1290
+ const clone = (overrides) => decorateCollection(Object.freeze({
1291
+ ...definition,
1292
+ ...overrides
1293
+ }));
1294
+ return Object.freeze({
1295
+ kind: definition.kind,
1296
+ name: definition.name,
1297
+ definition,
1298
+ disk(diskName) {
1299
+ return clone({ diskName: diskName.trim() || void 0 });
1300
+ },
1301
+ conversionsDisk(diskName) {
1302
+ return clone({ conversionsDiskName: diskName.trim() || void 0 });
1303
+ },
1304
+ singleFile() {
1305
+ return clone({ singleFile: true, onlyKeepLatest: 1 });
1306
+ },
1307
+ onlyKeepLatest(limit) {
1308
+ return clone({
1309
+ onlyKeepLatest: Math.max(1, Math.floor(limit))
1310
+ });
1311
+ },
1312
+ acceptsMimeTypes(mimeTypes) {
1313
+ return clone({
1314
+ acceptedMimeTypes: Object.freeze(
1315
+ mimeTypes.map((mimeType) => mimeType.trim()).map((mimeType) => mimeType.toLowerCase()).filter(Boolean)
1316
+ )
1317
+ });
1318
+ },
1319
+ acceptsExtensions(extensions) {
1320
+ return clone({
1321
+ acceptedExtensions: Object.freeze(
1322
+ extensions.map((extension) => extension.trim().replace(/^\./, "").toLowerCase()).filter(Boolean)
1323
+ )
1324
+ });
1325
+ },
1326
+ maxSize(bytes) {
1327
+ return clone({
1328
+ maxFileSize: Math.max(1, Math.floor(bytes))
1329
+ });
1330
+ }
1331
+ });
1332
+ }
1333
+ function collection(name) {
1334
+ return decorateCollection(Object.freeze({
1335
+ kind: "collection",
1336
+ name,
1337
+ singleFile: false,
1338
+ acceptedMimeTypes: Object.freeze([]),
1339
+ acceptedExtensions: Object.freeze([])
1340
+ }));
1341
+ }
1342
+ function normalizeCollectionDefinitions(definitions) {
1343
+ return Object.freeze(definitions.map((definition) => {
1344
+ return Object.freeze({
1345
+ ...definition,
1346
+ disk: definition.diskName?.trim() || void 0,
1347
+ conversionsDisk: definition.conversionsDiskName?.trim() || void 0,
1348
+ singleFile: Boolean(definition.singleFile),
1349
+ onlyKeepLatest: typeof definition.onlyKeepLatest === "number" ? Math.max(1, Math.floor(definition.onlyKeepLatest)) : void 0,
1350
+ acceptedMimeTypes: Object.freeze(
1351
+ definition.acceptedMimeTypes.map((mimeType) => mimeType.trim()).map((mimeType) => mimeType.toLowerCase()).filter(Boolean)
1352
+ ),
1353
+ acceptedExtensions: Object.freeze(
1354
+ definition.acceptedExtensions.map((extension) => extension.trim().replace(/^\./, "").toLowerCase()).filter(Boolean)
1355
+ ),
1356
+ maxSize: typeof definition.maxFileSize === "number" ? Math.max(1, Math.floor(definition.maxFileSize)) : void 0
1357
+ });
1358
+ }));
1359
+ }
1360
+
1361
+ // src/definitions/conversions.ts
1362
+ function decorateConversion(definition) {
1363
+ const clone = (overrides) => decorateConversion(Object.freeze({
1364
+ ...definition,
1365
+ ...overrides
1366
+ }));
1367
+ return Object.freeze({
1368
+ kind: definition.kind,
1369
+ name: definition.name,
1370
+ definition,
1371
+ performOnCollections(...collections) {
1372
+ return clone({
1373
+ collections: Object.freeze(
1374
+ collections.filter(Boolean)
1375
+ )
1376
+ });
1377
+ },
1378
+ width(pixels) {
1379
+ return clone({
1380
+ width: Math.max(1, Math.floor(pixels))
1381
+ });
1382
+ },
1383
+ height(pixels) {
1384
+ return clone({
1385
+ height: Math.max(1, Math.floor(pixels))
1386
+ });
1387
+ },
1388
+ fit(mode) {
1389
+ return clone({ fit: mode });
1390
+ },
1391
+ format(format) {
1392
+ return clone({ format });
1393
+ },
1394
+ quality(value) {
1395
+ return clone({
1396
+ quality: Math.max(1, Math.min(100, Math.floor(value)))
1397
+ });
1398
+ },
1399
+ queued() {
1400
+ return clone({ queued: true });
1401
+ },
1402
+ nonQueued() {
1403
+ return clone({ queued: false });
1404
+ }
1405
+ });
1406
+ }
1407
+ function conversion(name) {
1408
+ return decorateConversion(Object.freeze({
1409
+ kind: "conversion",
1410
+ name,
1411
+ collections: Object.freeze([]),
1412
+ queued: false
1413
+ }));
1414
+ }
1415
+ function normalizeConversionDefinitions(definitions) {
1416
+ return Object.freeze(definitions.map((definition) => {
1417
+ return Object.freeze({
1418
+ ...definition,
1419
+ collections: Object.freeze(
1420
+ definition.collections.filter(Boolean)
1421
+ ),
1422
+ width: typeof definition.width === "number" ? Math.max(1, Math.floor(definition.width)) : void 0,
1423
+ height: typeof definition.height === "number" ? Math.max(1, Math.floor(definition.height)) : void 0,
1424
+ quality: typeof definition.quality === "number" ? Math.max(1, Math.min(100, Math.floor(definition.quality))) : void 0,
1425
+ queued: Boolean(definition.queued)
1426
+ });
1427
+ }));
1428
+ }
1429
+
1430
+ // src/definitions/config.ts
1431
+ function resolveMediaDefinition(input) {
1432
+ if (typeof input === "function") {
1433
+ return input({ collection, conversion });
1434
+ }
1435
+ return input;
1436
+ }
1437
+ function normalizeMediaDefinition(input) {
1438
+ const collections = normalizeCollectionDefinitions(
1439
+ (input.collections ?? []).map((item) => item.definition)
1440
+ );
1441
+ const conversions = normalizeConversionDefinitions(
1442
+ (input.conversions ?? []).map((item) => item.definition)
1443
+ );
1444
+ const collectionsByName = /* @__PURE__ */ Object.create(null);
1445
+ for (const definition of collections) {
1446
+ if (collectionsByName[definition.name]) {
1447
+ throw new Error(`[Holo Media] Duplicate media collection "${definition.name}".`);
1448
+ }
1449
+ collectionsByName[definition.name] = definition;
1450
+ }
1451
+ const conversionsByName = /* @__PURE__ */ Object.create(null);
1452
+ for (const definition of conversions) {
1453
+ if (conversionsByName[definition.name]) {
1454
+ throw new Error(`[Holo Media] Duplicate media conversion "${definition.name}".`);
1455
+ }
1456
+ for (const collectionName of definition.collections) {
1457
+ if (!collectionsByName[collectionName]) {
1458
+ throw new Error(
1459
+ `[Holo Media] Conversion "${definition.name}" references unknown collection "${collectionName}".`
1460
+ );
1461
+ }
1462
+ }
1463
+ conversionsByName[definition.name] = definition;
1464
+ }
1465
+ return Object.freeze({
1466
+ collections,
1467
+ conversions,
1468
+ collectionsByName: Object.freeze(collectionsByName),
1469
+ conversionsByName: Object.freeze(conversionsByName)
1470
+ });
1471
+ }
1472
+
1473
+ // src/defineMediaModel.ts
1474
+ function defineMediaModel(model, definition) {
1475
+ installEntityMediaMethods();
1476
+ const resolvedDefinition = normalizeMediaDefinition(
1477
+ resolveMediaDefinition(definition)
1478
+ );
1479
+ registerMediaDefinition(model, resolvedDefinition);
1480
+ model.resolveRelationUsing("media", () => morphMany(
1481
+ () => Media,
1482
+ "model"
1483
+ ));
1484
+ return model;
1485
+ }
1486
+
1487
+ // src/index.ts
1488
+ installEntityMediaMethods();
1489
+ ensureMediaQueueJobRegistered();
1490
+ export {
1491
+ MEDIA_GENERATE_CONVERSIONS_JOB,
1492
+ Media,
1493
+ MediaAdder,
1494
+ MediaItem,
1495
+ collection,
1496
+ conversion,
1497
+ createDefaultMediaConversionExecutor,
1498
+ defaultMediaConversionExecutor,
1499
+ defineMediaModel,
1500
+ dispatchQueuedMediaConversions,
1501
+ ensureMediaQueueJobRegistered,
1502
+ getMediaConversionExecutor,
1503
+ getMediaDefinition,
1504
+ getMediaDefinitionForMorphClass,
1505
+ getMediaPathGenerator,
1506
+ normalizeCollectionDefinitions,
1507
+ normalizeConversionDefinitions,
1508
+ normalizeMediaDefinition,
1509
+ registerMediaDefinition,
1510
+ requireMediaDefinition,
1511
+ requireMediaDefinitionForMorphClass,
1512
+ resetMediaPathGenerator,
1513
+ resetMediaRuntime,
1514
+ resolveMediaCollection,
1515
+ resolveMediaConversion,
1516
+ resolveMediaDefinition,
1517
+ runMediaGenerateConversionsJob,
1518
+ setMediaConversionExecutor,
1519
+ setMediaPathGenerator
1520
+ };