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