@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/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 the pull (default: VIDFARM_API_KEY)
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
- ghostcut <forkId> Read the two video sources: original vs → GET .../compositions/:forkId/ghostcut
108
- decomposed (caption-free), non-billing
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 runGhostcutCommand(rest);
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; keep any cloud creds already present
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. The fork is either an
529
- // explicit --fork or the template's default fork (which may be another user's
530
- // public decomposition). Media stays on cloud URLs only the composition +
531
- // records are localized. Runs in-process after startRuntime(), so it shares the
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 storage = new StorageService();
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 auth.authenticate(input.bootstrapKey);
541
- const authHeaders = buildAuthHeaders({ apiKey: input.cloudApiKey, shareToken: input.shareToken });
542
- // 1. Resolve the source fork: explicit --fork wins, else the template's
543
- // default fork (grabs the shared decomposition even if another user owns it).
544
- let cloudForkId = input.forkId ?? null;
545
- if (!cloudForkId && input.templateId) {
546
- cloudForkId = await resolveForkId({ host: input.host, templateId: input.templateId, apiKey: input.cloudApiKey, shareToken: input.shareToken });
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}(fully local — records + storage on disk)${RESET}`);
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}GhostCut subtitle-removal is running in the background — poll \`vidfarm api POST /api/v1/compositions/${forkId}/ghostcut-poll\` or GET .../ghostcut until done.${RESET}`);
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 runGhostcutCommand(argv) {
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("ghostcut requires a fork id.");
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)}/ghostcut`, auth: ctx.auth });
1263
- assertApiOk(result, "ghostcut");
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) {
@@ -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"),
@@ -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/ghostcut 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.",
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/ghostcut 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.",
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/ghostcut 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 /ghostcut-poll unless the user explicitly asks to advance the subtitle-removal job.",
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],