@lalalic/markcut 2.6.0 → 2.7.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.
package/AGENTS.md CHANGED
@@ -214,20 +214,19 @@ node src/render/cli.mjs templates
214
214
 
215
215
  1. Basic Rendering — empty composition, dimension verification
216
216
  2. Image + Subtitle — image rendering, inline/VTT/karaoke subtitles
217
- 3. Built-in Components — AnimatedHeadline, StatCounter, GradientBackground
218
- 4. Effects — fadeIn, bounceIn, custom keyframes
219
- 5. Map Rendering — canvas route visualization
220
- 6. Audio Rendering — audio track, non-silent verification
221
- 7. Include — nested composition via include
222
- 8. Full Feature Combination — multiple features
223
- 9. Scene Node — scene-based rendering
224
- 10. Audio STT — speech-to-text verification via whisper
225
- 11. Multiple Aspect Ratios — 16x9, 9x16, 1x1
226
- 12. Cross-Stream Types — parallel rendering, effect-wrapped children
227
- 13. Frame-Accurate — timeline consistency, frame count
228
- 14. Description Field — schema validation, render preservation
229
- 15. Scene as Folder Alias — scene/folder interchangeability
230
- 16. **Video startFrom/endAt Trimming** — video trimming + photo in series
217
+ 3. Effects — fadeIn, bounceIn, custom keyframes
218
+ 4. Map Rendering — canvas route visualization
219
+ 5. Audio Rendering — audio track, non-silent verification
220
+ 6. Include — nested composition via include
221
+ 7. Full Feature Combination — multiple features
222
+ 8. Scene Node — scene-based rendering
223
+ 9. Audio STT — speech-to-text verification via whisper
224
+ 10. Multiple Aspect Ratios — 16x9, 9x16, 1x1
225
+ 11. Cross-Stream Types — parallel rendering, effect-wrapped children
226
+ 12. Frame-Accurate — timeline consistency, frame count
227
+ 13. Description Field — schema validation, render preservation
228
+ 14. Scene as Folder Alias — scene/folder interchangeability
229
+ 15. **Video startFrom/endAt Trimming** — video trimming + photo in series
231
230
 
232
231
  ### Running Tests
233
232
 
@@ -242,9 +241,9 @@ npx vitest run tests/render.test.ts -t "Test Name" # single test
242
241
  1. **TransitionSeries + isBackground**: `TransitionSeries` from `@remotion/transitions` rejects non-Sequence/Transition children. Don't mix `isBackground:true` children with `isSeries:true, transition:"..."` at the same level.
243
242
  2. **Video endAt formula**: Must be `startFrom_frames + ((endAt_source - startFrom_source) * fps / playbackRate)` — changing to `endAt_source * fps` breaks slow-motion playback.
244
243
  3. **`signalstats` ffmpeg filter**: Doesn't output YAVG on all systems; use file size as proxy for visual content.
245
- 4. **Asset paths**: All `src` paths in markdown are resolved relative to the `.md` file's directory. Preview mode serves files via a multi-directory HTTP server; render mode uses absolute paths directly (bypassing `staticFile()`). Never use a `public/` prefix in markdown `src`.
244
+ 4. **Asset paths (unified preview + render)**: All `src` paths in markdown are resolved relative to the `.md` file's directory. Both surfaces store every project-local `src` **relative to the `.md` folder** (e.g. `.markcut/generated/tts/x.mp3`, `assets/pic.png`). Preview: the server's `makePathsRelative` strips the project dir and `resolveAssetPaths`/`toServerUrl` turn them into `/`-rooted URLs served from the project folder. Render: `cli.mjs relativizePaths(streamTree, mdDir)` does the same, then Remotion is invoked with `--public-dir=<mdDir>` so `staticFile()` resolves them (including `fetch()`-based assets like subtitles and includes). Never use a `public/` prefix in markdown `src`.
246
245
  5. **Map rendering**: Depends on external tile availability; may produce blank frames offline.
