@vibeframe/cli 0.108.0 → 0.109.0

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.
@@ -85,6 +85,25 @@ export declare function isEpsilonOverlapFinding(finding: LintFinding): boolean;
85
85
  * findings pass through unchanged.
86
86
  */
87
87
  export declare function filterSubCompFalsePositives(findings: LintFinding[], isSubComposition: boolean): LintFinding[];
88
+ /**
89
+ * VibeFrame-side finding: inner phase clips inside a sub-composition.
90
+ *
91
+ * The producer's screenshot render mode does NOT toggle the visibility of
92
+ * `.clip` elements INSIDE a sub-composition by their `data-start` window —
93
+ * a scene split into clip phases (0-10s, 10-26s) renders BOTH phases
94
+ * simultaneously and text stacks on top of itself. Root index.html clips
95
+ * are unaffected (their windows work); this rule only applies to
96
+ * sub-composition internals. Phase changes must instead be driven by GSAP
97
+ * (animate the old phase out with autoAlpha, the new one in) inside
98
+ * full-window clips.
99
+ */
100
+ export declare function findInternalPhaseClipFindings(html: string): LintFinding[];
101
+ /**
102
+ * Append VibeFrame-side findings for sub-composition HTML. Call at every
103
+ * lint site (batch composer, scene_submit, scene repair, project lint) so
104
+ * all authoring paths share the exact same contract.
105
+ */
106
+ export declare function withVibeframeSubCompFindings(findings: LintFinding[], html: string, isSubComposition: boolean): LintFinding[];
88
107
  /**
89
108
  * Apply mechanical `--fix` rewrites for known-safe finding codes. Returns the
90
109
  * (possibly unchanged) HTML and the list of codes that were fixed at least
@@ -12,6 +12,8 @@ export interface StoryboardValidationResult {
12
12
  beats: Beat[];
13
13
  issues: StoryboardValidationIssue[];
14
14
  }
15
+ /** Beats beyond this render as static, overstuffed scenes. */
16
+ export declare const MAX_RECOMMENDED_BEAT_SEC = 15;
15
17
  export declare function validateStoryboardMarkdown(markdown: string): StoryboardValidationResult;
16
18
  export declare function getStoryboardBeat(markdown: string, beatId: string): Beat | null;
17
19
  export declare function setStoryboardCue(markdown: string, opts: {
package/dist/index.js CHANGED
@@ -11604,13 +11604,32 @@ async function loadKokoroFactory() {
11604
11604
  const mod = await import("kokoro-js");
11605
11605
  return mod.KokoroTTS;
11606
11606
  } catch (err) {
11607
+ const fallback2 = await loadKokoroFromWorkspace();
11608
+ if (fallback2)
11609
+ return fallback2;
11607
11610
  throw mapKokoroImportError(err);
11608
11611
  }
11609
11612
  }
11613
+ async function loadKokoroFromWorkspace(baseDir = process.cwd()) {
11614
+ try {
11615
+ const { readFile: readFile55 } = await import("node:fs/promises");
11616
+ const { join: join29, resolve: resolve85 } = await import("node:path");
11617
+ const { pathToFileURL } = await import("node:url");
11618
+ const pkgDir = resolve85(baseDir, "node_modules", "kokoro-js");
11619
+ const pkg = JSON.parse(await readFile55(join29(pkgDir, "package.json"), "utf-8"));
11620
+ const entry = pkg.exports?.node?.import ?? pkg.exports?.default ?? pkg.module ?? pkg.main;
11621
+ if (!entry)
11622
+ return null;
11623
+ const mod = await import(pathToFileURL(join29(pkgDir, entry)).href);
11624
+ return mod.KokoroTTS ?? null;
11625
+ } catch {
11626
+ return null;
11627
+ }
11628
+ }
11610
11629
  function mapKokoroImportError(err) {
11611
11630
  const code = err instanceof Error && "code" in err ? err.code : void 0;
11612
11631
  if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") {
11613
- return new Error("Local Kokoro TTS is unavailable: optional dependency kokoro-js is not installed (its native dependencies may have failed to build on this platform). Install it with `npm i kokoro-js`, or set ELEVENLABS_API_KEY to use the elevenlabs TTS provider instead.");
11632
+ return new Error("Local Kokoro TTS is unavailable: optional dependency kokoro-js is not installed (its native dependencies may have failed to build on this platform). Install it with `npm i kokoro-js` \u2014 run it inside your workspace folder when using the Claude Desktop extension \u2014 or set ELEVENLABS_API_KEY to use the elevenlabs TTS provider instead.");
11614
11633
  }
11615
11634
  return err instanceof Error ? err : new Error(String(err));
11616
11635
  }
