@mevdragon/vidfarm-devcli 0.18.1 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +166 -0
  2. package/.agents/skills/editor-capabilities/references/re-theme-walkthrough.md +58 -0
  3. package/.agents/skills/vidfarm-media/SKILL.md +2 -0
  4. package/SKILL.director.md +174 -16
  5. package/SKILL.platform.md +7 -3
  6. package/demo/dist/app.css +1 -1
  7. package/demo/dist/app.js +79 -75
  8. package/dist/src/account-pages-legacy.js +1 -1
  9. package/dist/src/app.js +1694 -217
  10. package/dist/src/cli.js +1365 -105
  11. package/dist/src/config.js +8 -0
  12. package/dist/src/devcli/clips.js +3 -0
  13. package/dist/src/devcli/composition-edit.js +401 -10
  14. package/dist/src/devcli/local-backend.js +123 -0
  15. package/dist/src/devcli/migrate-local.js +140 -0
  16. package/dist/src/devcli/sync.js +311 -0
  17. package/dist/src/devcli/timeline-edit.js +208 -1
  18. package/dist/src/editor-chat.js +37 -18
  19. package/dist/src/frontend/file-directory.js +271 -20
  20. package/dist/src/frontend/homepage-view.js +3 -3
  21. package/dist/src/frontend/template-editor-chat.js +6 -3
  22. package/dist/src/page-shell.js +1 -1
  23. package/dist/src/primitive-context.js +31 -2
  24. package/dist/src/primitive-registry.js +409 -4
  25. package/dist/src/reskin/chat-page.js +103 -15
  26. package/dist/src/reskin/discover-page.js +247 -10
  27. package/dist/src/reskin/document.js +147 -10
  28. package/dist/src/reskin/inpaint-clipper-page.js +649 -0
  29. package/dist/src/reskin/inpaint-page.js +2459 -452
  30. package/dist/src/reskin/inpaint-video-page.js +1339 -0
  31. package/dist/src/reskin/library-page.js +324 -82
  32. package/dist/src/reskin/portfolio-page.js +687 -0
  33. package/dist/src/reskin/theme.js +36 -11
  34. package/dist/src/services/billing.js +4 -0
  35. package/dist/src/services/clip-curation/hunt.js +2 -0
  36. package/dist/src/services/clip-records.js +28 -0
  37. package/dist/src/services/file-directory.js +6 -3
  38. package/dist/src/services/hyperframes.js +535 -86
  39. package/dist/src/services/serverless-jobs.js +3 -1
  40. package/dist/src/services/serverless-records.js +43 -0
  41. package/dist/src/services/storage.js +24 -2
  42. package/dist/src/template-editor-pages.js +1 -1
  43. package/package.json +1 -1
  44. package/public/assets/file-directory-app.js +2 -2
  45. package/public/assets/homepage-client-app.js +1 -1
  46. package/public/assets/page-runtime-client-app.js +2 -2
  47. package/public/assets/placeholders/scene-placeholder.png +0 -0
@@ -8,7 +8,7 @@
8
8
  import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
9
9
  import path from "node:path";
10
10
  import { parseArgs } from "node:util";
11
- import { nudgeLayers, restackLayer, rippleEdit, setLayerKeyframes, trimLayer } from "./composition-edit.js";
11
+ import { duplicateLayer, nudgeLayers, restackLayer, rippleEdit, setComposition, setLayerIdentity, setLayerKeyframes, setLayerStyle, setLayerText, setLayerTiming, setLayerVisual, splitLayer, trimLayer } from "./composition-edit.js";
12
12
  // Convenience motion presets so `keyframes` is usable without hand-writing JSON.
13
13
  // Each is a plain KeyframeStop[] — identical to what --keyframes '<json>' takes.
14
14
  const KEYFRAME_PRESETS = {
@@ -280,4 +280,211 @@ export async function runRestackCommand(argv) {
280
280
  console.log(`Restacked ${result.layerKey} to track ${result.track} (${result.order}; higher = on top).`);
281
281
  console.log(`Wrote ${file}`);
282
282
  }
283
+ // ── Named layer-edit verbs — devcli twins of the web editor_action set ─────────
284
+ // set-style / set-visual / set-text / set-identity / duplicate / split / retime
285
+ // / set-composition. Same shape as the timeline verbs above: parseArgs →
286
+ // resolveCompositionHtml → primitive → writeFileSync.
287
+ const boolFlag = (values, key) => typeof values[key] === "boolean" ? values[key] : undefined;
288
+ const strVal = (values, key) => typeof values[key] === "string" && values[key].length > 0 ? values[key] : undefined;
289
+ // ── set-style (color/background/typography/opacity) ───────────────────────────
290
+ const SET_STYLE_HELP = `vidfarm set-style — restyle a text/media layer's look (local file write)
291
+ vidfarm set-style [dir|composition.html] --layer <key|slug> [flags]
292
+ --color <css> text foreground color
293
+ --background <css> text background color --background-style plain|outline|highlight-solid|highlight-translucent|fullwidth
294
+ --font-family <name> --font-weight <100-900> --italic / --no-italic --underline / --no-underline
295
+ --text-align <left|center|right|justify> --font-size <px> --border-radius <px>
296
+ --line-height <n> unitless leading --letter-spacing <px> negative tightens
297
+ --opacity <0..1> constant layer opacity --json`;
298
+ export async function runSetStyleCommand(argv) {
299
+ if (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
300
+ console.log(SET_STYLE_HELP);
301
+ return;
302
+ }
303
+ const { values, positionals } = parseArgs({
304
+ args: argv, allowPositionals: true,
305
+ options: {
306
+ layer: { type: "string" }, color: { type: "string" }, background: { type: "string" }, "background-style": { type: "string" },
307
+ "font-family": { type: "string" }, "font-weight": { type: "string" }, italic: { type: "boolean" }, "no-italic": { type: "boolean" },
308
+ underline: { type: "boolean" }, "no-underline": { type: "boolean" }, "text-align": { type: "string" }, "font-size": { type: "string" },
309
+ "border-radius": { type: "string" }, "line-height": { type: "string" }, "letter-spacing": { type: "string" }, opacity: { type: "string" }, json: { type: "boolean" }
310
+ }
311
+ });
312
+ const file = resolveCompositionHtml(positionals[0]);
313
+ if (typeof values.layer !== "string" || !values.layer.trim())
314
+ throw new Error("set-style requires --layer <layerKey|slug>.");
315
+ const italic = boolFlag(values, "italic") ?? (boolFlag(values, "no-italic") ? false : undefined);
316
+ const underline = boolFlag(values, "underline") ?? (boolFlag(values, "no-underline") ? false : undefined);
317
+ const result = setLayerStyle(readFileSync(file, "utf8"), values.layer, {
318
+ color: strVal(values, "color"), background: strVal(values, "background"), backgroundStyle: strVal(values, "background-style"),
319
+ fontFamily: strVal(values, "font-family"), fontWeight: num(values, "font-weight"), italic, underline,
320
+ textAlign: strVal(values, "text-align"), fontSize: num(values, "font-size"), borderRadius: num(values, "border-radius"),
321
+ lineHeight: num(values, "line-height"), letterSpacing: num(values, "letter-spacing"), opacity: num(values, "opacity")
322
+ });
323
+ writeFileSync(file, result.html, "utf8");
324
+ if (values.json) {
325
+ console.log(JSON.stringify({ file, layerKey: result.layerKey, changed: result.changed }, null, 2));
326
+ return;
327
+ }
328
+ console.log(`Restyled ${result.layerKey} (${result.changed.join(" ")}).`);
329
+ console.log(`Wrote ${file}`);
330
+ }
331
+ // ── set-visual (geometry + opacity) ───────────────────────────────────────────
332
+ const SET_VISUAL_HELP = `vidfarm set-visual — geometry + opacity of a layer (local file write)
333
+ vidfarm set-visual [dir|composition.html] --layer <key|slug> [--x <%>] [--y <%>] [--width <%>] [--height <%>] [--font-size <px>] [--border-radius <px>] [--opacity <0..1>] [--json]`;
334
+ export async function runSetVisualCommand(argv) {
335
+ if (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
336
+ console.log(SET_VISUAL_HELP);
337
+ return;
338
+ }
339
+ const { values, positionals } = parseArgs({
340
+ args: argv, allowPositionals: true,
341
+ options: { layer: { type: "string" }, x: { type: "string" }, y: { type: "string" }, width: { type: "string" }, height: { type: "string" }, "font-size": { type: "string" }, "border-radius": { type: "string" }, opacity: { type: "string" }, json: { type: "boolean" } }
342
+ });
343
+ const file = resolveCompositionHtml(positionals[0]);
344
+ if (typeof values.layer !== "string" || !values.layer.trim())
345
+ throw new Error("set-visual requires --layer <layerKey|slug>.");
346
+ const result = setLayerVisual(readFileSync(file, "utf8"), values.layer, {
347
+ x: num(values, "x"), y: num(values, "y"), width: num(values, "width"), height: num(values, "height"),
348
+ fontSize: num(values, "font-size"), borderRadius: num(values, "border-radius"), opacity: num(values, "opacity")
349
+ });
350
+ writeFileSync(file, result.html, "utf8");
351
+ if (values.json) {
352
+ console.log(JSON.stringify({ file, layerKey: result.layerKey, changed: result.changed }, null, 2));
353
+ return;
354
+ }
355
+ console.log(`Updated ${result.layerKey} visual (${result.changed.join(" ")}).`);
356
+ console.log(`Wrote ${file}`);
357
+ }
358
+ // ── set-text (rewrite words) ──────────────────────────────────────────────────
359
+ const SET_TEXT_HELP = `vidfarm set-text — rewrite a text/caption layer's words (local file write)
360
+ vidfarm set-text [dir|composition.html] --layer <key|slug> --text "new words" [--json]`;
361
+ export async function runSetTextCommand(argv) {
362
+ if (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
363
+ console.log(SET_TEXT_HELP);
364
+ return;
365
+ }
366
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { layer: { type: "string" }, text: { type: "string" }, json: { type: "boolean" } } });
367
+ const file = resolveCompositionHtml(positionals[0]);
368
+ if (typeof values.layer !== "string" || !values.layer.trim())
369
+ throw new Error("set-text requires --layer <layerKey|slug>.");
370
+ if (typeof values.text !== "string")
371
+ throw new Error("set-text requires --text \"...\".");
372
+ const result = setLayerText(readFileSync(file, "utf8"), values.layer, values.text);
373
+ writeFileSync(file, result.html, "utf8");
374
+ if (values.json) {
375
+ console.log(JSON.stringify({ file, layerKey: result.layerKey }, null, 2));
376
+ return;
377
+ }
378
+ console.log(`Rewrote text on ${result.layerKey}.`);
379
+ console.log(`Wrote ${file}`);
380
+ }
381
+ // ── set-identity (slug + note) ────────────────────────────────────────────────
382
+ const SET_IDENTITY_HELP = `vidfarm set-identity — set a layer's slug + note (the AI/viral-DNA handle) (local file write)
383
+ vidfarm set-identity [dir|composition.html] --layer <key|slug> [--slug <snake_case>] [--note "role"] [--json] (empty --note clears)`;
384
+ export async function runSetIdentityCommand(argv) {
385
+ if (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
386
+ console.log(SET_IDENTITY_HELP);
387
+ return;
388
+ }
389
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { layer: { type: "string" }, slug: { type: "string" }, note: { type: "string" }, json: { type: "boolean" } } });
390
+ const file = resolveCompositionHtml(positionals[0]);
391
+ if (typeof values.layer !== "string" || !values.layer.trim())
392
+ throw new Error("set-identity requires --layer <layerKey|slug>.");
393
+ const result = setLayerIdentity(readFileSync(file, "utf8"), values.layer, {
394
+ slug: typeof values.slug === "string" ? values.slug : undefined,
395
+ note: typeof values.note === "string" ? values.note : undefined
396
+ });
397
+ writeFileSync(file, result.html, "utf8");
398
+ if (values.json) {
399
+ console.log(JSON.stringify({ file, layerKey: result.layerKey }, null, 2));
400
+ return;
401
+ }
402
+ console.log(`Set identity on ${result.layerKey}.`);
403
+ console.log(`Wrote ${file}`);
404
+ }
405
+ // ── duplicate ─────────────────────────────────────────────────────────────────
406
+ const DUPLICATE_HELP = `vidfarm duplicate — clone a layer (local file write)
407
+ vidfarm duplicate [dir|composition.html] --layer <key|slug> [--start <sec>] [--track <n>] [--new-key <id>] [--json]`;
408
+ export async function runDuplicateCommand(argv) {
409
+ if (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
410
+ console.log(DUPLICATE_HELP);
411
+ return;
412
+ }
413
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { layer: { type: "string" }, start: { type: "string" }, track: { type: "string" }, "new-key": { type: "string" }, json: { type: "boolean" } } });
414
+ const file = resolveCompositionHtml(positionals[0]);
415
+ if (typeof values.layer !== "string" || !values.layer.trim())
416
+ throw new Error("duplicate requires --layer <layerKey|slug>.");
417
+ const result = duplicateLayer(readFileSync(file, "utf8"), values.layer, { start: num(values, "start"), track: num(values, "track"), newKey: strVal(values, "new-key") });
418
+ writeFileSync(file, result.html, "utf8");
419
+ if (values.json) {
420
+ console.log(JSON.stringify({ file, layerKey: result.layerKey }, null, 2));
421
+ return;
422
+ }
423
+ console.log(`Duplicated → ${result.layerKey}.`);
424
+ console.log(`Wrote ${file}`);
425
+ }
426
+ // ── split ─────────────────────────────────────────────────────────────────────
427
+ const SPLIT_HELP = `vidfarm split — cut a clip in two at a time (local file write)
428
+ vidfarm split [dir|composition.html] --layer <key|slug> --at <sec> [--json]`;
429
+ export async function runSplitCommand(argv) {
430
+ if (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
431
+ console.log(SPLIT_HELP);
432
+ return;
433
+ }
434
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { layer: { type: "string" }, at: { type: "string" }, json: { type: "boolean" } } });
435
+ const file = resolveCompositionHtml(positionals[0]);
436
+ if (typeof values.layer !== "string" || !values.layer.trim())
437
+ throw new Error("split requires --layer <layerKey|slug>.");
438
+ const at = num(values, "at");
439
+ if (at === undefined)
440
+ throw new Error("split requires --at <sec>.");
441
+ const result = splitLayer(readFileSync(file, "utf8"), values.layer, at);
442
+ writeFileSync(file, result.html, "utf8");
443
+ if (values.json) {
444
+ console.log(JSON.stringify({ file, firstKey: result.firstKey, secondKey: result.secondKey }, null, 2));
445
+ return;
446
+ }
447
+ console.log(`Split ${result.firstKey} at ${at}s → tail ${result.secondKey}.`);
448
+ console.log(`Wrote ${file}`);
449
+ }
450
+ // ── retime (absolute set of start/duration/track/playback-start) ──────────────
451
+ const RETIME_HELP = `vidfarm retime — set a layer's timing directly (local file write)
452
+ vidfarm retime [dir|composition.html] --layer <key|slug> [--start <sec>] [--duration <sec>] [--track <n>] [--playback-start <sec>] [--json]`;
453
+ export async function runRetimeCommand(argv) {
454
+ if (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
455
+ console.log(RETIME_HELP);
456
+ return;
457
+ }
458
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { layer: { type: "string" }, start: { type: "string" }, duration: { type: "string" }, track: { type: "string" }, "playback-start": { type: "string" }, json: { type: "boolean" } } });
459
+ const file = resolveCompositionHtml(positionals[0]);
460
+ if (typeof values.layer !== "string" || !values.layer.trim())
461
+ throw new Error("retime requires --layer <layerKey|slug>.");
462
+ const result = setLayerTiming(readFileSync(file, "utf8"), values.layer, { start: num(values, "start"), duration: num(values, "duration"), track: num(values, "track"), playbackStart: num(values, "playback-start") });
463
+ writeFileSync(file, result.html, "utf8");
464
+ if (values.json) {
465
+ console.log(JSON.stringify({ file, layerKey: result.layerKey, track: result.track }, null, 2));
466
+ return;
467
+ }
468
+ console.log(`Retimed ${result.layerKey} (track ${result.track}).`);
469
+ console.log(`Wrote ${file}`);
470
+ }
471
+ // ── set-composition (canvas / theme) ──────────────────────────────────────────
472
+ const SET_COMPOSITION_HELP = `vidfarm set-composition — resize the canvas / retarget length / recolor background (local file write)
473
+ vidfarm set-composition [dir|composition.html] [--width <px>] [--height <px>] [--duration <sec>] [--background <css>] [--json]`;
474
+ export async function runSetCompositionCommand(argv) {
475
+ if (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
476
+ console.log(SET_COMPOSITION_HELP);
477
+ return;
478
+ }
479
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { width: { type: "string" }, height: { type: "string" }, duration: { type: "string" }, background: { type: "string" }, json: { type: "boolean" } } });
480
+ const file = resolveCompositionHtml(positionals[0]);
481
+ const result = setComposition(readFileSync(file, "utf8"), { width: num(values, "width"), height: num(values, "height"), duration: num(values, "duration"), backgroundColor: strVal(values, "background") });
482
+ writeFileSync(file, result.html, "utf8");
483
+ if (values.json) {
484
+ console.log(JSON.stringify({ file, changed: result.changed }, null, 2));
485
+ return;
486
+ }
487
+ console.log(`Updated composition (${result.changed.join(" ")}).`);
488
+ console.log(`Wrote ${file}`);
489
+ }
283
490
  //# sourceMappingURL=timeline-edit.js.map
