@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,1006 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Custom player server for markcut.
4
+ *
5
+ * Modes:
6
+ * --label – playback with label input overlay; labels map to media timestamps
7
+ * --edit – auto-reload when the JSON file changes (agent edits file, player refreshes)
8
+ *
9
+ * Usage:
10
+ * node src/player/server.mjs <video.json> [--label] [--edit] [--port 3001]
11
+ */
12
+ import { spawn } from "node:child_process";
13
+ import { createServer } from "node:http";
14
+ import { readFileSync, writeFileSync, watchFile, existsSync, mkdirSync, statSync } from "node:fs";
15
+ import { resolve, dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { isDescriptiveRoot, resolveAndCompile, resolveAndCompileMarkdown, parseImportsBlock, extractDependencySpecs } from "./pipeline.mjs";
18
+ import { bundleFromEntries } from "./bundler.mjs";
19
+ import { extractScenes, MIME, serveFile, handleShutdown } from "./server-shared.mjs";
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ const ROOT = resolve(__dirname, "..", "..");
23
+ const PORT = parseInt(process.argv.find(a => a.startsWith("--port="))?.split("=")[1] || process.argv[process.argv.indexOf("--port") + 1] || "3001", 10);
24
+
25
+ // Find video input path (.json or .md)
26
+ const inputArg = process.argv.find(a => (a.endsWith(".json") || a.endsWith(".md")) && !a.startsWith("--"));
27
+ const VIDEO_JSON = inputArg ? resolve(inputArg) : join(ROOT, "video.json");
28
+ const IS_MARKDOWN = VIDEO_JSON.endsWith(".md");
29
+ const MODE_LABEL = process.argv.includes("--label");
30
+ const MODE_EDIT = process.argv.includes("--edit");
31
+
32
+ // ─── Variant configuration ─────────────────────────────────────────────────
33
+ // Each --variant flag defines a separate compilation target.
34
+ // The label can be a dash-separated chain (e.g. "zh-tiktok" → ["zh", "tiktok"]).
35
+ // The special label "default" means no variant overrides (base content).
36
+ //
37
+ // Usage: --variant default --variant zh-tiktok --variant en-tiktok --variant youtube
38
+ // This compiles 4 variants: default (no overrides), zh+tiktok, en+tiktok, youtube.
39
+ //
40
+ // Each variant is served at its own URL path: /, /zh-tiktok, /en-tiktok, /youtube.
41
+ //
42
+ // Compilations run sequentially (not parallel) to avoid resource contention
43
+ // from CLI tools like whisper, ffprobe, etc.
44
+ //
45
+ // Variant config:
46
+ // { label: string, chain: string[] }
47
+ // - label: display name / URL path segment (e.g. "default", "zh-tiktok")
48
+ // - chain: variant override names (e.g. [], ["zh", "tiktok"], ["youtube"])
49
+ function parseVariantConfigs() {
50
+ const labels = [];
51
+ for (let i = 0; i < process.argv.length; i++) {
52
+ const arg = process.argv[i];
53
+ if (arg === "--variant") {
54
+ const next = process.argv[i + 1];
55
+ if (next && !next.startsWith("--")) {
56
+ labels.push(next);
57
+ i++; // skip the value
58
+ }
59
+ } else if (arg.startsWith("--variant=")) {
60
+ labels.push(arg.split("=")[1]);
61
+ }
62
+ }
63
+ // If no --variant flags, default to a single "default" variant
64
+ if (labels.length === 0) labels.push("default");
65
+ return labels.map(label => ({
66
+ label,
67
+ chain: label === "default" ? [] : label.split("-"),
68
+ }));
69
+ }
70
+ const VARIANT_CONFIGS = parseVariantConfigs();
71
+
72
+ // ─── SSE clients for reload notifications ────────────────────────────────
73
+ const sseClients = new Set();
74
+ let shutdownTimer = null;
75
+
76
+ // ─── Label store for label mode ───────────────────────────────────────────
77
+ let labels = [];
78
+
79
+ // ─── Edit history for context ─────────────────────────────────────────────
80
+ let editHistory = [];
81
+
82
+ // ─── .markcut/ directory layout ────────────────────────────────────────────
83
+ // .markcut/ ← server document root
84
+ // generated/ ← shared, content-addressed (across all variants & files)
85
+ // tts/ ← TTS audio (content-hash filenames)
86
+ // media/ ← TTI/TTV media (content-hash filenames)
87
+ // includes/ ← compiled sub-video JSON (content-hash)
88
+ // components/ ← component bundles (content-hash, shared across variants)
89
+ // <basename>/ ← per-source-file
90
+ // default/ ← default variant artifacts
91
+ // compiled.json
92
+ // zh-tiktok/ ← variant-specific artifacts
93
+ // compiled.json
94
+ const MARKCUT_BASE = join(dirname(VIDEO_JSON), ".markcut");
95
+ const MARKCUT_DIR = join(MARKCUT_BASE, "generated");
96
+ const BASENAME = VIDEO_JSON.split("/").pop().replace(/\.[^.]+$/, "");
97
+ const TTS_OUTPUT_DIR = join(MARKCUT_DIR, "tts");
98
+ const MEDIA_OUTPUT_DIR = join(MARKCUT_DIR, "media");
99
+ const INCLUDE_CACHE_DIR = join(MARKCUT_DIR, "includes");
100
+ const COMPONENT_OUTPUT_DIR = join(MARKCUT_DIR, "components");
101
+ /** Get the per-variant directory under .markcut/<basename>/<label>/ */
102
+ function variantDir(label) {
103
+ return join(MARKCUT_BASE, BASENAME, label);
104
+ }
105
+
106
+ /** Get the compiled.json path for a given variant */
107
+ function compiledCacheFile(label) {
108
+ return join(variantDir(label), "compiled.json");
109
+ }
110
+
111
+ /**
112
+ * Recursively convert all absolute paths under MARKCUT_BASE to relative paths.
113
+ * This makes the cached compiled.json portable across machines.
114
+ * The /api/video-data endpoint converts them back to server-relative URLs.
115
+ */
116
+ function makePathsRelative(obj) {
117
+ if (!obj || typeof obj !== "object") return obj;
118
+ if (Array.isArray(obj)) return obj.map(makePathsRelative);
119
+ const result = {};
120
+ for (const [key, value] of Object.entries(obj)) {
121
+ if (typeof value === "string" && value.startsWith(MARKCUT_BASE)) {
122
+ result[key] = value.replace(MARKCUT_BASE + "/", "");
123
+ } else if (typeof value === "object" && value !== null) {
124
+ result[key] = makePathsRelative(value);
125
+ } else {
126
+ result[key] = value;
127
+ }
128
+ }
129
+ return result;
130
+ }
131
+
132
+ /**
133
+ * Convert relative (to MARKCUT_BASE) paths in the compiled root to server-relative URLs
134
+ * for the browser. This is the inverse of makePathsRelative — it prepends "/" to paths
135
+ * so the browser can fetch them from the server.
136
+ *
137
+ * Handles:
138
+ * - compiled.imports (component bundle URL)
139
+ * - compiled.subtitle.src (VTT file)
140
+ * - Any node.src (media/tts/images) that is a relative path under MARKCUT_BASE
141
+ */
142
+ function resolveAssetPaths(root) {
143
+ const out = JSON.parse(JSON.stringify(root));
144
+
145
+ function walkNode(node) {
146
+ if (!node || typeof node !== "object") return;
147
+ if (Array.isArray(node)) { node.forEach(walkNode); return; }
148
+
149
+ // Convert src fields that are relative paths
150
+ if (typeof node.src === "string" && !node.src.startsWith("http://") && !node.src.startsWith("https://") && !node.src.startsWith("data:")) {
151
+ // Already a relative path under MARKCUT_BASE (shouldn't be absolute)
152
+ if (!node.src.startsWith("/") && !node.src.startsWith(".")) {
153
+ if (node.src.startsWith("generated/") || node.src.startsWith(BASENAME + "/")) {
154
+ node.src = "/" + node.src;
155
+ }
156
+ }
157
+ // Convert absolute paths under MARKCUT_BASE
158
+ if (typeof node.src === "string" && node.src.startsWith(MARKCUT_BASE)) {
159
+ node.src = "/" + node.src.replace(MARKCUT_BASE + "/", "");
160
+ }
161
+ }
162
+
163
+ // Convert subtitle src
164
+ if (node.subtitle && typeof node.subtitle.src === "string") {
165
+ if (node.subtitle.src.startsWith(MARKCUT_BASE)) {
166
+ node.subtitle.src = "/" + node.subtitle.src.replace(MARKCUT_BASE + "/", "");
167
+ } else if (!node.subtitle.src.startsWith("/") && !node.subtitle.src.startsWith("http")) {
168
+ node.subtitle.src = "/" + node.subtitle.src;
169
+ }
170
+ }
171
+
172
+ // Convert imports (component bundle URL)
173
+ if (typeof node.imports === "string") {
174
+ if (node.imports.startsWith(MARKCUT_BASE)) {
175
+ node.imports = "/" + node.imports.replace(MARKCUT_BASE + "/", "");
176
+ } else if (!node.imports.startsWith("/") && !node.imports.startsWith("http")) {
177
+ node.imports = "/" + node.imports;
178
+ }
179
+ }
180
+
181
+ if (Array.isArray(node.children)) {
182
+ node.children.forEach(walkNode);
183
+ }
184
+ }
185
+
186
+ walkNode(out);
187
+ return out;
188
+ }
189
+
190
+ // ─── extractScenes imported from ./server-shared.mjs ────────────────────
191
+
192
+ // ─── Compiled root cache (per-variant) ────────────────────────────────────
193
+ // Keyed by variant label (e.g. "default", "zh-tiktok").
194
+ const compiledRootCache = new Map();
195
+ let pipelineRunning = false;
196
+
197
+ /**
198
+ * Extract component import entries from the raw file source.
199
+ * Handles both markdown (```js imports code fence) and JSON formats.
200
+ */
201
+ function extractImportEntries(raw) {
202
+ let entries = null;
203
+ let extraSpecs = [];
204
+ let rawSource = null;
205
+
206
+ if (IS_MARKDOWN) {
207
+ const match = raw.match(/^(```|~~~)\s*js imports\s*\n([\s\S]*?)^\1\s*$/m);
208
+ if (match) {
209
+ rawSource = match[2];
210
+ entries = parseImportsBlock(match[2]);
211
+ extraSpecs = extractDependencySpecs(match[2]);
212
+ }
213
+ } else {
214
+ try {
215
+ const parsed = JSON.parse(raw);
216
+ const root = parsed.root || parsed;
217
+ if (root.importsBlock) {
218
+ rawSource = root.importsBlock;
219
+ entries = parseImportsBlock(root.importsBlock);
220
+ extraSpecs = extractDependencySpecs(root.importsBlock);
221
+ }
222
+ } catch { /* invalid JSON — skip */ }
223
+ }
224
+
225
+ return { entries, extraSpecs, rawSource };
226
+ }
227
+
228
+ /**
229
+ * Compile a single variant and cache the result.
230
+ *
231
+ * @param {object} config - Variant config { label, chain }
232
+ * @param {object} parsed - Parsed markdown variants (from parseMarkdownVariants)
233
+ * @param {string} raw - Raw source file content
234
+ */
235
+ async function compileVariant(config, parsed, raw) {
236
+ const cacheKey = config.label;
237
+ if (compiledRootCache.has(cacheKey)) return compiledRootCache.get(cacheKey);
238
+
239
+ const vDir = variantDir(config.label);
240
+ const subtitleDir = vDir;
241
+
242
+ const { entries: importEntries, extraSpecs, rawSource } = extractImportEntries(raw);
243
+
244
+ let compiled;
245
+
246
+ if (IS_MARKDOWN) {
247
+ let descriptive = parsed.base;
248
+
249
+ if (config.chain.length > 0) {
250
+ // Merge root config from the first variant's section
251
+ const variantRoot = parsed.variants.get(config.chain[0]);
252
+ if (variantRoot) {
253
+ const { children: _, ...configOverrides } = variantRoot;
254
+ descriptive = { ...descriptive, ...configOverrides };
255
+ }
256
+ const { resolveVariantOverrides } = await import("./pipeline.mjs");
257
+ descriptive = resolveVariantOverrides(descriptive, config.chain);
258
+ }
259
+
260
+ console.log(` 📝 Variant "${config.label}": resolving...`);
261
+ const { resolveAll, compileDescriptiveRoot } = await import("./pipeline.mjs");
262
+ const resolved = await resolveAll(descriptive, {
263
+ baseDir: dirname(VIDEO_JSON),
264
+ scriptOutputDir: TTS_OUTPUT_DIR,
265
+ mediaOutputDir: MEDIA_OUTPUT_DIR,
266
+ includeOutputDir: INCLUDE_CACHE_DIR,
267
+ subtitleOutputDir: subtitleDir,
268
+ variants: config.chain.length > 0 ? config.chain : undefined,
269
+ });
270
+ compiled = compileDescriptiveRoot(resolved);
271
+ } else {
272
+ const parsedJson = JSON.parse(raw);
273
+ const root = parsedJson.root || parsedJson;
274
+
275
+ if (isDescriptiveRoot(root)) {
276
+ let descriptive = root;
277
+
278
+ if (config.chain.length > 0) {
279
+ const { resolveVariantOverrides } = await import("./pipeline.mjs");
280
+ descriptive = resolveVariantOverrides(descriptive, config.chain);
281
+ }
282
+
283
+ console.log(` 📝 Variant "${config.label}": resolving...`);
284
+ compiled = await resolveAndCompile(descriptive, {
285
+ baseDir: dirname(VIDEO_JSON),
286
+ scriptOutputDir: TTS_OUTPUT_DIR,
287
+ mediaOutputDir: MEDIA_OUTPUT_DIR,
288
+ includeOutputDir: INCLUDE_CACHE_DIR,
289
+ subtitleOutputDir: subtitleDir,
290
+ variants: config.chain.length > 0 ? config.chain : undefined,
291
+ });
292
+ } else {
293
+ compiled = root;
294
+ }
295
+ }
296
+
297
+ // Bundle component imports — output goes to shared generated/components/ (content-addressed)
298
+ // The bundler's cache dir doubles as both the temp project location and output dir.
299
+ // Using a shared COMPONENT_OUTPUT_DIR ensures identical imports across variants
300
+ // reuse the same cached bundle.
301
+ const shouldBundle = (importEntries && importEntries.length > 0) || (rawSource && rawSource.trim());
302
+ if (shouldBundle) {
303
+ try {
304
+ const bundle = await bundleFromEntries(importEntries || [], extraSpecs, rawSource, COMPONENT_OUTPUT_DIR);
305
+ if (bundle.url) {
306
+ compiled.imports = bundle.url;
307
+ console.log(` ✅ ${config.label}: components → ${bundle.exports.join(", ")}`);
308
+ }
309
+ } catch (e) {
310
+ console.error(` ⚠️ ${config.label}: component bundling failed:`, e.message);
311
+ }
312
+ }
313
+
314
+ // Convert absolute paths under MARKCUT_BASE to relative paths for portability.
315
+ // The cached compiled.json on disk should not contain absolute filesystem paths
316
+ // so it's machine-independent. The /api/video-data endpoint converts them back
317
+ // to server-relative URLs when serving.
318
+ compiled = makePathsRelative(compiled);
319
+
320
+ // Persist to disk
321
+ try {
322
+ mkdirSync(vDir, { recursive: true });
323
+ writeFileSync(compiledCacheFile(config.label), JSON.stringify(compiled, null, 2), "utf-8");
324
+ } catch (e) {
325
+ console.warn(` ⚠ Failed to write compiled.json for "${config.label}":`, e.message);
326
+ }
327
+
328
+ compiledRootCache.set(cacheKey, compiled);
329
+ return compiled;
330
+ }
331
+
332
+ /**
333
+ * Compile all variants sequentially.
334
+ */
335
+ async function compileAllVariants() {
336
+ const raw = readFileSync(VIDEO_JSON, "utf-8");
337
+ let markdownParsed = null;
338
+
339
+ console.log(` 📄 ${VIDEO_JSON.split("/").pop()}`);
340
+
341
+ // Pre-parse markdown variants once (shared across all variants)
342
+ if (IS_MARKDOWN) {
343
+ const { parseMarkdownVariants } = await import("./pipeline.mjs");
344
+ markdownParsed = parseMarkdownVariants(raw);
345
+ }
346
+
347
+ for (const config of VARIANT_CONFIGS) {
348
+ const startTime = Date.now();
349
+ await compileVariant(config, markdownParsed, raw);
350
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
351
+ console.log(` ✅ "${config.label}" compiled (${elapsed}s)`);
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Get a compiled root for a given variant label.
357
+ * Returns the pre-compiled cached root (no re-compilation).
358
+ */
359
+ async function loadCompiledRoot(variantLabel) {
360
+ const label = variantLabel || "default";
361
+ if (compiledRootCache.has(label)) return compiledRootCache.get(label);
362
+ throw new Error(`Variant "${label}" not compiled. Available: ${[...compiledRootCache.keys()].join(", ")}`);
363
+ }
364
+
365
+ /**
366
+ * Walk the compiled tree for include nodes. For each include with a `.meta.json`
367
+ * companion file (created by resolveIncludes in the pipeline), bundle the
368
+ * sub-video's component imports and write the bundle URL into the compiled JSON.
369
+ *
370
+ * Sub-video component bundles are stored under .markcut/generated/components/
371
+ * (shared, content-addressed), served from .markcut/ as the document root.
372
+ *
373
+ * This runs AFTER resolveAndCompile so the server can bundle per-subvideo
374
+ * component registrations independently.
375
+ */
376
+ async function resolveIncludeImports(root) {
377
+ const includes = [];
378
+
379
+ function walk(node) {
380
+ if (!node || typeof node !== "object") return;
381
+ if (node.type === "include" && node.src) {
382
+ includes.push(node);
383
+ }
384
+ if (Array.isArray(node.children)) {
385
+ for (const child of node.children) walk(child);
386
+ }
387
+ }
388
+ walk(root);
389
+
390
+ for (const inc of includes) {
391
+ // Check for companion meta file
392
+ const metaPath = inc.src.replace(/\.json$/, ".meta.json");
393
+ if (!existsSync(metaPath)) continue;
394
+
395
+ let meta;
396
+ try {
397
+ meta = JSON.parse(readFileSync(metaPath, "utf-8"));
398
+ } catch {
399
+ continue;
400
+ }
401
+
402
+ const { importEntries, extraSpecs, rawSource } = meta;
403
+ if (!importEntries || importEntries.length === 0) continue;
404
+
405
+ try {
406
+ console.log(` 🔗 Sub-video includes: bundling ${importEntries.length} component(s) from "${inc.src}"`);
407
+ // Use shared generated/components/ for content-addressed caching
408
+ const bundle = await bundleFromEntries(importEntries, extraSpecs || [], rawSource, COMPONENT_OUTPUT_DIR);
409
+ if (bundle.url) {
410
+ // bundle.url: /Users/.../.markcut/generated/components/abc123.js
411
+ // server root: .markcut/ — strip MARKCUT_BASE to get /generated/components/abc123.js
412
+ let bundleUrl = bundle.url;
413
+ if (bundleUrl.startsWith(MARKCUT_BASE)) {
414
+ bundleUrl = bundleUrl.replace(MARKCUT_BASE, "");
415
+ }
416
+ if (!bundleUrl.startsWith("/")) bundleUrl = "/" + bundleUrl;
417
+
418
+ // Update the compiled JSON file with the bundle URL
419
+ const compiledPath = inc.src;
420
+ const compiledData = JSON.parse(readFileSync(compiledPath, "utf-8"));
421
+ if (compiledData.root) {
422
+ compiledData.root.imports = bundleUrl;
423
+ } else {
424
+ compiledData.imports = bundleUrl;
425
+ }
426
+ writeFileSync(compiledPath, JSON.stringify(compiledData, null, 2), "utf-8");
427
+ console.log(` ✅ Sub-video components: ${bundle.exports.join(", ")}`);
428
+ }
429
+ } catch (e) {
430
+ console.error(` ⚠️ Sub-video component bundling failed for "${inc.src}":`, e.message);
431
+ }
432
+ }
433
+ }
434
+
435
+ // ─── Scene info per variant ─────────────────────────────────────────────
436
+ const scenesCache = new Map(); // label → { scenes, totalDuration }
437
+
438
+ async function compileAndExtractScenes() {
439
+ // Compile all variants sequentially, then extract scenes for each
440
+ await compileAllVariants();
441
+
442
+ for (const config of VARIANT_CONFIGS) {
443
+ try {
444
+ const root = await loadCompiledRoot(config.label);
445
+ const extracted = extractScenes(root);
446
+ scenesCache.set(config.label, extracted);
447
+ } catch (e) {
448
+ console.error(` ⚠ Could not extract scenes for "${config.label}":`, e.message);
449
+ }
450
+ }
451
+ }
452
+
453
+ function getScenes(label) {
454
+ const key = label || "default";
455
+ return scenesCache.get(key) || { scenes: [], totalDuration: 0 };
456
+ }
457
+
458
+ // Will be awaited before announcing "Player ready"
459
+ const initScenesPromise = compileAndExtractScenes();
460
+
461
+ // ─── Watch file for changes (--edit mode) ───────────────────────────────
462
+ if (MODE_EDIT) {
463
+ let lastContent = readFileSync(VIDEO_JSON, "utf-8");
464
+ watchFile(VIDEO_JSON, { interval: 1000 }, async (curr, prev) => {
465
+ if (curr.mtimeMs === prev.mtimeMs) return;
466
+ if (pipelineRunning) return;
467
+ const newContent = readFileSync(VIDEO_JSON, "utf-8");
468
+ if (newContent === lastContent) return;
469
+ lastContent = newContent;
470
+ pipelineRunning = true;
471
+ console.log(` 📁 ${VIDEO_JSON} changed, re-running all variants...`);
472
+
473
+ try {
474
+ compiledRootCache.clear();
475
+ scenesCache.clear();
476
+ await compileAllVariants();
477
+
478
+ for (const config of VARIANT_CONFIGS) {
479
+ try {
480
+ const root = await loadCompiledRoot(config.label);
481
+ scenesCache.set(config.label, extractScenes(root));
482
+ } catch {}
483
+ }
484
+
485
+ for (const client of sseClients) {
486
+ client.write("data: " + JSON.stringify({ type: "reload" }) + "\n\n");
487
+ }
488
+ } catch (e) {
489
+ console.error(" ⚠️ Failed to re-process after change:", e.message);
490
+ } finally {
491
+ pipelineRunning = false;
492
+ }
493
+ });
494
+ }
495
+ // ─── MIME imported from ./server-shared.mjs ──────────────────────────────
496
+
497
+ // ─── Variant detection from URL path ─────────────────────────────────────
498
+ // Extract variant label from the first path segment.
499
+ // "/" or "/default" → "default"
500
+ // "/zh-tiktok" → "zh-tiktok"
501
+ // "/zh-tiktok/player.js" → "zh-tiktok" (with subpath /player.js)
502
+ function parseVariantFromPath(urlPath) {
503
+ const parts = urlPath.split("/").filter(Boolean);
504
+ if (parts.length === 0) return { variant: "default", subpath: "/" };
505
+ const first = parts[0];
506
+ // Check if this segment matches a known variant label
507
+ const match = VARIANT_CONFIGS.find(c => c.label === first);
508
+ if (match) {
509
+ const subpath = "/" + parts.slice(1).join("/");
510
+ return { variant: match.label, subpath: subpath || "/" };
511
+ }
512
+ return { variant: "default", subpath: urlPath };
513
+ }
514
+
515
+ // ─── Resolve asset path ──────────────────────────────────────────────────
516
+ function resolveAsset(urlPath, variantLabel) {
517
+ // Bundle dir for the Remotion Player (same for all variants)
518
+ if (urlPath === "/player.js") return join(ROOT, "src", "player", "bundle", "player.js");
519
+ // Absolute filesystem path — serve directly
520
+ if (urlPath.startsWith("/") && existsSync(urlPath)) return urlPath;
521
+ // Serve from variant dir, then .markcut/, then ROOT/public, etc.
522
+ const vDir = variantDir(variantLabel || "default");
523
+ const jsonDir = dirname(VIDEO_JSON);
524
+ const candidates = [
525
+ join(vDir, urlPath),
526
+ join(MARKCUT_BASE, urlPath),
527
+ join(MARKCUT_DIR, urlPath),
528
+ join(ROOT, "public", urlPath),
529
+ join(ROOT, urlPath),
530
+ join(jsonDir, urlPath),
531
+ ];
532
+ for (const c of candidates) {
533
+ if (existsSync(c)) return c;
534
+ }
535
+ return null;
536
+ }
537
+
538
+ // ─── HTML page ───────────────────────────────────────────────────────────
539
+ function getHtml(variantLabel) {
540
+ const label = variantLabel || "default";
541
+ const hasLabel = MODE_LABEL ? "true" : "false";
542
+ const hasWatch = MODE_EDIT ? "true" : "false";
543
+ const title = label !== "default" ? ` — ${label}` : MODE_LABEL ? " — Label" : MODE_EDIT ? " — Edit" : "";
544
+
545
+ // Build variant switcher links
546
+ const variantLinks = VARIANT_CONFIGS.map(c =>
547
+ `<a href="/${c.label === "default" ? "" : c.label}" class="variant-link${c.label === label ? " active" : ""}">${c.label}</a>`
548
+ ).join("");
549
+
550
+ return `<!DOCTYPE html>
551
+ <html lang="en">
552
+ <head>
553
+ <meta charset="UTF-8">
554
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
555
+ <title>Remotion Player${title}</title>
556
+ <style>
557
+ * { margin: 0; padding: 0; box-sizing: border-box; }
558
+ html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a0a; }
559
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; flex-direction: column; align-items: center; }
560
+ #header { display: flex; align-items: center; justify-content: flex-end; width: 100%; max-width: 500px; padding: 8px 12px; flex-shrink: 0; gap: 8px; }
561
+ #header-status { font-size: 11px; color: rgba(255,255,255,.4); flex: 1; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
562
+ #header-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
563
+ #close-btn { width: 22px; height: 22px; border-radius: 50%; border: 1px solid rgba(255,255,255,.15); background: rgba(0,0,0,.3); color: rgba(255,255,255,.4); font-size: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all .15s; }
564
+ #close-btn:hover { background: rgba(255,60,60,.4); border-color: rgba(255,60,60,.5); color: #fff; }
565
+ #variant-bar { display: flex; gap: 4px; align-items: center; width: 100%; max-width: 500px; padding: 6px 12px; flex-shrink: 0; overflow-x: auto; }
566
+ .variant-link { font-size: 11px; padding: 3px 10px; border-radius: 12px; background: rgba(255,255,255,.06); color: rgba(255,255,255,.4); text-decoration: none; white-space: nowrap; transition: all .15s; }
567
+ .variant-link:hover { background: rgba(255,255,255,.12); color: rgba(255,255,255,.7); }
568
+ .variant-link.active { background: rgba(74,158,255,.2); color: #4a9eff; }
569
+ #player-frame { flex: 1; width: 100%; max-width: 480px; min-height: 0; border-radius: 16px; overflow: hidden; border: 1px solid rgba(255,255,255,.08); background: #000; box-shadow: 0 4px 40px rgba(0,0,0,.6); margin: 0 12px; }
570
+ #root { width: 100%; height: 100%; }
571
+ #reload-toast { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(74,158,255,.9); color: #fff; padding: 12px 24px; border-radius: 10px; font-size: 14px; font-weight: 600; opacity: 0; transition: opacity .3s; pointer-events: none; z-index: 200; backdrop-filter: blur(8px); }
572
+ #reload-toast.show { opacity: 1; }
573
+ #bottom-bar { display: flex; gap: 6px; align-items: center; width: 100%; max-width: 500px; padding: 8px 12px; flex-shrink: 0; }
574
+ #edit-input { flex: 1; padding: 8px 12px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.05); color: #eee; border-radius: 8px; font-size: 13px; outline: none; transition: border-color .15s; }
575
+ #edit-input:focus { border-color: rgba(74,158,255,.5); }
576
+ #edit-input::placeholder { color: rgba(255,255,255,.25); }
577
+ #edit-btn { width: 32px; height: 32px; padding: 0; background: rgba(255,255,255,.06); color: rgba(255,255,255,.5); border: 1px solid rgba(255,255,255,.1); border-radius: 8px; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; transition: all .15s; flex-shrink: 0; }
578
+ #edit-btn:hover { background: rgba(74,158,255,.2); border-color: rgba(74,158,255,.4); color: #4a9eff; }
579
+ #edit-btn:disabled { opacity: 0.3; cursor: wait; }
580
+ </style>
581
+ </head>
582
+ <body>
583
+ <script>window.VARIANT = "${label}";</script>
584
+ ${MODE_EDIT ? `<div id="header">
585
+ <span id="header-status"></span>
586
+ <div id="header-actions">
587
+ <button id="close-btn" title="Close player and return to terminal">✕</button>
588
+ </div>
589
+ </div>` : ""}
590
+ <div id="variant-bar">${variantLinks}</div>
591
+ <div id="player-frame">
592
+ <div id="root"></div>
593
+ </div>
594
+ <div id="reload-toast">🔄 JSON changed — reloading...</div>
595
+ ${MODE_EDIT ? `<div id="bottom-bar">
596
+ <input id="edit-input" placeholder="What should change? e.g. make text bigger" />
597
+ <button id="edit-btn" title="Apply edit">&#x2728;</button>
598
+ </div>` : ""}
599
+ <script src="/player.js" type="module"></script>
600
+ ${MODE_EDIT ? `<script>
601
+ // ─── SSE reload ───────────────────────────────────────────────────────
602
+ const evtSource = new EventSource("/api/events");
603
+ evtSource.onmessage = (e) => {
604
+ const msg = JSON.parse(e.data);
605
+ if (msg.type === "reload" && !suppressReload) {
606
+ window.dispatchEvent(new Event("refresh-player"));
607
+ }
608
+ };
609
+
610
+ // ─── Close button ─────────────────────────────────────────────────────
611
+ document.getElementById("close-btn")?.addEventListener("click", () => {
612
+ navigator.sendBeacon("/api/shutdown", "{}");
613
+ document.body.innerHTML = "<div style='display:flex;align-items:center;justify-content:center;height:100vh;background:#0a0a0a;color:#555;font-family:sans-serif;font-size:16px'>\u2B61 player closed \u2014 return to terminal</div>";
614
+ });
615
+
616
+ // ─── Edit input ───────────────────────────────────────────────────────
617
+ const editInput = document.getElementById("edit-input");
618
+ const editBtn = document.getElementById("edit-btn");
619
+ const headerStatus = document.getElementById("header-status");
620
+
621
+ let suppressReload = false;
622
+
623
+ async function applyEdit() {
624
+ const text = editInput.value.trim();
625
+ if (!text) return;
626
+ editBtn.disabled = true;
627
+ headerStatus.textContent = "\u231B editing...";
628
+ editInput.value = "";
629
+ suppressReload = true;
630
+ try {
631
+ const res = await fetch("/api/edit", {
632
+ method: "POST",
633
+ headers: { "Content-Type": "application/json" },
634
+ body: JSON.stringify({ text }),
635
+ });
636
+ const data = await res.json();
637
+ if (res.ok) {
638
+ const summary = (data.output || "done").split("\\n")[0].slice(0, 65);
639
+ headerStatus.textContent = summary;
640
+ setTimeout(() => { suppressReload = false; window.dispatchEvent(new Event("refresh-player")); }, 4000);
641
+ } else {
642
+ headerStatus.textContent = "\u274C " + (data.error || "failed");
643
+ suppressReload = false;
644
+ }
645
+ } catch (e) {
646
+ headerStatus.textContent = "\u274C error";
647
+ suppressReload = false;
648
+ }
649
+ editBtn.disabled = false;
650
+ }
651
+
652
+ editBtn?.addEventListener("click", applyEdit);
653
+ editInput?.addEventListener("keydown", (e) => {
654
+ if (e.key === "Enter") { e.preventDefault(); applyEdit(); }
655
+ });
656
+ </script>` : ""}
657
+
658
+ </body>
659
+ </html>`;
660
+ }
661
+
662
+ // ─── HTTP Server ──────────────────────────────────────────────────────────
663
+ const server = createServer(async (req, res) => {
664
+ const url = new URL(req.url, "http://localhost");
665
+ const rawPath = url.pathname;
666
+
667
+ // Extract variant label from URL path.
668
+ // API paths (starting with /api/) are not variant-routed — they use ?variant= query param.
669
+ // Everything else gets variant routing from the first path segment.
670
+ const isApiPath = rawPath.startsWith("/api/");
671
+ let path, variantLabel;
672
+
673
+ if (isApiPath) {
674
+ path = rawPath;
675
+ variantLabel = url.searchParams.get("variant") || "default";
676
+ } else {
677
+ const parsed = parseVariantFromPath(rawPath);
678
+ variantLabel = parsed.variant;
679
+ path = parsed.subpath;
680
+ }
681
+
682
+ try {
683
+ // API: Get or save labels (label mode)
684
+ if (path === "/api/labels") {
685
+ const labelsPath = join(dirname(VIDEO_JSON), "labels.json");
686
+ if (req.method === "GET") {
687
+ try {
688
+ const data = readFileSync(labelsPath, "utf-8");
689
+ res.writeHead(200, { "Content-Type": "application/json" });
690
+ res.end(data);
691
+ } catch (e) {
692
+ res.writeHead(200, { "Content-Type": "application/json" });
693
+ res.end(JSON.stringify({ labels: [], scenes: [] }));
694
+ }
695
+ return;
696
+ }
697
+ if (req.method === "POST") {
698
+ let body = "";
699
+ req.on("data", c => body += c);
700
+ req.on("end", () => {
701
+ try {
702
+ writeFileSync(labelsPath, body, "utf-8");
703
+ res.writeHead(200, { "Content-Type": "application/json" });
704
+ res.end(JSON.stringify({ saved: true, path: labelsPath }));
705
+ } catch (e) {
706
+ res.writeHead(500, { "Content-Type": "application/json" });
707
+ res.end(JSON.stringify({ error: e.message }));
708
+ }
709
+ });
710
+ return;
711
+ }
712
+ }
713
+
714
+ // API: Shutdown — kill the server, return control to terminal
715
+ if (path === "/api/shutdown") {
716
+ handleShutdown(req, res, "Close requested from browser — shutting down");
717
+ return;
718
+ }
719
+
720
+ // API: Feedback from user
721
+ if (path === "/api/feedback" && req.method === "POST") {
722
+ let body = "";
723
+ req.on("data", c => body += c);
724
+ req.on("end", () => {
725
+ try {
726
+ const { text } = JSON.parse(body);
727
+ const line = `[${new Date().toISOString()}] ${text}`;
728
+ console.log(`\n 💬 USER FEEDBACK: ${text}\n`);
729
+ try { writeFileSync(join(dirname(VIDEO_JSON), "feedback.txt"), line + "\n", { flag: "a" }); } catch {}
730
+ res.writeHead(200, { "Content-Type": "application/json" });
731
+ res.end(JSON.stringify({ received: true }));
732
+ } catch (e) {
733
+ res.writeHead(400, { "Content-Type": "application/json" });
734
+ res.end(JSON.stringify({ error: e.message }));
735
+ }
736
+ });
737
+ return;
738
+ }
739
+
740
+ // API: Edit — call pi one-shot to edit the JSON, player auto-reloads
741
+ if (path === "/api/edit" && req.method === "POST") {
742
+ let body = "";
743
+ req.on("data", c => body += c);
744
+ req.on("end", () => {
745
+ try {
746
+ const { text } = JSON.parse(body);
747
+ if (!text) { res.writeHead(400); res.end(JSON.stringify({ error: "empty text" })); return; }
748
+
749
+ editHistory.push(text);
750
+
751
+ // Build tree structure description from current JSON (recursive, any depth)
752
+ let treeInfo = "";
753
+ try {
754
+ const raw = readFileSync(VIDEO_JSON, "utf-8");
755
+ const parsed = JSON.parse(raw);
756
+ const root = parsed.root || parsed;
757
+
758
+ function describeNode(node, depth) {
759
+ const indent = " ".repeat(depth);
760
+ const id = node.id || "";
761
+ const name = node.name || "";
762
+ const label = name || id;
763
+ const type = node.type || "unknown";
764
+ const dur = node.durationInSeconds !== undefined ? `, ${node.durationInSeconds}s` : "";
765
+
766
+ let line = `${indent}${type} "${label}"`;
767
+
768
+ if (type === "root") {
769
+ line += ` (${node.width}x${node.height}, ${node.fps}fps${node.isSeries ? ", series" : ""}${node.transition ? `, transition:${node.transition}` : ""}${node.theme ? `, theme:${node.theme}` : ""})`;
770
+ } else if (type === "folder") {
771
+ line += ` (${node.isSeries ? "series" : "parallel"}${node.transition ? `, transition:${node.transition}` : ""}${dur})`;
772
+ } else if (type === "component") {
773
+ const props = node.props ? JSON.stringify(Object.fromEntries(Object.entries(node.props).filter(([k]) => !k.startsWith("_")))) : "{}";
774
+ line += ` ${node.componentName}(${props.slice(0, 100)})${dur}`;
775
+ } else if (type === "subtitle") {
776
+ const txt = (node.src || "").slice(0, 60);
777
+ line += ` "${txt}"${dur}`;
778
+ } else if (type === "video" || type === "audio" || type === "image") {
779
+ const src = (node.src || "").slice(0, 50);
780
+ line += ` "${src}"${dur}`;
781
+ } else if (type === "effect") {
782
+ line += ` animation:${node.animation || "custom"}${dur}`;
783
+ } else if (type === "rhythm") {
784
+ line += ` src:"${(node.src || "").slice(0, 40)}"${dur}`;
785
+ } else if (type === "map") {
786
+ line += ` waypoints:${(node.waypoints || []).length}${dur}`;
787
+ } else if (type === "include") {
788
+ line += ` src:"${(node.src || "").slice(0, 50)}"${dur}`;
789
+ }
790
+
791
+ // Add timing info for leaf actions
792
+ if (node.actions && node.actions.length > 0) {
793
+ const act = node.actions[0];
794
+ line += ` [${act.start}→${act.end}s`;
795
+ if (node.isBackground) line += ", bg";
796
+ if (act.volume !== undefined) line += `, vol:${act.volume}`;
797
+ if (act.style) line += `, style:"${act.style.slice(0, 40)}"`;
798
+ if (act.loop) line += `, loop:${act.loop}`;
799
+ line += `]`;
800
+ } else if (node.isBackground) {
801
+ line += ` [bg]`;
802
+ }
803
+
804
+ // Add notable fields
805
+ const extras = [];
806
+ if (node.componentName) extras.push(node.componentName);
807
+ if (node.fit) extras.push(`fit:${node.fit}`);
808
+ if (node.fontSize) extras.push(`fontSize:${node.fontSize}`);
809
+ if (node.volume !== undefined && type !== "root") extras.push(`vol:${node.volume}`);
810
+ if (node.playbackRate) extras.push(`rate:${node.playbackRate}`);
811
+ if (node.style) extras.push(`style:"${node.style.slice(0, 40)}"`);
812
+ if (node.transitionTime !== undefined) extras.push(`transTime:${node.transitionTime}`);
813
+ if (node.visible === false) extras.push("hidden");
814
+ if (extras.length > 0) line += ` {${extras.join(", ")}}`;
815
+
816
+ return line;
817
+ }
818
+
819
+ function walkTree(node, depth = 0) {
820
+ const lines = [describeNode(node, depth)];
821
+ if (node.children && node.children.length > 0) {
822
+ const shown = node.children.filter(c => c.visible !== false || c.visible === undefined);
823
+ for (const child of shown) {
824
+ lines.push(...walkTree(child, depth + 1));
825
+ }
826
+ }
827
+ return lines;
828
+ }
829
+
830
+ treeInfo = walkTree(root).join("\n");
831
+ } catch {}
832
+
833
+ const historyStr = editHistory.length > 1
834
+ ? "\nPrevious edits on this file (in order):\n" + editHistory.slice(0, -1).map((e, i) => `${i+1}. ${e}`).join("\n") + "\n"
835
+ : "";
836
+
837
+ const prompt = `You are editing ${VIDEO_JSON.split("/").pop()}, a Remotion stream tree JSON.
838
+
839
+ The stream tree (indentation shows nesting; timing in seconds):
840
+ ${treeInfo || "(could not read tree)"}
841
+ ${historyStr}
842
+ Edit request: ${text}
843
+
844
+ --- Knowledge ---
845
+ You can edit ANY field on ANY node in the JSON. Common fields across all types:
846
+ - id, name, type, style (inline CSS string), visible (boolean)
847
+
848
+ Stream types:
849
+ root: {width, height, fps, isSeries, transition, transitionTime, theme, stylesheet, children}
850
+ folder: {isSeries (parallel if false), transition, transitionTime, children}
851
+ video: {src, volume, playbackRate, width, height, actions}
852
+ audio: {src, volume, foreground (ducks parent video), actions}
853
+ image: {src, fit (contain/cover/fill), actions}
854
+ subtitle: {src (text or VTT), cues[], fontSize, fontStyle, style, actions}
855
+ component: {componentName, props:{}, src (remote URL), actions}
856
+ effect: {animation (builtin name or "custom"), animationTimingFunction, animationIterationCount, customKeyframes, children, actions}
857
+ rhythm: {src (audio), volume, spots[] (beat timestamps), children, actions}
858
+ map: {waypoints[{lat,lng,label?,media?}], routeColor, routeWeight, markerSrc, zoom, actions}
859
+ include: src (video JSON file path/URL), volume, actions — embeds an external video composition referenced by src. Falls back to inline children (legacy).
860
+
861
+ Actions (on leaf types): [{start, end, style?, volume?, effectId?, loop?}] — start/end in seconds, relative to parent container
862
+
863
+ Composition rules:
864
+ - isSeries=true → children play sequentially (one after another), with optional transition between them
865
+ - isSeries=false → children play in parallel, max duration wins (default)
866
+ - isBackground=true → node loops for the full duration of its parent, excluded from duration calc
867
+ - transition can be: "fade"|"slide"|"wipe"|"flip"|"clockWipe"
868
+
869
+ Subtitle styling: style field supports CSS (e.g. "color:#fff;font-size:48px"). fontSize field for quick sizing. Supports HTML in src for rich text. For word-highlight karaoke: set className:"karaoke" on cue, or provide words[{text,start,end}] array.
870
+
871
+ Themes: set root.theme = "cinematic"|"minimal"|"neon"|"corporate" or an inline theme JSON object. Default is "cinematic".
872
+ Global stylesheet: root.stylesheet = "CSS string" — selectors use .type and .name class names on each node.
873
+
874
+ IMPORTANT: Read the full existing JSON file before editing. Only edit the JSON file. You can change, add, or remove any field on any node. Output ONLY a one-line summary of what specific change you made. Do not add explanations.`;
875
+
876
+ console.log(` 🤖 pi edit: ${text}`);
877
+ const child = spawn("pi", ["-p", prompt], {
878
+ cwd: ROOT,
879
+ stdio: ["ignore", "pipe", "pipe"],
880
+ });
881
+
882
+ let output = "";
883
+ child.stdout.on("data", d => output += d);
884
+ child.stderr.on("data", d => output += d);
885
+
886
+ child.on("exit", (code) => {
887
+ if (code === 0) {
888
+ console.log(` ✅ pi edit complete`);
889
+ res.writeHead(200, { "Content-Type": "application/json" });
890
+ res.end(JSON.stringify({ done: true, output: output.trim() }));
891
+ } else {
892
+ console.error(` ❌ pi edit failed (exit ${code}): ${output.trim()}`);
893
+ res.writeHead(500, { "Content-Type": "application/json" });
894
+ res.end(JSON.stringify({ error: `pi exited with code ${code}`, output: output.trim() }));
895
+ }
896
+ });
897
+ } catch (e) {
898
+ res.writeHead(500, { "Content-Type": "application/json" });
899
+ res.end(JSON.stringify({ error: e.message }));
900
+ }
901
+ });
902
+ return;
903
+ }
904
+
905
+ // API: SSE stream for reload notifications
906
+ // When the browser tab closes, this connection drops → server shuts down
907
+ // Grace period: wait 3s for reconnection (page reload), then exit
908
+ if (path === "/api/events") {
909
+ res.writeHead(200, {
910
+ "Content-Type": "text/event-stream",
911
+ "Cache-Control": "no-cache",
912
+ "Connection": "keep-alive",
913
+ });
914
+ sseClients.add(res);
915
+ if (shutdownTimer) {
916
+ clearTimeout(shutdownTimer);
917
+ shutdownTimer = null;
918
+ }
919
+ req.on("close", () => {
920
+ sseClients.delete(res);
921
+ if (MODE_EDIT && sseClients.size === 0) {
922
+ shutdownTimer = setTimeout(() => {
923
+ console.error("\n 🚪 Browser tab closed — shutting down\n");
924
+ process.exit(0);
925
+ }, 3000);
926
+ }
927
+ });
928
+ return;
929
+ }
930
+
931
+ // API: Get video.json data for a specific variant
932
+ if (path === "/api/video-data") {
933
+ try {
934
+ const root = await loadCompiledRoot(variantLabel);
935
+ const rootOut = resolveAssetPaths(root);
936
+ res.writeHead(200, { "Content-Type": "application/json" });
937
+ res.end(JSON.stringify(rootOut));
938
+ } catch (e) {
939
+ res.writeHead(500, { "Content-Type": "application/json" });
940
+ res.end(JSON.stringify({ error: e.message }));
941
+ }
942
+ return;
943
+ }
944
+
945
+ // API: Get scenes with media info for a specific variant
946
+ if (path === "/api/scenes") {
947
+ const { scenes, totalDuration } = getScenes(variantLabel);
948
+ res.writeHead(200, { "Content-Type": "application/json" });
949
+ res.end(JSON.stringify(scenes));
950
+ return;
951
+ }
952
+
953
+ // API: Get current video info for a specific variant
954
+ if (path === "/api/video-info") {
955
+ const { scenes, totalDuration } = getScenes(variantLabel);
956
+ res.writeHead(200, { "Content-Type": "application/json" });
957
+ res.end(JSON.stringify({
958
+ scenes,
959
+ totalDuration,
960
+ variant: variantLabel,
961
+ variants: VARIANT_CONFIGS.map(c => c.label),
962
+ mode: { label: MODE_LABEL, edit: MODE_EDIT },
963
+ }));
964
+ return;
965
+ }
966
+
967
+ // Serve the main HTML page (variant-aware)
968
+ if (path === "/" || path === "/index.html") {
969
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
970
+ res.end(getHtml(variantLabel));
971
+ return;
972
+ }
973
+
974
+ // Serve static files
975
+ const assetPath = resolveAsset(path, variantLabel);
976
+ if (assetPath && serveFile(req, res, assetPath)) return;
977
+
978
+ res.writeHead(404);
979
+ res.end("Not found");
980
+ } catch (err) {
981
+ try {
982
+ res.writeHead(500, { "Content-Type": "text/plain" });
983
+ res.end("Server error: " + err.message);
984
+ } catch {
985
+ // Response already sent
986
+ }
987
+ }
988
+ });
989
+
990
+ server.listen(PORT, async () => {
991
+ try {
992
+ await initScenesPromise;
993
+ } catch {
994
+ // Pipeline failed — still announce ready so the player can show an error state
995
+ }
996
+ const mode = MODE_LABEL ? " --label" : MODE_EDIT ? " --edit" : "";
997
+ console.log(`\n🎬 Player ready at http://localhost:${PORT}${mode}`);
998
+ if (VARIANT_CONFIGS.length > 1) {
999
+ for (const config of VARIANT_CONFIGS) {
1000
+ const url = config.label === "default" ? `http://localhost:${PORT}` : `http://localhost:${PORT}/${config.label}`;
1001
+ console.log(` ${config.label}: ${url}`);
1002
+ }
1003
+ }
1004
+ if (MODE_EDIT) console.log(` Watching: ${VIDEO_JSON.split("/").pop()}`);
1005
+ console.log("");
1006
+ });