@mevdragon/vidfarm-devcli 0.20.4 → 0.20.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -114,8 +114,10 @@ Make a video in one command (orchestrates ingest → decompose → fork):
114
114
  Composition lifecycle (fork → decompose → snapshot → render):
115
115
  fork <template_id> Fork a template into an editable composition → POST /api/v1/compositions
116
116
  pull <forkId> Sync composition.html/json + video-context → GET .../compositions/:forkId/{composition.html,...}
117
- + cast.json to disk, then print timeline gaps
118
- (blank space) and scene/layer keys to target.
117
+ + cast.json to disk, write a local agent brief
118
+ (.harness/context.json + .harness/agent-guide.md),
119
+ then print
120
+ timeline gaps (blank space) and scene/layer keys.
119
121
  --dir <path> Local dir (default: .vidfarm/<forkId>)
120
122
  --refetch Overwrite local copies with the cloud version
121
123
  decompose <forkId> Split the fork's source into scenes (smart) → POST .../compositions/:forkId/auto-decompose
@@ -1376,6 +1378,129 @@ async function fetchCompositionFiles(input) {
1376
1378
  console.log(`[vidfarm] fetched ${filename} (${body.length} bytes)`);
1377
1379
  }
1378
1380
  }
1381
+ function readJsonFile(filePath) {
1382
+ if (!existsSync(filePath))
1383
+ return null;
1384
+ try {
1385
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
1386
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
1387
+ }
1388
+ catch {
1389
+ return null;
1390
+ }
1391
+ }
1392
+ function readJsonString(value) {
1393
+ return typeof value === "string" ? value.trim() : "";
1394
+ }
1395
+ function readJsonStringList(value, max = 8) {
1396
+ if (!Array.isArray(value))
1397
+ return [];
1398
+ const out = [];
1399
+ for (const entry of value) {
1400
+ const text = readJsonString(entry);
1401
+ if (text && !out.includes(text))
1402
+ out.push(text);
1403
+ if (out.length >= max)
1404
+ break;
1405
+ }
1406
+ return out;
1407
+ }
1408
+ function writeLocalAgentHarnessFiles(dir) {
1409
+ const videoContext = readJsonFile(path.join(dir, "video-context.json"));
1410
+ const editorHarness = readJsonFile(path.join(dir, "editor-harness.json"));
1411
+ const sceneAnnotations = readJsonFile(path.join(dir, "scene-annotations.json"));
1412
+ const cast = readJsonFile(path.join(dir, "cast.json"));
1413
+ const viralDna = videoContext && readJsonString(videoContext.status) === "ready" && videoContext.viral_dna && typeof videoContext.viral_dna === "object"
1414
+ ? videoContext.viral_dna
1415
+ : null;
1416
+ const harness = editorHarness && readJsonString(editorHarness.status) === "ready" && editorHarness.harness && typeof editorHarness.harness === "object"
1417
+ ? editorHarness.harness
1418
+ : null;
1419
+ const annotations = sceneAnnotations && readJsonString(sceneAnnotations.status) === "done" && Array.isArray(sceneAnnotations.annotations)
1420
+ ? sceneAnnotations.annotations
1421
+ : [];
1422
+ const castMembers = cast && Array.isArray(cast.cast) ? cast.cast : [];
1423
+ const harnessDir = path.join(dir, ".harness");
1424
+ mkdirSync(harnessDir, { recursive: true });
1425
+ const contextPath = path.join(harnessDir, "context.json");
1426
+ const instructionsPath = path.join(harnessDir, "agent-guide.md");
1427
+ const bundle = {
1428
+ generated_by: "vidfarm-devcli",
1429
+ generated_at: new Date().toISOString(),
1430
+ source_title: readJsonString(videoContext?.summary) || null,
1431
+ composition_context: viralDna,
1432
+ editor_harness: harness,
1433
+ scenes: Array.isArray(videoContext?.scenes) ? videoContext?.scenes : [],
1434
+ scene_annotations: annotations,
1435
+ cast: castMembers,
1436
+ transcript: videoContext?.transcript ?? null
1437
+ };
1438
+ writeFileSync(contextPath, JSON.stringify(bundle, null, 2));
1439
+ const emotionalPunch = viralDna?.emotional_punch && typeof viralDna.emotional_punch === "object"
1440
+ ? viralDna.emotional_punch
1441
+ : null;
1442
+ const importantScenes = readJsonStringList(harness?.important_scenes, 6);
1443
+ const editingBias = readJsonStringList(harness?.editing_bias, 6);
1444
+ const doList = readJsonStringList(harness?.do, 5);
1445
+ const dontList = readJsonStringList(harness?.dont, 5);
1446
+ const preserve = readJsonStringList(emotionalPunch?.preserve, 5);
1447
+ const mustPreserve = annotations
1448
+ .flatMap((entry) => readJsonStringList(entry.must_preserve, 3).map((item) => {
1449
+ const slug = readJsonString(entry.scene_slug);
1450
+ return slug ? `${slug}: ${item}` : item;
1451
+ }))
1452
+ .slice(0, 8);
1453
+ const agentMd = `# Vidfarm Local Harness
1454
+
1455
+ Read \`.harness/context.json\` before making substantial edits. This fork was pulled from Vidfarm with decompose context; do not freestyle the rewrite from \`composition.html\` alone.
1456
+
1457
+ Files to use:
1458
+ - \`video-context.json\`: transcript, scene descriptions, and viral DNA.
1459
+ - \`editor-harness.json\`: the technical editing brief under \`harness\` (pace, typography, audio, scene roles).
1460
+ - \`scene-annotations.json\`: per-scene replacement and regeneration DNA.
1461
+ - \`.harness/context.json\`: merged agent-facing snapshot generated by \`vidfarm pull\`.
1462
+ - \`.harness/agent-guide.md\`: Vidfarm-specific local scripting guidance generated by \`vidfarm pull\`.
1463
+
1464
+ Rules:
1465
+ - Treat the viral DNA and editor harness as hard defaults unless the user explicitly overrides them.
1466
+ - If you rewrite text, make it fit the existing performance and joke mechanic. Do not replace a format-specific bit with generic product-benefit copy.
1467
+ - Preserve the emotional punch first: tone, mechanism, contrast, audio/visual tension, delivery, and preserve beats.
1468
+ - Protect any harness scene marked critical/must_keep and the must_preserve notes from scene annotations.
1469
+ - When swapping visuals, match both the literal scene DNA and the narrative purpose of the beat.
1470
+
1471
+ Current format snapshot:
1472
+ - Trend tagline: ${readJsonString(viralDna?.trend_tagline) || "unknown"}
1473
+ - Hook: ${readJsonString(viralDna?.hook) || "unknown"}
1474
+ - Payoff: ${readJsonString(viralDna?.payoff) || "unknown"}
1475
+ - Emotional core: ${readJsonString(emotionalPunch?.core_emotion) || "unknown"}
1476
+ - Tone: ${readJsonString(emotionalPunch?.tone) || "unknown"}
1477
+ - Mechanism: ${readJsonString(emotionalPunch?.mechanism) || "unknown"}
1478
+ - Contrast: ${readJsonString(emotionalPunch?.contrast) || "unknown"}
1479
+ - Audio/visual tension: ${readJsonString(emotionalPunch?.audio_visual_tension) || "unknown"}
1480
+ - Delivery: ${readJsonString(emotionalPunch?.delivery) || "unknown"}
1481
+ - Harness one-liner: ${readJsonString(harness?.one_liner) || "unknown"}
1482
+
1483
+ Priority preserve beats:
1484
+ ${preserve.length ? preserve.map((item) => `- ${item}`).join("\n") : "- none recorded"}
1485
+
1486
+ Important scenes:
1487
+ ${importantScenes.length ? importantScenes.map((item) => `- ${item}`).join("\n") : "- none recorded"}
1488
+
1489
+ Scene-level must-preserve cues:
1490
+ ${mustPreserve.length ? mustPreserve.map((item) => `- ${item}`).join("\n") : "- none recorded"}
1491
+
1492
+ Editing bias:
1493
+ ${editingBias.length ? editingBias.map((item) => `- ${item}`).join("\n") : "- none recorded"}
1494
+
1495
+ Do:
1496
+ ${doList.length ? doList.map((item) => `- ${item}`).join("\n") : "- none recorded"}
1497
+
1498
+ Don't:
1499
+ ${dontList.length ? dontList.map((item) => `- ${item}`).join("\n") : "- none recorded"}
1500
+ `;
1501
+ writeFileSync(instructionsPath, agentMd);
1502
+ return { contextPath, instructionsPath };
1503
+ }
1379
1504
  async function fetchVideoAssets(input) {
1380
1505
  const refs = readSourceRefs(input.dir);
1381
1506
  if (!refs.compositionSourceUrl && !refs.originalSourceUrl) {
@@ -4439,12 +4564,24 @@ async function runPullCommand(argv) {
4439
4564
  if (!existsSync(htmlPath))
4440
4565
  throw new Error(`pull did not fetch composition.html for ${forkId} (check credentials / fork id).`);
4441
4566
  const info = inspectComposition(readFileSync(htmlPath, "utf8"));
4567
+ const harnessFiles = writeLocalAgentHarnessFiles(dir);
4442
4568
  const hasContext = existsSync(path.join(dir, "video-context.json"));
4443
4569
  const hasCast = existsSync(path.join(dir, "cast.json"));
4444
4570
  const hasAnnotations = existsSync(path.join(dir, "scene-annotations.json"));
4445
4571
  const hasHarness = existsSync(path.join(dir, "editor-harness.json"));
4446
4572
  if (ctx.json) {
4447
- printJson({ ok: true, fork_id: forkId, dir, has_video_context: hasContext, has_cast: hasCast, has_scene_annotations: hasAnnotations, has_editor_harness: hasHarness, ...info });
4573
+ printJson({
4574
+ ok: true,
4575
+ fork_id: forkId,
4576
+ dir,
4577
+ has_video_context: hasContext,
4578
+ has_cast: hasCast,
4579
+ has_scene_annotations: hasAnnotations,
4580
+ has_editor_harness: hasHarness,
4581
+ harness_context_path: harnessFiles.contextPath,
4582
+ harness_instructions_path: harnessFiles.instructionsPath,
4583
+ ...info
4584
+ });
4448
4585
  return;
4449
4586
  }
4450
4587
  console.log(`${GREEN}Pulled ${forkId} → ${dir}${RESET}`);
@@ -4456,6 +4593,7 @@ async function runPullCommand(argv) {
4456
4593
  console.log(` ${layer.key} ${DIM}${layer.kind ?? "?"} ${layer.start}-${(layer.start + layer.duration).toFixed(2)}s track${layer.track}${flags ? ` [${flags}]` : ""}${layer.slug ? ` slug=${layer.slug}` : ""}${RESET}`);
4457
4594
  }
4458
4595
  console.log(`${DIM}Grounding: ${hasContext ? "video-context.json ✓" : "video-context.json ✗ (run `vidfarm decompose`)"}, ${hasCast ? "cast.json ✓" : "cast.json ✗"}, ${hasAnnotations ? "scene-annotations.json ✓" : "scene-annotations.json ✗"}, ${hasHarness ? "editor-harness.json ✓ (style brief under .harness)" : "editor-harness.json ✗"}.${RESET}`);
4596
+ console.log(`${DIM}Agent brief: ${path.relative(dir, harnessFiles.instructionsPath) || path.basename(harnessFiles.instructionsPath)}; merged context: ${path.relative(dir, harnessFiles.contextPath)}.${RESET}`);
4459
4597
  console.log(`${DIM}Next: vidfarm generate video --prompt "..." --aspect-ratio ${info.aspect_ratio ?? "9:16"} --place ${dir} --at <gap start>|--replace <layer_key>${RESET}`);
4460
4598
  }
4461
4599
  async function runVisibilityCommand(argv) {
@@ -117,7 +117,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
117
117
  "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.",
118
118
  "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.",
119
119
  "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.",
120
- "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) plus emotional_punch (core_emotion, tone, arc, peak_moment, mechanism, humor, delivery, preserve) — the FEELING the format delivers and what makes it land. 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. emotional_punch is the part a remix most often flattens: if the format is a joke, keep emotional_punch.humor's mechanic (rebuild the SAME joke around the new subject, don't drop it for a feature bullet); match emotional_punch.tone and emotional_punch.delivery so intonation, comedic timing, and vibe survive; and never trade the peak_moment's payoff for a flat product plug. 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.",
120
+ "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) plus emotional_punch (core_emotion, tone, arc, peak_moment, mechanism, contrast, humor, audio_visual_tension, delivery, preserve) — the FEELING the format delivers and what makes it land. 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. emotional_punch is the part a remix most often flattens: if the format is a joke, keep emotional_punch.humor's mechanic (rebuild the SAME joke around the new subject, don't drop it for a feature bullet); preserve emotional_punch.contrast when the bit depends on a mismatch (for example tough soundtrack vs delicate visuals, swagger vs mundane setting); use emotional_punch.audio_visual_tension when audio and visuals intentionally play against each other; match emotional_punch.tone and emotional_punch.delivery so intonation, comedic timing, and vibe survive; and never trade the peak_moment's payoff for a flat product plug. 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.",
121
121
  "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.",
122
122
  "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).",
123
123
  "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.",
@@ -1550,7 +1550,9 @@ Return strictly valid JSON matching this schema:
1550
1550
  "arc": string,
1551
1551
  "peak_moment": string,
1552
1552
  "mechanism": string,
1553
+ "contrast": string,
1553
1554
  "humor": string,
1555
+ "audio_visual_tension": string,
1554
1556
  "delivery": string,
1555
1557
  "preserve": [string, ...]
1556
1558
  },
@@ -1598,10 +1600,12 @@ VIRAL_DNA RULES:
1598
1600
  - "arc": how the feeling MOVES across the runtime (e.g. "curiosity → mounting tension → relief-laugh", "flat setup → sudden absurd turn").
1599
1601
  - "peak_moment": the single beat that lands the emotional hit — the punchline, the reveal, the gut-punch, the beat-drop — and roughly when.
1600
1602
  - "mechanism": WHY it lands — the technique doing the work (comedic timing, irony, subversion of expectation, escalation, deadpan intonation, relatability, a trending-sound drop synced to the visual). Weigh the AUDIO here: on TikTok/Reels the trending sound or music bed is frequently the single biggest driver of the emotional hit, so if a recognizable trending sound, a beat drop, or a music swell is doing the work, name it as the mechanism.
1601
- - "humor": if it's funny, state what the JOKE actually is and why it works, concretely enough that a remixer can rebuild the same joke with new subject matter; write "none" if the format is not comedic.
1603
+ - "contrast": the specific mismatch / juxtaposition that creates the feeling. This is CRITICAL for irony- or sass-driven formats: identify the actual tension, not just the visible gesture. Examples: "hard, macho soundtrack over a dainty office-laptop scene", "overconfident delivery about a trivial preference", "luxury attitude applied to a mundane workflow". If there is no real mismatch, write "none".
1604
+ - "humor": if it's funny, state what the JOKE actually is and why it works, concretely enough that a remixer can rebuild the same joke with new subject matter; write "none" if the format is not comedic. Focus on the underlying bit, not surface choreography.
1605
+ - "audio_visual_tension": describe how the SOUND and VISUALS interact emotionally: whether the soundtrack / vocal cadence reinforces the visuals, undercuts them, or creates a deliberate mismatch. Name the exact pairing if that's the joke or swagger engine; write "none" only if audio contributes nothing.
1602
1606
  - "delivery": the performance cues to preserve so the vibe survives — speech intonation / cadence (from the AUDIO TRANSCRIPT), the pause held before the punchline, a facial beat or reaction, and especially the SOUND: the music / trending-audio bed, where the beat drop or lyric hit lands relative to the cut, a music swell, an sfx sting, the deadpan-then-cut. This is what keeps intonation, comedic timing, and the audio-driven vibe from being lost in a remix.
1603
1607
  - "preserve": 2-4 emotional beats a remix MUST keep to reproduce the same feeling (e.g. "keep the beat drop landing exactly on the reveal cut", "hold the silent beat before the reveal", "keep the flat unbothered delivery", "the payoff must undercut the buildup, not confirm it").
1604
- Use BOTH the frames and the AUDIO TRANSCRIPT block above. Never leave emotional_punch empty — infer the feeling from the frames, captions, and pacing even when there is no audio, and when the audio is music-only, treat the sound itself as the emotional engine.
1608
+ Use BOTH the frames and the AUDIO TRANSCRIPT block above. Never leave emotional_punch empty — infer the feeling from the frames, captions, and pacing even when there is no audio, and when the audio is music-only, treat the sound itself as the emotional engine. Do NOT over-index on a single gesture or hand movement if the real emotional engine is the character attitude, the soundtrack choice, the setting mismatch, or the audio-vs-visual contrast.
1605
1609
  - "preserve": 3-5 concrete elements a remixer MUST keep to preserve the format (e.g., "Pinned comment stays on-screen the whole video", "Wedding dance payoff shot").
1606
1610
  - "avoid": 3-5 things that would break the format if changed (e.g., "Do not remove the on-screen text overlay", "Do not slow the cuts below 1.5s").
1607
1611
  - "promotions": 3-5 categories of product / service / offer this exact format could be reused to promote, WITHOUT changing the beat structure or hook mechanic. Each item is a concrete pitchable use-case, not a genre. Examples: "SaaS free-trial signup (before/after workflow)", "Fitness challenge (7-day transformation)", "Local restaurant grand-opening (line-out-the-door proof)", "Ebook / course launch (result-first payoff)", "Real-estate open-house (walk-through reveal)". Anchor each promotion to the same viral tactic captured in "hook" — do not invent unrelated verticals.
@@ -1781,7 +1785,16 @@ function resolveFontSize(input) {
1781
1785
  return normalizeFontSize(input.fontSize, input.canvasHeight);
1782
1786
  }
1783
1787
  const DEFAULT_EMOTIONAL_PUNCH = {
1784
- core_emotion: "", tone: "", arc: "", peak_moment: "", mechanism: "", humor: "", delivery: "", preserve: []
1788
+ core_emotion: "",
1789
+ tone: "",
1790
+ arc: "",
1791
+ peak_moment: "",
1792
+ mechanism: "",
1793
+ contrast: "",
1794
+ humor: "",
1795
+ audio_visual_tension: "",
1796
+ delivery: "",
1797
+ preserve: []
1785
1798
  };
1786
1799
  export const DEFAULT_VIRAL_DNA = {
1787
1800
  trend_tagline: "", hook: "", retention: "", payoff: "", emotional_punch: { ...DEFAULT_EMOTIONAL_PUNCH }, preserve: [], avoid: [], promotions: [], keywords: []
@@ -1800,7 +1813,9 @@ function normalizeEmotionalPunch(value) {
1800
1813
  arc: asString(record.arc ?? record.emotional_arc),
1801
1814
  peak_moment: asString(record.peak_moment ?? record.peakMoment),
1802
1815
  mechanism: asString(record.mechanism),
1816
+ contrast: asString(record.contrast),
1803
1817
  humor: asString(record.humor),
1818
+ audio_visual_tension: asString(record.audio_visual_tension ?? record.audioVisualTension),
1804
1819
  delivery: asString(record.delivery),
1805
1820
  preserve: asStringArray(record.preserve).slice(0, 4)
1806
1821
  };
@@ -1865,12 +1880,26 @@ function buildEditorHarnessPrompt(input) {
1865
1880
  const roleVocab = SCENE_ROLE_VOCAB.join(" | ");
1866
1881
  const formatVocab = CONTENT_FORMATS.join(" | ");
1867
1882
  const audioDigest = buildAudioDigestForPrompt(input.transcript ?? null);
1883
+ const emotional = input.viralDna?.emotional_punch;
1884
+ const emotionalSnapshot = emotional && [
1885
+ emotional.core_emotion,
1886
+ emotional.tone,
1887
+ emotional.arc,
1888
+ emotional.peak_moment,
1889
+ emotional.mechanism,
1890
+ emotional.contrast,
1891
+ emotional.humor,
1892
+ emotional.audio_visual_tension,
1893
+ emotional.delivery
1894
+ ].some((value) => value && value.trim().length > 0)
1895
+ ? `\nVIRAL DNA FEELING SNAPSHOT (operationalize this in the harness; do NOT drift from it):\n- core_emotion: ${emotional.core_emotion || "—"}\n- tone: ${emotional.tone || "—"}\n- arc: ${emotional.arc || "—"}\n- peak_moment: ${emotional.peak_moment || "—"}\n- mechanism: ${emotional.mechanism || "—"}\n- contrast: ${emotional.contrast || "—"}\n- humor: ${emotional.humor || "—"}\n- audio_visual_tension: ${emotional.audio_visual_tension || "—"}\n- delivery: ${emotional.delivery || "—"}\n- preserve: ${(emotional.preserve || []).join("; ") || "—"}\n`
1896
+ : "";
1868
1897
  return `You are Vidfarm's EDITING DIRECTOR. You are studying a short-form video to write an EDITOR HARNESS — a compact, technical brief that lets another AI editor recreate this video's STYLE (not its exact content) in one clean pass on a timeline editor (scenes, captions, audio, transitions, keyframes).
1869
1898
 
1870
1899
  This is DIFFERENT from "viral DNA" (why the video works / what to preserve). The harness is HOW to edit: pace, typography, b-roll usage, transitions, audio bed, and the role each scene plays. Be concrete and directive — every field should be something an editor can act on, not vibes.
1871
1900
 
1872
1901
  Video duration: ${input.durationSeconds.toFixed(2)} seconds.
1873
- ${frameListing}${cutListing}${audioDigest}${promptHint}
1902
+ ${frameListing}${cutListing}${audioDigest}${emotionalSnapshot}${promptHint}
1874
1903
  Return strictly valid JSON matching this schema:
1875
1904
  {
1876
1905
  "format": string, // one of: ${formatVocab}
@@ -1933,7 +1962,7 @@ RULES:
1933
1962
  - Ground pacing numbers on the detected cuts + duration when cuts are supplied; otherwise estimate from the frames. Round avg_scene_seconds and cuts_per_10s to at most 2 decimals.
1934
1963
  - "scenes": 3-8 entries covering the whole runtime in order, each tied to a real moment you see. Reserve importance "critical" for the 1-3 beats that actually carry the style/retention (usually the hook + payoff).
1935
1964
  - Prefer the caption_style / transition / shot_kind vocabulary above so the editor can map straight to presets; a free-form label is fine when nothing fits.
1936
- - "emotional": this is HOW to edit so the FEELING survives — the vibe, the joke, the intonation, and the SOUND are the first things a remix flattens, so make these directive. Use the AUDIO TRANSCRIPT block: on TikTok/Reels the trending sound / music bed is often the biggest carrier of the feeling. "target_feeling" = the emotion the recreated edit must produce; "tone" = the register to hold; "comedic_timing" = the exact editing move that sells the joke (the beat held before a cut, the deadpan-then-punch, the sfx sting or beat-drop on the button word) or "none"; "intonation" = the vocal delivery/cadence to preserve on any VO or on-cam speech (or "none" if silent) — flag it if the speech must be re-voiced in the SAME cadence; "vibe_anchors" = 2-4 concrete edit choices that carry the emotion, and include the audio ones (e.g. "keep the trending sound and land the beat drop on the reveal cut", "swell the music on the payoff", "hold a full beat of silence before the reveal cut", "cut hard on the punch word"). Infer the feeling from frames + pacing even without audio, and when the audio is music-only treat the sound as the emotional engine; never leave it empty.
1965
+ - "emotional": this is HOW to edit so the FEELING survives — the vibe, the joke, the intonation, and the SOUND are the first things a remix flattens, so make these directive. Use the AUDIO TRANSCRIPT block: on TikTok/Reels the trending sound / music bed is often the biggest carrier of the feeling. "target_feeling" = the emotion the recreated edit must produce; "tone" = the register to hold; "comedic_timing" = the exact editing move that sells the joke (the beat held before a cut, the deadpan-then-punch, the sfx sting or beat-drop on the button word) or "none"; "intonation" = the vocal delivery/cadence to preserve on any VO or on-cam speech (or "none" if silent) — flag it if the speech must be re-voiced in the SAME cadence; "vibe_anchors" = 2-4 concrete edit choices that carry the emotion, and include the audio ones (e.g. "keep the trending sound and land the beat drop on the reveal cut", "swell the music on the payoff", "hold a full beat of silence before the reveal cut", "cut hard on the punch word"). When the viral-DNA snapshot above names a contrast or audio_visual_tension, convert that into actionable edit moves instead of drifting to superficial gestures. Infer the feeling from frames + pacing even without audio, and when the audio is music-only treat the sound as the emotional engine; never leave it empty.
1937
1966
  - Keep every string tight and directive. Never invent audio you cannot infer — say "none"/"unknown" instead.
1938
1967
  - Output ONLY the JSON object.`;
1939
1968
  }
@@ -2030,7 +2059,8 @@ async function runEditorHarnessPass(input) {
2030
2059
  frameTimestamps: input.frames.map((f) => f.timestamp),
2031
2060
  sceneCuts: input.sceneCuts,
2032
2061
  userPrompt: input.userPrompt ?? null,
2033
- transcript: input.transcript ?? null
2062
+ transcript: input.transcript ?? null,
2063
+ viralDna: input.viralDna ?? null
2034
2064
  });
2035
2065
  const attempt = await callVisionWithFallback({ prompt, frames: input.frames, keys: input.keys });
2036
2066
  return normalizeEditorHarness(attempt.result);
@@ -2437,13 +2467,12 @@ export async function smartDecomposeVideo(input) {
2437
2467
  sceneCuts,
2438
2468
  transcript
2439
2469
  });
2440
- // Phase 2 — vision, OCR, product-placement, and the editor harness run
2441
- // concurrently, all now transcript-aware. OCR and placement failures are
2442
- // non-fatal. The placement + harness calls are DELIBERATELY separate
2443
- // requests from the caption/scene vision call so they can never dilute
2444
- // timeline-layer quality they reuse the same sparse frames (no extra
2445
- // ffmpeg) plus the transcript from phase 1.
2446
- const [attempt, ocrFrames, placementSurfaces, editorHarness] = await Promise.all([
2470
+ // Phase 2 — vision, OCR, and product-placement run concurrently, all now
2471
+ // transcript-aware. OCR and placement failures are non-fatal. The
2472
+ // placement call is DELIBERATELY separate from the caption/scene vision
2473
+ // call so it can never dilute timeline-layer quality it reuses the same
2474
+ // sparse frames (no extra ffmpeg) plus the transcript from phase 1.
2475
+ const [attempt, ocrFrames, placementSurfaces] = await Promise.all([
2447
2476
  callVisionWithFallback({
2448
2477
  prompt,
2449
2478
  frames,
@@ -2461,19 +2490,6 @@ export async function smartDecomposeVideo(input) {
2461
2490
  }).catch((err) => {
2462
2491
  console.error("smart decompose placement pass failed", err instanceof Error ? err.message : err);
2463
2492
  return [];
2464
- }),
2465
- // Editor harness — technical editing direction. Isolated call in the same
2466
- // burst (reuses these frames + the detected cuts + the transcript); non-fatal.
2467
- runEditorHarnessPass({
2468
- frames,
2469
- keys: input.keys,
2470
- durationSeconds: meta.durationSeconds,
2471
- sceneCuts,
2472
- userPrompt: input.userPrompt ?? null,
2473
- transcript
2474
- }).catch((err) => {
2475
- console.error("smart decompose editor-harness pass failed", err instanceof Error ? err.message : err);
2476
- return DEFAULT_EDITOR_HARNESS;
2477
2493
  })
2478
2494
  ]);
2479
2495
  devLog("hyperframes.smart_decompose.ocr_completed", {
@@ -2559,6 +2575,21 @@ export async function smartDecomposeVideo(input) {
2559
2575
  if (input.trendTaglineHint && input.trendTaglineHint.trim().length > 0) {
2560
2576
  viralDna.trend_tagline = input.trendTaglineHint.trim();
2561
2577
  }
2578
+ // Editor harness — technical editing direction. Run AFTER viral DNA is
2579
+ // normalized so the harness can operationalize the exact emotional punch
2580
+ // instead of guessing independently and drifting from the decompose result.
2581
+ const editorHarness = await runEditorHarnessPass({
2582
+ frames,
2583
+ keys: input.keys,
2584
+ durationSeconds: meta.durationSeconds,
2585
+ sceneCuts,
2586
+ userPrompt: input.userPrompt ?? null,
2587
+ transcript,
2588
+ viralDna
2589
+ }).catch((err) => {
2590
+ console.error("smart decompose editor-harness pass failed", err instanceof Error ? err.message : err);
2591
+ return DEFAULT_EDITOR_HARNESS;
2592
+ });
2562
2593
  // NOTE: the per-scene clip-annotation + content-format pass is deliberately
2563
2594
  // NOT run here — it is a separate vision call that would risk the ~60s origin
2564
2595
  // timeout on longer videos. /auto-decompose seeds scene-annotations.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.20.4",
3
+ "version": "0.20.5",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,7 @@
32
32
  "scripts": {
33
33
  "dev": "tsx --import ./src/instrument.ts watch src/index.ts",
34
34
  "dev:frontend": "node scripts/build-homepage-client.mjs --watch",
35
- "dev:cli": "tsx src/cli.ts --help",
35
+ "dev:cli": "tsx src/cli.ts",
36
36
  "build:frontend": "node scripts/build-homepage-client.mjs",
37
37
  "build:hyperframes-editor": "cd demo && node scripts/build.mjs",
38
38
  "operator:template-artifact": "node scripts/platform-operator-template-artifact.mjs",