@lalalic/markcut 1.0.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.
Files changed (98) hide show
  1. package/.env.example +27 -0
  2. package/.github/user-steer.md +9 -0
  3. package/.vscode/settings.json +3 -0
  4. package/AGENTS.md +271 -0
  5. package/README.md +219 -0
  6. package/SKILL.md +209 -0
  7. package/docs/dynamic-components.md +191 -0
  8. package/docs/edit-mode.md +220 -0
  9. package/docs/json-descriptive.md +539 -0
  10. package/docs/label-mode.md +110 -0
  11. package/docs/markdown-descriptive.md +751 -0
  12. package/docs/templates.md +52 -0
  13. package/package.json +64 -0
  14. package/remotion.config.ts +5 -0
  15. package/scripts/artlist-dl.mjs +190 -0
  16. package/scripts/build-pipeline.sh +19 -0
  17. package/scripts/build-player.sh +20 -0
  18. package/src/Root.tsx +55 -0
  19. package/src/config.mjs +88 -0
  20. package/src/context/EventContext.tsx +168 -0
  21. package/src/context/index.tsx +42 -0
  22. package/src/descriptive/compiler.test.ts +1135 -0
  23. package/src/descriptive/compiler.ts +1230 -0
  24. package/src/descriptive/dsl.ts +455 -0
  25. package/src/descriptive/markdown.test.ts +866 -0
  26. package/src/descriptive/markdown.ts +674 -0
  27. package/src/descriptive/resolve.test.ts +951 -0
  28. package/src/descriptive/resolve.ts +891 -0
  29. package/src/entry.tsx +163 -0
  30. package/src/index.ts +4 -0
  31. package/src/player/browser.tsx +356 -0
  32. package/src/player/bundle/player.js +60259 -0
  33. package/src/player/bundler.mjs +269 -0
  34. package/src/player/label-server.mjs +599 -0
  35. package/src/player/pipeline.mjs +11123 -0
  36. package/src/player/pipeline.ts +117 -0
  37. package/src/player/server-shared.mjs +144 -0
  38. package/src/player/server.mjs +1006 -0
  39. package/src/render/cli-tools.ts +177 -0
  40. package/src/render/cli.mjs +628 -0
  41. package/src/schema/index.ts +259 -0
  42. package/src/types/Audio.tsx +56 -0
  43. package/src/types/Component.tsx +135 -0
  44. package/src/types/Effect.tsx +88 -0
  45. package/src/types/Folder.tsx +180 -0
  46. package/src/types/FrameSyncStyle.tsx +51 -0
  47. package/src/types/Image.tsx +51 -0
  48. package/src/types/Include.tsx +394 -0
  49. package/src/types/Map.tsx +252 -0
  50. package/src/types/Rhythm.tsx +58 -0
  51. package/src/types/Scene.tsx +42 -0
  52. package/src/types/Subtitle.tsx +218 -0
  53. package/src/types/Video.tsx +70 -0
  54. package/src/types/keyframes.ts +454 -0
  55. package/src/utils/__tests__/vtt.test.ts +129 -0
  56. package/src/utils/index.ts +168 -0
  57. package/src/utils/tween.ts +118 -0
  58. package/src/vision/cli.mjs +1187 -0
  59. package/src/vision/vision_prompts.md +67 -0
  60. package/tests/dsl.test.ts +317 -0
  61. package/tests/fixtures/audio.json +25 -0
  62. package/tests/fixtures/basic.json +27 -0
  63. package/tests/fixtures/component-all.json +38 -0
  64. package/tests/fixtures/components.json +38 -0
  65. package/tests/fixtures/effects.json +64 -0
  66. package/tests/fixtures/full.json +51 -0
  67. package/tests/fixtures/map.json +23 -0
  68. package/tests/fixtures/md/all-nodes.md +28 -0
  69. package/tests/fixtures/md/basic.md +6 -0
  70. package/tests/fixtures/md/component-imports.md +20 -0
  71. package/tests/fixtures/md/edge-cases.md +33 -0
  72. package/tests/fixtures/md/effects.md +17 -0
  73. package/tests/fixtures/md/frontmatter.md +20 -0
  74. package/tests/fixtures/md/full-feature.md +58 -0
  75. package/tests/fixtures/md/imports-block.md +19 -0
  76. package/tests/fixtures/md/include-main.md +11 -0
  77. package/tests/fixtures/md/include-sub.md +25 -0
  78. package/tests/fixtures/md/jsx-code-fence.md +21 -0
  79. package/tests/fixtures/md/map.md +11 -0
  80. package/tests/fixtures/md/nested-scenes.md +25 -0
  81. package/tests/fixtures/md/rhythm.md +17 -0
  82. package/tests/fixtures/md/scenes.md +16 -0
  83. package/tests/fixtures/md/tween.md +11 -0
  84. package/tests/fixtures/md/vars-test.md +6 -0
  85. package/tests/fixtures/scenes.json +40 -0
  86. package/tests/fixtures/subtitle.json +59 -0
  87. package/tests/fixtures/subvideo.json +59 -0
  88. package/tests/fixtures/templates/courseware.md +351 -0
  89. package/tests/fixtures/tween-visual.json +28 -0
  90. package/tests/fixtures/video-series.json +54 -0
  91. package/tests/md-descriptive.test.ts +742 -0
  92. package/tests/render.test.ts +985 -0
  93. package/tests/schema.test.ts +68 -0
  94. package/tests/server.test.ts +308 -0
  95. package/tests/utils.ts +391 -0
  96. package/tests/vitest.config.ts +18 -0
  97. package/tests/vitest.integration.config.ts +16 -0
  98. package/tsconfig.json +20 -0