@@ -26662,6 +26681,8 @@ imageProvider: openai
26662
26681
 
26663
26682
  Edit these beats before running \`vibe build\`. Each beat starts with
26664
26683
  YAML cues that drive narration, backdrop generation, and timing.
26684
+ Pacing: keep beats 6-15 seconds (split longer ones); a 90s video should
26685
+ have 6-8 beats, not 3 long ones.
26665
26686
 
26666
26687
  ## Beat hook \u2014 Hook
26667
26688
 
@@ -27778,6 +27799,29 @@ function filterSubCompFalsePositives(findings, isSubComposition) {
27778
27799
  if (!isSubComposition) return noEpsilon;
27779
27800
  return noEpsilon.filter((f) => !SUB_COMP_FALSE_POSITIVES.has(f.code));
27780
27801
  }
27802
+ function findInternalPhaseClipFindings(html) {
27803
+ const findings = [];
27804
+ for (const tagMatch of html.matchAll(/<[a-z][a-z0-9-]*\s[^>]*>/gi)) {
27805
+ const tag = tagMatch[0];
27806
+ if (!/class="[^"]*\bclip\b[^"]*"/i.test(tag)) continue;
27807
+ const startAttr = tag.match(/data-start="([^"]+)"/i);
27808
+ if (!startAttr) continue;
27809
+ const start = Number(startAttr[1]);
27810
+ if (!Number.isFinite(start) || start === 0) continue;
27811
+ findings.push({
27812
+ code: "internal_phase_clip_unsupported",
27813
+ severity: "error",
27814
+ message: `Inner .clip has data-start="${startAttr[1]}". The renderer does not toggle internal clip visibility inside sub-compositions, so phased clips render ALL phases at once (overlapping text).`,
27815
+ fixHint: 'Give every inner .clip data-start="0" spanning the full beat, and drive phase changes with GSAP autoAlpha instead (animate the previous phase out, the next one in).',
27816
+ snippet: tag.slice(0, 120)
27817
+ });
27818
+ }
27819
+ return findings;
27820
+ }
27821
+ function withVibeframeSubCompFindings(findings, html, isSubComposition) {
27822
+ if (!isSubComposition) return findings;
27823
+ return [...findings, ...findInternalPhaseClipFindings(html)];
27824
+ }
27781
27825
  function applyMechanicalFixes(html, findings) {
27782
27826
  const fixedCodes = /* @__PURE__ */ new Set();
27783
27827
  let updated = html;
@@ -27823,7 +27867,11 @@ async function runProjectLint(opts) {
27823
27867
  source: "projectDir"
27824
27868
  };
27825
27869
  const raw = runHyperframeLint(prepared);
27826
- const findings = filterSubCompFalsePositives(raw.findings, isSub);
27870
+ const findings = withVibeframeSubCompFindings(
27871
+ filterSubCompFalsePositives(raw.findings, isSub),
27872
+ html,
27873
+ isSub
27874
+ );
27827
27875
  if (opts.fix && findings.length > 0) {
27828
27876
  const { html: nextHtml, fixedCodes } = applyMechanicalFixes(html, findings);
27829
27877
  if (fixedCodes.length > 0) {
@@ -27833,7 +27881,11 @@ async function runProjectLint(opts) {
27833
27881
  files.push({
27834
27882
  file: rel,
27835
27883
  isSubComposition: isSub,
27836
- findings: filterSubCompFalsePositives(reLinted.findings, isSub)
27884
+ findings: withVibeframeSubCompFindings(
27885
+ filterSubCompFalsePositives(reLinted.findings, isSub),
27886
+ nextHtml,
27887
+ isSub
27888
+ )
27837
27889
  });
27838
27890
  continue;
27839
27891
  }
@@ -29713,8 +29765,14 @@ ${finalDurationBullet}
29713
29765
  Animate the backdrop/media plane instead; let text enter briefly, then hold
29714
29766
  still at its final CSS position. Continuous transforms on text ancestors can
29715
29767
  create subpixel shimmer in screenshot-captured renders.
29716
- - Timed children inside the composition have \`class="clip"\` plus
29717
- \`data-start\`, \`data-duration\`, \`data-track-index\`.
29768
+ - Inner \`.clip\` elements carry \`data-start\`, \`data-duration\`,
29769
+ \`data-track-index\` \u2014 but **every inner clip MUST be full-window:
29770
+ \`data-start="0"\` spanning the whole beat.** The renderer does NOT toggle
29771
+ internal clip visibility inside sub-compositions, so "phase clips" with a
29772
+ non-zero \`data-start\` render ALL phases at once and text stacks on top of
29773
+ itself. For multi-phase beats, drive phase changes with GSAP \`autoAlpha\`
29774
+ inside full-window clips: animate phase A out (\`autoAlpha: 0\`), then
29775
+ phase B in, on the same timeline.
29718
29776
  - If \`assets/backdrop-${ctx.beat.id}.png\` exists, use that local file as the
29719
29777
  full-frame visual backdrop. The exact path string is
29720
29778
  \`assets/backdrop-${ctx.beat.id}.png\`; do NOT prefix it with \`../\` or \`./\`
@@ -29728,7 +29786,9 @@ ${finalDurationBullet}
29728
29786
  narration.
29729
29787
  - Keep a meaningful visual progression across the full beat duration: line
29730
29788
  tracing, gentle camera drift, staged labels, value cards, or parallax. Avoid
29731
- a completed static frame holding silently for most of the beat.
29789
+ a completed static frame holding silently for most of the beat. For longer
29790
+ beats, progress through content phases with GSAP autoAlpha (old phase out,
29791
+ new phase in) \u2014 never with inner clip windows.
29732
29792
  - Do not import external font or image URLs. Use the project DESIGN.md font
29733
29793
  choice when explicit; otherwise use \`Inter\`, which is available in the
29734
29794
  deterministic render font map.
@@ -29903,7 +29963,11 @@ function lintBeatHtml(html, beatId) {
29903
29963
  source: "projectDir"
29904
29964
  };
29905
29965
  const raw = runHyperframeLint2(prepared);
29906
- const findings = filterSubCompFalsePositives(raw.findings, true);
29966
+ const findings = withVibeframeSubCompFindings(
29967
+ filterSubCompFalsePositives(raw.findings, true),
29968
+ html,
29969
+ true
29970
+ );
29907
29971
  return {
29908
29972
  errorCount: findings.filter((f) => f.severity === "error").length,
29909
29973
  warningCount: findings.filter((f) => f.severity === "warning").length,
@@ -30338,7 +30402,8 @@ function buildInstructions(args) {
30338
30402
  lines.push(`2. Read \`DESIGN.md\` for project-specific palette, typography, motion signature.`);