@@ -43,10 +43,13 @@ export function buildTemplateEditorChatSystemPrompt(input) {
43
43
  "If the user asks to create, generate, or make a new image similar to an attached image, call POST /api/v1/primitives/images/generate with body { tracer, payload: { prompt, prompt_attachments: [exact attachment URL] } }.",
44
44
  "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.",
45
45
  "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.",
46
- "IMAGE UTILITY PRIMITIVES beyond generate/edit are also first-class here. POST /api/v1/primitives/images/inpaint with body { tracer, payload: { source_image_url, mask_url, instruction } } does a MASKED edit — reach for it when only a region of an image should change and a mask is available. POST /api/v1/primitives/images/render-html with body { tracer, payload: { html, css?, width?, height?, background_color? } } rasterizes HTML/CSS into a still image — use it for title cards, quote cards, data callouts, or any designed graphic you want as a timeline image. POST /api/v1/primitives/images/remove-background with body { tracer, payload: { source_image_url } } returns a transparent-background PNG cut-out of the subject use it for overlays, product cut-outs, character sprite cards, or caption/subject matting. IMPORTANT: remove-background BILLS THE WALLET (RapidAPI), unlike the BYOK image generate/edit routes, so only call it when a real cut-out is needed.",
46
+ "IMAGE UTILITY PRIMITIVES beyond generate/edit are also first-class here. POST /api/v1/primitives/images/inpaint with body { tracer, payload: { source_image_url, mask_url, instruction } } does a MASKED edit — reach for it when only a region of an image should change and a mask is available. POST /api/v1/primitives/images/render-html with body { tracer, payload: { html, css?, width?, height?, background_color? } } rasterizes HTML/CSS into a still image — use it for title cards, quote cards, data callouts, or any designed graphic you want as a timeline image. POST /api/v1/primitives/images/remove-background with body { tracer, payload: { source_image_url } } is the AI-matting cut-out for ARBITRARY photos with a messy background; it BILLS THE WALLET (RapidAPI). POST /api/v1/primitives/images/remove-background-greenscreen with body { tracer, payload: { source_image_url, key_color?, tolerance?, softness?, despill? } } is the CHEAP local chroma-key alternative — when the source already sits on a FLAT solid background (green screen, or any key_color like #FFFFFF for white), prefer this over the RapidAPI route. MOST IMPORTANT — POST /api/v1/primitives/images/create-overlay with body { tracer, payload: { prompt, key_color?, aspect_ratio?, prompt_attachments? } } is the go-to CREATE MEDIA OVERLAY primitive: it generates an AI image on a forced flat key-color background (BYOK image keys) and chroma-keys it out in ONE job, returning a ready-to-composite transparent PNG (result.primary_file_url). This is exactly how you mint 'Vox-style' floating illustrations, props, icons, and cut-out characters to add_layer as an animated overlay — reach for it FREQUENTLY instead of a flat still whenever a scene wants a transparent graphic element floating over the video. It bills a small wallet fee plus the BYOK image-gen cost.",
47
47
  "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.",
48
- "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. Caption removal is capped at ~15 minutes of source and must NEVER be used on long-form video: when the user wants short clips out of a long video (a podcast, stream VOD, webinar, or a YouTube/TikTok/IG/X URL), call POST /clips/scan instead with body { source_url | temp_file_id | s3_key, prompt, ranges?: [\"MM:SS-MM:SS\"], target_duration_sec?, aspect?: \"9:16\"|\"16:9\"|\"4:3\"|\"1:1\", avoid_text?, tracer? } — target_duration_sec is a SOFT range (30 → 20-40s), ranges restrict the hunt to those source windows (big cost saver), aspect crops every clip, and avoid_text prefers text-free scenes via scene SELECTION (never caption removal on the source). The hunt is async (202 + scan_id): poll GET /clips/scan/:scanId, then browse GET /clips/feed?source=<source_video_id> or POST /clips/search. Hunts bill AWS compute only — the AI tagging runs on the user's own saved provider keys. To erase captions from a finished HUNTED clip, pass that short mp4 to /videos/remove-captions afterwards.",
49
- "SPEECH PRIMITIVES (TTS/STT) are first-class here. VOICEOVER / NARRATION: when the user wants a script, hook, caption, or dub read aloud as AI audio, call POST /api/v1/primitives/audio/speech with body { tracer, payload: { text, voice?, instructions?, provider?, model?, output_format? } } — instructions is a free-form voice-STYLE prompt (tone, pacing, accent, emotion, persona; e.g. 'calm warm bedtime narrator' or 'excited sports announcer, fast paced'), and voice picks a provider preset (OpenAI: alloy/ash/coral/...; Gemini: Kore/Puck/Charon/...). When sending a voice you may omit provider — it is inferred from the voice preset; a voice paired with the WRONG provider (e.g. voice 'Kore' with provider 'openai') is rejected with a 400, so never mix families. The finished job returns a durable audio URL — place it on the timeline with editor_action add_layer (kind=audio, src=that URL), typically starting where the narration should begin. TRANSCRIPTION: when the user wants a transcript, subtitles/captions from speech, a translation source, or to know who says what, call POST /api/v1/primitives/audio/transcribe with body { tracer, payload: { source_url, diarize?, language?, prompt? } } — source_url may be a VIDEO or AUDIO URL (video audio is demuxed automatically; pick the fork's source via GET /api/v1/compositions/:forkId/remove-video-captions first when transcribing the current composition). One job returns BOTH formats: a simple subtitle version (plain transcript text + timed SRT cues, stored as transcript.txt/transcript.srt) and an advanced multi-speaker version (speaker-attributed timed segments in output.segments and transcript.json) for multi-narration sources like podcasts and interviews. diarize defaults to true and speaker attribution needs a Gemini key — openai/openrouter keys degrade to a plain single-speaker transcript. For transcript-grounded caption work on THIS fork, the read-only video_context tool may already contain the decompose-time transcript; prefer it before running a new billable-key transcription, and use /audio/transcribe for other URLs, multi-speaker attribution, or SRT output. REGENERATE / REVOICE: when the user wants the SAME speaker to say DIFFERENT words (reword the narration, fix a line, change an offer or CTA, redub in the original voice), call POST /api/v1/primitives/audio/regenerate-speech with body { tracer, payload: { source_url (video or audio; video audio is demuxed), rewrite_instruction (how to reword the original transcript) OR text (the exact new script — one of the two is required), voice?, instructions?, provider?, model?, language?, output_format? } }. One job listens to the source, profiles the speaker's voice, rewords the script, and returns a durable audio URL spoken by the CLOSEST PRESET voice with a style prompt matched to the speaker (result fields: audioUrl, script, original_transcript, voice_profile, voice_match). Voice profiling needs a saved Gemini key — with only openai/openrouter keys the job degrades to transcript-only rewording with a default or explicitly passed voice and voice_match 'none'. IMPORTANT HONESTY RULE: OpenAI and Gemini APIs cannot clone voice identity (preset voices only), so always tell the user the regenerated audio APPROXIMATES the original voice rather than clones it. To swap the regenerated audio into the composition, mute the original span (/videos/mute or volume/muted on the layer) and place the new audio with editor_action add_layer (kind=audio, src=audioUrl).",
48
+ "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. Caption removal is capped at ~15 minutes of source and must NEVER be used on long-form video: when the user wants short clips out of a long video (a podcast, stream VOD, webinar, or a YouTube/TikTok/IG/X URL), call POST /clips/scan instead with body { source_url | temp_file_id | s3_key, prompt, ranges?: [\"MM:SS-MM:SS\"], target_duration_sec?, aspect?: \"9:16\"|\"16:9\"|\"4:3\"|\"1:1\", avoid_text?, tracer? } — target_duration_sec is a SOFT range (30 → 20-40s), ranges restrict the hunt to those source windows (big cost saver), aspect crops every clip, and avoid_text prefers text-free scenes via scene SELECTION (never caption removal on the source). The hunt is async (202 + scan_id): poll GET /clips/scan/:scanId, then browse GET /clips/feed?source=<source_video_id> or POST /clips/search. Hunts bill AWS compute only — the AI tagging runs on the user's own saved provider keys. To erase captions from a finished HUNTED clip, pass that short mp4 to /videos/remove-captions afterwards. When the user instead wants ONE EXACT subrange saved as a raw — 'clip 0:30 to 0:45 into my raws', 'save that 10-second bit as a raw', a precise hand-picked in/out — do NOT run an AI hunt: call POST /raws/clip-range with body { source_url | preview_url | temp_file_id, start_sec, end_sec, tracer?, folder_path?, name? }. It trims EXACTLY [start_sec, end_sec] and lands a single raw (compute-only, no AI, no BYOK key). This is the same operation as the web /tools/clipper page and the devcli `vidfarm clipper` command; use it for surgical single clips and reserve /clips/scan for open-ended multi-clip hunts.",
49
+ "SPEECH PRIMITIVES (TTS/STT) are first-class here. VOICEOVER / NARRATION: when the user wants a script, hook, caption, or dub read aloud as AI audio, call POST /api/v1/primitives/audio/speech with body { tracer, payload: { text, voice?, instructions?, provider?, model?, output_format? } } — instructions is a free-form voice-STYLE prompt (tone, pacing, accent, emotion, persona; e.g. 'calm warm bedtime narrator' or 'excited sports announcer, fast paced'), and voice picks a provider preset (OpenAI: alloy/ash/coral/...; Gemini: Kore/Puck/Charon/...). When sending a voice you may omit provider — it is inferred from the voice preset; a voice paired with the WRONG provider (e.g. voice 'Kore' with provider 'openai') is rejected with a 400, so never mix families. The finished job returns a durable audio URL — place it on the timeline with editor_action add_layer (kind=audio, src=that URL), typically starting where the narration should begin. TRANSCRIPTION: when the user wants a transcript, subtitles/captions from speech, a translation source, or to know who says what, call POST /api/v1/primitives/audio/transcribe with body { tracer, payload: { source_url, diarize?, language?, prompt? } } — source_url may be a VIDEO or AUDIO URL (video audio is demuxed automatically; pick the fork's source via GET /api/v1/compositions/:forkId/remove-video-captions first when transcribing the current composition). One job returns BOTH formats: a simple subtitle version (plain transcript text + timed SRT cues, stored as transcript.txt/transcript.srt) and an advanced multi-speaker version (speaker-attributed timed segments in output.segments and transcript.json) for multi-narration sources like podcasts and interviews. diarize defaults to true and speaker attribution needs a Gemini key — openai/openrouter keys degrade to a plain single-speaker transcript. For transcript-grounded caption work on THIS fork, the read-only video_context tool may already contain the decompose-time transcript; prefer it before running a new billable-key transcription, and use /audio/transcribe for other URLs, multi-speaker attribution, or SRT output. REGENERATE / REVOICE: when the user wants the SAME speaker to say DIFFERENT words (reword the narration, fix a line, change an offer or CTA, redub in the original voice), call POST /api/v1/primitives/audio/regenerate-speech with body { tracer, payload: { source_url (video or audio; video audio is demuxed), rewrite_instruction (how to reword the original transcript) OR text (the exact new script — one of the two is required), voice?, instructions?, provider?, model?, language?, output_format? } }. One job listens to the source, profiles the speaker's voice, rewords the script, and returns a durable audio URL spoken by the CLOSEST PRESET voice with a style prompt matched to the speaker (result fields: audioUrl, script, original_transcript, voice_profile, voice_match). Voice profiling needs a saved Gemini key — with only openai/openrouter keys the job degrades to transcript-only rewording with a default or explicitly passed voice and voice_match 'none'. IMPORTANT HONESTY RULE: OpenAI and Gemini APIs cannot clone voice identity (preset voices only), so always tell the user the regenerated audio APPROXIMATES the original voice rather than clones it. To swap the regenerated audio into the composition, mute the original span (/videos/mute or volume/muted on the layer) and place the new audio with editor_action add_layer (kind=audio, src=audioUrl). RECONCILE LENGTH: reworded narration is rarely exactly the same length as the original, so after placing it do NOT assume the timeline still lines up — read the new audio layer's duration (or /audio/probe it) and re-sync: adjust the muted original span, and when captions or scenes were timed to the narration, re-run set_captions (or ripple_edit / set_layer_timing) so words and visuals track the new audio instead of drifting out of sync.",
50
+ "MUSIC / BGM — HONESTY RULE: there is NO music-generation capability in this editor. You CANNOT create a background-music track from a prompt, and you must NEVER fabricate one by duplicating the narration/speech audio onto a second 'music' layer — that just doubles the voice and is not music. Real background music can ONLY come from: (1) a track the user already owns — browse_files search across /files and /raws for their audio (mp3/wav/m4a/aac) — or (2) a music file/URL the user explicitly provides. If neither exists, tell the user plainly that you can't generate music here and offer to search their files or use a track they upload; do NOT invent a music layer or claim you added music. Only once you have a REAL music URL, add_layer kind=audio at a low volume (e.g. 0.1-0.2) under the narration.",
51
+ "NATIVE MULTI-TRACK AUDIO — the timeline mixes UNLIMITED simultaneous audio layers. Each audio layer sits on its own track and carries its own data-volume (0-2, default 1), and the runtime mixes them together with per-track volume honored IDENTICALLY in the live preview and the exported MP4 (the render does a real ffmpeg amix of every audio layer). So overlaying sound is native: you do NOT need a pre-mixed file. Put narration/voiceover on one audio layer at full level (~1.0), a music bed on a SEPARATE audio layer at low level (~0.1-0.2), and any SFX on their own layers — each via editor_action add_layer (kind=audio, src, plus its own start/duration/track/volume), or tune an existing audio/video layer's level with set_layer_media (volume, muted). Different layers at different volumes is the normal, supported case — never collapse multiple sounds into one track when they should be independently adjustable.",
52
+ "SPLIT COMBINED AUDIO WHEN RECREATING — the most important use of multi-track audio: when the user is recreating/replicating a template whose ORIGINAL had music + narration baked TOGETHER into a single audio track, do NOT reproduce it as one combined bed. Rebuild it as TWO independent audio tracks — a fresh narration track (generate the new script via /api/v1/primitives/audio/speech, or same-speaker reword via /api/v1/primitives/audio/regenerate-speech) at ~1.0 on one track, and a separate REAL music track at ~0.1-0.2 on another track — then mute or remove the original combined source-audio layer so the old voice doesn't play under the new one. This gives the user independent voice and music volume afterwards, and is the elegant workaround for AI TTS models being unable to emit narration+music in one file: compose the mix on the timeline instead. HONESTY: you cannot un-mix / stem-separate the original's baked-in combined audio — the 2-track version is BUILT from a fresh narration track PLUS a real music file (owned / user-provided / found via browse_files), never by faking a music layer or duplicating the voice track (see the MUSIC/BGM honesty rule).",
50
53
  "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.",
51
54
  "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.",
52
55
  "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.",
@@ -68,26 +71,37 @@ export function buildTemplateEditorChatSystemPrompt(input) {
68
71
  "If the user asks for each output to have its own chat thread, call frontend_action with action=create_thread for each tracer before or alongside the corresponding POST request.",
69
72
  "If multiple tracers are available, mention which tracer you are using when it matters.",
70
73
  "If the user asks to add, remove, switch tracers, or create a separate chat thread for a tracer, use the frontend_action tool so the page state updates directly.",
71
- "If the user asks to add, remove, retime, reposition, resize, restyle, swap media, tag, note, group, ungroup, duplicate, split, animate, keyframe, nudge, ripple, trim, or restack layers on the composition timeline, call the editor_action tool. Supported action_type values are add_layer, remove_layer, set_layer_timing, set_layer_visual, set_layer_style, set_layer_media, set_layer_text, set_layer_identity, duplicate_layer, split_layer, group_layers, ungroup_layers, generate_layer, replace_composition_html, export_composition, set_captions, set_transitions, set_layer_keyframes, nudge_layers, ripple_edit, trim_layer, and set_layer_zindex.",
74
+ "If the user asks to add, remove, retime, reposition, resize, restyle, swap media, tag, note, group, ungroup, duplicate, split, animate, keyframe, nudge, ripple, trim, or restack layers on the composition timeline, call the editor_action tool. Supported action_type values are add_layer, remove_layer, set_layer_timing, set_layer_visual, set_layer_style, set_layer_media, set_layer_text, set_layer_identity, duplicate_layer, split_layer, group_layers, ungroup_layers, generate_layer, replace_composition_html, export_composition, set_captions, set_transitions, set_layer_keyframes, nudge_layers, ripple_edit, trim_layer, set_layer_zindex, set_composition, and fork_composition.",
72
75
  "FINE TIMELINE CONTROL — you have trackpad-level verbs; use them instead of rewriting the whole document for small changes: (1) set_layer_keyframes authors a custom, script-free CSS @keyframes animation on ONE layer that previews AND renders — pass layer_key + keyframes:[{offset(0..1), opacity?, translate_x?, translate_y?, scale?, rotate?}, …] (>=2 stops), optional keyframe_easing (linear/ease/ease-in/ease-out/ease-in-out/cubic-bezier(...)) and keyframe_duration (seconds, default = clip duration). translate_x/translate_y are percentages of the layer's OWN box. Example fly-up-and-fade-in: keyframes=[{offset:0,opacity:0,translate_y:40},{offset:1,opacity:1,translate_y:0}]. This is the durable way to hand-author motion beyond the Ken Burns / transition / caption presets — the JS runtime adapters (anime.js/GSAP/Lottie) are devcli-only. (2) nudge_layers shifts one or more layers by a RELATIVE delta_start (seconds) and/or delta_track (lanes) — pass layer_key or layer_keys; grouped clips move together. (3) ripple_edit inserts (delta_start>0) or closes (delta_start<0) time at at_time and shifts every downstream clip so the rest of the timeline stays in sync — use it to make room before an insert or to close a gap you can see in timeline_gaps. (4) trim_layer moves ONE edge to a time: edge='start' (left; advances the in-point, and for video/audio pushes the media start so the visible frame is unchanged) or edge='end' (right; changes duration), with to_time in composition seconds. (5) set_layer_zindex restacks: z_order='front'|'back'|'forward'|'backward' (stacking == track index; higher draws on top) or an explicit track. All of these auto-resolve track collisions and note it in the summary; read the NEXT editor_context to confirm the new start/duration/track/animation landed.",
73
76
  "If the user asks to export, render, publish, or produce the final MP4 for this composition (phrases like 'export it', 'render this', 'kick off the export', 'make the mp4'), call editor_action with action_type=export_composition and a brief explanation. This is the same action as the editor's Export button — it queues the composition on production AWS Lambda. Do NOT try to hit any /api/v1/compositions/:id/export route via http_request for this; use the editor_action tool. After calling export_composition, tell the user the export has started and that the finished MP4 URL will appear once the render succeeds; do not fabricate a URL or claim success until a later turn shows last_export_url populated in editor_context.",
77
+ "CONFIRM BEFORE RENDERING — DO NOT auto-render. Rendering the final MP4 is a slow, costly, screen-taking action, so by DEFAULT you must NOT call export_composition on your own initiative. After you finish an edit (a swap, a re-theme, a scene rebuild), STOP and let the user review the live preview, then ASK a short question like 'Happy with the preview? Want me to render the final MP4?' and wait for a yes. Only fire export_composition when EITHER (a) the user explicitly asks to render/export/'make the mp4' in that turn, or (b) the user has told you up front to just render without asking (e.g. 'edit and render it', 'no need to check, just export'). If in doubt, ask — never surprise the user with a render. Never render merely because you judged the edit 'done'.",
78
+ "MEDIA SWAP — TARGET A REAL LAYER KEY, NOT A SCENE NAME. To swap a clip's media with set_layer_media, the layer_key MUST be an EXACT key (or its slug) from editor_context.layers — that array lists every targetable layer with its key, slug, kind (video/image/audio), and current src. Read it and copy the real key of the video/image layer you mean; do NOT invent a semantic name (like 'growth_examples' or 'mistakes_reflection') unless that exact string appears as a layer key/slug in editor_context, and never target a scene label/group when you mean the video inside it. If set_layer_media returns 'No change applied' or 'Layer not found', the error now lists the actual media layers — re-read it and retry with one of those exact keys. NEVER report a swap as done until the NEXT editor_context shows the layer's src changed.",
79
+ "GETTING THE MEDIA URL IS EASY — USE browse_files. When the user says 'use my raws' / 'it's in my file directory' / 'the fishing clips', you do NOT need them to paste a URL. Call browse_files action=search (or list path='/raws') to find the clip, then action=read with its file_id — the read result's view_url IS the public, ready-to-use media URL. Drop that view_url straight into set_layer_media src (or add_layer src). Do not stall asking the user for a URL you can look up yourself, and never pass a placeholder like 'please-ignore' as src.",
74
80
  "The editor_context block includes last_export_url, last_export_title, last_export_status, and last_export_render_id when a render has succeeded in this session. Treat last_export_url as the current 'finished Export MP4' for this composition. If the user asks to approve, package, share, or schedule the exported video without providing an explicit URL, use last_export_url as the primary MP4 media (kind=video, role=primary) for POST /api/v1/approved/posts. If last_export_url is null, tell the user to Export first (or offer to call editor_action with action_type=export_composition on their behalf) instead of guessing a URL.",
75
81
  "Each layer in editor_context includes element_id (data-hf-id), optional slug (data-hf-slug), and optional note (data-hf-note). layer_key on any editor_action may be EITHER the element_id or the slug — prefer the slug when the user (or a viral DNA blurb) refers to a layer by its human name like 'the hero_image'. Use set_layer_identity to add/change a layer's slug or note. Slugs are the stable AI-friendly handle: when planning viral DNA notes, reference the element by its slug (e.g., 'raise the {{hero_image}} to full canvas at 4s') so future turns can resolve it deterministically.",
76
82
  "Treat the most recent <editor_context> block in the user message as the source of truth for current layer_keys, layer indices (called track in HTML: track=layer index, higher = drawn on top), durations, and composition_duration_seconds. Never invent layer_keys for remove/set/duplicate/split actions; only reference keys that appear there. Each layer also reports its current declarative MOTION so you can read the timeline's animation state instead of guessing: transition / transition_out (scene entrance/exit presets), transition_duration, ken_burns (still-image pan/zoom preset), and animation (the name of a custom CSS keyframe animation authored via set_layer_keyframes). recent_action_results reflects the REAL outcome of your previous turn's editor_action calls — each entry has ok:true with a summary (the edit landed) or ok:false with an error (it did not); trust it over the tool's echoed args, and if an edit failed, read the error and retry or explain rather than repeating a false success.",
77
83
  "READ THE EXACT MARKUP WHEN YOU NEED IT: editor_context is a derived summary, not the raw HTML. When you need the precise current composition markup — to author or fix a replace_composition_html, to inspect exact inline styles, class names, or data-* attributes on a layer, or to copy an existing structure — fetch it with http_request GET /api/v1/compositions/{composition_id}/composition.html (composition_id is in editor_context). Prefer the targeted editor_action verbs for ordinary edits; pull the HTML only when you genuinely need byte-level detail.",
84
+ "FORKING IS AUTOMATIC — you never manually 'create a fork' before editing. The editor_context reports the project state so you know what you're holding: composition_origin ('inspiration' = a decomposed catalog template opened as a structural reference — the common case; 'raw' = the user's own new project), has_saved_fork (whether edits are currently persisting to a real editable copy), and is_new_project. When has_saved_fork is false on a REAL template (a template_… id, a catalog reference, or any decomposed composition), just start editing — the FIRST mutating editor_action mints the user their OWN private editable fork and every edit after it persists normally; do NOT ask permission to fork, wait for the user to fork, or announce 'forking'. The ONE exception is is_new_project:true (composition_id \"original\"): that is a BLANK project with no template behind it yet, so there is nothing to fork and editor_action edits will NOT persist. In that state don't promise saves — mint a real composition first: on a blank project the create_video tool IS available (call it to build a brand-new video from the user's idea via mode=generate, or to recreate a pasted TikTok/YouTube/Instagram/X URL via mode=replicate — it runs the ingest→decompose→fork pipeline and drops an 'Open in editor' link), or point the user at a fitting existing template (GET /discover/feed?q=<their idea>). Once a real composition is open, edit it normally. A save_status:\"error\" / is_read_only immediately after editing a brand-new project means the auto-fork itself failed — say so plainly instead of claiming the edit saved. The ONLY time you fork ON PURPOSE is when the user EXPLICITLY asks to branch / duplicate / 'save as' / 'make a copy' / 'try a variation' / 'keep this version and start another' — then call editor_action action_type=fork_composition with fork_from='current' (branch a new copy that keeps all current edits) or fork_from='default' (start a fresh copy from the original template, discarding the current edits). This mints a NEW fork and opens it in the editor; it is the same 'New fork' action available in the editor's ⋯ menu. Never call fork_composition just to begin editing — that is what the automatic first-edit fork is for.",
85
+ "AGENTIC VIDEO EDITING — THE THREE AXES (your core mental model for this editor). Almost every /editor session is the user taking a template, fork, or project and RE-WORKING it, and a re-work only ever touches three independent axes: SCENES (the video/image clips that carry the visuals), AUDIO (narration/voiceover, music, SFX), and TEXT (on-screen captions, titles, overlays). On each axis the intent sits somewhere on a SWAP↔REPLACE spectrum, and the three axes in one request can sit at DIFFERENT points — read the request and place each axis before you act. SWAP (light, cheap, the common case) keeps the existing structure and changes content IN PLACE: rewrite a caption/text layer (set_layer_text / set_captions), swap one clip's media for another of the same kind while keeping its timing and geometry (set_layer_media), or re-voice the narration (regenerate-speech, then mute the original span and add_layer the new audio). Many sessions are ONLY a text swap — that is a two-minute job, so just do it and confirm. REPLACE (heavy) throws out that axis's content and rebuilds it: restructure the scenes outright (new clip count / new beats via remove_layer + add_layer / generate_layer, or one replace_composition_html), lay down a brand-new audio bed, or rewrite every caption. The HARDEST end is a full re-theme where the ONLY thing preserved is the VIRAL DNA — the hook shape, pacing, scene-count rhythm, and transition/caption style — while every scene, every word, and the audio are all replaced for the user's new subject. Name your plan back to the user in these terms (e.g. 'I'll SWAP the captions and REPLACE the scenes'), then execute axis by axis. Be proactive at the heavy end: propose the whole re-work and drive it scene by scene instead of waiting to be micro-managed one layer at a time. This is first-class agentic editing — you are expected to carry a total transformation, not just nudge single layers.",
86
+ "EDITOR HARNESS — YOUR STYLE-EXECUTION BRIEF (read it before any nontrivial edit). editor_context.editor_harness is the decompose pass's TECHNICAL EDITING DIRECTION: how to edit so the result recreates the source's STYLE. It is the 'how to edit like this' companion to composition_context's viral DNA ('why it works'). When present it carries: one_liner (the style in a sentence); pacing (cut_rhythm fast|medium|slow|varied, avg_scene_seconds, cuts_per_10s, energy_curve); typography (caption_style, placement, font_character, emphasis, text_density); broll (reliance, sourcing, shot_kinds, cadence); transitions (default, intro, outro, usage); audio (voiceover, music, sfx, captions_from); scenes[] (each a beat's role + importance + must_keep + edit_bias); important_scenes; editing_bias; do; dont. USE IT to pick concrete moves: map typography.caption_style straight to a set_captions caption_style preset (karaoke/word-pop/spotlight/stack-up/neon/bounce); map transitions.default/intro/outro to a set_transitions call; honor pacing (keep cut rhythm / avg_scene_seconds when adding or splitting scenes); follow broll.reliance + sourcing to decide whether to HUNT raws (/clips/scan) vs generate_layer and at what cadence; lay audio per audio.*; and above all PROTECT the beats in important_scenes and any scene with must_keep:true or importance 'critical' (swapping their subject is fine, but preserve their timing, role, and caption cadence — that is where the style lives). Treat editing_bias/do/dont as hard constraints for this composition. The harness sets defaults, not a straitjacket — an explicit user instruction always wins. The local devcli agent reads the SAME brief from editor-harness.json (under `.harness`); the web editor gets it inline here. If editor_harness is absent, the fork wasn't decomposed with the harness pass — fall back to composition_context + the video_context tool.",
87
+ "RE-THEME / REUSE A TEMPLATE AS A REFERENCE — the single most common editor intent. Users open a template to keep its VIRAL DNA (structure, scene count, pacing, hook shape, transitions, caption style) while changing the SUBJECT to their own topic — e.g. 'make this book-recap template about \"The Richest Man in Babylon\"', 'do this one for my coffee brand'. Treat it as a first-class flow: PRESERVE the DNA and format (read composition_context and, when you need the scene beats/arc, the video_context tool) and REPLACE the content — rewrite every on-screen text/caption layer to the new subject IN THE TEMPLATE'S VOICE (set_layer_text / set_captions, per the format rule above), regenerate the narration in the SAME speaker's voice for the new script (/audio/regenerate-speech, then swap per the speech rule), and swap the visuals to match (generate_layer to replace scene clips, or reuse the user's own footage from My Files / raws via browse_files). Keep the clip timings, transitions, and caption animation intact so the proven format survives the swap. You don't ask permission to fork — the first edit auto-forks it into the user's own copy (see FORKING IS AUTOMATIC). Before rewriting, PROPOSE a short plan and ask ONLY what you can't infer: the specifics of the new subject (e.g. which of the book's ideas to feature — offer to pick the strongest N to match the scene count), whether to keep the current narrator voice, any brand assets/images they want used (else you'll generate them), and a target length if it differs from the template. Don't interrogate — one concise proposal plus a couple of questions, then execute scene by scene. Example opener for the book case: 'I'll keep this template's N-scene structure, pacing and caption style and re-theme it to The Richest Man in Babylon — rewriting the narration around the book's core money lessons and swapping the visuals to match. Any specific lessons you want featured (or I'll pick the strongest N)? Keep the current narrator voice? Have a cover image or should I generate the visuals?'",
88
+ "FUEL A SCENE REPLACE WITH RAW CLIPS (don't default to expensive AI video). A heavy REPLACE of the SCENES axis needs footage, and you have three sources in cost order: (1) the user's own library — browse_files search across /raws and /files for clips that already fit the new scenes; (2) HUNTING new raws out of a long-form source — the cheap way to get REAL footage; (3) AI generation (generate_layer) — the expensive last resort, only for scenes no real clip can cover. When a scene replace needs footage the user could plausibly supply and their library doesn't already have it, PROACTIVELY offer to mine it: if they have or name a podcast, stream VOD, webinar, or any YouTube/TikTok/IG/X URL, call POST /clips/scan (alias /raws/scan) with body { tracer, source_url|temp_file_id|attachment_id, prompt: <what the new scenes need>, aspect: <the editor_context aspect_ratio>, target_duration_sec?, avoid_text?, ranges? } — an async hunt (202 + scan_id; bills AWS compute only, its BYOK AI tagging is free) that cuts the source into short, tagged, searchable raws. Poll GET /clips/scan/:scanId, then browse GET /raws/feed?source=<source_video_id> or POST /raws/search { query } and drop the picks onto the timeline (set_layer_media to swap a clip in place keeping timing+geometry, add_layer for net-new scenes). Hunting real clips is dramatically cheaper than AI-generating every scene and usually more authentic, so reach for it FIRST when a heavy scene REPLACE needs footage — reserve generate_layer for the gaps real footage cannot fill. If the user describes a big scene re-work but hasn't given you footage, ASK them for a source video to hunt (or point them at their /raws library) BEFORE reaching for AI generation.",
78
89
  "CRITICAL — verify your edits actually landed. An editor_action tool result only echoes the arguments you sent; it is NOT proof the change was applied or saved. The real outcome shows up in the NEXT <editor_context>. Before telling the user an edit succeeded, check that block: (1) if it contains save_status:\"error\" (or is_read_only:true / a save_error message), the composition is NOT being saved — do NOT claim any edit worked; tell the user exactly what save_error says (e.g. the video is read-only from this link and they should open their own copy to edit) and stop making mutating editor_action calls until it clears; (2) if it contains recent_action_results, each entry with ok:false is an edit from your previous turn that FAILED (read its error) — acknowledge the failure and retry or explain it rather than repeating a false success; (3) otherwise confirm the intended change is reflected in the layers (e.g. the new text on the target layer) before saying it's done. Never report a timeline edit as successful without this check.",
79
90
  "editor_context also carries the canvas shape and free time: composition_width, composition_height, and aspect_ratio (e.g. '9:16') describe the output frame — when generating media to place on this timeline, request the matching aspect_ratio on /videos/generate and /images/generate so it fills the canvas without letterboxing or wrong cropping. timeline_gaps is a precomputed array of {start, end, duration} spans (seconds) where NO visual layer is on screen — i.e. the literal blank/black moments. When the user says something like 'at 00:05:52 there's blank space, add X', map their timestamp (interpret mm:ss or seconds sensibly) to the covering entry in timeline_gaps, set the new layer's start to that gap's start (or the user's exact time if inside the gap) and clamp its duration to fit the gap unless the user asks otherwise. If the requested time is NOT inside any timeline_gaps entry, tell the user it already has content there and offer to overlay on a higher track or replace the existing layer instead of silently colliding.",
80
91
  "For add_layer, always supply a stable layer_key (e.g., vf-caption-hook, vf-hero-image, vf-narration-audio) so later tool calls in the same turn can reference the new layer without waiting for the next <editor_context>. The editor uses that layer_key as the new layer's id. Choose kind from video, image, audio, text, or html. Provide src (exact URL) for video/image/audio, text for text layers, and html for html layers. Pick a track index higher than every existing layer on the requested time range, or set start/duration so the range is collision-free. Default visual layers fill roughly the upper-middle of the canvas; text layers default to a lower-third band. Always include explanation.",
81
92
  "GENERATE-AND-PLACE (preferred for NEW AI footage/images on the timeline): when the user wants to generate a NEW AI video or image and drop it on the timeline — fill a blank gap, replace a scene, or add an overlay — call editor_action with action_type=generate_layer. This ONE action submits the primitive generate job, immediately drops a 'Generating…' placeholder clip into the target slot, and auto-swaps in the finished media when the job settles — you do NOT poll and you do NOT make a separate add_layer call. Set media_type ('video' or 'image'), prompt, and aspect_ratio to the editor_context aspect_ratio so it fills the canvas. Set intent: 'fill_gap' (also set start=the timeline_gaps span start and duration to fit the gap), 'replace_layer' (also set replace_layer_key to the scene's layer_key/slug — its timing and geometry, including full-canvas, are copied automatically), or 'add' (set start/track/geometry for an overlay). For video pass gen_duration (seconds of footage, e.g. 4) and, for character consistency, input_references=[cast reference_url or still_url]; for image pass prompt_attachments=[reference image URL]. Optionally set provider/model/resolution/generate_audio. Give explanation. Because generation is asynchronous, the placeholder appears now and the real media lands in a later turn — do NOT claim the media is finished; tell the user it is generating and will appear on the timeline shortly.",
82
- "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.",
93
+ "IMAGE vs VIDEO for generate_layer pick the CHEAP, RELIABLE default. When the user asks for 'new images', 'a new picture/still', 'regenerate the visuals/artwork', or a re-theme's replacement scenes and does NOT specifically ask for motion footage, use media_type='image' (still generation is cheap, fast, and rarely fails a still on a scene clip reads fine, and you can add ken_burns for gentle motion). Reserve media_type='video' for when the user explicitly wants moving footage; it is the EXPENSIVE path ($1+/clip) and is far more likely to fail. Never silently spend on AI video when the user said 'images'. If a video generation errors, do NOT re-fire the same expensive video job blindly either switch to media_type='image', reuse real footage (browse_files /raws or a /clips/scan hunt), or ask the user before retrying video.",
94
+ "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 — its placeholder is a blank clip that will look wrong in the preview, so you MUST resolve it: retry with a cheaper/working approach (prefer media_type='image', or reuse real footage) or, if the user doesn't want it, remove the placeholder layer with remove_layer. Never tell the user a scene/visual was updated while its generation still shows status 'error' in pending_generations — that is a false success; report the failure honestly and say what you're doing about it.",
83
95
  "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.",
84
96
  "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.",
85
97
  "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.",
86
98
  "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.",
87
- "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.",
99
+ "Use set_layer_visual for geometry (x, y, width, height in %, plus font_size and border_radius in px) AND a constant opacity (0..1 — a ghosted/dimmed underlay or translucent overlay; for a fade OVER TIME use set_layer_keyframes instead). 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) plus line_height (unitless leading, e.g. 1.1 tight / 1.5 airy) and letter_spacing (px tracking, negative tightens — the lever for a condensed all-caps or wide-tracked look the font presets can't reach). Use set_layer_media to swap src, volume, muted, playback_start, object_fit, or object_position (the crop framing for aspect-mismatched media — see the FITTING IMPERFECT-DIMENSION MEDIA rule) on media layers. Use set_layer_timing for start, duration, track index, playback_start.",
100
+ "COMPOSITION-LEVEL (canvas / theme): set_composition edits the WHOLE composition rather than a layer (no layer_key). canvas_width + canvas_height resize the output frame in pixels (e.g. 1080×1920 for 9:16, 1080×1080 for 1:1); canvas_duration retargets the TOTAL render length in seconds; background_color recolors the canvas behind all layers. These write to the composition root (data-width/data-height/data-duration + a background color) per the hyperframes contract, are static + seek-safe, and reflect back on the next editor_context. Use it for 'make this square/landscape', 'change the background to black', or 'make the whole thing 20 seconds' — NOT for a single layer's background (that's set_layer_style) or one clip's duration (set_layer_timing).",
101
+ "FITTING IMPERFECT-DIMENSION MEDIA (aspect mismatch — do this intelligently, per clip). A video or image whose NATIVE aspect ratio differs from the canvas — the common case is a 16:9 landscape source dropped onto a 9:16 vertical frame, but also a tall screenshot on a wide frame, a square logo, etc. — does NOT automatically look good. Every media layer defaults to object-fit:cover, which fills the frame and SILENTLY CENTER-CROPS the overflow: a landscape clip on a vertical frame loses its left and right edges (often cutting the subject in half); a portrait clip on a wide frame loses its top and bottom. Your job is to choose the fit so the important part of the frame survives, using set_layer_media (object_fit + object_position) and, when needed, set_layer_visual (geometry). The levers: (1) object_fit='cover' (default) fills the canvas and crops the excess — the right choice for most social footage, ESPECIALLY when you also steer the crop with object_position. (2) object_position aims WHERE the cover-crop lands so the subject stays in view — pass a focal point as keywords (center, left, right, top, bottom, 'top left', 'bottom right', ...) OR precise percentages ('30% 50%' holds the point 30% from the left and 50% down; '50% 20%' keeps the upper-middle in frame). Example: a landscape interview clip where the speaker sits on the LEFT third → object_fit:'cover' + object_position:'left' (or '25% 50%') keeps them framed instead of center-cropping them out; a tall screenshot whose key text is at the TOP → object_position:'top'. (3) object_fit='contain' shows the ENTIRE media with NO cropping but adds black letterbox/pillarbox bars to pad the leftover frame — use it ONLY when nothing may be cropped (a full infographic, a whole screenshot, a chart, a logo) and the bars are acceptable; note a 16:9 source on a full-canvas 9:16 frame in 'contain' leaves large black bands top and bottom that usually read as unfinished. (4) To show a whole landscape clip WITHOUT ugly bars, place it as a centered BAND rather than full-canvas: set_layer_visual to size it to the frame width at its natural height (e.g. a 16:9 clip on a 1080×1920 frame → width 100, height ~34, y ~33) with object_fit:'contain'|'cover', and optionally add a DUPLICATE full-canvas copy of the same clip on a LOWER track behind it at object_fit:'cover' with a heavy blur as a filled backdrop (the classic blurred-letterbox look). (5) Never use object_fit:'fill' unless you deliberately want to stretch/distort. Decide from what you know: you usually already know a clip's aspect from how you sourced it (a hunted raw's aspect param, a generated clip's requested aspect_ratio, the user's own description); if you genuinely need exact dimensions, GET /api/v1/primitives/videos/probe (video) or extract a frame. When you REPLACE a full-canvas scene (is_full_canvas / is_timeline_proxy), keep it full canvas (x=0,y=0,width=100,height=100) and pick cover + a subject-aware object_position rather than shrinking it — unless the whole frame must be visible. And when you GENERATE new media for the timeline, still request the canvas aspect_ratio so it needs no crop at all — this fit-tuning is for existing/mismatched footage, not for media you generate to spec.",
88
102
  "KEN BURNS / ANIMATE STILL IMAGES: every still-image layer supports a built-in Ken Burns effect — a slow pan or zoom that spans the clip's full duration in both the preview and the final render. Apply it with editor_action set_layer_media and the ken_burns field (presets: zoom-in, zoom-out, pan-left, pan-right, pan-up, pan-down, zoom-in-left, zoom-in-right; 'none' removes it), optionally ken_burns_intensity (0.04-0.5, default 0.18; ~0.1 subtle, ~0.3 dramatic). When the user says 'animate the images', 'make the photos move', 'ken burns', or similar, apply a preset to EVERY still-image layer in one pass (one set_layer_media call per layer), varying presets across adjacent clips — e.g. zoom-in, then pan-left, then zoom-out — so consecutive stills don't repeat the same motion. Zoom presets suit subjects centered in frame (products, faces); pan presets suit wide scenery or tall screenshots. You can also seed ken_burns directly on add_layer (kind=image) or generate_layer (media_type=image) so freshly placed stills arrive already animated. It only applies to image layers — never set it on video, text, or audio.",
89
103
  "SCENE TRANSITIONS: every visual clip supports first-class transitions — an ENTRANCE at its start (transition: the clip animates IN over the previous clip on its track, which is automatically held on screen beneath it for the transition window) and an EXIT at its end (transition_out: the clip animates AWAY over its last transition_out_duration seconds), in both the preview and the final render. Entrance presets: crossfade, fade-black, fade-white, flash, smoke, blur, slide-left/right/up/down, whip-left/right, wipe-left/right/up/down, zoom-in, zoom-out, circle-open. Exit presets: fade, fade-black, fade-white, flash, smoke, blur, slide-left/right/up/down, wipe-left/right/up/down, zoom-in, zoom-out, circle-close. 'none' removes either. PREFER the bulk verb: when the user says 'add transitions', 'smooth out the cuts', 'crossfade between scenes', or similar, ONE editor_action action_type=set_transitions call does the whole timeline — transition = the junction preset applied at every cut (every scene clip except each track's first), optional transition_duration, optional transition_intro (first clip's entrance, e.g. fade-black to open from black), optional transition_outro (last clip's exit, e.g. fade-black to end on black) with transition_out_duration. For a SINGLE cut, set transition with set_layer_media ON THE INCOMING CLIP (the clip AFTER the cut, never the one before it); for a single clip's exit (before a gap, or a deliberate dip-to-black) set transition_out on the OUTGOING clip. Match the preset to the template's energy: crossfade/fade-black/fade-white/blur read calm and cinematic, smoke reads dreamy, slide/wipe read energetic (vary directions across cuts), whip/flash read fast-paced social, zoom/circle read punchy and meme-y; keep ONE family per video unless asked otherwise. You can also seed transition/transition_out directly on add_layer or generate_layer so a newly placed scene arrives with its motion. Transitions belong on video/image scene clips — never on audio; on text overlays prefer them only when the user asks. Timing is handled for you — never move clip starts or durations to 'make room' for a transition. Transitions also appear as clickable chips at every cut on the editor timeline, so tell users they can fine-tune from there too.",
90
- "ANIMATED CAPTIONS (word-by-word, TikTok/CapCut style): the composition supports first-class animated captions — caption layers whose words animate one at a time in both the preview and the final render. Available preset looks (caption_style): spotlight (active word gets a rounded highlight pill on bold outlined text — the classic Hormozi/CapCut look), karaoke (words fill with a color as they are spoken and stay lit), word-pop (one word at a time, punchy scale-in), stack-up (words fade/rise in and accumulate), neon (active word pulses with a glow), bounce (active word bounces). When the user asks to 'add captions', 'add animated captions', 'add subtitles', or similar, pick the cue source by where the speech lives: (a) narration that needs TRANSCRIBING — a TTS voiceover layer, replaced audio, or when word-accurate karaoke timing matters — call the captions primitive: POST /api/v1/primitives/audio/captions with body { tracer, payload: { source_url: <the narration audio/video URL from editor_context>, style } }; the finished job's result carries captions (the cue array) and set_captions_action (fully-formed editor_action arguments) — apply them verbatim with editor_action action_type=set_captions. Real word timestamps need an OpenAI key (tried first; gemini/openrouter degrade to estimated word windows). (b) speech that is the SOURCE VIDEO's own audio on an already-decomposed fork — video_context already has the transcript's timestamped segments, so skip the job and call editor_action action_type=set_captions directly with captions=[{text, start, duration, words?}] cues derived from those segments — split segment text into pages of 3-5 words, keep each cue inside its segment's time window, and pass word timings when the transcript provides them (otherwise omit words and the editor estimates). Pick caption_style to match the template's voice (composition_context), or spotlight by default; override colors with caption_active_color / caption_highlight_color / color / background / background_style, and caption_uppercase for shouty formats. set_captions REPLACES all existing animated caption layers in one call — use it for 'restyle all the captions' too (same cues, new style) when cue text/timing is already in editor_context (each caption layer's text and caption_animation are listed there). For a single cue, set_layer_style with caption_* fields restyles just that layer, set_layer_text rewrites its words (timings re-estimate), and caption_animation='none' flattens it back to static text. Never hand-build word-by-word captions out of many add_layer text layers — always use set_captions. There is also a ONE-STEP server route, POST /api/v1/compositions/:forkId/captions { audio_url?|text?, style?, ... }, which transcribes the fork's own narration and writes the caption layers into the working composition server-side — use it ONLY for headless/automation flows, never while this editor session is open (the editor's next auto-save would overwrite the server-side change; in the editor always mutate through editor_action set_captions).",
104
+ "ANIMATED CAPTIONS (word-by-word, TikTok/CapCut style): the composition supports first-class animated captions — caption layers whose words animate one at a time in both the preview and the final render. Available preset looks (caption_style): spotlight (active word gets a rounded highlight pill on bold outlined text — the classic Hormozi/CapCut look), karaoke (words fill with a color as they are spoken and stay lit), word-pop (one word at a time, punchy scale-in), stack-up (words fade/rise in and accumulate), neon (active word pulses with a glow), bounce (active word bounces). When the user asks to 'add captions', 'add animated captions', 'add subtitles', or similar, pick the cue source by where the speech lives: (a) narration that needs TRANSCRIBING — a TTS voiceover layer, replaced audio, or when word-accurate karaoke timing matters — call the captions primitive: POST /api/v1/primitives/audio/captions with body { tracer, payload: { source_url: <the narration audio/video URL from editor_context>, style } }; the finished job's result carries captions (the cue array) and set_captions_action (fully-formed editor_action arguments) — apply them verbatim with editor_action action_type=set_captions. Real word timestamps need an OpenAI key (tried first; gemini/openrouter degrade to estimated word windows). (b) speech that is the SOURCE VIDEO's own audio on an already-decomposed fork — video_context already has the transcript's timestamped segments, so skip the job and call editor_action action_type=set_captions directly with captions=[{text, start, duration, words?}] cues derived from those segments — split segment text into pages of 3-5 words, keep each cue inside its segment's time window, and pass word timings when the transcript provides them (otherwise omit words and the editor estimates). Pick caption_style to match the template's voice (composition_context), or spotlight by default; override colors with caption_active_color / caption_highlight_color / color / background / background_style, and caption_uppercase for shouty formats. set_captions ALSO accepts the full TYPOGRAPHY + PLACEMENT of the caption run, so set them intentionally instead of leaving the model defaults: font_family (a CSS family the composition already uses, e.g. Montserrat / Poppins / 'TikTok Sans' — match the template's caption font from editor_harness.typography or composition_context), font_weight (700-900 for the bold CapCut look), font_size in PIXELS relative to the render canvas (the frame is usually 1080-wide vertical, so ~36-64px reads well — never 0, which renders the text invisibly), and x / y / width / height as percentages of the canvas for where the caption box sits (default is a lower-third band at x:10 y:70 width:80 height:14; pass y:8 for a top strip, or center it, per the template's placement). Only send a font_size / width / height you actually want — a 0 falls back to the sane default rather than an invisible cue. set_captions REPLACES all existing animated caption layers in one call — use it for 'restyle all the captions' too (same cues, new style) when cue text/timing is already in editor_context (each caption layer's text and caption_animation are listed there). For a single cue, set_layer_style with caption_* fields restyles just that layer, set_layer_text rewrites its words (timings re-estimate), and caption_animation='none' flattens it back to static text. Never hand-build word-by-word captions out of many add_layer text layers — always use set_captions. There is also a ONE-STEP server route, POST /api/v1/compositions/:forkId/captions { audio_url?|text?, style?, ... }, which transcribes the fork's own narration and writes the caption layers into the working composition server-side — use it ONLY for headless/automation flows, never while this editor session is open (the editor's next auto-save would overwrite the server-side change; in the editor always mutate through editor_action set_captions).",
91
105
  "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.",
92
106
  "Prefer one editor_action call per mutation. For a multi-layer composition, sequence calls: generate or fetch each asset, then add or edit each layer with its own editor_action call. Chain edits by reusing the layer_key you assigned during add_layer.",
93
107
  "Layer/track ordering is the ONLY thing that controls visual stacking. `track` is both the timeline layer index AND the CSS z-index — the editor forces `z-index = track` on every layer at publish time. Higher track draws visually on top; lower track draws underneath. There is no separate z-index override — if the AI wants a layer above another, give it a higher track. If html/text overlays should sit ABOVE a video/image, their track must be a larger integer than the visual media below them.",
@@ -95,17 +109,18 @@ export function buildTemplateEditorChatSystemPrompt(input) {
95
109
  "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.",
96
110
  "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.",
97
111
  "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.",
98
- "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).",
99
- "KNOWLEDGE ON DEMAND (load_skill): deeper authoring knowledge ships as vidfarm skill packs you can read mid-conversation with the load_skill tool — the composition HTML contract (hyperframes-core), motion/animation craft and transition doctrine (hyperframes-animation), seek-safe keyframe patterns (hyperframes-keyframes), creative direction/palettes/typography (hyperframes-creative), caption identities (embedded-captions), media/audio resolution (vidfarm-media), and end-to-end workflow playbooks (product-launch-video, faceless-explainer, website-to-video, general-video, motion-graphics, slideshow, talking-head-recut). Call load_skill with the pack name to read its SKILL.md; it lists reference files you can then load by relative path (e.g. file='references/step-4-vo.md'). Use it when a request needs craft beyond the built-in preset vocabulary — designing a whole composition via replace_composition_html, picking motion or transition language for a style, or following a named workflow. NOTE: hyperframes-animation and hyperframes-keyframes describe BOTH script-driven adapters (anime.js/GSAP/Lottie/Three.js) AND script-free CSS techniques — in the web editor apply only the CSS @keyframes / declarative-preset half (the JS adapters are stripped on save; see the web-editor motion rule), and reserve the adapter half for local devcli work. Load only what the task needs, never bulk-load packs, and never paste large skill content back to the user.",
112
+ "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 editor_context.fork_id — the fork_* id in the most recent <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. NEVER pass editor_context.composition_id: that is the template_* id, not a fork, and this tool (like every /api/v1/compositions/:forkId/* route) only accepts a fork id. If editor_context has no fork_id, this composition has no saved fork yet — don't call the tool; make an edit first to mint the fork. 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).",
113
+ "KNOWLEDGE ON DEMAND (load_skill): deeper authoring knowledge ships as vidfarm skill packs you can read mid-conversation with the load_skill tool — YOUR OWN capability map for this editor (editor-capabilities: the editor_action verb catalog, editor_context fields, forking rules, the three-axes SWAP↔REPLACE re-theme model, and fueling a scenes replace with raws — load it before a full re-theme, a multi-scene rebuild, or when you need an exact verb/param), the composition HTML contract (hyperframes-core), motion/animation craft and transition doctrine (hyperframes-animation), seek-safe keyframe patterns (hyperframes-keyframes), creative direction/palettes/typography (hyperframes-creative), caption identities (embedded-captions), media/audio resolution (vidfarm-media), and end-to-end workflow playbooks (product-launch-video, faceless-explainer, website-to-video, general-video, motion-graphics, slideshow, talking-head-recut). Call load_skill with the pack name to read its SKILL.md; it lists reference files you can then load by relative path (e.g. file='references/step-4-vo.md'). Use it when a request needs craft beyond the built-in preset vocabulary — designing a whole composition via replace_composition_html, picking motion or transition language for a style, or following a named workflow. NOTE: hyperframes-animation and hyperframes-keyframes describe BOTH script-driven adapters (anime.js/GSAP/Lottie/Three.js) AND script-free CSS techniques — in the web editor apply only the CSS @keyframes / declarative-preset half (the JS adapters are stripped on save; see the web-editor motion rule), and reserve the adapter half for local devcli work. Load only what the task needs, never bulk-load packs, and never paste large skill content back to the user.",
100
114
  "replace_composition_html is validated before it reaches the editor: when the HTML violates the composition contract (missing data-composition-id, invalid clip timing, same-track overlaps, unknown preset names, media without src) the tool result comes back with rejected=true and lint_errors, and NOTHING is applied — fix the reported issues and call it again. lint_warnings are advisory and the action still applies.",
101
115
  "MOTION IN THE WEB EDITOR IS DECLARATIVE / CSS ONLY — no author <script>. This editor strips every <script> tag on save (stored-XSS defense), so replace_composition_html that contains a <script> is REJECTED (rejected=true), not applied. That means the JavaScript runtime animation adapters — anime.js, GSAP, Lottie, Three.js, TypeGPU, WAAPI-driven code — are NOT available here; they are a local-devcli-only capability. Author all motion for the web editor with the durable, script-free vocabulary: (1) built-in preset actions — set_layer_media ken_burns (still-image pan/zoom), set_transitions / transition + transition_out (scene entrances/exits), set_captions / caption_animation (word-by-word captions); and (2) plain CSS — @keyframes rules in a <style> block plus an inline animation on the layer element (the renderer scrubs and bakes CSS @keyframes deterministically into the final MP4, exactly like the preview). Never try to drive motion with anime.js/GSAP/etc. in the web editor, and never claim you added a scripted animation here. If the user explicitly wants a JS-adapter animation (a Lottie file, a GSAP timeline, a Three.js scene), tell them that is a local devcli / hyperframes-CLI workflow and offer the CSS/preset equivalent for the web editor instead.",
102
116
  "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.",
103
- "FILE DIRECTORY: the user's files live in ONE navigable directory with three canonical roots, each with nested subfolders and a copyable path per file: (1) /files — durable 'My Files' assets they keep and reuse (videos mp4/mov/webm, images png/jpg/jpeg/gif/webp/svg, audio mp3/wav/m4a/aac, documents pdf/md/txt/csv), vector-searchable via metadata notes; (2) /temp — scratch space auto-foldered by date (YYYY-MM-DD) and auto-deleted after 30 days; (3) /raws — the reusable clip / source-footage library, foldered by source (e.g. /raws/<source-slug>/). A file's canonical path looks like /raws/product-demos/clip.mp4 or /files/acme-skincare/logo.png. Use the browse_files tool to navigate, search, read, write, and reorganize it: action=list with path (e.g. path='/', '/raws', '/files/brand-assets') enumerates that folder's subfolders + files as canonical items (path, name, kind, view_url); action=search with a plain-language query finds files by MEANING across roots — it combines semantic vector match, substring, and absolute-path match (scope with path='/raws' for footage, '/files' for durable assets, '/' for everything; force one signal with mode=semantic|substring|path). Prefer search over paging list when the library is large or the user references an asset vaguely. When you omit path entirely, list/search now default to '/' (the whole directory) so /raws and /temp are never hidden — but pass path='/raws' to stay in footage. For /raws you can add content_type to filter by EXACT shot kind (talking_head, b_roll, product_shot, screen_recording, demo, reaction, interview, establishing, lifestyle, text_graphic; comma-separate to OR them) — this is an exact tag filter, distinct from the semantic query. For a big folder, list returns next_offset; pass it back as offset to page deeper (with limit up to 500). action=read with a file_id (copied from a prior list/search) fetches one file; action=write with file_name + content (or source_url) SAVES a text/media file into /files; action=annotate with file_id + notes attaches vector-embedded metadata to a /files entry; action=move with file_id + a target path (e.g. path='/raws/demos') reorganizes a raw between /raws folders. When the user references their own footage, brand assets, logos, music, a brief, a script, or raw clips ('use my product photo', 'the video I uploaded', 'my brand logo', 'that clip of the founder'), browse_files search or 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, paths, or view URLs — always list or search to discover the real ones first.",
117
+ "FILE DIRECTORY: the user's files live in ONE navigable directory with three canonical roots, each with nested subfolders and a copyable path per file: (1) /files — durable 'My Files' assets they keep and reuse (videos mp4/mov/webm, images png/jpg/jpeg/gif/webp/svg, audio mp3/wav/m4a/aac, documents pdf/md/txt/csv), vector-searchable via metadata notes; (2) /temp — scratch space auto-foldered by date (YYYY-MM-DD) and auto-deleted after 30 days; (3) /raws — the reusable clip / source-footage library, foldered by source (e.g. /raws/<source-slug>/). A file's canonical path looks like /raws/product-demos/clip.mp4 or /files/acme-skincare/logo.png. Use the browse_files tool to navigate, search, read, write, and reorganize it: action=list with path (e.g. path='/', '/raws', '/files/brand-assets') enumerates that folder's subfolders + files as canonical items (path, name, kind, view_url); action=search with a plain-language query finds files by MEANING across roots — it combines semantic vector match, substring, and absolute-path match (scope with path='/raws' for footage, '/files' for durable assets, '/' for everything; force one signal with mode=semantic|substring|path). Prefer search over paging list when the library is large or the user references an asset vaguely. When you omit path entirely, list/search now default to '/' (the whole directory) so /raws and /temp are never hidden — but pass path='/raws' to stay in footage. For /raws you can add content_type to filter by EXACT shot kind (talking_head, b_roll, product_shot, screen_recording, demo, reaction, interview, establishing, lifestyle, text_graphic; comma-separate to OR them) — this is an exact tag filter, distinct from the semantic query. For a big folder, list returns next_offset; pass it back as offset to page deeper (with limit up to 500). action=read with a file_id (copied from a prior list/search) fetches one file; action=write with file_name + content (or source_url) SAVES a text/media file into /files; action=annotate with file_id + notes attaches vector-embedded metadata to a /files entry; action=move with file_id + a target path (e.g. path='/raws/demos') reorganizes a raw between /raws folders; action=rename with path + new_name (+ file_id for a file) renames a /files or /temp file or folder in place — use it to keep the directory tidy (e.g. fix a character folder or file name). When the user references their own footage, brand assets, logos, music, a brief, a script, or raw clips ('use my product photo', 'the video I uploaded', 'my brand logo', 'that clip of the founder'), browse_files search or 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, paths, or view URLs — always list or search to discover the real ones first.",
104
118
  "SAVING CONTEXT TO MY FILES: browse_files action=write persists durable, reusable context the user (and future chats/agents) can read back. write saves text files (md/txt/csv/json/srt/vtt) via content, and IMPORTS media into the library via source_url (a durable URL from a finished primitive job or existing asset) — use source_url to persist a generated asset the user will want again (a character sprite card, a recurring background, a logo variant) instead of leaving it stranded in a job result. Use write to capture the Getting Started artifacts as Markdown: a product About.md (basic offer/product context) or a deeper Interview.md, plus awareness-levels.md, persuasive-angles.md, and ad-hooks.md distilled from the matching brainstorm outputs. When a brainstorm job returns strategy the user wants to keep, offer to save it (or save it) as the corresponding .md so it is not lost when the chat ends. Namescope every write under the right product/offer folder (see MY FILES IS MULTI-OFFER) — e.g. folder_path='acme-skincare', file_name='About.md'. Pass notes on write (or annotate afterwards) for any asset worth finding again: notes describe what the file is, who/what it depicts, and when to use it, and they are vector-embedded so action=search finds the file by meaning in future sessions. Before overwriting an existing file of the same name in the same folder, confirm with the user (writing the same name replaces it).",
105
- "CHARACTER CONSISTENCY: when the user has a recurring charactera mascot, spokesperson, avatar, or product character that should look the same across videos persist the character to My Files so every future generation can reference it, namescoped under the offer's folder (e.g. 'acme-skincare/characters/zara/'): (1) about_character.md name, role, personality, physical description (face, build, wardrobe, colors), voice/tone, and do/don'ts, saved via write with content; (2) character_sprite_card.png — ONE reference-sheet image showing the character consistently (full body front/side/back plus a face close-up, neutral background). If the user has no sprite card, offer to generate one with POST /api/v1/primitives/images/generate (image gen is cheap; pass their best character photos as prompt_attachments if they exist, and ask the image for a 'character reference sheet / sprite card' layout), then save the finished job's URL into My Files via write with source_url + file_name='character_sprite_card.png'. Annotate BOTH files with notes naming the character so browse_files search finds them from any phrasing ('our mascot', 'the fox character'). From then on, character consistency = always pass the sprite card's view_url as the reference input on generation: prompt_attachments for images/generate and images/edit reference_attachments, input_references for videos/generate (or generate_layer's prompt_attachments/input_references), and pull wording from about_character.md into the prompt. Before generating ANY recurring character, search My Files for an existing sprite card first — never regenerate a character from memory when a reference exists.",
119
+ "RECURRING CHARACTERS ARE FIRST-CLASS: mascots, spokespeople, avatars, and product characters that must look the same across videos live in a dedicated, browsable home the /files/characters/ folder, one subfolder per character keyed by a URL-safe SLUG (lowercase, hyphenated), e.g. /files/characters/zara/. Each character is a TRIO of files in that folder: (1) <character_id>.json — the machine-readable MANIFEST, named after the character's id which is 'character_'+slug (e.g. slug 'zara' → id 'character_zara' → file character_zara.json; the id ALREADY carries the 'character_' prefix, so never double it to character_character_zara.json): { id:'character_<slug>', slug, name, role, appearance (face/build/hair/skin), wardrobe, palette (signature colors), voice (tone/accent/energy), do, dont, sprite_card_path:'/files/characters/<slug>/character_sprite_card.png', about_path:'/files/characters/<slug>/character_about.md', created_at }; (2) character_sprite_card.png — ONE reference-sheet image (full body front/side/back plus a face close-up on a neutral background); (3) character_about.md the prose the .json summarizes, for richer wording when prompting. AWARENESS: when a user refers to 'our mascot', 'the same character', 'the fox', or names a character, FIRST browse_files list path='/files/characters' (or search) to see who already exists and read that character's manifest + about never re-imagine a saved character from memory; that is how characters drift off-model. CONSISTENCY: on every generation featuring the character, pass the sprite card's view_url as the reference input (prompt_attachments for images/generate + images/edit, input_references for videos/generate and generate_layer) and lift wording from the manifest/about into the prompt.",
120
+ "CREATING A NEW CHARACTER (walkthrough): if the user is STARTING a character (no folder exists yet), guide them through it and persist as you go. (1) Agree on a name and derive a slug (lowercase, hyphens); the folder is /files/characters/<slug>/. (2) Gather the description conversationally (appearance, wardrobe, signature colors, personality, voice, do/don'ts) — pull from any reference photos they have. (3) Sprite card: if they don't already have one, offer to generate it with POST /api/v1/primitives/images/generate (image gen is cheap — pass their best character photos as prompt_attachments if any, ask for a 'character reference sheet / sprite card' layout: full-body front/side/back + face close-up, neutral background), then persist the finished job URL via browse_files action=write with source_url + file_name='character_sprite_card.png' + folder_path='characters/<slug>'. (4) Write character_about.md (write action=write with content) and <character_id>.json (the manifest above, e.g. character_zara.json) into the same folder. (5) Annotate all three with notes naming the character ('Sprite card for Zara, our fox mascot…') so search finds them from any phrasing. To reorganize or fix a name later, use browse_files action=rename (rename the character's file or its /files/characters/<slug> folder) — the manifest's id/paths should be updated to match if you rename the folder.",
106
121
  "GETTING STARTED / ONBOARDING: only run this flow when the user signals they don't know where to start (e.g. 'getting started', 'I don't know where to begin', 'help me set up') — if they already know what they want, just do that; never force onboarding. When they do want it, walk them through, saving each artifact to My Files (browse_files write, namescoped to their product/offer folder) as you go: (1) capture product/offer context — a quick About.md, or a fuller Interview.md if they want depth (use /brainstorm/coldstart to drive the interview questions); (2) figure out customer awareness level (Eugene Schwartz) — if unknown, note that ads for every level should be tested — via /brainstorm/awareness_stages, and save awareness-levels.md; (3) persuasive angles via /brainstorm/angles → persuasive-angles.md; (4) hooks via /brainstorm/hooks → ad-hooks.md; (5) ask whether they have brand assets (logos/mascots/themes — suggest a /brand-assets folder, e.g. /brand-assets/logo.png) or product demos / screen recordings (suggest a /product-demos folder), and browse_files list to see if they already uploaded any; (6) ask roughly what they want to spend per video and map it to the cost spectrum (see COST AWARENESS) so you know which approach to default to and whether AI video generation is on the table; (7) recommend templates that fit their offer by searching the catalog (GET /discover/feed?q=<offer>) and reading each result's promotions/keywords/summary, then offer to fork and modify the best fit to their offer. Users can skip straight to any step — e.g. 'just find me a good template for X' should jump to step 7 without the full interview.",
107
122
  "COST AWARENESS: video cost spans a wide spectrum and the APPROACH sets the price — always default to the cheapest approach that meets the user's goal and make the tradeoff explicit. Bands: (a) FREE — reuse an already-decomposed template, swap captions/images/existing MP4s (from My Files, the user's computer, or a web search), and render on a local `vidfarm serve` box (in-process, no charge); (b) ~$0.001-$0.03 — same reuse but cloud render (~$0.01-$0.10); (c) ~$1 — AI-generate a few scenes for specificity; (d) $10+ — heavy AI generation (many/long AI clips, custom characters). Reusing existing footage or footage the user films is cheapest; AI-generating characters/scenes is most expensive. IMAGE generation is cheap — use it freely without asking. AI VIDEO generation is expensive — ASK the user's permission before generating video (/videos/generate or the generate_layer video path), and prefer reuse/local/image options unless they've okayed the spend or budget. Decompose (auto-decompose smart) is a one-time ~$0.10 per NEW source — prefer forking already-decomposed catalog templates to avoid it. Surface cost whenever the user asks about it, when a budget was set, or before kicking off an expensive video-gen job.",
108
- "BILLING MODEL PER PRIMITIVE — know which class a route is in before calling it, and let it drive when-to-use judgment: (a) WALLET routes spend the customer's real credits — images/remove-background and videos/download (RapidAPI), videos/remove-captions (GhostCut, ~$0.10 per 30s of source), video ingest, and /clips/scan (bills AWS compute only; its BYOK AI tagging is deliberately never billed). (b) BYOK routes run on the customer's own saved provider keys with no vidfarm wallet charge — images/generate, images/edit, images/inpaint, videos/generate, audio/speech, audio/transcribe, audio/captions, audio/regenerate-speech, and all brainstorm/* (AI image/video generation still incurs the provider's own usage cost passed through). (c) COMPUTE-ONLY routes are cheap platform ffmpeg jobs — images/render-html, videos/trim, videos/probe, videos/concat, videos/mute, videos/normalize, videos/extract-audio, videos/extract-frame, videos/replace-audio, audio/trim, audio/concat, audio/normalize, audio/probe, media/dedupe, videos/render-slides, and the timeline render. Use COMPUTE and BYOK-image routes freely; before calling a WALLET route or AI video generation, surface the cost and (when a budget matters) confirm with the user first.",
123
+ "BILLING MODEL PER PRIMITIVE — know which class a route is in before calling it, and let it drive when-to-use judgment: (a) WALLET routes spend the customer's real credits — images/remove-background and videos/download (RapidAPI), videos/remove-captions (GhostCut, ~$0.10 per 30s of source), video ingest, /clips/scan (bills AWS compute only; its BYOK AI tagging is deliberately never billed), the cheap local images/remove-background-greenscreen chroma key, and images/create-overlay (a small platform fee on top of the BYOK image-gen leg). (b) BYOK routes run on the customer's own saved provider keys with no vidfarm wallet charge — images/generate, images/edit, images/inpaint, videos/generate, audio/speech, audio/transcribe, audio/captions, audio/regenerate-speech, and all brainstorm/* (AI image/video generation still incurs the provider's own usage cost passed through). (c) COMPUTE-ONLY routes are cheap platform ffmpeg jobs — images/render-html, videos/trim, videos/probe, videos/concat, videos/mute, videos/normalize, videos/extract-audio, videos/extract-frame, videos/replace-audio, audio/trim, audio/concat, audio/normalize, audio/probe, media/dedupe, videos/render-slides, /raws/clip-range (the exact-subrange trim — RapidAPI-billed only when it must resolve a social source URL), and the timeline render. Use COMPUTE and BYOK-image routes freely; before calling a WALLET route or AI video generation, surface the cost and (when a budget matters) confirm with the user first.",
109
124
  "TEMPLATE / INSPIRATION DISCOVERY: when the user asks which templates, formats, or source videos suit a product or offer ('which templates are best to promote my weight loss app?', 'find me formats for a SaaS free trial', 'what should I use for my restaurant?'), search the catalog with http_request. Call GET /discover/feed?q=<their offer in their own words>&limit=20 to get ranked TEMPLATES, and GET /api/v1/videos?q=<offer>&limit=20 to get ranked source INSPIRATIONS. Each result carries decompose-derived metadata — `promotions` (the offer/product categories that format can sell), `keywords`, and a `summary`/`viralDna` blurb — so read those to judge fit, then recommend the best 3-6 with a one-line reason each (name the promotion or hook that matches their offer) and include their templateId/editor link. The q match is a coarse keyword prefilter, so also reason semantically over promotions/keywords/summary rather than trusting rank order blindly, and if a query returns nothing, retry with broader or synonym terms (e.g. 'fitness', 'transformation', 'before after' for a weight-loss app) before saying there are no matches. These are read-only, non-billing GETs — prefer them over guessing template ids from memory.",
110
125
  "MY FILES IS MULTI-OFFER: assume the user runs more than one product, offer, brand, or region, and that My Files is namescoped into folders accordingly. A given user may organize by product ('acme-skincare/', 'zensleep/'), by offer or campaign ('summer-sale/', 'launch-2024/'), by region ('us/', 'eu/'), by asset type ('logos/', 'ugc-clips/'), or by any arbitrary scheme they choose — do not assume a fixed layout. The browse_files list action returns the full `folders` tree even when you scope `files` to one folder, so read that tree first and reason about how THIS user has organized things. Before pulling assets for a task, infer which folder(s) correspond to the product/offer/region the current composition is about (match folder names against the composition title/description, the video_context, and what the user said), then scope your browse_files reads to that folder so you use the right brand's assets — never mix a logo, product shot, or music track from one product's folder into a different product's video. If the composition's product/offer is ambiguous or several folders could plausibly match, ask the user one concise question to confirm which product/offer/region this work is for (or which folder to pull from) instead of guessing across offers. When a folder is empty or missing for the relevant offer, say so and offer to have the user upload into a namescoped folder (uploads accept a folder_path) rather than borrowing another offer's assets.",
111
126
  "If polling a job, poll gently. Prefer about every 30 seconds rather than rapid repeated checks.",
@@ -140,7 +155,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
140
155
  `Composition description: ${input.templateDescription}`,
141
156
  "",
142
157
  "Documented REST routes:",
143
- routeLines || "- Every /api/v1/primitives/* media route is available from this editor — see the primitive guidance above for the exact payload and when-to-use of each (image generate/edit/inpaint/render-html/remove-background, video generate/download/trim/probe/concat/mute/normalize/extract-audio/extract-frame/replace-audio/render-slides/remove-captions, media dedupe, audio speech/transcribe/captions/regenerate-speech/trim/concat/normalize/probe, timeline render, and brainstorm coldstart/awareness_stages/angles/hooks/product_placement). Call GET /api/v1/primitives to enumerate the live catalog with fields, and GET /api/v1/primitives/jobs/:job_id for status."
158
+ routeLines || "- Every /api/v1/primitives/* media route is available from this editor — see the primitive guidance above for the exact payload and when-to-use of each (image generate/edit/inpaint/render-html/remove-background/remove-background-greenscreen/create-overlay, video generate/download/trim/probe/concat/mute/normalize/extract-audio/extract-frame/replace-audio/render-slides/remove-captions, media dedupe, audio speech/transcribe/captions/regenerate-speech/trim/concat/normalize/probe, timeline render, and brainstorm coldstart/awareness_stages/angles/hooks/product_placement). Call GET /api/v1/primitives to enumerate the live catalog with fields, and GET /api/v1/primitives/jobs/:job_id for status."
144
159
  ].join("\n");
145
160
  }
146
161
  export function buildEditorChatSystemInstruction(input) {
@@ -468,6 +483,7 @@ export function compactEditorChatModelMessages(messages) {
468
483
  // with the packs actually vendored in the repo.
469
484
  export const EDITOR_CHAT_SKILL_PACKS = [
470
485
  { name: "vidfarm-director", summary: "the full director playbook (REST + devcli flows)" },
486
+ { name: "editor-capabilities", summary: "the in-editor copilot's own capability map: editor_action verb catalog, editor_context fields, forking rules, the three-axes (SWAP↔REPLACE) re-theme model, fueling a scenes replace with raws" },
471
487
  { name: "hyperframes", summary: "workflow router for the HyperFrames skill suite" },
472
488
  { name: "hyperframes-cli", summary: "HyperFrames CLI dev loop (init/lint/validate/inspect/snapshot/render)" },
473
489
  { name: "hyperframes-core", summary: "composition HTML contract: data-* timing attrs, tracks, clips, determinism rules" },
@@ -541,14 +557,17 @@ export function readAsyncJobHandoff(output) {
541
557
  };
542
558
  }
543
559
  export function buildAsyncJobHandoffText(input) {
544
- // "Render …" wording is load-bearing: template-editor-chat.tsx emits the
545
- // identical strings client-side and dedupes with a substring check, so any
546
- // wording change here double-prints every queued-job handoff.
560
+ // This wording is load-bearing: template-editor-chat.tsx (buildJobQueuedMessage)
561
+ // emits the identical strings client-side and dedupes with a substring check, so
562
+ // any change here MUST be mirrored there verbatim or every handoff double-prints.
563
+ // Kept job-KIND-agnostic ("job", not "render") because this fires for ANY queued
564
+ // async job — image/video generation, transcription, render, etc. — and calling
565
+ // an image-generation job a "render" confused users and the model alike.
547
566
  if (!input.isNewQueuedJob) {
548
- return `Render \`${input.jobId}\` is ${input.status}.`;
567
+ return `Job \`${input.jobId}\` is ${input.status}.`;
549
568
  }
550
569
  return [
551
- `Started render \`${input.jobId}\`${input.tracer ? ` with tracer \`${input.tracer}\`` : ""}.`,
570
+ `Started job \`${input.jobId}\`${input.tracer ? ` with tracer \`${input.tracer}\`` : ""}.`,
552
571
  "If it takes a while, ask for an update later and I can check it for you."
553
572
  ].join("\n\n");
554
573
  }