@mevdragon/vidfarm-devcli 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.director.md +28 -7
- package/SKILL.platform.md +9 -5
- package/demo/dist/app.js +59 -59
- package/dist/src/app.js +332 -47
- package/dist/src/cli.js +59 -96
- package/dist/src/config.js +7 -0
- package/dist/src/editor-chat.js +4 -4
- package/dist/src/primitive-registry.js +125 -0
- package/dist/src/services/upstream.js +248 -0
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -43,6 +43,11 @@ Local editor loop:
|
|
|
43
43
|
serve [template_id] [opts] Boot the FULL editor locally (single origin, disk-backed).
|
|
44
44
|
Records + storage run on disk; render runs in-process on
|
|
45
45
|
this box for FREE (no cloud, no wallet charge).
|
|
46
|
+
/discover and /library still list the CLOUD catalog —
|
|
47
|
+
opening a cloud template seeds it onto local disk, and
|
|
48
|
+
the editor's Render button can hand off to the cloud
|
|
49
|
+
renderer (needs the cloud --api-key). Only the editor
|
|
50
|
+
and its data are local.
|
|
46
51
|
With a template_id or --fork, pulls that composition
|
|
47
52
|
(or the template's default/shared decomposition) from
|
|
48
53
|
the cloud host onto local disk to edit offline.
|
|
@@ -51,8 +56,9 @@ Local editor loop:
|
|
|
51
56
|
--key <api-key> Bootstrap/browser key (default: VIDFARM_API_KEY or a dev key)
|
|
52
57
|
--fork <id> Pull + open a specific cloud fork
|
|
53
58
|
--host <url> Cloud host to pull from (default: ${DEFAULT_HOST})
|
|
54
|
-
--api-key <key> Cloud key for
|
|
59
|
+
--api-key <key> Cloud key for pulls/library/cloud render (default: VIDFARM_API_KEY)
|
|
55
60
|
--refetch Overwrite local composition with the cloud copy
|
|
61
|
+
--no-cloud Fully local: no cloud catalog, seeding, or cloud render
|
|
56
62
|
--no-open Don't auto-open the browser
|
|
57
63
|
<template_id> [opts] Alias for 'serve <template_id>' (boot the local editor)
|
|
58
64
|
publish [template_id] Push local composition.html/json to your fork → PUT/PATCH composition + POST versions
|
|
@@ -104,8 +110,9 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
104
110
|
--track <n> Timeline layer index (auto if omitted)
|
|
105
111
|
--x --y --width --height Geometry as % (default full canvas 0/0/100/100)
|
|
106
112
|
--object-fit <fit> cover|contain|fill|none|scale-down (default cover)
|
|
107
|
-
|
|
108
|
-
decomposed (caption-free),
|
|
113
|
+
remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
|
|
114
|
+
original vs decomposed (caption-free),
|
|
115
|
+
non-billing (alias: ghostcut)
|
|
109
116
|
versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
|
|
110
117
|
snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
|
|
111
118
|
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
|
|
@@ -244,8 +251,9 @@ async function main() {
|
|
|
244
251
|
case "decompose":
|
|
245
252
|
await runDecomposeCommand(rest);
|
|
246
253
|
return;
|
|
254
|
+
case "remove-video-captions":
|
|
247
255
|
case "ghostcut":
|
|
248
|
-
await
|
|
256
|
+
await runRemoveVideoCaptionsCommand(rest);
|
|
249
257
|
return;
|
|
250
258
|
case "versions":
|
|
251
259
|
await runVersionsCommand(rest);
|
|
@@ -454,7 +462,10 @@ async function runServeCommand(argv) {
|
|
|
454
462
|
share: { type: "string" },
|
|
455
463
|
refetch: { type: "boolean", default: false },
|
|
456
464
|
open: { type: "boolean", default: true },
|
|
457
|
-
"no-open": { type: "boolean", default: false }
|
|
465
|
+
"no-open": { type: "boolean", default: false },
|
|
466
|
+
// Disable the cloud passthrough entirely: /discover and /library list
|
|
467
|
+
// only local data and the editor offers no "Render in Cloud".
|
|
468
|
+
"no-cloud": { type: "boolean", default: false }
|
|
458
469
|
}
|
|
459
470
|
});
|
|
460
471
|
const port = Number(parsed.values.port);
|
|
@@ -476,16 +487,27 @@ async function runServeCommand(argv) {
|
|
|
476
487
|
const templateId = parsed.positionals[0];
|
|
477
488
|
const forkFlag = parsed.values.fork;
|
|
478
489
|
// Env MUST be set before importing runtime — config.ts reads it at module
|
|
479
|
-
// load. Force the fully-local drivers
|
|
480
|
-
// in the environment so render passthrough still works.
|
|
490
|
+
// load. Force the fully-local drivers.
|
|
481
491
|
process.env.RECORDS_DRIVER = "local";
|
|
482
492
|
process.env.STORAGE_DRIVER = "local";
|
|
483
493
|
process.env.AWS_S3_BUCKET = "";
|
|
494
|
+
// A dev-shell .env can carry the staging Step Functions ARN, which would
|
|
495
|
+
// silently route this box's renders to the cloud state machine. serve is
|
|
496
|
+
// local-render by definition — cloud rendering is the EXPLICIT
|
|
497
|
+
// render_target="cloud" upstream handoff (the editor's "Render in Cloud").
|
|
498
|
+
process.env.VIDFARM_JOB_STATE_MACHINE_ARN = "";
|
|
484
499
|
process.env.VIDFARM_DATA_DIR = dataDir;
|
|
485
500
|
process.env.VIDFARM_API_KEY = apiKey;
|
|
486
501
|
process.env.PORT = String(port);
|
|
487
502
|
if (!process.env.NODE_ENV)
|
|
488
503
|
process.env.NODE_ENV = "development";
|
|
504
|
+
// Cloud passthrough: only the editor is local — /discover and /library keep
|
|
505
|
+
// listing the upstream (cloud) catalog, templates seed onto disk on demand,
|
|
506
|
+
// and the editor's Render button can hand off to the cloud renderer. The
|
|
507
|
+
// upstream key is the AMBIENT cloud key (never the local bootstrap key).
|
|
508
|
+
const cloudEnabled = !parsed.values["no-cloud"];
|
|
509
|
+
process.env.VIDFARM_UPSTREAM_HOST = cloudEnabled ? host : "";
|
|
510
|
+
process.env.VIDFARM_UPSTREAM_API_KEY = cloudEnabled ? (cloudApiKey ?? "") : "";
|
|
489
511
|
const { startRuntime } = await import("./runtime.js");
|
|
490
512
|
await startRuntime();
|
|
491
513
|
// If a template/fork was requested, pull its composition from the cloud host
|
|
@@ -493,13 +515,14 @@ async function runServeCommand(argv) {
|
|
|
493
515
|
// resolves it. Best-effort: a failure just drops us to /discover.
|
|
494
516
|
let openTemplateId = templateId;
|
|
495
517
|
let openForkId = forkFlag;
|
|
496
|
-
if (templateId || forkFlag) {
|
|
518
|
+
if ((templateId || forkFlag) && !cloudEnabled) {
|
|
519
|
+
console.warn("[vidfarm] serve: --no-cloud set — skipping cloud seed of the requested template/fork.");
|
|
520
|
+
}
|
|
521
|
+
else if (templateId || forkFlag) {
|
|
497
522
|
try {
|
|
498
523
|
const seeded = await seedForkFromCloud({
|
|
499
|
-
host,
|
|
500
524
|
templateId,
|
|
501
525
|
forkId: forkFlag,
|
|
502
|
-
cloudApiKey,
|
|
503
526
|
shareToken: parsed.values.share,
|
|
504
527
|
bootstrapKey: apiKey,
|
|
505
528
|
refetch: parsed.values.refetch
|
|
@@ -518,105 +541,45 @@ async function runServeCommand(argv) {
|
|
|
518
541
|
? `/editor/${encodeURIComponent(openTemplateId)}${openForkId ? `?fork=${encodeURIComponent(openForkId)}` : ""}`
|
|
519
542
|
: "/discover";
|
|
520
543
|
const openUrl = `${base}/auto-login?api_key=${encodeURIComponent(apiKey)}&redirect=${encodeURIComponent(editorPath)}`;
|
|
521
|
-
printServeBanner({ base, dataDir, openUrl, editorPath });
|
|
544
|
+
printServeBanner({ base, dataDir, openUrl, editorPath, cloud: cloudEnabled ? { host, hasKey: Boolean(cloudApiKey) } : null });
|
|
522
545
|
const shouldOpen = parsed.values.open && !parsed.values["no-open"];
|
|
523
546
|
if (shouldOpen)
|
|
524
547
|
openInBrowser(openUrl);
|
|
525
548
|
// startRuntime() keeps the process alive via its HTTP server; nothing else to do.
|
|
526
549
|
}
|
|
527
550
|
// Pull a cloud fork's composition onto local disk and create the local
|
|
528
|
-
// template + fork records so the editor resolves it.
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
// server's disk-backed records/storage singletons.
|
|
551
|
+
// template + fork records so the editor resolves it. Thin wrapper over
|
|
552
|
+
// services/upstream.ts seedFromUpstream (which reads VIDFARM_UPSTREAM_HOST/
|
|
553
|
+
// _API_KEY — set above from --host/--api-key). Runs in-process after
|
|
554
|
+
// startRuntime(), so it shares the server's disk-backed records/storage.
|
|
533
555
|
async function seedForkFromCloud(input) {
|
|
534
|
-
const { serverlessRecords } = await import("./services/serverless-records.js");
|
|
535
|
-
const { StorageService } = await import("./services/storage.js");
|
|
536
556
|
const { ServerlessAuthService } = await import("./services/serverless-auth.js");
|
|
537
|
-
const
|
|
538
|
-
const auth = new ServerlessAuthService();
|
|
557
|
+
const { seedFromUpstream } = await import("./services/upstream.js");
|
|
539
558
|
// Ensure the local bootstrap customer exists and grab its id to own the seed.
|
|
540
|
-
const customer = await
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
}
|
|
548
|
-
if (!cloudForkId) {
|
|
549
|
-
console.warn("[vidfarm] serve: no --fork and no default fork resolvable from cloud; starting empty.");
|
|
550
|
-
return null;
|
|
551
|
-
}
|
|
552
|
-
// 2. Read the serialized fork for its template id + title.
|
|
553
|
-
const forkMetaRes = await fetch(`${input.host}/api/v1/compositions/${encodeURIComponent(cloudForkId)}`, { headers: authHeaders });
|
|
554
|
-
const forkMeta = forkMetaRes.ok ? (await forkMetaRes.json().catch(() => null)) : null;
|
|
555
|
-
const templateId = input.templateId ?? (typeof forkMeta?.template_id === "string" ? forkMeta.template_id : null);
|
|
556
|
-
if (!templateId || !templateId.startsWith("template_")) {
|
|
557
|
-
console.warn(`[vidfarm] serve: could not determine a template id for fork ${cloudForkId}; starting empty.`);
|
|
558
|
-
return null;
|
|
559
|
-
}
|
|
560
|
-
const title = (typeof forkMeta?.title === "string" && forkMeta.title) || `Local · ${templateId}`;
|
|
561
|
-
// 3. Materialize the local template record (the editor 404s without it).
|
|
562
|
-
const existingTemplate = await serverlessRecords.getTemplate(templateId);
|
|
563
|
-
if (!existingTemplate) {
|
|
564
|
-
await serverlessRecords.createTemplate({
|
|
565
|
-
id: templateId,
|
|
566
|
-
inspirationId: `local:${templateId}`,
|
|
567
|
-
customerId: customer.id,
|
|
568
|
-
originalUrl: typeof forkMeta?.original_url === "string" ? forkMeta.original_url : "",
|
|
569
|
-
sourceHost: "local",
|
|
570
|
-
title,
|
|
571
|
-
visibility: "public"
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
// 4. Materialize the local fork (reuse the cloud id for stable URLs).
|
|
575
|
-
const localForkId = cloudForkId;
|
|
576
|
-
const existingFork = await serverlessRecords.getCompositionFork(localForkId);
|
|
577
|
-
if (!existingFork) {
|
|
578
|
-
await serverlessRecords.createCompositionFork({
|
|
579
|
-
id: localForkId,
|
|
580
|
-
customerId: customer.id,
|
|
581
|
-
templateId,
|
|
582
|
-
title,
|
|
583
|
-
visibility: "private"
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
// 5. Pull composition.html/json onto disk (skip if present unless --refetch,
|
|
587
|
-
// so local edits aren't clobbered on re-serve).
|
|
588
|
-
const htmlKey = storage.compositionForkWorkingKey(localForkId, "composition.html");
|
|
589
|
-
const jsonKey = storage.compositionForkWorkingKey(localForkId, "composition.json");
|
|
590
|
-
const hasLocalHtml = Boolean(await storage.readText(htmlKey));
|
|
591
|
-
if (!hasLocalHtml || input.refetch) {
|
|
592
|
-
const htmlRes = await fetch(`${input.host}/api/v1/compositions/${encodeURIComponent(cloudForkId)}/composition.html`, { headers: authHeaders });
|
|
593
|
-
if (!htmlRes.ok) {
|
|
594
|
-
console.warn(`[vidfarm] serve: could not fetch composition.html for ${cloudForkId} (${htmlRes.status}); starting empty.`);
|
|
595
|
-
return null;
|
|
596
|
-
}
|
|
597
|
-
await storage.putText(htmlKey, await htmlRes.text(), "text/html; charset=utf-8");
|
|
598
|
-
const jsonRes = await fetch(`${input.host}/api/v1/compositions/${encodeURIComponent(cloudForkId)}/composition.json`, { headers: authHeaders });
|
|
599
|
-
if (jsonRes.ok)
|
|
600
|
-
await storage.putBuffer(jsonKey, Buffer.from(await jsonRes.text(), "utf8"), "application/json");
|
|
601
|
-
console.log(`[vidfarm] seeded fork ${localForkId} from ${input.host}`);
|
|
602
|
-
}
|
|
603
|
-
else {
|
|
604
|
-
console.log(`[vidfarm] fork ${localForkId} already local (use --refetch to overwrite)`);
|
|
605
|
-
}
|
|
606
|
-
// 6. Point the template's default fork at the local copy so a bare
|
|
607
|
-
// /editor/:templateId resolves it too.
|
|
608
|
-
await serverlessRecords.stampDefaultForkIfUnset({ templateId, forkId: localForkId });
|
|
609
|
-
return { templateId, forkId: localForkId };
|
|
559
|
+
const customer = await new ServerlessAuthService().authenticate(input.bootstrapKey);
|
|
560
|
+
return seedFromUpstream({
|
|
561
|
+
customerId: customer.id,
|
|
562
|
+
templateId: input.templateId,
|
|
563
|
+
forkId: input.forkId,
|
|
564
|
+
shareToken: input.shareToken,
|
|
565
|
+
refetch: input.refetch
|
|
566
|
+
});
|
|
610
567
|
}
|
|
611
568
|
function printServeBanner(input) {
|
|
612
569
|
const line = `${DIM}${"─".repeat(74)}${RESET}`;
|
|
613
570
|
console.log("");
|
|
614
571
|
console.log(line);
|
|
615
|
-
console.log(`${BOLD}${GREEN} Vidfarm local server${RESET} ${DIM}(
|
|
572
|
+
console.log(`${BOLD}${GREEN} Vidfarm local server${RESET} ${DIM}(local editor — records + storage on disk)${RESET}`);
|
|
616
573
|
console.log(line);
|
|
617
574
|
console.log(` server ${input.base}`);
|
|
618
575
|
console.log(` data dir ${input.dataDir}`);
|
|
619
576
|
console.log(` render ${DIM}local (in-process HyperFrames render — free, no cloud charge)${RESET}`);
|
|
577
|
+
if (input.cloud) {
|
|
578
|
+
console.log(` cloud ${input.cloud.host} ${DIM}(/discover + /library list the cloud catalog; templates seed locally on open${input.cloud.hasKey ? "; Render can hand off to the cloud renderer" : "; set VIDFARM_API_KEY for /library + cloud render"})${RESET}`);
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
console.log(` cloud ${DIM}off (--no-cloud) — local data only${RESET}`);
|
|
582
|
+
}
|
|
620
583
|
console.log("");
|
|
621
584
|
console.log(` ${DIM}Agents: edit composition files under ${input.dataDir}/storage — the`);
|
|
622
585
|
console.log(` browser live-reloads on save. Multiple forks can be edited at once.${RESET}`);
|
|
@@ -1249,18 +1212,18 @@ async function runDecomposeCommand(argv) {
|
|
|
1249
1212
|
});
|
|
1250
1213
|
assertApiOk(result, "decompose");
|
|
1251
1214
|
if (!ctx.json && result.json?.ghostcut_pending) {
|
|
1252
|
-
console.log(`${DIM}
|
|
1215
|
+
console.log(`${DIM}Subtitle removal (GhostCut) is running in the background — poll \`vidfarm api POST /api/v1/compositions/${forkId}/remove-video-captions-poll\` or GET .../remove-video-captions until done.${RESET}`);
|
|
1253
1216
|
}
|
|
1254
1217
|
emitResult(result, ctx.json);
|
|
1255
1218
|
}
|
|
1256
|
-
async function
|
|
1219
|
+
async function runRemoveVideoCaptionsCommand(argv) {
|
|
1257
1220
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
|
|
1258
1221
|
const forkId = parsed.positionals[0];
|
|
1259
1222
|
if (!forkId)
|
|
1260
|
-
throw new Error("
|
|
1223
|
+
throw new Error("remove-video-captions requires a fork id.");
|
|
1261
1224
|
const ctx = commonContext(parsed.values);
|
|
1262
|
-
const result = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/
|
|
1263
|
-
assertApiOk(result, "
|
|
1225
|
+
const result = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/remove-video-captions`, auth: ctx.auth });
|
|
1226
|
+
assertApiOk(result, "remove-video-captions");
|
|
1264
1227
|
emitResult(result, ctx.json);
|
|
1265
1228
|
}
|
|
1266
1229
|
async function runVersionsCommand(argv) {
|
package/dist/src/config.js
CHANGED
|
@@ -68,6 +68,13 @@ const schema = z.object({
|
|
|
68
68
|
GHOSTCUT_KEY: z.string().optional(),
|
|
69
69
|
GHOSTCUT_SECRET: z.string().optional(),
|
|
70
70
|
GHOSTCUT_USD_PER_30S: z.coerce.number().min(0).default(0.10),
|
|
71
|
+
// Cloud passthrough for `vidfarm serve`: the local box keeps editing/render
|
|
72
|
+
// fully local but lists the upstream (prod) catalog in /discover and
|
|
73
|
+
// /library, seeds forks on demand, and can hand a render off to the cloud.
|
|
74
|
+
// Set by the serve command from --host + the ambient cloud VIDFARM_API_KEY;
|
|
75
|
+
// never set on deployed environments.
|
|
76
|
+
VIDFARM_UPSTREAM_HOST: z.string().optional(),
|
|
77
|
+
VIDFARM_UPSTREAM_API_KEY: z.string().optional(),
|
|
71
78
|
RAPIDAPI_KEY: z.string().optional(),
|
|
72
79
|
RAPIDAPI_VIDEO_DOWNLOAD_URL: z.string().url().default("https://snap-video3.p.rapidapi.com/download"),
|
|
73
80
|
RAPIDAPI_VIDEO_DOWNLOAD_HOST: z.string().default("snap-video3.p.rapidapi.com"),
|
package/dist/src/editor-chat.js
CHANGED
|
@@ -41,9 +41,9 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
41
41
|
"Use POST /api/v1/primitives/images/edit only when the user wants to revise the attached/source image itself; its body is { tracer, payload: { source_image_url, instruction, reference_attachments? } }. Use POST /api/v1/primitives/images/generate when they want a new image inspired by or similar to the attachment.",
|
|
42
42
|
"If the user asks to modify an attached image and the message includes an attachment URL, call POST /api/v1/primitives/images/edit with source_image_url inside payload set to that exact URL and instruction inside payload set to the requested edit.",
|
|
43
43
|
"If the user asks to generate a new AI video from text or reference images, call POST /api/v1/primitives/videos/generate with body { tracer, payload: { prompt, input_references?, duration? } }. AI video duration is seconds; use duration: 4 for a 4-second video and never use duration_ms on /videos/generate. input_references must be direct image asset URLs, not webpage or social post URLs. Use frame_images for exact first/last-frame image-to-video.",
|
|
44
|
-
"If the user asks to render HTML/design into an image, call POST /api/v1/primitives/images/render-html. If the user asks to turn existing media URLs into a slideshow/video, call POST /api/v1/primitives/videos/render-slides. If the user asks to dedupe, camouflage, or apply small zoom/tilt/rotate/filter/tint transforms to an image or video for reuse, call POST /api/v1/primitives/media/dedupe with body { tracer, payload: { source_media_url, media_type?, effects?: { zoom?, tilt?, rotate?, saturation?, speed?, horizontal_flip?, contrast?, brightness?, hue_rotate?, blur? }, tint_color?, tint_opacity?, width?, height?, duration_ms?, object_fit?, background_color?, output_format? }, webhook_url? }. Primitive media utility routes are also allowed directly here, including /videos/trim, /videos/extract-audio, /videos/probe, /videos/extract-frame, /videos/mute, /videos/replace-audio, /videos/concat, /audio/trim, /audio/probe, /audio/concat, and /audio/normalize.",
|
|
44
|
+
"If the user asks to render HTML/design into an image, call POST /api/v1/primitives/images/render-html. If the user asks to turn existing media URLs into a slideshow/video, call POST /api/v1/primitives/videos/render-slides. If the user asks to dedupe, camouflage, or apply small zoom/tilt/rotate/filter/tint transforms to an image or video for reuse, call POST /api/v1/primitives/media/dedupe with body { tracer, payload: { source_media_url, media_type?, effects?: { zoom?, tilt?, rotate?, saturation?, speed?, horizontal_flip?, contrast?, brightness?, hue_rotate?, blur? }, tint_color?, tint_opacity?, width?, height?, duration_ms?, object_fit?, background_color?, output_format? }, webhook_url? }. Primitive media utility routes are also allowed directly here, including /videos/trim, /videos/remove-captions, /videos/extract-audio, /videos/probe, /videos/extract-frame, /videos/mute, /videos/replace-audio, /videos/concat, /audio/trim, /audio/probe, /audio/concat, and /audio/normalize. If the user asks to remove burned-in captions, subtitles, or on-screen text from a video, call POST /api/v1/primitives/videos/remove-captions with body { tracer, payload: { source_video_url } } — an async job that returns a durable caption-free MP4 and bills about $0.10 per 30 seconds of source video.",
|
|
45
45
|
"Brainstorm primitives are also available directly here. If the user explicitly asks for hooks, angles, awareness-stage advice, a cold-start strategy questionnaire, or product-placement opportunities in a video, call the matching brainstorm primitive immediately instead of brainstorming in chat from memory. Use POST /api/v1/primitives/brainstorm/coldstart with body { tracer, payload: { user_message }, webhook_url? } when the user is clueless and needs foundational strategy questions. Use /brainstorm/awareness_stages with body { tracer, payload: { offer_description }, webhook_url? } when they are unsure what type of ads to make. Use /brainstorm/angles with body { tracer, payload: { offer_description, problem_awareness, solution_awareness }, webhook_url? } when they want persuasive ad angles; problem_awareness must be problem_unaware or problem_aware, and solution_awareness must be solution_unaware or solution_aware. Use /brainstorm/hooks with body { tracer, payload: { offer_description }, webhook_url? } when they are stuck on openings and want many hooks.",
|
|
46
|
-
"Product placement is a first-class brainstorm primitive, just like angles and hooks. When the user asks to identify product-placement opportunities in a video, or to repurpose a video into a native ad for their product, prefer POST /api/v1/primitives/brainstorm/product_placement with body { tracer, payload: { source_video_url, offer_description }, webhook_url? }. source_video_url is the exact video to analyze — for the current composition, first call GET /api/v1/compositions/:forkId/
|
|
46
|
+
"Product placement is a first-class brainstorm primitive, just like angles and hooks. When the user asks to identify product-placement opportunities in a video, or to repurpose a video into a native ad for their product, prefer POST /api/v1/primitives/brainstorm/product_placement with body { tracer, payload: { source_video_url, offer_description }, webhook_url? }. source_video_url is the exact video to analyze — for the current composition, first call GET /api/v1/compositions/:forkId/remove-video-captions to pick the correct source (the ORIGINAL uploaded video vs the DECOMPOSED, caption-free video), then pass that URL. offer_description is the user's product/offer. This primitive watches the video and returns timestamped, native placement opportunities. It prefers a Gemini key for video understanding.",
|
|
47
47
|
"BEFORE calling /brainstorm/product_placement for the CURRENT composition, first call the product_placement_context tool (or GET /api/v1/compositions/:forkId/product-placement.json). Decompose already scouted product-AGNOSTIC placement surfaces for this fork — real, grounded moments (timestamp, surface_type, surface_note, best_for, why_it_works). Use those surfaces to ground your answer and to seed the primitive: fold the strongest surfaces into offer_description (for example append 'Known native placement surfaces: 0:04 empty desk prop; 0:12 phone screen swap') so the product-specific pass builds on grounded moments instead of re-watching blind. If the user just wants a quick conversational take on where their product could go, the surfaces from product_placement_context are often enough to answer directly without the billable primitive.",
|
|
48
48
|
"You can ALSO satisfy a product-placement or video-analysis request WITHOUT the dedicated primitive: if the user attaches or references a video and you want to reason about it directly, you may analyze the attached video yourself and describe placement opportunities in chat. Support both paths — use the /brainstorm/product_placement primitive for a durable, structured, billable artifact the user can save, and direct multimodal reasoning for quick, conversational answers. When the user wants a saved/structured result or many opportunities, prefer the primitive.",
|
|
49
49
|
"When brainstorm jobs finish and the response includes a JSON artifact, tell the user to open and review that JSON in the chat UI because it contains the structured brainstorm output.",
|
|
@@ -74,7 +74,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
74
74
|
"AI generations in flight are listed in editor_context.pending_generations (each has layer_key, media_type, status, job_id, start, duration, prompt, and error). Use it to see what is still rendering, to avoid re-generating something already queued, and to report progress. A status of 'error' with an error message means that generation failed — offer to retry (a fresh generate_layer) or explain the failure; a resolved generation drops out of the list once its media is swapped in.",
|
|
75
75
|
"Only use the http_request→add_layer flow (call the primitive route, then editor_action add_layer with src=<returned URL>) for media that ALREADY has a URL — e.g. /videos/render-slides slideshows, /images/edit revisions, /videos/download results, or a My Files asset. For net-new AI /videos/generate or /images/generate media destined for the timeline, prefer generate_layer so the async poll+placement is handled for you. Never invent media URLs or describe media that was not returned by a tool result.",
|
|
76
76
|
"CAST / CHARACTER CONSISTENCY: the COMPOSITION BRIEF (stable template context in the system prompt) may include a `cast` array — the recurring people/characters identified from the source video by the decompose 2nd pass. Each entry has: slug, name, description (detailed appearance for consistent regeneration), appears_in (scene slugs), is_primary (the single most central subject), reference_url (that person isolated on a transparent background — the preferred reference), and still_url (the raw frame they were pulled from). When the user refers to an on-screen person by pronoun or description ('the same girl', 'her', 'that guy', 'the narrator', 'keep the character'), resolve them against the brief's cast: match by description/name/scene, or default to the is_primary member when the reference is ambiguous. Then, to generate a NEW clip of that same person AND place it on the timeline, call editor_action action_type=generate_layer with media_type='video', prompt describing the requested action/scene while restating their key appearance from the cast description so the model holds identity, and input_references=[reference_url] (fallback: [still_url]); for a NEW image use media_type='image' with prompt_attachments=[reference_url]. (If you only need the media as a reusable URL and are NOT placing it on this timeline, you may instead call POST /api/v1/primitives/videos/generate or /images/generate directly with the same reference in payload.input_references / payload.prompt_attachments.) Never pass a webpage/social URL as a reference — only the cast reference_url/still_url or another direct image asset URL.",
|
|
77
|
-
"If the composition brief's cast is absent or empty, or the referenced person is not in it, fall back to extracting a still yourself: call GET /api/v1/compositions/:forkId/
|
|
77
|
+
"If the composition brief's cast is absent or empty, or the referenced person is not in it, fall back to extracting a still yourself: call GET /api/v1/compositions/:forkId/remove-video-captions to pick the correct source video, then POST /api/v1/primitives/videos/extract-frame with source_video_url set to that URL and at_ms at a moment the person is clearly on screen (use video_context scene timings), then feed the returned image URL as generate_layer input_references (video) or prompt_attachments (image) exactly as above. Prefer the cast reference_url when present because it is already isolated from other people and reusable.",
|
|
78
78
|
"When placing generated character media on the timeline, prefer generate_layer (it applies the placement rules for you): to drop the character into a blank moment use intent='fill_gap' with start/duration on a timeline_gaps span; to swap them into an existing scene use intent='replace_layer' with replace_layer_key (its timing and full-canvas geometry are copied automatically). Always set aspect_ratio to the canvas (e.g. 9:16 for vertical) so the clip fills the frame. Only when you already have a finished media URL should you fall back to add_layer with explicit collision-free start/duration and full-canvas geometry (x=0, y=0, width=100, height=100) for a timeline-proxy/full-canvas replacement.",
|
|
79
79
|
"Use set_layer_visual for geometry (x, y, width, height in %, plus font_size and border_radius in px). Use set_layer_style for text/color styling (color, background, background_style plain|outline|highlight-solid|highlight-translucent, font_family, font_weight, italic, underline, text_align, border_radius, font_size). Use set_layer_media to swap src, volume, muted, playback_start, or object_fit on media layers. Use set_layer_timing for start, duration, track index, playback_start.",
|
|
80
80
|
"Use duplicate_layer to clone an existing layer; supply layer_key for the source, and optionally start/duration/track/label on the duplicate. Use split_layer with layer_key and split_time to cut at a moment in the timeline. Use group_layers with layer_keys (>=2) and optional group_label; ungroup_layers with layer_keys of any grouped members.",
|
|
@@ -83,7 +83,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
83
83
|
"When you REPLACE an existing layer with a different kind (e.g., remove a video and add an image in its place), first read the removed layer's `track`, `start`, `duration`, `x`, `y`, `width`, `height`, and (for media) `playback_start` from editor_context, then pass ALL of them to add_layer. If the removed layer has `is_full_canvas: true` OR `is_timeline_proxy: true`, the replacement MUST be full canvas — set x=0, y=0, width=100, height=100. Timeline-proxy videos always render full canvas (100%×100%) at publish time even though editor_context may show them as tiny placeholders — trust the `is_timeline_proxy` / `is_full_canvas` flags. Skipping the geometry leaves you with a small floating overlay while the rest of the frame is black.",
|
|
84
84
|
"Rule of thumb for replacements: (1) copy the removed layer's track (higher = drawn on top; keep the same so overlays stay in order), (2) copy start/duration exactly, (3) copy x/y/width/height exactly (or use 0/0/100/100 for full-canvas), (4) for video→image conversions, drop playback_start (images have no timeline). Never use the AI default geometry (12/18/54/32) for a replacement.",
|
|
85
85
|
"When you simply want to change a layer's media without moving it, prefer set_layer_media (kind must match: video↔video, image↔image, audio↔audio) — it keeps the same node, id, track, and geometry.",
|
|
86
|
-
"CONTEXT AVAILABLE FOR USE — this composition can expose TWO video sources plus a text/scene context: (1) the ORIGINAL video — the raw uploaded source, with any baked-in subtitles/captions; (2) the DECOMPOSED video — a processed, caption-free copy Vidfarm produces asynchronously (subtitles removed) that the composition timeline renders from; and (3) the decomposed video context (transcript + per-scene visual descriptions + viral DNA) available via the video_context tool. The original and the decomposed video are NOT the same URL, and you should not carry both URLs in default context. When the user asks to reuse, re-render, or feed the composition's video into a primitive route (image extract, ai video edit, media/dedupe, video trim, video probe, etc.), call GET /api/v1/compositions/:forkId/
|
|
86
|
+
"CONTEXT AVAILABLE FOR USE — this composition can expose TWO video sources plus a text/scene context: (1) the ORIGINAL video — the raw uploaded source, with any baked-in subtitles/captions; (2) the DECOMPOSED video — a processed, caption-free copy Vidfarm produces asynchronously (subtitles removed) that the composition timeline renders from; and (3) the decomposed video context (transcript + per-scene visual descriptions + viral DNA) available via the video_context tool. The original and the decomposed video are NOT the same URL, and you should not carry both URLs in default context. When the user asks to reuse, re-render, or feed the composition's video into a primitive route (image extract, ai video edit, media/dedupe, video trim, video probe, etc.), call GET /api/v1/compositions/:forkId/remove-video-captions FIRST to read { status, original_source_url, mirrored_url } and pick correctly — here original_source_url is the ORIGINAL video and mirrored_url is the DECOMPOSED video: prefer the DECOMPOSED video (mirrored_url) when status=\"done\" and the downstream call should be caption-free (thumbnails, style references, re-cut hooks); prefer the ORIGINAL video (original_source_url) when the user explicitly wants the raw upload (branded intros, provenance, when captions are the subject matter). If status is \"pending\" and the user is not blocked on subtitle removal, use the ORIGINAL video and mention that the decomposed (caption-free) video is still processing. If status is \"none\" or \"failed\", there is no decomposed video yet — use the ORIGINAL video only. Do not call POST /remove-video-captions-poll unless the user explicitly asks to advance the subtitle-removal job. To strip burned-in captions from any OTHER video URL (not this fork's decompose flow), use the POST /api/v1/primitives/videos/remove-captions primitive instead.",
|
|
87
87
|
"The OPTIONAL video_context tool returns this fork's decomposed video context: the source video's verbatim audio transcript (full text plus timestamped segments), a literal visual description of every scene, per-scene transcript excerpts, and viral DNA. Call it with the fork id from editor_context whenever the user asks what the source video says or shows, when writing/translating captions, hooks, or dubs that must match the spoken audio, or when you need scene-by-scene grounding before planning timeline edits. It is read-only and non-billing, so prefer it over guessing from frames or memory. If it returns status=\"none\", the fork was never smart-decomposed — offer to run POST /api/v1/compositions/:forkId/auto-decompose first. The same data is available via GET /api/v1/compositions/:forkId/video-context.json through http_request (useful for other forks).",
|
|
88
88
|
"TEMPLATE FORMAT — MATCH IT WHEN WRITING TEXT: the COMPOSITION BRIEF (stable template context in the system prompt) carries composition_context — WHAT THIS TEMPLATE IS: its format/genre and narrative arc (trend_tagline, hook, retention, payoff, preserve, avoid). ALWAYS read it before writing or adjusting ANY captions, text layers, or on-screen copy, and make your copy fit the template's format and voice — do NOT default to generic product/app ad copy. The format dictates the caption style: a text-message / DM / iMessage conversation template's captions must read like short back-and-forth chat messages between people (in-character, conversational), a 'story time' / talking-head template reads like first-person spoken narration, a listicle reads like punchy numbered items, a fake-news / headline template reads like a news chyron, etc. `preserve` usually names format cues that must stay (e.g. the messaging-bubble layout); `avoid` names what to keep out. When the template's format is a conversation, skit, or roleplay, keep the captions in that voice and only weave the product in the way that format allows (subtly, in-dialogue) rather than replacing the messages with feature bullets. If the brief's composition_context is thin or you are unsure what the video actually shows, call video_context (scene visual descriptions + transcript) to ground the format BEFORE writing captions.",
|
|
89
89
|
"MY FILES: the user has a personal file library ('My Files') of uploaded assets — videos (mp4/mov/webm), images (png/jpg/jpeg/gif/webp/svg), audio (mp3/wav/m4a/aac), and documents (pdf/md/txt/csv) — organized into virtual folders. Use the browse_files tool to explore AND write it: call action=list (optionally with folder_path) to see what files and folders exist, action=read with a file_id (copied from a prior list) to fetch one file, and action=write with file_name + content (+ optional folder_path) to SAVE a text file. When the user references their own footage, brand assets, logos, music, a brief, or a script ('use my product photo', 'the video I uploaded', 'my brand logo', 'the script in my files'), browse_files list first to find it rather than asking them to re-upload or paste a URL. For text files (md/txt/csv/srt/vtt/json), read returns the full text_content inline so you can read a brief or script directly. For images/video/audio/pdf, read returns only a durable view_url — use that URL as media: drop it into the timeline via editor_action add_layer/set_layer_media, or pass it into primitive routes (images/edit source_image_url, videos/generate input_references, videos/download). Never invent file ids or view URLs — always list to discover the real ones first.",
|
|
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
import { config } from "./config.js";
|
|
7
7
|
import { definePrimitive } from "./primitive-sdk.js";
|
|
8
8
|
import { PrimitiveMediaLambdaService } from "./services/primitive-media-lambda.js";
|
|
9
|
+
import { estimateGhostcutCostUsd, isGhostcutConfigured, pollStatus as pollGhostcutStatus, submitSubtitleRemoval } from "./services/ghostcut.js";
|
|
9
10
|
import { buildHtmlImageCompositionHtml, buildMediaDedupeCompositionHtml, DEDUPE_DEFAULT_EFFECTS } from "./primitives/hyperframes-media.js";
|
|
10
11
|
const imageProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
|
|
11
12
|
const videoProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
|
|
@@ -360,6 +361,9 @@ const trimAudioPayloadSchema = z.object({
|
|
|
360
361
|
const probeVideoPayloadSchema = z.object({
|
|
361
362
|
source_video_url: mediaSourceUrlSchema
|
|
362
363
|
});
|
|
364
|
+
const removeVideoCaptionsPayloadSchema = z.object({
|
|
365
|
+
source_video_url: mediaSourceUrlSchema
|
|
366
|
+
});
|
|
363
367
|
const probeAudioPayloadSchema = z.object({
|
|
364
368
|
source_audio_url: mediaSourceUrlSchema
|
|
365
369
|
});
|
|
@@ -477,6 +481,7 @@ const PRIMITIVE_VIDEO_RENDER_SLIDES_ID = "primitive:video_render_slides";
|
|
|
477
481
|
const PRIMITIVE_VIDEO_NORMALIZE_ID = "primitive:video_normalize";
|
|
478
482
|
const PRIMITIVE_VIDEO_DOWNLOAD_ID = "primitive:video_download";
|
|
479
483
|
const PRIMITIVE_VIDEO_TRIM_ID = "primitive:video_trim";
|
|
484
|
+
const PRIMITIVE_VIDEO_REMOVE_CAPTIONS_ID = "primitive:video_remove_captions";
|
|
480
485
|
const PRIMITIVE_VIDEO_EXTRACT_AUDIO_ID = "primitive:video_extract_audio";
|
|
481
486
|
const PRIMITIVE_AUDIO_TRIM_ID = "primitive:audio_trim";
|
|
482
487
|
const PRIMITIVE_VIDEO_PROBE_ID = "primitive:video_probe";
|
|
@@ -1222,6 +1227,125 @@ const videoTrimPrimitive = definePrimitive({
|
|
|
1222
1227
|
}
|
|
1223
1228
|
}
|
|
1224
1229
|
});
|
|
1230
|
+
// GhostCut task polling budget. The job runner lambda has a 15-minute
|
|
1231
|
+
// timeout; probe + submit + mirror take up the rest. GhostCut usually clears
|
|
1232
|
+
// a sub-3-minute clip in a few minutes.
|
|
1233
|
+
const REMOVE_CAPTIONS_POLL_TIMEOUT_MS = 12 * 60 * 1000;
|
|
1234
|
+
const REMOVE_CAPTIONS_POLL_INTERVAL_MS = 5000;
|
|
1235
|
+
// Removes burned-in captions/subtitles/on-screen text from a source video via
|
|
1236
|
+
// the GhostCut inpainting service — the same plumbing auto-decompose uses
|
|
1237
|
+
// through src/services/ghostcut.ts. Unlike the fork-scoped
|
|
1238
|
+
// /remove-video-captions(-poll) composition routes, where the CLIENT drives
|
|
1239
|
+
// completion to keep per-request lambda time bounded, this primitive runs
|
|
1240
|
+
// inside the job runner and polls GhostCut to completion itself.
|
|
1241
|
+
const videoRemoveCaptionsPrimitive = definePrimitive({
|
|
1242
|
+
id: PRIMITIVE_VIDEO_REMOVE_CAPTIONS_ID,
|
|
1243
|
+
kind: "video_remove_captions",
|
|
1244
|
+
operations: {
|
|
1245
|
+
run: {
|
|
1246
|
+
description: "Remove burned-in captions/subtitles/on-screen text from a source video into a reusable caption-free platform MP4.",
|
|
1247
|
+
inputSchema: removeVideoCaptionsPayloadSchema,
|
|
1248
|
+
workflow: "run"
|
|
1249
|
+
}
|
|
1250
|
+
},
|
|
1251
|
+
jobs: {
|
|
1252
|
+
async run(ctx, input) {
|
|
1253
|
+
const payload = removeVideoCaptionsPayloadSchema.parse(input);
|
|
1254
|
+
if (!isGhostcutConfigured()) {
|
|
1255
|
+
throw new Error("Caption removal is unavailable: GHOSTCUT_KEY/GHOSTCUT_SECRET are not configured on this deployment.");
|
|
1256
|
+
}
|
|
1257
|
+
// Probe first: confirms the source actually has a video stream and
|
|
1258
|
+
// captures the duration that prices the GhostCut task (per-30s chunks).
|
|
1259
|
+
ctx.logger.progress(0.05, "Probing source video");
|
|
1260
|
+
const probed = await executePrimitiveMediaOperation(ctx, {
|
|
1261
|
+
operation: "video_probe",
|
|
1262
|
+
payload: { source_video_url: payload.source_video_url },
|
|
1263
|
+
outputKey: "probe.json"
|
|
1264
|
+
});
|
|
1265
|
+
const probeMetadata = (probed.metadata ?? {});
|
|
1266
|
+
if (probeMetadata.hasVideo === false) {
|
|
1267
|
+
throw new Error("source_video_url has no video stream — caption removal only works on videos.");
|
|
1268
|
+
}
|
|
1269
|
+
const probedDurationSeconds = Number(probeMetadata.durationSeconds ?? 0);
|
|
1270
|
+
const durationSeconds = Number.isFinite(probedDurationSeconds) && probedDurationSeconds > 0 ? probedDurationSeconds : 30;
|
|
1271
|
+
const cost = estimateGhostcutCostUsd(durationSeconds);
|
|
1272
|
+
ctx.logger.progress(0.12, "Submitting caption-removal task", {
|
|
1273
|
+
durationSeconds: Number(durationSeconds.toFixed(3)),
|
|
1274
|
+
estimatedChunks: cost.chunks,
|
|
1275
|
+
estimatedUsd: cost.usd
|
|
1276
|
+
});
|
|
1277
|
+
const submission = await submitSubtitleRemoval(payload.source_video_url);
|
|
1278
|
+
const startedAtMs = Date.now();
|
|
1279
|
+
let cleanedVideoUrl = null;
|
|
1280
|
+
while (cleanedVideoUrl === null) {
|
|
1281
|
+
if (Date.now() - startedAtMs > REMOVE_CAPTIONS_POLL_TIMEOUT_MS) {
|
|
1282
|
+
throw new Error(`GhostCut task ${submission.taskId} did not finish within ${Math.round(REMOVE_CAPTIONS_POLL_TIMEOUT_MS / 60000)} minutes.`);
|
|
1283
|
+
}
|
|
1284
|
+
const status = await pollGhostcutStatus(submission.taskId);
|
|
1285
|
+
if (status.state === "failed") {
|
|
1286
|
+
throw new Error(`GhostCut task ${submission.taskId} failed: ${status.reason}`);
|
|
1287
|
+
}
|
|
1288
|
+
if (status.state === "done") {
|
|
1289
|
+
cleanedVideoUrl = status.videoUrl;
|
|
1290
|
+
break;
|
|
1291
|
+
}
|
|
1292
|
+
const upstreamProgress = Math.max(0, Math.min(100, status.progress));
|
|
1293
|
+
ctx.logger.progress(0.15 + (upstreamProgress / 100) * 0.65, "Removing burned-in captions", {
|
|
1294
|
+
taskId: submission.taskId,
|
|
1295
|
+
upstreamProgress
|
|
1296
|
+
});
|
|
1297
|
+
await new Promise((resolve) => setTimeout(resolve, REMOVE_CAPTIONS_POLL_INTERVAL_MS));
|
|
1298
|
+
}
|
|
1299
|
+
// GhostCut hosts results on a short-lived third-party CDN — mirror the
|
|
1300
|
+
// cleaned MP4 into platform storage so the output URL is durable.
|
|
1301
|
+
ctx.logger.progress(0.85, "Mirroring caption-free video to platform storage");
|
|
1302
|
+
const remoteVideo = await fetchRemoteBinary(cleanedVideoUrl, "video/mp4");
|
|
1303
|
+
const stored = await storePrimitiveVideo(ctx, {
|
|
1304
|
+
key: "captions-removed.mp4",
|
|
1305
|
+
bytes: remoteVideo.bytes,
|
|
1306
|
+
contentType: "video/mp4",
|
|
1307
|
+
metadata: {
|
|
1308
|
+
primitive: "video_remove_captions",
|
|
1309
|
+
operation: "remove-video-captions",
|
|
1310
|
+
source_video_url: payload.source_video_url,
|
|
1311
|
+
ghostcut_task_id: submission.taskId
|
|
1312
|
+
}
|
|
1313
|
+
});
|
|
1314
|
+
await ctx.billing.record({
|
|
1315
|
+
type: "cpu_estimate",
|
|
1316
|
+
costUsd: cost.usd,
|
|
1317
|
+
costCenterSlug: "ghostcut_subtitle_removal",
|
|
1318
|
+
idempotencyKey: `ghostcut_subtitle_removal:${submission.taskId}`,
|
|
1319
|
+
occurredAtMs: Date.now(),
|
|
1320
|
+
metadata: {
|
|
1321
|
+
operation: "remove-video-captions",
|
|
1322
|
+
source_video_url: payload.source_video_url,
|
|
1323
|
+
duration_seconds: Number(durationSeconds.toFixed(3)),
|
|
1324
|
+
chunk_count: cost.chunks,
|
|
1325
|
+
usd_per_chunk: cost.usdPerChunk,
|
|
1326
|
+
chunk_seconds: 30,
|
|
1327
|
+
task_id: submission.taskId
|
|
1328
|
+
}
|
|
1329
|
+
});
|
|
1330
|
+
ctx.logger.progress(1, "Caption removal complete", { videoUrl: stored.url });
|
|
1331
|
+
return {
|
|
1332
|
+
progress: 1,
|
|
1333
|
+
output: {
|
|
1334
|
+
files: compactUrls([payload.source_video_url, stored.url]),
|
|
1335
|
+
sourceVideoUrl: payload.source_video_url,
|
|
1336
|
+
captionsRemovedVideoUrl: stored.url,
|
|
1337
|
+
primary_file_url: stored.url,
|
|
1338
|
+
video: {
|
|
1339
|
+
file_url: stored.url,
|
|
1340
|
+
content_type: stored.contentType,
|
|
1341
|
+
duration_seconds: Number(durationSeconds.toFixed(3)),
|
|
1342
|
+
ghostcut_task_id: submission.taskId
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1225
1349
|
const videoExtractAudioPrimitive = definePrimitive({
|
|
1226
1350
|
id: PRIMITIVE_VIDEO_EXTRACT_AUDIO_ID,
|
|
1227
1351
|
kind: "video_extract_audio",
|
|
@@ -1864,6 +1988,7 @@ class PrimitiveRegistry {
|
|
|
1864
1988
|
[videoNormalizePrimitive.id, videoNormalizePrimitive],
|
|
1865
1989
|
[videoDownloadPrimitive.id, videoDownloadPrimitive],
|
|
1866
1990
|
[videoTrimPrimitive.id, videoTrimPrimitive],
|
|
1991
|
+
[videoRemoveCaptionsPrimitive.id, videoRemoveCaptionsPrimitive],
|
|
1867
1992
|
[videoExtractAudioPrimitive.id, videoExtractAudioPrimitive],
|
|
1868
1993
|
[audioTrimPrimitive.id, audioTrimPrimitive],
|
|
1869
1994
|
[videoProbePrimitive.id, videoProbePrimitive],
|