30339
30403
  lines.push(`3. For each beat in the \`beats\` array below, author HTML at \`outputPath\` matching the \`userPrompt\`. The beat \`body\` carries the narrative + visual + animation intent; \`cues\` carries machine-readable per-beat overrides (narration, duration, backdrop, voice).`);
30340
30404
  lines.push(`3b. Use each beat's \`finalDurationSec\` (narration-synced) for \`data-duration\` and timeline anchors when present \u2014 NOT the storyboard \`duration\`, which is only the minimum. Scenes composed at the storyboard duration end early and render black tails.`);
30341
- lines.push(`3c. If your environment cannot write files (e.g. Claude Desktop / MCP-only hosts), author each beat's HTML and submit it with the \`scene_submit\` tool (beat id + html). It validates with the same Hyperframes lint and writes the file for you; on lint errors it returns the findings without writing \u2014 fix and resubmit.`);
30405
+ lines.push(`3c. Never give inner \`.clip\` elements a non-zero \`data-start\` \u2014 the renderer does not toggle internal clip visibility inside sub-compositions, so phased clips render all phases at once (overlapping text). Use full-window clips and GSAP autoAlpha phase transitions instead. Also keep beats 6-15s; split anything longer in the storyboard first.`);
30406
+ lines.push(`3d. If your environment cannot write files (e.g. Claude Desktop / MCP-only hosts), author each beat's HTML and submit it with the \`scene_submit\` tool (beat id + html). It validates with the same Hyperframes lint and writes the file for you; on lint errors it returns the findings without writing \u2014 fix and resubmit.`);
30342
30407
  if (args.beatCount > 1) {
30343
30408
  lines.push(`4. After authoring all ${args.beatCount} beat(s), run \`vibe scene lint --fix\` to validate. Fix any remaining errors by editing the HTML directly.`);
30344
30409
  } else if (args.filtered) {
@@ -30780,7 +30845,11 @@ function lintHtml(html, rel, isSub) {
30780
30845
  source: "projectDir"
30781
30846
  };
30782
30847
  const raw = runHyperframeLint3(prepared);
30783
- return filterSubCompFalsePositives(raw.findings, isSub);
30848
+ return withVibeframeSubCompFindings(
30849
+ filterSubCompFalsePositives(raw.findings, isSub),
30850
+ html,
30851
+ isSub
30852
+ );
30784
30853
  }
