@lalalic/markcut 2.4.5 → 2.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lalalic/markcut",
3
- "version": "2.4.5",
3
+ "version": "2.5.1",
4
4
  "description": "Markdown-to-video engine. Describe scenes in markdown, get a rendered video.",
5
5
  "bin": {
6
6
  "markcut": "bin/markcut"
@@ -291,22 +291,12 @@ Subtitles are configured at the root level as a VTT overlay. Set via `subtitle:`
291
291
 
292
292
  | Field | Required | Type | Notes |
293
293
  |---|---|---|---|
294
- | `src` | yes | string | VTT path/URL, inline VTT body, or plain text |
295
294
  | `type` | opt | string | caption animation: `Bounce`, `Fade`, `Typewriter`, `Colorful`, `Glowing`, `Neon`, `Zoom`. Default: plain static caption |
296
295
  | `fontSize` | opt | number \| string | default 56 (accepts CSS value like `"2em"` or number) |
297
296
  | `fontFamily` | opt | string | font family |
298
297
  | `fontStyle` | opt | string | `normal`, `italic`, `bold`, `bold italic` |
299
298
  | `style` | opt | string | inline CSS for the overlay container |
300
299
 
301
- > **HTML in cue text**: Cue text supports HTML tags with inline CSS, so you can style individual words:
302
- > ```vtt
303
- > 00:00:01.000 --> 00:00:03.000
304
- > Hello <span style="color:#ff6b6b">world</span>, welcome to <b>our show</b>!
305
- > ```
306
- > The engine renders cue text via `dangerouslySetInnerHTML`, making `<span>`, `<b>`, `<i>`, `<br>`, and inline `style` attributes all work.
307
-
308
- If `src` is plain text (no `-->` markers), it renders as a single caption for the full video duration. The `type` field maps to a `remotion-subtitle` animation component — omit for a plain static caption.
309
-
310
300
  ## Key Reference (use these names)
311
301
 
312
302
  | Key | Means | Applies to | Note
@@ -9,11 +9,15 @@ Full path: ${filePath}
9
9
  @{skills/markcut/docs/markdown-descriptive.md}
10
10
 
11
11
  TASKS:
12
- 1. Read the .md file directly using your file tools to see its current content
13
- 2. Edit the .md file directly change content, add/remove sections, update frontmatter
14
- 3. Save the changes to the .md file using your write/edit tools
15
- 4. Output ONLY a JSON object on a single line:
16
- {"summary":"what specific change you made","fileChanged":true}
17
- - `summary`: short description of the change
18
- - `fileChanged`: true if you edited the file, false if no change was needed
19
- 5. Do NOT output any other text, explanations, or reasoning — only the JSON line
12
+ - locate the section of the .md file according to current timestamp and active scene, and user instructions
13
+ - edit the .md file to implement user instructions, while preserving existing content and formatting
14
+ - Read the .md file directly using your file tools to see its current content
15
+ - Edit the .md file directly change content, add/remove sections, update frontmatter
16
+ - **Caption**: directly `edit` vtt file when stylishing text in captions
17
+ **HTML in cue text**: Cue text supports HTML tags with inline CSS, so you can style individual words:
18
+ ~~~vtt
19
+ 00:00:01.000 --> 00:00:03.000
20
+ Hello <span style="color:#ff6b6b">world</span>, welcome to <b>our show</b>!
21
+ ~~~
22
+ - Save the changes to the .md file using your `edit` tool
23
+
package/src/config.mjs CHANGED
@@ -92,7 +92,7 @@ export const DEFAULT_AGENT_CLI = args.cliOverrides.agent || process.env.MARKCUT_
92
92
  /** Default edit agent CLI. Override via --edit-cli flag or MARKCUT_EDIT_CLI env var. */
93
93
  export const DEFAULT_EDIT_CLI = args.cliOverrides.editCli || process.env.MARKCUT_EDIT_CLI || 'npx pi --session-id {sessionid} --system-prompt {systemprompt} -p {prompt}';
94
94
  /** Text-to-image CLI. Override via --tti flag or MARKCUT_TTI_CLI env var. */
95
- export const DEFAULT_TTI_CLI = args.cliOverrides.tti || process.env.MARKCUT_TTI_CLI || 'uvx --from mflux mflux-generate-flux2 --model flux2-klein-4b --steps 5 --prompt "{input}" --output "{output}" --seed {seed}';
95
+ export const DEFAULT_TTI_CLI = args.cliOverrides.tti || process.env.MARKCUT_TTI_CLI || 'uvx --from mflux mflux-generate-flux2 --model flux2-klein-4b --steps 2 --prompt "{input}" --output "{output}" --seed {seed}';
96
96
  /** Text-to-video CLI. Override via --ttv flag or MARKCUT_TTV_CLI env var. */
97
97
  export const DEFAULT_TTV_CLI = args.cliOverrides.ttv || process.env.MARKCUT_TTV_CLI || '';
98
98
  /** Image-to-text CLI. Override via --itt flag or MARKCUT_ITT_CLI env var. */
@@ -395,11 +395,10 @@ describe("resolveScripts — additional", () => {
395
395
 
396
396
  it("caches TTS output and skips second generateTTS call", async () => {
397
397
  let callCount = 0;
398
- (generateTTS as any).mockImplementation(() => {
398
+ (generateTTS as any).mockImplementation((_script, audioPath) => {
399
399
  callCount++;
400
- const p = join(tmpDir, `hook.wav`);
401
- writeFileSync(p, "");
402
- return p;
400
+ writeFileSync(audioPath, "");
401
+ return audioPath;
403
402
  });
404
403
 
405
404
  const root: DescriptiveRoot = {
@@ -412,7 +411,7 @@ describe("resolveScripts — additional", () => {
412
411
  await resolveScripts(root, { outputDir: tmpDir });
413
412
  expect(callCount).toBe(1);
414
413
 
415
- // Second call — should hit cache
414
+ // Second call — filesystem cache: file named by hash already exists
416
415
  const result = await resolveScripts(root, { outputDir: tmpDir });
417
416
  expect(callCount).toBe(1);
418
417
  const scene = result.children[0] as any;
@@ -552,8 +551,9 @@ describe("resolveSubtitles — additional", () => {
552
551
  it("merges VTT from multiple audio clips with correct offsets", async () => {
553
552
  const a1 = join(tmpDir, "clip1.wav");
554
553
  const a2 = join(tmpDir, "clip2.wav");
555
- writeFileSync(a1, "fake");
556
- writeFileSync(a2, "fake");
554
+ // Different content ensures distinct audio hashes (avoid STT cache collision)
555
+ writeFileSync(a1, "clip1-content");
556
+ writeFileSync(a2, "clip2-content");
557
557
  (generateSTT as any)
558
558
  .mockImplementationOnce(async () => {
559
559
  writeFileSync(join(tmpDir, "clip1.vtt"), "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nfirst\n");
@@ -9,7 +9,7 @@
9
9
  * has complete duration and renderable children.
10
10
  */
11
11
  import { execSync } from "node:child_process";
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, renameSync } from "node:fs";
13
13
  import { createHash } from "node:crypto";
14
14
  import { join, dirname, resolve as resolvePath, relative } from "node:path";
15
15
  import {
@@ -28,62 +28,15 @@ import { compileDescriptiveRoot, parseImportsBlock, extractDependencySpecs, reso
28
28
  import { parseMarkdownDescriptive, parseMarkdownVariants } from "./markdown";
29
29
 
30
30
  // ── Content-hash cache for expensive operations (TTS, STT) ────────────────
31
- // Skips regeneration when input hasn't changed. Cache key is a hash of all
32
- // inputs that affect the output (script + cli template).
33
-
34
- interface CacheEntry {
35
- hash: string;
36
- output: string;
37
- }
31
+ // Output files are named by content hash the filesystem IS the cache.
32
+ // If a file named `<hash>.<ext>` exists, it's guaranteed to match its inputs.
33
+ // No manifest file needed.
38
34
 
39
35
  function computeCacheKey(parts: Record<string, unknown>): string {
40
36
  const json = JSON.stringify(parts);
41
37
  return createHash("sha1").update(json).digest("hex").slice(0, 12);
42
38
  }
43
39
 
44
- function readCacheManifest(outputDir: string): Record<string, CacheEntry> {
45
- const manifestPath = join(outputDir, ".cache.json");
46
- try {
47
- return JSON.parse(readFileSync(manifestPath, "utf-8"));
48
- } catch {
49
- return {};
50
- }
51
- }
52
-
53
- function writeCacheManifest(outputDir: string, manifest: Record<string, CacheEntry>): void {
54
- const manifestPath = join(outputDir, ".cache.json");
55
- try {
56
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
57
- } catch {
58
- // best-effort
59
- }
60
- }
61
-
62
- /**
63
- * Returns cached output path if inputs unchanged AND output file still exists.
64
- * Otherwise returns null (caller should regenerate).
65
- */
66
- function checkCache(
67
- manifest: Record<string, CacheEntry>,
68
- key: string,
69
- cacheKey: string,
70
- ): string | null {
71
- const entry = manifest[key];
72
- if (entry?.hash === cacheKey && entry.output && existsSync(entry.output)) {
73
- return entry.output;
74
- }
75
- return null;
76
- }
77
-
78
- function updateCache(
79
- manifest: Record<string, CacheEntry>,
80
- key: string,
81
- cacheKey: string,
82
- output: string,
83
- ): void {
84
- manifest[key] = { hash: cacheKey, output };
85
- }
86
-
87
40
  // ── Media Duration ─────────────────────────────────────────────────────────
88
41
 
89
42
  /** First N words of a string, safe for log labels. */
@@ -399,8 +352,6 @@ export async function resolveScripts(
399
352
  ): Promise<DescriptiveRoot> {
400
353
  const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
401
354
  mkdirSync(options.outputDir, { recursive: true });
402
- const cache = readCacheManifest(options.outputDir);
403
- let cacheDirty = false;
404
355
 
405
356
  // Collect all audio nodes that have script text but no src yet
406
357
  const allScriptNodes: Array<{ node: any; id: string }> = [];
@@ -439,21 +390,18 @@ export async function resolveScripts(
439
390
  const cacheKey = computeCacheKey({ script: node.script, cli: ttsCli });
440
391
  const audioPath = join(options.outputDir, `${cacheKey}.mp3`);
441
392
 
442
- // Check cache skip TTS if script + config unchanged AND audio file exists
443
- const cached = checkCache(cache, `tts:${cacheKey}`, cacheKey);
393
+ // Content-addressed cache: file is named by hash if it exists, it's valid
444
394
  let generated: string;
445
395
  const speakerLabel = node.speaker ? `${node.speaker}: ` : "";
446
396
  const label = `${speakerLabel}${firstWords(node.script, 8)}`;
447
- if (cached) {
448
- generated = cached;
397
+ if (existsSync(audioPath)) {
398
+ generated = audioPath;
449
399
  cacheHits++;
450
400
  console.log(` ✓ TTS: ${label} (cached)`);
451
401
  } else {
452
402
  console.log(` 🔊 TTS (${scriptsDone}/${totalScripts}): ${label}...`);
453
403
  generated = generateTTS(node.script, audioPath, ttsCli);
454
404
  if (generated) {
455
- updateCache(cache, `tts:${cacheKey}`, cacheKey, generated);
456
- cacheDirty = true;
457
405
  console.log(` ✓ TTS (${scriptsDone}/${totalScripts}): ${label}`);
458
406
  } else {
459
407
  console.warn(` ⚠ TTS produced no audio for "${label}". Audio will have no source. Check root.tts config.`);
@@ -466,9 +414,7 @@ export async function resolveScripts(
466
414
  delete node.script;
467
415
  }
468
416
 
469
- if (cacheDirty) writeCacheManifest(options.outputDir, cache);
470
417
  if (totalScripts > 0) {
471
- try { writeFileSync(join(options.outputDir, ".cache.json"), JSON.stringify(cache, null, 2), "utf-8"); } catch {}
472
418
  const ttsElapsed = Math.round((Date.now() - ttsStart) / 1000);
473
419
  const unique = new Set(allScriptNodes.filter(s => s.node.src).map(s => s.node.src)).size;
474
420
  console.log(` ✅ TTS: ${unique} unique audio${unique > 1 ? "s" : ""} (${scriptsDone} nodes) in ${ttsElapsed}s (${cacheHits} cached)`);
@@ -502,8 +448,6 @@ export async function resolveSubtitles(
502
448
  if (!sttCli) return clone;
503
449
 
504
450
  mkdirSync(options.outputDir, { recursive: true });
505
- const cache = readCacheManifest(options.outputDir);
506
- let cacheDirty = false;
507
451
 
508
452
  // Collect { audioSrc, absoluteOffset } from the compiled tree
509
453
  const clips: Array<{ audioSrc: string; offset: number; speaker?: string }> = [];
@@ -596,30 +540,27 @@ export async function resolveSubtitles(
596
540
  let sttFailedCount = 0;
597
541
 
598
542
  for (const { audioSrc, offset, speaker } of clips) {
599
- // Cache key: audio hash + STT CLI string
543
+ // Content-addressed STT cache: hash of (audio content + CLI) → hash-named VTT
600
544
  const audioHash = existsSync(audioSrc)
601
545
  ? createHash("sha1").update(readFileSync(audioSrc)).digest("hex").slice(0, 12)
602
546
  : audioSrc;
603
547
  const sttCacheKey = computeCacheKey({ audioHash, cli: sttCli });
604
- const sttKey = `stt:${audioSrc.split("/").pop()}`;
605
548
  const clipName = audioSrc.split("/").pop()!;
549
+ const expectedVtt = join(options.outputDir, `${sttCacheKey}.vtt`);
606
550
 
607
551
  let vttPath: string | null = null;
608
- // Cache per-clip STT by audio hash + CLI (individual VTT files are stable)
609
- const cachedVtt = checkCache(cache, sttKey, sttCacheKey);
610
- if (cachedVtt) {
611
- vttPath = cachedVtt;
552
+ if (existsSync(expectedVtt)) {
553
+ vttPath = expectedVtt;
612
554
  } else {
613
555
  try {
614
556
  await generateSTT(audioSrc, options.outputDir, sttCli);
615
- // Find generated VTT file
557
+ // Whisper names VTT after input audio file; rename to hash-based name
616
558
  const base = audioSrc.replace(/\.wav$/, "").replace(/\.mp3$/, "");
617
559
  const name = base.split("/").pop()!;
618
- const candidate = join(options.outputDir, `${name}.vtt`);
619
- if (existsSync(candidate)) {
620
- vttPath = candidate;
621
- updateCache(cache, sttKey, sttCacheKey, vttPath);
622
- cacheDirty = true;
560
+ const whisperVtt = join(options.outputDir, `${name}.vtt`);
561
+ if (existsSync(whisperVtt)) {
562
+ renameSync(whisperVtt, expectedVtt);
563
+ vttPath = expectedVtt;
623
564
  }
624
565
  } catch {
625
566
  console.warn(` ⚠ STT failed for ${clipName}. Install whisper (pip install openai-whisper) or set root.stt.`);
@@ -674,7 +615,6 @@ export async function resolveSubtitles(
674
615
  console.log(` ✅ STT: subtitles ready (${cueIndex - 1} cues)`);
675
616
  }
676
617
 
677
- if (cacheDirty) writeCacheManifest(options.outputDir, cache);
678
618
  return clone;
679
619
  }
680
620
 
@@ -708,8 +648,6 @@ export async function resolveGeneratedMedia(
708
648
  ): Promise<DescriptiveRoot> {
709
649
  const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
710
650
  mkdirSync(options.outputDir, { recursive: true });
711
- const cache = readCacheManifest(options.outputDir);
712
- let cacheDirty = false;
713
651
 
714
652
  // Collect all image/video nodes that have prompt but no src
715
653
  const genNodes: Array<{
@@ -753,16 +691,17 @@ export async function resolveGeneratedMedia(
753
691
  ? (clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI)
754
692
  : (clone.ttv ?? options.ttvCli ?? DEFAULT_TTV_CLI);
755
693
 
694
+ // Use the shared seed (from root, options, or content-derived default).
756
695
  const seed = options.seed;
757
696
  const cacheKey = computeCacheKey({ prompt, cli, type, seed });
758
697
  const outputPath = join(options.outputDir, `${cacheKey}.${ext}`);
759
698
 
760
- const cached = checkCache(cache, `gen:${cacheKey}`, cacheKey);
761
699
  const label = type === "image" ? "TTI" : "TTV";
762
700
  const labelText = firstWords(prompt, 8);
763
701
 
764
- if (cached) {
765
- node.src = resolvePath(cached);
702
+ // Content-addressed cache: file is named by hash — if it exists, it's valid
703
+ if (existsSync(outputPath)) {
704
+ node.src = resolvePath(outputPath);
766
705
  cacheHits++;
767
706
  if (done % 5 === 0 || done === total) console.log(progressLine());
768
707
  continue;
@@ -776,8 +715,6 @@ export async function resolveGeneratedMedia(
776
715
  : generateTTV(prompt, outputPath, cli, ttiCmd, seed);
777
716
  if (result) {
778
717
  node.src = outputPath;
779
- updateCache(cache, `gen:${cacheKey}`, cacheKey, outputPath);
780
- cacheDirty = true;
781
718
  console.log(` ✓ ${label} (${done}/${total}): ${labelText}`);
782
719
  if (done % 3 === 0 || done === total) console.log(progressLine());
783
720
  } else {
@@ -799,7 +736,6 @@ export async function resolveGeneratedMedia(
799
736
  const mediaType = genNodes[0]?.type === "video" ? "TTV" : "TTI";
800
737
  console.log(` ✅ ${mediaType} complete: ${done} items in ${elapsed}s (${cacheHits} cached)`);
801
738
  }
802
- if (cacheDirty) writeCacheManifest(options.outputDir, cache);
803
739
  return clone;
804
740
  }
805
741
 
@@ -830,6 +766,10 @@ export interface ResolveAllOptions extends ResolveMediaOptions {
830
766
  * When set, include nodes parse their target .md files with variant awareness
831
767
  * and apply variant overrides (zh-src → src, bare `zh` key → primary content). */
832
768
  variants?: string[];
769
+ /** Path to the source file (.md or .json). When set and root.seed is missing,
770
+ * a deterministic seed is auto-generated and written back to the source file,
771
+ * making it stable across restarts for cache consistency. */
772
+ sourcePath?: string;
833
773
  }
834
774
 
835
775
  /**
@@ -993,6 +933,37 @@ export async function resolveAll(
993
933
  ): Promise<DescriptiveRoot> {
994
934
  let result = root;
995
935
 
936
+ // ── Auto-seed: persist a deterministic seed into the source file ───────────
937
+ // When no seed is set by the user (root.seed) or caller (options.seed),
938
+ // generate one from the content hash and write it back to the source file.
939
+ // This makes the seed visible and stable across restarts — the cache key
940
+ // never changes unless the content or the user intentionally edits the seed.
941
+ if (result.seed == null && options.seed == null && options.sourcePath) {
942
+ const autoSeed = parseInt(computeCacheKey(result as any).slice(0, 8), 16);
943
+ result = { ...result, seed: autoSeed };
944
+ try {
945
+ const raw = readFileSync(options.sourcePath, "utf-8");
946
+ if (options.sourcePath.endsWith(".md")) {
947
+ // Insert seed: <N> after the first line (# video) or after existing header
948
+ const lines = raw.split("\n");
949
+ const headerEnd = lines.findIndex((l) => l.trim() === "" || l.startsWith("##"));
950
+ const insertAt = headerEnd > 1 ? headerEnd : Math.min(1, lines.length);
951
+ lines.splice(insertAt, 0, `seed:${autoSeed}`);
952
+ writeFileSync(options.sourcePath, lines.join("\n"), "utf-8");
953
+ } else if (options.sourcePath.endsWith(".json")) {
954
+ const parsed = JSON.parse(raw);
955
+ const target = parsed.root || parsed;
956
+ target.seed = autoSeed;
957
+ writeFileSync(options.sourcePath, JSON.stringify(parsed, null, 2), "utf-8");
958
+ }
959
+ console.log(` 🌱 Auto-seed: ${autoSeed} → ${options.sourcePath}`);
960
+ } catch (e: any) {
961
+ console.warn(` ⚠ Could not write seed to source file: ${e.message}`);
962
+ }
963
+ }
964
+ const contentSeed = result.seed ?? options.seed ?? 0;
965
+ result = { ...result, seed: contentSeed };
966
+
996
967
  // Step 0: Resolve includes (pre-compile referenced .md files to JSON with accurate durations)
997
968
  result = await resolveIncludes(result, options);
998
969
 
@@ -1012,15 +983,12 @@ export async function resolveAll(
1012
983
  result = await resolveMediaSrcs(result, { baseDir: options.baseDir });
1013
984
 
1014
985
  // Step 3: Generate images/videos from prompts before probing durations.
1015
- // Use root.seed if set; otherwise inherit from options (for includes).
1016
- // Auto-generate a random seed so {seed} in the CLI template always gets a value.
1017
- const generationSeed = result.seed ?? options.seed ?? Math.floor(Math.random() * 2_147_483_647);
1018
986
  if (options.mediaOutputDir) {
1019
987
  result = await resolveGeneratedMedia(result, {
1020
988
  outputDir: options.mediaOutputDir,
1021
989
  ttiCli: options.ttiCli,
1022
990
  ttvCli: options.ttvCli,
1023
- seed: generationSeed,
991
+ seed: contentSeed,
1024
992
  });
1025
993
  }
1026
994
 
@@ -165,6 +165,11 @@ function PlayerApp() {
165
165
  const durationInSeconds = data ? (getDurationInSeconds(data, true) || 5) : 5;
166
166
  const durationInFrames = Math.max(1, Math.ceil(durationInSeconds * fps));
167
167
 
168
+ // Memoize inputProps to avoid Remotion Player composition restart on every render.
169
+ // Without memoization, inputProps creates a new object reference on each render,
170
+ // causing ~10 composition restarts/sec (audio nodes remount → audio jumps back).
171
+ const inputProps = React.useMemo(() => ({ root: data, compose: {} }), [data]);
172
+
168
173
  // ── Load data (does NOT set ready=false to avoid player unmount) ─────
169
174
  const loadData = React.useCallback((initial = false) => {
170
175
  if (initial) setReady(false);
@@ -446,7 +451,7 @@ function PlayerApp() {
446
451
  <Player
447
452
  ref={playerRef}
448
453
  component={MarkCut}
449
- inputProps={{ root: data, compose: {} }}
454
+ inputProps={inputProps}
450
455
  durationInFrames={durationInFrames}
451
456
  fps={fps}
452
457
  compositionWidth={width}
@@ -45998,9 +45998,9 @@ function SubtitleOverlay({ subtitle }) {
45998
45998
  finish();
45999
45999
  };
46000
46000
  }, [subtitle.src]);
46001
+ const cueGroups = React46.useMemo(() => cues ? groupConsecutiveCues(cues) : [], [cues]);
46001
46002
  if (!cues) return null;
46002
46003
  const boxCss = subtitle.style ? cssJS(subtitle.style) : {};
46003
- const cueGroups = React46.useMemo(() => groupConsecutiveCues(cues), [cues]);
46004
46004
  return /* @__PURE__ */ (0, import_jsx_runtime88.jsx)("div", { className: `${subtitle.type || "default"} subtitle-overlay`, style: { ...DEFAULT_BOX_STYLE, ...boxCss }, children: cueGroups.map((group, gi) => /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(
46005
46005
  CueFrame,
46006
46006
  {
@@ -60558,6 +60558,7 @@ function PlayerApp() {
60558
60558
  const fps = data2?.fps ?? 30;
60559
60559
  const durationInSeconds = data2 ? getDurationInSeconds(data2, true) || 5 : 5;
60560
60560
  const durationInFrames = Math.max(1, Math.ceil(durationInSeconds * fps));
60561
+ const inputProps = React55.useMemo(() => ({ root: data2, compose: {} }), [data2]);
60561
60562
  const loadData = React55.useCallback((initial = false) => {
60562
60563
  if (initial) setReady(false);
60563
60564
  const variant = window.VARIANT || "default";
@@ -60793,7 +60794,7 @@ function PlayerApp() {
60793
60794
  {
60794
60795
  ref: playerRef,
60795
60796
  component: MarkCut,
60796
- inputProps: { root: data2, compose: {} },
60797
+ inputProps,
60797
60798
  durationInFrames,
60798
60799
  fps,
60799
60800
  compositionWidth: width,
@@ -964,7 +964,7 @@ init_compiler();
964
964
 
965
965
  // src/descriptive/resolve.ts
966
966
  import { execSync as execSync2 } from "node:child_process";
967
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "node:fs";
967
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync, renameSync } from "node:fs";
968
968
  import { createHash } from "node:crypto";
969
969
  import { join, dirname as dirname2, resolve as resolvePath, relative } from "node:path";
970
970
 
@@ -985,7 +985,7 @@ var args = (function parseArgs(argv) {
985
985
  "--tti": "tti",
986
986
  "--ttv": "ttv"
987
987
  };
988
- const args2 = { 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: {} };
988
+ const args2 = { 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: {} };
989
989
  let i = 2;
990
990
  if (argv[i]) args2.command = argv[i++];
991
991
  if (argv[i] && !argv[i].startsWith("--")) args2.file = argv[i++];
@@ -999,6 +999,7 @@ var args = (function parseArgs(argv) {
999
999
  else if (flag === "--compile") args2.compile = true;
1000
1000
  else if (flag === "--force-new") args2.forceNew = true;
1001
1001
  else if (flag === "--verbose") args2.verbose = true;
1002
+ else if (flag === "--dev") args2.dev = true;
1002
1003
  else if (flag === "--label") args2.label = true;
1003
1004
  else if (flag === "--edit") args2.edit = true;
1004
1005
  else if (flag === "--no-browser") args2.noBrowser = true;
@@ -1022,7 +1023,7 @@ var DEFAULT_STT_CLI = args.cliOverrides.stt || process.env.MARKCUT_STT_CLI || 'u
1022
1023
  var DEFAULT_TTS_CLI = args.cliOverrides.tts || process.env.MARKCUT_TTS_CLI || 'uvx edge-tts --voice "en-US-GuyNeural" --text "{input}" --write-media "{output}"';
1023
1024
  var DEFAULT_AGENT_CLI = args.cliOverrides.agent || process.env.MARKCUT_AGENT_CLI || "npx pi -p {prompt}";
1024
1025
  var DEFAULT_EDIT_CLI = args.cliOverrides.editCli || process.env.MARKCUT_EDIT_CLI || "npx pi --session-id {sessionid} --system-prompt {systemprompt} -p {prompt}";
1025
- var DEFAULT_TTI_CLI = args.cliOverrides.tti || process.env.MARKCUT_TTI_CLI || 'uvx --from mflux mflux-generate-flux2 --model flux2-klein-4b --steps 5 --prompt "{input}" --output "{output}" --seed {seed}';
1026
+ var DEFAULT_TTI_CLI = args.cliOverrides.tti || process.env.MARKCUT_TTI_CLI || 'uvx --from mflux mflux-generate-flux2 --model flux2-klein-4b --steps 2 --prompt "{input}" --output "{output}" --seed {seed}';
1026
1027
  var DEFAULT_TTV_CLI = args.cliOverrides.ttv || process.env.MARKCUT_TTV_CLI || "";
1027
1028
  var DEFAULT_ITT_CLI = args.cliOverrides.itt || process.env.MARKCUT_ITT_CLI || 'uvx --from mlx-vlm mlx_vlm.generate --model mlx-community/MiniCPM-V-4.6-bf16 --max-tokens 2048 --prompt "{prompt}" --image {input} --temperature 0.0 --thinking-mode disabled';
1028
1029
  var DEFAULT_VTT_CLI = args.cliOverrides.vtt || process.env.MARKCUT_VTT_CLI || `uvx --from mlx-vlm mlx_vlm.generate --model mlx-community/MiniCPM-V-4.6-bf16 --max-tokens 2048 --prompt "{prompt}" --video {input} --temperature 0.0 --processor-kwargs '{"max_num_frames": 32, "stack_frames": 1, "max_slice_nums": 1, "use_image_id": false}'`;
@@ -10133,6 +10134,7 @@ function preserveVariantAttrs(node2, attrs) {
10133
10134
  "duration",
10134
10135
  "startFrom",
10135
10136
  "endAt",
10137
+ "loop",
10136
10138
  "width",
10137
10139
  "height",
10138
10140
  "fit",
@@ -10645,31 +10647,6 @@ function computeCacheKey(parts) {
10645
10647
  const json = JSON.stringify(parts);
10646
10648
  return createHash("sha1").update(json).digest("hex").slice(0, 12);
10647
10649
  }
10648
- function readCacheManifest(outputDir) {
10649
- const manifestPath = join(outputDir, ".cache.json");
10650
- try {
10651
- return JSON.parse(readFileSync(manifestPath, "utf-8"));
10652
- } catch {
10653
- return {};
10654
- }
10655
- }
10656
- function writeCacheManifest(outputDir, manifest) {
10657
- const manifestPath = join(outputDir, ".cache.json");
10658
- try {
10659
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
10660
- } catch {
10661
- }
10662
- }
10663
- function checkCache(manifest, key, cacheKey) {
10664
- const entry = manifest[key];
10665
- if (entry?.hash === cacheKey && entry.output && existsSync2(entry.output)) {
10666
- return entry.output;
10667
- }
10668
- return null;
10669
- }
10670
- function updateCache(manifest, key, cacheKey, output) {
10671
- manifest[key] = { hash: cacheKey, output };
10672
- }
10673
10650
  function firstWords(text3, n) {
10674
10651
  if (!text3) return "";
10675
10652
  return text3.split(/\s+/).slice(0, n).join(" ").replace(/[^a-zA-Z0-9\u4e00-\u9fff\s-]/g, "").slice(0, 60) || text3.slice(0, 60);
@@ -10833,8 +10810,6 @@ function resolveDialogue(root) {
10833
10810
  async function resolveScripts(root, options) {
10834
10811
  const clone = JSON.parse(JSON.stringify(root));
10835
10812
  mkdirSync2(options.outputDir, { recursive: true });
10836
- const cache = readCacheManifest(options.outputDir);
10837
- let cacheDirty = false;
10838
10813
  const allScriptNodes = [];
10839
10814
  walkDown(clone, (node2) => {
10840
10815
  if (node2.type !== "audio") return;
@@ -10843,10 +10818,15 @@ async function resolveScripts(root, options) {
10843
10818
  const id = node2.id ?? `audio-${allScriptNodes.length}`;
10844
10819
  allScriptNodes.push({ node: node2, id });
10845
10820
  });
10846
- if (allScriptNodes.length > 0) {
10847
- console.log(` \u{1F50A} TTS: generating ${allScriptNodes.length} script${allScriptNodes.length > 1 ? "s" : ""}...`);
10821
+ const totalScripts = allScriptNodes.length;
10822
+ let scriptsDone = 0;
10823
+ let cacheHits = 0;
10824
+ const ttsStart = Date.now();
10825
+ if (totalScripts > 0) {
10826
+ console.log(` \u{1F50A} TTS: generating ${totalScripts} script${totalScripts > 1 ? "s" : ""}...`);
10848
10827
  }
10849
10828
  for (const { node: node2, id } of allScriptNodes) {
10829
+ scriptsDone++;
10850
10830
  let ttsCli = clone.tts ?? options.ttsCli ?? DEFAULT_TTS_CLI;
10851
10831
  if (node2.speaker && clone.voices) {
10852
10832
  const speakerVoice = clone.voices[node2.speaker];
@@ -10856,19 +10836,18 @@ async function resolveScripts(root, options) {
10856
10836
  }
10857
10837
  const cacheKey = computeCacheKey({ script: node2.script, cli: ttsCli });
10858
10838
  const audioPath = join(options.outputDir, `${cacheKey}.mp3`);
10859
- const cached = checkCache(cache, `tts:${cacheKey}`, cacheKey);
10860
10839
  let generated;
10861
10840
  const speakerLabel = node2.speaker ? `${node2.speaker}: ` : "";
10862
10841
  const label = `${speakerLabel}${firstWords(node2.script, 8)}`;
10863
- if (cached) {
10864
- generated = cached;
10842
+ if (existsSync2(audioPath)) {
10843
+ generated = audioPath;
10844
+ cacheHits++;
10865
10845
  console.log(` \u2713 TTS: ${label} (cached)`);
10866
10846
  } else {
10847
+ console.log(` \u{1F50A} TTS (${scriptsDone}/${totalScripts}): ${label}...`);
10867
10848
  generated = generateTTS(node2.script, audioPath, ttsCli);
10868
10849
  if (generated) {
10869
- updateCache(cache, `tts:${cacheKey}`, cacheKey, generated);
10870
- cacheDirty = true;
10871
- console.log(` \u2713 TTS: ${label}`);
10850
+ console.log(` \u2713 TTS (${scriptsDone}/${totalScripts}): ${label}`);
10872
10851
  } else {
10873
10852
  console.warn(` \u26A0 TTS produced no audio for "${label}". Audio will have no source. Check root.tts config.`);
10874
10853
  }
@@ -10877,14 +10856,10 @@ async function resolveScripts(root, options) {
10877
10856
  node2.src = resolvePath(generated);
10878
10857
  delete node2.script;
10879
10858
  }
10880
- if (cacheDirty) writeCacheManifest(options.outputDir, cache);
10881
- if (allScriptNodes.length > 0) {
10882
- try {
10883
- writeFileSync(join(options.outputDir, ".cache.json"), JSON.stringify(cache, null, 2), "utf-8");
10884
- } catch {
10885
- }
10859
+ if (totalScripts > 0) {
10860
+ const ttsElapsed = Math.round((Date.now() - ttsStart) / 1e3);
10886
10861
  const unique = new Set(allScriptNodes.filter((s) => s.node.src).map((s) => s.node.src)).size;
10887
- console.log(` \u2705 TTS: ${unique} unique audio file${unique > 1 ? "s" : ""} (${allScriptNodes.length} node${allScriptNodes.length > 1 ? "s" : ""})`);
10862
+ console.log(` \u2705 TTS: ${unique} unique audio${unique > 1 ? "s" : ""} (${scriptsDone} nodes) in ${ttsElapsed}s (${cacheHits} cached)`);
10888
10863
  }
10889
10864
  return clone;
10890
10865
  }
@@ -10893,8 +10868,6 @@ async function resolveSubtitles(root, options) {
10893
10868
  const sttCli = clone.stt ?? options.sttCli ?? DEFAULT_STT_CLI;
10894
10869
  if (!sttCli) return clone;
10895
10870
  mkdirSync2(options.outputDir, { recursive: true });
10896
- const cache = readCacheManifest(options.outputDir);
10897
- let cacheDirty = false;
10898
10871
  const clips = [];
10899
10872
  function walkSiblings(nodes, parentOffset, parentIsSeries, parentTransition, parentTransitionTime) {
10900
10873
  let seriesOffset = parentOffset;
@@ -10943,22 +10916,20 @@ async function resolveSubtitles(root, options) {
10943
10916
  for (const { audioSrc, offset, speaker } of clips) {
10944
10917
  const audioHash = existsSync2(audioSrc) ? createHash("sha1").update(readFileSync(audioSrc)).digest("hex").slice(0, 12) : audioSrc;
10945
10918
  const sttCacheKey = computeCacheKey({ audioHash, cli: sttCli });
10946
- const sttKey = `stt:${audioSrc.split("/").pop()}`;
10947
10919
  const clipName = audioSrc.split("/").pop();
10920
+ const expectedVtt = join(options.outputDir, `${sttCacheKey}.vtt`);
10948
10921
  let vttPath = null;
10949
- const cachedVtt = checkCache(cache, sttKey, sttCacheKey);
10950
- if (cachedVtt) {
10951
- vttPath = cachedVtt;
10922
+ if (existsSync2(expectedVtt)) {
10923
+ vttPath = expectedVtt;
10952
10924
  } else {
10953
10925
  try {
10954
10926
  await generateSTT(audioSrc, options.outputDir, sttCli);
10955
10927
  const base = audioSrc.replace(/\.wav$/, "").replace(/\.mp3$/, "");
10956
10928
  const name = base.split("/").pop();
10957
- const candidate = join(options.outputDir, `${name}.vtt`);
10958
- if (existsSync2(candidate)) {
10959
- vttPath = candidate;
10960
- updateCache(cache, sttKey, sttCacheKey, vttPath);
10961
- cacheDirty = true;
10929
+ const whisperVtt = join(options.outputDir, `${name}.vtt`);
10930
+ if (existsSync2(whisperVtt)) {
10931
+ renameSync(whisperVtt, expectedVtt);
10932
+ vttPath = expectedVtt;
10962
10933
  }
10963
10934
  } catch {
10964
10935
  console.warn(` \u26A0 STT failed for ${clipName}. Install whisper (pip install openai-whisper) or set root.stt.`);
@@ -11008,14 +10979,11 @@ async function resolveSubtitles(root, options) {
11008
10979
  clone.subtitle = { ...clone.subtitle ?? {}, src: mergedPath };
11009
10980
  console.log(` \u2705 STT: subtitles ready (${cueIndex - 1} cues)`);
11010
10981
  }
11011
- if (cacheDirty) writeCacheManifest(options.outputDir, cache);
11012
10982
  return clone;
11013
10983
  }
11014
10984
  async function resolveGeneratedMedia(root, options) {
11015
10985
  const clone = JSON.parse(JSON.stringify(root));
11016
10986
  mkdirSync2(options.outputDir, { recursive: true });
11017
- const cache = readCacheManifest(options.outputDir);
11018
- let cacheDirty = false;
11019
10987
  const genNodes = [];
11020
10988
  walkDown(clone, (node2) => {
11021
10989
  if (node2.type !== "image" && node2.type !== "video") return;
@@ -11024,29 +10992,44 @@ async function resolveGeneratedMedia(root, options) {
11024
10992
  const id = node2.id ?? `${node2.type}-${genNodes.length}`;
11025
10993
  genNodes.push({ node: node2, id, type: node2.type, prompt: node2.prompt });
11026
10994
  });
10995
+ const total = genNodes.length;
10996
+ let done = 0;
10997
+ let cacheHits = 0;
10998
+ const startTime = Date.now();
10999
+ function progressLine() {
11000
+ const elapsed = (Date.now() - startTime) / 1e3;
11001
+ const rate = done / Math.max(elapsed, 0.1);
11002
+ const remaining = total - done;
11003
+ const eta = rate > 0 ? Math.round(remaining / rate) : "?";
11004
+ const etaStr = eta === "?" ? "" : `, ~${eta}s remaining`;
11005
+ return ` ${done}/${total} generated (${cacheHits} cached)${etaStr}`;
11006
+ }
11007
+ if (total > 0) {
11008
+ console.log(` \u{1F3A8} Generating ${total} media item${total > 1 ? "s" : ""}...`);
11009
+ }
11027
11010
  for (const { node: node2, id, type, prompt } of genNodes) {
11011
+ done++;
11028
11012
  const ext = type === "image" ? "png" : "mp4";
11029
11013
  const cli = type === "image" ? clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI : clone.ttv ?? options.ttvCli ?? DEFAULT_TTV_CLI;
11030
11014
  const seed = options.seed;
11031
11015
  const cacheKey = computeCacheKey({ prompt, cli, type, seed });
11032
11016
  const outputPath = join(options.outputDir, `${cacheKey}.${ext}`);
11033
- const cached = checkCache(cache, `gen:${cacheKey}`, cacheKey);
11034
11017
  const label = type === "image" ? "TTI" : "TTV";
11035
11018
  const labelText = firstWords(prompt, 8);
11036
- if (cached) {
11037
- node2.src = resolvePath(cached);
11038
- console.log(` \u2713 ${label}: ${labelText} (cached)`);
11019
+ if (existsSync2(outputPath)) {
11020
+ node2.src = resolvePath(outputPath);
11021
+ cacheHits++;
11022
+ if (done % 5 === 0 || done === total) console.log(progressLine());
11039
11023
  continue;
11040
11024
  }
11041
11025
  try {
11042
- console.log(` \u{1F50A} ${label}: ${labelText}...`);
11026
+ console.log(` \u{1F50A} ${label} (${done}/${total}): ${labelText}...`);
11043
11027
  const ttiCmd = clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI;
11044
11028
  const result = type === "image" ? generateTTI(prompt, outputPath, cli, seed) : generateTTV(prompt, outputPath, cli, ttiCmd, seed);
11045
11029
  if (result) {
11046
11030
  node2.src = outputPath;
11047
- updateCache(cache, `gen:${cacheKey}`, cacheKey, outputPath);
11048
- cacheDirty = true;
11049
- console.log(` \u2713 ${label}: ${labelText}`);
11031
+ console.log(` \u2713 ${label} (${done}/${total}): ${labelText}`);
11032
+ if (done % 3 === 0 || done === total) console.log(progressLine());
11050
11033
  } else {
11051
11034
  const hint = cli.includes("echo") ? `No ${label} tool installed. The default CLI just echoes a message \u2014 set root.${type === "image" ? "tti" : "ttv"} to a real generation command.` : `The command ran but produced no output file. Check the CLI template or run the script manually to debug.`;
11052
11035
  console.error(` \u26A0 ${label}: "${labelText}" produced no output. ${hint}`);
@@ -11056,7 +11039,11 @@ async function resolveGeneratedMedia(root, options) {
11056
11039
  console.error(` \u2717 ${label}: "${labelText}" failed \u2014 ${err.message}. ${hint}`);
11057
11040
  }
11058
11041
  }
11059
- if (cacheDirty) writeCacheManifest(options.outputDir, cache);
11042
+ if (total > 0) {
11043
+ const elapsed = Math.round((Date.now() - startTime) / 1e3);
11044
+ const mediaType = genNodes[0]?.type === "video" ? "TTV" : "TTI";
11045
+ console.log(` \u2705 ${mediaType} complete: ${done} items in ${elapsed}s (${cacheHits} cached)`);
11046
+ }
11060
11047
  return clone;
11061
11048
  }
11062
11049
  async function resolveIncludes(root, options = {}) {
@@ -11142,6 +11129,30 @@ async function resolveIncludes(root, options = {}) {
11142
11129
  }
11143
11130
  async function resolveAll2(root, options = {}) {
11144
11131
  let result = root;
11132
+ if (result.seed == null && options.seed == null && options.sourcePath) {
11133
+ const autoSeed = parseInt(computeCacheKey(result).slice(0, 8), 16);
11134
+ result = { ...result, seed: autoSeed };
11135
+ try {
11136
+ const raw = readFileSync(options.sourcePath, "utf-8");
11137
+ if (options.sourcePath.endsWith(".md")) {
11138
+ const lines = raw.split("\n");
11139
+ const headerEnd = lines.findIndex((l) => l.trim() === "" || l.startsWith("##"));
11140
+ const insertAt = headerEnd > 1 ? headerEnd : Math.min(1, lines.length);
11141
+ lines.splice(insertAt, 0, `seed:${autoSeed}`);
11142
+ writeFileSync(options.sourcePath, lines.join("\n"), "utf-8");
11143
+ } else if (options.sourcePath.endsWith(".json")) {
11144
+ const parsed = JSON.parse(raw);
11145
+ const target = parsed.root || parsed;
11146
+ target.seed = autoSeed;
11147
+ writeFileSync(options.sourcePath, JSON.stringify(parsed, null, 2), "utf-8");
11148
+ }
11149
+ console.log(` \u{1F331} Auto-seed: ${autoSeed} \u2192 ${options.sourcePath}`);
11150
+ } catch (e) {
11151
+ console.warn(` \u26A0 Could not write seed to source file: ${e.message}`);
11152
+ }
11153
+ }
11154
+ const contentSeed = result.seed ?? options.seed ?? 0;
11155
+ result = { ...result, seed: contentSeed };
11145
11156
  result = await resolveIncludes(result, options);
11146
11157
  const { resolveAllTemplateVars: resolveAllTemplateVars2 } = await Promise.resolve().then(() => (init_compiler(), compiler_exports));
11147
11158
  result = resolveAllTemplateVars2(result, {
@@ -11151,13 +11162,12 @@ async function resolveAll2(root, options = {}) {
11151
11162
  variant: options.variants?.[0] ?? "video"
11152
11163
  });
11153
11164
  result = await resolveMediaSrcs(result, { baseDir: options.baseDir });
11154
- const generationSeed = result.seed ?? options.seed ?? Math.floor(Math.random() * 2147483647);
11155
11165
  if (options.mediaOutputDir) {
11156
11166
  result = await resolveGeneratedMedia(result, {
11157
11167
  outputDir: options.mediaOutputDir,
11158
11168
  ttiCli: options.ttiCli,
11159
11169
  ttvCli: options.ttvCli,
11160
- seed: generationSeed
11170
+ seed: contentSeed
11161
11171
  });
11162
11172
  }
11163
11173
  result = await resolveMediaDurations(result, {
@@ -11201,6 +11211,7 @@ function isDescriptiveRoot(data) {
11201
11211
  }
11202
11212
  async function resolveAndCompile(data, options = {}) {
11203
11213
  const resolved = await resolveAll2(data, {
11214
+ sourcePath: options.sourcePath,
11204
11215
  baseDir: options.baseDir,
11205
11216
  scriptOutputDir: options.scriptOutputDir,
11206
11217
  mediaOutputDir: options.mediaOutputDir,
@@ -42,6 +42,9 @@ export interface ResolveAndCompileOptions {
42
42
  /** Variant chain to apply to include nodes (e.g. ["zh", "tiktok"]).
43
43
  * When set, included .md files are parsed with variant awareness. */
44
44
  variants?: string[];
45
+ /** Path to the source file (.md or .json). When set and root.seed is missing,
46
+ * a deterministic seed is auto-generated and written back to the source file. */
47
+ sourcePath?: string;
45
48
  }
46
49
 
47
50
  /**
@@ -86,6 +89,7 @@ export async function resolveAndCompile(
86
89
  ): Promise<Root> {
87
90
  // 1. Async resolve: durations, TTS, STT, includes
88
91
  const resolved = await resolveAll(data, {
92
+ sourcePath: options.sourcePath,
89
93
  baseDir: options.baseDir,
90
94
  scriptOutputDir: options.scriptOutputDir,
91
95
  mediaOutputDir: options.mediaOutputDir,
@@ -436,6 +436,7 @@ async function compileVariant(config, parsed, raw) {
436
436
  console.log(` 📝 Variant "${config.label}": resolving...`);
437
437
  const { resolveAll, compileDescriptiveRoot } = await import("./pipeline.mjs");
438
438
  const resolved = await resolveAll(descriptive, {
439
+ sourcePath: VIDEO_JSON,
439
440
  baseDir: dirname(VIDEO_JSON),
440
441
  scriptOutputDir: TTS_OUTPUT_DIR,
441
442
  mediaOutputDir: MEDIA_OUTPUT_DIR,
@@ -458,6 +459,7 @@ async function compileVariant(config, parsed, raw) {
458
459
 
459
460
  console.log(` 📝 Variant "${config.label}": resolving...`);
460
461
  compiled = await resolveAndCompile(descriptive, {
462
+ sourcePath: VIDEO_JSON,
461
463
  baseDir: dirname(VIDEO_JSON),
462
464
  scriptOutputDir: TTS_OUTPUT_DIR,
463
465
  mediaOutputDir: MEDIA_OUTPUT_DIR,
@@ -1007,7 +1009,7 @@ Edit request: ${text}`;
1007
1009
  }
1008
1010
  req.on("close", () => {
1009
1011
  sseClients.delete(res);
1010
- if (MODE_EDIT && sseClients.size === 0) {
1012
+ if (sseClients.size === 0) {
1011
1013
  shutdownTimer = setTimeout(() => {
1012
1014
  console.error("\n 🚪 Browser tab closed — shutting down\n");
1013
1015
  process.exit(0);
@@ -317,6 +317,7 @@ edit=${DEFAULT_EDIT_CLI}`);
317
317
  const fileDir = dirname(filePath);
318
318
  const parsed = parseMarkdownVariants(raw);
319
319
  streamTree = await resolveWithVariants(parsed.base, {
320
+ sourcePath: filePath,
320
321
  baseDir: fileDir,
321
322
  scriptOutputDir: generatedDir(filePath, "tts"),
322
323
  mediaOutputDir: generatedDir(filePath, "media"),
@@ -330,6 +331,7 @@ edit=${DEFAULT_EDIT_CLI}`);
330
331
  if (isDescriptiveRoot(root)) {
331
332
  const fileDir = dirname(filePath);
332
333
  streamTree = await resolveAndCompile(root, {
334
+ sourcePath: filePath,
333
335
  baseDir: fileDir,
334
336
  scriptOutputDir: generatedDir(filePath, "tts"),
335
337
  mediaOutputDir: generatedDir(filePath, "media"),
@@ -518,6 +520,7 @@ function hasScript(root) {
518
520
 
519
521
  const baseDir = dirname(filePath);
520
522
  const baseOpts = {
523
+ sourcePath: filePath,
521
524
  baseDir,
522
525
  scriptOutputDir: args.scriptOutputDir || join(baseDir, "assets", "tts"),
523
526
  mediaOutputDir: args.mediaOutputDir || join(baseDir, "assets", "media"),
@@ -288,13 +288,14 @@ export function SubtitleOverlay({ subtitle }: { subtitle: SubtitleOverlayConfig
288
288
  };
289
289
  }, [subtitle.src]);
290
290
 
291
- if (!cues) return null;@
292
-
293
- const boxCss = subtitle.style ? (cssJS(subtitle.style) as React.CSSProperties) : {};@
294
-
295
291
  // Group consecutive cues with same plain text (word-level highlighting pattern)
296
292
  // so the caption component stays mounted across word transitions — no flash.
297
- const cueGroups = React.useMemo(() => groupConsecutiveCues(cues), [cues]);
293
+ // Must be called unconditionally (before early return) to keep hooks order stable.
294
+ const cueGroups = React.useMemo(() => cues ? groupConsecutiveCues(cues) : [], [cues]);
295
+
296
+ if (!cues) return null;
297
+
298
+ const boxCss = subtitle.style ? (cssJS(subtitle.style) as React.CSSProperties) : {};
298
299
 
299
300
  return (
300
301
  <div className={`${subtitle.type || "default"} subtitle-overlay`} style={{ ...DEFAULT_BOX_STYLE, ...boxCss }}>