@vibeframe/cli 0.113.0 → 0.113.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/dist/index.js CHANGED
@@ -17334,6 +17334,19 @@ var require_src = __commonJS({
17334
17334
  });
17335
17335
 
17336
17336
  // ../ai-providers/dist/fal/FalProvider.js
17337
+ function estimateSeedanceVideoCostUsd(opts) {
17338
+ const shortSide = SEEDANCE_SHORT_SIDE[opts.resolution ?? "720p"] ?? 720;
17339
+ const [a, b] = (opts.aspectRatio ?? "16:9").split(":").map(Number);
17340
+ const ratioFactor = a > 0 && b > 0 ? Math.max(a, b) / Math.min(a, b) : 16 / 9;
17341
+ const longSide = Math.round(shortSide * ratioFactor);
17342
+ const tokensPerSecond = shortSide * longSide * 24 / 1024;
17343
+ let usdPerSecond = tokensPerSecond / 1e3 * SEEDANCE_USD_PER_1K_TOKENS;
17344
+ if (opts.fast && shortSide <= 720)
17345
+ usdPerSecond *= SEEDANCE_FAST_FACTOR;
17346
+ if (opts.hasVideoReference)
17347
+ usdPerSecond *= SEEDANCE_VIDEO_REF_FACTOR;
17348
+ return Number((usdPerSecond * Math.max(0, opts.durationSec)).toFixed(2));
17349
+ }
17337
17350
  function formatFalError(error) {
17338
17351
  if (!(error instanceof Error))
17339
17352
  return String(error);
@@ -17443,7 +17456,7 @@ function normaliseDuration(value) {
17443
17456
  return "auto";
17444
17457
  return Math.max(4, Math.min(15, Math.round(value)));
17445
17458
  }
17446
- var import_client, ENDPOINT_TEXT_TO_VIDEO, ENDPOINT_IMAGE_TO_VIDEO, ENDPOINT_REFERENCE_TO_VIDEO, DEFAULT_VARIANT, VALID_RESOLUTIONS, VALID_ASPECTS, FalProvider, falProvider;
17459
+ var import_client, ENDPOINT_TEXT_TO_VIDEO, ENDPOINT_IMAGE_TO_VIDEO, ENDPOINT_REFERENCE_TO_VIDEO, DEFAULT_VARIANT, VALID_RESOLUTIONS, VALID_ASPECTS, SEEDANCE_USD_PER_1K_TOKENS, SEEDANCE_FAST_FACTOR, SEEDANCE_VIDEO_REF_FACTOR, SEEDANCE_SHORT_SIDE, FalProvider, falProvider;
17447
17460
  var init_FalProvider = __esm({
17448
17461
  "../ai-providers/dist/fal/FalProvider.js"() {
17449
17462
  "use strict";
@@ -17463,6 +17476,10 @@ var init_FalProvider = __esm({
17463
17476
  DEFAULT_VARIANT = "seedance-2.0";
17464
17477
  VALID_RESOLUTIONS = ["480p", "720p", "1080p"];
17465
17478
  VALID_ASPECTS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "auto"];
17479
+ SEEDANCE_USD_PER_1K_TOKENS = 0.014;
17480
+ SEEDANCE_FAST_FACTOR = 0.8;
17481
+ SEEDANCE_VIDEO_REF_FACTOR = 0.6;
17482
+ SEEDANCE_SHORT_SIDE = { "480p": 480, "720p": 720, "1080p": 1080 };
17466
17483
  FalProvider = class {
17467
17484
  constructor() {
17468
17485
  this.id = "seedance";
@@ -18287,6 +18304,7 @@ __export(dist_exports2, {
18287
18304
  defineApiKey: () => defineApiKey,
18288
18305
  defineProvider: () => defineProvider,
18289
18306
  elevenLabsProvider: () => elevenLabsProvider,
18307
+ estimateSeedanceVideoCostUsd: () => estimateSeedanceVideoCostUsd,
18290
18308
  falProvider: () => falProvider,
18291
18309
  geminiProvider: () => geminiProvider,
18292
18310
  getAllApiKeys: () => getAllApiKeys,
@@ -27005,6 +27023,43 @@ vibe host setup all --write # write project/app config
27005
27023
  vibe host doctor all --json # verify readiness
27006
27024
  \`\`\`
27007
27025
 
27026
+ ## Native host goal loop
27027
+
27028
+ For long-running work, use Codex Goal mode, Claude Code \`/goal\`, or the
27029
+ host's equivalent as the outer loop. VibeFrame should not run a competing
27030
+ project-level goal loop; it provides \`--json\` commands, dry runs, cost caps,
27031
+ \`build-report.json\`, \`review-report.json\`, \`nextActions\`, \`safeToAutoRun\`,
27032
+ \`requiresConfirmation\`, \`fixOwner\`, deterministic repair, and render
27033
+ inspection for the host agent to reason over.
27034
+
27035
+ Copy-paste examples:
27036
+
27037
+ \`\`\`text
27038
+ /goal Build this VibeFrame project into renders/final.mp4. Use --json for every
27039
+ vibe command, run --dry-run before paid operations, use --max-cost 5 for builds
27040
+ unless the user sets another budget, read build-report.json and
27041
+ review-report.json before deciding the next action, prefer nextActions before
27042
+ guessing, run only safeToAutoRun:true actions automatically, and ask before
27043
+ requiresConfirmation:true actions. Treat retryWith as the compatibility
27044
+ fallback, fixOwner:"vibe" as CLI repair work, and fixOwner:"host-agent" as
27045
+ STORYBOARD.md, DESIGN.md, or composition edits.
27046
+ Stop only when renders/final.mp4 exists, duration and aspect ratio match the
27047
+ brief, inspect render --cheap has no errors, any AI review score is >= 90 when AI review is requested, and every
27048
+ remaining host-agent issue is fixed, accepted with rationale, or reported as
27049
+ blocked.
27050
+ \`\`\`
27051
+
27052
+ \`\`\`text
27053
+ /goal Finish this VibeFrame render using Claude Code goal mode as the outer
27054
+ loop. Use vibe context/schema when unsure, --json everywhere, dry-run before
27055
+ paid operations, budget cap via --max-cost 5, nextActions before guessing,
27056
+ build-report.json/review-report.json as loop state, and safeToAutoRun,
27057
+ requiresConfirmation, and fixOwner to decide between vibe scene repair and
27058
+ host-agent edits. Stop only after final MP4,
27059
+ target duration/aspect ratio, clean render inspection, any AI review score >= 90 when AI review is requested, and
27060
+ no unresolved unacknowledged host-agent issues.
27061
+ \`\`\`
27062
+
27008
27063
  ## Skills \u2014 USE THESE FIRST
27009
27064
 
27010
27065
  @SKILL.md
@@ -27500,6 +27555,9 @@ function clean(text) {
27500
27555
  function humanise(id) {
27501
27556
  return id.split(/[-_]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
27502
27557
  }
27558
+ function sceneCompositionId(beatId) {
27559
+ return `scene-${beatId}`;
27560
+ }
27503
27561
  function slugifySceneName(name) {
27504
27562
  const normalised = name.normalize("NFKD").replace(/[̀-ͯ]/g, "");
27505
27563
  const slug = normalised.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -27536,10 +27594,16 @@ function fitKineticFontSize(words) {
27536
27594
  function kenBurnsTween(scope, dur, endScale = 1.08) {
27537
27595
  return `tl.fromTo('${scope} .backdrop', { scale: 1.0 }, { scale: ${endScale}, duration: ${dur.toFixed(2)}, ease: 'none' }, 0);`;
27538
27596
  }
27597
+ function backdropMotionTweens(scope, dur, hasImage, endScale = 1.08) {
27598
+ const sweep = hasImage ? "" : `
27599
+ tl.fromTo('${scope} .ambient-sweep', { xPercent: -75, opacity: 0.18 }, { xPercent: 75, opacity: 0.62, duration: ${dur.toFixed(2)}, ease: 'sine.inOut' }, 0);`;
27600
+ return `${kenBurnsTween(scope, dur, endScale)}${sweep}`;
27601
+ }
27539
27602
  function buildPreset(input) {
27540
27603
  const id = input.id;
27541
27604
  const scope = `[data-composition-id="${id}"]`;
27542
- const headline = clean(input.headline) || humanise(id);
27605
+ const displayId = input.beatId ?? id;
27606
+ const headline = clean(input.headline) || humanise(displayId);
27543
27607
  const subhead = clean(input.subhead);
27544
27608
  const kicker = clean(input.kicker);
27545
27609
  const dur = input.duration;
@@ -27562,8 +27626,17 @@ function buildPreset(input) {
27562
27626
  position: absolute;
27563
27627
  inset: 0;
27564
27628
  background: radial-gradient(ellipse at center, #1a1a2e 0%, #000 70%);
27629
+ overflow: hidden;
27630
+ }
27631
+ ${scope} .ambient-sweep {
27632
+ position: absolute;
27633
+ inset: -22%;
27634
+ display: block;
27635
+ background: linear-gradient(115deg, rgba(255,255,255,0) 20%, rgba(0,201,255,0.34) 48%, rgba(142,45,226,0.26) 58%, rgba(255,255,255,0) 78%);
27636
+ filter: blur(12px);
27637
+ transform: rotate(8deg);
27565
27638
  }`;
27566
- const backdropMarkup = `<div class="backdrop"></div>`;
27639
+ const backdropMarkup = hasImage ? `<div class="backdrop"></div>` : `<div class="backdrop"><div class="ambient-sweep"></div></div>`;
27567
27640
  switch (input.preset) {
27568
27641
  case "simple": {
27569
27642
  const transcript = input.transcript;
@@ -27595,7 +27668,7 @@ function buildPreset(input) {
27595
27668
  body: `${backdropMarkup}
27596
27669
  <div class="caption" id="caption">${captionInner}</div>`,
27597
27670
  timeline: `${crossfadeTweens(scope, dur)}
27598
- ${kenBurnsTween(scope, dur)}
27671
+ ${backdropMotionTweens(scope, dur, hasImage)}
27599
27672
  ${captionTween}`
27600
27673
  };
27601
27674
  }
@@ -27634,12 +27707,12 @@ function buildPreset(input) {
27634
27707
  // scene's own fade-in handles the visual transition. Backdrop
27635
27708
  // Ken-Burns fills what used to be 80% dead static frame.
27636
27709
  timeline: `${crossfadeTweens(scope, dur)}
27637
- ${kenBurnsTween(scope, dur)}
27710
+ ${backdropMotionTweens(scope, dur, hasImage)}
27638
27711
  tl.from('${scope} #headline', { opacity: 0, scale: 0.92, duration: 0.55, ease: 'power3.out' }, 0.05);`
27639
27712
  };
27640
27713
  }
27641
27714
  case "explainer": {
27642
- const k = kicker || humanise(id).toUpperCase();
27715
+ const k = kicker || humanise(displayId).toUpperCase();
27643
27716
  const sub = subhead || "";
27644
27717
  const transcript = input.transcript;
27645
27718
  const useWordSync = !!(transcript && transcript.length > 0 && sub);
@@ -27681,7 +27754,7 @@ function buildPreset(input) {
27681
27754
  <div class="subtitle" id="subtitle">${subtitleInner}</div>` : ""}
27682
27755
  </div>`,
27683
27756
  timeline: `${crossfadeTweens(scope, dur)}
27684
- ${kenBurnsTween(scope, dur)}
27757
+ ${backdropMotionTweens(scope, dur, hasImage)}
27685
27758
  tl.from('${scope} #kicker', { opacity: 0, y: 16, duration: 0.4, ease: 'power2.out' }, 0.1);
27686
27759
  tl.from('${scope} #title', { opacity: 0, y: 60, duration: 0.7, ease: 'power3.out' }, 0.25);
27687
27760
  ${subtitleTween}`
@@ -27722,12 +27795,12 @@ function buildPreset(input) {
27722
27795
  body: `${backdropMarkup}
27723
27796
  <div class="kinetic">${wordSpans}</div>`,
27724
27797
  timeline: `${crossfadeTweens(scope, dur)}
27725
- ${kenBurnsTween(scope, dur)}
27798
+ ${backdropMotionTweens(scope, dur, hasImage)}
27726
27799
  ${tweens}`
27727
27800
  };
27728
27801
  }
27729
27802
  case "product-shot": {
27730
- const label = kicker || humanise(id);
27803
+ const label = kicker || humanise(displayId);
27731
27804
  const headlineFontPx = fitFontSize(headline, 72, 40, 50);
27732
27805
  return {
27733
27806
  css: `${scope} {
@@ -27761,7 +27834,7 @@ function buildPreset(input) {
27761
27834
  <div class="product-headline" id="headline">${esc(headline)}</div>${subhead ? `
27762
27835
  <div class="product-sub" id="subhead">${esc(subhead)}</div>` : ""}`,
27763
27836
  timeline: `${crossfadeTweens(scope, dur)}
27764
- tl.fromTo('${scope} .backdrop', { scale: 1.0 }, { scale: 1.12, duration: ${dur.toFixed(2)}, ease: 'none' }, 0);
27837
+ ${backdropMotionTweens(scope, dur, hasImage, 1.12)}
27765
27838
  tl.from('${scope} #label', { opacity: 0, x: -30, duration: 0.5, ease: 'power3.out' }, 0.2);
27766
27839
  tl.from('${scope} #headline', { opacity: 0, y: 40, duration: 0.6, ease: 'power3.out' }, 0.4);${subhead ? `
27767
27840
  tl.from('${scope} #subhead', { opacity: 0, y: 20, duration: 0.5, ease: 'power3.out' }, 0.65);` : ""}`
@@ -27822,9 +27895,10 @@ function emitSceneHtml(input) {
27822
27895
  if (input.width <= 0 || input.height <= 0) {
27823
27896
  throw new Error(`Invalid canvas dims: ${input.width}x${input.height}`);
27824
27897
  }
27825
- const id = input.id;
27898
+ const beatId = input.id;
27899
+ const id = sceneCompositionId(beatId);
27826
27900
  const dur = Number(input.duration.toFixed(3));
27827
- const parts = buildPreset({ ...input, id, duration: dur });
27901
+ const parts = buildPreset({ ...input, id, beatId, duration: dur });
27828
27902
  const audioBlock = input.audioPath ? `
27829
27903
  <audio
27830
27904
  id="narration"
@@ -27840,7 +27914,7 @@ function emitSceneHtml(input) {
27840
27914
  ${lottieLayer.markup}` : "";
27841
27915
  const lottieScript = lottieLayer ? `
27842
27916
  ${lottieLayer.script}` : "";
27843
- return `<template id="scene-${id}-template">
27917
+ return `<template id="${id}-template">
27844
27918
  <div data-composition-id="${id}" data-start="0" data-duration="${dur}" data-width="${input.width}" data-height="${input.height}">
27845
27919
  <style>
27846
27920
  ${parts.css}
@@ -27876,8 +27950,9 @@ function buildClipReference(opts) {
27876
27950
  const duration = Number(opts.duration.toFixed(3));
27877
27951
  const track = opts.trackIndex ?? 1;
27878
27952
  const src = opts.src ?? `compositions/scene-${opts.id}.html`;
27953
+ const compositionId = sceneCompositionId(opts.id);
27879
27954
  const zIndex = Math.max(1, 1e6 - Math.floor(start * 1e3));
27880
- return `<div class="clip" data-composition-id="${esc(opts.id)}" data-composition-src="${esc(src)}" data-start="${start}" data-duration="${duration}" data-track-index="${track}" style="z-index: ${zIndex};"></div>`;
27955
+ return `<div class="clip" data-composition-id="${esc(compositionId)}" data-composition-src="${esc(src)}" data-start="${start}" data-duration="${duration}" data-track-index="${track}" style="z-index: ${zIndex};"></div>`;
27881
27956
  }
27882
27957
  function insertClipIntoRoot(rootHtml, clip) {
27883
27958
  const clipDiv = buildClipReference(clip);
@@ -29355,6 +29430,39 @@ var init_install_skill = __esm({
29355
29430
  });
29356
29431
 
29357
29432
  // src/commands/_shared/storyboard-parse.ts
29433
+ function resolveCharacters(frontmatter) {
29434
+ const pool = frontmatter?.characters;
29435
+ if (!pool || typeof pool !== "object") return [];
29436
+ const resolved = [];
29437
+ for (const [name, spec] of Object.entries(pool)) {
29438
+ if (typeof spec === "string") {
29439
+ const prompt3 = spec.trim();
29440
+ if (prompt3) resolved.push({ name, prompt: prompt3 });
29441
+ continue;
29442
+ }
29443
+ if (spec && typeof spec === "object") {
29444
+ const image = typeof spec.image === "string" ? spec.image.trim() : "";
29445
+ if (image) {
29446
+ resolved.push({ name, imagePath: image });
29447
+ continue;
29448
+ }
29449
+ const description = typeof spec.description === "string" ? spec.description.trim() : "";
29450
+ if (description) resolved.push({ name, prompt: description });
29451
+ }
29452
+ }
29453
+ return resolved;
29454
+ }
29455
+ function beatCharacterNames(cues) {
29456
+ const raw = cues?.characters;
29457
+ if (typeof raw === "string") {
29458
+ const name = raw.trim();
29459
+ return name ? [name] : [];
29460
+ }
29461
+ if (Array.isArray(raw)) {
29462
+ return raw.filter((v) => typeof v === "string" && v.trim().length > 0).map((v) => v.trim());
29463
+ }
29464
+ return [];
29465
+ }
29358
29466
  function parseStoryboard(md) {
29359
29467
  const normalized = md.replace(/\r\n/g, "\n");
29360
29468
  const { frontmatter, remaining } = extractProjectFrontmatter(normalized);
@@ -29960,7 +30068,15 @@ ${finalDurationBullet}
29960
30068
  itself. For multi-phase beats, drive phase changes with GSAP \`autoAlpha\`
29961
30069
  inside full-window clips: animate phase A out (\`autoAlpha: 0\`), then
29962
30070
  phase B in, on the same timeline.
29963
- - If \`assets/backdrop-${ctx.beat.id}.png\` exists, use that local file as the
30071
+ - If \`assets/video-${ctx.beat.id}.mp4\` exists, it is an AI-generated clip for
30072
+ this beat \u2014 use it as the FULL-FRAME visual and prefer it over any backdrop
30073
+ image or CSS. Put one full-bleed \`<video>\` inside a full-window \`.clip\`:
30074
+ \`<video src="assets/video-${ctx.beat.id}.mp4" data-start="0" data-duration="${BEAT_DURATION}" data-media-start="0" muted playsinline style="width:100%;height:100%;object-fit:cover"></video>\`
30075
+ Use the exact path (no \`./\` or \`../\`). The framework seeks the clip to each
30076
+ timeline frame, so do NOT add transform tweens to the \`<video>\` or its
30077
+ ancestors; layer text/motion graphics on top instead.
30078
+ - If \`assets/backdrop-${ctx.beat.id}.png\` exists (and no video clip above),
30079
+ use that local file as the
29964
30080
  full-frame visual backdrop. The exact path string is
29965
30081
  \`assets/backdrop-${ctx.beat.id}.png\`; do NOT prefix it with \`../\` or \`./\`
29966
30082
  because fragments are mounted from the project root.
@@ -30878,6 +30994,101 @@ function uniqueRetryWith(items) {
30878
30994
  ...new Set(items.filter((item) => typeof item === "string" && item.length > 0))
30879
30995
  ];
30880
30996
  }
30997
+ function commandReviewAction(command, opts) {
30998
+ const classified = classifyReviewCommand(command);
30999
+ return stripUndefined({
31000
+ id: actionId("command", command),
31001
+ kind: "command",
31002
+ label: opts.label ?? command,
31003
+ command,
31004
+ fixOwner: opts.fixOwner ?? classified.fixOwner,
31005
+ costTier: opts.costTier ?? classified.costTier,
31006
+ safeToAutoRun: opts.safeToAutoRun ?? classified.safeToAutoRun,
31007
+ requiresConfirmation: opts.requiresConfirmation ?? classified.requiresConfirmation,
31008
+ reason: opts.reason,
31009
+ sourceIssueCodes: uniqueStrings(opts.sourceIssueCodes ?? [])
31010
+ });
31011
+ }
31012
+ function agentReviewAction(opts) {
31013
+ return stripUndefined({
31014
+ id: actionId("agent", opts.agentPrompt),
31015
+ kind: "agent",
31016
+ label: opts.label,
31017
+ agentPrompt: opts.agentPrompt,
31018
+ fixOwner: "host-agent",
31019
+ costTier: "unknown",
31020
+ safeToAutoRun: false,
31021
+ requiresConfirmation: false,
31022
+ reason: opts.reason,
31023
+ sourceIssueCodes: uniqueStrings(opts.sourceIssueCodes ?? [])
31024
+ });
31025
+ }
31026
+ function manualReviewAction(opts) {
31027
+ return stripUndefined({
31028
+ id: actionId("manual", opts.label),
31029
+ kind: "manual",
31030
+ label: opts.label,
31031
+ fixOwner: opts.fixOwner ?? "host-agent",
31032
+ costTier: "unknown",
31033
+ safeToAutoRun: false,
31034
+ requiresConfirmation: false,
31035
+ reason: opts.reason,
31036
+ sourceIssueCodes: uniqueStrings(opts.sourceIssueCodes ?? [])
31037
+ });
31038
+ }
31039
+ function reviewActionsFromRetryWith(retryWith, sourceIssueCodes = []) {
31040
+ return normalizeReviewActions(
31041
+ retryWith.map((item) => {
31042
+ if (/^codex\s+/i.test(item)) {
31043
+ return agentReviewAction({
31044
+ label: "Ask the host agent to fix review issues",
31045
+ agentPrompt: item,
31046
+ reason: "The review report contains host-agent-owned issues.",
31047
+ sourceIssueCodes
31048
+ });
31049
+ }
31050
+ return commandReviewAction(item, {
31051
+ reason: "Backward-compatible retryWith command.",
31052
+ sourceIssueCodes
31053
+ });
31054
+ })
31055
+ );
31056
+ }
31057
+ function normalizeReviewActions(actions) {
31058
+ const merged = /* @__PURE__ */ new Map();
31059
+ for (const action of actions) {
31060
+ const key = [action.kind, action.command ?? action.agentPrompt ?? action.label].join("\0");
31061
+ const existing = merged.get(key);
31062
+ if (!existing) {
31063
+ merged.set(key, {
31064
+ ...action,
31065
+ sourceIssueCodes: uniqueStrings(action.sourceIssueCodes ?? [])
31066
+ });
31067
+ continue;
31068
+ }
31069
+ merged.set(key, {
31070
+ ...existing,
31071
+ safeToAutoRun: existing.safeToAutoRun && action.safeToAutoRun,
31072
+ requiresConfirmation: existing.requiresConfirmation || action.requiresConfirmation,
31073
+ sourceIssueCodes: uniqueStrings([
31074
+ ...existing.sourceIssueCodes ?? [],
31075
+ ...action.sourceIssueCodes ?? []
31076
+ ])
31077
+ });
31078
+ }
31079
+ return [...merged.values()].map(
31080
+ (action) => stripUndefined({
31081
+ ...action,
31082
+ sourceIssueCodes: action.sourceIssueCodes && action.sourceIssueCodes.length > 0 ? action.sourceIssueCodes : void 0
31083
+ })
31084
+ );
31085
+ }
31086
+ function deriveNextReviewActions(opts) {
31087
+ return normalizeReviewActions([
31088
+ ...opts.issues.flatMap((issue) => issue.actions ?? []),
31089
+ ...reviewActionsFromRetryWith(opts.retryWith)
31090
+ ]);
31091
+ }
30881
31092
  function fixOwnerForIssue(issue) {
30882
31093
  if (issue.fixOwner) return issue.fixOwner;
30883
31094
  if (issue.code.startsWith("AI_REVIEW_")) return "host-agent";
@@ -30887,11 +31098,23 @@ function fixOwnerForIssue(issue) {
30887
31098
  }
30888
31099
  return "vibe";
30889
31100
  }
30890
- function normalizeReviewIssues(issues, fallbackOwner) {
30891
- return issues.map((issue) => ({
30892
- ...issue,
30893
- fixOwner: issue.fixOwner ?? fallbackOwner ?? fixOwnerForIssue(issue)
30894
- }));
31101
+ function normalizeReviewIssues(issues, opts) {
31102
+ const fallbackOwner = typeof opts === "string" ? opts : opts?.fallbackOwner;
31103
+ const projectDir = typeof opts === "string" ? void 0 : opts?.projectDir;
31104
+ return issues.map((issue) => {
31105
+ const normalized = {
31106
+ ...issue,
31107
+ fixOwner: issue.fixOwner ?? fallbackOwner ?? fixOwnerForIssue(issue)
31108
+ };
31109
+ const actions = normalizeReviewActions([
31110
+ ...issue.actions ?? [],
31111
+ ...projectDir ? defaultActionsForIssue(normalized, projectDir) : []
31112
+ ]);
31113
+ return stripUndefined({
31114
+ ...normalized,
31115
+ actions: actions.length > 0 ? actions : void 0
31116
+ });
31117
+ });
30895
31118
  }
30896
31119
  function summarizeReviewIssues(issues) {
30897
31120
  const normalized = normalizeReviewIssues(issues);
@@ -30907,7 +31130,11 @@ function summarizeReviewIssues(issues) {
30907
31130
  };
30908
31131
  }
30909
31132
  function buildReviewReport(opts) {
30910
- const issues = normalizeReviewIssues(opts.issues);
31133
+ const retryWith = uniqueRetryWith(opts.retryWith);
31134
+ const issues = normalizeReviewIssues(opts.issues, {
31135
+ projectDir: opts.project,
31136
+ retryWith
31137
+ });
30911
31138
  return stripUndefined({
30912
31139
  schemaVersion: "1",
30913
31140
  kind: "review",
@@ -30918,7 +31145,8 @@ function buildReviewReport(opts) {
30918
31145
  score: opts.score,
30919
31146
  issues,
30920
31147
  summary: summarizeReviewIssues(issues),
30921
- retryWith: uniqueRetryWith(opts.retryWith),
31148
+ nextActions: deriveNextReviewActions({ issues, retryWith, projectDir: opts.project }),
31149
+ retryWith,
30922
31150
  sourceReports: opts.sourceReports ?? [],
30923
31151
  reportPath: opts.reportPath
30924
31152
  });
@@ -30932,6 +31160,306 @@ async function writeReviewReport(path2, report) {
30932
31160
  function stripUndefined(value) {
30933
31161
  return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
30934
31162
  }
31163
+ function defaultActionsForIssue(issue, projectDir) {
31164
+ const code = issue.code;
31165
+ const beat = issue.beatId ?? issue.scene;
31166
+ const beatFlag = beat ? ` --beat ${beat}` : "";
31167
+ const sourceIssueCodes = [code];
31168
+ if (code === "PROJECT_NOT_FOUND" || code === "MISSING_STORYBOARD") {
31169
+ return [
31170
+ commandReviewAction(`vibe init ${projectDir} --from "<brief>" --json`, {
31171
+ label: "Initialize the project from a brief",
31172
+ fixOwner: "host-agent",
31173
+ reason: "The project cannot be inspected until the core project files exist.",
31174
+ sourceIssueCodes
31175
+ })
31176
+ ];
31177
+ }
31178
+ if (code === "MISSING_DESIGN" || code === "DESIGN_PLACEHOLDER_FIELD") {
31179
+ return [
31180
+ manualReviewAction({
31181
+ label: "Create or complete DESIGN.md",
31182
+ reason: "Visual direction needs host-agent or human input before VibeFrame can repair it.",
31183
+ sourceIssueCodes
31184
+ })
31185
+ ];
31186
+ }
31187
+ if (code === "BEAT_NOT_FOUND" || code.startsWith("STORYBOARD_")) {
31188
+ const actions = [
31189
+ commandReviewAction(`vibe storyboard validate ${projectDir} --json`, {
31190
+ label: "Validate the storyboard",
31191
+ fixOwner: "host-agent",
31192
+ reason: "The storyboard issue needs structured validation before editing.",
31193
+ sourceIssueCodes
31194
+ })
31195
+ ];
31196
+ if (code === "STORYBOARD_PLACEHOLDER_CUE") {
31197
+ actions.push(
31198
+ commandReviewAction(
31199
+ `vibe storyboard revise ${projectDir} --from "<make cues concrete>" --dry-run --json`,
31200
+ {
31201
+ label: "Preview a storyboard revision",
31202
+ fixOwner: "host-agent",
31203
+ reason: "Placeholder cues should be revised into concrete video intent.",
31204
+ sourceIssueCodes
31205
+ }
31206
+ )
31207
+ );
31208
+ }
31209
+ return actions;
31210
+ }
31211
+ if (code === "MISSING_ROOT_COMPOSITION" || code === "ROOT_SYNC_CHECK_FAILED") {
31212
+ return [
31213
+ commandReviewAction(`vibe build ${projectDir} --stage sync --json`, {
31214
+ label: "Sync the project root composition",
31215
+ reason: "The root composition is missing or could not be verified.",
31216
+ sourceIssueCodes
31217
+ })
31218
+ ];
31219
+ }
31220
+ if (code.startsWith("ROOT_")) {
31221
+ return issue.fixOwner === "host-agent" ? [
31222
+ agentReviewAction({
31223
+ label: "Repair the root composition shell",
31224
+ agentPrompt: "Fix host-agent-owned root composition issues from review-report.json",
31225
+ reason: "The root composition cannot be repaired deterministically.",
31226
+ sourceIssueCodes
31227
+ })
31228
+ ] : [
31229
+ commandReviewAction(`vibe scene repair ${projectDir} --json`, {
31230
+ label: "Repair deterministic scene/root issues",
31231
+ reason: "The root composition has deterministic sync issues.",
31232
+ sourceIssueCodes
31233
+ }),
31234
+ commandReviewAction(`vibe build ${projectDir} --stage sync --json`, {
31235
+ label: "Resync root composition after repair",
31236
+ reason: "The root composition should be synced after deterministic repair.",
31237
+ sourceIssueCodes
31238
+ })
31239
+ ];
31240
+ }
31241
+ if (code === "MISSING_COMPOSITION") {
31242
+ return [
31243
+ commandReviewAction(`vibe build ${projectDir}${beatFlag} --stage compose --json`, {
31244
+ label: beat ? `Compose beat ${beat}` : "Compose missing scenes",
31245
+ reason: "Missing scene compositions must be generated or authored before render.",
31246
+ sourceIssueCodes
31247
+ })
31248
+ ];
31249
+ }
31250
+ if (code === "MISSING_BUILD_REPORT") {
31251
+ return [
31252
+ commandReviewAction(`vibe build ${projectDir} --dry-run --json`, {
31253
+ label: "Preview the build plan",
31254
+ reason: "A dry-run build report is needed before choosing a paid or mutating build step.",
31255
+ sourceIssueCodes
31256
+ })
31257
+ ];
31258
+ }
31259
+ if (code === "BUILD_REPORT_BEAT_MISSING") {
31260
+ return [
31261
+ commandReviewAction(`vibe build ${projectDir}${beatFlag} --stage sync --json`, {
31262
+ label: beat ? `Sync build report for beat ${beat}` : "Sync the build report",
31263
+ reason: "The build report does not include the inspected beat.",
31264
+ sourceIssueCodes
31265
+ })
31266
+ ];
31267
+ }
31268
+ if (code === "MALFORMED_BUILD_REPORT") {
31269
+ return [
31270
+ commandReviewAction(`vibe build ${projectDir} --json`, {
31271
+ label: "Regenerate the build report",
31272
+ reason: "The existing build report cannot be parsed.",
31273
+ sourceIssueCodes
31274
+ })
31275
+ ];
31276
+ }
31277
+ if (code === "MISSING_REPORTED_ASSET" || code === "STALE_ASSET" || code === "UNKNOWN_ASSET_FRESHNESS") {
31278
+ return [
31279
+ commandReviewAction(`vibe build ${projectDir}${beatFlag} --stage assets --force --json`, {
31280
+ label: beat ? `Regenerate assets for beat ${beat}` : "Regenerate stale or missing assets",
31281
+ reason: "Asset generation may call providers, so the host should confirm first.",
31282
+ sourceIssueCodes
31283
+ })
31284
+ ];
31285
+ }
31286
+ if (code === "MUSIC_CUE_NOT_READY") {
31287
+ return [
31288
+ commandReviewAction(`vibe build ${projectDir}${beatFlag} --stage assets --json`, {
31289
+ label: beat ? `Generate music for beat ${beat}` : "Generate missing music assets",
31290
+ reason: "Music generation may call providers, so the host should confirm first.",
31291
+ sourceIssueCodes
31292
+ }),
31293
+ commandReviewAction(`vibe build ${projectDir} --stage sync --json`, {
31294
+ label: "Sync music into the root composition",
31295
+ reason: "Generated music needs root composition wiring.",
31296
+ sourceIssueCodes
31297
+ })
31298
+ ];
31299
+ }
31300
+ if (code.startsWith("SCENE_LINT_")) {
31301
+ return [
31302
+ commandReviewAction(`vibe scene repair ${projectDir} --json`, {
31303
+ label: "Repair deterministic scene lint issues",
31304
+ reason: "Scene lint reported an issue VibeFrame may be able to repair mechanically.",
31305
+ sourceIssueCodes
31306
+ })
31307
+ ];
31308
+ }
31309
+ if (code === "RENDER_NOT_FOUND" || code === "EMPTY_RENDER") {
31310
+ return [
31311
+ commandReviewAction(
31312
+ beat ? `vibe render ${projectDir} --beat ${beat} --json` : `vibe render ${projectDir} --json`,
31313
+ {
31314
+ label: beat ? `Render beat ${beat}` : "Render the project",
31315
+ reason: "A render file is required before render inspection can pass.",
31316
+ sourceIssueCodes
31317
+ }
31318
+ )
31319
+ ];
31320
+ }
31321
+ if (code === "NO_AUDIO_STREAM" || code === "DURATION_DRIFT") {
31322
+ return [
31323
+ commandReviewAction(`vibe build ${projectDir}${beatFlag} --stage sync --json`, {
31324
+ label: beat ? `Sync timing/audio for beat ${beat}` : "Sync timing and audio wiring",
31325
+ reason: "The render output no longer matches synced project state.",
31326
+ sourceIssueCodes
31327
+ }),
31328
+ commandReviewAction(
31329
+ beat ? `vibe render ${projectDir} --beat ${beat} --json` : `vibe render ${projectDir} --json`,
31330
+ {
31331
+ label: beat ? `Rerender beat ${beat}` : "Rerender the project",
31332
+ reason: "The render should be regenerated after sync repair.",
31333
+ sourceIssueCodes
31334
+ }
31335
+ )
31336
+ ];
31337
+ }
31338
+ if (code === "BLACK_FRAME_SEGMENT") {
31339
+ return issue.fixOwner === "host-agent" ? [
31340
+ agentReviewAction({
31341
+ label: beat ? `Fix black frames in beat ${beat}` : "Fix black frame segment",
31342
+ agentPrompt: "Fix black-frame issues from review-report.json, then rerender.",
31343
+ reason: "The issue likely needs scene, storyboard, or visual cue changes.",
31344
+ sourceIssueCodes
31345
+ })
31346
+ ] : [
31347
+ commandReviewAction(`vibe scene repair ${projectDir} --json`, {
31348
+ label: "Repair deterministic scene timing",
31349
+ reason: "Black frames outside a specific beat may be recoverable with scene repair.",
31350
+ sourceIssueCodes
31351
+ })
31352
+ ];
31353
+ }
31354
+ if (code === "STATIC_FRAME_SEGMENT") {
31355
+ return [
31356
+ agentReviewAction({
31357
+ label: beat ? `Add motion to beat ${beat}` : "Fix static visual hold",
31358
+ agentPrompt: "Fix static-frame issues from review-report.json, then rerender.",
31359
+ reason: "Static segments usually need creative scene, storyboard, or asset changes.",
31360
+ sourceIssueCodes
31361
+ })
31362
+ ];
31363
+ }
31364
+ if (code === "LONG_SILENCE") {
31365
+ return issue.fixOwner === "host-agent" ? [
31366
+ agentReviewAction({
31367
+ label: beat ? `Fix long silence in beat ${beat}` : "Fix long silence",
31368
+ agentPrompt: "Fix long-silence issues from review-report.json, then rerender.",
31369
+ reason: "The beat duration, narration, or music cue needs host-agent judgment.",
31370
+ sourceIssueCodes
31371
+ })
31372
+ ] : [
31373
+ commandReviewAction(`vibe build ${projectDir}${beatFlag} --stage sync --json`, {
31374
+ label: beat ? `Sync audio for beat ${beat}` : "Sync audio wiring",
31375
+ reason: "The silence may be caused by stale narration or music wiring.",
31376
+ sourceIssueCodes
31377
+ })
31378
+ ];
31379
+ }
31380
+ if (code.startsWith("AI_REVIEW_")) {
31381
+ return [
31382
+ agentReviewAction({
31383
+ label: "Fix AI review findings",
31384
+ agentPrompt: "Fix host-agent-owned AI review issues from review-report.json, then rerender.",
31385
+ reason: "AI review findings require storyboard, design, or composition judgment.",
31386
+ sourceIssueCodes
31387
+ })
31388
+ ];
31389
+ }
31390
+ if (code === "FFPROBE_UNAVAILABLE" || code === "FFMPEG_UNAVAILABLE") {
31391
+ return [
31392
+ manualReviewAction({
31393
+ label: "Install FFmpeg and rerun inspection",
31394
+ reason: "Local media inspection depends on ffmpeg/ffprobe being available.",
31395
+ fixOwner: "host-agent",
31396
+ sourceIssueCodes
31397
+ })
31398
+ ];
31399
+ }
31400
+ if (code === "FFPROBE_FAILED" || code === "NO_VIDEO_STREAM") {
31401
+ return [
31402
+ commandReviewAction(
31403
+ beat ? `vibe render ${projectDir} --beat ${beat} --json` : `vibe render ${projectDir} --json`,
31404
+ {
31405
+ label: beat ? `Rerender beat ${beat}` : "Rerender the project",
31406
+ reason: "The rendered media could not be probed as a valid video.",
31407
+ sourceIssueCodes
31408
+ }
31409
+ )
31410
+ ];
31411
+ }
31412
+ if (code === "ASPECT_MISMATCH") {
31413
+ return [
31414
+ manualReviewAction({
31415
+ label: "Check project aspect and render settings",
31416
+ reason: "Aspect mismatches can come from either config intent or render settings.",
31417
+ fixOwner: issue.fixOwner,
31418
+ sourceIssueCodes
31419
+ })
31420
+ ];
31421
+ }
31422
+ return [];
31423
+ }
31424
+ function classifyReviewCommand(command) {
31425
+ const lower = command.trim().toLowerCase();
31426
+ if (lower.startsWith("vibe status ")) return safe("free");
31427
+ if (lower.startsWith("vibe scene repair")) return safe("free");
31428
+ if (lower.startsWith("vibe storyboard validate") || lower.startsWith("vibe storyboard get") || lower.startsWith("vibe storyboard list")) {
31429
+ return safe("free", "host-agent");
31430
+ }
31431
+ if (lower.startsWith("vibe inspect project")) return safe("free");
31432
+ if (lower.startsWith("vibe inspect render")) {
31433
+ if (lower.includes("--ai")) return unsafe("low");
31434
+ return safe("free");
31435
+ }
31436
+ if (lower.startsWith("vibe render ")) return safe("free");
31437
+ if (lower.startsWith("vibe build ")) {
31438
+ if (lower.includes("--dry-run")) return safe("free");
31439
+ if (lower.includes("--stage sync") || lower.includes("--stage render")) return safe("free");
31440
+ return unsafe("unknown");
31441
+ }
31442
+ if (lower.startsWith("vibe generate video") || lower.startsWith("vibe edit fill-gaps") || lower.startsWith("vibe remix ")) {
31443
+ return unsafe("very-high");
31444
+ }
31445
+ if (lower.startsWith("vibe storyboard revise")) return unsafe("unknown", "host-agent");
31446
+ return unsafe("unknown");
31447
+ }
31448
+ function safe(costTier, fixOwner = "vibe") {
31449
+ return { fixOwner, costTier, safeToAutoRun: true, requiresConfirmation: false };
31450
+ }
31451
+ function unsafe(costTier, fixOwner = "vibe") {
31452
+ return { fixOwner, costTier, safeToAutoRun: false, requiresConfirmation: true };
31453
+ }
31454
+ function actionId(kind, value) {
31455
+ const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80);
31456
+ return `${kind}:${slug || "action"}`;
31457
+ }
31458
+ function uniqueStrings(items) {
31459
+ return [
31460
+ ...new Set(items.filter((item) => typeof item === "string" && item.length > 0))
31461
+ ];
31462
+ }
30935
31463
  var init_review_report = __esm({
30936
31464
  "src/commands/_shared/review-report.ts"() {
30937
31465
  "use strict";
@@ -31064,6 +31592,298 @@ var init_scene_repair = __esm({
31064
31592
  }
31065
31593
  });
31066
31594
 
31595
+ // src/commands/_shared/storyboard-edit.ts
31596
+ function validateStoryboardMarkdown(markdown) {
31597
+ const parsed = parseStoryboard(markdown);
31598
+ const sections = splitBeatSections(markdown);
31599
+ const issues = [];
31600
+ if (parsed.beats.length === 0) {
31601
+ issues.push({
31602
+ severity: "error",
31603
+ code: "NO_BEATS",
31604
+ message: "STORYBOARD.md must contain at least one `## Beat ...` heading."
31605
+ });
31606
+ }
31607
+ const seen = /* @__PURE__ */ new Map();
31608
+ for (const beat of parsed.beats) {
31609
+ seen.set(beat.id, (seen.get(beat.id) ?? 0) + 1);
31610
+ }
31611
+ for (const [id, count] of seen.entries()) {
31612
+ if (count > 1) {
31613
+ issues.push({
31614
+ severity: "error",
31615
+ code: "DUPLICATE_BEAT_ID",
31616
+ beatId: id,
31617
+ message: `Beat id "${id}" appears ${count} times. Beat ids must be unique.`
31618
+ });
31619
+ }
31620
+ }
31621
+ for (const section of sections) {
31622
+ const cueBlock = readLeadingCueBlock(section.body);
31623
+ if (cueBlock?.error) {
31624
+ issues.push({
31625
+ severity: "error",
31626
+ code: "MALFORMED_CUE_YAML",
31627
+ beatId: section.id,
31628
+ message: `Beat "${section.id}" has malformed YAML cues: ${cueBlock.error}`
31629
+ });
31630
+ }
31631
+ }
31632
+ const characterPool = new Set(
31633
+ Object.keys(
31634
+ parsed.frontmatter?.characters ?? {}
31635
+ )
31636
+ );
31637
+ for (const beat of parsed.beats) {
31638
+ const cues = beat.cues ?? {};
31639
+ for (const [key, value] of Object.entries(cues)) {
31640
+ if (!ALLOWED_CUE_KEYS.has(key)) {
31641
+ issues.push({
31642
+ severity: "warning",
31643
+ code: "UNKNOWN_CUE",
31644
+ beatId: beat.id,
31645
+ message: `Beat "${beat.id}" uses unknown cue "${key}". Supported cues: ${STORYBOARD_CUE_KEYS.join(", ")}.`
31646
+ });
31647
+ continue;
31648
+ }
31649
+ if (key === "characters") {
31650
+ const validShape = typeof value === "string" || Array.isArray(value) && value.every((v) => typeof v === "string");
31651
+ if (!validShape) {
31652
+ issues.push({
31653
+ severity: "error",
31654
+ code: "INVALID_CHARACTERS_VALUE",
31655
+ beatId: beat.id,
31656
+ message: `Beat "${beat.id}" cue "characters" must be a character name or a list of names.`
31657
+ });
31658
+ continue;
31659
+ }
31660
+ for (const name of beatCharacterNames({ characters: value })) {
31661
+ if (!characterPool.has(name)) {
31662
+ issues.push({
31663
+ severity: "warning",
31664
+ code: "UNKNOWN_CHARACTER",
31665
+ beatId: beat.id,
31666
+ message: `Beat "${beat.id}" references character "${name}" not defined in the frontmatter \`characters:\` pool.`
31667
+ });
31668
+ }
31669
+ }
31670
+ continue;
31671
+ }
31672
+ if (key === "duration") {
31673
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
31674
+ issues.push({
31675
+ severity: "error",
31676
+ code: "INVALID_DURATION",
31677
+ beatId: beat.id,
31678
+ message: `Beat "${beat.id}" cue "duration" must be a positive number of seconds.`
31679
+ });
31680
+ }
31681
+ continue;
31682
+ }
31683
+ if (STRING_CUE_KEYS.has(key) && typeof value !== "string") {
31684
+ issues.push({
31685
+ severity: "error",
31686
+ code: "INVALID_CUE_VALUE",
31687
+ beatId: beat.id,
31688
+ message: `Beat "${beat.id}" cue "${key}" must be a string.`
31689
+ });
31690
+ }
31691
+ }
31692
+ }
31693
+ for (const beat of parsed.beats) {
31694
+ if (beat.duration !== void 0 && beat.duration > MAX_RECOMMENDED_BEAT_SEC) {
31695
+ issues.push({
31696
+ severity: "warning",
31697
+ code: "BEAT_DURATION_TOO_LONG",
31698
+ beatId: beat.id,
31699
+ message: `Beat "${beat.id}" is ${beat.duration}s \u2014 beats longer than ${MAX_RECOMMENDED_BEAT_SEC}s render static and overstuffed. Split it into 6-15s beats (a 90s video should have 6-8 beats).`
31700
+ });
31701
+ }
31702
+ }
31703
+ return {
31704
+ ok: !issues.some((i) => i.severity === "error"),
31705
+ beats: parsed.beats,
31706
+ issues
31707
+ };
31708
+ }
31709
+ function getStoryboardBeat(markdown, beatId) {
31710
+ return parseStoryboard(markdown).beats.find((beat) => beat.id === beatId) ?? null;
31711
+ }
31712
+ function upsertStoryboardBeat(markdown, opts) {
31713
+ const beatId = normalizeBeatId(opts.beatId);
31714
+ const sections = splitBeatSections(markdown);
31715
+ const existing = sections.find((section2) => section2.id === beatId);
31716
+ const cues = storyboardCuesFromInput(opts);
31717
+ if (existing) {
31718
+ let next = markdown;
31719
+ for (const [key, value] of Object.entries(cues)) {
31720
+ next = setStoryboardCue(next, { beatId, key, value });
31721
+ }
31722
+ return { markdown: next, action: "updated" };
31723
+ }
31724
+ const section = buildStoryboardBeatSection({ ...opts, beatId, cues });
31725
+ if (sections.length > 0 && isStarterStoryboard(markdown)) {
31726
+ const global2 = markdown.slice(0, sections[0].start).trimEnd();
31727
+ return {
31728
+ markdown: `${global2}
31729
+
31730
+ ${section}`,
31731
+ action: "replaced-starter"
31732
+ };
31733
+ }
31734
+ const prefix = markdown.trimEnd();
31735
+ return {
31736
+ markdown: `${prefix}${prefix ? "\n\n" : ""}${section}`,
31737
+ action: "appended"
31738
+ };
31739
+ }
31740
+ function setStoryboardCue(markdown, opts) {
31741
+ const section = findBeatSection(markdown, opts.beatId);
31742
+ if (!section) {
31743
+ throw new Error(`Beat "${opts.beatId}" not found.`);
31744
+ }
31745
+ if (!ALLOWED_CUE_KEYS.has(opts.key)) {
31746
+ throw new Error(`Unsupported cue "${opts.key}". Supported cues: ${STORYBOARD_CUE_KEYS.join(", ")}.`);
31747
+ }
31748
+ const cue = readLeadingCueBlock(section.body);
31749
+ if (cue?.error) {
31750
+ throw new Error(`Cannot edit malformed YAML cues for beat "${opts.beatId}": ${cue.error}`);
31751
+ }
31752
+ const cues = cue?.value ? { ...cue.value } : {};
31753
+ if (opts.unset) {
31754
+ delete cues[opts.key];
31755
+ } else {
31756
+ cues[opts.key] = normalizeCueValue(opts.key, opts.value);
31757
+ }
31758
+ const remainingBody = cue ? section.body.slice(cue.full.length).replace(/^\s*\n/, "") : section.body.replace(/^\s*\n/, "");
31759
+ const cueBlock = Object.keys(cues).length > 0 ? "```yaml\n" + (0, import_yaml5.stringify)(cues, { lineWidth: 0 }).trimEnd() + "\n```\n\n" : "";
31760
+ const nextBody = cueBlock + remainingBody.trimStart();
31761
+ const nextSection = section.raw.slice(0, section.headingEnd - section.start) + "\n\n" + nextBody.trimEnd() + "\n";
31762
+ return markdown.slice(0, section.start) + nextSection + markdown.slice(section.end);
31763
+ }
31764
+ function moveStoryboardBeat(markdown, opts) {
31765
+ if (opts.beatId === opts.afterBeatId) return markdown;
31766
+ const sections = splitBeatSections(markdown);
31767
+ const movingIndex = sections.findIndex((section) => section.id === opts.beatId);
31768
+ if (movingIndex === -1) throw new Error(`Beat "${opts.beatId}" not found.`);
31769
+ const afterIndex = sections.findIndex((section) => section.id === opts.afterBeatId);
31770
+ if (afterIndex === -1) throw new Error(`Beat "${opts.afterBeatId}" not found.`);
31771
+ const global2 = sections.length > 0 ? markdown.slice(0, sections[0].start) : markdown;
31772
+ const chunks = sections.map((section) => section.raw);
31773
+ const [moving] = chunks.splice(movingIndex, 1);
31774
+ const adjustedAfterIndex = movingIndex < afterIndex ? afterIndex - 1 : afterIndex;
31775
+ chunks.splice(adjustedAfterIndex + 1, 0, moving);
31776
+ return global2 + normalizeBeatChunks(chunks);
31777
+ }
31778
+ function normalizeBeatChunks(chunks) {
31779
+ return chunks.map((chunk) => chunk.trim()).filter(Boolean).join("\n\n") + "\n";
31780
+ }
31781
+ function normalizeBeatId(id) {
31782
+ return deriveBeatId(id);
31783
+ }
31784
+ function storyboardCuesFromInput(opts) {
31785
+ const cues = {};
31786
+ if (opts.duration !== void 0) cues.duration = opts.duration;
31787
+ if (opts.narration && opts.narration.trim()) cues.narration = opts.narration.trim();
31788
+ if (opts.backdrop && opts.backdrop.trim()) cues.backdrop = opts.backdrop.trim();
31789
+ return cues;
31790
+ }
31791
+ function buildStoryboardBeatSection(opts) {
31792
+ const title = normalizeTitle(opts.title ?? humanizeBeatId(opts.beatId));
31793
+ const cueBlock = Object.keys(opts.cues).length > 0 ? "```yaml\n" + (0, import_yaml5.stringify)(opts.cues, { lineWidth: 0 }).trimEnd() + "\n```\n\n" : "";
31794
+ const body = opts.body?.trim() || "Scene created with `vibe scene add`. Edit this body if the build flow should recompose it.";
31795
+ return `## Beat ${opts.beatId} - ${title}
31796
+
31797
+ ${cueBlock}${body.trimEnd()}
31798
+ `;
31799
+ }
31800
+ function normalizeTitle(value) {
31801
+ return value.replace(/\s+/g, " ").trim() || "Scene";
31802
+ }
31803
+ function humanizeBeatId(id) {
31804
+ return id.split(/[-_]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ") || "Scene";
31805
+ }
31806
+ function isStarterStoryboard(markdown) {
31807
+ const parsed = parseStoryboard(markdown);
31808
+ if (parsed.beats.length !== 3) return false;
31809
+ const ids = parsed.beats.map((beat) => beat.id);
31810
+ if (ids.join(",") !== "hook,proof,close") return false;
31811
+ return markdown.includes("Introduce the promise in one crisp sentence.") && markdown.includes("Show the mechanism or proof point that makes the promise believable.") && markdown.includes("Close with the action the viewer should remember.");
31812
+ }
31813
+ function normalizeCueValue(key, value) {
31814
+ if (key === "duration") {
31815
+ const n = typeof value === "number" ? value : Number.parseFloat(String(value ?? ""));
31816
+ if (!Number.isFinite(n) || n <= 0) {
31817
+ throw new Error("Cue duration must be a positive number.");
31818
+ }
31819
+ return n;
31820
+ }
31821
+ if (typeof value !== "string") return value;
31822
+ return value.trim();
31823
+ }
31824
+ function splitBeatSections(markdown) {
31825
+ const normalized = markdown.replace(/\r\n/g, "\n");
31826
+ const headings = [];
31827
+ HEADING_RE2.lastIndex = 0;
31828
+ let match;
31829
+ while ((match = HEADING_RE2.exec(normalized)) !== null) {
31830
+ headings.push({ start: match.index, end: match.index + match[0].length, line: match[1].trim() });
31831
+ }
31832
+ return headings.map((heading, index) => {
31833
+ const end = index + 1 < headings.length ? headings[index + 1].start : normalized.length;
31834
+ return {
31835
+ id: deriveBeatId(heading.line),
31836
+ heading: heading.line,
31837
+ start: heading.start,
31838
+ headingEnd: heading.end,
31839
+ end,
31840
+ raw: normalized.slice(heading.start, end),
31841
+ body: normalized.slice(heading.end, end).trim()
31842
+ };
31843
+ });
31844
+ }
31845
+ function findBeatSection(markdown, beatId) {
31846
+ return splitBeatSections(markdown).find((section) => section.id === beatId) ?? null;
31847
+ }
31848
+ function readLeadingCueBlock(body) {
31849
+ const match = body.match(LEADING_CUE_RE);
31850
+ if (!match) return null;
31851
+ try {
31852
+ const parsed = (0, import_yaml5.parse)(match[2]);
31853
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
31854
+ return { full: match[0], error: "cue block must parse to a YAML object" };
31855
+ }
31856
+ return { full: match[0], value: parsed };
31857
+ } catch (error) {
31858
+ return { full: match[0], error: error instanceof Error ? error.message : String(error) };
31859
+ }
31860
+ }
31861
+ var import_yaml5, STORYBOARD_CUE_KEYS, HEADING_RE2, LEADING_CUE_RE, ALLOWED_CUE_KEYS, STRING_CUE_KEYS, MAX_RECOMMENDED_BEAT_SEC;
31862
+ var init_storyboard_edit = __esm({
31863
+ "src/commands/_shared/storyboard-edit.ts"() {
31864
+ "use strict";
31865
+ import_yaml5 = __toESM(require_dist2(), 1);
31866
+ init_storyboard_parse();
31867
+ STORYBOARD_CUE_KEYS = [
31868
+ "duration",
31869
+ "narration",
31870
+ "backdrop",
31871
+ "video",
31872
+ "keyframe",
31873
+ "motion",
31874
+ "voice",
31875
+ "music",
31876
+ "asset",
31877
+ "characters"
31878
+ ];
31879
+ HEADING_RE2 = /^##\s+(.+?)\s*$/gm;
31880
+ LEADING_CUE_RE = /^(\s*)```ya?ml\s*\n([\s\S]*?)\n```\s*(?:\n|$)/;
31881
+ ALLOWED_CUE_KEYS = new Set(STORYBOARD_CUE_KEYS);
31882
+ STRING_CUE_KEYS = /* @__PURE__ */ new Set(["narration", "backdrop", "video", "keyframe", "motion", "voice", "music", "asset"]);
31883
+ MAX_RECOMMENDED_BEAT_SEC = 15;
31884
+ }
31885
+ });
31886
+
31067
31887
  // src/commands/scene.ts
31068
31888
  import { resolve as resolve17, relative as relative6, dirname as dirname4 } from "node:path";
31069
31889
  import { mkdir as mkdir7, readFile as readFile13, writeFile as writeFile12, access as access5, copyFile } from "node:fs/promises";
@@ -31113,6 +31933,21 @@ async function loadVibeProjectConfig(projectDir) {
31113
31933
  }
31114
31934
  };
31115
31935
  }
31936
+ function escapeRegExp3(value) {
31937
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31938
+ }
31939
+ function readClipStart(rootHtml, beatId) {
31940
+ const compositionId = sceneCompositionId(beatId);
31941
+ const match = rootHtml.match(
31942
+ new RegExp(
31943
+ `<div\\s+class="clip"[^>]*\\bdata-composition-id="${escapeRegExp3(compositionId)}"[^>]*\\bdata-start="([\\d.]+)"`,
31944
+ "i"
31945
+ )
31946
+ );
31947
+ if (!match) return void 0;
31948
+ const start = Number.parseFloat(match[1]);
31949
+ return Number.isFinite(start) ? start : void 0;
31950
+ }
31116
31951
  async function resolveNarrationText(value) {
31117
31952
  if (!value) return void 0;
31118
31953
  const looksLikePath = /\.[a-z]{2,4}$/i.test(value) || value.includes("/") || value.includes("\\");
@@ -31139,6 +31974,7 @@ async function executeSceneAdd(opts) {
31139
31974
  const id = slugifySceneName(opts.name);
31140
31975
  const sceneRel = `compositions/scene-${id}.html`;
31141
31976
  const scenePath = resolve17(projectDir, sceneRel);
31977
+ const storyboardAbsPath = resolve17(projectDir, "STORYBOARD.md");
31142
31978
  const errResult = (error) => ({
31143
31979
  success: false,
31144
31980
  id,
@@ -31358,6 +32194,27 @@ async function executeSceneAdd(opts) {
31358
32194
  duration = fallback2;
31359
32195
  }
31360
32196
  duration = Number(duration.toFixed(2));
32197
+ let storyboardPath2;
32198
+ let storyboardSynced = false;
32199
+ let storyboardAction = "skipped";
32200
+ const shouldSyncStoryboard = opts.syncStoryboard !== false && await pathExists2(storyboardAbsPath);
32201
+ if (shouldSyncStoryboard) {
32202
+ opts.onProgress?.("Syncing STORYBOARD.md...");
32203
+ const before = await readFile13(storyboardAbsPath, "utf-8");
32204
+ const synced = upsertStoryboardBeat(before, {
32205
+ beatId: id,
32206
+ title: opts.headline,
32207
+ duration,
32208
+ narration: narrationText && !opts.skipAudio ? narrationText : void 0,
32209
+ backdrop: opts.visuals && !opts.skipImage ? opts.visuals : void 0
32210
+ });
32211
+ if (synced.markdown !== before) {
32212
+ await writeFile12(storyboardAbsPath, synced.markdown, "utf-8");
32213
+ }
32214
+ storyboardPath2 = relative6(process.cwd(), storyboardAbsPath) || storyboardAbsPath;
32215
+ storyboardSynced = true;
32216
+ storyboardAction = synced.action;
32217
+ }
31361
32218
  opts.onProgress?.("Emitting scene HTML...");
31362
32219
  const sceneHtml = emitSceneHtml({
31363
32220
  id,
@@ -31376,11 +32233,24 @@ async function executeSceneAdd(opts) {
31376
32233
  await mkdir7(dirname4(scenePath), { recursive: true });
31377
32234
  await writeFile12(scenePath, sceneHtml, "utf-8");
31378
32235
  opts.onProgress?.("Updating root composition...");
31379
- const start = nextSceneStart(rootHtmlBefore, SCENE_OVERLAP_SECONDS);
31380
- const existingClipCount = (rootHtmlBefore.match(/<div\s+class="clip"/g) || []).length;
31381
- const trackIndex = existingClipCount % 2 + 1;
31382
- const updated = insertClipIntoRoot(rootHtmlBefore, { id, start, duration, trackIndex });
31383
- await writeFile12(rootPath, updated, "utf-8");
32236
+ let start = 0;
32237
+ if (storyboardSynced) {
32238
+ const syncPlan = await syncRootComposition({
32239
+ projectDir,
32240
+ rootRel,
32241
+ beats: await loadProjectRootSyncBeats(projectDir)
32242
+ });
32243
+ const blockingIssue = syncPlan.issues.find((issue) => issue.severity === "error");
32244
+ if (blockingIssue) return errResult(blockingIssue.message);
32245
+ const rootHtmlAfterSync = await readFile13(rootPath, "utf-8");
32246
+ start = readClipStart(rootHtmlAfterSync, id) ?? 0;
32247
+ } else {
32248
+ start = nextSceneStart(rootHtmlBefore, SCENE_OVERLAP_SECONDS);
32249
+ const existingClipCount = (rootHtmlBefore.match(/<div\s+class="clip"/g) || []).length;
32250
+ const trackIndex = existingClipCount % 2 + 1;
32251
+ const updated = insertClipIntoRoot(rootHtmlBefore, { id, start, duration, trackIndex });
32252
+ await writeFile12(rootPath, updated, "utf-8");
32253
+ }
31384
32254
  const transcriptAbsPath = transcriptRelPath ? resolve17(projectDir, transcriptRelPath) : void 0;
31385
32255
  return {
31386
32256
  success: true,
@@ -31393,6 +32263,9 @@ async function executeSceneAdd(opts) {
31393
32263
  audioPath: audioAbsPath ? relative6(process.cwd(), audioAbsPath) || audioAbsPath : void 0,
31394
32264
  imagePath: imageAbsPath ? relative6(process.cwd(), imageAbsPath) || imageAbsPath : void 0,
31395
32265
  lottiePath: lottieAbsPath ? relative6(process.cwd(), lottieAbsPath) || lottieAbsPath : void 0,
32266
+ storyboardPath: storyboardPath2,
32267
+ storyboardSynced,
32268
+ storyboardAction,
31396
32269
  transcriptPath: transcriptAbsPath ? relative6(process.cwd(), transcriptAbsPath) || transcriptAbsPath : void 0,
31397
32270
  transcriptWordCount
31398
32271
  };
@@ -31434,6 +32307,8 @@ var init_scene = __esm({
31434
32307
  init_compose_prompts();
31435
32308
  init_scene_submit();
31436
32309
  init_scene_repair();
32310
+ init_root_sync();
32311
+ init_storyboard_edit();
31437
32312
  sceneCommand = new Command("scene").description(
31438
32313
  "Lower-level scene authoring (add, lint, list-styles). For project flow use `vibe init` / `vibe build` / `vibe render`."
31439
32314
  ).addHelpText(
@@ -31718,7 +32593,10 @@ Run 'vibe schema scene.<command>' for structured parameter info.`
31718
32593
  ).option("-d, --duration <sec>", "Explicit scene duration in seconds (overrides narration audio)").option(
31719
32594
  "--visuals <prompt>",
31720
32595
  "Image prompt \u2014 generates assets/scene-<id>.png via the configured image provider"
31721
- ).option("--headline <text>", "Visible headline (defaults to the humanised scene name)").option("--kicker <text>", "Small label above the headline (explainer / product-shot)").option("--insert-into <path>", "Root composition file to update", "index.html").option("--project <dir>", "Project directory", ".").option("--image-provider <name>", "Image provider: gemini, openai", "gemini").option(
32596
+ ).option("--headline <text>", "Visible headline (defaults to the humanised scene name)").option("--kicker <text>", "Small label above the headline (explainer / product-shot)").option("--insert-into <path>", "Root composition file to update", "index.html").option("--project <dir>", "Project directory", ".").option(
32597
+ "--no-storyboard",
32598
+ "Do not sync STORYBOARD.md; insert this scene directly into the root composition only"
32599
+ ).option("--image-provider <name>", "Image provider: gemini, openai", "gemini").option(
31722
32600
  "--tts <provider>",
31723
32601
  "TTS provider: auto, elevenlabs, openai, kokoro (default auto \u2014 ElevenLabs key > OpenAI key > Kokoro local)",
31724
32602
  "auto"
@@ -31787,6 +32665,7 @@ Run 'vibe schema scene.<command>' for structured parameter info.`
31787
32665
  kicker: options.kicker,
31788
32666
  project: options.project,
31789
32667
  insertInto: options.insertInto,
32668
+ syncStoryboard: options.storyboard !== false,
31790
32669
  imageProvider: options.imageProvider,
31791
32670
  tts,
31792
32671
  audio: options.audio,
@@ -31814,6 +32693,7 @@ Run 'vibe schema scene.<command>' for structured parameter info.`
31814
32693
  kicker: options.kicker,
31815
32694
  projectDir: options.project,
31816
32695
  insertInto: options.insertInto,
32696
+ syncStoryboard: options.storyboard !== false,
31817
32697
  imageProvider: options.imageProvider,
31818
32698
  tts,
31819
32699
  voice: options.voice,
@@ -31853,6 +32733,13 @@ Run 'vibe schema scene.<command>' for structured parameter info.`
31853
32733
  if (result.audioPath) console.log(source_default.green(" +"), result.audioPath);
31854
32734
  if (result.imagePath) console.log(source_default.green(" +"), result.imagePath);
31855
32735
  if (result.transcriptPath) console.log(source_default.green(" +"), result.transcriptPath);
32736
+ if (result.storyboardPath && result.storyboardSynced) {
32737
+ console.log(
32738
+ result.storyboardAction === "updated" ? source_default.yellow(" ~") : source_default.green(" +"),
32739
+ result.storyboardPath,
32740
+ source_default.dim(`(${result.storyboardAction})`)
32741
+ );
32742
+ }
31856
32743
  console.log(source_default.yellow(" ~"), result.rootPath, source_default.dim("(updated)"));
31857
32744
  console.log();
31858
32745
  console.log(source_default.bold.cyan("Composition"));
@@ -35661,6 +36548,10 @@ async function inspectProjectStatus(projectDirArg, opts = {}) {
35661
36548
  ...review?.retryWith ?? [],
35662
36549
  ...workflow.retryWith
35663
36550
  ];
36551
+ const nextActions = normalizeReviewActions([
36552
+ ...review?.nextActions ?? [],
36553
+ ...reviewActionsFromRetryWith([...build?.retryWith ?? [], ...workflow.retryWith])
36554
+ ]);
35664
36555
  return {
35665
36556
  schemaVersion: "1",
35666
36557
  kind: "project",
@@ -35672,6 +36563,7 @@ async function inspectProjectStatus(projectDirArg, opts = {}) {
35672
36563
  review,
35673
36564
  jobs,
35674
36565
  warnings,
36566
+ nextActions,
35675
36567
  retryWith: unique(retryWith)
35676
36568
  };
35677
36569
  }
@@ -35965,9 +36857,44 @@ async function readReviewSummary(projectDir) {
35965
36857
  },
35966
36858
  sourceReports: stringArray(report.sourceReports),
35967
36859
  updatedAt: info?.mtime.toISOString(),
36860
+ nextActions: reviewActionsFromUnknown(report.nextActions),
35968
36861
  retryWith: stringArray(report.retryWith)
35969
36862
  });
35970
36863
  }
36864
+ function reviewActionsFromUnknown(value) {
36865
+ if (!Array.isArray(value)) return [];
36866
+ return value.flatMap((item) => {
36867
+ if (!item || typeof item !== "object") return [];
36868
+ const record = item;
36869
+ const kind = stringEnum(record.kind, ["command", "agent", "manual"]);
36870
+ const fixOwner = stringEnum(record.fixOwner, ["vibe", "host-agent"]);
36871
+ const costTier = stringEnum(record.costTier, [
36872
+ "free",
36873
+ "low",
36874
+ "high",
36875
+ "very-high",
36876
+ "unknown"
36877
+ ]);
36878
+ if (!kind || !fixOwner || !costTier || typeof record.id !== "string" || typeof record.label !== "string" || typeof record.safeToAutoRun !== "boolean" || typeof record.requiresConfirmation !== "boolean" || typeof record.reason !== "string") {
36879
+ return [];
36880
+ }
36881
+ return [
36882
+ stripUndefined2({
36883
+ id: record.id,
36884
+ kind,
36885
+ label: record.label,
36886
+ command: typeof record.command === "string" ? record.command : void 0,
36887
+ agentPrompt: typeof record.agentPrompt === "string" ? record.agentPrompt : void 0,
36888
+ fixOwner,
36889
+ costTier,
36890
+ safeToAutoRun: record.safeToAutoRun,
36891
+ requiresConfirmation: record.requiresConfirmation,
36892
+ reason: record.reason,
36893
+ sourceIssueCodes: stringArray(record.sourceIssueCodes)
36894
+ })
36895
+ ];
36896
+ });
36897
+ }
35971
36898
  function summarizeJobs(records) {
35972
36899
  return {
35973
36900
  total: records.length,
@@ -36179,6 +37106,9 @@ function numberOr(fallback2, value) {
36179
37106
  function stringArray(value) {
36180
37107
  return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.length > 0) : [];
36181
37108
  }
37109
+ function stringEnum(value, allowed) {
37110
+ return typeof value === "string" && allowed.includes(value) ? value : void 0;
37111
+ }
36182
37112
  function isActiveStatus(status) {
36183
37113
  return status === "queued" || status === "running";
36184
37114
  }
@@ -36244,6 +37174,7 @@ var init_status_jobs = __esm({
36244
37174
  init_ai_video();
36245
37175
  init_music_status();
36246
37176
  init_build_asset_metadata();
37177
+ init_review_report();
36247
37178
  init_storyboard_parse();
36248
37179
  LOCAL_JOB_ORPHAN_AFTER_MS = 9e4;
36249
37180
  }
@@ -39125,10 +40056,18 @@ Examples:
39125
40056
  options.ratio = "16:9";
39126
40057
  }
39127
40058
  if (options.dryRun) {
40059
+ const costUsd = provider === "seedance" || provider === "fal" ? estimateSeedanceVideoCostUsd({
40060
+ durationSec: Number(options.duration) || 5,
40061
+ resolution: options.resolution,
40062
+ aspectRatio: options.ratio,
40063
+ fast: options.seedanceModel === "fast",
40064
+ hasVideoReference: Boolean(options.refVideos)
40065
+ }) : void 0;
39128
40066
  outputSuccess({
39129
40067
  command: "generate video",
39130
40068
  startedAt,
39131
40069
  dryRun: true,
40070
+ costUsd,
39132
40071
  data: {
39133
40072
  params: {
39134
40073
  prompt: prompt3,
@@ -44316,208 +45255,6 @@ var init_ai_review = __esm({
44316
45255
  }
44317
45256
  });
44318
45257
 
44319
- // src/commands/_shared/storyboard-edit.ts
44320
- function validateStoryboardMarkdown(markdown) {
44321
- const parsed = parseStoryboard(markdown);
44322
- const sections = splitBeatSections(markdown);
44323
- const issues = [];
44324
- if (parsed.beats.length === 0) {
44325
- issues.push({
44326
- severity: "error",
44327
- code: "NO_BEATS",
44328
- message: "STORYBOARD.md must contain at least one `## Beat ...` heading."
44329
- });
44330
- }
44331
- const seen = /* @__PURE__ */ new Map();
44332
- for (const beat of parsed.beats) {
44333
- seen.set(beat.id, (seen.get(beat.id) ?? 0) + 1);
44334
- }
44335
- for (const [id, count] of seen.entries()) {
44336
- if (count > 1) {
44337
- issues.push({
44338
- severity: "error",
44339
- code: "DUPLICATE_BEAT_ID",
44340
- beatId: id,
44341
- message: `Beat id "${id}" appears ${count} times. Beat ids must be unique.`
44342
- });
44343
- }
44344
- }
44345
- for (const section of sections) {
44346
- const cueBlock = readLeadingCueBlock(section.body);
44347
- if (cueBlock?.error) {
44348
- issues.push({
44349
- severity: "error",
44350
- code: "MALFORMED_CUE_YAML",
44351
- beatId: section.id,
44352
- message: `Beat "${section.id}" has malformed YAML cues: ${cueBlock.error}`
44353
- });
44354
- }
44355
- }
44356
- for (const beat of parsed.beats) {
44357
- const cues = beat.cues ?? {};
44358
- for (const [key, value] of Object.entries(cues)) {
44359
- if (!ALLOWED_CUE_KEYS.has(key)) {
44360
- issues.push({
44361
- severity: "warning",
44362
- code: "UNKNOWN_CUE",
44363
- beatId: beat.id,
44364
- message: `Beat "${beat.id}" uses unknown cue "${key}". Supported cues: ${STORYBOARD_CUE_KEYS.join(", ")}.`
44365
- });
44366
- continue;
44367
- }
44368
- if (key === "duration") {
44369
- if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
44370
- issues.push({
44371
- severity: "error",
44372
- code: "INVALID_DURATION",
44373
- beatId: beat.id,
44374
- message: `Beat "${beat.id}" cue "duration" must be a positive number of seconds.`
44375
- });
44376
- }
44377
- continue;
44378
- }
44379
- if (STRING_CUE_KEYS.has(key) && typeof value !== "string") {
44380
- issues.push({
44381
- severity: "error",
44382
- code: "INVALID_CUE_VALUE",
44383
- beatId: beat.id,
44384
- message: `Beat "${beat.id}" cue "${key}" must be a string.`
44385
- });
44386
- }
44387
- }
44388
- }
44389
- for (const beat of parsed.beats) {
44390
- if (beat.duration !== void 0 && beat.duration > MAX_RECOMMENDED_BEAT_SEC) {
44391
- issues.push({
44392
- severity: "warning",
44393
- code: "BEAT_DURATION_TOO_LONG",
44394
- beatId: beat.id,
44395
- message: `Beat "${beat.id}" is ${beat.duration}s \u2014 beats longer than ${MAX_RECOMMENDED_BEAT_SEC}s render static and overstuffed. Split it into 6-15s beats (a 90s video should have 6-8 beats).`
44396
- });
44397
- }
44398
- }
44399
- return {
44400
- ok: !issues.some((i) => i.severity === "error"),
44401
- beats: parsed.beats,
44402
- issues
44403
- };
44404
- }
44405
- function getStoryboardBeat(markdown, beatId) {
44406
- return parseStoryboard(markdown).beats.find((beat) => beat.id === beatId) ?? null;
44407
- }
44408
- function setStoryboardCue(markdown, opts) {
44409
- const section = findBeatSection(markdown, opts.beatId);
44410
- if (!section) {
44411
- throw new Error(`Beat "${opts.beatId}" not found.`);
44412
- }
44413
- if (!ALLOWED_CUE_KEYS.has(opts.key)) {
44414
- throw new Error(`Unsupported cue "${opts.key}". Supported cues: ${STORYBOARD_CUE_KEYS.join(", ")}.`);
44415
- }
44416
- const cue = readLeadingCueBlock(section.body);
44417
- if (cue?.error) {
44418
- throw new Error(`Cannot edit malformed YAML cues for beat "${opts.beatId}": ${cue.error}`);
44419
- }
44420
- const cues = cue?.value ? { ...cue.value } : {};
44421
- if (opts.unset) {
44422
- delete cues[opts.key];
44423
- } else {
44424
- cues[opts.key] = normalizeCueValue(opts.key, opts.value);
44425
- }
44426
- const remainingBody = cue ? section.body.slice(cue.full.length).replace(/^\s*\n/, "") : section.body.replace(/^\s*\n/, "");
44427
- const cueBlock = Object.keys(cues).length > 0 ? "```yaml\n" + (0, import_yaml5.stringify)(cues, { lineWidth: 0 }).trimEnd() + "\n```\n\n" : "";
44428
- const nextBody = cueBlock + remainingBody.trimStart();
44429
- const nextSection = section.raw.slice(0, section.headingEnd - section.start) + "\n\n" + nextBody.trimEnd() + "\n";
44430
- return markdown.slice(0, section.start) + nextSection + markdown.slice(section.end);
44431
- }
44432
- function moveStoryboardBeat(markdown, opts) {
44433
- if (opts.beatId === opts.afterBeatId) return markdown;
44434
- const sections = splitBeatSections(markdown);
44435
- const movingIndex = sections.findIndex((section) => section.id === opts.beatId);
44436
- if (movingIndex === -1) throw new Error(`Beat "${opts.beatId}" not found.`);
44437
- const afterIndex = sections.findIndex((section) => section.id === opts.afterBeatId);
44438
- if (afterIndex === -1) throw new Error(`Beat "${opts.afterBeatId}" not found.`);
44439
- const global2 = sections.length > 0 ? markdown.slice(0, sections[0].start) : markdown;
44440
- const chunks = sections.map((section) => section.raw);
44441
- const [moving] = chunks.splice(movingIndex, 1);
44442
- const adjustedAfterIndex = movingIndex < afterIndex ? afterIndex - 1 : afterIndex;
44443
- chunks.splice(adjustedAfterIndex + 1, 0, moving);
44444
- return global2 + normalizeBeatChunks(chunks);
44445
- }
44446
- function normalizeBeatChunks(chunks) {
44447
- return chunks.map((chunk) => chunk.trim()).filter(Boolean).join("\n\n") + "\n";
44448
- }
44449
- function normalizeCueValue(key, value) {
44450
- if (key === "duration") {
44451
- const n = typeof value === "number" ? value : Number.parseFloat(String(value ?? ""));
44452
- if (!Number.isFinite(n) || n <= 0) {
44453
- throw new Error("Cue duration must be a positive number.");
44454
- }
44455
- return n;
44456
- }
44457
- if (typeof value !== "string") return value;
44458
- return value.trim();
44459
- }
44460
- function splitBeatSections(markdown) {
44461
- const normalized = markdown.replace(/\r\n/g, "\n");
44462
- const headings = [];
44463
- HEADING_RE2.lastIndex = 0;
44464
- let match;
44465
- while ((match = HEADING_RE2.exec(normalized)) !== null) {
44466
- headings.push({ start: match.index, end: match.index + match[0].length, line: match[1].trim() });
44467
- }
44468
- return headings.map((heading, index) => {
44469
- const end = index + 1 < headings.length ? headings[index + 1].start : normalized.length;
44470
- return {
44471
- id: deriveBeatId(heading.line),
44472
- heading: heading.line,
44473
- start: heading.start,
44474
- headingEnd: heading.end,
44475
- end,
44476
- raw: normalized.slice(heading.start, end),
44477
- body: normalized.slice(heading.end, end).trim()
44478
- };
44479
- });
44480
- }
44481
- function findBeatSection(markdown, beatId) {
44482
- return splitBeatSections(markdown).find((section) => section.id === beatId) ?? null;
44483
- }
44484
- function readLeadingCueBlock(body) {
44485
- const match = body.match(LEADING_CUE_RE);
44486
- if (!match) return null;
44487
- try {
44488
- const parsed = (0, import_yaml5.parse)(match[2]);
44489
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
44490
- return { full: match[0], error: "cue block must parse to a YAML object" };
44491
- }
44492
- return { full: match[0], value: parsed };
44493
- } catch (error) {
44494
- return { full: match[0], error: error instanceof Error ? error.message : String(error) };
44495
- }
44496
- }
44497
- var import_yaml5, STORYBOARD_CUE_KEYS, HEADING_RE2, LEADING_CUE_RE, ALLOWED_CUE_KEYS, STRING_CUE_KEYS, MAX_RECOMMENDED_BEAT_SEC;
44498
- var init_storyboard_edit = __esm({
44499
- "src/commands/_shared/storyboard-edit.ts"() {
44500
- "use strict";
44501
- import_yaml5 = __toESM(require_dist2(), 1);
44502
- init_storyboard_parse();
44503
- STORYBOARD_CUE_KEYS = [
44504
- "duration",
44505
- "narration",
44506
- "backdrop",
44507
- "video",
44508
- "motion",
44509
- "voice",
44510
- "music",
44511
- "asset"
44512
- ];
44513
- HEADING_RE2 = /^##\s+(.+?)\s*$/gm;
44514
- LEADING_CUE_RE = /^(\s*)```ya?ml\s*\n([\s\S]*?)\n```\s*(?:\n|$)/;
44515
- ALLOWED_CUE_KEYS = new Set(STORYBOARD_CUE_KEYS);
44516
- STRING_CUE_KEYS = /* @__PURE__ */ new Set(["narration", "backdrop", "video", "motion", "voice", "music", "asset"]);
44517
- MAX_RECOMMENDED_BEAT_SEC = 15;
44518
- }
44519
- });
44520
-
44521
45258
  // src/commands/_shared/scene-inspect.ts
44522
45259
  import { existsSync as existsSync34 } from "node:fs";
44523
45260
  import { readdir as readdir4, readFile as readFile29, stat as stat5 } from "node:fs/promises";
@@ -44750,7 +45487,11 @@ async function inspectProject(opts) {
44750
45487
  );
44751
45488
  }
44752
45489
  function makeProjectResult(projectDir, issues, checks, retryWith, beatId) {
44753
- const normalizedIssues = normalizeReviewIssues(issues);
45490
+ const normalizedRetryWith = uniqueRetryWith(retryWith);
45491
+ const normalizedIssues = normalizeReviewIssues(issues, {
45492
+ projectDir,
45493
+ retryWith: normalizedRetryWith
45494
+ });
44754
45495
  const status = statusFromIssues(normalizedIssues);
44755
45496
  return {
44756
45497
  schemaVersion: "1",
@@ -44762,9 +45503,14 @@ function makeProjectResult(projectDir, issues, checks, retryWith, beatId) {
44762
45503
  score: scoreIssues(normalizedIssues),
44763
45504
  issues: normalizedIssues,
44764
45505
  summary: summarizeReviewIssues(normalizedIssues),
45506
+ nextActions: deriveNextReviewActions({
45507
+ issues: normalizedIssues,
45508
+ retryWith: normalizedRetryWith,
45509
+ projectDir
45510
+ }),
44765
45511
  sourceReports: projectSourceReports(checks),
44766
45512
  checks,
44767
- retryWith: uniqueRetryWith(retryWith)
45513
+ retryWith: normalizedRetryWith
44768
45514
  };
44769
45515
  }
44770
45516
  function storyboardPlaceholderIssues(storyboard) {
@@ -45306,6 +46052,8 @@ async function inspectRender(opts) {
45306
46052
  severity: "error",
45307
46053
  code: "RENDER_NOT_FOUND",
45308
46054
  message: opts.beatId ? `No rendered video was found for beat "${opts.beatId}". Pass --video or render the beat first.` : "No rendered video was found. Pass --video or render the project first.",
46055
+ beatId: opts.beatId,
46056
+ scene: opts.beatId,
45309
46057
  suggestedFix: opts.beatId ? "Run `vibe render <project> --beat <id> --json`." : "Run `vibe build --stage render --json` or `vibe render --json`."
45310
46058
  });
45311
46059
  retryWith.push(
@@ -45328,6 +46076,8 @@ async function inspectRender(opts) {
45328
46076
  code: "EMPTY_RENDER",
45329
46077
  message: "Rendered video file is empty.",
45330
46078
  file: displayPath(videoPath),
46079
+ beatId: opts.beatId,
46080
+ scene: opts.beatId,
45331
46081
  suggestedFix: opts.beatId ? "Render again with `vibe render --beat <id> --json`." : "Render again with `vibe render --json`."
45332
46082
  });
45333
46083
  retryWith.push(
@@ -45337,6 +46087,8 @@ async function inspectRender(opts) {
45337
46087
  const beatTimings = await beatTimingsFromBuildReport(projectDir, opts.beatId);
45338
46088
  const expectedDurationSec = beatTimings.length > 0 ? Number(beatTimings.reduce((sum, beat) => sum + beat.sceneDurationSec, 0).toFixed(3)) : await expectedDurationFromBuildReport(projectDir, opts.beatId);
45339
46089
  if (expectedDurationSec !== void 0) checks.expectedDurationSec = expectedDurationSec;
46090
+ const expectedAudio = await renderExpectsAudio(projectDir, opts.beatId);
46091
+ checks.expectedAudio = expectedAudio;
45340
46092
  if (!commandExists("ffprobe")) {
45341
46093
  issues.push({
45342
46094
  severity: "error",
@@ -45362,7 +46114,7 @@ async function inspectRender(opts) {
45362
46114
  file: displayPath(videoPath)
45363
46115
  });
45364
46116
  }
45365
- if (!audioStream) {
46117
+ if (!audioStream && expectedAudio) {
45366
46118
  issues.push({
45367
46119
  severity: "warning",
45368
46120
  code: "NO_AUDIO_STREAM",
@@ -45567,8 +46319,86 @@ async function inspectRender(opts) {
45567
46319
  );
45568
46320
  return maybeWriteRenderReport(projectDir, opts, result);
45569
46321
  }
46322
+ async function renderExpectsAudio(projectDir, beatId) {
46323
+ const root = resolve43(projectDir);
46324
+ const rootHtml = await readTextIfExists(join19(root, "index.html"));
46325
+ if (rootHtml && rootHtmlExpectsAudio(rootHtml, beatId)) return true;
46326
+ const buildReport = await readJsonIfExists(join19(root, "build-report.json"));
46327
+ if (buildReportExpectsAudio(buildReport, beatId)) return true;
46328
+ const storyboard = await readTextIfExists(join19(root, "STORYBOARD.md"));
46329
+ if (!storyboard) return false;
46330
+ try {
46331
+ const parsed = parseStoryboard(storyboard);
46332
+ const beats = beatId ? parsed.beats.filter((beat) => beat.id === beatId) : parsed.beats;
46333
+ return beats.some(
46334
+ (beat) => [
46335
+ `assets/narration-${beat.id}.mp3`,
46336
+ `assets/narration-${beat.id}.wav`,
46337
+ `assets/music-${beat.id}.mp3`,
46338
+ `assets/music-${beat.id}.wav`
46339
+ ].some((asset) => existsSync35(join19(root, asset)))
46340
+ );
46341
+ } catch {
46342
+ return false;
46343
+ }
46344
+ }
46345
+ function rootHtmlExpectsAudio(html, beatId) {
46346
+ const audioTags = html.match(/<audio\b[^>]*>/gi) ?? [];
46347
+ if (!beatId) return audioTags.length > 0;
46348
+ const escaped = escapeRegExp4(beatId);
46349
+ const beatAudioRe = new RegExp(
46350
+ `(id="(?:narration|music)-${escaped}"|src="[^"]*(?:narration|music)-${escaped}\\.(?:mp3|wav)")`,
46351
+ "i"
46352
+ );
46353
+ return audioTags.some((tag) => beatAudioRe.test(tag));
46354
+ }
46355
+ function buildReportExpectsAudio(report, beatId) {
46356
+ if (!report || typeof report !== "object") return false;
46357
+ const beats = Array.isArray(report.beats) ? report.beats ?? [] : [];
46358
+ const selected = beatId === void 0 ? beats : beats.filter((beat) => {
46359
+ if (!beat || typeof beat !== "object") return false;
46360
+ return beat.id === beatId;
46361
+ });
46362
+ return selected.some((beat) => beatReportExpectsAudio(beat));
46363
+ }
46364
+ function beatReportExpectsAudio(beat) {
46365
+ if (!beat || typeof beat !== "object") return false;
46366
+ const record = beat;
46367
+ if (typeof record.narrationPath === "string" && record.narrationPath.length > 0) return true;
46368
+ if (typeof record.musicPath === "string" && record.musicPath.length > 0) return true;
46369
+ if (parseOptionalNumber(record.narrationDurationSec) !== void 0) return true;
46370
+ const narration = record.narration;
46371
+ if (narration && typeof narration === "object") {
46372
+ const n = narration;
46373
+ if (typeof n.path === "string" && n.path.length > 0) return true;
46374
+ if (parseOptionalNumber(n.durationSec) !== void 0) return true;
46375
+ }
46376
+ const music = record.music;
46377
+ if (music && typeof music === "object") {
46378
+ const m = music;
46379
+ if (typeof m.path === "string" && m.path.length > 0) return true;
46380
+ if (parseOptionalNumber(m.durationSec) !== void 0) return true;
46381
+ }
46382
+ return false;
46383
+ }
46384
+ async function readJsonIfExists(path2) {
46385
+ const text = await readTextIfExists(path2);
46386
+ if (!text) return null;
46387
+ try {
46388
+ return JSON.parse(text);
46389
+ } catch {
46390
+ return null;
46391
+ }
46392
+ }
46393
+ function escapeRegExp4(value) {
46394
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46395
+ }
45570
46396
  function makeRenderResult(projectDir, opts, videoPath, issues, checks, retryWith, score) {
45571
- const normalizedIssues = normalizeReviewIssues(issues);
46397
+ const normalizedRetryWith = uniqueRetryWith(retryWith);
46398
+ const normalizedIssues = normalizeReviewIssues(issues, {
46399
+ projectDir,
46400
+ retryWith: normalizedRetryWith
46401
+ });
45572
46402
  const status = statusFromIssues(normalizedIssues);
45573
46403
  return {
45574
46404
  schemaVersion: "1",
@@ -45581,9 +46411,14 @@ function makeRenderResult(projectDir, opts, videoPath, issues, checks, retryWith
45581
46411
  score,
45582
46412
  issues: normalizedIssues,
45583
46413
  summary: summarizeReviewIssues(normalizedIssues),
46414
+ nextActions: deriveNextReviewActions({
46415
+ issues: normalizedIssues,
46416
+ retryWith: normalizedRetryWith,
46417
+ projectDir
46418
+ }),
45584
46419
  sourceReports: renderSourceReports(projectDir, videoPath, checks),
45585
46420
  checks,
45586
- retryWith: uniqueRetryWith(retryWith)
46421
+ retryWith: normalizedRetryWith
45587
46422
  };
45588
46423
  }
45589
46424
  async function maybeWriteRenderReport(projectDir, opts, result) {
@@ -59587,6 +60422,8 @@ var init_build_asset_reference = __esm({
59587
60422
  EXTENSIONS = {
59588
60423
  narration: [".mp3", ".wav", ".m4a"],
59589
60424
  backdrop: [".png", ".jpg", ".jpeg", ".webp"],
60425
+ character: [".png", ".jpg", ".jpeg", ".webp"],
60426
+ keyframe: [".png", ".jpg", ".jpeg", ".webp"],
59590
60427
  video: [".mp4", ".mov", ".webm"],
59591
60428
  music: [".mp3", ".wav", ".m4a"]
59592
60429
  };
@@ -59624,6 +60461,28 @@ function backdropCacheDescriptor(opts) {
59624
60461
  ext: "png"
59625
60462
  });
59626
60463
  }
60464
+ function characterCacheDescriptor(opts) {
60465
+ return cacheAssetDescriptor("character", {
60466
+ name: opts.name,
60467
+ cue: opts.cue,
60468
+ provider: opts.provider,
60469
+ quality: opts.quality,
60470
+ size: opts.size,
60471
+ ext: "png"
60472
+ });
60473
+ }
60474
+ function keyframeCacheDescriptor(opts) {
60475
+ return cacheAssetDescriptor("keyframe", {
60476
+ beatId: opts.beatId,
60477
+ cue: opts.cue,
60478
+ provider: opts.provider,
60479
+ quality: opts.quality,
60480
+ size: opts.size,
60481
+ ratio: opts.ratio,
60482
+ characters: opts.characters && opts.characters.length > 0 ? opts.characters : void 0,
60483
+ ext: "png"
60484
+ });
60485
+ }
59627
60486
  function imageRatioForSize(size) {
59628
60487
  switch (size) {
59629
60488
  case "1024x1024":
@@ -59643,6 +60502,10 @@ function videoCacheDescriptor(opts) {
59643
60502
  provider: opts.provider,
59644
60503
  duration: normalizeVideoDuration(opts.duration),
59645
60504
  ratio: "16:9",
60505
+ // Omit when empty so existing (character-less) clips keep their cache key.
60506
+ characters: opts.characters && opts.characters.length > 0 ? opts.characters : void 0,
60507
+ // Omit when absent so existing (non-keyframe) clips keep their cache key.
60508
+ keyframe: opts.keyframe || void 0,
59646
60509
  ext: "mp4"
59647
60510
  });
59648
60511
  }
@@ -59793,6 +60656,7 @@ async function createBuildPlan(opts) {
59793
60656
  const backdropPrompt = stringOrUndefined3(cue.backdrop);
59794
60657
  const augmentedBackdropPrompt = backdropPrompt ? augmentBackdropPrompt(backdropPrompt) : null;
59795
60658
  const videoPrompt = stringOrUndefined3(cue.video);
60659
+ const keyframePrompt = stringOrUndefined3(cue.keyframe);
59796
60660
  const musicPrompt = stringOrUndefined3(cue.music);
59797
60661
  const genericReference = resolveGenericAssetReference(projectDir, cue.asset);
59798
60662
  const narrationReference = resolveTypedAssetReference(projectDir, "narration", cue.narration);
@@ -59801,7 +60665,7 @@ async function createBuildPlan(opts) {
59801
60665
  const musicReference = resolveTypedAssetReference(projectDir, "music", cue.music) ?? (!musicPrompt && genericReference?.kind === "music" ? genericReference : null);
59802
60666
  const narrationCue = narrationText ?? narrationReference?.raw;
59803
60667
  const backdropCue = backdropPrompt ?? backdropReference?.raw;
59804
- const videoCue = videoPrompt ?? videoReference?.raw;
60668
+ const videoCue = videoPrompt ?? videoReference?.raw ?? keyframePrompt;
59805
60669
  const musicCue = musicPrompt ?? musicReference?.raw;
59806
60670
  const narrationCost = resolved.narration.resolved === "elevenlabs" ? ELEVENLABS_NARRATION_COST_USD : resolved.narration.resolved === "openai" ? OPENAI_NARRATION_COST_USD : 0;
59807
60671
  const narrationCache = narrationText && !narrationReference ? narrationCacheDescriptor({
@@ -59819,11 +60683,12 @@ async function createBuildPlan(opts) {
59819
60683
  size: imageSize2,
59820
60684
  ratio: imageRatio
59821
60685
  }) : null;
59822
- const videoCache = videoPrompt && !videoReference ? videoCacheDescriptor({
60686
+ const videoCache = (videoPrompt || keyframePrompt) && !videoReference ? videoCacheDescriptor({
59823
60687
  beatId: beat.id,
59824
- cue: videoPrompt,
60688
+ cue: videoPrompt ?? keyframePrompt,
59825
60689
  provider: resolved.video.resolved,
59826
- duration: beat.duration
60690
+ duration: beat.duration,
60691
+ keyframe: keyframePrompt
59827
60692
  }) : null;
59828
60693
  const musicCache = musicPrompt && !musicReference ? musicCacheDescriptor({
59829
60694
  beatId: beat.id,
@@ -59870,7 +60735,11 @@ async function createBuildPlan(opts) {
59870
60735
  reference: videoReference,
59871
60736
  projectDir,
59872
60737
  force: opts.force,
59873
- cost: VIDEO_COST_USD,
60738
+ cost: resolved.video.resolved === "seedance" || resolved.video.resolved === "fal" ? estimateSeedanceVideoCostUsd({
60739
+ durationSec: normalizeVideoDuration(beat.duration),
60740
+ resolution: "720p",
60741
+ aspectRatio: "16:9"
60742
+ }) : VIDEO_COST_USD,
59874
60743
  active: includeAssets
59875
60744
  }) : null;
59876
60745
  const music = musicCue && !opts.skipMusic ? assetPlan({
@@ -59914,6 +60783,12 @@ async function createBuildPlan(opts) {
59914
60783
  if (backdrop?.willGenerate && isSupportedBuildImageProvider(resolved.image.resolved))
59915
60784
  noteProviderNeed(resolved.image, "Backdrop generation");
59916
60785
  if (video?.willGenerate) noteProviderNeed(resolved.video, "Video generation");
60786
+ if (keyframePrompt && video?.willGenerate) {
60787
+ estimatedCostUsd += backdropCostUsd(imageQuality);
60788
+ providers.add(resolved.image.resolved);
60789
+ if (isSupportedBuildImageProvider(resolved.image.resolved))
60790
+ noteProviderNeed(resolved.image, "Keyframe generation");
60791
+ }
59917
60792
  if (music?.willGenerate) noteProviderNeed(resolved.music, "Music generation");
59918
60793
  if (!compositionExists) missing.add("compositions");
59919
60794
  if (includeCompose && !compositionExists && mode !== "agent") {
@@ -59981,6 +60856,21 @@ async function createBuildPlan(opts) {
59981
60856
  retryWith.push(skipCommand);
59982
60857
  }
59983
60858
  }
60859
+ if (validationOk && includeAssets && !opts.skipVideo) {
60860
+ const referenced = /* @__PURE__ */ new Set();
60861
+ for (const beat of sourceBeats) {
60862
+ for (const name of beatCharacterNames(beat.cues)) referenced.add(name);
60863
+ }
60864
+ if (referenced.size > 0) {
60865
+ const toGenerate = resolveCharacters(parsed.frontmatter).filter(
60866
+ (c) => referenced.has(c.name) && c.prompt && (opts.force || !existsSync47(join23(projectDir, `assets/character-${c.name}.png`)))
60867
+ );
60868
+ if (toGenerate.length > 0) {
60869
+ estimatedCostUsd += toGenerate.length * backdropCostUsd(opts.imageQuality ?? "hd");
60870
+ missing.add("assets");
60871
+ }
60872
+ }
60873
+ }
59984
60874
  return finalizeBuildPlan({
59985
60875
  projectDir,
59986
60876
  config: config2,
@@ -60345,6 +61235,7 @@ var init_build_plan = __esm({
60345
61235
  init_schema();
60346
61236
  init_build_asset_reference();
60347
61237
  init_build_cache();
61238
+ init_dist2();
60348
61239
  init_build_asset_metadata();
60349
61240
  init_build_backdrop_prompt();
60350
61241
  init_composer_resolve();
@@ -61172,7 +62063,7 @@ __export(scene_build_exports, {
61172
62063
  executeSceneBuild: () => executeSceneBuild,
61173
62064
  resolveSceneBuildMode: () => resolveSceneBuildMode
61174
62065
  });
61175
- import { existsSync as existsSync49 } from "node:fs";
62066
+ import { existsSync as existsSync49, readFileSync as readFileSync3 } from "node:fs";
61176
62067
  import { copyFile as copyFile5, mkdir as mkdir26, readFile as readFile46, writeFile as writeFile50 } from "node:fs/promises";
61177
62068
  import { dirname as dirname24, join as join25, resolve as resolve65 } from "node:path";
61178
62069
  async function executeSceneBuild(opts) {
@@ -61339,8 +62230,32 @@ async function executeSceneBuild(opts) {
61339
62230
  const voice = opts.voice ?? parsed.frontmatter?.voice;
61340
62231
  let beatOutcomes = collectExistingBeatOutcomes(parsed.beats, projectDir);
61341
62232
  let pendingJobs = [];
62233
+ let characterCostUsd = 0;
61342
62234
  if (shouldRunStage(selectedStage, "assets")) {
61343
62235
  onProgress({ type: "phase-start", phase: "primitives" });
62236
+ const characterPaths = /* @__PURE__ */ new Map();
62237
+ if (!(opts.skipVideo ?? false)) {
62238
+ const referenced = /* @__PURE__ */ new Set();
62239
+ for (const beat of activeBeats) {
62240
+ for (const name of beatCharacterNames(beat.cues)) referenced.add(name);
62241
+ }
62242
+ if (referenced.size > 0) {
62243
+ const charResult = await buildCharacters(
62244
+ resolveCharacters(parsed.frontmatter).filter((c) => referenced.has(c.name)),
62245
+ {
62246
+ projectDir,
62247
+ imageProvider,
62248
+ imageSize: opts.imageSize ?? "1536x1024",
62249
+ imageQuality: opts.imageQuality ?? "hd",
62250
+ force: opts.force ?? false,
62251
+ onProgress
62252
+ }
62253
+ );
62254
+ for (const [name, path2] of charResult.paths) characterPaths.set(name, path2);
62255
+ characterCostUsd = charResult.costUsd;
62256
+ warnings.push(...charResult.failures);
62257
+ }
62258
+ }
61344
62259
  const primitiveResults = await mapWithConcurrency(
61345
62260
  activeBeats,
61346
62261
  BEAT_PRIMITIVES_CONCURRENCY,
@@ -61358,7 +62273,8 @@ async function executeSceneBuild(opts) {
61358
62273
  skipVideo: opts.skipVideo ?? false,
61359
62274
  skipMusic: opts.skipMusic ?? false,
61360
62275
  force: opts.force ?? false,
61361
- onProgress
62276
+ onProgress,
62277
+ characterPaths
61362
62278
  })
61363
62279
  );
61364
62280
  beatOutcomes = primitiveResults.map((result) => result.outcome);
@@ -61367,10 +62283,7 @@ async function executeSceneBuild(opts) {
61367
62283
  (beat) => beat.narrationStatus === "failed" || beat.backdropStatus === "failed" || beat.videoStatus === "failed" || beat.musicStatus === "failed"
61368
62284
  );
61369
62285
  stageReports.assets.status = assetFailed ? "failed" : pendingJobs.length > 0 ? "pending-jobs" : "done";
61370
- stageReports.assets.costUsd = estimateActualAssetCost(
61371
- beatOutcomes,
61372
- opts.imageQuality ?? "hd"
61373
- );
62286
+ stageReports.assets.costUsd = estimateActualAssetCost(beatOutcomes, opts.imageQuality ?? "hd") + characterCostUsd;
61374
62287
  stageReports.assets.retryWith = pendingJobs.map(
61375
62288
  (job) => `vibe status job ${job.id} --project ${projectDir} --json`
61376
62289
  );
@@ -61535,6 +62448,18 @@ async function executeSceneBuild(opts) {
61535
62448
  stageReports.compose.status = "skipped";
61536
62449
  }
61537
62450
  if (stageReports.compose.status === "done") {
62451
+ for (const beat of activeBeats) {
62452
+ const videoRel = `assets/video-${beat.id}.mp4`;
62453
+ const compositionAbs = join25(projectDir, "compositions", `scene-${beat.id}.html`);
62454
+ if (!existsSync49(join25(projectDir, videoRel)) || !existsSync49(compositionAbs)) continue;
62455
+ const html = readFileSync3(compositionAbs, "utf-8");
62456
+ if (!html.includes(videoRel)) {
62457
+ warnings.push(
62458
+ `Beat "${beat.id}" has a generated clip (${videoRel}) but its composition does not reference it, so the clip will not appear. Recompose the beat.`
62459
+ );
62460
+ retryWith.push(`vibe build ${projectDir} --beat ${beat.id} --stage compose --force --json`);
62461
+ }
62462
+ }
61538
62463
  const repair = await runBuildSceneRepair(projectDir, "compose", false);
61539
62464
  sceneRepair = mergeSceneRepairSummaries(sceneRepair, repair);
61540
62465
  applySceneRepairToStage(stageReports.compose, repair);
@@ -61769,6 +62694,8 @@ async function buildBeatPrimitives(beat, ctx) {
61769
62694
  videoMetadataPath: video.metadataPath,
61770
62695
  videoFreshness: video.freshness,
61771
62696
  videoSourcePath: video.sourcePath,
62697
+ keyframeStatus: video.keyframeStatus,
62698
+ keyframePath: video.keyframePath,
61772
62699
  musicStatus: music.status,
61773
62700
  musicPath: music.path,
61774
62701
  musicJobId: music.job?.id,
@@ -61955,6 +62882,120 @@ async function dispatchNarration(beat, ctx) {
61955
62882
  freshness: "fresh"
61956
62883
  };
61957
62884
  }
62885
+ function characterSheetPrompt(name, description) {
62886
+ return [
62887
+ `Character turnaround reference sheet of an original fictional character named ${name}: ${description}.`,
62888
+ "Show three full-body views side by side on one clean canvas \u2014 front view, side profile, and back view \u2014",
62889
+ "with identical outfit, hairstyle, hair color, and body proportions across all three views.",
62890
+ "Plain light-grey studio background, even neutral lighting, no props.",
62891
+ "Photorealistic digital character, professional concept-art character sheet layout."
62892
+ ].join(" ");
62893
+ }
62894
+ async function buildCharacters(characters, ctx) {
62895
+ const paths = /* @__PURE__ */ new Map();
62896
+ const failures = [];
62897
+ let costUsd = 0;
62898
+ const size = ctx.imageSize ?? "1536x1024";
62899
+ for (const character of characters) {
62900
+ if (character.imagePath) {
62901
+ if (existsSync49(join25(ctx.projectDir, character.imagePath))) {
62902
+ paths.set(character.name, character.imagePath);
62903
+ ctx.onProgress({ type: "character-cached", name: character.name, path: character.imagePath });
62904
+ } else {
62905
+ const error = `character "${character.name}" image not found: ${character.imagePath}`;
62906
+ failures.push(error);
62907
+ ctx.onProgress({ type: "character-failed", name: character.name, error });
62908
+ }
62909
+ continue;
62910
+ }
62911
+ if (!character.prompt) continue;
62912
+ const prompt3 = characterSheetPrompt(character.name, character.prompt);
62913
+ const rel = `assets/character-${character.name}.png`;
62914
+ const abs = join25(ctx.projectDir, rel);
62915
+ const metadataOptions = { quality: ctx.imageQuality, size };
62916
+ const cache = characterCacheDescriptor({
62917
+ name: character.name,
62918
+ cue: prompt3,
62919
+ provider: ctx.imageProvider,
62920
+ quality: ctx.imageQuality,
62921
+ size
62922
+ });
62923
+ if (existsSync49(abs) && !ctx.force) {
62924
+ const metadata = readAssetMetadata(ctx.projectDir, "character", character.name);
62925
+ if (!metadata || isFreshCanonicalAsset({
62926
+ projectDir: ctx.projectDir,
62927
+ kind: "character",
62928
+ beatId: character.name,
62929
+ cue: prompt3,
62930
+ provider: ctx.imageProvider,
62931
+ options: metadataOptions,
62932
+ cacheKey: cache.key
62933
+ })) {
62934
+ paths.set(character.name, rel);
62935
+ ctx.onProgress({ type: "character-cached", name: character.name, path: rel });
62936
+ continue;
62937
+ }
62938
+ }
62939
+ const cacheAbs = join25(ctx.projectDir, cache.path);
62940
+ if (existsSync49(cacheAbs) && !ctx.force) {
62941
+ await mkdir26(dirname24(abs), { recursive: true });
62942
+ await copyFile5(cacheAbs, abs);
62943
+ await writeAssetMetadata({
62944
+ projectDir: ctx.projectDir,
62945
+ kind: "character",
62946
+ beatId: character.name,
62947
+ cue: prompt3,
62948
+ provider: ctx.imageProvider,
62949
+ options: metadataOptions,
62950
+ cacheKey: cache.key,
62951
+ canonicalPath: rel,
62952
+ cachePath: cache.path
62953
+ });
62954
+ paths.set(character.name, rel);
62955
+ ctx.onProgress({ type: "character-cached", name: character.name, path: rel });
62956
+ continue;
62957
+ }
62958
+ const generated = await generateBackdropImage(
62959
+ prompt3,
62960
+ {
62961
+ projectDir: ctx.projectDir,
62962
+ imageProvider: ctx.imageProvider,
62963
+ imageSize: size,
62964
+ imageQuality: ctx.imageQuality
62965
+ },
62966
+ imageRatioForSize(size)
62967
+ );
62968
+ if (!generated.success) {
62969
+ failures.push(`character "${character.name}": ${generated.error}`);
62970
+ ctx.onProgress({ type: "character-failed", name: character.name, error: generated.error });
62971
+ continue;
62972
+ }
62973
+ await mkdir26(dirname24(abs), { recursive: true });
62974
+ await writeFile50(abs, generated.buffer);
62975
+ await mkdir26(dirname24(cacheAbs), { recursive: true });
62976
+ await writeFile50(cacheAbs, generated.buffer);
62977
+ await writeAssetMetadata({
62978
+ projectDir: ctx.projectDir,
62979
+ kind: "character",
62980
+ beatId: character.name,
62981
+ cue: prompt3,
62982
+ provider: ctx.imageProvider,
62983
+ options: metadataOptions,
62984
+ cacheKey: cache.key,
62985
+ canonicalPath: rel,
62986
+ cachePath: cache.path
62987
+ });
62988
+ costUsd += backdropCostUsd(ctx.imageQuality);
62989
+ paths.set(character.name, rel);
62990
+ ctx.onProgress({
62991
+ type: "character-generated",
62992
+ name: character.name,
62993
+ path: rel,
62994
+ provider: ctx.imageProvider
62995
+ });
62996
+ }
62997
+ return { paths, costUsd: Number(costUsd.toFixed(2)), failures };
62998
+ }
61958
62999
  async function dispatchBackdrop(beat, ctx) {
61959
63000
  const reference = assetReferenceForBeat(ctx.projectDir, "backdrop", beat);
61960
63001
  if (reference) return referencePrimitiveOutcome("backdrop", beat, ctx, reference);
@@ -62099,6 +63140,112 @@ async function generateBackdropImage(prompt3, ctx, ratio) {
62099
63140
  });
62100
63141
  return imageBufferFromResult(result);
62101
63142
  }
63143
+ async function editKeyframeImage(prompt3, sheetBuffers, ctx, ratio) {
63144
+ loadSceneBuildEnv(ctx.projectDir);
63145
+ const keyInfo = imageProviderKeyInfo(ctx.imageProvider);
63146
+ const apiKey = await getApiKeyFromConfig(keyInfo.configKey, { cwd: ctx.projectDir }) ?? process.env[keyInfo.envVar] ?? "";
63147
+ if (!apiKey) {
63148
+ return {
63149
+ success: false,
63150
+ error: `${keyInfo.envVar} not set \u2014 cannot generate keyframe with ${ctx.imageProvider}`
63151
+ };
63152
+ }
63153
+ if (ctx.imageProvider === "openai") {
63154
+ const provider2 = new OpenAIImageProvider();
63155
+ await provider2.initialize({ apiKey });
63156
+ const result2 = await provider2.editImage(sheetBuffers, prompt3);
63157
+ return imageBufferFromResult(result2);
63158
+ }
63159
+ if (ctx.imageProvider === "gemini") {
63160
+ const provider2 = new GeminiProvider();
63161
+ await provider2.initialize({ apiKey });
63162
+ const result2 = await provider2.editImage(sheetBuffers, prompt3, {
63163
+ model: "flash",
63164
+ aspectRatio: ratio
63165
+ });
63166
+ return imageBufferFromResult(result2);
63167
+ }
63168
+ const provider = new GrokProvider();
63169
+ await provider.initialize({ apiKey });
63170
+ const result = await provider.editImage(sheetBuffers[0], prompt3, { aspectRatio: ratio });
63171
+ return imageBufferFromResult(result);
63172
+ }
63173
+ async function ensureKeyframe(beat, ctx, keyframeCue, characterRefsRel) {
63174
+ const rel = `assets/keyframe-${beat.id}.png`;
63175
+ const abs = join25(ctx.projectDir, rel);
63176
+ const size = ctx.imageSize ?? "1536x1024";
63177
+ const ratio = imageRatioForSize(size);
63178
+ const metadataOptions = { quality: ctx.imageQuality, size, ratio };
63179
+ const cache = keyframeCacheDescriptor({
63180
+ beatId: beat.id,
63181
+ cue: keyframeCue,
63182
+ provider: ctx.imageProvider,
63183
+ quality: ctx.imageQuality,
63184
+ size,
63185
+ ratio,
63186
+ characters: characterRefsRel
63187
+ });
63188
+ if (existsSync49(abs) && !ctx.force) {
63189
+ const metadata = readAssetMetadata(ctx.projectDir, "keyframe", beat.id);
63190
+ if (!metadata || isFreshCanonicalAsset({
63191
+ projectDir: ctx.projectDir,
63192
+ kind: "keyframe",
63193
+ beatId: beat.id,
63194
+ cue: keyframeCue,
63195
+ provider: ctx.imageProvider,
63196
+ options: metadataOptions,
63197
+ cacheKey: cache.key
63198
+ })) {
63199
+ return { status: "cached", rel };
63200
+ }
63201
+ }
63202
+ const cacheAbs = join25(ctx.projectDir, cache.path);
63203
+ if (existsSync49(cacheAbs) && !ctx.force) {
63204
+ await mkdir26(dirname24(abs), { recursive: true });
63205
+ await copyFile5(cacheAbs, abs);
63206
+ await writeAssetMetadata({
63207
+ projectDir: ctx.projectDir,
63208
+ kind: "keyframe",
63209
+ beatId: beat.id,
63210
+ cue: keyframeCue,
63211
+ provider: ctx.imageProvider,
63212
+ options: metadataOptions,
63213
+ cacheKey: cache.key,
63214
+ canonicalPath: rel,
63215
+ cachePath: cache.path
63216
+ });
63217
+ return { status: "cached", rel };
63218
+ }
63219
+ const sheetBuffers = [];
63220
+ for (const relPath of characterRefsRel) {
63221
+ const sheetAbs = join25(ctx.projectDir, relPath);
63222
+ if (existsSync49(sheetAbs)) sheetBuffers.push(await readFile46(sheetAbs));
63223
+ }
63224
+ const imgCtx = {
63225
+ projectDir: ctx.projectDir,
63226
+ imageProvider: ctx.imageProvider,
63227
+ imageSize: size,
63228
+ imageQuality: ctx.imageQuality
63229
+ };
63230
+ const generated = sheetBuffers.length > 0 ? await editKeyframeImage(keyframeCue, sheetBuffers, imgCtx, ratio) : await generateBackdropImage(keyframeCue, imgCtx, ratio);
63231
+ if (!generated.success) return { status: "failed", error: generated.error };
63232
+ await mkdir26(dirname24(abs), { recursive: true });
63233
+ await writeFile50(abs, generated.buffer);
63234
+ await mkdir26(dirname24(cacheAbs), { recursive: true });
63235
+ await writeFile50(cacheAbs, generated.buffer);
63236
+ await writeAssetMetadata({
63237
+ projectDir: ctx.projectDir,
63238
+ kind: "keyframe",
63239
+ beatId: beat.id,
63240
+ cue: keyframeCue,
63241
+ provider: ctx.imageProvider,
63242
+ options: metadataOptions,
63243
+ cacheKey: cache.key,
63244
+ canonicalPath: rel,
63245
+ cachePath: cache.path
63246
+ });
63247
+ return { status: "generated", rel };
63248
+ }
62102
63249
  async function imageBufferFromResult(result) {
62103
63250
  const image = result.images?.[0];
62104
63251
  if (!result.success || !image) {
@@ -62122,15 +63269,22 @@ function imageProviderKeyInfo(provider) {
62122
63269
  async function dispatchVideo(beat, ctx) {
62123
63270
  const reference = assetReferenceForBeat(ctx.projectDir, "video", beat);
62124
63271
  if (reference) return referencePrimitiveOutcome("video", beat, ctx, reference);
62125
- const prompt3 = stringOrUndefined4(beat.cues?.video);
63272
+ const keyframeCue = stringOrUndefined4(beat.cues?.keyframe);
63273
+ const prompt3 = stringOrUndefined4(beat.cues?.video) ?? keyframeCue;
62126
63274
  if (!prompt3) return { status: "no-cue" };
63275
+ const characterRefsRel = beatCharacterNames(beat.cues).map((name) => ctx.characterPaths?.get(name)).filter((p) => typeof p === "string");
63276
+ const characterRefsAbs = characterRefsRel.map((p) => join25(ctx.projectDir, p));
62127
63277
  const rel = `assets/video-${beat.id}.mp4`;
62128
63278
  const abs = join25(ctx.projectDir, rel);
62129
63279
  const cache = videoCacheDescriptor({
62130
63280
  beatId: beat.id,
62131
63281
  cue: prompt3,
62132
63282
  provider: ctx.videoProvider,
62133
- duration: normalizeVideoDuration(beat.duration)
63283
+ duration: normalizeVideoDuration(beat.duration),
63284
+ // Keyframe mode keys the clip to the keyframe prompt; refs feed the keyframe,
63285
+ // not the clip directly.
63286
+ characters: keyframeCue ? void 0 : characterRefsRel,
63287
+ keyframe: keyframeCue
62134
63288
  });
62135
63289
  const metadataPath = assetMetadataPath("video", beat.id);
62136
63290
  if (existsSync49(abs) && !ctx.force) {
@@ -62183,6 +63337,19 @@ async function dispatchVideo(beat, ctx) {
62183
63337
  };
62184
63338
  }
62185
63339
  loadSceneBuildEnv(ctx.projectDir);
63340
+ let keyframeStatus;
63341
+ let keyframeRel;
63342
+ let keyframeImageAbs;
63343
+ if (keyframeCue) {
63344
+ const kf = await ensureKeyframe(beat, ctx, keyframeCue, characterRefsRel);
63345
+ if (kf.status === "failed") {
63346
+ ctx.onProgress({ type: "video-failed", beatId: beat.id, error: kf.error });
63347
+ return { status: "failed", error: kf.error };
63348
+ }
63349
+ keyframeStatus = kf.status;
63350
+ keyframeRel = kf.rel;
63351
+ keyframeImageAbs = join25(ctx.projectDir, kf.rel);
63352
+ }
62186
63353
  const result = await executeVideoGenerate({
62187
63354
  prompt: prompt3,
62188
63355
  provider: ctx.videoProvider,
@@ -62190,6 +63357,10 @@ async function dispatchVideo(beat, ctx) {
62190
63357
  ratio: "16:9",
62191
63358
  output: abs,
62192
63359
  wait: false,
63360
+ // Keyframe mode → single init frame (image-to-video); otherwise character
63361
+ // reference-to-video.
63362
+ image: keyframeImageAbs,
63363
+ refImages: keyframeImageAbs ? void 0 : characterRefsAbs.length > 0 ? characterRefsAbs : void 0,
62193
63364
  apiKey: await apiKeyForVideoProvider(ctx.videoProvider, ctx.projectDir)
62194
63365
  });
62195
63366
  if (!result.success || !result.taskId) {
@@ -62224,14 +63395,16 @@ async function dispatchVideo(beat, ctx) {
62224
63395
  cachePath: cache.path,
62225
63396
  cacheKey: cache.key,
62226
63397
  metadataPath,
62227
- freshness: "fresh"
63398
+ freshness: "fresh",
63399
+ keyframeStatus,
63400
+ keyframePath: keyframeRel
62228
63401
  };
62229
63402
  }
62230
63403
  const job = await createAndWriteJobRecord({
62231
63404
  jobType: "generate-video",
62232
63405
  provider: result.provider ?? ctx.videoProvider,
62233
63406
  providerTaskId: result.taskId,
62234
- providerTaskType: "text2video",
63407
+ providerTaskType: keyframeImageAbs ? "image2video" : "text2video",
62235
63408
  status: "running",
62236
63409
  projectDir: ctx.projectDir,
62237
63410
  workingDirectory: ctx.projectDir,
@@ -62256,7 +63429,9 @@ async function dispatchVideo(beat, ctx) {
62256
63429
  provider: job.provider,
62257
63430
  cachePath: cache.path,
62258
63431
  cacheKey: cache.key,
62259
- metadataPath
63432
+ metadataPath,
63433
+ keyframeStatus,
63434
+ keyframePath: keyframeRel
62260
63435
  };
62261
63436
  }
62262
63437
  async function dispatchMusic(beat, ctx) {
@@ -62558,7 +63733,14 @@ function estimateActualAssetCost(outcomes, imageQuality) {
62558
63733
  for (const outcome of outcomes) {
62559
63734
  if (outcome.narrationStatus === "generated") cost += 0.05;
62560
63735
  if (outcome.backdropStatus === "generated") cost += backdropCostUsd(imageQuality);
62561
- if (outcome.videoStatus === "generated" || outcome.videoStatus === "pending") cost += 5;
63736
+ if (outcome.keyframeStatus === "generated") cost += backdropCostUsd(imageQuality);
63737
+ if (outcome.videoStatus === "generated" || outcome.videoStatus === "pending") {
63738
+ cost += outcome.videoProvider === "seedance" || outcome.videoProvider === "fal" ? estimateSeedanceVideoCostUsd({
63739
+ durationSec: normalizeVideoDuration(outcome.sceneDurationSec),
63740
+ resolution: "720p",
63741
+ aspectRatio: "16:9"
63742
+ }) : 5;
63743
+ }
62562
63744
  if (outcome.musicStatus === "generated" || outcome.musicStatus === "pending") cost += 0.5;
62563
63745
  }
62564
63746
  return Number(cost.toFixed(2));
@@ -68455,6 +69637,9 @@ var init_scene2 = __esm({
68455
69637
  kicker: external_exports.string().optional().describe("Small label above the headline (used by 'explainer' and 'product-shot' presets)."),
68456
69638
  projectDir: external_exports.string().optional().describe(PROJECT_DIR_DESCRIPTION),
68457
69639
  insertInto: external_exports.string().optional().describe("Root composition file (relative to projectDir). Default 'index.html'."),
69640
+ syncStoryboard: external_exports.boolean().optional().describe(
69641
+ "Sync STORYBOARD.md when present, then rebuild root refs from it. Default true; set false for root-only insertion."
69642
+ ),
68458
69643
  imageProvider: external_exports.enum(["gemini", "openai"]).optional().describe("Image provider for visuals. Default 'gemini'."),
68459
69644
  voice: external_exports.string().optional().describe("ElevenLabs voice id or name."),
68460
69645
  skipAudio: external_exports.boolean().optional().describe("Skip TTS even if narration is provided."),
@@ -68467,7 +69652,7 @@ var init_scene2 = __esm({
68467
69652
  cost: "low",
68468
69653
  title: "Add Scene from Preset",
68469
69654
  annotations: { readOnly: false, openWorld: true },
68470
- description: "Add a single scene to an existing scene project. Optionally generates narration audio (ElevenLabs) and/or a backdrop image (Gemini/OpenAI), then emits compositions/scene-<id>.html with a paused GSAP timeline and splices a clip reference into the root index.html. Use skipAudio:true and skipImage:true for text-only scenes that need no API calls.",
69655
+ description: "Add a single scene to an existing scene project. Optionally generates narration audio (ElevenLabs) and/or a backdrop image (Gemini/OpenAI), then emits compositions/scene-<id>.html with a paused GSAP timeline. By default, syncs STORYBOARD.md and rebuilds root refs from it; set syncStoryboard:false for root-only insertion. Use skipAudio:true and skipImage:true for text-only scenes that need no API calls.",
68471
69656
  schema: sceneAddSchema,
68472
69657
  async execute(args, ctx) {
68473
69658
  const projectDir = args.projectDir ? resolve75(ctx.workingDirectory, args.projectDir) : ctx.workingDirectory;
@@ -68481,6 +69666,7 @@ var init_scene2 = __esm({
68481
69666
  kicker: args.kicker,
68482
69667
  projectDir,
68483
69668
  insertInto: args.insertInto,
69669
+ syncStoryboard: args.syncStoryboard,
68484
69670
  imageProvider: args.imageProvider,
68485
69671
  voice: args.voice,
68486
69672
  skipAudio: args.skipAudio,
@@ -68499,6 +69685,9 @@ var init_scene2 = __esm({
68499
69685
  ];
68500
69686
  if (result.audioPath) lines.push(` audio: ${result.audioPath}`);
68501
69687
  if (result.imagePath) lines.push(` image: ${result.imagePath}`);
69688
+ if (result.storyboardPath && result.storyboardSynced) {
69689
+ lines.push(` story: ${result.storyboardPath} (${result.storyboardAction})`);
69690
+ }
68502
69691
  return {
68503
69692
  success: true,
68504
69693
  data: {
@@ -68509,7 +69698,10 @@ var init_scene2 = __esm({
68509
69698
  scenePath: result.scenePath,
68510
69699
  rootPath: result.rootPath,
68511
69700
  audioPath: result.audioPath,
68512
- imagePath: result.imagePath
69701
+ imagePath: result.imagePath,
69702
+ storyboardPath: result.storyboardPath,
69703
+ storyboardSynced: result.storyboardSynced,
69704
+ storyboardAction: result.storyboardAction
68513
69705
  },
68514
69706
  humanLines: lines
68515
69707
  };
@@ -73627,7 +74819,7 @@ init_source();
73627
74819
  // package.json
73628
74820
  var package_default = {
73629
74821
  name: "@vibeframe/cli",
73630
- version: "0.113.0",
74822
+ version: "0.113.3",
73631
74823
  description: "VibeFrame CLI - agentic video workflow runtime",
73632
74824
  private: false,
73633
74825
  license: "MIT",
@@ -73692,7 +74884,7 @@ var package_default = {
73692
74884
  lint: "eslint --ext .ts,.tsx src/",
73693
74885
  test: "vitest run",
73694
74886
  "test:watch": "vitest",
73695
- pretest: "node build.js",
74887
+ pretest: "pnpm --dir ../core build && pnpm --dir ../ai-providers build && node build.js",
73696
74888
  prepublishOnly: "node build.js"
73697
74889
  },
73698
74890
  dependencies: {
@@ -82210,13 +83402,13 @@ function titleizeProjectName(name) {
82210
83402
  return name.split(/[-_\s]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ") || "Video";
82211
83403
  }
82212
83404
  function describeTheme(brief, product) {
82213
- const cleaned = brief.replace(/\b\d+[- ]?second\b/gi, "").replace(new RegExp(`\\bfor\\s+${escapeRegExp3(product)}\\b`, "i"), "").replace(/\blaunch video\b/gi, "launch story").replace(/\s+/g, " ").trim();
83405
+ const cleaned = brief.replace(/\b\d+[- ]?second\b/gi, "").replace(new RegExp(`\\bfor\\s+${escapeRegExp5(product)}\\b`, "i"), "").replace(/\blaunch video\b/gi, "launch story").replace(/\s+/g, " ").trim();
82214
83406
  return cleaned || "focused product story";
82215
83407
  }
82216
83408
  function isMountainSunriseBrief(brief) {
82217
83409
  return /\b(mountain|ridge|sunrise|fog|peak)\b/i.test(brief);
82218
83410
  }
82219
- function escapeRegExp3(value) {
83411
+ function escapeRegExp5(value) {
82220
83412
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
82221
83413
  }
82222
83414
 
@@ -82259,6 +83451,7 @@ function hostDefinitions(_projectDir = process.cwd()) {
82259
83451
  configKind: "codex-toml",
82260
83452
  notes: [
82261
83453
  "Codex reads AGENTS.md and can also load project-scoped .codex/config.toml after the project is trusted.",
83454
+ "Use Codex /goal as the outer loop for long-running VibeFrame projects; vibe supplies JSON reports, nextActions classified by cost/safety (prefer these), retryWith as a compatibility fallback, and repair commands.",
82262
83455
  "Keep provider/auth keys in VibeFrame config or environment, not in project-local Codex config."
82263
83456
  ]
82264
83457
  },
@@ -82271,6 +83464,7 @@ function hostDefinitions(_projectDir = process.cwd()) {
82271
83464
  configKind: "mcp-json",
82272
83465
  notes: [
82273
83466
  "Claude Code can drive vibe directly through shell plus AGENTS.md/CLAUDE.md.",
83467
+ "Use Claude Code /goal as the outer loop for long-running VibeFrame projects; vibe supplies JSON reports, nextActions classified by cost/safety (prefer these), retryWith as a compatibility fallback, and repair commands.",
82274
83468
  "The MCP entry is optional and gives Claude Code a typed tool surface."
82275
83469
  ]
82276
83470
  },
@@ -82345,7 +83539,15 @@ async function planHostSetup(host, projectDir, opts) {
82345
83539
  status: opts.write ? "would-merge" : "would-write",
82346
83540
  reason: opts.write ? "dry run" : "snippet only; pass --write to modify"
82347
83541
  });
82348
- return { host, snippet, command: host.id === "claude-code" ? claudeCodeAddCommand() : void 0, configPath, files, warnings, nextSteps };
83542
+ return {
83543
+ host,
83544
+ snippet,
83545
+ command: host.id === "claude-code" ? claudeCodeAddCommand() : void 0,
83546
+ configPath,
83547
+ files,
83548
+ warnings,
83549
+ nextSteps
83550
+ };
82349
83551
  }
82350
83552
  try {
82351
83553
  const action = host.configKind === "codex-toml" ? await mergeCodexToml(configPath, { force: opts.force === true }) : await mergeMcpJson(configPath, {
@@ -82357,7 +83559,15 @@ async function planHostSetup(host, projectDir, opts) {
82357
83559
  } catch (error) {
82358
83560
  warnings.push(error instanceof Error ? error.message : String(error));
82359
83561
  }
82360
- return { host, snippet, command: host.id === "claude-code" ? claudeCodeAddCommand() : void 0, configPath, files, warnings, nextSteps };
83562
+ return {
83563
+ host,
83564
+ snippet,
83565
+ command: host.id === "claude-code" ? claudeCodeAddCommand() : void 0,
83566
+ configPath,
83567
+ files,
83568
+ warnings,
83569
+ nextSteps
83570
+ };
82361
83571
  }
82362
83572
  async function inspectHost(host, projectDir) {
82363
83573
  const configPath = host.configPath(projectDir);
@@ -82401,7 +83611,9 @@ async function mergeMcpJson(configPath, opts) {
82401
83611
  try {
82402
83612
  root = raw.trim() ? JSON.parse(raw) : {};
82403
83613
  } catch (error) {
82404
- throw new Error(`Invalid JSON in ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
83614
+ throw new Error(
83615
+ `Invalid JSON in ${configPath}: ${error instanceof Error ? error.message : String(error)}`
83616
+ );
82405
83617
  }
82406
83618
  }
82407
83619
  const mcpServers = typeof root.mcpServers === "object" && root.mcpServers !== null && !Array.isArray(root.mcpServers) ? root.mcpServers : {};
@@ -82414,10 +83626,18 @@ async function mergeMcpJson(configPath, opts) {
82414
83626
  };
82415
83627
  await mkdir23(dirname19(configPath), { recursive: true });
82416
83628
  if (exists && opts.backup) {
82417
- await writeFile45(`${configPath}.bak-${timestamp()}`, await readFile39(configPath, "utf-8"), "utf-8");
83629
+ await writeFile45(
83630
+ `${configPath}.bak-${timestamp()}`,
83631
+ await readFile39(configPath, "utf-8"),
83632
+ "utf-8"
83633
+ );
82418
83634
  }
82419
83635
  await writeFile45(configPath, JSON.stringify(root, null, 2) + "\n", "utf-8");
82420
- return { path: configPath, status: "merged", reason: "anchored existing default vibeframe MCP server" };
83636
+ return {
83637
+ path: configPath,
83638
+ status: "merged",
83639
+ reason: "anchored existing default vibeframe MCP server"
83640
+ };
82421
83641
  }
82422
83642
  return {
82423
83643
  path: configPath,
@@ -82428,7 +83648,11 @@ async function mergeMcpJson(configPath, opts) {
82428
83648
  root.mcpServers = { ...mcpServers, vibeframe: opts.server };
82429
83649
  await mkdir23(dirname19(configPath), { recursive: true });
82430
83650
  if (exists && opts.backup) {
82431
- await writeFile45(`${configPath}.bak-${timestamp()}`, await readFile39(configPath, "utf-8"), "utf-8");
83651
+ await writeFile45(
83652
+ `${configPath}.bak-${timestamp()}`,
83653
+ await readFile39(configPath, "utf-8"),
83654
+ "utf-8"
83655
+ );
82432
83656
  }
82433
83657
  await writeFile45(configPath, JSON.stringify(root, null, 2) + "\n", "utf-8");
82434
83658
  return { path: configPath, status: exists ? "merged" : "wrote" };
@@ -82439,16 +83663,22 @@ async function mergeCodexToml(configPath, opts) {
82439
83663
  ${CODEX_MCP_BLOCK}${CODEX_MANAGED_END}`;
82440
83664
  if (!exists) {
82441
83665
  await mkdir23(dirname19(configPath), { recursive: true });
82442
- await writeFile45(configPath, `#:schema https://developers.openai.com/codex/config-schema.json
83666
+ await writeFile45(
83667
+ configPath,
83668
+ `#:schema https://developers.openai.com/codex/config-schema.json
82443
83669
 
82444
83670
  ${block}
82445
- `, "utf-8");
83671
+ `,
83672
+ "utf-8"
83673
+ );
82446
83674
  return { path: configPath, status: "wrote" };
82447
83675
  }
82448
83676
  const raw = await readFile39(configPath, "utf-8");
82449
83677
  if (raw.includes(CODEX_MANAGED_START)) {
82450
83678
  const updated = raw.replace(
82451
- new RegExp(`${escapeRegExp4(CODEX_MANAGED_START)}[\\s\\S]*?${escapeRegExp4(CODEX_MANAGED_END)}`),
83679
+ new RegExp(
83680
+ `${escapeRegExp6(CODEX_MANAGED_START)}[\\s\\S]*?${escapeRegExp6(CODEX_MANAGED_END)}`
83681
+ ),
82452
83682
  block
82453
83683
  );
82454
83684
  await writeFile45(configPath, ensureTrailingNewline(updated), "utf-8");
@@ -82482,7 +83712,8 @@ function hasVibeframeMcpServer(value) {
82482
83712
  function readVibeframeMcpServer(value) {
82483
83713
  if (!value || typeof value !== "object") return null;
82484
83714
  const root = value;
82485
- if (!root.mcpServers || typeof root.mcpServers !== "object" || Array.isArray(root.mcpServers)) return null;
83715
+ if (!root.mcpServers || typeof root.mcpServers !== "object" || Array.isArray(root.mcpServers))
83716
+ return null;
82486
83717
  const server = root.mcpServers.vibeframe;
82487
83718
  if (!server || typeof server !== "object" || Array.isArray(server)) return null;
82488
83719
  return server;
@@ -82493,7 +83724,9 @@ function isAnchoredClaudeDesktopServer(server) {
82493
83724
  return server.args[0] === "-lc" && typeof server.args[1] === "string" && server.args[1].includes("exec npx -y @vibeframe/mcp-server");
82494
83725
  }
82495
83726
  if (server.command === "cmd.exe" && Array.isArray(server.args)) {
82496
- return server.args.some((arg) => typeof arg === "string" && arg.includes("npx -y @vibeframe/mcp-server"));
83727
+ return server.args.some(
83728
+ (arg) => typeof arg === "string" && arg.includes("npx -y @vibeframe/mcp-server")
83729
+ );
82497
83730
  }
82498
83731
  return false;
82499
83732
  }
@@ -82510,13 +83743,15 @@ function hostNextSteps(host) {
82510
83743
  if (host.id === "codex") {
82511
83744
  return [
82512
83745
  "Trust the project in Codex so .codex/config.toml is loaded.",
82513
- "Run /mcp in Codex to confirm the vibeframe server is connected."
83746
+ "Run /mcp in Codex to confirm the vibeframe server is connected.",
83747
+ "Use /goal with the project AGENTS.md stop rules for long-running video builds."
82514
83748
  ];
82515
83749
  }
82516
83750
  if (host.id === "claude-code") {
82517
83751
  return [
82518
83752
  "Restart Claude Code or run /mcp to approve the project-scoped server.",
82519
- "Use AGENTS.md/CLAUDE.md for shell-driven vibe workflows."
83753
+ "Use AGENTS.md/CLAUDE.md for shell-driven vibe workflows.",
83754
+ "Use /goal with the project AGENTS.md stop rules for long-running video builds."
82520
83755
  ];
82521
83756
  }
82522
83757
  if (host.id === "claude-desktop") {
@@ -82530,7 +83765,11 @@ function claudeDesktopConfigPath() {
82530
83765
  return join21(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
82531
83766
  }
82532
83767
  if (process.platform === "win32") {
82533
- return join21(process.env.APPDATA ?? join21(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
83768
+ return join21(
83769
+ process.env.APPDATA ?? join21(home, "AppData", "Roaming"),
83770
+ "Claude",
83771
+ "claude_desktop_config.json"
83772
+ );
82534
83773
  }
82535
83774
  return join21(home, ".config", "Claude", "claude_desktop_config.json");
82536
83775
  }
@@ -82541,7 +83780,7 @@ function ensureTrailingNewline(value) {
82541
83780
  return value.endsWith("\n") ? value : `${value}
82542
83781
  `;
82543
83782
  }
82544
- function escapeRegExp4(value) {
83783
+ function escapeRegExp6(value) {
82545
83784
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
82546
83785
  }
82547
83786
  function shellQuote(value) {
@@ -82733,6 +83972,45 @@ Default setup is snippet-only; use \`--write\` when you want VibeFrame to merge
82733
83972
  the generated config. Keep provider keys in \`.vibeframe/config.yaml\`, user
82734
83973
  config, or env files \u2014 not in app host config.
82735
83974
 
83975
+ ## Native host goal loop
83976
+
83977
+ For long-running work, use Codex Goal mode, Claude Code \`/goal\`, Cursor, or
83978
+ another host-native goal feature as the outer loop. VibeFrame should not own a
83979
+ competing project-level goal runner. It provides \`--json\` commands, dry runs,
83980
+ budget caps, \`build-report.json\`, \`review-report.json\`, \`nextActions\`,
83981
+ \`safeToAutoRun\`, \`requiresConfirmation\`, \`fixOwner\`, deterministic repair,
83982
+ and render inspection for the host agent to decide what to do next.
83983
+
83984
+ Copy-paste Codex:
83985
+
83986
+ \`\`\`text
83987
+ /goal Build this VibeFrame project into renders/final.mp4. Use --json for every
83988
+ vibe command, run --dry-run before paid operations, use --max-cost 5 for builds
83989
+ unless the user gives another budget, read build-report.json and
83990
+ review-report.json before deciding the next action, prefer nextActions before
83991
+ guessing, run only safeToAutoRun:true actions automatically, and ask before
83992
+ requiresConfirmation:true actions. Treat retryWith as the compatibility
83993
+ fallback, fixOwner:"vibe" as CLI repair work, and fixOwner:"host-agent" as
83994
+ STORYBOARD.md, DESIGN.md, or composition edits.
83995
+ Stop only when renders/final.mp4 exists, target duration and aspect ratio are
83996
+ met, inspect render --cheap has no errors, any AI review score is >= 90 when AI review is requested, and every
83997
+ remaining host-agent issue is fixed, accepted with rationale, or reported as
83998
+ blocked.
83999
+ \`\`\`
84000
+
84001
+ Copy-paste Claude Code:
84002
+
84003
+ \`\`\`text
84004
+ /goal Finish this VibeFrame render using Claude Code goal mode as the outer
84005
+ loop. Use vibe context/schema when unsure, --json everywhere, dry-run before
84006
+ paid operations, budget cap via --max-cost 5, nextActions before guessing,
84007
+ build-report.json/review-report.json as loop state, and safeToAutoRun,
84008
+ requiresConfirmation, and fixOwner to decide between vibe scene repair and
84009
+ host-agent edits. Stop only after final MP4,
84010
+ target duration/aspect ratio, clean render inspection, any AI review score >= 90 when AI review is requested, and
84011
+ no unresolved unacknowledged host-agent issues.
84012
+ \`\`\`
84013
+
82736
84014
  ### Composition rules (scene HTML)
82737
84015
 
82738
84016
  \`vibe init\` installs local composition rules into your project. The
@@ -83821,7 +85099,7 @@ init_build_plan();
83821
85099
  init_storyboard_parse();
83822
85100
  init_output();
83823
85101
  import { resolve as resolve66, join as join26 } from "node:path";
83824
- import { readFileSync as readFileSync3, existsSync as existsSync50 } from "node:fs";
85102
+ import { readFileSync as readFileSync4, existsSync as existsSync50 } from "node:fs";
83825
85103
  var VALID_MODES2 = ["agent", "batch", "auto"];
83826
85104
  var VALID_STAGES2 = ["assets", "compose", "sync", "render", "all"];
83827
85105
  var VALID_IMAGE_PROVIDERS2 = ["openai", "gemini", "grok"];
@@ -84188,7 +85466,7 @@ function estimateBuildCost(params) {
84188
85466
  if (!existsSync50(storyboardPath2)) return null;
84189
85467
  let beats = 0;
84190
85468
  try {
84191
- const md = readFileSync3(storyboardPath2, "utf-8");
85469
+ const md = readFileSync4(storyboardPath2, "utf-8");
84192
85470
  beats = parseStoryboard(md).beats.length;
84193
85471
  } catch {
84194
85472
  return null;
@@ -84572,6 +85850,18 @@ function printProjectStatus(result) {
84572
85850
  console.log(` Build: ${result.build ? formatBuild(result.build) : source_default.dim("no build-report.json")}`);
84573
85851
  console.log(` Review: ${result.review ? formatReview(result.review) : source_default.dim("no review-report.json")}`);
84574
85852
  console.log(` Jobs: ${result.jobs.total} total, ${result.jobs.active} active, ${result.jobs.failed} failed`);
85853
+ if (result.nextActions.length > 0) {
85854
+ console.log();
85855
+ console.log(source_default.bold("Next actions"));
85856
+ for (const action of result.nextActions.slice(0, 5)) {
85857
+ console.log(formatNextAction(action));
85858
+ if (action.command) console.log(source_default.dim(` ${action.command}`));
85859
+ if (action.agentPrompt) console.log(source_default.dim(` ${action.agentPrompt}`));
85860
+ }
85861
+ if (result.nextActions.length > 5) {
85862
+ console.log(source_default.dim(` ... ${result.nextActions.length - 5} more`));
85863
+ }
85864
+ }
84575
85865
  if (result.jobs.latest.length > 0) {
84576
85866
  console.log();
84577
85867
  for (const job of result.jobs.latest.slice(0, 5)) {
@@ -84592,6 +85882,10 @@ function formatReview(review) {
84592
85882
  const status = review.status ?? "unknown";
84593
85883
  return `${formatStatus(status)} score ${review.score ?? "-"} (${review.issueCount} issue${review.issueCount === 1 ? "" : "s"})`;
84594
85884
  }
85885
+ function formatNextAction(action) {
85886
+ const badge = action.safeToAutoRun ? "auto" : action.requiresConfirmation ? "confirm" : action.kind;
85887
+ return ` ${source_default.dim(`[${badge}]`)} ${action.label}`;
85888
+ }
84595
85889
  function formatStatus(status) {
84596
85890
  if (status === "completed" || status === "pass") return source_default.green(status);
84597
85891
  if (status === "failed" || status === "fail") return source_default.red(status);
@@ -85598,6 +86892,8 @@ var contextCommand = new Command("context").description("Print CLI context/guide
85598
86892
  product: "vibeframe",
85599
86893
  sourceOfTruth: ["STORYBOARD.md", "DESIGN.md", "vibe.config.json"],
85600
86894
  preferredFlow: [
86895
+ "native Codex Goal mode or Claude Code /goal owns persistence, iteration, and stop decisions",
86896
+ "vibe context/schema for command discovery",
85601
86897
  "storyboard revise after init when the brief changes",
85602
86898
  "storyboard validate",
85603
86899
  "plan",
@@ -85612,6 +86908,19 @@ var contextCommand = new Command("context").description("Print CLI context/guide
85612
86908
  "inspect render --ai when needed",
85613
86909
  "status project when async jobs are involved"
85614
86910
  ],
86911
+ nativeGoalContract: {
86912
+ owner: "host-native goal mode (Codex /goal, Claude Code /goal, Cursor or other host equivalent)",
86913
+ vibeRole: "video-specific commands, JSON reports, cost gates, deterministic repair, render inspection, nextActions, and retryWith compatibility hints",
86914
+ canonicalLoop: "native host goal -> vibe context/schema -> plan dry-run -> build with budget -> status polling -> inspect project -> render -> inspect render -> repair/edit using nextActions/fixOwner -> repeat",
86915
+ stopRules: [
86916
+ "final MP4 exists at the requested output path",
86917
+ "duration and aspect ratio match the brief or explicitly accepted constraints",
86918
+ "inspect render --cheap reports no errors",
86919
+ "any AI review score meets the goal threshold, commonly >= 90, when AI review is requested",
86920
+ "all fixOwner:vibe issues have been repaired or are blocked with evidence",
86921
+ "all fixOwner:host-agent issues are fixed, intentionally accepted with a written reason, or reported as blocked"
86922
+ ]
86923
+ },
85615
86924
  mentalModel: {
85616
86925
  storyboard: "intent layer; edit or mutate beat cues here",
85617
86926
  scene: "generated artifact layer; lint/repair composition HTML here"
@@ -85645,7 +86954,8 @@ var contextCommand = new Command("context").description("Print CLI context/guide
85645
86954
  reviewReportContract: {
85646
86955
  kind: "review",
85647
86956
  modes: ["project", "render"],
85648
- fields: ["status", "score", "issues", "summary", "sourceReports", "retryWith"],
86957
+ fields: ["status", "score", "issues", "summary", "sourceReports", "nextActions", "retryWith"],
86958
+ recovery: "prefer nextActions (classified by costTier/safeToAutoRun/requiresConfirmation/fixOwner); retryWith is the compatibility fallback",
85649
86959
  issueFixOwner: {
85650
86960
  vibe: "deterministic CLI recovery",
85651
86961
  "host-agent": "storyboard/design/composition edits the host agent should make"
@@ -85653,7 +86963,7 @@ var contextCommand = new Command("context").description("Print CLI context/guide
85653
86963
  },
85654
86964
  machineStatusContract: {
85655
86965
  buildReport: "kind/status/currentStage/beatSummary/jobs/sceneRepair/stageReports/warnings/retryWith plus beat timing and nested per-beat asset metadata",
85656
- projectStatus: "kind/status/currentStage/beats/jobs.latest/build/review/warnings/retryWith",
86966
+ projectStatus: "kind/status/currentStage/beats/jobs.latest/build/review/warnings/nextActions/retryWith (nextActions is populated for every workflow state, not only when a review report exists)",
85657
86967
  reviewSummary: "review.mode/issueCount/errorCount/warningCount/infoCount/fixOwners/sourceReports/retryWith"
85658
86968
  },
85659
86969
  productSurfaceContract: {
@@ -85736,12 +87046,13 @@ render. Repair failures return \`code:"SCENE_REPAIR_FAILED"\` with
85736
87046
  render with \`currentStage:"assets"\` and \`code:"ASSET_REFERENCE_INVALID"\`,
85737
87047
  \`code:"MISSING_API_KEY"\`, or \`code:"ASSET_GENERATION_FAILED"\`.
85738
87048
  \`build-report.json\` includes \`sceneRepair\`, and \`status project\` carries
85739
- review issue counts, \`fixOwners\`, \`sourceReports\`, and
85740
- \`review.retryWith\`.
87049
+ review issue counts, \`fixOwners\`, \`sourceReports\`, top-level
87050
+ \`nextActions\` (populated for every workflow state), and \`retryWith\`.
87051
+ Prefer \`nextActions\` and treat \`retryWith\` as the compatibility fallback.
85741
87052
 
85742
87053
  \`review-report.json\` is written by \`inspect project\` and \`inspect render\`
85743
87054
  as \`kind:"review"\` with \`mode:"project"|"render"\`, \`summary\`,
85744
- \`sourceReports\`, \`retryWith\`, and issue-level
87055
+ \`sourceReports\`, \`nextActions\`, \`retryWith\`, and issue-level
85745
87056
  \`fixOwner:"vibe"|"host-agent"\`.
85746
87057
  \`inspect render --cheap\` checks duration drift, audio presence, black frames,
85747
87058
  long silence, and static-frame holds. Static or semantic beat issues are
@@ -85763,6 +87074,8 @@ Use 'vibe schema --list --surface public' for the small product surface.
85763
87074
  Use 'vibe schema <command> --json' to get parameter schemas.
85764
87075
  Use 'vibe doctor --json' to check configured API keys.
85765
87076
  Use '--dry-run --json' before any mutating/costly operation.
87077
+ Use native Codex Goal mode or Claude Code /goal as the outer loop; VibeFrame provides JSON reports, retryWith, fixOwner, cost gates, and render inspection for that host loop.
87078
+ Stop only when the final MP4 exists, target duration/aspect ratio are met, inspect render has no errors, any AI review score meets the goal threshold when AI review is requested, and every host-agent issue is fixed, accepted with rationale, or reported blocked.
85766
87079
 
85767
87080
  Cost tiers: Free (schema/context/doctor/detect/status/plan/storyboard validate/inspect project/render --cheap, deterministic edits) | Low (generate narration/sound-effect/music, audio transcribe, inspect media, optional AI review) | High (generate image/motion, edit image/reframe/grade/speed-ramp) | Very High (generate video, edit fill-gaps, remix highlights/auto-shorts, build with generated assets)
85768
87081