@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.
- package/.agents/skills/editor-capabilities/SKILL.md +2 -1
- package/SKILL.director.md +17 -3
- package/SKILL.platform.md +1 -1
- package/dist/src/app.js +20 -6
- package/dist/src/cli.js +6 -3
- package/dist/src/editor-chat.js +2 -2
- package/dist/src/services/hyperframes.js +160 -26
- package/package.json +1 -1
|
@@ -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
|
|
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 ??
|
|
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
|
|
1356
|
-
//
|
|
1357
|
-
//
|
|
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);
|
package/dist/src/editor-chat.js
CHANGED
|
@@ -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
|
|
1741
|
-
|
|
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
|
-
//
|
|
2317
|
-
// the
|
|
2318
|
-
|
|
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)
|
|
2322
|
-
|
|
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(
|
|
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
|
-
//
|
|
2346
|
-
// concurrently. OCR
|
|
2347
|
-
// non-fatal. The placement
|
|
2348
|
-
// caption/scene vision call so
|
|
2349
|
-
//
|
|
2350
|
-
|
|
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.
|
|
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": {
|