30785
30854
  function lintFilesToIssues(files) {
30786
30855
  const issues = [];
@@ -44130,6 +44199,16 @@ function validateStoryboardMarkdown(markdown) {
44130
44199
  }
44131
44200
  }
44132
44201
  }
44202
+ for (const beat of parsed.beats) {
44203
+ if (beat.duration !== void 0 && beat.duration > MAX_RECOMMENDED_BEAT_SEC) {
44204
+ issues.push({
44205
+ severity: "warning",
44206
+ code: "BEAT_DURATION_TOO_LONG",
44207
+ beatId: beat.id,
44208
+ 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).`
44209
+ });
44210
+ }
44211
+ }
44133
44212
  return {
44134
44213
  ok: !issues.some((i) => i.severity === "error"),
44135
44214
  beats: parsed.beats,
@@ -44228,7 +44307,7 @@ function readLeadingCueBlock(body) {
44228
44307
  return { full: match[0], error: error instanceof Error ? error.message : String(error) };
44229
44308
  }
44230
44309
  }
44231
- var import_yaml5, STORYBOARD_CUE_KEYS, HEADING_RE2, LEADING_CUE_RE, ALLOWED_CUE_KEYS, STRING_CUE_KEYS;
44310
+ var import_yaml5, STORYBOARD_CUE_KEYS, HEADING_RE2, LEADING_CUE_RE, ALLOWED_CUE_KEYS, STRING_CUE_KEYS, MAX_RECOMMENDED_BEAT_SEC;
44232
44311
  var init_storyboard_edit = __esm({
44233
44312
  "src/commands/_shared/storyboard-edit.ts"() {
44234
44313
  "use strict";
@@ -44248,6 +44327,7 @@ var init_storyboard_edit = __esm({
44248
44327
  LEADING_CUE_RE = /^(\s*)```ya?ml\s*\n([\s\S]*?)\n```\s*(?:\n|$)/;
44249
44328
  ALLOWED_CUE_KEYS = new Set(STORYBOARD_CUE_KEYS);
44250
44329
  STRING_CUE_KEYS = /* @__PURE__ */ new Set(["narration", "backdrop", "video", "motion", "voice", "music", "asset"]);
44330
+ MAX_RECOMMENDED_BEAT_SEC = 15;
44251
44331
  }
44252
44332
  });
44253
44333
 
@@ -59092,7 +59172,8 @@ function revisionMessages(opts) {
59092
59172
  "The JSON object must have: storyboardMd string, summary string, changedBeats string[], warnings string[].",
59093
59173
  "Preserve existing frontmatter, beat ids, cue YAML keys, and useful prose unless the user asks to change structure.",
59094
59174
  "Keep cue YAML valid. Allowed cue keys are duration, narration, backdrop, video, motion, voice, music, asset.",
59095
- "If a target duration is supplied, every beat must have a positive duration cue and durations must sum to that target."
59175
+ "If a target duration is supplied, every beat must have a positive duration cue and durations must sum to that target.",
59176
+ "Aim for 6-15 seconds per beat and never exceed ~15s \u2014 long beats render as static, overstuffed scenes. A 90-second video should have 6-8 beats."
59096
59177
  ].join("\n");
59097
59178
  const repairBlock = opts.invalidStoryboard ? [
59098
59179
  "The previous revision failed validation. Repair it.",
@@ -72828,7 +72909,7 @@ init_source();
72828
72909
  // package.json
72829
72910
  var package_default = {
72830
72911
  name: "@vibeframe/cli",
72831
- version: "0.108.0",
72912
+ version: "0.109.0",
72832
72913
  description: "VibeFrame CLI - agentic video workflow runtime",
72833
72914
  private: false,
72834
72915
  license: "MIT",
@@ -81287,6 +81368,9 @@ providers:
81287
81368
 
81288
81369
  # ${product} - Storyboard
81289
81370
 
81371
+ Pacing: keep beats 6-15 seconds. Longer beats render as static, overstuffed
81372
+ scenes \u2014 split them instead.
81373
+
81290
81374
  Brief: ${brief}
81291
81375
 
81292
81376
  ${beats.map(renderBeatDraft).join("\n\n")}
@@ -4729,13 +4729,32 @@ async function loadKokoroFactory() {
4729
4729
  const mod = await import("kokoro-js");
4730
4730
  return mod.KokoroTTS;
4731
4731
  } catch (err) {
4732
+ const fallback2 = await loadKokoroFromWorkspace();
4733
+ if (fallback2)
4734
+ return fallback2;
4732
4735
  throw mapKokoroImportError(err);
4733
4736
  }
4734
4737
  }
4738
+ async function loadKokoroFromWorkspace(baseDir = process.cwd()) {
4739
+ try {
4740
+ const { readFile: readFile44 } = await import("node:fs/promises");
4741
+ const { join: join26, resolve: resolve65 } = await import("node:path");
4742
+ const { pathToFileURL } = await import("node:url");
4743
+ const pkgDir = resolve65(baseDir, "node_modules", "kokoro-js");
4744
+ const pkg = JSON.parse(await readFile44(join26(pkgDir, "package.json"), "utf-8"));
4745
+ const entry = pkg.exports?.node?.import ?? pkg.exports?.default ?? pkg.module ?? pkg.main;
4746
+ if (!entry)
4747
+ return null;
4748
+ const mod = await import(pathToFileURL(join26(pkgDir, entry)).href);
4749
+ return mod.KokoroTTS ?? null;
4750
+ } catch {
4751
+ return null;
4752
+ }
4753
+ }
4735
4754
  function mapKokoroImportError(err) {
4736
4755
  const code = err instanceof Error && "code" in err ? err.code : void 0;
4737
4756
  if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") {
4738
- return new Error("Local Kokoro TTS is unavailable: optional dependency kokoro-js is not installed (its native dependencies may have failed to build on this platform). Install it with `npm i kokoro-js`, or set ELEVENLABS_API_KEY to use the elevenlabs TTS provider instead.");
4757
+ return new Error("Local Kokoro TTS is unavailable: optional dependency kokoro-js is not installed (its native dependencies may have failed to build on this platform). Install it with `npm i kokoro-js` \u2014 run it inside your workspace folder when using the Claude Desktop extension \u2014 or set ELEVENLABS_API_KEY to use the elevenlabs TTS provider instead.");
4739
4758
  }
4740
4759
  return err instanceof Error ? err : new Error(String(err));
4741
4760
  }
@@ -25266,6 +25285,8 @@ imageProvider: openai
25266
25285
 
25267
25286
  Edit these beats before running \`vibe build\`. Each beat starts with
25268
25287
  YAML cues that drive narration, backdrop generation, and timing.
25288
+ Pacing: keep beats 6-15 seconds (split longer ones); a 90s video should
25289
+ have 6-8 beats, not 3 long ones.
25269
25290
 
25270
25291
  ## Beat hook \u2014 Hook
25271
25292
 
@@ -28829,6 +28850,29 @@ function filterSubCompFalsePositives(findings, isSubComposition) {
28829
28850
  if (!isSubComposition) return noEpsilon;
28830
28851
  return noEpsilon.filter((f) => !SUB_COMP_FALSE_POSITIVES.has(f.code));
28831
28852
  }
28853
+ function findInternalPhaseClipFindings(html) {
28854
+ const findings = [];
28855
+ for (const tagMatch of html.matchAll(/<[a-z][a-z0-9-]*\s[^>]*>/gi)) {
28856
+ const tag = tagMatch[0];
28857
+ if (!/class="[^"]*\bclip\b[^"]*"/i.test(tag)) continue;
28858
+ const startAttr = tag.match(/data-start="([^"]+)"/i);
28859
+ if (!startAttr) continue;
28860
+ const start = Number(startAttr[1]);
28861
+ if (!Number.isFinite(start) || start === 0) continue;
28862
+ findings.push({
28863
+ code: "internal_phase_clip_unsupported",
28864
+ severity: "error",
28865
+ message: `Inner .clip has data-start="${startAttr[1]}". The renderer does not toggle internal clip visibility inside sub-compositions, so phased clips render ALL phases at once (overlapping text).`,
28866
+ fixHint: 'Give every inner .clip data-start="0" spanning the full beat, and drive phase changes with GSAP autoAlpha instead (animate the previous phase out, the next one in).',
28867
+ snippet: tag.slice(0, 120)
28868
+ });
28869
+ }
28870
+ return findings;
28871
+ }
28872
+ function withVibeframeSubCompFindings(findings, html, isSubComposition) {
28873
+ if (!isSubComposition) return findings;
28874
+ return [...findings, ...findInternalPhaseClipFindings(html)];
28875
+ }
28832
28876
  function applyMechanicalFixes(html, findings) {
28833
28877
  const fixedCodes = /* @__PURE__ */ new Set();
28834
28878
  let updated = html;
@@ -28874,7 +28918,11 @@ async function runProjectLint(opts) {
28874
28918
  source: "projectDir"
28875
28919
  };
28876
28920
  const raw = runHyperframeLint(prepared);
28877
- const findings = filterSubCompFalsePositives(raw.findings, isSub);
28921
+ const findings = withVibeframeSubCompFindings(
28922
+ filterSubCompFalsePositives(raw.findings, isSub),
28923
+ html,
28924
+ isSub
28925
+ );
28878
28926
  if (opts.fix && findings.length > 0) {
28879
28927
  const { html: nextHtml, fixedCodes } = applyMechanicalFixes(html, findings);
28880
28928
  if (fixedCodes.length > 0) {
@@ -28884,7 +28932,11 @@ async function runProjectLint(opts) {
28884
28932
  files.push({
28885
28933
  file: rel,
28886
28934
  isSubComposition: isSub,
28887
- findings: filterSubCompFalsePositives(reLinted.findings, isSub)
28935
+ findings: withVibeframeSubCompFindings(
28936
+ filterSubCompFalsePositives(reLinted.findings, isSub),
28937
+ nextHtml,
28938
+ isSub
28939
+ )
28888
28940
  });
28889
28941
  continue;
28890
28942
  }
@@ -30419,8 +30471,14 @@ ${finalDurationBullet}
30419
30471
  Animate the backdrop/media plane instead; let text enter briefly, then hold
30420
30472
  still at its final CSS position. Continuous transforms on text ancestors can
30421
30473
  create subpixel shimmer in screenshot-captured renders.
30422
- - Timed children inside the composition have \`class="clip"\` plus
30423
- \`data-start\`, \`data-duration\`, \`data-track-index\`.
30474
+ - Inner \`.clip\` elements carry \`data-start\`, \`data-duration\`,
30475
+ \`data-track-index\` \u2014 but **every inner clip MUST be full-window:
30476
+ \`data-start="0"\` spanning the whole beat.** The renderer does NOT toggle
30477
+ internal clip visibility inside sub-compositions, so "phase clips" with a
30478
+ non-zero \`data-start\` render ALL phases at once and text stacks on top of
30479
+ itself. For multi-phase beats, drive phase changes with GSAP \`autoAlpha\`
30480
+ inside full-window clips: animate phase A out (\`autoAlpha: 0\`), then
30481
+ phase B in, on the same timeline.
30424
30482
  - If \`assets/backdrop-${ctx.beat.id}.png\` exists, use that local file as the
30425
30483
  full-frame visual backdrop. The exact path string is
30426
30484
  \`assets/backdrop-${ctx.beat.id}.png\`; do NOT prefix it with \`../\` or \`./\`
@@ -30434,7 +30492,9 @@ ${finalDurationBullet}
30434
30492
  narration.
30435
30493
  - Keep a meaningful visual progression across the full beat duration: line
30436
30494
  tracing, gentle camera drift, staged labels, value cards, or parallax. Avoid
30437
- a completed static frame holding silently for most of the beat.
30495
+ a completed static frame holding silently for most of the beat. For longer
30496
+ beats, progress through content phases with GSAP autoAlpha (old phase out,
30497
+ new phase in) \u2014 never with inner clip windows.
30438
30498
  - Do not import external font or image URLs. Use the project DESIGN.md font
30439
30499
  choice when explicit; otherwise use \`Inter\`, which is available in the
30440
30500
  deterministic render font map.
@@ -30609,7 +30669,11 @@ function lintBeatHtml(html, beatId) {
30609
30669
  source: "projectDir"
30610
30670
  };
30611
30671
  const raw = runHyperframeLint2(prepared);
30612
- const findings = filterSubCompFalsePositives(raw.findings, true);
30672
+ const findings = withVibeframeSubCompFindings(
30673
+ filterSubCompFalsePositives(raw.findings, true),
30674
+ html,
30675
+ true
30676
+ );
30613
30677
  return {
30614
30678
  errorCount: findings.filter((f) => f.severity === "error").length,
30615
30679
  warningCount: findings.filter((f) => f.severity === "warning").length,
@@ -31044,7 +31108,8 @@ function buildInstructions(args) {
31044
31108
  lines.push(`2. Read \`DESIGN.md\` for project-specific palette, typography, motion signature.`);
31045
31109
  lines.push(`3. For each beat in the \`beats\` array below, author HTML at \`outputPath\` matching the \`userPrompt\`. The beat \`body\` carries the narrative + visual + animation intent; \`cues\` carries machine-readable per-beat overrides (narration, duration, backdrop, voice).`);
31046
31110
  lines.push(`3b. Use each beat's \`finalDurationSec\` (narration-synced) for \`data-duration\` and timeline anchors when present \u2014 NOT the storyboard \`duration\`, which is only the minimum. Scenes composed at the storyboard duration end early and render black tails.`);
31047
- lines.push(`3c. If your environment cannot write files (e.g. Claude Desktop / MCP-only hosts), author each beat's HTML and submit it with the \`scene_submit\` tool (beat id + html). It validates with the same Hyperframes lint and writes the file for you; on lint errors it returns the findings without writing \u2014 fix and resubmit.`);
31111
+ lines.push(`3c. Never give inner \`.clip\` elements a non-zero \`data-start\` \u2014 the renderer does not toggle internal clip visibility inside sub-compositions, so phased clips render all phases at once (overlapping text). Use full-window clips and GSAP autoAlpha phase transitions instead. Also keep beats 6-15s; split anything longer in the storyboard first.`);
31112
+ lines.push(`3d. If your environment cannot write files (e.g. Claude Desktop / MCP-only hosts), author each beat's HTML and submit it with the \`scene_submit\` tool (beat id + html). It validates with the same Hyperframes lint and writes the file for you; on lint errors it returns the findings without writing \u2014 fix and resubmit.`);
31048
31113
  if (args.beatCount > 1) {
31049
31114
  lines.push(`4. After authoring all ${args.beatCount} beat(s), run \`vibe scene lint --fix\` to validate. Fix any remaining errors by editing the HTML directly.`);
31050
31115
  } else if (args.filtered) {
@@ -31367,7 +31432,11 @@ function lintHtml(html, rel, isSub) {
31367
31432
  source: "projectDir"
31368
31433
  };
31369
31434
  const raw = runHyperframeLint3(prepared);
31370
- return filterSubCompFalsePositives(raw.findings, isSub);
31435
+ return withVibeframeSubCompFindings(
31436
+ filterSubCompFalsePositives(raw.findings, isSub),
31437
+ html,
31438
+ isSub
31439
+ );
31371
31440
  }
31372
31441
  function lintFilesToIssues(files) {
31373
31442
  const issues = [];
@@ -32440,6 +32509,16 @@ function validateStoryboardMarkdown(markdown) {
32440
32509
  }
32441
32510
  }
32442
32511
  }
32512
+ for (const beat of parsed.beats) {
32513
+ if (beat.duration !== void 0 && beat.duration > MAX_RECOMMENDED_BEAT_SEC) {
32514
+ issues.push({
32515
+ severity: "warning",
32516
+ code: "BEAT_DURATION_TOO_LONG",
32517
+ beatId: beat.id,
32518
+ 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).`
32519
+ });
32520
+ }
32521
+ }
32443
32522
  return {
32444
32523
  ok: !issues.some((i) => i.severity === "error"),
32445
32524
  beats: parsed.beats,
@@ -32538,7 +32617,7 @@ function readLeadingCueBlock(body) {
32538
32617
  return { full: match[0], error: error instanceof Error ? error.message : String(error) };
32539
32618
  }
32540
32619
  }
32541
- var import_yaml5, STORYBOARD_CUE_KEYS, HEADING_RE2, LEADING_CUE_RE, ALLOWED_CUE_KEYS, STRING_CUE_KEYS;
32620
+ var import_yaml5, STORYBOARD_CUE_KEYS, HEADING_RE2, LEADING_CUE_RE, ALLOWED_CUE_KEYS, STRING_CUE_KEYS, MAX_RECOMMENDED_BEAT_SEC;
32542
32621
  var init_storyboard_edit = __esm({
32543
32622
  "src/commands/_shared/storyboard-edit.ts"() {
32544
32623
  "use strict";
@@ -32558,6 +32637,7 @@ var init_storyboard_edit = __esm({
32558
32637
  LEADING_CUE_RE = /^(\s*)```ya?ml\s*\n([\s\S]*?)\n```\s*(?:\n|$)/;
32559
32638
  ALLOWED_CUE_KEYS = new Set(STORYBOARD_CUE_KEYS);
32560
32639
  STRING_CUE_KEYS = /* @__PURE__ */ new Set(["narration", "backdrop", "video", "motion", "voice", "music", "asset"]);
32640
+ MAX_RECOMMENDED_BEAT_SEC = 15;
32561
32641
  }
32562
32642
  });
32563
32643
 
@@ -56751,7 +56831,8 @@ function revisionMessages(opts) {
56751
56831
  "The JSON object must have: storyboardMd string, summary string, changedBeats string[], warnings string[].",
56752
56832
  "Preserve existing frontmatter, beat ids, cue YAML keys, and useful prose unless the user asks to change structure.",
56753
56833
  "Keep cue YAML valid. Allowed cue keys are duration, narration, backdrop, video, motion, voice, music, asset.",
56754
- "If a target duration is supplied, every beat must have a positive duration cue and durations must sum to that target."
56834
+ "If a target duration is supplied, every beat must have a positive duration cue and durations must sum to that target.",
56835
+ "Aim for 6-15 seconds per beat and never exceed ~15s \u2014 long beats render as static, overstuffed scenes. A 90-second video should have 6-8 beats."
56755
56836
  ].join("\n");
56756
56837
  const repairBlock = opts.invalidStoryboard ? [
56757
56838
  "The previous revision failed validation. Repair it.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibeframe/cli",
3
- "version": "0.108.0",
3
+ "version": "0.109.0",
4
4
  "description": "VibeFrame CLI - agentic video workflow runtime",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -83,8 +83,8 @@
83
83
  "tsx": "^4.21.0",
84
84
  "typescript": "^5.3.3",
85
85
  "vitest": "^1.2.2",
86
- "@vibeframe/ai-providers": "0.108.0",
87
- "@vibeframe/core": "0.108.0"
86
+ "@vibeframe/core": "0.109.0",
87
+ "@vibeframe/ai-providers": "0.109.0"
88
88
  },
89
89
  "engines": {
90
90
  "node": ">=20"