@mevdragon/vidfarm-devcli 0.20.3 → 0.20.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/editor-capabilities/SKILL.md +1 -1
- package/SKILL.director.md +19 -2
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +72 -72
- package/dist/src/cli.js +141 -3
- package/dist/src/editor-chat.js +1 -1
- package/dist/src/services/hyperframes.js +57 -26
- package/package.json +2 -2
package/dist/src/cli.js
CHANGED
|
@@ -114,8 +114,10 @@ Make a video in one command (orchestrates ingest → decompose → fork):
|
|
|
114
114
|
Composition lifecycle (fork → decompose → snapshot → render):
|
|
115
115
|
fork <template_id> Fork a template into an editable composition → POST /api/v1/compositions
|
|
116
116
|
pull <forkId> Sync composition.html/json + video-context → GET .../compositions/:forkId/{composition.html,...}
|
|
117
|
-
+ cast.json to disk,
|
|
118
|
-
(
|
|
117
|
+
+ cast.json to disk, write a local agent brief
|
|
118
|
+
(.harness/context.json + .harness/agent-guide.md),
|
|
119
|
+
then print
|
|
120
|
+
timeline gaps (blank space) and scene/layer keys.
|
|
119
121
|
--dir <path> Local dir (default: .vidfarm/<forkId>)
|
|
120
122
|
--refetch Overwrite local copies with the cloud version
|
|
121
123
|
decompose <forkId> Split the fork's source into scenes (smart) → POST .../compositions/:forkId/auto-decompose
|
|
@@ -1376,6 +1378,129 @@ async function fetchCompositionFiles(input) {
|
|
|
1376
1378
|
console.log(`[vidfarm] fetched ${filename} (${body.length} bytes)`);
|
|
1377
1379
|
}
|
|
1378
1380
|
}
|
|
1381
|
+
function readJsonFile(filePath) {
|
|
1382
|
+
if (!existsSync(filePath))
|
|
1383
|
+
return null;
|
|
1384
|
+
try {
|
|
1385
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
1386
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
1387
|
+
}
|
|
1388
|
+
catch {
|
|
1389
|
+
return null;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
function readJsonString(value) {
|
|
1393
|
+
return typeof value === "string" ? value.trim() : "";
|
|
1394
|
+
}
|
|
1395
|
+
function readJsonStringList(value, max = 8) {
|
|
1396
|
+
if (!Array.isArray(value))
|
|
1397
|
+
return [];
|
|
1398
|
+
const out = [];
|
|
1399
|
+
for (const entry of value) {
|
|
1400
|
+
const text = readJsonString(entry);
|
|
1401
|
+
if (text && !out.includes(text))
|
|
1402
|
+
out.push(text);
|
|
1403
|
+
if (out.length >= max)
|
|
1404
|
+
break;
|
|
1405
|
+
}
|
|
1406
|
+
return out;
|
|
1407
|
+
}
|
|
1408
|
+
function writeLocalAgentHarnessFiles(dir) {
|
|
1409
|
+
const videoContext = readJsonFile(path.join(dir, "video-context.json"));
|
|
1410
|
+
const editorHarness = readJsonFile(path.join(dir, "editor-harness.json"));
|
|
1411
|
+
const sceneAnnotations = readJsonFile(path.join(dir, "scene-annotations.json"));
|
|
1412
|
+
const cast = readJsonFile(path.join(dir, "cast.json"));
|
|
1413
|
+
const viralDna = videoContext && readJsonString(videoContext.status) === "ready" && videoContext.viral_dna && typeof videoContext.viral_dna === "object"
|
|
1414
|
+
? videoContext.viral_dna
|
|
1415
|
+
: null;
|
|
1416
|
+
const harness = editorHarness && readJsonString(editorHarness.status) === "ready" && editorHarness.harness && typeof editorHarness.harness === "object"
|
|
1417
|
+
? editorHarness.harness
|
|
1418
|
+
: null;
|
|
1419
|
+
const annotations = sceneAnnotations && readJsonString(sceneAnnotations.status) === "done" && Array.isArray(sceneAnnotations.annotations)
|
|
1420
|
+
? sceneAnnotations.annotations
|
|
1421
|
+
: [];
|
|
1422
|
+
const castMembers = cast && Array.isArray(cast.cast) ? cast.cast : [];
|
|
1423
|
+
const harnessDir = path.join(dir, ".harness");
|
|
1424
|
+
mkdirSync(harnessDir, { recursive: true });
|
|
1425
|
+
const contextPath = path.join(harnessDir, "context.json");
|
|
1426
|
+
const instructionsPath = path.join(harnessDir, "agent-guide.md");
|
|
1427
|
+
const bundle = {
|
|
1428
|
+
generated_by: "vidfarm-devcli",
|
|
1429
|
+
generated_at: new Date().toISOString(),
|
|
1430
|
+
source_title: readJsonString(videoContext?.summary) || null,
|
|
1431
|
+
composition_context: viralDna,
|
|
1432
|
+
editor_harness: harness,
|
|
1433
|
+
scenes: Array.isArray(videoContext?.scenes) ? videoContext?.scenes : [],
|
|
1434
|
+
scene_annotations: annotations,
|
|
1435
|
+
cast: castMembers,
|
|
1436
|
+
transcript: videoContext?.transcript ?? null
|
|
1437
|
+
};
|
|
1438
|
+
writeFileSync(contextPath, JSON.stringify(bundle, null, 2));
|
|
1439
|
+
const emotionalPunch = viralDna?.emotional_punch && typeof viralDna.emotional_punch === "object"
|
|
1440
|
+
? viralDna.emotional_punch
|
|
1441
|
+
: null;
|
|
1442
|
+
const importantScenes = readJsonStringList(harness?.important_scenes, 6);
|
|
1443
|
+
const editingBias = readJsonStringList(harness?.editing_bias, 6);
|
|
1444
|
+
const doList = readJsonStringList(harness?.do, 5);
|
|
1445
|
+
const dontList = readJsonStringList(harness?.dont, 5);
|
|
1446
|
+
const preserve = readJsonStringList(emotionalPunch?.preserve, 5);
|
|
1447
|
+
const mustPreserve = annotations
|
|
1448
|
+
.flatMap((entry) => readJsonStringList(entry.must_preserve, 3).map((item) => {
|
|
1449
|
+
const slug = readJsonString(entry.scene_slug);
|
|
1450
|
+
return slug ? `${slug}: ${item}` : item;
|
|
1451
|
+
}))
|
|
1452
|
+
.slice(0, 8);
|
|
1453
|
+
const agentMd = `# Vidfarm Local Harness
|
|
1454
|
+
|
|
1455
|
+
Read \`.harness/context.json\` before making substantial edits. This fork was pulled from Vidfarm with decompose context; do not freestyle the rewrite from \`composition.html\` alone.
|
|
1456
|
+
|
|
1457
|
+
Files to use:
|
|
1458
|
+
- \`video-context.json\`: transcript, scene descriptions, and viral DNA.
|
|
1459
|
+
- \`editor-harness.json\`: the technical editing brief under \`harness\` (pace, typography, audio, scene roles).
|
|
1460
|
+
- \`scene-annotations.json\`: per-scene replacement and regeneration DNA.
|
|
1461
|
+
- \`.harness/context.json\`: merged agent-facing snapshot generated by \`vidfarm pull\`.
|
|
1462
|
+
- \`.harness/agent-guide.md\`: Vidfarm-specific local scripting guidance generated by \`vidfarm pull\`.
|
|
1463
|
+
|
|
1464
|
+
Rules:
|
|
1465
|
+
- Treat the viral DNA and editor harness as hard defaults unless the user explicitly overrides them.
|
|
1466
|
+
- If you rewrite text, make it fit the existing performance and joke mechanic. Do not replace a format-specific bit with generic product-benefit copy.
|
|
1467
|
+
- Preserve the emotional punch first: tone, mechanism, contrast, audio/visual tension, delivery, and preserve beats.
|
|
1468
|
+
- Protect any harness scene marked critical/must_keep and the must_preserve notes from scene annotations.
|
|
1469
|
+
- When swapping visuals, match both the literal scene DNA and the narrative purpose of the beat.
|
|
1470
|
+
|
|
1471
|
+
Current format snapshot:
|
|
1472
|
+
- Trend tagline: ${readJsonString(viralDna?.trend_tagline) || "unknown"}
|
|
1473
|
+
- Hook: ${readJsonString(viralDna?.hook) || "unknown"}
|
|
1474
|
+
- Payoff: ${readJsonString(viralDna?.payoff) || "unknown"}
|
|
1475
|
+
- Emotional core: ${readJsonString(emotionalPunch?.core_emotion) || "unknown"}
|
|
1476
|
+
- Tone: ${readJsonString(emotionalPunch?.tone) || "unknown"}
|
|
1477
|
+
- Mechanism: ${readJsonString(emotionalPunch?.mechanism) || "unknown"}
|
|
1478
|
+
- Contrast: ${readJsonString(emotionalPunch?.contrast) || "unknown"}
|
|
1479
|
+
- Audio/visual tension: ${readJsonString(emotionalPunch?.audio_visual_tension) || "unknown"}
|
|
1480
|
+
- Delivery: ${readJsonString(emotionalPunch?.delivery) || "unknown"}
|
|
1481
|
+
- Harness one-liner: ${readJsonString(harness?.one_liner) || "unknown"}
|
|
1482
|
+
|
|
1483
|
+
Priority preserve beats:
|
|
1484
|
+
${preserve.length ? preserve.map((item) => `- ${item}`).join("\n") : "- none recorded"}
|
|
1485
|
+
|
|
1486
|
+
Important scenes:
|
|
1487
|
+
${importantScenes.length ? importantScenes.map((item) => `- ${item}`).join("\n") : "- none recorded"}
|
|
1488
|
+
|
|
1489
|
+
Scene-level must-preserve cues:
|
|
1490
|
+
${mustPreserve.length ? mustPreserve.map((item) => `- ${item}`).join("\n") : "- none recorded"}
|
|
1491
|
+
|
|
1492
|
+
Editing bias:
|
|
1493
|
+
${editingBias.length ? editingBias.map((item) => `- ${item}`).join("\n") : "- none recorded"}
|
|
1494
|
+
|
|
1495
|
+
Do:
|
|
1496
|
+
${doList.length ? doList.map((item) => `- ${item}`).join("\n") : "- none recorded"}
|
|
1497
|
+
|
|
1498
|
+
Don't:
|
|
1499
|
+
${dontList.length ? dontList.map((item) => `- ${item}`).join("\n") : "- none recorded"}
|
|
1500
|
+
`;
|
|
1501
|
+
writeFileSync(instructionsPath, agentMd);
|
|
1502
|
+
return { contextPath, instructionsPath };
|
|
1503
|
+
}
|
|
1379
1504
|
async function fetchVideoAssets(input) {
|
|
1380
1505
|
const refs = readSourceRefs(input.dir);
|
|
1381
1506
|
if (!refs.compositionSourceUrl && !refs.originalSourceUrl) {
|
|
@@ -4439,12 +4564,24 @@ async function runPullCommand(argv) {
|
|
|
4439
4564
|
if (!existsSync(htmlPath))
|
|
4440
4565
|
throw new Error(`pull did not fetch composition.html for ${forkId} (check credentials / fork id).`);
|
|
4441
4566
|
const info = inspectComposition(readFileSync(htmlPath, "utf8"));
|
|
4567
|
+
const harnessFiles = writeLocalAgentHarnessFiles(dir);
|
|
4442
4568
|
const hasContext = existsSync(path.join(dir, "video-context.json"));
|
|
4443
4569
|
const hasCast = existsSync(path.join(dir, "cast.json"));
|
|
4444
4570
|
const hasAnnotations = existsSync(path.join(dir, "scene-annotations.json"));
|
|
4445
4571
|
const hasHarness = existsSync(path.join(dir, "editor-harness.json"));
|
|
4446
4572
|
if (ctx.json) {
|
|
4447
|
-
printJson({
|
|
4573
|
+
printJson({
|
|
4574
|
+
ok: true,
|
|
4575
|
+
fork_id: forkId,
|
|
4576
|
+
dir,
|
|
4577
|
+
has_video_context: hasContext,
|
|
4578
|
+
has_cast: hasCast,
|
|
4579
|
+
has_scene_annotations: hasAnnotations,
|
|
4580
|
+
has_editor_harness: hasHarness,
|
|
4581
|
+
harness_context_path: harnessFiles.contextPath,
|
|
4582
|
+
harness_instructions_path: harnessFiles.instructionsPath,
|
|
4583
|
+
...info
|
|
4584
|
+
});
|
|
4448
4585
|
return;
|
|
4449
4586
|
}
|
|
4450
4587
|
console.log(`${GREEN}Pulled ${forkId} → ${dir}${RESET}`);
|
|
@@ -4456,6 +4593,7 @@ async function runPullCommand(argv) {
|
|
|
4456
4593
|
console.log(` ${layer.key} ${DIM}${layer.kind ?? "?"} ${layer.start}-${(layer.start + layer.duration).toFixed(2)}s track${layer.track}${flags ? ` [${flags}]` : ""}${layer.slug ? ` slug=${layer.slug}` : ""}${RESET}`);
|
|
4457
4594
|
}
|
|
4458
4595
|
console.log(`${DIM}Grounding: ${hasContext ? "video-context.json ✓" : "video-context.json ✗ (run `vidfarm decompose`)"}, ${hasCast ? "cast.json ✓" : "cast.json ✗"}, ${hasAnnotations ? "scene-annotations.json ✓" : "scene-annotations.json ✗"}, ${hasHarness ? "editor-harness.json ✓ (style brief under .harness)" : "editor-harness.json ✗"}.${RESET}`);
|
|
4596
|
+
console.log(`${DIM}Agent brief: ${path.relative(dir, harnessFiles.instructionsPath) || path.basename(harnessFiles.instructionsPath)}; merged context: ${path.relative(dir, harnessFiles.contextPath)}.${RESET}`);
|
|
4459
4597
|
console.log(`${DIM}Next: vidfarm generate video --prompt "..." --aspect-ratio ${info.aspect_ratio ?? "9:16"} --place ${dir} --at <gap start>|--replace <layer_key>${RESET}`);
|
|
4460
4598
|
}
|
|
4461
4599
|
async function runVisibilityCommand(argv) {
|
package/dist/src/editor-chat.js
CHANGED
|
@@ -117,7 +117,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
117
117
|
"KNOWLEDGE ON DEMAND (load_skill): deeper authoring knowledge ships as vidfarm skill packs you can read mid-conversation with the load_skill tool — YOUR OWN capability map for this editor (editor-capabilities: the editor_action verb catalog, editor_context fields, forking rules, the three-axes SWAP↔REPLACE re-theme model, and fueling a scenes replace with raws — load it before a full re-theme, a multi-scene rebuild, or when you need an exact verb/param), the composition HTML contract (hyperframes-core), motion/animation craft and transition doctrine (hyperframes-animation), seek-safe keyframe patterns (hyperframes-keyframes), creative direction/palettes/typography (hyperframes-creative), caption identities (embedded-captions), media/audio resolution (vidfarm-media), and end-to-end workflow playbooks (product-launch-video, faceless-explainer, website-to-video, general-video, motion-graphics, slideshow, talking-head-recut). Call load_skill with the pack name to read its SKILL.md; it lists reference files you can then load by relative path (e.g. file='references/step-4-vo.md'). Use it when a request needs craft beyond the built-in preset vocabulary — designing a whole composition via replace_composition_html, picking motion or transition language for a style, or following a named workflow. NOTE: hyperframes-animation and hyperframes-keyframes describe BOTH script-driven adapters (anime.js/GSAP/Lottie/Three.js) AND script-free CSS techniques — in the web editor apply only the CSS @keyframes / declarative-preset half (the JS adapters are stripped on save; see the web-editor motion rule), and reserve the adapter half for local devcli work. Load only what the task needs, never bulk-load packs, and never paste large skill content back to the user.",
|
|
118
118
|
"replace_composition_html is validated before it reaches the editor: when the HTML violates the composition contract (missing data-composition-id, invalid clip timing, same-track overlaps, unknown preset names, media without src) the tool result comes back with rejected=true and lint_errors, and NOTHING is applied — fix the reported issues and call it again. lint_warnings are advisory and the action still applies.",
|
|
119
119
|
"MOTION IN THE WEB EDITOR IS DECLARATIVE / CSS ONLY — no author <script>. This editor strips every <script> tag on save (stored-XSS defense), so replace_composition_html that contains a <script> is REJECTED (rejected=true), not applied. That means the JavaScript runtime animation adapters — anime.js, GSAP, Lottie, Three.js, TypeGPU, WAAPI-driven code — are NOT available here; they are a local-devcli-only capability. Author all motion for the web editor with the durable, script-free vocabulary: (1) built-in preset actions — set_layer_media ken_burns (still-image pan/zoom), set_transitions / transition + transition_out (scene entrances/exits), set_captions / caption_animation (word-by-word captions); and (2) plain CSS — @keyframes rules in a <style> block plus an inline animation on the layer element (the renderer scrubs and bakes CSS @keyframes deterministically into the final MP4, exactly like the preview). Never try to drive motion with anime.js/GSAP/etc. in the web editor, and never claim you added a scripted animation here. If the user explicitly wants a JS-adapter animation (a Lottie file, a GSAP timeline, a Three.js scene), tell them that is a local devcli / hyperframes-CLI workflow and offer the CSS/preset equivalent for the web editor instead.",
|
|
120
|
-
"TEMPLATE FORMAT — MATCH IT WHEN WRITING TEXT: the COMPOSITION BRIEF (stable template context in the system prompt) carries composition_context — WHAT THIS TEMPLATE IS: its format/genre and narrative arc (trend_tagline, hook, retention, payoff, preserve, avoid) plus emotional_punch (core_emotion, tone, arc, peak_moment, mechanism, humor, delivery, preserve) — the FEELING the format delivers and what makes it land. ALWAYS read it before writing or adjusting ANY captions, text layers, or on-screen copy, and make your copy fit the template's format and voice — do NOT default to generic product/app ad copy. emotional_punch is the part a remix most often flattens: if the format is a joke, keep emotional_punch.humor's mechanic (rebuild the SAME joke around the new subject, don't drop it for a feature bullet); match emotional_punch.tone and emotional_punch.delivery so intonation, comedic timing, and vibe survive; and never trade the peak_moment's payoff for a flat product plug. The format dictates the caption style: a text-message / DM / iMessage conversation template's captions must read like short back-and-forth chat messages between people (in-character, conversational), a 'story time' / talking-head template reads like first-person spoken narration, a listicle reads like punchy numbered items, a fake-news / headline template reads like a news chyron, etc. `preserve` usually names format cues that must stay (e.g. the messaging-bubble layout); `avoid` names what to keep out. When the template's format is a conversation, skit, or roleplay, keep the captions in that voice and only weave the product in the way that format allows (subtly, in-dialogue) rather than replacing the messages with feature bullets. If the brief's composition_context is thin or you are unsure what the video actually shows, call video_context (scene visual descriptions + transcript) to ground the format BEFORE writing captions.",
|
|
120
|
+
"TEMPLATE FORMAT — MATCH IT WHEN WRITING TEXT: the COMPOSITION BRIEF (stable template context in the system prompt) carries composition_context — WHAT THIS TEMPLATE IS: its format/genre and narrative arc (trend_tagline, hook, retention, payoff, preserve, avoid) plus emotional_punch (core_emotion, tone, arc, peak_moment, mechanism, contrast, humor, audio_visual_tension, delivery, preserve) — the FEELING the format delivers and what makes it land. ALWAYS read it before writing or adjusting ANY captions, text layers, or on-screen copy, and make your copy fit the template's format and voice — do NOT default to generic product/app ad copy. emotional_punch is the part a remix most often flattens: if the format is a joke, keep emotional_punch.humor's mechanic (rebuild the SAME joke around the new subject, don't drop it for a feature bullet); preserve emotional_punch.contrast when the bit depends on a mismatch (for example tough soundtrack vs delicate visuals, swagger vs mundane setting); use emotional_punch.audio_visual_tension when audio and visuals intentionally play against each other; match emotional_punch.tone and emotional_punch.delivery so intonation, comedic timing, and vibe survive; and never trade the peak_moment's payoff for a flat product plug. The format dictates the caption style: a text-message / DM / iMessage conversation template's captions must read like short back-and-forth chat messages between people (in-character, conversational), a 'story time' / talking-head template reads like first-person spoken narration, a listicle reads like punchy numbered items, a fake-news / headline template reads like a news chyron, etc. `preserve` usually names format cues that must stay (e.g. the messaging-bubble layout); `avoid` names what to keep out. When the template's format is a conversation, skit, or roleplay, keep the captions in that voice and only weave the product in the way that format allows (subtly, in-dialogue) rather than replacing the messages with feature bullets. If the brief's composition_context is thin or you are unsure what the video actually shows, call video_context (scene visual descriptions + transcript) to ground the format BEFORE writing captions.",
|
|
121
121
|
"FILE DIRECTORY: the user's files live in ONE navigable directory with three canonical roots, each with nested subfolders and a copyable path per file: (1) /files — durable 'My Files' assets they keep and reuse (videos mp4/mov/webm, images png/jpg/jpeg/gif/webp/svg, audio mp3/wav/m4a/aac, documents pdf/md/txt/csv), vector-searchable via metadata notes; (2) /temp — scratch space auto-foldered by date (YYYY-MM-DD) and auto-deleted after 30 days; (3) /raws — the reusable clip / source-footage library, foldered by source (e.g. /raws/<source-slug>/). A file's canonical path looks like /raws/product-demos/clip.mp4 or /files/acme-skincare/logo.png. Use the browse_files tool to navigate, search, read, write, and reorganize it: action=list with path (e.g. path='/', '/raws', '/files/brand-assets') enumerates that folder's subfolders + files as canonical items (path, name, kind, view_url); action=search with a plain-language query finds files by MEANING across roots — it combines semantic vector match, substring, and absolute-path match (scope with path='/raws' for footage, '/files' for durable assets, '/' for everything; force one signal with mode=semantic|substring|path). Prefer search over paging list when the library is large or the user references an asset vaguely. When you omit path entirely, list/search now default to '/' (the whole directory) so /raws and /temp are never hidden — but pass path='/raws' to stay in footage. For /raws you can add content_type to filter by EXACT shot kind (talking_head, b_roll, product_shot, screen_recording, demo, reaction, interview, establishing, lifestyle, text_graphic; comma-separate to OR them) — this is an exact tag filter, distinct from the semantic query. For a big folder, list returns next_offset; pass it back as offset to page deeper (with limit up to 500). action=read with a file_id (copied from a prior list/search) fetches one file; action=write with file_name + content (or source_url) SAVES a text/media file into /files; action=annotate with file_id + notes attaches vector-embedded metadata to a /files entry; action=move with file_id + a target path (e.g. path='/raws/demos') reorganizes a raw between /raws folders; action=rename with path + new_name (+ file_id for a file) renames a /files or /temp file or folder in place — use it to keep the directory tidy (e.g. fix a character folder or file name). When the user references their own footage, brand assets, logos, music, a brief, a script, or raw clips ('use my product photo', 'the video I uploaded', 'my brand logo', 'that clip of the founder'), browse_files search or list first to find it rather than asking them to re-upload or paste a URL. For text files (md/txt/csv/srt/vtt/json), read returns the full text_content inline so you can read a brief or script directly. For images/video/audio/pdf, read returns only a durable view_url — use that URL as media: drop it into the timeline via editor_action add_layer/set_layer_media, or pass it into primitive routes (images/edit source_image_url, videos/generate input_references, videos/download). Never invent file ids, paths, or view URLs — always list or search to discover the real ones first.",
|
|
122
122
|
"SAVING CONTEXT TO MY FILES: browse_files action=write persists durable, reusable context the user (and future chats/agents) can read back. write saves text files (md/txt/csv/json/srt/vtt) via content, and IMPORTS media into the library via source_url (a durable URL from a finished primitive job or existing asset) — use source_url to persist a generated asset the user will want again (a character sprite card, a recurring background, a logo variant) instead of leaving it stranded in a job result. Use write to capture the Getting Started artifacts as Markdown: a product About.md (basic offer/product context) or a deeper Interview.md, plus awareness-levels.md, persuasive-angles.md, and ad-hooks.md distilled from the matching brainstorm outputs. When a brainstorm job returns strategy the user wants to keep, offer to save it (or save it) as the corresponding .md so it is not lost when the chat ends. Namescope every write under the right product/offer folder (see MY FILES IS MULTI-OFFER) — e.g. folder_path='acme-skincare', file_name='About.md'. Pass notes on write (or annotate afterwards) for any asset worth finding again: notes describe what the file is, who/what it depicts, and when to use it, and they are vector-embedded so action=search finds the file by meaning in future sessions. Before overwriting an existing file of the same name in the same folder, confirm with the user (writing the same name replaces it).",
|
|
123
123
|
"RECURRING CHARACTERS ARE FIRST-CLASS: mascots, spokespeople, avatars, and product characters that must look the same across videos live in a dedicated, browsable home — the /files/characters/ folder, one subfolder per character keyed by a URL-safe SLUG (lowercase, hyphenated), e.g. /files/characters/zara/. Each character is a TRIO of files in that folder: (1) <character_id>.json — the machine-readable MANIFEST, named after the character's id which is 'character_'+slug (e.g. slug 'zara' → id 'character_zara' → file character_zara.json; the id ALREADY carries the 'character_' prefix, so never double it to character_character_zara.json): { id:'character_<slug>', slug, name, role, appearance (face/build/hair/skin), wardrobe, palette (signature colors), voice (tone/accent/energy), do, dont, sprite_card_path:'/files/characters/<slug>/character_sprite_card.png', about_path:'/files/characters/<slug>/character_about.md', created_at }; (2) character_sprite_card.png — ONE reference-sheet image (full body front/side/back plus a face close-up on a neutral background); (3) character_about.md — the prose the .json summarizes, for richer wording when prompting. AWARENESS: when a user refers to 'our mascot', 'the same character', 'the fox', or names a character, FIRST browse_files list path='/files/characters' (or search) to see who already exists and read that character's manifest + about — never re-imagine a saved character from memory; that is how characters drift off-model. CONSISTENCY: on every generation featuring the character, pass the sprite card's view_url as the reference input (prompt_attachments for images/generate + images/edit, input_references for videos/generate and generate_layer) and lift wording from the manifest/about into the prompt.",
|
|
@@ -1550,7 +1550,9 @@ Return strictly valid JSON matching this schema:
|
|
|
1550
1550
|
"arc": string,
|
|
1551
1551
|
"peak_moment": string,
|
|
1552
1552
|
"mechanism": string,
|
|
1553
|
+
"contrast": string,
|
|
1553
1554
|
"humor": string,
|
|
1555
|
+
"audio_visual_tension": string,
|
|
1554
1556
|
"delivery": string,
|
|
1555
1557
|
"preserve": [string, ...]
|
|
1556
1558
|
},
|
|
@@ -1598,10 +1600,12 @@ VIRAL_DNA RULES:
|
|
|
1598
1600
|
- "arc": how the feeling MOVES across the runtime (e.g. "curiosity → mounting tension → relief-laugh", "flat setup → sudden absurd turn").
|
|
1599
1601
|
- "peak_moment": the single beat that lands the emotional hit — the punchline, the reveal, the gut-punch, the beat-drop — and roughly when.
|
|
1600
1602
|
- "mechanism": WHY it lands — the technique doing the work (comedic timing, irony, subversion of expectation, escalation, deadpan intonation, relatability, a trending-sound drop synced to the visual). Weigh the AUDIO here: on TikTok/Reels the trending sound or music bed is frequently the single biggest driver of the emotional hit, so if a recognizable trending sound, a beat drop, or a music swell is doing the work, name it as the mechanism.
|
|
1601
|
-
- "
|
|
1603
|
+
- "contrast": the specific mismatch / juxtaposition that creates the feeling. This is CRITICAL for irony- or sass-driven formats: identify the actual tension, not just the visible gesture. Examples: "hard, macho soundtrack over a dainty office-laptop scene", "overconfident delivery about a trivial preference", "luxury attitude applied to a mundane workflow". If there is no real mismatch, write "none".
|
|
1604
|
+
- "humor": if it's funny, state what the JOKE actually is and why it works, concretely enough that a remixer can rebuild the same joke with new subject matter; write "none" if the format is not comedic. Focus on the underlying bit, not surface choreography.
|
|
1605
|
+
- "audio_visual_tension": describe how the SOUND and VISUALS interact emotionally: whether the soundtrack / vocal cadence reinforces the visuals, undercuts them, or creates a deliberate mismatch. Name the exact pairing if that's the joke or swagger engine; write "none" only if audio contributes nothing.
|
|
1602
1606
|
- "delivery": the performance cues to preserve so the vibe survives — speech intonation / cadence (from the AUDIO TRANSCRIPT), the pause held before the punchline, a facial beat or reaction, and especially the SOUND: the music / trending-audio bed, where the beat drop or lyric hit lands relative to the cut, a music swell, an sfx sting, the deadpan-then-cut. This is what keeps intonation, comedic timing, and the audio-driven vibe from being lost in a remix.
|
|
1603
1607
|
- "preserve": 2-4 emotional beats a remix MUST keep to reproduce the same feeling (e.g. "keep the beat drop landing exactly on the reveal cut", "hold the silent beat before the reveal", "keep the flat unbothered delivery", "the payoff must undercut the buildup, not confirm it").
|
|
1604
|
-
Use BOTH the frames and the AUDIO TRANSCRIPT block above. Never leave emotional_punch empty — infer the feeling from the frames, captions, and pacing even when there is no audio, and when the audio is music-only, treat the sound itself as the emotional engine.
|
|
1608
|
+
Use BOTH the frames and the AUDIO TRANSCRIPT block above. Never leave emotional_punch empty — infer the feeling from the frames, captions, and pacing even when there is no audio, and when the audio is music-only, treat the sound itself as the emotional engine. Do NOT over-index on a single gesture or hand movement if the real emotional engine is the character attitude, the soundtrack choice, the setting mismatch, or the audio-vs-visual contrast.
|
|
1605
1609
|
- "preserve": 3-5 concrete elements a remixer MUST keep to preserve the format (e.g., "Pinned comment stays on-screen the whole video", "Wedding dance payoff shot").
|
|
1606
1610
|
- "avoid": 3-5 things that would break the format if changed (e.g., "Do not remove the on-screen text overlay", "Do not slow the cuts below 1.5s").
|
|
1607
1611
|
- "promotions": 3-5 categories of product / service / offer this exact format could be reused to promote, WITHOUT changing the beat structure or hook mechanic. Each item is a concrete pitchable use-case, not a genre. Examples: "SaaS free-trial signup (before/after workflow)", "Fitness challenge (7-day transformation)", "Local restaurant grand-opening (line-out-the-door proof)", "Ebook / course launch (result-first payoff)", "Real-estate open-house (walk-through reveal)". Anchor each promotion to the same viral tactic captured in "hook" — do not invent unrelated verticals.
|
|
@@ -1781,7 +1785,16 @@ function resolveFontSize(input) {
|
|
|
1781
1785
|
return normalizeFontSize(input.fontSize, input.canvasHeight);
|
|
1782
1786
|
}
|
|
1783
1787
|
const DEFAULT_EMOTIONAL_PUNCH = {
|
|
1784
|
-
core_emotion: "",
|
|
1788
|
+
core_emotion: "",
|
|
1789
|
+
tone: "",
|
|
1790
|
+
arc: "",
|
|
1791
|
+
peak_moment: "",
|
|
1792
|
+
mechanism: "",
|
|
1793
|
+
contrast: "",
|
|
1794
|
+
humor: "",
|
|
1795
|
+
audio_visual_tension: "",
|
|
1796
|
+
delivery: "",
|
|
1797
|
+
preserve: []
|
|
1785
1798
|
};
|
|
1786
1799
|
export const DEFAULT_VIRAL_DNA = {
|
|
1787
1800
|
trend_tagline: "", hook: "", retention: "", payoff: "", emotional_punch: { ...DEFAULT_EMOTIONAL_PUNCH }, preserve: [], avoid: [], promotions: [], keywords: []
|
|
@@ -1800,7 +1813,9 @@ function normalizeEmotionalPunch(value) {
|
|
|
1800
1813
|
arc: asString(record.arc ?? record.emotional_arc),
|
|
1801
1814
|
peak_moment: asString(record.peak_moment ?? record.peakMoment),
|
|
1802
1815
|
mechanism: asString(record.mechanism),
|
|
1816
|
+
contrast: asString(record.contrast),
|
|
1803
1817
|
humor: asString(record.humor),
|
|
1818
|
+
audio_visual_tension: asString(record.audio_visual_tension ?? record.audioVisualTension),
|
|
1804
1819
|
delivery: asString(record.delivery),
|
|
1805
1820
|
preserve: asStringArray(record.preserve).slice(0, 4)
|
|
1806
1821
|
};
|
|
@@ -1865,12 +1880,26 @@ function buildEditorHarnessPrompt(input) {
|
|
|
1865
1880
|
const roleVocab = SCENE_ROLE_VOCAB.join(" | ");
|
|
1866
1881
|
const formatVocab = CONTENT_FORMATS.join(" | ");
|
|
1867
1882
|
const audioDigest = buildAudioDigestForPrompt(input.transcript ?? null);
|
|
1883
|
+
const emotional = input.viralDna?.emotional_punch;
|
|
1884
|
+
const emotionalSnapshot = emotional && [
|
|
1885
|
+
emotional.core_emotion,
|
|
1886
|
+
emotional.tone,
|
|
1887
|
+
emotional.arc,
|
|
1888
|
+
emotional.peak_moment,
|
|
1889
|
+
emotional.mechanism,
|
|
1890
|
+
emotional.contrast,
|
|
1891
|
+
emotional.humor,
|
|
1892
|
+
emotional.audio_visual_tension,
|
|
1893
|
+
emotional.delivery
|
|
1894
|
+
].some((value) => value && value.trim().length > 0)
|
|
1895
|
+
? `\nVIRAL DNA FEELING SNAPSHOT (operationalize this in the harness; do NOT drift from it):\n- core_emotion: ${emotional.core_emotion || "—"}\n- tone: ${emotional.tone || "—"}\n- arc: ${emotional.arc || "—"}\n- peak_moment: ${emotional.peak_moment || "—"}\n- mechanism: ${emotional.mechanism || "—"}\n- contrast: ${emotional.contrast || "—"}\n- humor: ${emotional.humor || "—"}\n- audio_visual_tension: ${emotional.audio_visual_tension || "—"}\n- delivery: ${emotional.delivery || "—"}\n- preserve: ${(emotional.preserve || []).join("; ") || "—"}\n`
|
|
1896
|
+
: "";
|
|
1868
1897
|
return `You are Vidfarm's EDITING DIRECTOR. You are studying a short-form video to write an EDITOR HARNESS — a compact, technical brief that lets another AI editor recreate this video's STYLE (not its exact content) in one clean pass on a timeline editor (scenes, captions, audio, transitions, keyframes).
|
|
1869
1898
|
|
|
1870
1899
|
This is DIFFERENT from "viral DNA" (why the video works / what to preserve). The harness is HOW to edit: pace, typography, b-roll usage, transitions, audio bed, and the role each scene plays. Be concrete and directive — every field should be something an editor can act on, not vibes.
|
|
1871
1900
|
|
|
1872
1901
|
Video duration: ${input.durationSeconds.toFixed(2)} seconds.
|
|
1873
|
-
${frameListing}${cutListing}${audioDigest}${promptHint}
|
|
1902
|
+
${frameListing}${cutListing}${audioDigest}${emotionalSnapshot}${promptHint}
|
|
1874
1903
|
Return strictly valid JSON matching this schema:
|
|
1875
1904
|
{
|
|
1876
1905
|
"format": string, // one of: ${formatVocab}
|
|
@@ -1933,7 +1962,7 @@ RULES:
|
|
|
1933
1962
|
- Ground pacing numbers on the detected cuts + duration when cuts are supplied; otherwise estimate from the frames. Round avg_scene_seconds and cuts_per_10s to at most 2 decimals.
|
|
1934
1963
|
- "scenes": 3-8 entries covering the whole runtime in order, each tied to a real moment you see. Reserve importance "critical" for the 1-3 beats that actually carry the style/retention (usually the hook + payoff).
|
|
1935
1964
|
- Prefer the caption_style / transition / shot_kind vocabulary above so the editor can map straight to presets; a free-form label is fine when nothing fits.
|
|
1936
|
-
- "emotional": this is HOW to edit so the FEELING survives — the vibe, the joke, the intonation, and the SOUND are the first things a remix flattens, so make these directive. Use the AUDIO TRANSCRIPT block: on TikTok/Reels the trending sound / music bed is often the biggest carrier of the feeling. "target_feeling" = the emotion the recreated edit must produce; "tone" = the register to hold; "comedic_timing" = the exact editing move that sells the joke (the beat held before a cut, the deadpan-then-punch, the sfx sting or beat-drop on the button word) or "none"; "intonation" = the vocal delivery/cadence to preserve on any VO or on-cam speech (or "none" if silent) — flag it if the speech must be re-voiced in the SAME cadence; "vibe_anchors" = 2-4 concrete edit choices that carry the emotion, and include the audio ones (e.g. "keep the trending sound and land the beat drop on the reveal cut", "swell the music on the payoff", "hold a full beat of silence before the reveal cut", "cut hard on the punch word"). Infer the feeling from frames + pacing even without audio, and when the audio is music-only treat the sound as the emotional engine; never leave it empty.
|
|
1965
|
+
- "emotional": this is HOW to edit so the FEELING survives — the vibe, the joke, the intonation, and the SOUND are the first things a remix flattens, so make these directive. Use the AUDIO TRANSCRIPT block: on TikTok/Reels the trending sound / music bed is often the biggest carrier of the feeling. "target_feeling" = the emotion the recreated edit must produce; "tone" = the register to hold; "comedic_timing" = the exact editing move that sells the joke (the beat held before a cut, the deadpan-then-punch, the sfx sting or beat-drop on the button word) or "none"; "intonation" = the vocal delivery/cadence to preserve on any VO or on-cam speech (or "none" if silent) — flag it if the speech must be re-voiced in the SAME cadence; "vibe_anchors" = 2-4 concrete edit choices that carry the emotion, and include the audio ones (e.g. "keep the trending sound and land the beat drop on the reveal cut", "swell the music on the payoff", "hold a full beat of silence before the reveal cut", "cut hard on the punch word"). When the viral-DNA snapshot above names a contrast or audio_visual_tension, convert that into actionable edit moves instead of drifting to superficial gestures. Infer the feeling from frames + pacing even without audio, and when the audio is music-only treat the sound as the emotional engine; never leave it empty.
|
|
1937
1966
|
- Keep every string tight and directive. Never invent audio you cannot infer — say "none"/"unknown" instead.
|
|
1938
1967
|
- Output ONLY the JSON object.`;
|
|
1939
1968
|
}
|
|
@@ -2030,7 +2059,8 @@ async function runEditorHarnessPass(input) {
|
|
|
2030
2059
|
frameTimestamps: input.frames.map((f) => f.timestamp),
|
|
2031
2060
|
sceneCuts: input.sceneCuts,
|
|
2032
2061
|
userPrompt: input.userPrompt ?? null,
|
|
2033
|
-
transcript: input.transcript ?? null
|
|
2062
|
+
transcript: input.transcript ?? null,
|
|
2063
|
+
viralDna: input.viralDna ?? null
|
|
2034
2064
|
});
|
|
2035
2065
|
const attempt = await callVisionWithFallback({ prompt, frames: input.frames, keys: input.keys });
|
|
2036
2066
|
return normalizeEditorHarness(attempt.result);
|
|
@@ -2437,13 +2467,12 @@ export async function smartDecomposeVideo(input) {
|
|
|
2437
2467
|
sceneCuts,
|
|
2438
2468
|
transcript
|
|
2439
2469
|
});
|
|
2440
|
-
// Phase 2 — vision, OCR, product-placement
|
|
2441
|
-
//
|
|
2442
|
-
//
|
|
2443
|
-
//
|
|
2444
|
-
//
|
|
2445
|
-
|
|
2446
|
-
const [attempt, ocrFrames, placementSurfaces, editorHarness] = await Promise.all([
|
|
2470
|
+
// Phase 2 — vision, OCR, and product-placement run concurrently, all now
|
|
2471
|
+
// transcript-aware. OCR and placement failures are non-fatal. The
|
|
2472
|
+
// placement call is DELIBERATELY separate from the caption/scene vision
|
|
2473
|
+
// call so it can never dilute timeline-layer quality — it reuses the same
|
|
2474
|
+
// sparse frames (no extra ffmpeg) plus the transcript from phase 1.
|
|
2475
|
+
const [attempt, ocrFrames, placementSurfaces] = await Promise.all([
|
|
2447
2476
|
callVisionWithFallback({
|
|
2448
2477
|
prompt,
|
|
2449
2478
|
frames,
|
|
@@ -2461,19 +2490,6 @@ export async function smartDecomposeVideo(input) {
|
|
|
2461
2490
|
}).catch((err) => {
|
|
2462
2491
|
console.error("smart decompose placement pass failed", err instanceof Error ? err.message : err);
|
|
2463
2492
|
return [];
|
|
2464
|
-
}),
|
|
2465
|
-
// Editor harness — technical editing direction. Isolated call in the same
|
|
2466
|
-
// burst (reuses these frames + the detected cuts + the transcript); non-fatal.
|
|
2467
|
-
runEditorHarnessPass({
|
|
2468
|
-
frames,
|
|
2469
|
-
keys: input.keys,
|
|
2470
|
-
durationSeconds: meta.durationSeconds,
|
|
2471
|
-
sceneCuts,
|
|
2472
|
-
userPrompt: input.userPrompt ?? null,
|
|
2473
|
-
transcript
|
|
2474
|
-
}).catch((err) => {
|
|
2475
|
-
console.error("smart decompose editor-harness pass failed", err instanceof Error ? err.message : err);
|
|
2476
|
-
return DEFAULT_EDITOR_HARNESS;
|
|
2477
2493
|
})
|
|
2478
2494
|
]);
|
|
2479
2495
|
devLog("hyperframes.smart_decompose.ocr_completed", {
|
|
@@ -2559,6 +2575,21 @@ export async function smartDecomposeVideo(input) {
|
|
|
2559
2575
|
if (input.trendTaglineHint && input.trendTaglineHint.trim().length > 0) {
|
|
2560
2576
|
viralDna.trend_tagline = input.trendTaglineHint.trim();
|
|
2561
2577
|
}
|
|
2578
|
+
// Editor harness — technical editing direction. Run AFTER viral DNA is
|
|
2579
|
+
// normalized so the harness can operationalize the exact emotional punch
|
|
2580
|
+
// instead of guessing independently and drifting from the decompose result.
|
|
2581
|
+
const editorHarness = await runEditorHarnessPass({
|
|
2582
|
+
frames,
|
|
2583
|
+
keys: input.keys,
|
|
2584
|
+
durationSeconds: meta.durationSeconds,
|
|
2585
|
+
sceneCuts,
|
|
2586
|
+
userPrompt: input.userPrompt ?? null,
|
|
2587
|
+
transcript,
|
|
2588
|
+
viralDna
|
|
2589
|
+
}).catch((err) => {
|
|
2590
|
+
console.error("smart decompose editor-harness pass failed", err instanceof Error ? err.message : err);
|
|
2591
|
+
return DEFAULT_EDITOR_HARNESS;
|
|
2592
|
+
});
|
|
2562
2593
|
// NOTE: the per-scene clip-annotation + content-format pass is deliberately
|
|
2563
2594
|
// NOT run here — it is a separate vision call that would risk the ~60s origin
|
|
2564
2595
|
// timeout on longer videos. /auto-decompose seeds scene-annotations.json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mevdragon/vidfarm-devcli",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.5",
|
|
4
4
|
"description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"scripts": {
|
|
33
33
|
"dev": "tsx --import ./src/instrument.ts watch src/index.ts",
|
|
34
34
|
"dev:frontend": "node scripts/build-homepage-client.mjs --watch",
|
|
35
|
-
"dev:cli": "tsx src/cli.ts
|
|
35
|
+
"dev:cli": "tsx src/cli.ts",
|
|
36
36
|
"build:frontend": "node scripts/build-homepage-client.mjs",
|
|
37
37
|
"build:hyperframes-editor": "cd demo && node scripts/build.mjs",
|
|
38
38
|
"operator:template-artifact": "node scripts/platform-operator-template-artifact.mjs",
|