@mevdragon/vidfarm-devcli 0.20.2 → 0.20.3

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.
@@ -142,9 +142,10 @@ You usually already know a clip's aspect from how you sourced it (a hunted raw's
142
142
  - `transitions` — `default`/`intro`/`outro` (map to one `set_transitions` call), `usage`.
143
143
  - `broll` — `reliance`, `sourcing`, `shot_kinds`, `cadence`. Drives whether you HUNT raws (`/clips/scan`) vs `generate_layer`, and at what cadence.
144
144
  - `audio` — `voiceover`, `music`, `sfx`, `captions_from`.
145
+ - `emotional` — HOW to edit so the FEELING survives: `target_feeling`, `tone`, `comedic_timing` (the beat/pause/hard-cut that sells the joke), `intonation` (vocal cadence to preserve when you re-voice), `vibe_anchors` (the concrete edit moves that carry the emotion — often "keep the trending sound and land the beat drop on the reveal cut"). The vibe/joke/intonation/sound are the first things a remix flattens, so treat these as must-dos.
145
146
  - `scenes[]` — each beat's `role` + `importance` + `must_keep` + `edit_bias`. `important_scenes`, `editing_bias`, `do`, `dont`.
146
147
 
147
- **How to use it:** map `caption_style` → `set_captions`, `transitions.*` → `set_transitions`, honor `pacing`, follow `broll` for footage sourcing, lay `audio`. **PROTECT** any beat in `important_scenes` or with `must_keep:true` / `importance:"critical"` — swap its subject if needed but preserve its timing, role, and caption cadence; that's where the style lives. Treat `editing_bias`/`do`/`dont` as hard constraints for this composition. It sets defaults, not a straitjacket — an explicit user instruction wins. **In local devcli** the same brief is on disk as `editor-harness.json` (under `.harness`, pulled by `vidfarm pull`/`sync`); read it there before a big edit. If `editor_harness` is absent, the fork wasn't decomposed with the harness pass — fall back to `composition_context` + the `video_context` tool.
148
+ **How to use it:** map `caption_style` → `set_captions`, `transitions.*` → `set_transitions`, honor `pacing`, follow `broll` for footage sourcing, lay `audio`, and honor `emotional` (preserve `comedic_timing`, keep `intonation` when re-voicing narration, execute every `vibe_anchors` entry). **PROTECT** any beat in `important_scenes` or with `must_keep:true` / `importance:"critical"` — swap its subject if needed but preserve its timing, role, and caption cadence; that's where the style lives. Treat `editing_bias`/`do`/`dont` as hard constraints for this composition. It sets defaults, not a straitjacket — an explicit user instruction wins. **In local devcli** the same brief is on disk as `editor-harness.json` (under `.harness`, pulled by `vidfarm pull`/`sync`); read it there before a big edit. If `editor_harness` is absent, the fork wasn't decomposed with the harness pass — fall back to `composition_context` + the `video_context` tool.
148
149
 
149
150
  ## Web-editor motion rule (hard constraint)
150
151
 
package/SKILL.director.md CHANGED
@@ -200,7 +200,7 @@ Content-Type: application/json
200
200
 
201
201
  Two modes:
202
202
 
203
- - **`smart`** — uses the caller's Gemini/OpenAI/OpenRouter key. Samples frames, extracts scenes with labels, literal visual **descriptions**, and viral notes, extracts on-screen captions with positions, **transcribes the audio track verbatim** (timestamped segments; Gemini first, OpenAI Whisper fallback; skipped when the video has no audio), and detects viral DNA (hook, retention, payoff). Takes 30-60 seconds. Auto-fires GhostCut for subtitle removal in the background.
203
+ - **`smart`** — uses the caller's Gemini/OpenAI/OpenRouter key. Samples frames, extracts scenes with labels, literal visual **descriptions**, and viral notes, extracts on-screen captions with positions, **transcribes the audio track verbatim** (timestamped segments; Gemini first, OpenAI Whisper fallback; skipped when the video has no audio), and detects viral DNA (hook, retention, payoff, plus **`emotional_punch`** — the vibe/joke/intonation that makes it FEEL, reasoned over BOTH the frames and the transcript so the trending sound and vocal delivery are captured, not just the visuals). Takes 30-60 seconds. Auto-fires GhostCut for subtitle removal in the background.
204
204
  - **`time-slice`** — deterministic equal-duration split. Instant. No AI provider needed.
205
205
 
206
206
  **Source length cap.** Vidfarm caps auto-decompose (and all inspiration ingest) at **120 seconds** of source video. If the source is longer, the API responds with `400 { ok: false, code: "source_too_long", duration_seconds, max_duration_seconds }` and no scenes are written. Trim the source before forking / ingesting.
