@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,628 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry point for the Remotion engine.
4
+ *
5
+ * Pipeline:
6
+ * .md ──[parse]──▶ descriptive ──[resolve]──▶ resolved ──[compile]──▶ stream tree ──[render]──▶ MP4
7
+ * (TTS/STT/durations)
8
+ */
9
+ import { execSync, spawn } from "node:child_process";
10
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
11
+ import { resolve, dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = dirname(__filename);
16
+ const ROOT = resolve(__dirname, "../..");
17
+
18
+ const ASPECTS = {
19
+ "16x9": { width: 1920, height: 1080 },
20
+ "9x16": { width: 1080, height: 1920 },
21
+ "1x1": { width: 1080, height: 1080 },
22
+ };
23
+
24
+ /**
25
+ * How many "Rendered X/Y" lines to skip before printing one.
26
+ * E.g. 50 means print every 50th frame — for 1860 frames that's ~37 lines.
27
+ */
28
+ const PROGRESS_INTERVAL = 50;
29
+
30
+ /** Print an error message to stderr. */
31
+ function emitError(msg) { console.error(`❌ ${msg}`); }
32
+
33
+ /** Print a warning message to stderr. */
34
+ function emitWarn(msg) { console.error(`⚠️ ${msg}`); }
35
+
36
+ /** Print a success message to stderr. */
37
+ function emitSuccess(msg) { console.error(`✅ ${msg}`); }
38
+
39
+ /** Print an info message (stderr, always visible). */
40
+ function emitInfo(msg) { console.error(msg); }
41
+
42
+ function usage() {
43
+ console.log(`
44
+ markcut CLI — Markdown/JSON → video pipeline
45
+
46
+ Commands:
47
+
48
+ compile <file> --output <path> Parse + compile → stream tree JSON (sync, no I/O)
49
+
50
+ verify <file> Parse + validate descriptive file
51
+ --cli Check required CLI tools are installed
52
+
53
+ resolve <file> --output <path> Run async pipeline: TTS, STT, media durations
54
+ --script-output-dir <dir> Directory for generated TTS/STT files
55
+ --media-output-dir <dir> Directory for generated TTI/TTV media files
56
+ --compile Also compile to stream tree after resolving
57
+
58
+ render <file.json|.md> Resolve + compile + render to MP4
59
+ --aspect <16x9|9x16|1x1|all> Aspect ratio (default: 16x9)
60
+ --output <path> Output path (default: out/video-{aspect}.mp4)
61
+ --verbose Show full per-frame progress (default: compact)
62
+
63
+ preview <file.json|.md> Open player with live preview
64
+ --edit Auto-reload on file change
65
+ --label Open label input overlay
66
+ --no-browser Skip opening browser automatically
67
+ --port <num> Port for the player server (default: 3001)
68
+
69
+ vision <folder> Extract metadata into metadata.json
70
+ --label Full pipeline: preview → label → normalize → percept → segments
71
+ --agent <template> Custom text LLM CLI for detect-scenes ({prompt})
72
+ --itt <template> Custom ITT CLI template with {input}, {prompt}
73
+ --vtt <template> Custom VTT CLI template with {input}, {prompt}
74
+ --stt <template> Custom STT CLI template with {input}, {output}
75
+ --context "text" Background context (injected into prompts)
76
+ --<prompt-name> "text" Override a prompt from vision_prompts.md
77
+ `);
78
+ }
79
+
80
+ function parseArgs(argv) {
81
+ const args = { command: "", file: "", aspect: "16x9", output: "", forceNew: false, verbose: false, label: false, edit: false, noBrowser: false, chat: false, port: 3001, compile: false, cli: false, scriptOutputDir: "", mediaOutputDir: "", variant: [] };
82
+ let i = 2;
83
+ if (argv[i]) args.command = argv[i++];
84
+ if (argv[i] && !argv[i].startsWith("--")) args.file = argv[i++];
85
+ while (i < argv.length) {
86
+ const flag = argv[i++];
87
+ if (flag === "--aspect" && argv[i]) args.aspect = argv[i++];
88
+ else if (flag === "--output" && argv[i]) args.output = argv[i++];
89
+ else if (flag === "--script-output-dir" && argv[i]) args.scriptOutputDir = argv[i++];
90
+ else if (flag === "--media-output-dir" && argv[i]) args.mediaOutputDir = argv[i++];
91
+ else if (flag === "--cli") args.cli = true;
92
+ else if (flag === "--compile") args.compile = true;
93
+ else if (flag === "--force-new") args.forceNew = true;
94
+ else if (flag === "--verbose") args.verbose = true;
95
+ else if (flag === "--label") args.label = true;
96
+ else if (flag === "--edit") args.edit = true;
97
+ else if (flag === "--no-browser") args.noBrowser = true;
98
+ else if (flag === "--port" && argv[i]) args.port = parseInt(argv[i], 10);
99
+ else if (flag.startsWith("--port=")) args.port = parseInt(flag.split("=")[1], 10);
100
+ else if (flag === "--variant" && argv[i]) args.variant.push(argv[i++]);
101
+ else if (flag.startsWith("--variant=")) args.variant.push(flag.split("=")[1]);
102
+ }
103
+ return args;
104
+ }
105
+
106
+ /**
107
+ * Render one aspect ratio with compact progress output.
108
+ *
109
+ * In compact mode (default), "Rendered X/Y" lines are shown only every
110
+ * PROGRESS_INTERVAL frames and at the final frame, drastically reducing
111
+ * token consumption when output is captured by an LLM agent.
112
+ *
113
+ * Use --verbose to see every frame line (original behavior).
114
+ */
115
+ function renderOne(streamTree, aspect, outputPath, verbose) {
116
+ const dims = ASPECTS[aspect];
117
+ if (!dims) throw new Error(`Unknown aspect: ${aspect}`);
118
+
119
+ const adapted = { ...streamTree, width: dims.width, height: dims.height };
120
+ const tmpProps = join(ROOT, ".tmp", `render-${aspect}.json`);
121
+ mkdirSync(dirname(tmpProps), { recursive: true });
122
+ writeFileSync(tmpProps, JSON.stringify({ root: adapted }));
123
+
124
+ mkdirSync(dirname(outputPath), { recursive: true });
125
+
126
+ console.log(`\n▶ Rendering ${aspect} → ${outputPath}`);
127
+
128
+ return new Promise((resolvePromise, reject) => {
129
+ const proc = spawn("npx", ["remotion", "render", "Root", outputPath, "--props", tmpProps, "--config", "remotion.config.ts"], { cwd: ROOT, stdio: ["ignore", "inherit", "pipe"] });
130
+
131
+ let lastLoggedFrame = 0;
132
+ let totalFrames = 0;
133
+ let lineBuffer = "";
134
+
135
+ // Parse "Rendered X/Y, time remaining: ..." or "Rendered X/Y" lines
136
+ const progressRe = /^Rendered\s+(\d+)\/(\d+)/;
137
+
138
+ proc.stderr.on("data", (chunk) => {
139
+ lineBuffer += chunk.toString();
140
+ const lines = lineBuffer.split("\n");
141
+ // Keep the last (potentially incomplete) segment in the buffer
142
+ lineBuffer = lines.pop() || "";
143
+ for (const line of lines) {
144
+ if (!line) continue;
145
+
146
+ const match = line.match(progressRe);
147
+ if (match) {
148
+ const currentFrame = parseInt(match[1], 10);
149
+ totalFrames = parseInt(match[2], 10);
150
+
151
+ if (verbose) {
152
+ // Original behavior: print every line
153
+ console.error(line);
154
+ } else {
155
+ // Compact mode: print only at intervals + completion
156
+ const isComplete = currentFrame >= totalFrames;
157
+ const intervalElapsed = currentFrame - lastLoggedFrame >= PROGRESS_INTERVAL;
158
+ const isStarting = currentFrame === 0 || (lastLoggedFrame === 0 && currentFrame > 0);
159
+
160
+ if (isComplete || intervalElapsed || isStarting) {
161
+ // Show a compact progress line
162
+ const pct = totalFrames > 0 ? ` (${Math.round((currentFrame / totalFrames) * 100)}%)` : "";
163
+ console.error(` Rendered ${currentFrame}/${totalFrames}${pct}`);
164
+ lastLoggedFrame = currentFrame;
165
+ }
166
+ }
167
+ } else {
168
+ // Non-progress line — always print (bundle progress, errors, metadata)
169
+ console.error(line);
170
+ }
171
+ }
172
+ });
173
+
174
+ proc.on("error", (err) => {
175
+ reject(err);
176
+ });
177
+
178
+ proc.on("exit", (code) => {
179
+ // Flush any remaining line in the buffer (non-progress lines only)
180
+ if (lineBuffer && !lineBuffer.match(progressRe)) {
181
+ console.error(lineBuffer);
182
+ }
183
+ if (code === 0) {
184
+ console.log(`✓ ${outputPath}`);
185
+ resolvePromise();
186
+ } else {
187
+ reject(new Error(`Remotion exited with code ${code}`));
188
+ }
189
+ });
190
+ });
191
+ }
192
+
193
+ async function main() {
194
+ const args = parseArgs(process.argv);
195
+
196
+ if (!args.command || args.command === "help" || args.command === "--help") {
197
+ usage();
198
+ process.exit(0);
199
+ }
200
+
201
+ if (args.command === "vision") {
202
+ const { main: visionMain } = await import("../vision/cli.mjs");
203
+ await visionMain(process.argv);
204
+ process.exit(0);
205
+ }
206
+
207
+ if (args.command === "preview") {
208
+ // Auto-detect: markdown files must use the player server (not Remotion Studio)
209
+ const isMarkdown = args.file?.endsWith(".md");
210
+ if (isMarkdown && !args.edit && !args.label) {
211
+ args.edit = true;
212
+ }
213
+
214
+ if (args.label || args.edit) {
215
+ const playerServer = args.label
216
+ ? join(__dirname, "..", "player", "label-server.mjs")
217
+ : join(__dirname, "..", "player", "server.mjs");
218
+ if (!existsSync(playerServer)) {
219
+ console.error("Player server not found at", playerServer);
220
+ process.exit(1);
221
+ }
222
+ const modeFlags = args.noBrowser ? "--no-browser" : "";
223
+ const editFlag = args.edit ? "--edit" : "";
224
+ const portFlag = `--port=${args.port || 3001}`;
225
+ const fileFlag = args.file || join(ROOT, "video.json");
226
+ const port = args.port || 3001;
227
+ // Pass variant flags to the server
228
+ const variantFlags = args.variant.map(v => `--variant=${v}`);
229
+ const serverArgs = [playerServer, resolve(fileFlag), modeFlags, editFlag, `--port=${port}`, ...variantFlags].filter(Boolean);
230
+ const child = spawn("node", serverArgs, { cwd: ROOT, stdio: ["ignore", "pipe", "inherit"] });
231
+ let serverReady = false;
232
+ let stdoutBuffer = "";
233
+ // Forward stdout and open browser once server is ready
234
+ if (child.stdout) {
235
+ child.stdout.on("data", (chunk) => {
236
+ process.stdout.write(chunk);
237
+ if (!serverReady && !args.noBrowser) {
238
+ stdoutBuffer += chunk.toString();
239
+ if (stdoutBuffer.includes("Player ready") || stdoutBuffer.includes("Label Preview")) {
240
+ serverReady = true;
241
+
242
+ try {
243
+ execSync(`open http://localhost:${port}`, { stdio: "ignore" });
244
+ } catch {}
245
+ }
246
+ }
247
+ });
248
+ }
249
+ child.on("exit", (code) => process.exit(code ?? 0));
250
+ // Keep running until killed
251
+ process.on("SIGINT", () => { child.kill(); process.exit(0); });
252
+ process.on("SIGTERM", () => { child.kill(); process.exit(0); });
253
+ return;
254
+ }
255
+
256
+ // Default: open Remotion Studio (JSON files only)
257
+ const propsFlag = args.file ? `--props="${resolve(args.file)}"` : "";
258
+ const forceNewFlag = args.forceNew ? "--force-new" : "";
259
+ const cmd = `npx remotion studio --config=remotion.config.ts ${propsFlag} ${forceNewFlag}`;
260
+ execSync(cmd, { cwd: ROOT, stdio: "inherit" });
261
+ process.exit(0);
262
+ }
263
+
264
+ if (args.command === "render") {
265
+ let streamTree;
266
+
267
+ if (args.file) {
268
+ const filePath = resolve(args.file);
269
+ const raw = readFileSync(filePath, "utf-8");
270
+
271
+ // Helper: all generated artifacts live under .markcut/generated/
272
+ function generatedDir(filePath, sub) {
273
+ return join(dirname(filePath), ".markcut", "generated", sub);
274
+ }
275
+
276
+ // Helper: per-variant output directory for subtitles and variant-specific artifacts
277
+ function variantDir(filePath) {
278
+ const base = dirname(filePath);
279
+ const basename = filePath.split("/").pop().replace(/\.[^.]+$/, "");
280
+ const variantLabel = args.variant && args.variant.length > 0 ? args.variant.join("-") : "";
281
+ return variantLabel
282
+ ? join(base, ".markcut", basename, variantLabel)
283
+ : join(base, ".markcut", basename);
284
+ }
285
+
286
+ // Helper: resolve variants from the parsed descriptive root
287
+ async function resolveWithVariants(descriptive, options) {
288
+ const { resolveVariantOverrides, compileDescriptiveRoot, resolveAll } = await import("../player/pipeline.mjs");
289
+ const { parseMarkdownDescriptive } = await import("../player/pipeline.mjs");
290
+
291
+ let resolved = descriptive;
292
+
293
+ // If variants are specified, apply variant-prefixed overrides
294
+ if (args.variant && args.variant.length > 0) {
295
+ // Also apply root config overrides from variant sections
296
+ const parsed = parseMarkdownDescriptive(raw);
297
+ const variantRoot = parsed.variants.get(args.variant[0]);
298
+ if (variantRoot) {
299
+ // Merge variant root config into base (tts, stt, width, height, etc.)
300
+ const { variant, children: _, ...configOverrides } = variantRoot;
301
+ resolved = { ...resolved, ...configOverrides };
302
+ }
303
+ // Apply leaf-level variant overrides (zh-src → src, etc.)
304
+ resolved = resolveVariantOverrides(resolved, args.variant);
305
+ }
306
+
307
+ const final = await resolveAll(resolved, options);
308
+ return compileDescriptiveRoot(final);
309
+ }
310
+
311
+ if (filePath.endsWith(".md")) {
312
+ const { parseMarkdownDescriptive } = await import("../player/pipeline.mjs");
313
+ const fileDir = dirname(filePath);
314
+ const parsed = parseMarkdownDescriptive(raw);
315
+ streamTree = await resolveWithVariants(parsed.base, {
316
+ baseDir: fileDir,
317
+ scriptOutputDir: generatedDir(filePath, "tts"),
318
+ mediaOutputDir: generatedDir(filePath, "media"),
319
+ includeOutputDir: generatedDir(filePath, "includes"),
320
+ subtitleOutputDir: variantDir(filePath),
321
+ });
322
+ } else {
323
+ const parsed = JSON.parse(raw);
324
+ const root = parsed.root ?? parsed;
325
+ const { isDescriptiveRoot, resolveAndCompile } = await import("../player/pipeline.mjs");
326
+ if (isDescriptiveRoot(root)) {
327
+ const fileDir = dirname(filePath);
328
+ streamTree = await resolveAndCompile(root, {
329
+ baseDir: fileDir,
330
+ scriptOutputDir: generatedDir(filePath, "tts"),
331
+ mediaOutputDir: generatedDir(filePath, "media"),
332
+ includeOutputDir: generatedDir(filePath, "includes"),
333
+ subtitleOutputDir: variantDir(filePath),
334
+ });
335
+ } else {
336
+ streamTree = root;
337
+ }
338
+ }
339
+ } else {
340
+ console.error("Error: provide a stream tree file (.json or .md)");
341
+ process.exit(1);
342
+ }
343
+
344
+ const aspects = args.aspect === "all" ? Object.keys(ASPECTS) : [args.aspect];
345
+
346
+ for (const aspect of aspects) {
347
+ const output = args.output && aspects.length === 1
348
+ ? resolve(args.output)
349
+ : join(ROOT, "out", `video-${aspect}.mp4`);
350
+ await renderOne(streamTree, aspect, output, args.verbose);
351
+ }
352
+
353
+ console.log("\n✅ All renders complete.");
354
+ process.exit(0);
355
+ }
356
+
357
+ /** Check if any audio node in the tree has a script field (needs TTS). */
358
+ function hasScript(root) {
359
+ let found = false;
360
+ function walk(nodes) {
361
+ for (const n of nodes) {
362
+ if (n.type === "audio" && n.script) { found = true; return; }
363
+ if (n.children) walk(n.children);
364
+ }
365
+ }
366
+ walk(root.children);
367
+ return found;
368
+ }
369
+
370
+ if (args.command === "verify") {
371
+ const errors = [];
372
+ const warnings = [];
373
+
374
+ if (!args.file) {
375
+ emitError("No input file provided. Usage: markcut verify <file.json|.md>");
376
+ process.exit(1);
377
+ }
378
+
379
+ const filePath = resolve(args.file);
380
+ if (!existsSync(filePath)) {
381
+ emitError(`File not found: ${filePath}`);
382
+ process.exit(1);
383
+ }
384
+
385
+ const raw = readFileSync(filePath, "utf-8");
386
+ const isMarkdown = filePath.endsWith(".md");
387
+
388
+ emitInfo(`File: ${filePath}`);
389
+ emitInfo(`Format: ${isMarkdown ? "Markdown" : "JSON"}`);
390
+
391
+ const { compileDescriptiveRoot, parseMarkdownDescriptive, parseImportsBlock } = await import("../player/pipeline.mjs");
392
+
393
+ try {
394
+ let descriptive, needsTti, needsTtv;
395
+ if (isMarkdown) {
396
+ descriptive = parseMarkdownDescriptive(raw);
397
+ } else {
398
+ const parsed = JSON.parse(raw);
399
+ const root = parsed.root ?? parsed;
400
+
401
+ if (root.type === "root" && root.isSeries !== undefined && !root.layout) {
402
+ emitError("This looks like a compiled stream tree, not a descriptive file. Use 'markcut render' to render a compiled tree.");
403
+ process.exit(1);
404
+ }
405
+ descriptive = root;
406
+ }
407
+
408
+ // ── Check 1: JSX components referenced in imports ──────────────────
409
+ const importedNames = new Set(
410
+ descriptive.importsBlock
411
+ ? parseImportsBlock(descriptive.importsBlock).map((e) => e.name)
412
+ : [],
413
+ );
414
+
415
+ function walk(nodes) {
416
+ for (const n of nodes) {
417
+ if (n.type === "component" && n.jsx) {
418
+ const tagRe = /<\s*([A-Z][a-zA-Z0-9_.]*)/g;
419
+ let m;
420
+ while ((m = tagRe.exec(n.jsx)) !== null) {
421
+ const tag = m[1];
422
+ if (!importedNames.has(tag)) {
423
+ errors.push(`Component "${tag}" used in jsx but not found in imports`);
424
+ }
425
+ }
426
+ }
427
+ if (n.children) walk(n.children);
428
+ }
429
+ }
430
+ walk(descriptive.children);
431
+
432
+ // ── Check media prompt needs ────────────────────────────────────────
433
+ needsTti = false;
434
+ needsTtv = false;
435
+ function walkMedia(nodes) {
436
+ for (const n of nodes) {
437
+ if (n.type === "image" && n.prompt && !n.src) needsTti = true;
438
+ if (n.type === "video" && n.prompt && !n.src) needsTtv = true;
439
+ if (n.children) walkMedia(n.children);
440
+ }
441
+ }
442
+ walkMedia(descriptive.children);
443
+
444
+ // ── Check 2: CLI tool availability (--cli flag) ────────────────────
445
+ if (args.cli) {
446
+ function extractCmd(configStr) {
447
+ if (!configStr) return null;
448
+ return configStr.trim().split(/\s+/)[0] || null;
449
+ }
450
+
451
+ const pipelineConfigs = [
452
+ { type: "TTS", config: descriptive.tts, defaultCmd: "edge-tts", needsCheck: hasScript(descriptive) },
453
+ { type: "STT", config: descriptive.stt, defaultCmd: "whisper", needsCheck: hasScript(descriptive) },
454
+ { type: "TTI", config: descriptive.tti, defaultCmd: "pi", needsCheck: needsTti },
455
+ { type: "TTV", config: descriptive.ttv, defaultCmd: "pi", needsCheck: needsTtv },
456
+ ];
457
+
458
+ const checked = new Set();
459
+ for (const p of pipelineConfigs) {
460
+ if (!p.needsCheck) continue;
461
+ const cmd = extractCmd(p.config) || p.defaultCmd;
462
+ if (checked.has(cmd)) continue;
463
+ checked.add(cmd);
464
+ const { execSync } = await import("node:child_process");
465
+ try {
466
+ execSync(`which ${cmd}`, { stdio: "ignore" });
467
+ } catch {
468
+ const hints = {
469
+ "pi": " (pip install pi-sdk)",
470
+ "edge-tts": " (pip install edge-tts)",
471
+ "whisper": " (pip install openai-whisper)",
472
+ };
473
+ errors.push(`CLI tool "${cmd}" not found${hints[cmd] || ""}. Required by ${p.type}`);
474
+ }
475
+ }
476
+ }
477
+
478
+ // ── Results ─────────────────────────────────────────────────────────
479
+ for (const w of warnings) emitWarn(w);
480
+ if (errors.length > 0) {
481
+ for (const e of errors) emitError(e);
482
+ process.exit(1);
483
+ }
484
+
485
+ // Compile to validate
486
+ const compiled = compileDescriptiveRoot(descriptive);
487
+ emitSuccess(`Valid. Duration: ~${compiled.durationInSeconds ?? "?"}s, Scenes: ${compiled.children?.length ?? 0}`);
488
+ process.exit(0);
489
+
490
+ } catch (err) {
491
+ emitError(err.message);
492
+ process.exit(1);
493
+ }
494
+ }
495
+
496
+ if (args.command === "resolve") {
497
+ if (!args.file) {
498
+ emitError("No input file provided. Usage: markcut resolve <file.json|.md> --output <path>");
499
+ process.exit(1);
500
+ }
501
+ if (!args.output) {
502
+ emitError("--output path is required. Usage: markcut resolve <file> --output <path>");
503
+ process.exit(1);
504
+ }
505
+
506
+ const filePath = resolve(args.file);
507
+ if (!existsSync(filePath)) {
508
+ emitError(`File not found: ${filePath}`);
509
+ process.exit(1);
510
+ }
511
+
512
+ const raw = readFileSync(filePath, "utf-8");
513
+ const isMarkdown = filePath.endsWith(".md");
514
+
515
+ emitInfo(`Resolving: ${filePath}`);
516
+
517
+ const { resolveAndCompile, resolveAndCompileMarkdown, compileDescriptiveRoot, parseMarkdownDescriptive } = await import("../player/pipeline.mjs");
518
+
519
+ const baseDir = dirname(filePath);
520
+ const baseOpts = {
521
+ baseDir,
522
+ scriptOutputDir: args.scriptOutputDir || join(baseDir, "assets", "tts"),
523
+ mediaOutputDir: args.mediaOutputDir || join(baseDir, "assets", "media"),
524
+ };
525
+
526
+ try {
527
+ let result;
528
+ let isCompiled = false;
529
+
530
+ if (isMarkdown) {
531
+ if (args.compile) {
532
+ result = await resolveAndCompileMarkdown(raw, baseOpts);
533
+ isCompiled = true;
534
+ } else {
535
+ const descriptive = parseMarkdownDescriptive(raw);
536
+ const { resolveAll } = await import("../player/pipeline.mjs");
537
+ result = await resolveAll(descriptive, baseOpts);
538
+ }
539
+ } else {
540
+ const parsed = JSON.parse(raw);
541
+ const root = parsed.root ?? parsed;
542
+ const { isDescriptiveRoot } = await import("../player/pipeline.mjs");
543
+
544
+ if (root.type === "root" && root.isSeries !== undefined && !root.layout) {
545
+ result = root;
546
+ isCompiled = true;
547
+ } else if (isDescriptiveRoot(root)) {
548
+ if (args.compile) {
549
+ result = await resolveAndCompile(root, baseOpts);
550
+ isCompiled = true;
551
+ } else {
552
+ const { resolveAll } = await import("../player/pipeline.mjs");
553
+ result = await resolveAll(root, baseOpts);
554
+ }
555
+ } else {
556
+ result = root;
557
+ }
558
+ }
559
+
560
+ const output = resolve(args.output);
561
+ mkdirSync(dirname(output), { recursive: true });
562
+ writeFileSync(output, JSON.stringify(result, null, 2));
563
+ emitSuccess(`Resolved → ${output}`);
564
+ process.exit(0);
565
+ } catch (err) {
566
+ emitError(`Resolve failed: ${err.message}`);
567
+ process.exit(1);
568
+ }
569
+ }
570
+
571
+ if (args.command === "compile") {
572
+ if (!args.file) {
573
+ emitError("No input file provided. Usage: markcut compile <file.json|.md> --output <path>");
574
+ process.exit(1);
575
+ }
576
+ if (!args.output) {
577
+ emitError("--output path is required. Usage: markcut compile <file> --output <path>");
578
+ process.exit(1);
579
+ }
580
+
581
+ const filePath = resolve(args.file);
582
+ if (!existsSync(filePath)) {
583
+ emitError(`File not found: ${filePath}`);
584
+ process.exit(1);
585
+ }
586
+
587
+ const raw = readFileSync(filePath, "utf-8");
588
+ const isMarkdown = filePath.endsWith(".md");
589
+
590
+ emitInfo(`Compiling: ${filePath}`);
591
+
592
+ const { compileDescriptiveRoot, parseMarkdownDescriptive } = await import("../player/pipeline.mjs");
593
+
594
+ try {
595
+ let descriptive;
596
+ if (isMarkdown) {
597
+ descriptive = parseMarkdownDescriptive(raw);
598
+ } else {
599
+ const parsed = JSON.parse(raw);
600
+ const root = parsed.root ?? parsed;
601
+ if (root.type === "root" && root.isSeries !== undefined && !root.layout) {
602
+ emitError("Input is already a compiled stream tree. Use --output to save, or pass a descriptive file.");
603
+ process.exit(1);
604
+ }
605
+ descriptive = root;
606
+ }
607
+
608
+ const compiled = compileDescriptiveRoot(descriptive);
609
+ const output = resolve(args.output);
610
+ mkdirSync(dirname(output), { recursive: true });
611
+ writeFileSync(output, JSON.stringify(compiled, null, 2));
612
+ emitSuccess(`Compiled → ${output}`);
613
+ process.exit(0);
614
+ } catch (err) {
615
+ emitError(`Compile failed: ${err.message}`);
616
+ process.exit(1);
617
+ }
618
+ }
619
+
620
+ console.error(`Unknown command: ${args.command}`);
621
+ usage();
622
+ process.exit(1);
623
+ }
624
+
625
+ main().catch((e) => {
626
+ console.error(e);
627
+ process.exit(1);
628
+ });