@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.
- package/.env.example +27 -0
- package/.github/user-steer.md +9 -0
- package/.vscode/settings.json +3 -0
- package/AGENTS.md +271 -0
- package/README.md +219 -0
- package/SKILL.md +209 -0
- package/docs/dynamic-components.md +191 -0
- package/docs/edit-mode.md +220 -0
- package/docs/json-descriptive.md +539 -0
- package/docs/label-mode.md +110 -0
- package/docs/markdown-descriptive.md +751 -0
- package/docs/templates.md +52 -0
- package/package.json +64 -0
- package/remotion.config.ts +5 -0
- package/scripts/artlist-dl.mjs +190 -0
- package/scripts/build-pipeline.sh +19 -0
- package/scripts/build-player.sh +20 -0
- package/src/Root.tsx +55 -0
- package/src/config.mjs +88 -0
- package/src/context/EventContext.tsx +168 -0
- package/src/context/index.tsx +42 -0
- package/src/descriptive/compiler.test.ts +1135 -0
- package/src/descriptive/compiler.ts +1230 -0
- package/src/descriptive/dsl.ts +455 -0
- package/src/descriptive/markdown.test.ts +866 -0
- package/src/descriptive/markdown.ts +674 -0
- package/src/descriptive/resolve.test.ts +951 -0
- package/src/descriptive/resolve.ts +891 -0
- package/src/entry.tsx +163 -0
- package/src/index.ts +4 -0
- package/src/player/browser.tsx +356 -0
- package/src/player/bundle/player.js +60259 -0
- package/src/player/bundler.mjs +269 -0
- package/src/player/label-server.mjs +599 -0
- package/src/player/pipeline.mjs +11123 -0
- package/src/player/pipeline.ts +117 -0
- package/src/player/server-shared.mjs +144 -0
- package/src/player/server.mjs +1006 -0
- package/src/render/cli-tools.ts +177 -0
- package/src/render/cli.mjs +628 -0
- package/src/schema/index.ts +259 -0
- package/src/types/Audio.tsx +56 -0
- package/src/types/Component.tsx +135 -0
- package/src/types/Effect.tsx +88 -0
- package/src/types/Folder.tsx +180 -0
- package/src/types/FrameSyncStyle.tsx +51 -0
- package/src/types/Image.tsx +51 -0
- package/src/types/Include.tsx +394 -0
- package/src/types/Map.tsx +252 -0
- package/src/types/Rhythm.tsx +58 -0
- package/src/types/Scene.tsx +42 -0
- package/src/types/Subtitle.tsx +218 -0
- package/src/types/Video.tsx +70 -0
- package/src/types/keyframes.ts +454 -0
- package/src/utils/__tests__/vtt.test.ts +129 -0
- package/src/utils/index.ts +168 -0
- package/src/utils/tween.ts +118 -0
- package/src/vision/cli.mjs +1187 -0
- package/src/vision/vision_prompts.md +67 -0
- package/tests/dsl.test.ts +317 -0
- package/tests/fixtures/audio.json +25 -0
- package/tests/fixtures/basic.json +27 -0
- package/tests/fixtures/component-all.json +38 -0
- package/tests/fixtures/components.json +38 -0
- package/tests/fixtures/effects.json +64 -0
- package/tests/fixtures/full.json +51 -0
- package/tests/fixtures/map.json +23 -0
- package/tests/fixtures/md/all-nodes.md +28 -0
- package/tests/fixtures/md/basic.md +6 -0
- package/tests/fixtures/md/component-imports.md +20 -0
- package/tests/fixtures/md/edge-cases.md +33 -0
- package/tests/fixtures/md/effects.md +17 -0
- package/tests/fixtures/md/frontmatter.md +20 -0
- package/tests/fixtures/md/full-feature.md +58 -0
- package/tests/fixtures/md/imports-block.md +19 -0
- package/tests/fixtures/md/include-main.md +11 -0
- package/tests/fixtures/md/include-sub.md +25 -0
- package/tests/fixtures/md/jsx-code-fence.md +21 -0
- package/tests/fixtures/md/map.md +11 -0
- package/tests/fixtures/md/nested-scenes.md +25 -0
- package/tests/fixtures/md/rhythm.md +17 -0
- package/tests/fixtures/md/scenes.md +16 -0
- package/tests/fixtures/md/tween.md +11 -0
- package/tests/fixtures/md/vars-test.md +6 -0
- package/tests/fixtures/scenes.json +40 -0
- package/tests/fixtures/subtitle.json +59 -0
- package/tests/fixtures/subvideo.json +59 -0
- package/tests/fixtures/templates/courseware.md +351 -0
- package/tests/fixtures/tween-visual.json +28 -0
- package/tests/fixtures/video-series.json +54 -0
- package/tests/md-descriptive.test.ts +742 -0
- package/tests/render.test.ts +985 -0
- package/tests/schema.test.ts +68 -0
- package/tests/server.test.ts +308 -0
- package/tests/utils.ts +391 -0
- package/tests/vitest.config.ts +18 -0
- package/tests/vitest.integration.config.ts +16 -0
- 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
|
+
});
|