247
- 6. **CLI md render needs two things preview gets for free**: (a) `parseMarkdownVariants(raw)` (returns `{base, variants}`) — `parseMarkdownDescriptive` returns the root directly; (b) bundling the ` ```js imports ``` ` block via `bundleFromEntries` and setting `root.imports` (otherwise jsx components are unregistered → slides render as unstyled black frames). Asset staging (`stageLocalAssets`) was removed — render now keeps absolute paths from `resolve.ts`, bypassing `staticFile()` entirely. All live in `src/render/cli.mjs`; the import-map shim for shared react/remotion instances is `src/utils/component-import-map.ts` (must stay in sync with `player/browser.tsx` + `bundler.mjs getSharedExternals`).
246
+ 6. **CLI md render needs two things preview gets for free**: (a) `parseMarkdownVariants(raw)` (returns `{base, variants}`) — `parseMarkdownDescriptive` returns the root directly; (b) bundling the ` ```js imports ``` ` block via `bundleFromEntries` and setting `root.imports` (otherwise jsx components are unregistered → slides render as unstyled black frames). Render and preview now share one folder layout **and** one path scheme: generated artifacts live in `.markcut/generated/{tts,media,includes,components}` (content-addressed, shared caches) and per-variant subtitles/compiled.json in `.markcut/<basename>/<label>/` (label defaults to `default`). Render makes paths relative to the `.md` folder and serves it via `--public-dir` (see gotcha 4) — do NOT revert to absolute paths, which break `fetch()`-based assets (subtitles, includes) that hit the render server origin and 404. All live in `src/render/cli.mjs`; the import-map shim for shared react/remotion instances is `src/utils/component-import-map.ts` (must stay in sync with `player/browser.tsx` + `bundler.mjs getSharedExternals`).
248
247
  7. **Publishing**: verify `npx @lalalic/markcut render <md>` from a clean temp dir before/after publish — v1.1.1 shipped with cli.mjs/pipeline.mjs out of sync and md rendering was completely broken. Also beware stale npx caches (`~/.npm/_npx`) silently running old code.
249
248
  8. **Clean break from `actions[]`**: leaf timing is now flat base fields (`start`/`end`/`duration`/`startFrom`/`endAt`). Old compiled JSON carrying `actions:[{...}]` no longer parses into a duration — re-compile from markdown/descriptive JSON, or migrate `actions[0]` → flat fields (drop `id`/`volume`/`style`/`effectId`; keep `start`/`end`/`startFrom`/`endAt`/`loop`).
