@kimjansheden/payload-video-processor 0.1.1

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/dist/index.cjs ADDED
@@ -0,0 +1,1141 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var zod = require('zod');
6
+ var bullmq = require('bullmq');
7
+ var IORedis = require('ioredis');
8
+ var fs2 = require('fs/promises');
9
+ var path = require('path');
10
+ var url = require('url');
11
+ var payload = require('payload');
12
+ var ffmpeg2 = require('fluent-ffmpeg');
13
+ var ffmpegStatic = require('ffmpeg-static');
14
+ var ffprobeStatic = require('ffprobe-static');
15
+
16
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
17
+
18
+ var IORedis__default = /*#__PURE__*/_interopDefault(IORedis);
19
+ var fs2__default = /*#__PURE__*/_interopDefault(fs2);
20
+ var path__default = /*#__PURE__*/_interopDefault(path);
21
+ var payload__default = /*#__PURE__*/_interopDefault(payload);
22
+ var ffmpeg2__default = /*#__PURE__*/_interopDefault(ffmpeg2);
23
+ var ffmpegStatic__default = /*#__PURE__*/_interopDefault(ffmpegStatic);
24
+ var ffprobeStatic__default = /*#__PURE__*/_interopDefault(ffprobeStatic);
25
+
26
+ // src/options.ts
27
+ var presetSchema = zod.z.object({
28
+ args: zod.z.array(zod.z.string()),
29
+ label: zod.z.string().optional(),
30
+ enableCrop: zod.z.boolean().optional()
31
+ });
32
+ var videoPluginOptionsSchema = zod.z.object({
33
+ presets: zod.z.record(presetSchema).refine((value) => Object.keys(value).length > 0, {
34
+ message: "At least one preset must be defined."
35
+ }),
36
+ queue: zod.z.object({
37
+ name: zod.z.string().min(1).optional(),
38
+ redisUrl: zod.z.string().optional(),
39
+ concurrency: zod.z.number().int().positive().optional()
40
+ }).optional(),
41
+ access: zod.z.any().optional(),
42
+ resolvePaths: zod.z.any().optional()
43
+ });
44
+ var normalizePresets = (presets) => {
45
+ const entries = Object.entries(presets).map(([name, preset]) => [
46
+ name,
47
+ { ...preset }
48
+ ]);
49
+ return Object.fromEntries(entries);
50
+ };
51
+ var ensureOptions = (options) => videoPluginOptionsSchema.parse(options);
52
+ var createQueue = ({
53
+ name,
54
+ redisUrl
55
+ }) => {
56
+ const connection = redisUrl ? new IORedis__default.default(redisUrl, { maxRetriesPerRequest: null }) : new IORedis__default.default({ maxRetriesPerRequest: null });
57
+ const queue = new bullmq.Queue(name, {
58
+ connection
59
+ });
60
+ const events = new bullmq.QueueEvents(name, {
61
+ connection: connection.duplicate()
62
+ });
63
+ events.on("error", (error) => {
64
+ console.error("[video-processor] QueueEvents error", error);
65
+ });
66
+ void events.waitUntilReady();
67
+ return { queue, events };
68
+ };
69
+ var cropSchema = zod.z.object({
70
+ x: zod.z.number().min(0).max(1),
71
+ y: zod.z.number().min(0).max(1),
72
+ width: zod.z.number().positive().max(1),
73
+ height: zod.z.number().positive().max(1)
74
+ }).refine((value) => value.width > 0 && value.height > 0, {
75
+ message: "Crop width and height must be > 0"
76
+ });
77
+ var videoJobSchema = zod.z.object({
78
+ collection: zod.z.string().min(1),
79
+ id: zod.z.union([zod.z.string(), zod.z.number()]).transform((value) => value.toString()),
80
+ preset: zod.z.string().min(1),
81
+ crop: cropSchema.optional()
82
+ });
83
+
84
+ // src/api/shared.ts
85
+ var readRequestBody = async (req) => {
86
+ if (typeof req.json === "function") {
87
+ try {
88
+ return await req.json();
89
+ } catch {
90
+ }
91
+ }
92
+ if (typeof req.body !== "undefined") {
93
+ return req.body;
94
+ }
95
+ return void 0;
96
+ };
97
+
98
+ // src/api/enqueue.ts
99
+ var bodySchema = videoJobSchema.extend({
100
+ crop: videoJobSchema.shape.crop.optional()
101
+ });
102
+ var createEnqueueHandler = ({ getQueue, presets, access }) => async (req) => {
103
+ try {
104
+ if (access?.enqueue) {
105
+ const allowed = await access.enqueue({ req });
106
+ if (!allowed) {
107
+ return Response.json(
108
+ { error: "Not allowed to enqueue video processing jobs." },
109
+ { status: 403 }
110
+ );
111
+ }
112
+ }
113
+ const rawBody = await readRequestBody(req);
114
+ const parsed = bodySchema.parse(rawBody);
115
+ if (!presets[parsed.preset]) {
116
+ return Response.json(
117
+ { error: `Unknown preset \`${parsed.preset}\`.` },
118
+ { status: 400 }
119
+ );
120
+ }
121
+ const payloadClient = req.payload;
122
+ const doc = await payloadClient.findByID({
123
+ collection: parsed.collection,
124
+ id: parsed.id
125
+ });
126
+ if (!doc) {
127
+ return Response.json({ error: "Document not found." }, { status: 404 });
128
+ }
129
+ const queue = getQueue();
130
+ const job = await queue.add(parsed.preset, parsed, {
131
+ // Keep completed jobs briefly so the status endpoint can report "completed"
132
+ // before BullMQ removes the job entry.
133
+ removeOnComplete: { age: 60 },
134
+ removeOnFail: false
135
+ });
136
+ return Response.json({ id: job.id, state: "queued" }, { status: 202 });
137
+ } catch (error) {
138
+ if (error instanceof zod.z.ZodError) {
139
+ return Response.json(
140
+ { error: error.message, issues: error.issues },
141
+ { status: 400 }
142
+ );
143
+ }
144
+ console.error("[video-processor] Enqueue handler failed", error);
145
+ return Response.json(
146
+ { error: "Unexpected error while enqueuing video job." },
147
+ { status: 500 }
148
+ );
149
+ }
150
+ };
151
+
152
+ // src/api/status.ts
153
+ var readJobId = (req) => {
154
+ const directParam = req.params?.jobId;
155
+ if (typeof directParam === "string" && directParam.length > 0) {
156
+ return directParam;
157
+ }
158
+ const routeParam = req.routeParams?.jobId;
159
+ if (typeof routeParam === "string" && routeParam.length > 0) {
160
+ return routeParam;
161
+ }
162
+ return void 0;
163
+ };
164
+ var createStatusHandler = ({ getQueue }) => async (req) => {
165
+ try {
166
+ const jobId = readJobId(req);
167
+ if (!jobId) {
168
+ return Response.json(
169
+ { error: "jobId parameter is required." },
170
+ { status: 400 }
171
+ );
172
+ }
173
+ const queue = getQueue();
174
+ const job = await queue.getJob(jobId);
175
+ if (!job) {
176
+ return Response.json({ error: "Job not found." }, { status: 404 });
177
+ }
178
+ const state = await job.getState();
179
+ return Response.json({ id: job.id, state, progress: job.progress });
180
+ } catch (error) {
181
+ console.error("[video-processor] Status handler failed", error);
182
+ return Response.json(
183
+ { error: "Unexpected error while reading job status." },
184
+ { status: 500 }
185
+ );
186
+ }
187
+ };
188
+ var safeNormalize = (input) => path__default.default.resolve(input);
189
+ var ensureTrailingSep = (input) => input.endsWith(path__default.default.sep) ? input : `${input}${path__default.default.sep}`;
190
+ var isWithinRoot = (candidate, root) => {
191
+ const normalizedCandidate = safeNormalize(candidate);
192
+ const normalizedRoot = safeNormalize(root);
193
+ return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(ensureTrailingSep(normalizedRoot));
194
+ };
195
+ var gatherAllowedRoots = ({
196
+ collection,
197
+ doc
198
+ }) => {
199
+ const roots = /* @__PURE__ */ new Set();
200
+ roots.add(safeNormalize(process.cwd()));
201
+ const staticDirEnv = process.env.STATIC_DIR;
202
+ if (typeof staticDirEnv === "string" && staticDirEnv.trim()) {
203
+ roots.add(safeNormalize(staticDirEnv));
204
+ }
205
+ const uploadsDirEnv = process.env.PAYLOAD_UPLOADS_DIR;
206
+ if (typeof uploadsDirEnv === "string" && uploadsDirEnv.trim()) {
207
+ roots.add(safeNormalize(uploadsDirEnv));
208
+ }
209
+ const uploadConfig = collection && typeof collection.upload === "object" ? collection.upload : null;
210
+ const staticDirConfig = uploadConfig && typeof uploadConfig.staticDir === "string" ? uploadConfig.staticDir : null;
211
+ if (staticDirConfig) {
212
+ roots.add(safeNormalize(staticDirConfig));
213
+ }
214
+ const docPath = doc && typeof doc.path === "string" ? doc.path.trim() : void 0;
215
+ if (docPath && docPath.length > 0) {
216
+ if (path__default.default.isAbsolute(docPath)) {
217
+ roots.add(safeNormalize(path__default.default.dirname(docPath)));
218
+ } else {
219
+ roots.add(safeNormalize(path__default.default.join(process.cwd(), path__default.default.dirname(docPath))));
220
+ if (staticDirConfig) {
221
+ roots.add(
222
+ safeNormalize(path__default.default.join(staticDirConfig, path__default.default.dirname(docPath)))
223
+ );
224
+ }
225
+ }
226
+ }
227
+ return Array.from(roots);
228
+ };
229
+ var resolveAbsolutePath = (input, allowedRoots) => {
230
+ if (!input) return null;
231
+ const trimmed = input.trim();
232
+ if (!trimmed) return null;
233
+ const normalizedRoots = allowedRoots.map(safeNormalize);
234
+ if (path__default.default.isAbsolute(trimmed)) {
235
+ const normalized = safeNormalize(trimmed);
236
+ return normalizedRoots.some((root) => isWithinRoot(normalized, root)) ? normalized : null;
237
+ }
238
+ for (const root of normalizedRoots) {
239
+ const candidate = safeNormalize(path__default.default.join(root, trimmed));
240
+ if (isWithinRoot(candidate, root)) {
241
+ return candidate;
242
+ }
243
+ }
244
+ return null;
245
+ };
246
+ var cachedClient = null;
247
+ var localInitialized = false;
248
+ var normalizeConfigPath = (configPath) => {
249
+ if (configPath.startsWith("file://")) return configPath;
250
+ return url.pathToFileURL(path__default.default.resolve(configPath)).href;
251
+ };
252
+ var buildAuthHeaders = (token) => ({
253
+ Authorization: `Bearer ${token}`,
254
+ "X-Payload-API-Key": token
255
+ });
256
+ var initLocalPayload = async () => {
257
+ const secret = process.env.PAYLOAD_SECRET;
258
+ const mongoURL = process.env.MONGODB_URI;
259
+ if (!secret || !mongoURL) {
260
+ return null;
261
+ }
262
+ try {
263
+ const configPath = process.env.PAYLOAD_CONFIG_PATH;
264
+ let configModule;
265
+ if (configPath) {
266
+ const imported = await import(normalizeConfigPath(configPath));
267
+ configModule = imported?.default ?? imported;
268
+ }
269
+ if (!localInitialized) {
270
+ const initOptions = {
271
+ secret,
272
+ mongoURL,
273
+ local: true
274
+ };
275
+ if (configModule) {
276
+ initOptions.config = configModule;
277
+ }
278
+ await payload__default.default.init(initOptions);
279
+ localInitialized = true;
280
+ }
281
+ const instance = payload__default.default;
282
+ return {
283
+ findByID: ({ collection, id }) => instance.findByID({
284
+ collection,
285
+ id
286
+ }),
287
+ update: ({ collection, id, data }) => instance.update({
288
+ collection,
289
+ id,
290
+ data
291
+ }),
292
+ getCollectionConfig: (slug) => instance.collections?.[slug]?.config
293
+ };
294
+ } catch (error) {
295
+ console.warn(
296
+ "[video-processor] Failed to initialize Payload locally, falling back to REST client.",
297
+ error
298
+ );
299
+ return null;
300
+ }
301
+ };
302
+ var initRestClient = async () => {
303
+ const baseUrl = process.env.PAYLOAD_REST_URL || process.env.PAYLOAD_PUBLIC_URL || process.env.PAYLOAD_SERVER_URL;
304
+ const token = process.env.PAYLOAD_ADMIN_TOKEN;
305
+ if (!baseUrl || !token) {
306
+ throw new Error(
307
+ "Unable to establish Payload REST client. Provide PAYLOAD_REST_URL (or PAYLOAD_PUBLIC_URL) and PAYLOAD_ADMIN_TOKEN."
308
+ );
309
+ }
310
+ const base = baseUrl.replace(/\/$/, "");
311
+ const headers = buildAuthHeaders(token);
312
+ const request = async (url, init) => {
313
+ const response = await fetch(url, {
314
+ ...init,
315
+ headers: {
316
+ "Content-Type": "application/json",
317
+ Accept: "application/json",
318
+ ...headers,
319
+ ...init?.headers
320
+ }
321
+ });
322
+ if (!response.ok) {
323
+ const text = await response.text();
324
+ throw new Error(
325
+ `Payload REST request failed (${response.status}): ${text}`
326
+ );
327
+ }
328
+ return await response.json();
329
+ };
330
+ return {
331
+ findByID: async ({ collection, id }) => {
332
+ const result = await request(
333
+ `${base}/api/${collection}/${id}`
334
+ );
335
+ return result.doc ?? result;
336
+ },
337
+ update: async ({ collection, id, data }) => {
338
+ const result = await request(
339
+ `${base}/api/${collection}/${id}`,
340
+ {
341
+ method: "PATCH",
342
+ body: JSON.stringify(data)
343
+ }
344
+ );
345
+ return result.doc ?? result;
346
+ }
347
+ };
348
+ };
349
+ var getPayloadClient = async () => {
350
+ if (cachedClient) return cachedClient;
351
+ const localClient = await initLocalPayload();
352
+ if (localClient) {
353
+ cachedClient = localClient;
354
+ return localClient;
355
+ }
356
+ const restClient = await initRestClient();
357
+ cachedClient = restClient;
358
+ return restClient;
359
+ };
360
+ var getCollectionConfigFromRequest = (req, slug) => {
361
+ const payloadInstance = req.payload;
362
+ const collections = payloadInstance?.collections;
363
+ const fromCollections = collections?.[slug];
364
+ if (fromCollections) {
365
+ if (typeof fromCollections.config === "object") {
366
+ return fromCollections.config;
367
+ }
368
+ if (typeof fromCollections === "object") {
369
+ return fromCollections;
370
+ }
371
+ }
372
+ const configured = Array.isArray(payloadInstance.config?.collections) ? payloadInstance.config?.collections : void 0;
373
+ if (configured) {
374
+ const match = configured.find(
375
+ (collection) => collection && collection.slug === slug
376
+ );
377
+ if (match) {
378
+ return match;
379
+ }
380
+ }
381
+ return null;
382
+ };
383
+
384
+ // src/api/removeVariant.ts
385
+ var bodySchema2 = zod.z.object({
386
+ collection: zod.z.string().min(1, "collection is required"),
387
+ id: zod.z.string().min(1, "id is required"),
388
+ preset: zod.z.string().min(1).optional(),
389
+ variantId: zod.z.string().min(1).optional(),
390
+ variantIndex: zod.z.union([zod.z.number().int().nonnegative(), zod.z.string().regex(/^\d+$/)]).optional()
391
+ }).superRefine((value, ctx) => {
392
+ if (typeof value.variantIndex === "undefined" && !value.variantId && !value.preset) {
393
+ ctx.addIssue({
394
+ code: zod.z.ZodIssueCode.custom,
395
+ message: "preset, variantId or variantIndex must be provided to remove a variant.",
396
+ path: ["variantIndex"]
397
+ });
398
+ }
399
+ });
400
+ var createRemoveVariantHandler = ({ access }) => async (req) => {
401
+ try {
402
+ const rawBody = await readRequestBody(req);
403
+ const parsed = bodySchema2.parse(rawBody);
404
+ const variantIndex = typeof parsed.variantIndex === "number" ? parsed.variantIndex : typeof parsed.variantIndex === "string" ? Number(parsed.variantIndex) : void 0;
405
+ if (Number.isNaN(variantIndex)) {
406
+ return Response.json(
407
+ { error: "variantIndex must be a non-negative integer." },
408
+ { status: 400 }
409
+ );
410
+ }
411
+ if (access?.removeVariant) {
412
+ const allowed = await access.removeVariant({
413
+ req,
414
+ collection: parsed.collection,
415
+ id: parsed.id,
416
+ preset: parsed.preset,
417
+ variantId: parsed.variantId,
418
+ variantIndex
419
+ });
420
+ if (!allowed) {
421
+ return Response.json(
422
+ { error: "Not allowed to remove video variants." },
423
+ { status: 403 }
424
+ );
425
+ }
426
+ }
427
+ const payloadClient = req.payload;
428
+ const doc = await payloadClient.findByID({
429
+ collection: parsed.collection,
430
+ id: parsed.id
431
+ }).catch((error) => {
432
+ console.error("[video-processor] Failed to load document", error);
433
+ return null;
434
+ });
435
+ if (!doc) {
436
+ return Response.json({ error: "Document not found." }, { status: 404 });
437
+ }
438
+ const variants = Array.isArray(doc.variants) ? doc.variants : [];
439
+ let targetIndex = typeof variantIndex === "number" ? variantIndex : Number.NaN;
440
+ if (Number.isNaN(targetIndex) && parsed.variantId) {
441
+ targetIndex = variants.findIndex(
442
+ (variant) => Boolean(
443
+ variant && typeof variant === "object" && variant.id === parsed.variantId
444
+ )
445
+ );
446
+ }
447
+ if (Number.isNaN(targetIndex) && parsed.preset) {
448
+ targetIndex = variants.findIndex(
449
+ (variant) => Boolean(
450
+ variant && typeof variant === "object" && variant.preset === parsed.preset
451
+ )
452
+ );
453
+ }
454
+ if (!Number.isInteger(targetIndex) || targetIndex < 0) {
455
+ return Response.json({ error: "Variant not found." }, { status: 404 });
456
+ }
457
+ const targetVariant = variants[targetIndex];
458
+ if (!targetVariant || typeof targetVariant !== "object") {
459
+ return Response.json({ error: "Variant not found." }, { status: 404 });
460
+ }
461
+ const collectionConfig = getCollectionConfigFromRequest(
462
+ req,
463
+ parsed.collection
464
+ );
465
+ const allowedRoots = gatherAllowedRoots({
466
+ collection: collectionConfig,
467
+ doc
468
+ });
469
+ const variantPath = typeof targetVariant.path === "string" ? targetVariant.path.trim() : "";
470
+ let resolvedVariantPath = null;
471
+ if (variantPath) {
472
+ resolvedVariantPath = resolveAbsolutePath(variantPath, allowedRoots);
473
+ if (!resolvedVariantPath) {
474
+ return Response.json(
475
+ { error: "Variant path is outside allowed directories." },
476
+ { status: 400 }
477
+ );
478
+ }
479
+ }
480
+ const nextVariants = variants.filter(
481
+ (_variant, index) => index !== targetIndex
482
+ );
483
+ if (resolvedVariantPath) {
484
+ await fs2__default.default.rm(resolvedVariantPath).catch(() => {
485
+ console.warn(
486
+ `[video-processor] Could not remove variant file at ${resolvedVariantPath}`
487
+ );
488
+ });
489
+ } else {
490
+ const fallbackUrl = typeof targetVariant.url === "string" ? targetVariant.url : "";
491
+ const fallbackFilename = fallbackUrl.split("/").pop();
492
+ if (fallbackFilename) {
493
+ const fallbackPath = resolveAbsolutePath(
494
+ fallbackFilename,
495
+ allowedRoots
496
+ );
497
+ if (fallbackPath) {
498
+ await fs2__default.default.rm(fallbackPath).catch(() => {
499
+ console.warn(
500
+ `[video-processor] Could not remove fallback variant file at ${fallbackPath}`
501
+ );
502
+ });
503
+ }
504
+ }
505
+ }
506
+ const updated = await payloadClient.update({
507
+ collection: parsed.collection,
508
+ id: doc.id ?? parsed.id,
509
+ data: { variants: nextVariants }
510
+ });
511
+ return Response.json({ success: true, doc: updated });
512
+ } catch (error) {
513
+ if (error instanceof zod.z.ZodError) {
514
+ return Response.json(
515
+ { error: error.message, issues: error.issues },
516
+ { status: 400 }
517
+ );
518
+ }
519
+ console.error("[video-processor] Failed to remove variant", error);
520
+ return Response.json(
521
+ { error: "Unable to remove video variant." },
522
+ { status: 500 }
523
+ );
524
+ }
525
+ };
526
+ var bodySchema3 = zod.z.object({
527
+ collection: zod.z.string().min(1, "collection is required"),
528
+ id: zod.z.string().min(1, "id is required"),
529
+ preset: zod.z.string().min(1).optional()
530
+ });
531
+ var createReplaceOriginalHandler = ({ access }) => async (req) => {
532
+ try {
533
+ const rawBody = await readRequestBody(req);
534
+ const parsed = bodySchema3.parse(rawBody);
535
+ if (access?.replaceOriginal) {
536
+ const allowed = await access.replaceOriginal({
537
+ req,
538
+ collection: parsed.collection,
539
+ id: parsed.id,
540
+ preset: parsed.preset
541
+ });
542
+ if (!allowed) {
543
+ return Response.json(
544
+ { error: "Not allowed to replace original video." },
545
+ { status: 403 }
546
+ );
547
+ }
548
+ }
549
+ const payloadClient = req.payload;
550
+ const doc = await payloadClient.findByID({
551
+ collection: parsed.collection,
552
+ id: parsed.id
553
+ }).catch((error) => {
554
+ console.error(
555
+ "[video-processor] Failed to load document for replace-original",
556
+ error
557
+ );
558
+ return null;
559
+ });
560
+ if (!doc) {
561
+ return Response.json({ error: "Document not found." }, { status: 404 });
562
+ }
563
+ const variants = Array.isArray(doc.variants) ? [...doc.variants] : [];
564
+ if (variants.length === 0) {
565
+ return Response.json(
566
+ { error: "No variants are available for replacement." },
567
+ { status: 400 }
568
+ );
569
+ }
570
+ const targetVariant = parsed.preset ? variants.find((variant) => variant?.preset === parsed.preset) : variants[0];
571
+ if (!targetVariant) {
572
+ return Response.json(
573
+ { error: "Requested variant was not found." },
574
+ { status: 404 }
575
+ );
576
+ }
577
+ const collectionConfig = getCollectionConfigFromRequest(
578
+ req,
579
+ parsed.collection
580
+ );
581
+ const allowedRoots = gatherAllowedRoots({
582
+ collection: collectionConfig,
583
+ doc
584
+ });
585
+ const variantPath = typeof targetVariant.path === "string" ? targetVariant.path.trim() : "";
586
+ if (!variantPath) {
587
+ return Response.json(
588
+ { error: "Variant does not expose a file path." },
589
+ { status: 400 }
590
+ );
591
+ }
592
+ const resolvedVariantPath = resolveAbsolutePath(
593
+ variantPath,
594
+ allowedRoots
595
+ );
596
+ if (!resolvedVariantPath) {
597
+ return Response.json(
598
+ { error: "Variant path is outside allowed directories." },
599
+ { status: 400 }
600
+ );
601
+ }
602
+ const originalPath = typeof doc.path === "string" && doc.path.trim().length > 0 ? doc.path.trim() : typeof doc.filename === "string" && doc.filename.trim().length > 0 ? doc.filename.trim() : "";
603
+ const resolvedOriginalPath = originalPath ? resolveAbsolutePath(originalPath, allowedRoots) : null;
604
+ if (!resolvedOriginalPath) {
605
+ return Response.json(
606
+ { error: "Original file path could not be resolved." },
607
+ { status: 400 }
608
+ );
609
+ }
610
+ await fs2__default.default.rm(resolvedOriginalPath).catch(() => void 0);
611
+ await fs2__default.default.mkdir(path__default.default.dirname(resolvedOriginalPath), { recursive: true });
612
+ await fs2__default.default.rename(resolvedVariantPath, resolvedOriginalPath);
613
+ const updateData = {
614
+ variants: variants.filter(
615
+ (variant) => variant?.preset !== targetVariant.preset
616
+ )
617
+ };
618
+ if (typeof targetVariant.size === "number") {
619
+ updateData.filesize = targetVariant.size;
620
+ }
621
+ if (typeof targetVariant.duration === "number") {
622
+ updateData.duration = targetVariant.duration;
623
+ }
624
+ if (typeof targetVariant.width === "number") {
625
+ updateData.width = targetVariant.width;
626
+ }
627
+ if (typeof targetVariant.height === "number") {
628
+ updateData.height = targetVariant.height;
629
+ }
630
+ if (typeof targetVariant.bitrate === "number") {
631
+ updateData.bitrate = targetVariant.bitrate;
632
+ }
633
+ const updated = await payloadClient.update({
634
+ collection: parsed.collection,
635
+ id: doc.id ?? parsed.id,
636
+ data: updateData
637
+ });
638
+ return Response.json({ success: true, doc: updated });
639
+ } catch (error) {
640
+ if (error instanceof zod.z.ZodError) {
641
+ return Response.json(
642
+ { error: error.message, issues: error.issues },
643
+ { status: 400 }
644
+ );
645
+ }
646
+ console.error("[video-processor] Failed to replace original", error);
647
+ return Response.json(
648
+ { error: "Unable to replace original video file." },
649
+ { status: 500 }
650
+ );
651
+ }
652
+ };
653
+
654
+ // src/ffmpeg/args.ts
655
+ var FASTSTART_FLAGS = ["-movflags", "+faststart"];
656
+ var CRF_FLAG = "-crf";
657
+ var hasCrf = (args) => {
658
+ for (let i = 0; i < args.length; i += 1) {
659
+ if (args[i] === CRF_FLAG) {
660
+ return true;
661
+ }
662
+ }
663
+ return false;
664
+ };
665
+ var hasFaststart = (args) => {
666
+ for (let i = 0; i < args.length; i += 1) {
667
+ if (args[i] === "-movflags") {
668
+ const value = args[i + 1];
669
+ if (typeof value === "string" && value.includes("faststart")) {
670
+ return true;
671
+ }
672
+ }
673
+ }
674
+ return false;
675
+ };
676
+ var extractFilters = (args) => {
677
+ const rest = [];
678
+ const filters = [];
679
+ for (let i = 0; i < args.length; i += 1) {
680
+ const current = args[i];
681
+ if (current === "-vf" || current === "-filter:v") {
682
+ const value = args[i + 1];
683
+ if (typeof value === "string") {
684
+ filters.push(value);
685
+ }
686
+ i += 1;
687
+ } else {
688
+ rest.push(current);
689
+ }
690
+ }
691
+ return { rest, filters };
692
+ };
693
+ var clamp = (value, min, max) => {
694
+ if (Number.isNaN(value)) return min;
695
+ if (value < min) return min;
696
+ if (value > max) return max;
697
+ return value;
698
+ };
699
+ var buildCropFilter = (crop, dimensions) => {
700
+ if (!dimensions?.width || !dimensions?.height) return void 0;
701
+ const cropWidth = Math.max(1, Math.round(dimensions.width * crop.width));
702
+ const cropHeight = Math.max(1, Math.round(dimensions.height * crop.height));
703
+ const maxX = Math.max(0, dimensions.width - cropWidth);
704
+ const maxY = Math.max(0, dimensions.height - cropHeight);
705
+ const x = clamp(Math.round(dimensions.width * crop.x), 0, maxX);
706
+ const y = clamp(Math.round(dimensions.height * crop.y), 0, maxY);
707
+ return `crop=${cropWidth}:${cropHeight}:${x}:${y}`;
708
+ };
709
+ var buildFfmpegArgs = ({
710
+ presetArgs,
711
+ crop,
712
+ dimensions,
713
+ defaultCrf = 24
714
+ }) => {
715
+ const args = [...presetArgs];
716
+ const { rest, filters } = extractFilters(args);
717
+ if (!hasCrf(rest)) {
718
+ rest.push(CRF_FLAG, String(defaultCrf));
719
+ }
720
+ if (!hasFaststart(rest)) {
721
+ rest.push(...FASTSTART_FLAGS);
722
+ }
723
+ if (crop) {
724
+ const cropFilter = buildCropFilter(crop, dimensions);
725
+ if (cropFilter) {
726
+ filters.push(cropFilter);
727
+ }
728
+ }
729
+ if (filters.length > 0) {
730
+ rest.push("-vf", filters.join(","));
731
+ }
732
+ return {
733
+ globalOptions: ["-y"],
734
+ outputOptions: rest
735
+ };
736
+ };
737
+ if (ffprobeStatic__default.default.path) {
738
+ ffmpeg2__default.default.setFfprobePath(ffprobeStatic__default.default.path);
739
+ }
740
+ var probeVideo = async (filePath) => new Promise((resolve, reject) => {
741
+ ffmpeg2__default.default.ffprobe(filePath, (error, metadata) => {
742
+ if (error) {
743
+ reject(error);
744
+ return;
745
+ }
746
+ const videoStream = metadata.streams.find(
747
+ (stream) => stream.codec_type === "video"
748
+ );
749
+ const width = videoStream?.width;
750
+ const height = videoStream?.height;
751
+ const durationRaw = videoStream?.duration ?? metadata.format?.duration;
752
+ const duration = typeof durationRaw !== "undefined" ? Number(durationRaw) : void 0;
753
+ const bitrateRaw = videoStream?.bit_rate ?? metadata.format?.bit_rate;
754
+ const bitrate = typeof bitrateRaw !== "undefined" ? Number(bitrateRaw) : void 0;
755
+ resolve({
756
+ width: width ?? void 0,
757
+ height: height ?? void 0,
758
+ duration: Number.isNaN(duration) ? void 0 : duration,
759
+ bitrate: Number.isNaN(bitrate) ? void 0 : bitrate
760
+ });
761
+ });
762
+ });
763
+ var normalizeUrl = (input, filename) => {
764
+ if (!input) return filename ?? "";
765
+ const parts = input.split("?");
766
+ const base = parts[0];
767
+ const query = parts[1] ? `?${parts.slice(1).join("?")}` : "";
768
+ const lastSlash = base.lastIndexOf("/");
769
+ if (lastSlash === -1) {
770
+ return filename ?? base;
771
+ }
772
+ const prefix = base.slice(0, lastSlash);
773
+ const sanitized = filename ?? base.slice(lastSlash + 1);
774
+ return `${prefix}/${sanitized}${query}`;
775
+ };
776
+ var defaultResolvePaths = ({
777
+ original,
778
+ presetName
779
+ }) => {
780
+ const originalFilename = original.filename ?? path__default.default.basename(original.path);
781
+ const extension = path__default.default.extname(originalFilename) || path__default.default.extname(original.path) || ".mp4";
782
+ const baseName = path__default.default.basename(originalFilename, extension);
783
+ const variantFilename = `${baseName}_${presetName}${extension}`;
784
+ const originalDir = path__default.default.dirname(original.path);
785
+ const absoluteDir = path__default.default.isAbsolute(original.path) ? originalDir : path__default.default.join(process.cwd(), originalDir);
786
+ const url = normalizeUrl(original.url, variantFilename);
787
+ return {
788
+ dir: absoluteDir,
789
+ filename: variantFilename,
790
+ url
791
+ };
792
+ };
793
+ var buildStoredPath = (originalPath, variantFilename) => {
794
+ const originalDir = path__default.default.dirname(originalPath);
795
+ return path__default.default.join(originalDir, variantFilename);
796
+ };
797
+ var buildWritePath = (dir, filename) => path__default.default.join(dir, filename);
798
+
799
+ // src/queue/createWorker.ts
800
+ var envFfmpegPath = process.env.FFMPEG_BIN?.trim();
801
+ var ffmpegBinary = envFfmpegPath && envFfmpegPath.length > 0 ? envFfmpegPath : typeof ffmpegStatic__default.default === "string" ? ffmpegStatic__default.default : null;
802
+ if (ffmpegBinary) {
803
+ ffmpeg2__default.default.setFfmpegPath(ffmpegBinary);
804
+ }
805
+ if (ffprobeStatic__default.default.path) {
806
+ ffmpeg2__default.default.setFfprobePath(ffprobeStatic__default.default.path);
807
+ }
808
+ var createWorker = async (rawOptions) => {
809
+ const options = ensureOptions(rawOptions);
810
+ const presets = options.presets;
811
+ const queueName = options.queue?.name ?? "video-transcode";
812
+ const concurrency = options.queue?.concurrency ?? 1;
813
+ const redisUrl = options.queue?.redisUrl ?? process.env.REDIS_URL;
814
+ const connection = redisUrl ? new IORedis__default.default(redisUrl, { maxRetriesPerRequest: null }) : new IORedis__default.default({ maxRetriesPerRequest: null });
815
+ const worker = new bullmq.Worker(
816
+ queueName,
817
+ async (job) => {
818
+ const parsed = videoJobSchema.parse(job.data);
819
+ const preset = presets[parsed.preset];
820
+ if (!preset) {
821
+ throw new Error(`Unknown preset ${parsed.preset}`);
822
+ }
823
+ job.updateProgress(5);
824
+ const client = await getPayloadClient();
825
+ const document = await client.findByID({
826
+ collection: parsed.collection,
827
+ id: parsed.id
828
+ });
829
+ if (!document) {
830
+ throw new Error(
831
+ `Document ${parsed.id} in collection ${parsed.collection} not found`
832
+ );
833
+ }
834
+ const originalPath = document?.path;
835
+ const filename = document?.filename;
836
+ const url = document?.url;
837
+ if (!originalPath) {
838
+ throw new Error("Source document does not expose a `path` property.");
839
+ }
840
+ const absoluteInputPath = path__default.default.isAbsolute(originalPath) ? originalPath : path__default.default.join(process.cwd(), originalPath);
841
+ const inputMetadata = await probeVideo(absoluteInputPath);
842
+ job.updateProgress(15);
843
+ const resolvePaths = options.resolvePaths ?? defaultResolvePaths;
844
+ const collectionConfig = client.getCollectionConfig?.(parsed.collection) ?? null;
845
+ const resolved = resolvePaths({
846
+ doc: document,
847
+ collection: collectionConfig,
848
+ collectionSlug: parsed.collection,
849
+ original: {
850
+ filename: filename ?? path__default.default.basename(originalPath),
851
+ path: originalPath,
852
+ url: url ?? ""
853
+ },
854
+ presetName: parsed.preset
855
+ });
856
+ const writeDir = resolved.dir;
857
+ const writeFilename = resolved.filename;
858
+ const targetUrl = resolved.url;
859
+ const writePath = buildWritePath(writeDir, writeFilename);
860
+ await fs2.mkdir(writeDir, { recursive: true });
861
+ const { globalOptions, outputOptions } = buildFfmpegArgs({
862
+ presetArgs: preset.args,
863
+ crop: parsed.crop,
864
+ dimensions: {
865
+ width: inputMetadata.width,
866
+ height: inputMetadata.height
867
+ }
868
+ });
869
+ await new Promise((resolve, reject) => {
870
+ const command = ffmpeg2__default.default(absoluteInputPath);
871
+ globalOptions.forEach((option) => command.addOption(option));
872
+ command.outputOptions(outputOptions);
873
+ command.output(writePath);
874
+ command.on("progress", (progress) => {
875
+ if (typeof progress.percent === "number") {
876
+ const bounded = Math.min(95, 15 + progress.percent * 0.7);
877
+ void job.updateProgress(bounded);
878
+ }
879
+ });
880
+ command.on("end", () => resolve());
881
+ command.on("error", (error) => reject(error));
882
+ command.run();
883
+ });
884
+ const fileStats = await fs2.stat(writePath);
885
+ const outputMetadata = await probeVideo(writePath);
886
+ const storedPath = buildStoredPath(originalPath, writeFilename);
887
+ const variant = {
888
+ preset: parsed.preset,
889
+ url: targetUrl,
890
+ path: storedPath,
891
+ size: fileStats.size,
892
+ duration: outputMetadata.duration ?? inputMetadata.duration,
893
+ width: outputMetadata.width ?? inputMetadata.width,
894
+ height: outputMetadata.height ?? inputMetadata.height,
895
+ bitrate: outputMetadata.bitrate,
896
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
897
+ };
898
+ const existingVariants = Array.isArray(document.variants) ? document.variants : [];
899
+ const nextVariants = [
900
+ ...existingVariants.filter((item) => item?.preset !== variant.preset),
901
+ variant
902
+ ];
903
+ await client.update({
904
+ collection: parsed.collection,
905
+ id: parsed.id,
906
+ data: {
907
+ variants: nextVariants
908
+ }
909
+ });
910
+ await job.updateProgress(100);
911
+ return variant;
912
+ },
913
+ {
914
+ connection,
915
+ concurrency
916
+ }
917
+ );
918
+ worker.on("failed", (job, error) => {
919
+ console.error(`[video-processor] Job ${job?.id} failed`, error);
920
+ });
921
+ worker.on("completed", (job) => {
922
+ console.log(`[video-processor] Job ${job.id} completed`);
923
+ });
924
+ await worker.waitUntilReady();
925
+ console.log(`[video-processor] Worker listening on queue ${queueName}`);
926
+ const shutdown = async () => {
927
+ await worker.close();
928
+ await connection.quit();
929
+ };
930
+ process.once("SIGINT", () => {
931
+ void shutdown().then(() => process.exit(0));
932
+ });
933
+ process.once("SIGTERM", () => {
934
+ void shutdown().then(() => process.exit(0));
935
+ });
936
+ return worker;
937
+ };
938
+
939
+ // src/index.ts
940
+ var adminFieldPath = "@kimjansheden/payload-video-processor/client#VideoField";
941
+ var acceptsVideoUploads = (collection) => {
942
+ const upload = collection.upload;
943
+ if (!upload) return false;
944
+ if (upload === true) {
945
+ return false;
946
+ }
947
+ const mimeTypes = Array.isArray(upload?.mimeTypes) ? upload.mimeTypes : [];
948
+ return mimeTypes.some((type) => type.startsWith("video/"));
949
+ };
950
+ var createVariantsField = () => ({
951
+ name: "variants",
952
+ type: "array",
953
+ label: "Video variants",
954
+ admin: {
955
+ readOnly: true
956
+ },
957
+ defaultValue: [],
958
+ fields: [
959
+ {
960
+ name: "preset",
961
+ label: "Preset",
962
+ type: "text",
963
+ admin: { readOnly: true }
964
+ },
965
+ {
966
+ name: "url",
967
+ label: "URL",
968
+ type: "text",
969
+ admin: { readOnly: true }
970
+ },
971
+ {
972
+ name: "path",
973
+ label: "Path",
974
+ type: "text",
975
+ admin: { readOnly: true }
976
+ },
977
+ {
978
+ name: "size",
979
+ label: "Size (bytes)",
980
+ type: "number",
981
+ admin: { readOnly: true }
982
+ },
983
+ {
984
+ name: "duration",
985
+ label: "Duration (s)",
986
+ type: "number",
987
+ admin: { readOnly: true }
988
+ },
989
+ {
990
+ name: "width",
991
+ label: "Width",
992
+ type: "number",
993
+ admin: { readOnly: true }
994
+ },
995
+ {
996
+ name: "height",
997
+ label: "Height",
998
+ type: "number",
999
+ admin: { readOnly: true }
1000
+ },
1001
+ {
1002
+ name: "bitrate",
1003
+ label: "Bitrate",
1004
+ type: "number",
1005
+ admin: { readOnly: true }
1006
+ },
1007
+ {
1008
+ name: "createdAt",
1009
+ label: "Created",
1010
+ type: "date",
1011
+ admin: { readOnly: true }
1012
+ }
1013
+ ]
1014
+ });
1015
+ var buildAdminPresetMap = (presets) => Object.fromEntries(
1016
+ Object.entries(presets).map(([name, preset]) => [
1017
+ name,
1018
+ {
1019
+ label: preset.label ?? name,
1020
+ enableCrop: Boolean(preset.enableCrop)
1021
+ }
1022
+ ])
1023
+ );
1024
+ var trimTrailingSlash = (value) => value.endsWith("/") ? value.slice(0, -1) : value;
1025
+ var getApiBasePath = (_config) => "";
1026
+ var pluginFactory = (rawOptions) => {
1027
+ const options = ensureOptions(rawOptions);
1028
+ const presets = normalizePresets(options.presets);
1029
+ const plugin = (config) => {
1030
+ const queueName = options.queue?.name ?? "video-transcode";
1031
+ const redisUrl = options.queue?.redisUrl ?? process.env.REDIS_URL;
1032
+ const apiBase = getApiBasePath();
1033
+ const endpointEnqueuePath = `${apiBase}/video-queue/enqueue`;
1034
+ const endpointStatusBase = `${apiBase}/video-queue/status`;
1035
+ const endpointReplaceOriginalPath = `${apiBase}/video-queue/replace-original`;
1036
+ const endpointRemoveVariantPath = `${apiBase}/video-queue/remove-variant`;
1037
+ const routesApiBase = trimTrailingSlash(config.routes?.api ?? "/api");
1038
+ const clientEnqueuePath = `${routesApiBase}/video-queue/enqueue`;
1039
+ const clientStatusBase = `${routesApiBase}/video-queue/status`;
1040
+ const clientReplaceOriginalPath = `${routesApiBase}/video-queue/replace-original`;
1041
+ const clientRemoveVariantPath = `${routesApiBase}/video-queue/remove-variant`;
1042
+ console.log(
1043
+ `[payload-video-processor] enabled (queue: ${queueName}, presets: ${Object.keys(presets).length})`
1044
+ );
1045
+ const queueRef = {};
1046
+ const getQueue = () => {
1047
+ if (!queueRef.queue) {
1048
+ queueRef.queue = createQueue({ name: queueName, redisUrl });
1049
+ }
1050
+ return queueRef.queue.queue;
1051
+ };
1052
+ const collections = (config.collections ?? []).map(
1053
+ (collection) => {
1054
+ if (!acceptsVideoUploads(collection)) {
1055
+ return collection;
1056
+ }
1057
+ const fields = [...collection.fields ?? []];
1058
+ const hasVariantsField = fields.some(
1059
+ (field) => "name" in field && field.name === "variants"
1060
+ );
1061
+ if (!hasVariantsField) {
1062
+ fields.push(createVariantsField());
1063
+ }
1064
+ const hasControlField = fields.some(
1065
+ (field) => "name" in field && field.name === "videoProcessing"
1066
+ );
1067
+ if (!hasControlField) {
1068
+ const clientProps = {
1069
+ presets: buildAdminPresetMap(presets),
1070
+ enqueuePath: clientEnqueuePath,
1071
+ statusPath: clientStatusBase,
1072
+ replaceOriginalPath: clientReplaceOriginalPath,
1073
+ removeVariantPath: clientRemoveVariantPath,
1074
+ queueName,
1075
+ collectionSlug: collection.slug
1076
+ };
1077
+ const controlField = {
1078
+ name: "videoProcessing",
1079
+ type: "ui",
1080
+ label: "Video processing",
1081
+ admin: {
1082
+ components: {
1083
+ Field: {
1084
+ path: adminFieldPath,
1085
+ clientProps
1086
+ }
1087
+ },
1088
+ position: "sidebar"
1089
+ },
1090
+ custom: clientProps
1091
+ };
1092
+ fields.push(controlField);
1093
+ }
1094
+ return {
1095
+ ...collection,
1096
+ fields
1097
+ };
1098
+ }
1099
+ );
1100
+ const endpoints = [
1101
+ {
1102
+ method: "post",
1103
+ path: endpointEnqueuePath,
1104
+ handler: createEnqueueHandler({
1105
+ getQueue,
1106
+ presets,
1107
+ access: options.access
1108
+ })
1109
+ },
1110
+ {
1111
+ method: "get",
1112
+ path: `${endpointStatusBase}/:jobId`,
1113
+ handler: createStatusHandler({ getQueue })
1114
+ },
1115
+ {
1116
+ method: "post",
1117
+ path: endpointReplaceOriginalPath,
1118
+ handler: createReplaceOriginalHandler({ access: options.access })
1119
+ },
1120
+ {
1121
+ method: "post",
1122
+ path: endpointRemoveVariantPath,
1123
+ handler: createRemoveVariantHandler({ access: options.access })
1124
+ }
1125
+ ];
1126
+ const existingEndpoints = Array.isArray(config.endpoints) ? config.endpoints : [];
1127
+ return {
1128
+ ...config,
1129
+ collections,
1130
+ endpoints: [...existingEndpoints, ...endpoints]
1131
+ };
1132
+ };
1133
+ return plugin;
1134
+ };
1135
+ var src_default = pluginFactory;
1136
+
1137
+ exports.createWorker = createWorker;
1138
+ exports.default = src_default;
1139
+ exports.defaultResolvePaths = defaultResolvePaths;
1140
+ //# sourceMappingURL=index.cjs.map
1141
+ //# sourceMappingURL=index.cjs.map