@mevdragon/vidfarm-devcli 0.20.1 → 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 +96 -10
- package/SKILL.platform.md +3 -3
- package/dist/src/app.js +326 -24
- package/dist/src/cli.js +253 -26
- package/dist/src/devcli/sync.js +66 -9
- package/dist/src/editor-chat.js +2 -2
- package/dist/src/services/hyperframes.js +162 -28
- package/package.json +1 -1
|
@@ -469,7 +469,7 @@ export class HyperframesService {
|
|
|
469
469
|
});
|
|
470
470
|
const timestamp = Date.now().toString(36);
|
|
471
471
|
const renderId = `vfimg-${metadata.compositionId.slice(0, 40)}-${timestamp}-${randomUUID().slice(0, 8)}`;
|
|
472
|
-
const framesDir = path.join(config.VIDFARM_DATA_DIR, "
|
|
472
|
+
const framesDir = path.join(config.VIDFARM_DATA_DIR, "vidfarm-projects", `${renderId}-frames`);
|
|
473
473
|
await mkdir(framesDir, { recursive: true });
|
|
474
474
|
devLog("hyperframes.render_still.start", {
|
|
475
475
|
composition_id: metadata.compositionId,
|
|
@@ -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
|
|
@@ -3113,7 +3247,7 @@ async function resolveHyperframesStack() {
|
|
|
3113
3247
|
return value;
|
|
3114
3248
|
}
|
|
3115
3249
|
async function stageProject(input) {
|
|
3116
|
-
const root = path.join(config.VIDFARM_DATA_DIR, "
|
|
3250
|
+
const root = path.join(config.VIDFARM_DATA_DIR, "vidfarm-projects");
|
|
3117
3251
|
const projectDir = path.join(root, `${Date.now().toString(36)}-${safeIdentifier(input.compositionId)}-${randomUUID().slice(0, 8)}`);
|
|
3118
3252
|
await rm(projectDir, { recursive: true, force: true });
|
|
3119
3253
|
await mkdir(projectDir, { recursive: true });
|
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": {
|