@@ -239,12 +239,25 @@ Read-only state (does **not** advance the job or bill): `GET /api/v1/composition
239
239
  "viral_note": "...",
240
240
  "transcript_excerpt": "what is spoken during this scene"
241
241
  }],
242
- "viral_dna": { ... }
242
+ "viral_dna": {
243
+ "trend_tagline": "...", "hook": "...", "retention": "...", "payoff": "...",
244
+ "emotional_punch": {
245
+ "core_emotion": "smug relief-laugh", "tone": "deadpan comedic",
246
+ "arc": "flat setup → mounting absurdity → sudden turn", "peak_moment": "the reveal at ~0:07",
247
+ "mechanism": "subversion + trending-sound beat drop synced to the cut",
248
+ "humor": "the joke is X — it works because Y (rebuild the SAME joke around a new subject)",
249
+ "delivery": "flat VO cadence, hold the beat before the punch, beat drop lands on the reveal",
250
+ "preserve": ["keep the beat drop on the reveal cut", "keep the flat unbothered delivery"]
251
+ },
252
+ "preserve": ["..."], "avoid": ["..."], "promotions": ["..."], "keywords": ["..."]
253
+ }
243
254
  }
244
255
  ```
245
256
 
246
257
  `status: "none"` means the fork was never smart-decomposed — run `POST /auto-decompose` with `mode: "smart"` first. `transcript` is `null` when the source has no audio track or transcription failed.
247
258
 
259
+ **`viral_dna.emotional_punch`** is the FEELING of the format and what makes it land — the part a remix most often flattens. Where hook/retention/payoff are the mechanical structure, this captures the vibe, the joke, and the intonation. It is now **audio-aware**: the decompose feeds the transcript into the reasoning, so `mechanism`/`delivery` account for the trending sound, the music-bed drop, and vocal cadence (on short-form the sound is frequently the biggest driver of the punch). When you re-theme or remix, rebuild `humor`'s joke around the new subject rather than dropping it, match `tone`/`delivery` so intonation and comedic timing survive, and never trade the `peak_moment` payoff for a flat product plug.
260
+
248
261
  Use it whenever you need to know what the video says or shows: writing/translating captions, matching hooks or dubs to spoken audio, or planning scene-level edits.
249
262
 
250
263
  - **Editor chat (frontend AI)** exposes this as the optional `video_context` tool — the copilot calls it on demand.
@@ -267,13 +280,14 @@ Use it whenever you need to know what the video says or shows: writing/translati
267
280
  "broll": { "reliance": "heavy", "sourcing": "screen-recording", "shot_kinds": ["b_roll"], "cadence": "new visual ~every 2s" },
268
281
  "transitions": { "default": "cut", "intro": "fade", "outro": "none", "usage": "hard cuts only" },
269
282
  "audio": { "voiceover": "first-person narration", "music": "upbeat lofi bed, low", "sfx": "whoosh on cuts", "captions_from": "voiceover" },
283
+ "emotional": { "target_feeling": "smug relief-laugh", "tone": "deadpan comedic", "comedic_timing": "hold a beat, then hard cut on the punch word", "intonation": "flat unbothered VO, no upspeak", "vibe_anchors": ["keep the trending sound, land the beat drop on the reveal cut", "hold silence before the payoff"] },
270
284
  "scenes": [{ "role": "hook", "importance": "critical", "must_keep": true, "note": "...", "edit_bias": "swap subject, keep timing+caption cadence" }],
271
285
  "important_scenes": ["..."], "editing_bias": ["..."], "do": ["..."], "dont": ["..."]
272
286
  }
273
287
  }
