@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.
@@ -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, "hyperframes-projects", `${renderId}-frames`);
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 DEFAULT_VIRAL_DNA = {
1741
- trend_tagline: "", hook: "", retention: "", payoff: "", preserve: [], avoid: [], promotions: [], keywords: []
1783
+ const DEFAULT_EMOTIONAL_PUNCH = {
1784
+ core_emotion: "", tone: "", arc: "", peak_moment: "", mechanism: "", humor: "", delivery: "", preserve: []
1785
+ };
1786
+ export const DEFAULT_VIRAL_DNA = {
1787
+ trend_tagline: "", hook: "", retention: "", payoff: "", emotional_punch: { ...DEFAULT_EMOTIONAL_PUNCH }, preserve: [], avoid: [], promotions: [], keywords: []
1742
1788
  };
1789
+ function normalizeEmotionalPunch(value) {
1790
+ if (!value || typeof value !== "object")
1791
+ return { ...DEFAULT_EMOTIONAL_PUNCH };
1792
+ const record = value;
1793
+ const asString = (v) => typeof v === "string" ? v.trim() : "";
1794
+ const asStringArray = (v) => Array.isArray(v)
1795
+ ? v.map((item) => (typeof item === "string" ? item.trim() : "")).filter((item) => item.length > 0)
1796
+ : [];
1797
+ return {
1798
+ core_emotion: asString(record.core_emotion ?? record.coreEmotion),
1799
+ tone: asString(record.tone),
1800
+ arc: asString(record.arc ?? record.emotional_arc),
1801
+ peak_moment: asString(record.peak_moment ?? record.peakMoment),
1802
+ mechanism: asString(record.mechanism),
1803
+ humor: asString(record.humor),
1804
+ delivery: asString(record.delivery),
1805
+ preserve: asStringArray(record.preserve).slice(0, 4)
1806
+ };
1807
+ }
1743
1808
  function normalizeViralDna(value) {
1744
1809
  if (!value || typeof value !== "object")
1745
1810
  return DEFAULT_VIRAL_DNA;
@@ -1753,6 +1818,7 @@ function normalizeViralDna(value) {
1753
1818
  hook: asString(record.hook),
1754
1819
  retention: asString(record.retention),
1755
1820
  payoff: asString(record.payoff),
1821
+ emotional_punch: normalizeEmotionalPunch(record.emotional_punch ?? record.emotionalPunch),
1756
1822
  preserve: asStringArray(record.preserve),
1757
1823
  avoid: asStringArray(record.avoid),
1758
1824
  promotions: asStringArray(record.promotions),
@@ -1775,6 +1841,7 @@ export const DEFAULT_EDITOR_HARNESS = {
1775
1841
  broll: { reliance: "none", sourcing: "", shot_kinds: [], cadence: "" },
1776
1842
  transitions: { default: "cut", intro: "", outro: "", usage: "" },
1777
1843
  audio: { voiceover: "", music: "", sfx: "", captions_from: "" },
1844
+ emotional: { target_feeling: "", tone: "", comedic_timing: "", intonation: "", vibe_anchors: [] },
1778
1845
  scenes: [],
1779
1846
  important_scenes: [],
1780
1847
  editing_bias: [],
@@ -1797,12 +1864,13 @@ function buildEditorHarnessPrompt(input) {
1797
1864
  : "";
1798
1865
  const roleVocab = SCENE_ROLE_VOCAB.join(" | ");
1799
1866
  const formatVocab = CONTENT_FORMATS.join(" | ");
1867
+ const audioDigest = buildAudioDigestForPrompt(input.transcript ?? null);
1800
1868
  return `You are Vidfarm's EDITING DIRECTOR. You are studying a short-form video to write an EDITOR HARNESS — a compact, technical brief that lets another AI editor recreate this video's STYLE (not its exact content) in one clean pass on a timeline editor (scenes, captions, audio, transitions, keyframes).
1801
1869
 
1802
1870
  This is DIFFERENT from "viral DNA" (why the video works / what to preserve). The harness is HOW to edit: pace, typography, b-roll usage, transitions, audio bed, and the role each scene plays. Be concrete and directive — every field should be something an editor can act on, not vibes.
1803
1871
 
1804
1872
  Video duration: ${input.durationSeconds.toFixed(2)} seconds.
1805
- ${frameListing}${cutListing}${promptHint}
1873
+ ${frameListing}${cutListing}${audioDigest}${promptHint}
1806
1874
  Return strictly valid JSON matching this schema:
1807
1875
  {
1808
1876
  "format": string, // one of: ${formatVocab}
@@ -1839,6 +1907,13 @@ Return strictly valid JSON matching this schema:
1839
1907
  "sfx": string, // e.g. "whoosh on cuts" | "none"
1840
1908
  "captions_from": string // "voiceover" | "on-screen text" | "none"
1841
1909
  },
1910
+ "emotional": {
1911
+ "target_feeling": string, // the emotion the edit must reproduce (the vibe/joke that makes it land)
1912
+ "tone": string, // register to hold: comedic | deadpan | earnest | dramatic | wholesome | hype | ...
1913
+ "comedic_timing": string, // where the beat/pause/cut sells the joke; "none" if not comedic
1914
+ "intonation": string, // vocal delivery/cadence to preserve on VO or on-cam speech; "none" if silent
1915
+ "vibe_anchors": [string, ...] // 2-4 concrete edit moves that carry the emotion
1916
+ },
1842
1917
  "scenes": [{
1843
1918
  "role": string, // ${roleVocab}
1844
1919
  "timestamp": string, // human range, e.g. "0:00-0:03"
@@ -1858,6 +1933,7 @@ RULES:
1858
1933
  - Ground pacing numbers on the detected cuts + duration when cuts are supplied; otherwise estimate from the frames. Round avg_scene_seconds and cuts_per_10s to at most 2 decimals.
1859
1934
  - "scenes": 3-8 entries covering the whole runtime in order, each tied to a real moment you see. Reserve importance "critical" for the 1-3 beats that actually carry the style/retention (usually the hook + payoff).
1860
1935
  - Prefer the caption_style / transition / shot_kind vocabulary above so the editor can map straight to presets; a free-form label is fine when nothing fits.
1936
+ - "emotional": this is HOW to edit so the FEELING survives — the vibe, the joke, the intonation, and the SOUND are the first things a remix flattens, so make these directive. Use the AUDIO TRANSCRIPT block: on TikTok/Reels the trending sound / music bed is often the biggest carrier of the feeling. "target_feeling" = the emotion the recreated edit must produce; "tone" = the register to hold; "comedic_timing" = the exact editing move that sells the joke (the beat held before a cut, the deadpan-then-punch, the sfx sting or beat-drop on the button word) or "none"; "intonation" = the vocal delivery/cadence to preserve on any VO or on-cam speech (or "none" if silent) — flag it if the speech must be re-voiced in the SAME cadence; "vibe_anchors" = 2-4 concrete edit choices that carry the emotion, and include the audio ones (e.g. "keep the trending sound and land the beat drop on the reveal cut", "swell the music on the payoff", "hold a full beat of silence before the reveal cut", "cut hard on the punch word"). Infer the feeling from frames + pacing even without audio, and when the audio is music-only treat the sound as the emotional engine; never leave it empty.
1861
1937
  - Keep every string tight and directive. Never invent audio you cannot infer — say "none"/"unknown" instead.
1862
1938
  - Output ONLY the JSON object.`;
1863
1939
  }
@@ -1880,6 +1956,7 @@ function normalizeEditorHarness(raw) {
1880
1956
  const broll = asPlainRecord(rec.broll);
1881
1957
  const transitions = asPlainRecord(rec.transitions);
1882
1958
  const audio = asPlainRecord(rec.audio);
1959
+ const emotional = asPlainRecord(rec.emotional);
1883
1960
  const scenesRaw = Array.isArray(rec.scenes) ? rec.scenes : [];
1884
1961
  const scenes = scenesRaw
1885
1962
  .map((s) => {
@@ -1931,6 +2008,13 @@ function normalizeEditorHarness(raw) {
1931
2008
  sfx: readAnnotationString(audio.sfx),
1932
2009
  captions_from: readAnnotationString(audio.captions_from)
1933
2010
  },
2011
+ emotional: {
2012
+ target_feeling: readAnnotationString(emotional.target_feeling),
2013
+ tone: readAnnotationString(emotional.tone),
2014
+ comedic_timing: readAnnotationString(emotional.comedic_timing),
2015
+ intonation: readAnnotationString(emotional.intonation),
2016
+ vibe_anchors: readAnnotationStringList(emotional.vibe_anchors, 4)
2017
+ },
1934
2018
  scenes,
1935
2019
  important_scenes: readAnnotationStringList(rec.important_scenes, 8),
1936
2020
  editing_bias: readAnnotationStringList(rec.editing_bias, 8),
@@ -1945,7 +2029,8 @@ async function runEditorHarnessPass(input) {
1945
2029
  durationSeconds: input.durationSeconds,
1946
2030
  frameTimestamps: input.frames.map((f) => f.timestamp),
1947
2031
  sceneCuts: input.sceneCuts,
1948
- userPrompt: input.userPrompt ?? null
2032
+ userPrompt: input.userPrompt ?? null,
2033
+ transcript: input.transcript ?? null
1949
2034
  });
1950
2035
  const attempt = await callVisionWithFallback({ prompt, frames: input.frames, keys: input.keys });
1951
2036
  return normalizeEditorHarness(attempt.result);
@@ -2313,13 +2398,22 @@ export async function smartDecomposeVideo(input) {
2313
2398
  });
2314
2399
  // Sparse frames for the vision pass (scenes, captions, style, viral_dna)
2315
2400
  // and dense frames for the OCR pass (deterministic caption timing).
2316
- // Extract both up front so the OCR API calls can run in parallel with
2317
- // the main vision call.
2318
- const [frames, ocrRawFrames, audioPath, sceneCuts] = await Promise.all([
2401
+ // Phase 1 deterministic ffmpeg/whisper work up front. Transcription is
2402
+ // pulled INTO this phase (chained off the audio extract) so the transcript
2403
+ // is ready BEFORE the vision + harness passes run: emotional punch on
2404
+ // short-form video is frequently carried by the AUDIO (a trending sound, a
2405
+ // beat drop, vocal intonation), and the silent frames alone can't capture
2406
+ // it. The transcript overlaps with frame-sampling/OCR/cut-detection here
2407
+ // instead of with the vision call, so this stays roughly latency-neutral.
2408
+ const [frames, ocrRawFrames, transcript, sceneCuts] = await Promise.all([
2319
2409
  sampleFramesAsBase64(sourcePath, meta.durationSeconds, tempDir),
2320
2410
  extractDenseOcrFrames(sourcePath, meta.durationSeconds, tempDir),
2321
- extractAudioAsWav(sourcePath, tempDir).catch((err) => {
2322
- console.error("smart decompose audio extract failed", err instanceof Error ? err.message : err);
2411
+ extractAudioAsWav(sourcePath, tempDir)
2412
+ .then((audioPath) => audioPath
2413
+ ? transcribeAudioWithFallback({ audioPath, durationSeconds: meta.durationSeconds, keys: input.keys })
2414
+ : null)
2415
+ .catch((err) => {
2416
+ console.error("smart decompose audio extract/transcription failed", err instanceof Error ? err.message : err);
2323
2417
  return null;
2324
2418
  }),
2325
2419
  detectSceneCuts(sourcePath, meta.durationSeconds, tempDir).catch((err) => {
@@ -2330,7 +2424,7 @@ export async function smartDecomposeVideo(input) {
2330
2424
  devLog("hyperframes.smart_decompose.frames_sampled", {
2331
2425
  frame_count: frames.length,
2332
2426
  ocr_frame_count: ocrRawFrames.length,
2333
- has_audio_track: Boolean(audioPath),
2427
+ has_audio_track: Boolean(transcript),
2334
2428
  scene_cut_count: sceneCuts.length
2335
2429
  });
2336
2430
  const prompt = buildSmartDecomposePrompt({
@@ -2340,14 +2434,16 @@ export async function smartDecomposeVideo(input) {
2340
2434
  userPrompt: input.userPrompt ?? null,
2341
2435
  trendTaglineHint: input.trendTaglineHint ?? null,
2342
2436
  frameTimestamps: frames.map((f) => f.timestamp),
2343
- sceneCuts
2437
+ sceneCuts,
2438
+ transcript
2344
2439
  });
2345
- // Vision, OCR, transcription, and the product-placement scout run
2346
- // concurrently. OCR, transcription, and placement failures are all
2347
- // non-fatal. The placement call is DELIBERATELY a separate request from the
2348
- // caption/scene vision call so it can never dilute timeline-layer quality —
2349
- // it just reuses the same already-sampled sparse frames (no extra ffmpeg).
2350
- const [attempt, ocrFrames, transcript, placementSurfaces, editorHarness] = await Promise.all([
2440
+ // Phase 2 — vision, OCR, product-placement, and the editor harness run
2441
+ // concurrently, all now transcript-aware. OCR and placement failures are
2442
+ // non-fatal. The placement + harness calls are DELIBERATELY separate
2443
+ // requests from the caption/scene vision call so they can never dilute
2444
+ // timeline-layer quality they reuse the same sparse frames (no extra
2445
+ // ffmpeg) plus the transcript from phase 1.
2446
+ const [attempt, ocrFrames, placementSurfaces, editorHarness] = await Promise.all([
2351
2447
  callVisionWithFallback({
2352
2448
  prompt,
2353
2449
  frames,
@@ -2357,12 +2453,6 @@ export async function smartDecomposeVideo(input) {
2357
2453
  console.error("smart decompose OCR pass failed", err instanceof Error ? err.message : err);
2358
2454
  return [];
2359
2455
  }),
2360
- audioPath
2361
- ? transcribeAudioWithFallback({ audioPath, durationSeconds: meta.durationSeconds, keys: input.keys }).catch((err) => {
2362
- console.error("smart decompose transcription failed", err instanceof Error ? err.message : err);
2363
- return null;
2364
- })
2365
- : Promise.resolve(null),
2366
2456
  runPlacementSurfacePass({
2367
2457
  frames,
2368
2458
  keys: input.keys,
@@ -2373,13 +2463,14 @@ export async function smartDecomposeVideo(input) {
2373
2463
  return [];
2374
2464
  }),
2375
2465
  // Editor harness — technical editing direction. Isolated call in the same
2376
- // burst (reuses these frames + the detected cuts); non-fatal.
2466
+ // burst (reuses these frames + the detected cuts + the transcript); non-fatal.
2377
2467
  runEditorHarnessPass({
2378
2468
  frames,
2379
2469
  keys: input.keys,
2380
2470
  durationSeconds: meta.durationSeconds,
2381
2471
  sceneCuts,
2382
- userPrompt: input.userPrompt ?? null
2472
+ userPrompt: input.userPrompt ?? null,
2473
+ transcript
2383
2474
  }).catch((err) => {
2384
2475
  console.error("smart decompose editor-harness pass failed", err instanceof Error ? err.message : err);
2385
2476
  return DEFAULT_EDITOR_HARNESS;
@@ -2588,6 +2679,49 @@ export function buildSmartDecomposedHtml(input) {
2588
2679
  </body>
2589
2680
  </html>`;
2590
2681
  }
2682
+ export function buildSmartDecomposedData(input) {
2683
+ const { sourceUrl, result } = input;
2684
+ const durationSeconds = Number(result.durationSeconds.toFixed(3));
2685
+ return {
2686
+ summary: result.summary,
2687
+ scenes: result.scenes.map((scene) => ({
2688
+ slug: scene.slug,
2689
+ start: scene.start,
2690
+ duration: scene.duration,
2691
+ label: scene.label,
2692
+ description: scene.description,
2693
+ viral_note: scene.viral_note
2694
+ })),
2695
+ captions: result.captions.map((caption) => ({
2696
+ slug: caption.slug,
2697
+ text: caption.text,
2698
+ start: caption.start,
2699
+ duration: caption.duration,
2700
+ x: caption.x,
2701
+ y: caption.y,
2702
+ width: caption.width,
2703
+ height: caption.height,
2704
+ color: caption.color,
2705
+ background: caption.background,
2706
+ background_style: caption.background_style,
2707
+ font_family: caption.font_family,
2708
+ font_size: caption.font_size,
2709
+ font_weight: caption.font_weight,
2710
+ viral_note: caption.viral_note
2711
+ })),
2712
+ viralDna: result.viralDna,
2713
+ width: result.width,
2714
+ height: result.height,
2715
+ durationSeconds,
2716
+ source_assets: [{
2717
+ type: "video",
2718
+ url: sourceUrl,
2719
+ width: result.width,
2720
+ height: result.height,
2721
+ duration: durationSeconds
2722
+ }]
2723
+ };
2724
+ }
2591
2725
  // The public placeholder tile every blank scene starts on — a gray diagonal-
2592
2726
  // striped 720×1280 image with a dashed text box (uploaded once by
2593
2727
  // scripts/upload-scene-placeholder.ts). Hardcoded to the prod bucket URL (a
@@ -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, "hyperframes-projects");
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.1",
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": {