@@ -0,0 +1,891 @@
1
+ /**
2
+ * Async pre-pass resolvers for the descriptive compiler.
3
+ *
4
+ * - resolveMediaDurations: probe actual video/audio duration via ffprobe
5
+ * - resolveScripts: TTS each `script` to audio, STT to VTT, attach as children
6
+ * - resolveIncludes: recursively resolve included descriptive markdown files
7
+ *
8
+ * These run BEFORE compileDescriptiveRoot() so the synchronous compiler
9
+ * has complete duration and renderable children.
10
+ */
11
+ import { execSync } from "node:child_process";
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
13
+ import { createHash } from "node:crypto";
14
+ import { join, dirname, resolve as resolvePath, relative } from "node:path";
15
+ import {
16
+ generateTTS,
17
+ generateSTT,
18
+ generateTTI,
19
+ generateTTV,
20
+ DEFAULT_TTS_CLI,
21
+ DEFAULT_STT_CLI,
22
+ DEFAULT_TTI_CLI,
23
+ DEFAULT_TTV_CLI,
24
+ } from "../render/cli-tools";
25
+ import { walkDown } from "../utils";
26
+ import type { DescriptiveNode, DescriptiveRoot } from "./compiler";
27
+ import { compileDescriptiveRoot, parseImportsBlock, extractDependencySpecs, resolveTemplateVars, resolveVariantOverrides } from "./compiler";
28
+ import { parseMarkdownDescriptive, parseMarkdownVariants } from "./markdown";
29
+
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
+ }
38
+
39
+ function computeCacheKey(parts: Record<string, unknown>): string {
40
+ const json = JSON.stringify(parts);
41
+ return createHash("sha1").update(json).digest("hex").slice(0, 12);
42
+ }
43
+
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
+ // ── Media Duration ─────────────────────────────────────────────────────────
88
+
89
+ /** First N words of a string, safe for log labels. */
90
+ function firstWords(text: string, n: number): string {
91
+ if (!text) return "";
92
+ return text.split(/\s+/).slice(0, n).join(" ").replace(/[^a-zA-Z0-9\u4e00-\u9fff\s-]/g, "").slice(0, 60) || text.slice(0, 60);
93
+ }
94
+
95
+ export interface ResolveMediaOptions {
96
+ /** Base directory for resolving relative src paths (default: cwd) */
97
+ baseDir?: string;
98
+ /** Skip nodes whose src matches this regex */
99
+ skip?: RegExp;
100
+ }
101
+
102
+ /**
103
+ * Probe actual media duration via ffprobe.
104
+ * Returns duration in seconds, or null if probe fails.
105
+ */
106
+ function probeDuration(src: string, baseDir?: string): number | null {
107
+ const absPath = resolveSrc(src, baseDir);
108
+ try {
109
+ const out = execSync(
110
+ `ffprobe -v error -show_entries format=duration -of csv=p=0 "${absPath}"`,
111
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 10_000 },
112
+ ).trim();
113
+ const d = parseFloat(out);
114
+ return Number.isFinite(d) && d > 0 ? d : null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ function resolveSrc(src: string, baseDir?: string): string {
121
+ if (/^(https?:|file:|\/)/.test(src)) return src;
122
+ return resolvePath(baseDir ?? process.cwd(), src);
123
+ }
124
+
125
+ /**
126
+ * Common display resolutions for best-match fallback, ordered by popularity.
127
+ * Used when a pattern-based src (e.g. photo_${width}x${height}.jpg) doesn't
128
+ * have an exact file match — the resolver tries nearby resolutions and picks
129
+ * the one with the closest aspect ratio.
130
+ */
131
+ const COMMON_RESOLUTIONS: Array<{ width: number; height: number }> = [
132
+ { width: 1920, height: 1080 }, // 16:9
133
+ { width: 1080, height: 1920 }, // 9:16
134
+ { width: 1080, height: 1080 }, // 1:1
135
+ { width: 3840, height: 2160 }, // 4K 16:9
136
+ { width: 2560, height: 1440 }, // 2K 16:9
137
+ { width: 1280, height: 720 }, // HD 16:9
138
+ { width: 640, height: 480 }, // 4:3
139
+ ];
140
+
141
+ /**
142
+ * Resolve a potentially pattern-based media src to the best matching file.
143
+ *
144
+ * If `src` doesn't contain pattern placeholders, returns `resolveSrc(src, baseDir)`.
145
+ *
146
+ * If `src` contains placeholders (resolved via `width`/`height`), it:
147
+ * 1. Generates the exact-dimension path
148
+ * 2. If the exact file doesn't exist, tries COMMON_RESOLUTIONS sorted by
149
+ * closest aspect-ratio match
150
+ * 3. Falls back to the exact-dimension path if nothing matches
151
+ *
152
+ * This allows authors to write:
153
+ * image src:photo_${width}x${height}.jpg
154
+ * And have the resolver pick photo_1920x1080.jpg for landscape or
155
+ * photo_1080x1920.jpg for portrait, with fallback to nearby resolutions.
156
+ */
157
+ export function resolveMediaSrc(
158
+ src: string,
159
+ targetWidth: number,
160
+ targetHeight: number,
161
+ baseDir?: string,
162
+ ): string {
163
+ // If no pattern, direct resolve
164
+ if (!src.includes("${")) {
165
+ return resolveSrc(src, baseDir);
166
+ }
167
+
168
+ // Build template context from targets
169
+ const ctx = { width: targetWidth, height: targetHeight, fps: 30, variant: "video" };
170
+
171
+ // Exact match: resolve placeholders to exact target dimensions
172
+ const exactPath = resolveTemplateVars(src, ctx);
173
+ const exactAbs = resolveSrc(exactPath, baseDir);
174
+ if (existsSync(exactAbs)) {
175
+ return exactAbs;
176
+ }
177
+
178
+ // Best-match fallback: try common resolutions sorted by closest aspect ratio
179
+ const targetRatio = targetWidth / targetHeight;
180
+ const scored = COMMON_RESOLUTIONS.map((r) => {
181
+ const ratio = r.width / r.height;
182
+ const score = Math.abs(ratio - targetRatio);
183
+ return { ...r, score };
184
+ }).sort((a, b) => a.score - b.score);
185
+
186
+ for (const res of scored) {
187
+ const candidate = resolveTemplateVars(src, { width: res.width, height: res.height, fps: 30, variant: "video" });
188
+ const candidateAbs = resolveSrc(candidate, baseDir);
189
+ if (existsSync(candidateAbs)) {
190
+ console.log(` 📐 Media src matched: ${candidate} (${res.width}x${res.height}) for target ${targetWidth}x${targetHeight}`);
191
+ return candidateAbs;
192
+ }
193
+ }
194
+
195
+ // No fallback matched — return the exact path (will 404 gracefully)
196
+ console.warn(` ⚠ No matching media file for "${src}" at ${targetWidth}x${targetHeight}`);
197
+ return exactAbs;
198
+ }
199
+
200
+ /**
201
+ * Walk the descriptive tree and resolve pattern-based src fields using
202
+ * best-match resolution. Should be called after resolveAllTemplateVars
203
+ * so that `${width}`/`${height}` are replaced with actual values first.
204
+ */
205
+ export async function resolveMediaSrcs(
206
+ root: DescriptiveRoot,
207
+ options: ResolveMediaOptions = {},
208
+ ): Promise<DescriptiveRoot> {
209
+ const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
210
+ const baseDir = options.baseDir;
211
+ const targetWidth = clone.width ?? 1080;
212
+ const targetHeight = clone.height ?? 1920;
213
+
214
+ walkDown(clone as any, (node) => {
215
+ const n = node as any;
216
+ if (n.src && typeof n.src === "string" && n.src.includes("${")) {
217
+ n.src = resolveMediaSrc(n.src, targetWidth, targetHeight, baseDir);
218
+ }
219
+ if (n.prompt && typeof n.prompt === "string" && n.prompt.includes("${")) {
220
+ n.prompt = resolveTemplateVars(n.prompt, {
221
+ width: targetWidth,
222
+ height: targetHeight,
223
+ fps: clone.fps ?? 30,
224
+ variant: "video",
225
+ });
226
+ }
227
+ });
228
+
229
+ return clone;
230
+ }
231
+
232
+ /**
233
+ * Walk the descriptive tree and fill `duration` on video/audio nodes
234
+ * that lack it, by probing actual media files.
235
+ *
236
+ * For video/audio without startFrom/endAt, also sets startFrom=0 and endAt=duration.
237
+ */
238
+ export async function resolveMediaDurations(
239
+ root: DescriptiveRoot,
240
+ options: ResolveMediaOptions = {},
241
+ ): Promise<DescriptiveRoot> {
242
+ const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
243
+ const baseDir = options.baseDir;
244
+
245
+ walkDown(clone as any, (node) => {
246
+ const n = node as DescriptiveNode;
247
+ if (n.type !== "video" && n.type !== "audio") return;
248
+ if (typeof n.duration === "number" && n.duration > 0) return;
249
+ if (typeof n.endAt === "number") return; // trim already specified
250
+ if (!n.src) return;
251
+ if (options.skip?.test(n.src)) return;
252
+
253
+ const probed = probeDuration(n.src, baseDir);
254
+ if (probed != null) {
255
+ n.duration = probed;
256
+ if (n.startFrom == null) (n as any).startFrom = 0;
257
+ if (n.endAt == null) (n as any).endAt = probed;
258
+ }
259
+ });
260
+
261
+ return clone;
262
+ }
263
+
264
+ // ── Script → TTS → STT ─────────────────────────────────────────────────────
265
+
266
+ export interface ResolveScriptOptions {
267
+ /** Output directory for generated audio files */
268
+ outputDir: string;
269
+ /** CLI template override (default: edge-tts) */
270
+ ttsCli?: string;
271
+ }
272
+
273
+ /**
274
+ * Walk the descriptive tree and for each audio node with a `script` field:
275
+ * 1. Generate TTS audio from script text → set as `src`
276
+ * 2. Remove the `script` field from the node
277
+ *
278
+ * At this point in the pipeline, the parser has already converted all
279
+ * container-level `script` attributes into audio children. This function
280
+ * only needs to find those audio nodes and generate TTS for them.
281
+ *
282
+ * Subtitles are handled separately as a post-compile step that merges
283
+ * per-clip VTTs (with absolute timing) into root.subtitle.src.
284
+ */
285
+ export async function resolveScripts(
286
+ root: DescriptiveRoot,
287
+ options: ResolveScriptOptions,
288
+ ): Promise<DescriptiveRoot> {
289
+ const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
290
+ mkdirSync(options.outputDir, { recursive: true });
291
+ const cache = readCacheManifest(options.outputDir);
292
+ let cacheDirty = false;
293
+
294
+ // Collect all audio nodes that have script text but no src yet
295
+ // Also store their parent scene for per-scene tts resolution
296
+ const allScriptNodes: Array<{ node: any; id: string; parent: any }> = [];
297
+ walkDown(clone as any, (node, parent) => {
298
+ if (node.type !== "audio") return;
299
+ if (!node.script || typeof node.script !== "string") return;
300
+ if (node.src) return; // already has real source
301
+ const id = node.id ?? `audio-${allScriptNodes.length}`;
302
+ allScriptNodes.push({ node, id, parent });
303
+ });
304
+
305
+ if (allScriptNodes.length > 0) {
306
+ console.log(` 🔊 TTS: generating ${allScriptNodes.length} script${allScriptNodes.length > 1 ? "s" : ""}...`);
307
+ }
308
+
309
+ for (const { node, id, parent } of allScriptNodes) {
310
+ // Resolve TTS config: parent scene tts > root tts > options ttsCli > default
311
+ const ttsCli = parent?.tts ?? clone.tts ?? options.ttsCli ?? DEFAULT_TTS_CLI;
312
+
313
+ // Content-addressed filename: hash of script + CLI, so identical scripts
314
+ // across different source files share the same audio file.
315
+ const cacheKey = computeCacheKey({ script: node.script, cli: ttsCli });
316
+ const audioPath = join(options.outputDir, `${cacheKey}.mp3`);
317
+
318
+ // Check cache — skip TTS if script + config unchanged AND audio file exists
319
+ const cached = checkCache(cache, `tts:${cacheKey}`, cacheKey);
320
+ let generated: string;
321
+ const label = firstWords(node.script, 8);
322
+ if (cached) {
323
+ generated = cached;
324
+ console.log(` ✓ TTS: ${label} (cached)`);
325
+ } else {
326
+ generated = generateTTS(node.script, audioPath, ttsCli);
327
+ if (generated) {
328
+ updateCache(cache, `tts:${cacheKey}`, cacheKey, generated);
329
+ cacheDirty = true;
330
+ console.log(` ✓ TTS: ${label}`);
331
+ } else {
332
+ console.warn(` ⚠ TTS produced no audio for "${label}". Audio will have no source. Check root.tts config.`);
333
+ }
334
+ }
335
+ if (!generated) continue;
336
+
337
+ // Set resolved src (normalize to absolute for reliable probing later)
338
+ node.src = resolvePath(generated);
339
+ delete node.script;
340
+ }
341
+
342
+ if (cacheDirty) writeCacheManifest(options.outputDir, cache);
343
+ if (allScriptNodes.length > 0) {
344
+ try { writeFileSync(join(options.outputDir, ".cache.json"), JSON.stringify(cache, null, 2), "utf-8"); } catch {}
345
+ const unique = new Set(allScriptNodes.filter(s => s.node.src).map(s => s.node.src)).size;
346
+ console.log(` ✅ TTS: ${unique} unique audio file${unique > 1 ? "s" : ""} (${allScriptNodes.length} node${allScriptNodes.length > 1 ? "s" : ""})`);
347
+ }
348
+ return clone;
349
+ }
350
+
351
+ /**
352
+ * Post-compile: walk the compiled stream tree, find all audio nodes whose
353
+ * source was TTS-generated (relative path inside outputDir), run STT on each,
354
+ * and merge the resulting per-clip VTTs into a single root.subtitle VTT with
355
+ * absolute timestamps derived from the audio node's action start time.
356
+ *
357
+ * Sets root.subtitle.src on the returned root.
358
+ */
359
+ export async function resolveSubtitles(
360
+ root: DescriptiveRoot,
361
+ options: {
362
+ outputDir: string;
363
+ sttCli?: string;
364
+ /** Compiled stream tree with action start times, used for correct offset calculation. */
365
+ compiled?: { children?: any[] };
366
+ /** Separate output directory for the merged subtitles.vtt.
367
+ * When set, per-clip VTTs stay in outputDir (shared cache) while
368
+ * the merged VTT goes here (e.g., per-variant). Defaults to outputDir. */
369
+ mergedOutputDir?: string;
370
+ },
371
+ ): Promise<DescriptiveRoot> {
372
+ const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
373
+ const sttCli = clone.stt ?? options.sttCli ?? DEFAULT_STT_CLI;
374
+ if (!sttCli) return clone;
375
+
376
+ mkdirSync(options.outputDir, { recursive: true });
377
+ const cache = readCacheManifest(options.outputDir);
378
+ let cacheDirty = false;
379
+
380
+ // Collect { audioSrc, absoluteOffset } from the compiled tree
381
+ const clips: Array<{ audioSrc: string; offset: number }> = [];
382
+
383
+ /**
384
+ * Walk an array of sibling nodes, tracking cumulative offset for series layouts.
385
+ *
386
+ * In the compiled stream tree, container nodes (Folder/Scene) don't carry `actions`
387
+ * — their position on the timeline is implicit in the `<Series>`/`<TransitionSeries>`
388
+ * renderer which plays each child sequentially based on `durationInSeconds`.
389
+ * This function replicates that logic to compute absolute audio offsets.
390
+ */
391
+ function walkSiblings(
392
+ nodes: any[],
393
+ parentOffset: number,
394
+ parentIsSeries: boolean,
395
+ parentTransition?: string,
396
+ parentTransitionTime?: number,
397
+ ): void {
398
+ let seriesOffset = parentOffset;
399
+ for (const node of nodes) {
400
+ // Skip background children — they are rendered outside the series as
401
+ // parallel overlays (see FolderLeaf), so they don't advance the offset.
402
+ if (node.isBackground) continue;
403
+
404
+ // In a series, each child starts after all previous siblings' durations.
405
+ // In parallel, all children share the parent offset.
406
+ const nodeStart = parentIsSeries ? seriesOffset : parentOffset;
407
+ // Leaf nodes (video/audio/image/component) carry an action start offset
408
+ // relative to the container start (e.g., parallel layout with staggered start).
409
+ const actionStart = node.actions?.[0]?.start ?? 0;
410
+ const effectiveOffset = nodeStart + actionStart;
411
+
412
+ if (node.type === "audio" && node.src) {
413
+ clips.push({ audioSrc: node.src, offset: effectiveOffset });
414
+ }
415
+
416
+ // Recurse into children — determine their layout from the node type
417
+ if (node.children && node.children.length > 0) {
418
+ let childIsSeries = false;
419
+ let childTransition = undefined as string | undefined;
420
+ let childTransitionTime = 0.5;
421
+
422
+ if (node.type === "folder") {
423
+ childIsSeries = node.isSeries ?? false;
424
+ childTransition = node.transition;
425
+ childTransitionTime = node.transitionTime ?? 0.5;
426
+ } else if (node.type === "scene") {
427
+ // A parallel scene has direct leaf children.
428
+ // A series/transitionSeries scene wraps its children in a single Folder child.
429
+ if (
430
+ node.children.length === 1 &&
431
+ node.children[0].type === "folder" &&
432
+ node.children[0].isSeries
433
+ ) {
434
+ childIsSeries = true;
435
+ childTransition = node.children[0].transition;
436
+ childTransitionTime = node.children[0].transitionTime ?? 0.5;
437
+ }
438
+ }
439
+
440
+ walkSiblings(node.children, effectiveOffset, childIsSeries, childTransition, childTransitionTime);
441
+ }
442
+
443
+ // In a series, advance the offset by this node's duration
444
+ // so the next sibling starts after this one finishes.
445
+ if (parentIsSeries && node.durationInSeconds) {
446
+ const overlap = parentTransition ? (parentTransitionTime ?? 0.5) : 0;
447
+ seriesOffset += node.durationInSeconds - overlap;
448
+ }
449
+ }
450
+ }
451
+
452
+ // Walk the compiled tree (with actions) for correct absolute offsets;
453
+ // fall back to the descriptive tree if no compiled tree provided.
454
+ const treeToWalk = options.compiled ?? (clone as any);
455
+ walkSiblings(
456
+ (treeToWalk as any).children ?? [],
457
+ 0,
458
+ (treeToWalk as any).isSeries ?? false,
459
+ (treeToWalk as any).transition,
460
+ (treeToWalk as any).transitionTime,
461
+ );
462
+
463
+ // Run STT and collect VTT cues with absolute timestamps
464
+ const mergedLines: string[] = ["WEBVTT", ""];
465
+ let cueIndex = 1;
466
+
467
+ let sttFailedCount = 0;
468
+
469
+ for (const { audioSrc, offset } of clips) {
470
+ // Cache key: audio hash + STT CLI string
471
+ const audioHash = existsSync(audioSrc)
472
+ ? createHash("sha1").update(readFileSync(audioSrc)).digest("hex").slice(0, 12)
473
+ : audioSrc;
474
+ const sttCacheKey = computeCacheKey({ audioHash, cli: sttCli });
475
+ const sttKey = `stt:${audioSrc.split("/").pop()}`;
476
+ const clipName = audioSrc.split("/").pop()!;
477
+
478
+ let vttPath: string | null = null;
479
+ // Cache per-clip STT by audio hash + CLI (individual VTT files are stable)
480
+ const cachedVtt = checkCache(cache, sttKey, sttCacheKey);
481
+ if (cachedVtt) {
482
+ vttPath = cachedVtt;
483
+ } else {
484
+ try {
485
+ await generateSTT(audioSrc, options.outputDir, sttCli);
486
+ // Find generated VTT file
487
+ const base = audioSrc.replace(/\.wav$/, "").replace(/\.mp3$/, "");
488
+ const name = base.split("/").pop()!;
489
+ const candidate = join(options.outputDir, `${name}.vtt`);
490
+ if (existsSync(candidate)) {
491
+ vttPath = candidate;
492
+ updateCache(cache, sttKey, sttCacheKey, vttPath);
493
+ cacheDirty = true;
494
+ }
495
+ } catch {
496
+ console.warn(` ⚠ STT failed for ${clipName}. Install whisper (pip install openai-whisper) or set root.stt.`);
497
+ sttFailedCount++;
498
+ }
499
+ }
500
+
501
+ if (!vttPath || !existsSync(vttPath)) continue;
502
+ const vttText = readFileSync(vttPath, "utf-8");
503
+ const blocks = vttText.replace(/\r\n/g, "\n").split(/\n\n+/);
504
+ for (const block of blocks) {
505
+ const lines = block.split("\n").filter(Boolean);
506
+ const tline = lines.find((l) => l.includes("-->"));
507
+ if (!tline) continue;
508
+ const [a, z] = tline.split("-->").map((s) => s.trim());
509
+ if (!a || !z) continue;
510
+ const toSec = (ts: string) => {
511
+ const parts = ts.split(":").map(Number);
512
+ if (parts.length === 3) return parts[0]! * 3600 + parts[1]! * 60 + parts[2]!;
513
+ return parts[0]! * 60 + parts[1]!;
514
+ };
515
+ const formatSec = (s: number) => {
516
+ const h = Math.floor(s / 3600);
517
+ const m = Math.floor((s % 3600) / 60);
518
+ const sec = (s % 60).toFixed(3).padStart(6, "0");
519
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${sec}`;
520
+ };
521
+ const text = lines.slice(lines.indexOf(tline) + 1).join("\n").trim();
522
+ mergedLines.push(String(cueIndex++));
523
+ mergedLines.push(`${formatSec(toSec(a) + offset)} --> ${formatSec(toSec(z) + offset)}`);
524
+ mergedLines.push(text, "");
525
+ }
526
+ }
527
+
528
+ if (clips.length > 0) {
529
+ const transcribed = clips.length - sttFailedCount;
530
+ if (transcribed > 0 || sttFailedCount > 0) {
531
+ console.log(` 📝 STT: ${transcribed} transcribed${sttFailedCount > 0 ? `, ${sttFailedCount} failed` : ""}`);
532
+ }
533
+ }
534
+
535
+ if (cueIndex > 1) {
536
+ const mergedDir = options.mergedOutputDir ?? options.outputDir;
537
+ mkdirSync(mergedDir, { recursive: true });
538
+ const mergedPath = join(mergedDir, "subtitles.vtt");
539
+ writeFileSync(mergedPath, mergedLines.join("\n"), "utf-8");
540
+ clone.subtitle = { ...(clone.subtitle ?? {}), src: mergedPath };
541
+ console.log(` ✅ STT: subtitles ready (${cueIndex - 1} cues)`);
542
+ }
543
+
544
+ if (cacheDirty) writeCacheManifest(options.outputDir, cache);
545
+ return clone;
546
+ }
547
+
548
+ // ── Image & Video Generation (TTI / TTV) ──────────────────────────────────
549
+
550
+ export interface ResolveGeneratedMediaOptions {
551
+ /** Output directory for generated media files */
552
+ outputDir: string;
553
+ /** Default TTI CLI template (overrides root.tti) */
554
+ ttiCli?: string;
555
+ /** Default TTV CLI template (overrides root.ttv) */
556
+ ttvCli?: string;
557
+ }
558
+
559
+ /**
560
+ * Walk the descriptive tree, find image/video nodes with `prompt` but no `src`,
561
+ * run the configured TTI/TTV CLI to generate media, and set `src` to the output.
562
+ *
563
+ * Images: prompt → generate .png via TTI CLI
564
+ * Videos: prompt → generate .mp4 via TTV CLI
565
+ *
566
+ * Both support content-hash caching (same prompt + same config → reuse).
567
+ */
568
+ export async function resolveGeneratedMedia(
569
+ root: DescriptiveRoot,
570
+ options: ResolveGeneratedMediaOptions,
571
+ ): Promise<DescriptiveRoot> {
572
+ const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
573
+ mkdirSync(options.outputDir, { recursive: true });
574
+ const cache = readCacheManifest(options.outputDir);
575
+ let cacheDirty = false;
576
+
577
+ // Collect all image/video nodes that have prompt but no src
578
+ const genNodes: Array<{
579
+ node: any;
580
+ id: string;
581
+ type: "image" | "video";
582
+ prompt: string;
583
+ }> = [];
584
+
585
+ walkDown(clone as any, (node) => {
586
+ if ((node.type !== "image" && node.type !== "video")) return;
587
+ if (!node.prompt || typeof node.prompt !== "string") return;
588
+ // Skip if src is already set (prompt is just metadata)
589
+ if (node.src) return;
590
+ const id = node.id ?? `${node.type}-${genNodes.length}`;
591
+ genNodes.push({ node, id, type: node.type as "image" | "video", prompt: node.prompt });
592
+ });
593
+
594
+ for (const { node, id, type, prompt } of genNodes) {
595
+ const ext = type === "image" ? "png" : "mp4";
596
+
597
+ // Resolve TTI/TTV config: root-level config overrides CLI defaults
598
+ const cli = type === "image"
599
+ ? (clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI)
600
+ : (clone.ttv ?? options.ttvCli ?? DEFAULT_TTV_CLI);
601
+
602
+ // Content-addressed filename: hash of prompt + CLI + type, so identical
603
+ // prompts across different source files share the same generated media.
604
+ const cacheKey = computeCacheKey({ prompt, cli, type });
605
+ const outputPath = join(options.outputDir, `${cacheKey}.${ext}`);
606
+
607
+ const cached = checkCache(cache, `gen:${cacheKey}`, cacheKey);
608
+ const label = type === "image" ? "TTI" : "TTV";
609
+ const labelText = firstWords(prompt, 8);
610
+
611
+ if (cached) {
612
+ node.src = resolvePath(cached);
613
+ console.log(` ✓ ${label}: ${labelText} (cached)`);
614
+ continue;
615
+ }
616
+
617
+ try {
618
+ console.log(` 🔊 ${label}: ${labelText}...`);
619
+ const ttiCmd = clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI;
620
+ const result = type === "image"
621
+ ? generateTTI(prompt, outputPath, cli)
622
+ : generateTTV(prompt, outputPath, cli, ttiCmd);
623
+ if (result) {
624
+ node.src = outputPath;
625
+ updateCache(cache, `gen:${cacheKey}`, cacheKey, outputPath);
626
+ cacheDirty = true;
627
+ console.log(` ✓ ${label}: ${labelText}`);
628
+ } else {
629
+ const hint = cli.includes("echo")
630
+ ? `No ${label} tool installed. The default CLI just echoes a message — set root.${type === "image" ? "tti" : "ttv"} to a real generation command.`
631
+ : `The command ran but produced no output file. Check the CLI template or run the script manually to debug.`;
632
+ console.error(` ⚠ ${label}: "${labelText}" produced no output. ${hint}`);
633
+ }
634
+ } catch (err: any) {
635
+ const hint = (err as any)?.stderr?.toString()?.includes("not found")
636
+ ? `${label} tool not found. Install the required CLI or configure root.${type === "image" ? "tti" : "ttv"}.`
637
+ : `Command failed. Try running the CLI template directly to debug: ${cli}`;
638
+ console.error(` ✗ ${label}: "${labelText}" failed — ${err.message}. ${hint}`);
639
+ }
640
+ }
641
+
642
+ if (cacheDirty) writeCacheManifest(options.outputDir, cache);
643
+ return clone;
644
+ }
645
+
646
+ // ── Combined Pipeline ──────────────────────────────────────────────────────
647
+
648
+ export interface ResolveAllOptions extends ResolveMediaOptions {
649
+ /** If set, enables script → TTS resolution */
650
+ scriptOutputDir?: string;
651
+ /** TTS CLI template override (default: edge-tts). Overrides root.tts. */
652
+ ttsCli?: string;
653
+ /** STT CLI template override (default: whisper). Overrides root.stt. */
654
+ sttCli?: string;
655
+ /** If set, enables TTI/TTV media generation from prompts */
656
+ mediaOutputDir?: string;
657
+ /** TTI CLI template override (default: pi agent). Overrides root.tti. */
658
+ ttiCli?: string;
659
+ /** TTV CLI template override (default: pi agent). Overrides root.ttv. */
660
+ ttvCli?: string;
661
+ /** If set, resolve includes. Directory where pre-compiled include JSON files are stored. */
662
+ includeOutputDir?: string;
663
+ /** Separate output dir for the merged subtitles.vtt.
664
+ * Per-clip VTTs stay in scriptOutputDir (shared cache) while the merged
665
+ * VTT goes here (e.g., per-variant). Defaults to scriptOutputDir. */
666
+ subtitleOutputDir?: string;
667
+ /** Variant chain to apply to included sub-videos (e.g. ["zh", "tiktok"]).
668
+ * When set, include nodes parse their target .md files with variant awareness
669
+ * and apply variant overrides (zh-src → src, bare `zh` key → primary content). */
670
+ variants?: string[];
671
+ }
672
+
673
+ /**
674
+ * Recursively resolve include nodes in the descriptive tree.
675
+ *
676
+ * For each `include` node with a `.md` src:
677
+ * 1. Read and parse the referenced markdown file
678
+ * 2. Recursively resolve it (media durations, TTS, STT, nested includes)
679
+ * 3. Compile it to a stream tree Root
680
+ * 4. Write the compiled Root to `includeOutputDir/<hash>.json`
681
+ * 5. Write a companion `.meta.json` file with import entries for the server
682
+ * 6. Update the include node's `src` to the compiled JSON path
683
+ * 7. Stamp `durationInSeconds` with the sub-video's real duration
684
+ *
685
+ * After this step, the descriptive tree is fully resolved: all includes point
686
+ * to pre-compiled JSON files with accurate durations. The server can then
687
+ * bundle per-subvideo component imports using the companion meta files.
688
+ */
689
+ export async function resolveIncludes(
690
+ root: DescriptiveRoot,
691
+ options: ResolveAllOptions = {},
692
+ ): Promise<DescriptiveRoot> {
693
+ const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
694
+ const baseDir = options.baseDir ?? process.cwd();
695
+ const outputDir = options.includeOutputDir ?? join(baseDir, ".markcut", "generated", "includes");
696
+ mkdirSync(outputDir, { recursive: true });
697
+
698
+ // Extract imports block from raw markdown source (reusable helper)
699
+ function extractImportEntriesFromRaw(raw: string): {
700
+ entries: Array<{ name: string; from?: string; exports?: string }> | null;
701
+ extraSpecs: string[];
702
+ rawSource: string | null;
703
+ } {
704
+ const match = raw.match(/^(```|~~~)\s*js imports\s*\n([\s\S]*?)^\1\s*$/m);
705
+ if (!match) return { entries: null, extraSpecs: [], rawSource: null };
706
+ const src = match[2]!;
707
+ const entries = parseImportsBlock(src);
708
+ const extraSpecs = extractDependencySpecs(src);
709
+ return { entries, extraSpecs, rawSource: src };
710
+ }
711
+
712
+ async function resolveOneInclude(
713
+ includeNode: DescriptiveNode,
714
+ parentBaseDir: string,
715
+ ): Promise<void> {
716
+ const n = includeNode as any;
717
+ if (n.type !== "include") return;
718
+ const src = n.src;
719
+ if (!src || !src.endsWith(".md")) return;
720
+
721
+ // Resolve the path
722
+ const absPath = src.startsWith("/") ? src : resolvePath(parentBaseDir, src);
723
+ if (!existsSync(absPath)) {
724
+ console.warn(` ⚠ Include "${src}" not found at ${absPath}`);
725
+ return;
726
+ }
727
+
728
+ // Read and parse
729
+ const raw = readFileSync(absPath, "utf-8");
730
+ let subRoot = parseMarkdownDescriptive(raw);
731
+
732
+ // Apply variant overrides to the sub-video if a variant chain is active
733
+ const variantChain = options.variants;
734
+ if (variantChain && variantChain.length > 0) {
735
+ const parsed = parseMarkdownVariants(raw);
736
+ subRoot = parsed.base;
737
+ // Merge root config from the first variant's section
738
+ const variantRoot = parsed.variants.get(variantChain[0]!);
739
+ if (variantRoot) {
740
+ const { children: _, ...configOverrides } = variantRoot;
741
+ subRoot = { ...subRoot, ...configOverrides };
742
+ }
743
+ subRoot = resolveVariantOverrides(subRoot, variantChain);
744
+ }
745
+
746
+ // Extract import info from the included file (for the server to bundle later)
747
+ const { entries: importEntries, extraSpecs, rawSource } = extractImportEntriesFromRaw(raw);
748
+
749
+ // Recursively resolve the sub-root (this handles nested includes too)
750
+ // Use the same output dir so all compiled includes are co-located.
751
+ // Pass the variant chain so nested includes also get variant overrides.
752
+ const resolved = await resolveAll(subRoot, {
753
+ ...options,
754
+ includeOutputDir: outputDir,
755
+ baseDir: dirname(absPath),
756
+ variants: variantChain,
757
+ });
758
+
759
+ // Compile to stream tree
760
+ const compiled = compileDescriptiveRoot(resolved);
761
+
762
+ // Determine duration
763
+ const dur = compiled.durationInSeconds ?? compiled.children?.reduce(
764
+ (max: number, c: any) => Math.max(max, c.durationInSeconds ?? 0), 0
765
+ ) ?? 3;
766
+
767
+ // Create a stable filename from the absolute path
768
+ const hash = createHash("sha1").update(absPath).digest("hex").slice(0, 12);
769
+ const compiledPath = join(outputDir, `${hash}.json`);
770
+ const metaPath = join(outputDir, `${hash}.meta.json`);
771
+
772
+ // Write companion meta file if there are imports
773
+ if (importEntries && importEntries.length > 0) {
774
+ const sourceName = absPath.split("/").pop().replace(/\.[^.]+$/, "");
775
+ const meta = { importEntries, extraSpecs, rawSource, sourceName };
776
+ writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf-8");
777
+ }
778
+
779
+ // Write compiled JSON (without imports field — the server will set it after bundling)
780
+ const compiledJson = {
781
+ root: {
782
+ ...compiled,
783
+ // Include a stub field so the server knows this needs bundling
784
+ ...(importEntries && importEntries.length > 0 ? { _pendingImports: true } : {}),
785
+ },
786
+ };
787
+ writeFileSync(compiledPath, JSON.stringify(compiledJson, null, 2), "utf-8");
788
+
789
+ // Update the include node
790
+ n.src = compiledPath;
791
+ n.durationInSeconds = dur;
792
+ n.duration = dur;
793
+ // Remove children if any (the sub-video is now an external reference)
794
+ delete n.children;
795
+
796
+ console.log(` 🔗 Include resolved: ${src} → ${relative(process.cwd(), compiledPath)} (${dur.toFixed(1)}s)`);
797
+ }
798
+
799
+ // Walk the tree and resolve includes. We do a multi-pass approach:
800
+ // first collect all includes, then resolve them (to avoid mutation during iteration).
801
+ const includes: Array<{ node: DescriptiveNode; baseDir: string }> = [];
802
+ walkDown(clone as any, (node, parent) => {
803
+ if ((node as any).type === "include" && (node as any).src?.endsWith(".md")) {
804
+ includes.push({ node: node as DescriptiveNode, baseDir: baseDir });
805
+ }
806
+ });
807
+
808
+ for (const inc of includes) {
809
+ await resolveOneInclude(inc.node, inc.baseDir);
810
+ }
811
+
812
+ return clone;
813
+ }
814
+
815
+ /**
816
+ * Run all pre-pass resolvers in the correct order:
817
+ * 0. Resolve includes (pre-compile referenced .md files to JSON)
818
+ * 1. Resolve template variables (${width}, ${height}, etc. in src/prompt)
819
+ * 2. Best-match media src resolution (pattern-based src → closest existing file)
820
+ * 3. Generated media (TTI/TTV) — resolve image/video prompts to actual files
821
+ * 4. Media duration probing
822
+ * 5. Script → TTS (audio only) — uses root.tts / scene.tts / CLI options
823
+ * 6. Post-compile: STT → VTT (subtitle) — uses root.stt / CLI options
824
+ *
825
+ * Step 0 runs first so that sub-video durations are known before duration
826
+ * probing of the parent tree.
827
+ */
828
+ export async function resolveAll(
829
+ root: DescriptiveRoot,
830
+ options: ResolveAllOptions = {},
831
+ ): Promise<DescriptiveRoot> {
832
+ let result = root;
833
+
834
+ // Step 0: Resolve includes (pre-compile referenced .md files to JSON with accurate durations)
835
+ result = await resolveIncludes(result, options);
836
+
837
+ // Step 1: Resolve template variables (${width}, ${height}, ${fps}, ${variant}) in all string fields.
838
+ // Uses root's current width/height/fps. This happens before media resolution
839
+ // so that pattern-based src/prompt values are resolved with actual dimensions.
840
+ const { resolveAllTemplateVars } = await import("./compiler");
841
+ result = resolveAllTemplateVars(result, {
842
+ width: result.width ?? 1080,
843
+ height: result.height ?? 1920,
844
+ fps: result.fps ?? 30,
845
+ variant: options.variants?.[0] ?? "video",
846
+ });
847
+
848
+ // Step 2: Best-match media src resolution — for pattern-based src values
849
+ // (e.g. photo_${width}x${height}.jpg), find the closest existing file.
850
+ result = await resolveMediaSrcs(result, { baseDir: options.baseDir });
851
+
852
+ // Step 3: Generate images/videos from prompts before probing durations
853
+ if (options.mediaOutputDir) {
854
+ result = await resolveGeneratedMedia(result, {
855
+ outputDir: options.mediaOutputDir,
856
+ ttiCli: options.ttiCli,
857
+ ttvCli: options.ttvCli,
858
+ });
859
+ }
860
+
861
+ // Step 4: Media duration probing
862
+ result = await resolveMediaDurations(result, {
863
+ baseDir: options.baseDir,
864
+ skip: options.skip,
865
+ });
866
+
867
+ // Step 5: Script → TTS
868
+ if (options.scriptOutputDir) {
869
+ result = await resolveScripts(result, {
870
+ outputDir: options.scriptOutputDir,
871
+ ttsCli: options.ttsCli,
872
+ });
873
+
874
+ // Re-probe for newly generated TTS audio durations
875
+ result = await resolveMediaDurations(result, {
876
+ baseDir: options.baseDir,
877
+ skip: options.skip,
878
+ });
879
+
880
+ // Post-compile subtitle generation (uses root.stt, options.sttCli, or default whisper CLI)
881
+ const compiled = compileDescriptiveRoot(result);
882
+ result = await resolveSubtitles(result, {
883
+ outputDir: options.scriptOutputDir,
884
+ sttCli: options.sttCli,
885
+ compiled,
886
+ mergedOutputDir: options.subtitleOutputDir,
887
+ });
888
+ }
889
+
890
+ return result;
891
+ }