274
288
  ```
275
289
 
276
- **Use it to pick concrete moves:** `typography.caption_style` maps to a `set_captions` preset; `transitions.*` to a `set_transitions` call; keep `pacing` (cut rhythm / avg_scene_seconds) when adding or splitting scenes; follow `broll.reliance`/`sourcing` to decide HUNT raws (`/raws/scan`) vs generate; lay `audio.*`. **Protect** any beat in `important_scenes` or with `must_keep:true` / `importance:"critical"` — swap the subject but preserve its timing, role, and caption cadence. Treat `editing_bias`/`do`/`dont` as hard constraints; an explicit user instruction still wins. `status:"none"` → run `POST /auto-decompose` first.
290
+ **Use it to pick concrete moves:** `typography.caption_style` maps to a `set_captions` preset; `transitions.*` to a `set_transitions` call; keep `pacing` (cut rhythm / avg_scene_seconds) when adding or splitting scenes; follow `broll.reliance`/`sourcing` to decide HUNT raws (`/raws/scan`) vs generate; lay `audio.*`. **`emotional`** is HOW to keep the FEELING while you swap the subject — the vibe/joke/intonation/sound are the first things a remix flattens, so honor `emotional.comedic_timing` (the held beat / hard cut that sells the joke), preserve `emotional.intonation` when you re-voice narration, and treat every `emotional.vibe_anchors` entry as a must-do (on short-form the trending sound / beat drop is often the biggest carrier of the punch — keep it and land it on the same cut). **Protect** any beat in `important_scenes` or with `must_keep:true` / `importance:"critical"` — swap the subject but preserve its timing, role, and caption cadence. Treat `editing_bias`/`do`/`dont` as hard constraints; an explicit user instruction still wins. `status:"none"` → run `POST /auto-decompose` first.
277
291
 
278
292
  - **Editor chat (frontend AI)** already receives the harness inline in `editor_context.editor_harness` (no tool call needed); it can also fetch this route via `http_request`.
279
293
  - **Desktop agents (Claude Code / Codex)**: `vidfarm pull` writes `editor-harness.json` to the fork dir (the brief is under `.harness`) alongside `video-context.json`; read it before a big edit. The `pull` grounding line reports whether it's present.
package/SKILL.platform.md CHANGED
@@ -160,7 +160,7 @@ A composition is a single HTML document plus a JSON sidecar.
160
160
  "summary": "...",
161
161
  "scenes": [{ "slug": "hook_reveal", "start": 0, "duration": 1.5, "label": "...", "viral_note": "..." }],
162
162
  "captions": [{ "slug": "caption_1", "text": "...", "start": 0, "duration": 1.5, "x": 5, "y": 60 }],
163
- "viralDna": { "hook": "...", "retention": "...", "payoff": "...", "preserve": [...], "avoid": [...] },
163
+ "viralDna": { "hook": "...", "retention": "...", "payoff": "...", "emotional_punch": { "core_emotion": "...", "tone": "...", "mechanism": "...", "humor": "...", "delivery": "...", "preserve": [...] }, "preserve": [...], "avoid": [...] },
164
164
  "width": 1080,
165
165
  "height": 1920,
166
166
  "durationSeconds": 10.5,
package/dist/src/app.js CHANGED
@@ -77,7 +77,7 @@ import { clipRecords, makeUserPreset } from "./services/clip-records.js";
77
77
  import { matchScenesToClips, searchUserClips, vectorIndex } from "./services/clip-search.js";
78
78
  import { ATTACHMENTS_FOLDER_SCOPE, buildDirectoryPath, DIRECTORY_ROOTS, DIRECTORY_ROOT_KEYS, folderItem, immediateChildFolders, normalizeFolderPath, parseDirectoryPath } from "./services/file-directory.js";
79
79
  import { applyHuntSpecDefaults, BUILTIN_PRESETS, buildClipEmbeddingText, buildEffectiveGuidance, ClipModelClient, cosineSimilarity, detectLocalAgent, effectiveHuntDurationSec, ACTIVE_TAXONOMY_VERSION, estimateScanCostFromDuration, extractAudioClip, extractClip, extractThumbnail, extractWaveformThumbnail, hasFfmpeg, LocalAgentClipClient, normalizeCropFocus, normalizeHuntSpec, parseClipHuntPrompt, pickBestVideoMedia, probeVideo, resolveDurationBand, resolveTargetClipCount, scanVideo } from "./services/clip-curation/index.js";
80
- import { analyzeCastMembers, annotateScenesFromSource, buildBlankCompositionHtml, buildSmartDecomposedHtml, DEFAULT_EDITOR_HARNESS, HyperframesService, smartDecomposeVideo, stampCompositionIdentity } from "./services/hyperframes.js";
80
+ import { analyzeCastMembers, annotateScenesFromSource, buildBlankCompositionHtml, buildSmartDecomposedData, buildSmartDecomposedHtml, DEFAULT_EDITOR_HARNESS, DEFAULT_VIRAL_DNA, HyperframesService, smartDecomposeVideo, stampCompositionIdentity } from "./services/hyperframes.js";
81
81
  import { buildPendingSceneAnnotationsState } from "./services/scene-annotations.js";
82
82
  import { createPrimitiveJobContext } from "./primitive-context.js";
83
83
  import { buildPendingCastState, castMemberFromRaw, extractCastStill, isolateCastMember } from "./services/cast.js";
@@ -8851,6 +8851,7 @@ app.post(`${INSPIRATIONS_PREFIX}/:inspirationId/decompose`, async (c) => {
8851
8851
  result: smart
8852
8852
  });
8853
8853
  await storage.putText(storage.compositionForkWorkingKey(canonicalFork.id, "composition.html"), nextHtml, "text/html; charset=utf-8");
8854
+ await storage.putJson(storage.compositionForkWorkingKey(canonicalFork.id, "composition.json"), buildSmartDecomposedData({ sourceUrl, result: smart }));
8854
8855
  // Persist placement surfaces on the canonical fork too, before the
8855
8856
  // snapshot, so they travel with v1 and seed every downstream fork.
8856
8857
  try {
@@ -9340,6 +9341,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
9340
9341
  }
9341
9342
  const nextHtml = buildSmartDecomposedHtml({ compositionId, sourceUrl, title, result: smart });
9342
9343
  await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), nextHtml, "text/html; charset=utf-8");
9344
+ await storage.putJson(storage.compositionForkWorkingKey(fork.id, "composition.json"), buildSmartDecomposedData({ sourceUrl, result: smart }));
9343
9345
  // Persist the raw smart-decompose result so the async /remove-video-captions-poll
9344
9346
  // handler can rebuild the composition HTML pointing at the cleaned
9345
9347
  // (ghostcut-mirrored) video URL — with the ORIGINAL captions, positions,
@@ -9729,10 +9731,7 @@ async function rebuildDecomposedCompositionAgainstCleanVideo(input) {
9729
9731
  title: persisted.title ?? fork.title ?? fork.id,
9730
9732
  result: {
9731
9733
  summary: persisted.result.summary ?? "",
9732
- viralDna: persisted.result.viralDna ?? {
9733
- trend_tagline: "", hook: "", retention: "", payoff: "",
9734
- preserve: [], avoid: [], promotions: [], keywords: []
9735
- },
9734
+ viralDna: persisted.result.viralDna ?? DEFAULT_VIRAL_DNA,
9736
9735
  transcript: persisted.result.transcript ?? null,
9737
9736
  durationSeconds: Number(persisted.result.durationSeconds ?? 0),
9738
9737
  width: Number(persisted.result.width ?? 720),
@@ -9748,6 +9747,21 @@ async function rebuildDecomposedCompositionAgainstCleanVideo(input) {
9748
9747
  }
9749
9748
  });
9750
9749
  await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), rebuilt, "text/html; charset=utf-8");
9750
+ await storage.putJson(storage.compositionForkWorkingKey(fork.id, "composition.json"), buildSmartDecomposedData({
9751
+ sourceUrl: cleanedSourceUrl,
9752
+ result: {
9753
+ summary: persisted.result.summary ?? "",
9754
+ viralDna: persisted.result.viralDna ?? DEFAULT_VIRAL_DNA,
9755
+ transcript: persisted.result.transcript ?? null,
9756
+ durationSeconds: Number(persisted.result.durationSeconds ?? 0),
9757
+ width: Number(persisted.result.width ?? 720),
9758
+ height: Number(persisted.result.height ?? 1280),
9759
+ scenes: persisted.result.scenes,
9760
+ captions: Array.isArray(persisted.result.captions) ? persisted.result.captions : [],
9761
+ placementSurfaces: [],
9762
+ editorHarness: persisted.result.editorHarness ?? DEFAULT_EDITOR_HARNESS
9763
+ }
9764
+ }));
9751
9765
  await snapshotForkVersion({
9752
9766
  fork,
9753
9767
  callerCustomerId,
@@ -10282,7 +10296,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/scene-annotations-poll`, async (c) => {
10282
10296
  sourceUrl: state.sourceUrl,
10283
10297
  keys,
10284
10298
  scenes,
10285
- viralDna: viralDna ?? { trend_tagline: "", hook: "", retention: "", payoff: "", preserve: [], avoid: [], promotions: [], keywords: [] },
10299
+ viralDna: viralDna ?? DEFAULT_VIRAL_DNA,
10286
10300
  summary,
10287
10301
  transcript,
10288
10302
  userPrompt
package/dist/src/cli.js CHANGED
@@ -1352,9 +1352,12 @@ async function fetchCompositionFiles(input) {
1352
1352
  // literal + viral DNA an agent needs to REPLACE a scene from the clip library
1353
1353
  // or RE-CREATE it from scratch (recreation_prompt + recreation_notes).
1354
1354
  // editor-harness.json carries the technical EDITING DIRECTION (pace,
1355
- // typography, b-roll, transitions, audio, scene roles under `.harness`) so an
1356
- // agent can one-shot a clean, on-style edit — the "how to edit like this"
1357
- // companion to the video-context viral DNA.
1355
+ // typography, b-roll, transitions, audio, scene roles, PLUS an `emotional`
1356
+ // block comedic_timing/intonation/vibe_anchors for keeping the FEELING,
1357
+ // all under `.harness`) so an agent can one-shot a clean, on-style edit — the
1358
+ // "how to edit like this" companion to the video-context viral DNA (whose
1359
+ // `emotional_punch` captures the joke/vibe/intonation and, being decomposed
1360
+ // over the transcript too, the trending sound driving the emotion).
1358
1361
  const files = ["composition.html", "composition.json", "manifest.json", "video-context.json", "cast.json", "scene-annotations.json", "editor-harness.json"];
1359
1362
  for (const filename of files) {
1360
1363
  const target = path.join(input.dir, filename);
@@ -87,7 +87,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
87
87
  "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.",
88
88
  "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.",
89
89
  "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.",
90
- "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.",
90
+ "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); emotional (target_feeling, tone, comedic_timing, intonation, vibe_anchors — HOW to edit so the FEELING survives); 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.*; treat emotional.* as a HARD constraint — the vibe, the joke, and the intonation are the first things a remix flattens, so preserve emotional.comedic_timing (the held beat / pause / hard cut that sells the joke), keep the delivery cadence in emotional.intonation when you rewrite or re-voice narration, and honor every one of emotional.vibe_anchors when you re-cut or re-time; 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.",
91
91
  "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?'",
92
92
  "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.",
93
93
  "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.",
@@ -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). 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.",
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.",
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.",
@@ -1493,6 +1493,28 @@ export async function annotateScenesFromSource(input) {
1493
1493
  await rm(tempDir, { recursive: true, force: true });
1494
1494
  }
1495
1495
  }
1496
+ // The frames handed to the vision passes are SILENT stills — but a short-form
1497
+ // video's emotional punch is often carried by its AUDIO (the trending sound, a
1498
+ // music-bed drop, vocal intonation, a punchline's delivery). This digest folds
1499
+ // the transcript pass into a compact block so the viral-DNA and editor-harness
1500
+ // prompts can reason about the sound, not just the pixels. Music-only clips
1501
+ // (no speech) still get a note so the model infers a trending-sound bed rather
1502
+ // than guessing there is silence.
1503
+ function buildAudioDigestForPrompt(transcript) {
1504
+ if (!transcript) {
1505
+ return `\nAUDIO TRACK: none detected, or transcription unavailable. Judge emotional punch from the visuals, on-screen text, and pacing alone — and do not fabricate spoken lines, music, or a trending sound you cannot infer.\n`;
1506
+ }
1507
+ const text = (transcript.text || "").trim();
1508
+ const clipped = text.length > 1200 ? `${text.slice(0, 1200)} …` : text;
1509
+ const segs = transcript.segments.slice(0, 24)
1510
+ .map((s) => ` [${s.start.toFixed(1)}-${s.end.toFixed(1)}s] ${s.text.trim()}`)
1511
+ .filter((line) => line.trim().length > 0)
1512
+ .join("\n");
1513
+ return `\nAUDIO TRANSCRIPT — the video's spoken/sung audio. The SOUND is a primary driver of emotional punch: read the words, the intonation and cadence they imply, WHERE the delivery lands (the pause before a punchline, an exclamation, a beat-synced line), and infer any music / trending-sound bed even when there are no lyrics.
1514
+ - language: ${transcript.language ?? "unknown"}
1515
+ - full text: ${clipped || "(no speech transcribed — likely a music-only or trending-sound bed; treat the audio as a music/sound-driven format and reason about the beat/drop, not dialogue)"}${segs ? `\n- timed lines:\n${segs}` : ""}
1516
+ `;
1517
+ }
1496
1518
  function buildSmartDecomposePrompt(input) {
1497
1519
  const sceneCutHint = input.sceneCuts && input.sceneCuts.length > 0
1498
1520
  ? `\nDETECTED SCENE CUTS (authoritative — ffmpeg measured hard visual cuts at these timestamps, in seconds):\n${input.sceneCuts.map((t) => ` cut @ ${t.toFixed(2)}s`).join("\n")}\n\nThese are ground truth for where the video visually changes. Align scene boundaries to these cuts: each detected cut should begin a new scene (a scene "start" should equal a detected cut time, within ~0.15s), and do NOT place a scene boundary where there is no detected cut unless the frames clearly show a change the detector missed. If two adjacent cuts are very close and belong to the same beat, you may merge them into one scene, but never exceed 6 scenes.\n`
@@ -1506,12 +1528,13 @@ function buildSmartDecomposePrompt(input) {
1506
1528
  const frameListing = input.frameTimestamps.length > 0
1507
1529
  ? `\nFRAMES SUPPLIED (in order — each image is captioned with its capture timestamp):\n${input.frameTimestamps.map((t, i) => ` frame ${i + 1} @ ${t.toFixed(2)}s`).join("\n")}\n\nUse the frame timestamps to determine caption "start" and "duration". Cross-reference consecutive frames: a caption whose text is identical across frames N and M was on-screen from at least the timestamp of frame N through the timestamp of frame M (extend "duration" accordingly). A caption present in only one frame is short-lived — set duration to roughly (next_frame_timestamp - this_frame_timestamp) or 0.8s, whichever is smaller.\n`
1508
1530
  : "";
1531
+ const audioDigest = buildAudioDigestForPrompt(input.transcript ?? null);
1509
1532
  return `You are decomposing a short-form vertical video (TikTok/Reel) into editable HyperFrames timeline layers.
1510
1533
  ${userGuidance}${trendTaglineHint}
1511
1534
  Video metadata:
1512
1535
  - duration: ${input.durationSeconds.toFixed(3)} seconds
1513
1536
  - dimensions: ${input.width} x ${input.height} (9:16 vertical, canvas is 100% x 100%)
1514
- ${frameListing}${sceneCutHint}
1537
+ ${frameListing}${sceneCutHint}${audioDigest}
1515
1538
 
1516
1539
  Return strictly valid JSON matching this schema:
1517
1540
  {
@@ -1521,6 +1544,16 @@ Return strictly valid JSON matching this schema:
1521
1544
  "hook": string,
1522
1545
  "retention": string,
1523
1546
  "payoff": string,
1547
+ "emotional_punch": {
1548
+ "core_emotion": string,
1549
+ "tone": string,
1550
+ "arc": string,
1551
+ "peak_moment": string,
1552
+ "mechanism": string,
1553
+ "humor": string,
1554
+ "delivery": string,
1555
+ "preserve": [string, ...]
1556
+ },
1524
1557
  "preserve": [string, ...],
1525
1558
  "avoid": [string, ...],
1526
1559
  "promotions": [string, ...],
@@ -1559,6 +1592,16 @@ VIRAL_DNA RULES:
1559
1592
  - "hook": the 1-sentence tactic used in the first 1-2 seconds to stop the scroll (e.g., "Pinned comment screenshot dares the viewer to keep watching").
1560
1593
  - "retention": how the middle of the video keeps attention (visual cuts, pattern loops, escalating stakes).
1561
1594
  - "payoff": what the viewer earns at the end (reveal, punchline, transformation).
1595
+ - "emotional_punch": the FEELING this format delivers and exactly what makes it land — the part remixes most often flatten. hook/retention/payoff are the mechanical structure; this is the vibe, the joke, and the intonation that make a viewer actually FEEL something. Fill EVERY sub-field:
1596
+ - "core_emotion": the dominant feeling a viewer walks away with (e.g. "delighted surprise", "righteous validation", "warm nostalgia", "second-hand cringe", "smug satisfaction").
1597
+ - "tone": the register / vibe in 1-3 words (comedic, deadpan, earnest, dramatic, wholesome, chaotic, satirical, hype, ...).
1598
+ - "arc": how the feeling MOVES across the runtime (e.g. "curiosity → mounting tension → relief-laugh", "flat setup → sudden absurd turn").
1599
+ - "peak_moment": the single beat that lands the emotional hit — the punchline, the reveal, the gut-punch, the beat-drop — and roughly when.
1600
+ - "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.
1602
+ - "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
+ - "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.
1562
1605
  - "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").
1563
1606
  - "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").
1564
1607
  - "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.
@@ -1737,9 +1780,31 @@ function resolveFontSize(input) {
1737
1780
  }
1738
1781
  return normalizeFontSize(input.fontSize, input.canvasHeight);
1739
1782
  }
1740
- const DEFAULT_VIRAL_DNA = {
1741
- trend_tagline: "", hook: "", retention: "", payoff: "", preserve: [], avoid: [], promotions: [], keywords: []
1783
+ const DEFAULT_EMOTIONAL_PUNCH = {
1784
+ core_emotion: "", tone: "", arc: "", peak_moment: "", mechanism: "", humor: "", delivery: "", preserve: []
1785
+ };
1786
+ export const DEFAULT_VIRAL_DNA = {
1787
+ trend_tagline: "", hook: "", retention: "", payoff: "", emotional_punch: { ...DEFAULT_EMOTIONAL_PUNCH }, preserve: [], avoid: [], promotions: [], keywords: []
1742
1788
  };
1789
+ function normalizeEmotionalPunch(value) {
1790
+ if (!value || typeof value !== "object")
1791
+ return { ...DEFAULT_EMOTIONAL_PUNCH };
1792
+ const record = value;
1793
+ const asString = (v) => typeof v === "string" ? v.trim() : "";
1794
+ const asStringArray = (v) => Array.isArray(v)
1795
+ ? v.map((item) => (typeof item === "string" ? item.trim() : "")).filter((item) => item.length > 0)
1796
+ : [];
1797
+ return {
1798
+ core_emotion: asString(record.core_emotion ?? record.coreEmotion),
1799
+ tone: asString(record.tone),
1800
+ arc: asString(record.arc ?? record.emotional_arc),
1801
+ peak_moment: asString(record.peak_moment ?? record.peakMoment),
1802
+ mechanism: asString(record.mechanism),
1803
+ humor: asString(record.humor),
1804
+ delivery: asString(record.delivery),
1805
+ preserve: asStringArray(record.preserve).slice(0, 4)
1806
+ };
1807
+ }
1743
1808
  function normalizeViralDna(value) {
1744
1809
  if (!value || typeof value !== "object")
1745
1810
  return DEFAULT_VIRAL_DNA;
@@ -1753,6 +1818,7 @@ function normalizeViralDna(value) {
1753
1818
  hook: asString(record.hook),
1754
1819
  retention: asString(record.retention),
1755
1820
  payoff: asString(record.payoff),
1821
+ emotional_punch: normalizeEmotionalPunch(record.emotional_punch ?? record.emotionalPunch),
1756
1822
  preserve: asStringArray(record.preserve),
1757
1823
  avoid: asStringArray(record.avoid),
1758
1824
  promotions: asStringArray(record.promotions),
@@ -1775,6 +1841,7 @@ export const DEFAULT_EDITOR_HARNESS = {
1775
1841
  broll: { reliance: "none", sourcing: "", shot_kinds: [], cadence: "" },
1776
1842
  transitions: { default: "cut", intro: "", outro: "", usage: "" },
1777
1843
  audio: { voiceover: "", music: "", sfx: "", captions_from: "" },
1844
+ emotional: { target_feeling: "", tone: "", comedic_timing: "", intonation: "", vibe_anchors: [] },
1778
1845
  scenes: [],
1779
1846
  important_scenes: [],
1780
1847
  editing_bias: [],
@@ -1797,12 +1864,13 @@ function buildEditorHarnessPrompt(input) {
1797
1864
  : "";
1798
1865
  const roleVocab = SCENE_ROLE_VOCAB.join(" | ");
1799
1866
  const formatVocab = CONTENT_FORMATS.join(" | ");
1867
+ const audioDigest = buildAudioDigestForPrompt(input.transcript ?? null);
1800
1868
  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).
1801
1869
 
1802
1870
  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.
1803
1871
 
1804
1872
  Video duration: ${input.durationSeconds.toFixed(2)} seconds.
1805
- ${frameListing}${cutListing}${promptHint}
1873
+ ${frameListing}${cutListing}${audioDigest}${promptHint}
1806
1874
  Return strictly valid JSON matching this schema:
1807
1875
  {
1808
1876
  "format": string, // one of: ${formatVocab}
@@ -1839,6 +1907,13 @@ Return strictly valid JSON matching this schema:
1839
1907
  "sfx": string, // e.g. "whoosh on cuts" | "none"
1840
1908
  "captions_from": string // "voiceover" | "on-screen text" | "none"
1841
1909
  },
1910
+ "emotional": {
1911
+ "target_feeling": string, // the emotion the edit must reproduce (the vibe/joke that makes it land)
1912
+ "tone": string, // register to hold: comedic | deadpan | earnest | dramatic | wholesome | hype | ...
1913
+ "comedic_timing": string, // where the beat/pause/cut sells the joke; "none" if not comedic
1914
+ "intonation": string, // vocal delivery/cadence to preserve on VO or on-cam speech; "none" if silent
1915
+ "vibe_anchors": [string, ...] // 2-4 concrete edit moves that carry the emotion
1916
+ },
1842
1917
  "scenes": [{
1843
1918
  "role": string, // ${roleVocab}
1844
1919
  "timestamp": string, // human range, e.g. "0:00-0:03"
@@ -1858,6 +1933,7 @@ RULES:
1858
1933
  - 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.
1859
1934
  - "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).
1860
1935
  - 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.
1861
1937
  - Keep every string tight and directive. Never invent audio you cannot infer — say "none"/"unknown" instead.
1862
1938
  - Output ONLY the JSON object.`;
1863
1939
  }
@@ -1880,6 +1956,7 @@ function normalizeEditorHarness(raw) {
1880
1956
  const broll = asPlainRecord(rec.broll);
1881
1957
  const transitions = asPlainRecord(rec.transitions);
1882
1958
  const audio = asPlainRecord(rec.audio);
1959
+ const emotional = asPlainRecord(rec.emotional);
1883
1960
  const scenesRaw = Array.isArray(rec.scenes) ? rec.scenes : [];
1884
1961
  const scenes = scenesRaw
1885
1962
  .map((s) => {
@@ -1931,6 +2008,13 @@ function normalizeEditorHarness(raw) {
1931
2008
  sfx: readAnnotationString(audio.sfx),
1932
2009
  captions_from: readAnnotationString(audio.captions_from)
1933
2010
  },
2011
+ emotional: {
2012
+ target_feeling: readAnnotationString(emotional.target_feeling),
2013
+ tone: readAnnotationString(emotional.tone),
2014
+ comedic_timing: readAnnotationString(emotional.comedic_timing),
2015
+ intonation: readAnnotationString(emotional.intonation),
2016
+ vibe_anchors: readAnnotationStringList(emotional.vibe_anchors, 4)
2017
+ },
1934
2018
  scenes,
1935
2019
  important_scenes: readAnnotationStringList(rec.important_scenes, 8),
1936
2020
  editing_bias: readAnnotationStringList(rec.editing_bias, 8),
@@ -1945,7 +2029,8 @@ async function runEditorHarnessPass(input) {
1945
2029
  durationSeconds: input.durationSeconds,
1946
2030
  frameTimestamps: input.frames.map((f) => f.timestamp),
1947
2031
  sceneCuts: input.sceneCuts,
1948
- userPrompt: input.userPrompt ?? null
2032
+ userPrompt: input.userPrompt ?? null,
2033
+ transcript: input.transcript ?? null
1949
2034
  });
1950
2035
  const attempt = await callVisionWithFallback({ prompt, frames: input.frames, keys: input.keys });
1951
2036
  return normalizeEditorHarness(attempt.result);
@@ -2313,13 +2398,22 @@ export async function smartDecomposeVideo(input) {
2313
2398
  });
2314
2399
  // Sparse frames for the vision pass (scenes, captions, style, viral_dna)
2315
2400
  // and dense frames for the OCR pass (deterministic caption timing).
2316
- // Extract both up front so the OCR API calls can run in parallel with
2317
- // the main vision call.
2318
- const [frames, ocrRawFrames, audioPath, sceneCuts] = await Promise.all([
2401
+ // Phase 1 deterministic ffmpeg/whisper work up front. Transcription is
2402
+ // pulled INTO this phase (chained off the audio extract) so the transcript
2403
+ // is ready BEFORE the vision + harness passes run: emotional punch on
2404
+ // short-form video is frequently carried by the AUDIO (a trending sound, a
2405
+ // beat drop, vocal intonation), and the silent frames alone can't capture
2406
+ // it. The transcript overlaps with frame-sampling/OCR/cut-detection here
2407
+ // instead of with the vision call, so this stays roughly latency-neutral.
2408
+ const [frames, ocrRawFrames, transcript, sceneCuts] = await Promise.all([
2319
2409
  sampleFramesAsBase64(sourcePath, meta.durationSeconds, tempDir),
2320
2410
  extractDenseOcrFrames(sourcePath, meta.durationSeconds, tempDir),
2321
- extractAudioAsWav(sourcePath, tempDir).catch((err) => {
2322
- console.error("smart decompose audio extract failed", err instanceof Error ? err.message : err);
2411
+ extractAudioAsWav(sourcePath, tempDir)
2412
+ .then((audioPath) => audioPath
2413
+ ? transcribeAudioWithFallback({ audioPath, durationSeconds: meta.durationSeconds, keys: input.keys })
2414
+ : null)
2415
+ .catch((err) => {
2416
+ console.error("smart decompose audio extract/transcription failed", err instanceof Error ? err.message : err);
2323
2417
  return null;
2324
2418
  }),
2325
2419
  detectSceneCuts(sourcePath, meta.durationSeconds, tempDir).catch((err) => {
@@ -2330,7 +2424,7 @@ export async function smartDecomposeVideo(input) {
2330
2424
  devLog("hyperframes.smart_decompose.frames_sampled", {
2331
2425
  frame_count: frames.length,
2332
2426
  ocr_frame_count: ocrRawFrames.length,
2333
- has_audio_track: Boolean(audioPath),
2427
+ has_audio_track: Boolean(transcript),
2334
2428
  scene_cut_count: sceneCuts.length
2335
2429
  });
2336
2430
  const prompt = buildSmartDecomposePrompt({
@@ -2340,14 +2434,16 @@ export async function smartDecomposeVideo(input) {
2340
2434
  userPrompt: input.userPrompt ?? null,
2341
2435
  trendTaglineHint: input.trendTaglineHint ?? null,
2342
2436
  frameTimestamps: frames.map((f) => f.timestamp),
2343
- sceneCuts
2437
+ sceneCuts,
2438
+ transcript
2344
2439
  });
2345
- // Vision, OCR, transcription, and the product-placement scout run
2346
- // concurrently. OCR, transcription, and placement failures are all
2347
- // non-fatal. The placement call is DELIBERATELY a separate request from the
2348
- // caption/scene vision call so it can never dilute timeline-layer quality —
2349
- // it just reuses the same already-sampled sparse frames (no extra ffmpeg).
2350
- const [attempt, ocrFrames, transcript, placementSurfaces, editorHarness] = await Promise.all([
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([
2351
2447
  callVisionWithFallback({
2352
2448
  prompt,
2353
2449
  frames,
@@ -2357,12 +2453,6 @@ export async function smartDecomposeVideo(input) {
2357
2453
  console.error("smart decompose OCR pass failed", err instanceof Error ? err.message : err);
2358
2454
  return [];
2359
2455
  }),
2360
- audioPath
2361
- ? transcribeAudioWithFallback({ audioPath, durationSeconds: meta.durationSeconds, keys: input.keys }).catch((err) => {
2362
- console.error("smart decompose transcription failed", err instanceof Error ? err.message : err);
2363
- return null;
2364
- })
2365
- : Promise.resolve(null),
2366
2456
  runPlacementSurfacePass({
2367
2457
  frames,
2368
2458
  keys: input.keys,
@@ -2373,13 +2463,14 @@ export async function smartDecomposeVideo(input) {
2373
2463
  return [];
2374
2464
  }),
2375
2465
  // Editor harness — technical editing direction. Isolated call in the same
2376
- // burst (reuses these frames + the detected cuts); non-fatal.
2466
+ // burst (reuses these frames + the detected cuts + the transcript); non-fatal.
2377
2467
  runEditorHarnessPass({
2378
2468
  frames,
2379
2469
  keys: input.keys,
2380
2470
  durationSeconds: meta.durationSeconds,
2381
2471
  sceneCuts,
2382
- userPrompt: input.userPrompt ?? null
2472
+ userPrompt: input.userPrompt ?? null,
2473
+ transcript
2383
2474
  }).catch((err) => {
2384
2475
  console.error("smart decompose editor-harness pass failed", err instanceof Error ? err.message : err);
2385
2476
  return DEFAULT_EDITOR_HARNESS;
@@ -2588,6 +2679,49 @@ export function buildSmartDecomposedHtml(input) {
2588
2679
  </body>
2589
2680
  </html>`;
2590
2681
  }
2682
+ export function buildSmartDecomposedData(input) {
2683
+ const { sourceUrl, result } = input;
2684
+ const durationSeconds = Number(result.durationSeconds.toFixed(3));
2685
+ return {
2686
+ summary: result.summary,
2687
+ scenes: result.scenes.map((scene) => ({
2688
+ slug: scene.slug,
2689
+ start: scene.start,
2690
+ duration: scene.duration,
2691
+ label: scene.label,
2692
+ description: scene.description,
2693
+ viral_note: scene.viral_note
2694
+ })),
2695
+ captions: result.captions.map((caption) => ({
2696
+ slug: caption.slug,
2697
+ text: caption.text,
2698
+ start: caption.start,
2699
+ duration: caption.duration,
2700
+ x: caption.x,
2701
+ y: caption.y,
2702
+ width: caption.width,
2703
+ height: caption.height,
2704
+ color: caption.color,
2705
+ background: caption.background,
2706
+ background_style: caption.background_style,
2707
+ font_family: caption.font_family,
2708
+ font_size: caption.font_size,
2709
+ font_weight: caption.font_weight,
2710
+ viral_note: caption.viral_note
2711
+ })),
2712
+ viralDna: result.viralDna,
2713
+ width: result.width,
2714
+ height: result.height,
2715
+ durationSeconds,
2716
+ source_assets: [{
2717
+ type: "video",
2718
+ url: sourceUrl,
2719
+ width: result.width,
2720
+ height: result.height,
2721
+ duration: durationSeconds
2722
+ }]
2723
+ };
2724
+ }
2591
2725
  // The public placeholder tile every blank scene starts on — a gray diagonal-
2592
2726
  // striped 720×1280 image with a dashed text box (uploaded once by
2593
2727
  // scripts/upload-scene-placeholder.ts). Hardcoded to the prod bucket URL (a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.20.2",
3
+ "version": "0.20.3",
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": {