@lalalic/markcut 2.4.0 → 2.4.1

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
@@ -47,7 +47,7 @@ See [docs/markdown-strict-descriptive.md](docs/markdown-strict-descriptive.md) f
47
47
  ## CLI
48
48
 
49
49
  ```bash
50
- markcut render <file.json|.md> [--aspect 16x9|9x16|1x1|all] [--output path]
50
+ markcut render <file.json|.md> [--output path]
51
51
  markcut preview <file.json|.md> [--edit] [--label] [--port 3001]
52
52
  ```
53
53
 
@@ -176,7 +176,7 @@ All types share base fields from `BaseShape`: `id`, `name`, `title`, `descriptio
176
176
  ### CLI Usage
177
177
 
178
178
  ```bash
179
- node src/render/cli.mjs render <file.json> [--aspect 16x9|9x16|1x1|all] [--output path]
179
+ node src/render/cli.mjs render <file.json> [--output path]
180
180
  node src/render/cli.mjs render --template <id> --data <data.json>
181
181
  node src/render/cli.mjs preview <file.json> [--edit] [--label] [--port 3001]
182
182
  node src/render/cli.mjs templates
package/README.md CHANGED
@@ -4,7 +4,7 @@ Write a storyboard in markdown, get a rendered video with TTS narration and subt
4
4
 