250
249
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lalalic/markcut",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Markdown-to-video engine. Describe scenes in markdown, get a rendered video.",
5
5
  "bin": {
6
6
  "markcut": "bin/markcut"
@@ -0,0 +1,74 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * Built-in component rendered by `--storyboard` preview mode instead of a
5
+ * TTS-generated audio clip. Shows the script text as a caption-style bar at
6
+ * the bottom of the frame.
7
+ *
8
+ * Usage in compiled JSON:
9
+ * <StoryboardCaption script={script} speaker={speaker} />
10
+ *
11
+ * No imports or frontmatter registration needed — it's a built-in.
12
+ */
13
+ export interface StoryboardCaptionProps {
14
+ /** The narration/dialogue text */
15
+ script: string;
16
+ /** Optional speaker name (for multi-turn dialogue) */
17
+ speaker?: string;
18
+ }
19
+
20
+ export function StoryboardCaption({ script, speaker }: StoryboardCaptionProps) {
21
+ return (
22
+ <div
23
+ style={{
24
+ position: "absolute",
25
+ bottom: "10%",
26
+ left: "5%",
27
+ right: "5%",
28
+ display: "flex",
29
+ flexDirection: "column",
30
+ alignItems: "center",
31
+ pointerEvents: "none",
32
+ }}
33
+ >
34
+ <div
35
+ style={{
36
+ maxWidth: "90%",
37
+ background: "rgba(0,0,0,0.7)",
38
+ backdropFilter: "blur(4px)",
39
+ borderRadius: 16,
40
+ padding: "2.5vh 4vh",
41
+ textAlign: "center",
42
+ color: "#fff",
43
+ fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
44
+ }}
45
+ >
46
+ {speaker && (
47
+ <div
48
+ style={{
49
+ fontSize: "2vh",
50
+ fontWeight: 700,
51
+ color: "#93c5fd",
52
+ marginBottom: "0.6vh",
53
+ letterSpacing: "0.04em",
54
+ }}
55
+ >
56
+ {speaker}
57
+ </div>
58
+ )}
59
+ <div
60
+ style={{
61
+ fontSize: "2.6vh",
62
+ lineHeight: 1.5,
63
+ fontWeight: 500,
64
+ textShadow: "0 1px 4px rgba(0,0,0,0.4)",
65
+ whiteSpace: "pre-wrap",
66
+ wordBreak: "break-word",
67
+ }}
68
+ >
69
+ {script}
70
+ </div>
71
+ </div>
72
+ </div>
73
+ );
74
+ }
@@ -0,0 +1,195 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * Built-in component rendered by `--storyboard` preview mode as the first
5
+ * frame of the video. Shows all project frontmatter config and variant info
6
+ * so the user has full context before reviewing the storyboard scenes.
7
+ *
8
+ * All props are strings (the compiler only collects string bindings).
9
+ *
10
+ * Usage in compiled JSON:
11
+ * <StoryboardInfo config="width: 1080 · height: 1920 · fps: 30 · layout: series"
12
+ * variants="default, zh-tiktok" />
13
+ *
14
+ * No imports or frontmatter registration needed — it's a built-in.
15
+ */
16
+ export interface StoryboardInfoProps {
17
+ /** Formatted string of all root config fields (separated by " · ") */
18
+ config?: string;
19
+ /** Comma-separated list of variant labels */
20
+ variants?: string;
21
+ /** Raw YAML frontmatter from the markdown source (if any) */
22
+ frontmatter?: string;
23
+ }
24
+
25
+ export function StoryboardInfo({ config, variants, frontmatter }: StoryboardInfoProps) {
26
+ const parts = (config ?? "").split(" · ").filter(Boolean);
27
+ const variantList = (variants ?? "").split(/,\s*/).filter(Boolean);
28
+
29
+ return (
30
+ <div
31
+ style={{
32
+ position: "absolute",
33
+ inset: 0,
34
+ display: "flex",
35
+ flexDirection: "column",
36
+ alignItems: "center",
37
+ justifyContent: "center",
38
+ background: "linear-gradient(135deg,#0f172a,#1e293b,#334155)",
39
+ color: "#fff",
40
+ fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
41
+ padding: "8%",
42
+ boxSizing: "border-box",
43
+ }}
44
+ >
45
+ {/* decorative rings */}
46
+ <div
47
+ style={{
48
+ position: "absolute",
49
+ inset: "5%",
50
+ border: "2px solid rgba(255,255,255,0.08)",
51
+ borderRadius: "50%",
52
+ }}
53
+ />
54
+ <div
55
+ style={{
56
+ position: "absolute",
57
+ inset: "12%",
58
+ border: "1px solid rgba(255,255,255,0.05)",
59
+ borderRadius: "50%",
60
+ }}
61
+ />
62
+
63
+ {/* header icon */}
64
+ <div style={{ fontSize: "6vh", marginBottom: "2.5vh", opacity: 0.7 }}>🎬</div>
65
+
66
+ {/* title */}
67
+ <div
68
+ style={{
69
+ fontSize: "4.2vh",
70
+ fontWeight: 800,
71
+ letterSpacing: "0.02em",
72
+ marginBottom: "3vh",
73
+ textAlign: "center",
74
+ textShadow: "0 2px 12px rgba(0,0,0,0.5)",
75
+ }}
76
+ >
77
+ Storyboard Preview
78
+ </div>
79
+
80
+ {/* config fields as a table */}
81
+ {parts.length > 0 && (
82
+ <div
83
+ style={{
84
+ display: "flex",
85
+ flexWrap: "wrap",
86
+ justifyContent: "center",
87
+ gap: "1.2vh",
88
+ maxWidth: "85%",
89
+ marginBottom: variantList.length > 0 ? "2.5vh" : 0,
90
+ }}
91
+ >
92
+ {parts.map((entry) => {
93
+ const colonIdx = entry.indexOf(":");
94
+ const label = colonIdx > 0 ? entry.slice(0, colonIdx).trim() : "";
95
+ const value = colonIdx > 0 ? entry.slice(colonIdx + 1).trim() : entry;
96
+ return (
97
+ <div
98
+ key={label || entry}
99
+ style={{
100
+ background: "rgba(255,255,255,0.07)",
101
+ border: "1px solid rgba(255,255,255,0.12)",
102
+ borderRadius: 12,
103
+ padding: "1.2vh 2.2vh",
104
+ textAlign: "center",
105
+ minWidth: "16vh",
106
+ }}
107
+ >
108
+ {label && (
109
+ <div
110
+ style={{
111
+ fontSize: "1.6vh",
112
+ fontWeight: 600,
113
+ color: "#93c5fd",
114
+ letterSpacing: "0.08em",
115
+ textTransform: "uppercase" as const,
116
+ marginBottom: "0.4vh",
117
+ }}
118
+ >
119
+ {label}
120
+ </div>
121
+ )}
122
+ <div
123
+ style={{
124
+ fontSize: "2.2vh",
125
+ fontWeight: 700,
126
+ whiteSpace: "pre-wrap",
127
+ wordBreak: "break-word",
128
+ opacity: value.startsWith("http") ? 0.8 : 1,
129
+ }}
130
+ >
131
+ {value}
132
+ </div>
133
+ </div>
134
+ );
135
+ })}
136
+ </div>
137
+ )}
138
+
139
+ {/* frontmatter block */}
140
+ {frontmatter && (
141
+ <div
142
+ style={{
143
+ maxWidth: "85%",
144
+ background: "rgba(255,255,255,0.05)",
145
+ border: "1px solid rgba(255,255,255,0.1)",
146
+ borderRadius: 12,
147
+ padding: "1.5vh 2.5vh",
148
+ marginBottom: "2.5vh",
149
+ fontFamily: "monospace",
150
+ fontSize: "1.8vh",
151
+ lineHeight: 1.6,
152
+ color: "rgba(255,255,255,0.8)",
153
+ whiteSpace: "pre-wrap",
154
+ wordBreak: "break-word",
155
+ textAlign: "left",
156
+ maxHeight: "28vh",
157
+ overflow: "auto",
158
+ }}
159
+ >
160
+ {frontmatter}
161
+ </div>
162
+ )}
163
+
164
+ {/* variants */}
165
+ {variantList.length > 0 && (
166
+ <div
167
+ style={{
168
+ display: "flex",
169
+ flexWrap: "wrap",
170
+ justifyContent: "center",
171
+ gap: "1vh",
172
+ }}
173
+ >
174
+ {variantList.map((v) => (
175
+ <div
176
+ key={v}
177
+ style={{
178
+ background: "rgba(251,191,36,0.15)",
179
+ border: "1px solid rgba(251,191,36,0.35)",
180
+ borderRadius: 999,
181
+ padding: "0.8vh 2vh",
182
+ fontSize: "1.8vh",
183
+ fontWeight: 600,
184
+ color: "#fbbf24",
185
+ letterSpacing: "0.04em",
186
+ }}
187
+ >
188
+ {v}
189
+ </div>
190
+ ))}
191
+ </div>
192
+ )}
193
+ </div>
194
+ );
195
+ }
@@ -0,0 +1,96 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * Built-in component rendered by `--storyboard` preview mode instead of a
5
+ * generated image (TTI) or video (TTV). Shows the generation prompt with a
6
+ * clear type indicator so the user can review the story structure fast and
7
+ * chat to reshape it before committing to slow media generation.
8
+ *
9
+ * Usage in markdown / compiled JSON:
10
+ * <StoryboardSlot kind="image" prompt={prompt} />
11
+ * <StoryboardSlot kind="video" prompt={prompt} />
12
+ *
13
+ * The `prompt` variable comes from the component node's data bindings.
14
+ * No imports or frontmatter registration needed — it's a built-in.
15
+ */
16
+ export interface StoryboardSlotProps {
17
+ kind: "image" | "video";
18
+ prompt?: string;
19
+ }
20
+
21
+ export function StoryboardSlot({ kind, prompt }: StoryboardSlotProps) {
22
+ const isVideo = kind === "video";
23
+
24
+ const bgGrad = isVideo
25
+ ? "linear-gradient(135deg,#7c2d12,#b91c1c)"
26
+ : "linear-gradient(135deg,#1e3a8a,#6d28d9)";
27
+ const accentColor = isVideo ? "#fbbf24" : "#93c5fd";
28
+ const label = isVideo ? "Video" : "Image";
29
+ const emoji = isVideo ? "🎬" : "🖼️";
30
+
31
+ return (
32
+ <div
33
+ style={{
34
+ position: "absolute",
35
+ inset: 0,
36
+ overflow: "hidden",
37
+ display: "flex",
38
+ flexDirection: "column",
39
+ alignItems: "center",
40
+ justifyContent: "center",
41
+ background: bgGrad,
42
+ color: "#fff",
43
+ fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
44
+ padding: "6%",
45
+ boxSizing: "border-box",
46
+ }}
47
+ >
48
+ {/* dashed border frame */}
49
+ <div
50
+ style={{
51
+ position: "absolute",
52
+ inset: "4%",
53
+ border: "3px dashed rgba(255,255,255,0.35)",
54
+ borderRadius: 24,
55
+ pointerEvents: "none",
56
+ }}
57
+ />
58
+
59
+ {/* type badge */}
60
+ <div
61
+ style={{
62
+ display: "flex",
63
+ alignItems: "center",
64
+ gap: "0.5em",
65
+ background: "rgba(0,0,0,0.35)",
66
+ border: `2px solid ${accentColor}`,
67
+ borderRadius: 999,
68
+ padding: "0.5em 1.1em",
69
+ fontSize: "2.2vh",
70
+ fontWeight: 800,
71
+ letterSpacing: "0.12em",
72
+ marginBottom: "3vh",
73
+ }}
74
+ >
75
+ <span style={{ fontSize: "3.4vh", lineHeight: 1 }}>{emoji}</span>
76
+ </div>
77
+
78
+ {/* prompt text */}
79
+ <div
80
+ style={{
81
+ position: "relative",
82
+ maxWidth: "88%",
83
+ textAlign: "center",
84
+ fontSize: "3.4vh",
85
+ lineHeight: 1.4,
86
+ fontWeight: 600,
87
+ textShadow: "0 2px 8px rgba(0,0,0,0.5)",
88
+ whiteSpace: "pre-wrap",
89
+ wordBreak: "break-word",
90
+ }}
91
+ >
92
+ {prompt ?? (isVideo ? "(no video prompt)" : "(no image prompt)")}
93
+ </div>
94
+ </div>
95
+ );
96
+ }
package/src/config.mjs CHANGED
@@ -20,7 +20,7 @@ export const args=(function parseArgs(argv) {
20
20
  "--tti": "tti",
21
21
  "--ttv": "ttv"
22
22
  };
23
- const args = { command: "", file: "", output: "", forceNew: false, verbose: false, dev: false, label: false, edit: false, noBrowser: false, chat: false, port: 3001, compile: false, cli: false, showClis: false, scriptOutputDir: "", mediaOutputDir: "", variant: [], cliOverrides: {} };
23
+ const args = { command: "", file: "", output: "", forceNew: false, verbose: false, dev: false, label: false, edit: false, storyboard: false, noBrowser: false, chat: false, port: 3001, compile: false, cli: false, showClis: false, scriptOutputDir: "", mediaOutputDir: "", variant: [], cliOverrides: {} };
24
24
  let i = 2;
25
25
  if (argv[i]) args.command = argv[i++];
26
26
  if (argv[i] && !argv[i].startsWith("--")) args.file = argv[i++];
@@ -37,6 +37,7 @@ export const args=(function parseArgs(argv) {
37
37
  else if (flag === "--dev") args.dev = true;
38
38
  else if (flag === "--label") args.label = true;
39
39
  else if (flag === "--edit") args.edit = true;
40
+ else if (flag === "--storyboard") args.storyboard = true;
40
41
  else if (flag === "--no-browser") args.noBrowser = true;
41
42
  else if (flag === "--port" && argv[i]) args.port = parseInt(argv[i], 10);
42
43
  else if (flag.startsWith("--port=")) args.port = parseInt(flag.split("=")[1], 10);
@@ -490,19 +490,27 @@ function wrapWithEffects(
490
490
 
491
491
  function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | DescriptiveScene | DescriptiveInclude>, ctx: CompileContext, parentKind: "series" | "parallel" | "transitionSeries"): CompileResult {
492
492
  const id = node.id ?? uid();
493
- const start = parentKind === "parallel" ? Math.max(0, node.start ?? 0) : 0;
494
- const duration = deriveLeafDuration(node, ctx);
495
- const end = start + duration;
493
+
494
+ // Background nodes without explicit duration/endAt: let parent fill timing later
495
+ const hasOwnDuration = typeof node.duration === "number" || typeof node.endAt === "number";
496
+ const isBgNoOwnTiming = node.isBackground && !hasOwnDuration;
497
+
498
+ // For background without own timing, skip deriving duration — will be filled by parent
499
+ const start = isBgNoOwnTiming
500
+ ? (typeof node.start === "number" ? node.start : undefined)
501
+ : (parentKind === "parallel" ? Math.max(0, node.start ?? 0) : 0);
502
+ const duration = isBgNoOwnTiming ? undefined : deriveLeafDuration(node, ctx);
503
+ const end = duration != null ? start! + duration : undefined;
496
504
 
497
505
  const base = {
498
506
  id,
499
507
  style: node.style,
500
508
  visible: node.visible ?? true,
501
509
  isBackground: node.isBackground,
502
- start,
510
+ start: isBgNoOwnTiming ? (typeof node.start === "number" ? node.start : undefined) : start,
503
511
  end,
504
- startFrom: node.type === "video" || node.type === "audio" ? node.startFrom : undefined,
505
- endAt: node.type === "video" || node.type === "audio" ? node.endAt : undefined,
512
+ startFrom: isBgNoOwnTiming ? undefined : (node.type === "video" || node.type === "audio" ? node.startFrom : undefined),
513
+ endAt: isBgNoOwnTiming ? undefined : (node.type === "video" || node.type === "audio" ? node.endAt : undefined),
506
514
  durationInSeconds: end,
507
515
  ...pickOn(node),
508
516
  };
@@ -518,7 +526,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
518
526
  width: node.width ?? 1080,
519
527
  height: node.height ?? 1920,
520
528
  };
521
- return { stream, duration: end };
529
+ return { stream, duration: end ?? 0 };
522
530
  }
523
531
  case "audio": {
524
532
  const stream: Audio = {
@@ -529,7 +537,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
529
537
  foreground: node.foreground,
530
538
  speaker: node.speaker,
531
539
  };
532
- return { stream, duration: end };
540
+ return { stream, duration: end ?? 0 };
533
541
  }
534
542
  case "image": {
535
543
  const stream: Image = {
@@ -538,7 +546,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
538
546
  src: node.src,
539
547
  fit: node.fit ?? "contain",
540
548
  };
541
- return { stream, duration: end };
549
+ return { stream, duration: end ?? 0 };
542
550
  }
543
551
  case "component": {
544
552
  // Collect extra properties from descriptive node (e.g. `source` from ~~~md source fences)
@@ -560,7 +568,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
560
568
  jsx: node.jsx,
561
569
  data: Object.keys(bindings).length ? bindings : undefined,
562
570
  };
563
- return { stream, duration: end };
571
+ return { stream, duration: end ?? 0 };
564
572
  }
565
573
  case "rhythm": {
566
574
  // rhythm without children compiles as a plain audio leaf
@@ -572,7 +580,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
572
580
  spots: node.spots,
573
581
  children: [],
574
582
  };
575
- return { stream, duration: end };
583
+ return { stream, duration: end ?? 0 };
576
584
  }
577
585
  case "map": {
578
586
  const stream: MapStream = {
@@ -588,7 +596,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
588
596
  routeMarker: node.routeMarker ?? "🚗",
589
597
  googleMapsApiKey: ctx.googleMapsApiKey,
590
598
  };
591
- return { stream, duration: end };
599
+ return { stream, duration: end ?? 0 };
592
600
  }
593
601
  }