5
5
  ```bash
6
6
  # Render a storyboard to MP4
7
- npx markcut render storyboard.md --aspect 9x16
7
+ npx markcut render storyboard.md
8
8
 
9
9
  # Preview with live edit (edit .md file, player auto-reloads)
10
10
  npx markcut preview storyboard.md --edit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lalalic/markcut",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "Markdown-to-video engine. Describe scenes in markdown, get a rendered video.",
5
5
  "bin": {
6
6
  "markcut": "bin/markcut"
@@ -85,4 +85,6 @@ some common issues (photo or video can't be displayed, audio missing), take belo
85
85
  - subtitle mismatch
86
86
  - sync issues between audio, video, and subtitles
87
87
  - don't set duration for script or stream's duration depending on audio script
88
- - markcut resolver will automatically calculate the duration based on the audio script length
88
+ - markcut resolver will automatically calculate the duration based on the audio script length
89
+ - **don't** rm `.markcut` directory, which served as cache for all generated content. cache will auto update according to the content change. rm `.markcut` will cause all content to be regenerated, which is time consuming and wasteful.
90
+ - put all manual assets in `assets` folder, such as bgm, logo, watermark, etc. don't put them in `.markcut` folder, which is auto generated and will be deleted when `markcut clean` command is run.
@@ -6,7 +6,7 @@ Complete reference for LLM-driven video generation. The parser uses **remark** (
6
6
 
7
7
  A markdown document compiled into a renderable scene tree.
8
8
 
9
- - Top heading `# <name>` (heading text ignored, just marks document root; `# video`, `# sub-video`, `# anything` all work)
9
+ - Top heading `# video` marks the document root. (Only `# video` is treated as the main section — `# anything_else` creates a **variant** section for alternate configs, see [Variants](#variants-language--platform--any-override).)
10
10
  - Optional YAML frontmatter block `---\n...\n---\n` at the very top
11
11
  - Root config line: `width:<n> height:<n> fps:<n> layout:<mode>` (key:value pairs on the line after `# video`)
12
12
  - Scenes via `##`/`###`/`####` headings
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, 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, 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++];
@@ -34,6 +34,7 @@ export const args=(function parseArgs(argv) {
34
34
  else if (flag === "--compile") args.compile = true;
35
35
  else if (flag === "--force-new") args.forceNew = true;
36
36
  else if (flag === "--verbose") args.verbose = true;
37
+ else if (flag === "--dev") args.dev = true;
37
38
  else if (flag === "--label") args.label = true;
38
39
  else if (flag === "--edit") args.edit = true;
39
40
  else if (flag === "--no-browser") args.noBrowser = true;
@@ -412,11 +412,17 @@ export async function resolveScripts(
412
412
  allScriptNodes.push({ node, id });
413
413
  });
414
414
 
415
- if (allScriptNodes.length > 0) {
416
- console.log(` šŸ”Š TTS: generating ${allScriptNodes.length} script${allScriptNodes.length > 1 ? "s" : ""}...`);
415
+ const totalScripts = allScriptNodes.length;
416
+ let scriptsDone = 0;
417
+ let cacheHits = 0;
418
+ const ttsStart = Date.now();
419
+
420
+ if (totalScripts > 0) {
421
+ console.log(` šŸ”Š TTS: generating ${totalScripts} script${totalScripts > 1 ? "s" : ""}...`);
417
422
  }
418
423
 
419
424
  for (const { node, id } of allScriptNodes) {
425
+ scriptsDone++;
420
426
  // TTS CLI from root config only
421
427
  let ttsCli = clone.tts ?? options.ttsCli ?? DEFAULT_TTS_CLI;
422
428
 
@@ -440,13 +446,15 @@ export async function resolveScripts(
440
446
  const label = `${speakerLabel}${firstWords(node.script, 8)}`;
441
447
  if (cached) {
442
448
  generated = cached;
449
+ cacheHits++;
443
450
  console.log(` āœ“ TTS: ${label} (cached)`);
444
451
  } else {
452
+ console.log(` šŸ”Š TTS (${scriptsDone}/${totalScripts}): ${label}...`);
445
453
  generated = generateTTS(node.script, audioPath, ttsCli);
446
454
  if (generated) {
447
455
  updateCache(cache, `tts:${cacheKey}`, cacheKey, generated);
448
456
  cacheDirty = true;
449
- console.log(` āœ“ TTS: ${label}`);
457
+ console.log(` āœ“ TTS (${scriptsDone}/${totalScripts}): ${label}`);
450
458
  } else {
451
459
  console.warn(` ⚠ TTS produced no audio for "${label}". Audio will have no source. Check root.tts config.`);
452
460
  }
@@ -459,10 +467,11 @@ export async function resolveScripts(
459
467
  }
460
468
 
461
469
  if (cacheDirty) writeCacheManifest(options.outputDir, cache);
462
- if (allScriptNodes.length > 0) {
470
+ if (totalScripts > 0) {
463
471
  try { writeFileSync(join(options.outputDir, ".cache.json"), JSON.stringify(cache, null, 2), "utf-8"); } catch {}
472
+ const ttsElapsed = Math.round((Date.now() - ttsStart) / 1000);
464
473
  const unique = new Set(allScriptNodes.filter(s => s.node.src).map(s => s.node.src)).size;
465
- console.log(` āœ… TTS: ${unique} unique audio file${unique > 1 ? "s" : ""} (${allScriptNodes.length} node${allScriptNodes.length > 1 ? "s" : ""})`);
474
+ console.log(` āœ… TTS: ${unique} unique audio${unique > 1 ? "s" : ""} (${scriptsDone} nodes) in ${ttsElapsed}s (${cacheHits} cached)`);
466
475
  }
467
476
  return clone;
468
477
  }
@@ -713,27 +722,38 @@ export async function resolveGeneratedMedia(
713
722
  walkDown(clone as any, (node) => {
714
723
  if ((node.type !== "image" && node.type !== "video")) return;
715
724
  if (!node.prompt || typeof node.prompt !== "string") return;
716
- // Skip if src is already set to a real path (prompt is just metadata).
717
- // "auto" is a placeholder meaning "auto-generate via TTI/TTV".
718
725
  if (node.src && node.src !== "auto") return;
719
726
  const id = node.id ?? `${node.type}-${genNodes.length}`;
720
727
  genNodes.push({ node, id, type: node.type as "image" | "video", prompt: node.prompt });
721
728
  });
722
729
 
730
+ const total = genNodes.length;
731
+ let done = 0;
732
+ let cacheHits = 0;
733
+ const startTime = Date.now();
734
+
735
+ function progressLine() {
736
+ const elapsed = (Date.now() - startTime) / 1000;
737
+ const rate = done / Math.max(elapsed, 0.1);
738
+ const remaining = total - done;
739
+ const eta = rate > 0 ? Math.round(remaining / rate) : "?";
740
+ const etaStr = eta === "?" ? "" : `, ~${eta}s remaining`;
741
+ return ` ${done}/${total} generated (${cacheHits} cached)${etaStr}`;
742
+ }
743
+
744
+ if (total > 0) {
745
+ console.log(` šŸŽØ Generating ${total} media item${total > 1 ? "s" : ""}...`);
746
+ }
747
+
723
748
  for (const { node, id, type, prompt } of genNodes) {
749
+ done++;
724
750
  const ext = type === "image" ? "png" : "mp4";
725
751
 
726
- // Resolve TTI/TTV config: root-level config overrides CLI defaults
727
752
  const cli = type === "image"
728
753
  ? (clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI)
729
754
  : (clone.ttv ?? options.ttvCli ?? DEFAULT_TTV_CLI);
730
755
 
731
- // Use root.seed for reproducible generation (applied when CLI template has {seed}).
732
- // seed is part of the cache key so different seeds produce different cached outputs.
733
756
  const seed = options.seed;
734
-
735
- // Content-addressed filename: hash of prompt + CLI + type + seed, so identical
736
- // prompts with different seeds produce distinct cached media.
737
757
  const cacheKey = computeCacheKey({ prompt, cli, type, seed });
738
758
  const outputPath = join(options.outputDir, `${cacheKey}.${ext}`);
739
759
 
@@ -743,12 +763,13 @@ export async function resolveGeneratedMedia(
743
763
 
744
764
  if (cached) {
745
765
  node.src = resolvePath(cached);
746
- console.log(` āœ“ ${label}: ${labelText} (cached)`);
766
+ cacheHits++;
767
+ if (done % 5 === 0 || done === total) console.log(progressLine());
747
768
  continue;
748
769
  }
749
770
 
750
771
  try {
751
- console.log(` šŸ”Š ${label}: ${labelText}...`);
772
+ console.log(` šŸ”Š ${label} (${done}/${total}): ${labelText}...`);
752
773
  const ttiCmd = clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI;
753
774
  const result = type === "image"
754
775
  ? generateTTI(prompt, outputPath, cli, seed)
@@ -757,7 +778,8 @@ export async function resolveGeneratedMedia(
757
778
  node.src = outputPath;
758
779
  updateCache(cache, `gen:${cacheKey}`, cacheKey, outputPath);
759
780
  cacheDirty = true;
760
- console.log(` āœ“ ${label}: ${labelText}`);
781
+ console.log(` āœ“ ${label} (${done}/${total}): ${labelText}`);
782
+ if (done % 3 === 0 || done === total) console.log(progressLine());
761
783
  } else {
762
784
  const hint = cli.includes("echo")
763
785
  ? `No ${label} tool installed. The default CLI just echoes a message — set root.${type === "image" ? "tti" : "ttv"} to a real generation command.`
@@ -772,6 +794,11 @@ export async function resolveGeneratedMedia(
772
794
  }
773
795
  }
774
796
 
797
+ if (total > 0) {
798
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
799
+ const mediaType = genNodes[0]?.type === "video" ? "TTV" : "TTI";
800
+ console.log(` āœ… ${mediaType} complete: ${done} items in ${elapsed}s (${cacheHits} cached)`);
801
+ }
775
802
  if (cacheDirty) writeCacheManifest(options.outputDir, cache);
776
803
  return clone;
777
804
  }
@@ -44710,11 +44710,6 @@ function getCurrentStep(leg, currentInSecond) {
44710
44710
  // src/types/Include.tsx
44711
44711
  var React41 = __toESM(require_react(), 1);
44712
44712
  var import_jsx_runtime84 = __toESM(require_jsx_runtime(), 1);
44713
- var ASPECT_DIMS = {
44714
- "16x9": { width: 1920, height: 1080 },
44715
- "9x16": { width: 1080, height: 1920 },
44716
- "1x1": { width: 1080, height: 1080 }
44717
- };
44718
44713
  function isSceneBased(data2) {
44719
44714
  if (!data2 || typeof data2 !== "object") return false;
44720
44715
  const d2 = data2;
@@ -44837,8 +44832,7 @@ function IncludeLeaf({ stream: stream2 }) {
44837
44832
  if (isSceneBased(externalData)) {
44838
44833
  const vj = externalData;
44839
44834
  const vjFps = vj.meta.fps ?? parentFps;
44840
- const aspectKey = vj.meta.aspects?.[0] ?? "16x9";
44841
- const dims = ASPECT_DIMS[aspectKey] ?? { width: parentWidth, height: parentHeight };
44835
+ const dims = { width: parentWidth, height: parentHeight };
44842
44836
  return /* @__PURE__ */ (0, import_jsx_runtime84.jsxs)(AbsoluteFill, { style: { backgroundColor: "#0a0a0a", width: dims.width, height: dims.height }, children: [
44843
44837
  vj.bgm && /* @__PURE__ */ (0, import_jsx_runtime84.jsx)(Audio, { src: vj.bgm.src, volume: vj.bgm.baseVolume }),
44844
44838
  vj.scenes.map((scene2) => {
@@ -21,12 +21,6 @@ const __filename = fileURLToPath(import.meta.url);
21
21
  const __dirname = dirname(__filename);
22
22
  const ROOT = resolve(__dirname, "../..");
23
23
 
24
- const ASPECTS = {
25
- "16x9": { width: 1920, height: 1080 },
26
- "9x16": { width: 1080, height: 1920 },
27
- "1x1": { width: 1080, height: 1080 },
28
- };
29
-
30
24
  /**
31
25
  * How many "Rendered X/Y" lines to skip before printing one.
32
26
  * E.g. 50 means print every 50th frame — for 1860 frames that's ~37 lines.
@@ -52,6 +46,7 @@ markcut CLI — Markdown/JSON → video pipeline
52
46
  npx @lalalic/markcut <command> [options]
53
47
 
54
48
  global options:
49
+ --dev Use development build (unminified React error messages)
55
50
  --show-clis Print all default CLI templates and exit
56
51
 
57
52
  --tti <template> Override default TTI CLI template
@@ -92,7 +87,7 @@ Commands:
92
87
 
93
88
 
94
89
  /**
95
- * Render one aspect ratio with compact progress output.
90
+ * Render a stream tree to an MP4 video with compact progress output.
96
91
  *
97
92
  * In compact mode (default), "Rendered X/Y" lines are shown only every
98
93
  * PROGRESS_INTERVAL frames and at the final frame, drastically reducing
@@ -100,28 +95,49 @@ Commands:
100
95
  *
101
96
  * Use --verbose to see every frame line (original behavior).
102
97
  */
98
+
99
+ /**
100
+ * Resolve a potentially relative source path against baseDir, skipping
101
+ * absolute paths, URLs, and data URIs.
102
+ */
103
+ function resolveAssetPath(src, baseDir) {
104
+ if (!src || typeof src !== "string") return null;
105
+ // Already absolute on disk
106
+ if (src.startsWith("/")) return src;
107
+ // URL or data URI — can't stage locally
108
+ if (/^(https?:|data:|blob:)/.test(src)) return null;
109
+ // Relative path: resolve against the input file's directory
110
+ const abs = resolve(baseDir, src);
111
+ if (existsSync(abs)) return abs;
112
+ return null;
113
+ }
114
+
103
115
  /**
104
- * Stage local absolute-path assets (TTS audio, TTI/TTV media, subtitles from
105
- * .markcut/) into ROOT/public/.render-assets/ and rewrite srcs to relative
106
- * paths, so `npx remotion render` (publicDir = ROOT/public) can serve them.
107
- * The preview server handles these paths via multi-root serving; the render
108
- * CLI needs them staged into the one public dir Remotion knows about.
116
+ * Stage all local src files (absolute and relative) into
117
+ * ROOT/public/.render-assets/ so `npx remotion render` (publicDir = ROOT/public)
118
+ * can serve them. Relative paths are resolved against `baseDir` (the input
119
+ * file's directory). The preview server handles these paths via multi-root
120
+ * serving; the render CLI needs them staged into the one public dir Remotion
121
+ * knows about.
109
122
  */
110
- function stageLocalAssets(tree) {
123
+ function stageLocalAssets(tree, baseDir) {
111
124
  const assetsDir = join(ROOT, "public", ".render-assets");
112
125
  const seen = new Map();
113
126
  const walk = (node) => {
114
127
  if (!node || typeof node !== "object") return;
115
- if (typeof node.src === "string" && node.src.startsWith("/") && !node.src.startsWith("//") && existsSync(node.src)) {
116
- let staged = seen.get(node.src);
117
- if (!staged) {
118
- const name = createHash("md5").update(node.src).digest("hex").slice(0, 12) + extname(node.src);
119
- mkdirSync(assetsDir, { recursive: true });
120
- copyFileSync(node.src, join(assetsDir, name));
121
- staged = `.render-assets/${name}`;
122
- seen.set(node.src, staged);
128
+ if (typeof node.src === "string") {
129
+ const absPath = resolveAssetPath(node.src, baseDir);
130
+ if (absPath) {
131
+ let staged = seen.get(absPath);
132
+ if (!staged) {
133
+ const name = createHash("md5").update(absPath).digest("hex").slice(0, 12) + extname(absPath);
134
+ mkdirSync(assetsDir, { recursive: true });
135
+ copyFileSync(absPath, join(assetsDir, name));
136
+ staged = `.render-assets/${name}`;
137
+ seen.set(absPath, staged);
138
+ }
139
+ node.src = staged;
123
140
  }
124
- node.src = staged;
125
141
  }
126
142
  for (const value of Object.values(node)) {
127
143
  if (value && typeof value === "object") walk(value);
@@ -131,21 +147,24 @@ function stageLocalAssets(tree) {
131
147
  return tree;
132
148
  }
133
149
 
134
- function renderOne(streamTree, aspect, outputPath, verbose) {
135
- const dims = ASPECTS[aspect];
136
- if (!dims) throw new Error(`Unknown aspect: ${aspect}`);
137
-
138
- const adapted = stageLocalAssets(JSON.parse(JSON.stringify({ ...streamTree, width: dims.width, height: dims.height })));
139
- const tmpProps = join(ROOT, ".tmp", `render-${aspect}.json`);
150
+ function renderOne(streamTree, outputPath, verbose, baseDir) {
151
+ const adapted = stageLocalAssets(JSON.parse(JSON.stringify(streamTree)), baseDir);
152
+ const tmpProps = join(ROOT, ".tmp", "render-stream.json");
140
153
  mkdirSync(dirname(tmpProps), { recursive: true });
141
154
  writeFileSync(tmpProps, JSON.stringify({ root: adapted }));
142
155
 
143
156
  mkdirSync(dirname(outputPath), { recursive: true });
144
157
 
145
- console.log(`\nā–¶ Rendering ${aspect} → ${outputPath}`);
158
+ console.log(`\nā–¶ Rendering → ${outputPath}`);
146
159
 
147
160
  return new Promise((resolvePromise, reject) => {
148
- const proc = spawn("npx", ["remotion", "render", "Root", outputPath, "--props", tmpProps, "--config", "remotion.config.ts"], { cwd: ROOT, stdio: ["ignore", "inherit", "pipe"] });
161
+ // Pass NODE_ENV=development to get unminified React error messages (--dev flag)
162
+ const spawnOpts = { cwd: ROOT, stdio: ["ignore", "inherit", "pipe"] };
163
+ // args is imported from config.mjs — check dev flag there
164
+ if (args.dev) {
165
+ spawnOpts.env = { ...process.env, NODE_ENV: "development" };
166
+ }
167
+ const proc = spawn("npx", ["remotion", "render", "Root", outputPath, "--props", tmpProps, "--config", "remotion.config.ts"], spawnOpts);
149
168
 
150
169
  let lastLoggedFrame = 0;
151
170
  let totalFrames = 0;
@@ -401,7 +420,8 @@ edit=${DEFAULT_EDIT_CLI}`);
401
420
  }
402
421
 
403
422
  const output = args.output ? resolve(args.output) : join(ROOT, "out", "video.mp4");
404
- await renderOne(streamTree, "16x9", output, args.verbose);
423
+ const fileDir = dirname(resolve(args.file));
424
+ await renderOne(streamTree, output, args.verbose, fileDir);
405
425
 
406
426
  console.log("\nāœ… Render complete.");
407
427
  process.exit(0);
@@ -441,12 +461,27 @@ function hasScript(root) {
441
461
  emitInfo(`File: ${filePath}`);
442
462
  emitInfo(`Format: ${isMarkdown ? "Markdown" : "JSON"}`);
443
463
 
444
- const { compileDescriptiveRoot, parseMarkdownDescriptive, parseImportsBlock } = await import("../player/pipeline.mjs");
464
+ const { compileDescriptiveRoot, parseMarkdownDescriptive, parseMarkdownVariants, parseImportsBlock } = await import("../player/pipeline.mjs");
445
465
 
446
466
  try {
447
467
  let descriptive, needsTti, needsTtv;
448
468
  if (isMarkdown) {
449
469
  descriptive = parseMarkdownDescriptive(raw);
470
+
471
+ // Check if content accidentally went into a variant section
472
+ // (happens when root heading is e.g. "# My Movie" instead of "# video")
473
+ const hasChildren = (descriptive.children?.length ?? 0) > 0;
474
+ if (!hasChildren) {
475
+ const variantResult = parseMarkdownVariants(raw);
476
+ const variantNames = [...variantResult.variants.keys()];
477
+ const variantChildren = variantNames.filter(n => (variantResult.variants.get(n)?.children?.length ?? 0) > 0);
478
+ if (variantChildren.length > 0) {
479
+ warnings.push(
480
+ `No scenes found in the main section (use '# video' as the root heading). ` +
481
+ `Content appears in variant section(s): ${variantChildren.join(", ")}`,
482
+ );
483
+ }
484
+ }
450
485
  } else {
451
486
  const parsed = JSON.parse(raw);
452
487
  const root = parsed.root ?? parsed;
@@ -5,15 +5,8 @@ import { cssJS, toClassName, getDurationInSeconds, type DurationStream } from ".
5
5
  import type { Include as IncludeStream, Root } from "../schema/index";
6
6
  import { FolderLeaf } from "./Folder";
7
7
 
8
- // Aspect dimensions for scene-based video content
9
- const ASPECT_DIMS: Record<string, { width: number; height: number }> = {
10
- "16x9": { width: 1920, height: 1080 },
11
- "9x16": { width: 1080, height: 1920 },
12
- "1x1": { width: 1080, height: 1080 },
13
- };
14
-
15
8
  interface SceneBasedVideo {
16
- meta: { title: string; fps: number; aspects?: string[] };
9
+ meta: { title: string; fps: number };
17
10
  voiceover?: { tts: string; voice: string };
18
11
  bgm?: { src: string; baseVolume: number };
19
12
  scenes: Array<{
@@ -207,9 +200,8 @@ export function IncludeLeaf({ stream }: { stream: IncludeStream }) {
207
200
  // ── Scene-based video.json ──────────────────────────────────
208
201
  const vj = externalData;
209
202
  const vjFps = vj.meta.fps ?? parentFps;
210
- // Use first aspect's dimensions (default to 16x9)
211
- const aspectKey = (vj.meta.aspects?.[0] ?? "16x9") as keyof typeof ASPECT_DIMS;
212
- const dims = ASPECT_DIMS[aspectKey] ?? { width: parentWidth, height: parentHeight };
203
+ // Use parent composition dimensions (dimensions come from the stream file)
204
+ const dims = { width: parentWidth, height: parentHeight };
213
205
 
214
206
  return (
215
207
  <AbsoluteFill style={{ backgroundColor: "#0a0a0a", width: dims.width, height: dims.height }}>
@@ -288,9 +288,9 @@ export function SubtitleOverlay({ subtitle }: { subtitle: SubtitleOverlayConfig
288
288
  };
289
289
  }, [subtitle.src]);
290
290
 
291
- if (!cues) return null;
291
+ if (!cues) return null;@
292
292
 
293
- const boxCss = subtitle.style ? (cssJS(subtitle.style) as React.CSSProperties) : {};
293
+ const boxCss = subtitle.style ? (cssJS(subtitle.style) as React.CSSProperties) : {};@
294
294
 
295
295
  // Group consecutive cues with same plain text (word-level highlighting pattern)
296
296
  // so the caption component stays mounted across word transitions — no flash.
@@ -473,11 +473,11 @@ describe("Audio STT Verification", () => {
473
473
  });
474
474
 
475
475
  // ───────────────────────────────────────────────────────────────────────────
476
- // 11. Multiple Aspect Ratios
476
+ // 11. Dimensions from Stream File
477
477
  // ───────────────────────────────────────────────────────────────────────────
478
478
 
479
- describe("Multiple Aspect Ratios", () => {
480
- it("renders same fixture at 16x9, 9x16, and 1x1", async () => {
479
+ describe("Dimensions from Stream File", () => {
480
+ it("respects width/height defined in the stream tree root", async () => {
481
481
  const fixture = {
482
482
  root: {
483
483
  id: "root",
@@ -490,7 +490,7 @@ describe("Multiple Aspect Ratios", () => {
490
490
  {
491
491
  id: "bg",
492
492
  type: "image",
493
- src: "https://picsum.photos/seed/aspect-test/1920/1080",
493
+ src: "https://picsum.photos/seed/dim-test/1920/1080",
494
494
  fit: "cover",
495
495
  start: 0,
496
496
  end: 2,
@@ -498,7 +498,7 @@ describe("Multiple Aspect Ratios", () => {
498
498
  {
499
499
  id: "title",
500
500
  type: "image",
501
- src: "https://picsum.photos/seed/aspect-title/1920/1080",
501
+ src: "https://picsum.photos/seed/dim-title/1920/1080",
502
502
  fit: "cover",
503
503
  start: 0.3,
504
504
  end: 1.7,
@@ -508,18 +508,16 @@ describe("Multiple Aspect Ratios", () => {
508
508
  };
509
509
 
510
510
  const { writeFileSync } = await import("node:fs");
511
- const tmpFixture = outPath("_aspects.json");
511
+ const tmpFixture = outPath("_dims.json");
512
512
  writeFileSync(tmpFixture, JSON.stringify(fixture));
513
513
 
514
- // Render with different aspect ratios via --props adaptation
515
- // Note: Aspect adaptation happens in the CLI, so we test with the default
514
+ // Dimensions come from the stream file root, not from a CLI --aspect flag
516
515
  const output = renderFixture(tmpFixture, {
517
- outputName: "aspect-default.mp4",
516
+ outputName: "dims-default.mp4",
518
517
  timeout: RENDER_TIMEOUT,
519
518
  });
520
519
 
521
520
  const info = getVideoInfo(output);
522
- // Default aspect is whatever the fixture says (1920x1080)
523
521
  expect(info.width).toBe(1920);
524
522
  expect(info.height).toBe(1080);
525
523