594
602
  }
@@ -676,6 +684,16 @@ function compileScene(
676
684
  ? aggregateDuration(compiledChildren, "parallel")
677
685
  : aggregateDuration(compiledChildren, sceneKind, resolved.time);
678
686
  const localDuration = Math.max(node.duration ?? 0, sceneContentDuration);
687
+
688
+ // Back-propagate parent duration to background children without own timing
689
+ for (const c of compiledChildren) {
690
+ if (c.stream.isBackground && c.stream.end == null) {
691
+ c.stream.end = localDuration;
692
+ c.stream.durationInSeconds = localDuration;
693
+ if (c.stream.start == null) c.stream.start = 0;
694
+ }
695
+ }
696
+
679
697
  const start = parentKind === "parallel" ? Math.max(0, node.start ?? 0) : 0;
680
698
  const end = start + localDuration;
681
699
 
@@ -814,6 +832,15 @@ function compileContainer(node: DescriptiveContainer, ctx: CompileContext, paren
814
832
  const children = compileChildren(node.children, ctx, node.type);
815
833
  const duration = aggregateDuration(children, node.type, resolved.time);
816
834
 
835
+ // Back-propagate parent duration to background children without own timing
836
+ for (const c of children) {
837
+ if (c.stream.isBackground && c.stream.end == null) {
838
+ c.stream.end = duration;
839
+ c.stream.durationInSeconds = duration;
840
+ if (c.stream.start == null) c.stream.start = 0;
841
+ }
842
+ }
843
+
817
844
  const stream: Folder = {
818
845
  id,
819
846
  type: "folder",
@@ -1200,6 +1227,10 @@ export function compileDescriptiveRoot(input: DescriptiveRoot, options: CompileO
1200
1227
 
1201
1228
  // Resolve frontmatter imports / inline component defs onto each component node.
1202
1229
  const registry = resolveComponentSources(input);
1230
+ // Add built-in components so warnUnregisteredComponents doesn't flag them.
1231
+ registry.add("StoryboardSlot");
1232
+ registry.add("StoryboardCaption");
1233
+ registry.add("StoryboardInfo");
1203
1234
  warnUnregisteredComponents(input, registry);
1204
1235
 
1205
1236
  ensureUniqueIds(input.children, "root");
@@ -1207,6 +1238,15 @@ export function compileDescriptiveRoot(input: DescriptiveRoot, options: CompileO
1207
1238
  const children = compileChildren(input.children, ctx, rootKind);
1208
1239
  const duration = aggregateDuration(children, rootKind, resolved.time);
1209
1240
 
1241
+ // Back-propagate root duration to background children without own timing
1242
+ for (const c of children) {
1243
+ if (c.stream.isBackground && c.stream.end == null) {
1244
+ c.stream.end = duration;
1245
+ c.stream.durationInSeconds = duration;
1246
+ if (c.stream.start == null) c.stream.start = 0;
1247
+ }
1248
+ }
1249
+
1210
1250
  const compiled: Root = {
1211
1251
  id: "root",
1212
1252
  type: "root",
@@ -401,7 +401,11 @@ function internalParse(markdown: string): VariantParseResult {
401
401
  for (const node of (mdast as any).children) {
402
402
  switch (node.type) {
403
403
  case "yaml": {
404
- // Frontmatter is metadata only — does not affect video config.
404
+ // Frontmatter is metadata only — store raw text for storyboard preview
405
+ const rawText = (node as any).value?.trim();
406
+ if (rawText) {
407
+ (targetRoot() as any).rawFrontmatter = rawText;
408
+ }
405
409
  break;
406
410
  }
407
411
  case "heading": {