@lalalic/markcut 1.2.0 → 2.1.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 (61) hide show
  1. package/AGENTS.md +40 -17
  2. package/README.md +7 -3
  3. package/SKILL.md +0 -2
  4. package/docs/edit-mode.md +1 -1
  5. package/docs/label-mode.md +6 -4
  6. package/docs/markdown-descriptive.md +34 -1
  7. package/docs/system-prompt-edit.md +19 -0
  8. package/package.json +1 -1
  9. package/src/config.mjs +8 -3
  10. package/src/descriptive/compiler.test.ts +42 -42
  11. package/src/descriptive/compiler.ts +41 -52
  12. package/src/descriptive/markdown.ts +8 -4
  13. package/src/descriptive/resolve.test.ts +14 -7
  14. package/src/descriptive/resolve.ts +148 -20
  15. package/src/player/browser.tsx +178 -54
  16. package/src/player/bundle/player.js +1149 -566
  17. package/src/player/components/EditControls.tsx +92 -0
  18. package/src/player/components/HeaderBar.tsx +60 -0
  19. package/src/player/components/LabelControls.tsx +367 -0
  20. package/src/player/components/SceneThumbnails.tsx +60 -0
  21. package/src/player/components/VariantBar.tsx +39 -0
  22. package/src/player/components/index.ts +5 -0
  23. package/src/player/pipeline.mjs +130 -66
  24. package/src/player/pipeline.ts +3 -1
  25. package/src/player/server-shared.mjs +5 -7
  26. package/src/player/server.mjs +336 -239
  27. package/src/render/cli.mjs +4 -6
  28. package/src/schema/index.ts +20 -23
  29. package/src/types/Audio.tsx +25 -33
  30. package/src/types/Component.tsx +18 -24
  31. package/src/types/Effect.tsx +31 -39
  32. package/src/types/Image.tsx +23 -30
  33. package/src/types/Include.tsx +70 -76
  34. package/src/types/Map.tsx +48 -44
  35. package/src/types/Rhythm.tsx +19 -27
  36. package/src/types/Video.tsx +40 -47
  37. package/src/utils/index.ts +23 -10
  38. package/src/vision/cli.mjs +6 -6
  39. package/templates/courseware/TEMPLATE.md +16 -10
  40. package/tests/fixtures/audio.json +4 -2
  41. package/tests/fixtures/basic.json +2 -1
  42. package/tests/fixtures/component-all.json +4 -2
  43. package/tests/fixtures/components.json +4 -2
  44. package/tests/fixtures/effects.json +12 -6
  45. package/tests/fixtures/full.json +8 -4
  46. package/tests/fixtures/map.json +2 -1
  47. package/tests/fixtures/md/dialogue.md +12 -0
  48. package/tests/fixtures/md/edge-cases.md +9 -0
  49. package/tests/fixtures/scenes.json +4 -2
  50. package/tests/fixtures/subtitle.json +6 -3
  51. package/tests/fixtures/subvideo.json +11 -6
  52. package/tests/fixtures/templates/courseware.md +61 -4
  53. package/tests/fixtures/tween-visual.json +2 -6
  54. package/tests/fixtures/video-series.json +6 -14
  55. package/tests/md-descriptive.test.ts +170 -0
  56. package/tests/render.test.ts +32 -16
  57. package/tests/schema.test.ts +6 -3
  58. package/tests/server.test.ts +9 -6
  59. package/tests/utils.ts +4 -4
  60. package/scripts/artlist-dl.mjs +0 -190
  61. package/src/player/label-server.mjs +0 -599
@@ -17,6 +17,8 @@ import { fileURLToPath } from "node:url";
17
17
  import { isDescriptiveRoot, resolveAndCompile, resolveAndCompileMarkdown, parseImportsBlock, extractDependencySpecs } from "./pipeline.mjs";
18
18
  import { bundleFromEntries } from "./bundler.mjs";
19
19
  import { extractScenes, MIME, serveFile, handleShutdown } from "./server-shared.mjs";
20
+ import { DEFAULT_AGENT_CLI } from "../config.mjs";
21
+
20
22
 
21
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
22
24
  const ROOT = resolve(__dirname, "..", "..");
@@ -76,6 +78,180 @@ let shutdownTimer = null;
76
78
  // ─── Label store for label mode ───────────────────────────────────────────
77
79
  let labels = [];
78
80
 
81
+ // ─── Agent process (rpc mode) for --edit mode ─────────────────────────────
82
+ // A persistent `pi --mode rpc` subprocess: ONE cold start, MANY edit turns.
83
+ // The agent keeps conversation memory across edits (fast, no re-spawn).
84
+ //
85
+ // JSON-lines protocol over stdio:
86
+ // request: {"id":N,"type":"prompt","message":"..."}
87
+ // response: {"id":N,"type":"response","command":"prompt","success":true} (accepted)
88
+ // events: streamed; a turn ENDS at {"type":"agent_settled"}
89
+ // We collect assistant text from {"type":"message_end", message:{role:"assistant"}}
90
+ // events and resolve the pending /api/edit call on agent_settled.
91
+ let agentProcess = null;
92
+ let agentBusy = false;
93
+ let agentLineBuf = ""; // partial stdout line buffer
94
+ let agentTurnText = ""; // accumulated assistant text for the current turn
95
+ let agentTurnTimer = null;
96
+ /** Resolve/reject for the pending edit API call */
97
+ let pendingEditResolver = null;
98
+ const AGENT_TURN_TIMEOUT = 300000; // 5min safety net per turn
99
+
100
+ /** Pull text out of an assistant message_end event's content blocks. */
101
+ function extractAssistantText(message) {
102
+ if (!message || message.role !== "assistant") return "";
103
+ const content = Array.isArray(message.content) ? message.content : [];
104
+ return content
105
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
106
+ .map((b) => b.text)
107
+ .join("");
108
+ }
109
+
110
+ /** Resolve the pending edit call and reset per-turn state. */
111
+ function finishAgentTurn(result) {
112
+ if (agentTurnTimer) { clearTimeout(agentTurnTimer); agentTurnTimer = null; }
113
+ agentBusy = false;
114
+ const turnText = agentTurnText;
115
+ agentTurnText = "";
116
+ const resolver = pendingEditResolver;
117
+ pendingEditResolver = null;
118
+ if (resolver) resolver({ ...result, output: result.output != null ? result.output : turnText });
119
+ }
120
+
121
+ /** Dispatch one parsed JSON line emitted on the agent's stdout. */
122
+ function handleAgentLine(obj) {
123
+ if (!obj || typeof obj !== "object") return;
124
+ const t = obj.type;
125
+ if (t === "message_end") {
126
+ const txt = extractAssistantText(obj.message);
127
+ if (txt) agentTurnText += (agentTurnText ? "\n" : "") + txt;
128
+ return;
129
+ }
130
+ if (t === "agent_settled" && pendingEditResolver) {
131
+ finishAgentTurn({ ok: true });
132
+ return;
133
+ }
134
+ if (t === "response" && obj.command === "prompt" && pendingEditResolver) {
135
+ // Preflight result: success means "accepted, events incoming"; failure aborts.
136
+ if (obj.success === false) finishAgentTurn({ ok: false, error: obj.error || "prompt rejected" });
137
+ return;
138
+ }
139
+ if (t === "extension_error" && obj.error) {
140
+ console.warn(` ⚠ agent extension error: ${String(obj.error).substring(0, 120)}`);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Start the persistent agent subprocess in rpc mode.
146
+ * The system prompt is fixed for the server lifetime (it only references the
147
+ * constant file path); per-edit context rides on each prompt's message.
148
+ */
149
+ function startAgentProcess() {
150
+ if (!MODE_EDIT) return;
151
+
152
+ const agentCli = process.env.MARKCUT_AGENT_CLI || DEFAULT_AGENT_CLI;
153
+
154
+ const fileLabel = VIDEO_JSON.split("/").pop();
155
+ const sessionId = VIDEO_JSON.replace(/[^a-zA-Z0-9\-_.]/g, "_").replace(/^[^a-zA-Z0-9]+/, "").replace(/[^a-zA-Z0-9]+$/, "");
156
+ const promptTemplate = readFileSync(join(ROOT, "docs", "system-prompt-edit.md"), "utf-8");
157
+ const systemPrompt = promptTemplate
158
+ .replace(/@\{([^}]+)\}/g, (_, refPath) => {
159
+ try { return readFileSync(resolve(ROOT, refPath.trim()), "utf-8"); }
160
+ catch { console.warn(` ⚠ could not read @{${refPath}}`); return `[Missing: ${refPath}]`; }
161
+ })
162
+ .replace(/\$\{fileName\}/g, fileLabel)
163
+ .replace(/\$\{filePath\}/g, VIDEO_JSON);
164
+
165
+ // Build the CLI command — strip -p/--prompt (rpc takes prompts via stdin) and
166
+ // force --mode rpc. e.g.
167
+ // "npx pi --session-id {sessionid} --system-prompt {systemprompt} -p {prompt}"
168
+ // → "npx pi --session-id SID --system-prompt SP --mode rpc"
169
+ const parts = agentCli.split(/\s+/).filter(Boolean);
170
+ const resolvedParts = [];
171
+ for (let i = 0; i < parts.length; i++) {
172
+ const p = parts[i];
173
+ if (p === "-p" || p === "--prompt") { i++; continue; } // drop prompt flag + value
174
+ const resolved = p
175
+ .replace(/\{sessionid\}/g, sessionId)
176
+ .replace(/\{systemprompt\}/g, systemPrompt)
177
+ .replace(/\{prompt\}/g, "");
178
+ if (resolved) resolvedParts.push(resolved);
179
+ }
180
+ if (!resolvedParts.includes("--mode")) resolvedParts.push("--mode", "rpc");
181
+ const cmd = resolvedParts[0];
182
+ const cmdArgs = resolvedParts.slice(1);
183
+
184
+ console.log(` 🎬 starting persistent agent (rpc): ${cmd} ${cmdArgs.slice(0, -1).join(" ")} --system-prompt <${systemPrompt.length} chars>`);
185
+
186
+ const child = spawn(cmd, cmdArgs, { cwd: ROOT, stdio: ["pipe", "pipe", "pipe"] });
187
+ agentProcess = child;
188
+ agentLineBuf = "";
189
+
190
+ // stdout is pure JSON-lines in rpc mode (it takes over stdout). Parse line by line.
191
+ child.stdout.on("data", (chunk) => {
192
+ agentLineBuf += chunk.toString();
193
+ let nl;
194
+ while ((nl = agentLineBuf.indexOf("\n")) >= 0) {
195
+ const line = agentLineBuf.slice(0, nl).trim();
196
+ agentLineBuf = agentLineBuf.slice(nl + 1);
197
+ if (!line) continue;
198
+ let obj;
199
+ try { obj = JSON.parse(line); } catch { continue; } // not JSON — ignore
200
+ handleAgentLine(obj);
201
+ }
202
+ });
203
+
204
+ child.stderr.on("data", (chunk) => { process.stderr.write(chunk); });
205
+
206
+ child.on("exit", (code) => {
207
+ console.log(` ⚫ agent process exited (code ${code})`);
208
+ agentProcess = null;
209
+ if (pendingEditResolver) finishAgentTurn({ ok: false, error: `agent exited (code ${code})` });
210
+ agentBusy = false;
211
+ // Keep the session warm: restart on any exit while in edit mode.
212
+ // The --session-id resumes conversation history from disk.
213
+ if (MODE_EDIT) {
214
+ console.log(` 🔄 restarting agent in 1s...`);
215
+ setTimeout(() => startAgentProcess(), 1000);
216
+ }
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Send an edit prompt to the persistent rpc agent and resolve when the turn
222
+ * settles. Completion is signalled authoritatively by the `agent_settled`
223
+ * event — no idle-timeout guessing.
224
+ * @param {string} prompt
225
+ * @returns {Promise<{ok: boolean, output: string, error?: string}>}
226
+ */
227
+ function sendToAgent(prompt) {
228
+ return new Promise((resolve) => {
229
+ if (!agentProcess || agentProcess.killed) {
230
+ resolve({ ok: false, output: "", error: "agent process not running" });
231
+ return;
232
+ }
233
+ if (agentBusy) {
234
+ resolve({ ok: false, output: "", error: "agent is busy" });
235
+ return;
236
+ }
237
+
238
+ agentBusy = true;
239
+ agentTurnText = "";
240
+ pendingEditResolver = resolve;
241
+
242
+ // Safety net — the agent_settled event is the real completion signal.
243
+ agentTurnTimer = setTimeout(() => {
244
+ console.warn(` ⏰ agent turn timed out after ${AGENT_TURN_TIMEOUT / 1000}s`);
245
+ finishAgentTurn({ ok: false, output: agentTurnText + "\n[TIMEOUT]", error: "agent turn timed out" });
246
+ }, AGENT_TURN_TIMEOUT);
247
+
248
+ const id = Date.now();
249
+ const req = JSON.stringify({ id, type: "prompt", message: prompt }) + "\n";
250
+ console.log(` 📤 rpc prompt (id=${id}): ${prompt.substring(0, 80).replace(/\n/g, " ")}`);
251
+ agentProcess.stdin.write(req);
252
+ });
253
+ }
254
+
79
255
  // ─── Edit history for context ─────────────────────────────────────────────
80
256
  let editHistory = [];
81
257
 
@@ -448,6 +624,29 @@ async function compileAndExtractScenes() {
448
624
  console.error(` ⚠ Could not extract scenes for "${config.label}":`, e.message);
449
625
  }
450
626
  }
627
+
628
+ // Merge existing labels from labels.json into the compiled root (label mode)
629
+ if (MODE_LABEL) {
630
+ const labelsPath = join(dirname(VIDEO_JSON), "labels.json");
631
+ try {
632
+ const labelsRaw = readFileSync(labelsPath, "utf-8");
633
+ const labelsData = JSON.parse(labelsRaw);
634
+ const labelsRoot = labelsData.root || labelsData;
635
+ const root = compiledRootCache.get("default");
636
+ if (labelsRoot.children && root?.children) {
637
+ for (let i = 0; i < Math.min(labelsRoot.children.length, root.children.length); i++) {
638
+ const labelMedia = labelsRoot.children[i]?.children?.[0];
639
+ const targetMedia = root.children[i]?.children?.[0];
640
+ if (labelMedia && targetMedia && labelMedia.description) {
641
+ targetMedia.description = labelMedia.description;
642
+ }
643
+ }
644
+ }
645
+ console.log(` 🏷️ Merged ${Object.keys(labelsRoot.children || {}).length} existing labels from labels.json`);
646
+ } catch {
647
+ // No existing labels file — that's fine
648
+ }
649
+ }
451
650
  }
452
651
 
453
652
  function getScenes(label) {
@@ -536,29 +735,25 @@ function resolveAsset(urlPath, variantLabel) {
536
735
  }
537
736
 
538
737
  // ─── HTML page ───────────────────────────────────────────────────────────
738
+ // Minimal HTML shell. All UI is rendered by React (player.js bundle).
739
+ // Mode-specific controls are in src/player/components/.
539
740
  function getHtml(variantLabel) {
540
741
  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("");
742
+ const mode = MODE_LABEL ? "label" : MODE_EDIT ? "edit" : "preview";
549
743
 
550
744
  return `<!DOCTYPE html>
551
745
  <html lang="en">
552
746
  <head>
553
747
  <meta charset="UTF-8">
554
748
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
555
- <title>Remotion Player${title}</title>
749
+ <title>Remotion Player${mode !== "preview" ? " — " + mode.charAt(0).toUpperCase() + mode.slice(1) : ""}</title>
556
750
  <style>
557
751
  * { margin: 0; padding: 0; box-sizing: border-box; }
558
752
  html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a0a; }
559
753
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; flex-direction: column; align-items: center; }
560
754
  #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; }
755
+ #header-status, #edit-status { font-size: 11px; color: rgba(255,255,255,.4); flex: 1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
756
+ #scene-info { font-size: 11px; color: rgba(255,255,255,.4); flex: 1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
562
757
  #header-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
563
758
  #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
759
  #close-btn:hover { background: rgba(255,60,60,.4); border-color: rgba(255,60,60,.5); color: #fff; }
@@ -571,90 +766,44 @@ function getHtml(variantLabel) {
571
766
  #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
767
  #reload-toast.show { opacity: 1; }
573
768
  #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; }
769
+ #edit-input, #label-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; }
770
+ #edit-input:focus, #label-input:focus { border-color: rgba(74,158,255,.5); }
771
+ #edit-input::placeholder, #label-input::placeholder { color: rgba(255,255,255,.25); }
772
+ #edit-btn, #label-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; }
773
+ #edit-btn:hover, #label-btn:hover { background: rgba(74,158,255,.2); border-color: rgba(74,158,255,.4); color: #4a9eff; }
774
+ #edit-btn:disabled, #label-btn:disabled { opacity: 0.3; cursor: wait; }
775
+ #thumbnails { display: flex; gap: 6px; width: 100%; max-width: 500px; padding: 4px 12px; flex-shrink: 0; overflow-x: auto; scrollbar-width: thin; }
776
+ #thumbnails::-webkit-scrollbar { height: 4px; }
777
+ #thumbnails::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 2px; }
778
+ .thumb-item { flex-shrink: 0; width: 64px; height: 48px; border-radius: 6px; overflow: hidden; cursor: pointer; border: 2px solid transparent; transition: all .15s; position: relative; background: rgba(255,255,255,.05); }
779
+ .thumb-item:hover { border-color: rgba(74,158,255,.4); }
780
+ .thumb-item.active { border-color: #4a9eff; box-shadow: 0 0 8px rgba(74,158,255,.3); }
781
+ .thumb-item img { width: 100%; height: 100%; object-fit: cover; }
782
+ .thumb-badge { position: absolute; top: 2px; right: 2px; width: 10px; height: 10px; border-radius: 50%; background: #4ade80; border: 1px solid rgba(0,0,0,.4); display: none; }
783
+ .thumb-badge.has-label { display: block; }
784
+ #timed-labels { width: 100%; max-width: 500px; padding: 2px 12px; flex-shrink: 0; display: flex; flex-direction: column; gap: 2px; max-height: 80px; overflow-y: auto; }
785
+ .timed-label { display: flex; align-items: center; gap: 6px; padding: 3px 6px; border-radius: 4px; background: rgba(255,255,255,.04); font-size: 11px; color: rgba(255,255,255,.6); }
786
+ .timed-label .tl-time { flex-shrink: 0; font-family: monospace; font-size: 10px; color: rgba(74,158,255,.7); min-width: 32px; }
787
+ .timed-label .tl-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
788
+ .timed-label .tl-del { width: 16px; height: 16px; border: none; background: rgba(255,60,60,.15); color: rgba(255,60,60,.5); border-radius: 3px; cursor: pointer; font-size: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; padding: 0; line-height: 1; }
789
+ .timed-label .tl-del:hover { background: rgba(255,60,60,.3); color: #fff; }
790
+ #saved-toast { position: fixed; bottom: 70px; left: 50%; transform: translateX(-50%); background: rgba(74,222,128,.9); color: #fff; padding: 8px 16px; border-radius: 8px; font-size: 12px; font-weight: 500; opacity: 0; transition: opacity .3s; pointer-events: none; z-index: 200; backdrop-filter: blur(8px); }
791
+ #saved-toast.show { opacity: 1; }
792
+ #scene-thumbnails { display: flex; gap: 6px; width: 100%; max-width: 500px; padding: 4px 12px; flex-shrink: 0; overflow-x: auto; scrollbar-width: thin; }
793
+ #scene-thumbnails::-webkit-scrollbar { height: 4px; }
794
+ #scene-thumbnails::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 2px; }
795
+ .scene-pill { flex-shrink: 0; padding: 4px 12px; border-radius: 12px; cursor: pointer; border: 1px solid transparent; transition: all .15s; background: rgba(255,255,255,.06); color: rgba(255,255,255,.45); font-size: 11px; white-space: nowrap; }
796
+ .scene-pill:hover { border-color: rgba(74,158,255,.4); color: rgba(255,255,255,.7); }
797
+ .scene-pill.active { background: rgba(74,158,255,.2); border-color: #4a9eff; color: #4a9eff; }
580
798
  </style>
581
799
  </head>
582
800
  <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>` : ""}
801
+ <script>
802
+ window.VARIANT = "${label}";
803
+ window.MODE = "${mode}";
804
+ </script>
805
+ <div id="root"></div>
599
806
  <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
807
  </body>
659
808
  </html>`;
660
809
  }
@@ -684,13 +833,25 @@ const server = createServer(async (req, res) => {
684
833
  if (path === "/api/labels") {
685
834
  const labelsPath = join(dirname(VIDEO_JSON), "labels.json");
686
835
  if (req.method === "GET") {
836
+ // Return the stream tree — descriptions on children are the labels
687
837
  try {
688
838
  const data = readFileSync(labelsPath, "utf-8");
839
+ const parsed = JSON.parse(data);
840
+ if (parsed.type || parsed.root) {
841
+ res.writeHead(200, { "Content-Type": "application/json" });
842
+ res.end(data);
843
+ return;
844
+ }
845
+ } catch { /* labels.json missing or invalid — fall through */ }
846
+ // Fall back to compiled root for the default variant
847
+ try {
848
+ const root = await loadCompiledRoot("default");
849
+ const tree = resolveAssetPaths(root);
689
850
  res.writeHead(200, { "Content-Type": "application/json" });
690
- res.end(data);
691
- } catch (e) {
851
+ res.end(JSON.stringify(tree));
852
+ } catch {
692
853
  res.writeHead(200, { "Content-Type": "application/json" });
693
- res.end(JSON.stringify({ labels: [], scenes: [] }));
854
+ res.end(JSON.stringify({ type: "root", children: [] }));
694
855
  }
695
856
  return;
696
857
  }
@@ -699,7 +860,38 @@ const server = createServer(async (req, res) => {
699
860
  req.on("data", c => body += c);
700
861
  req.on("end", () => {
701
862
  try {
702
- writeFileSync(labelsPath, body, "utf-8");
863
+ const data = JSON.parse(body);
864
+
865
+ // Granular label update: { sceneIndex, description, time, overall, removeTimed }
866
+ if (typeof data.sceneIndex === "number") {
867
+ // Load the compiled root to apply labels to the live tree
868
+ loadCompiledRoot("default").then((root) => {
869
+ const child = root?.children?.[data.sceneIndex];
870
+ const media = child?.children?.[0];
871
+ if (media) {
872
+ media.userHints = media.userHints || { overall: "", timed: {} };
873
+ if (data.removeTimed) {
874
+ delete media.userHints.timed[data.removeTimed];
875
+ } else if (typeof data.description === "string") {
876
+ if (data.overall) {
877
+ media.userHints.overall = data.description;
878
+ } else {
879
+ const timeMs = Math.round((data.time || 0) * 1000);
880
+ media.userHints.timed["at_" + timeMs] = data.description;
881
+ }
882
+ }
883
+ media.description = media.userHints.overall || Object.values(media.userHints.timed)[0] || undefined;
884
+ }
885
+ // Save the full tree to labels.json
886
+ writeFileSync(labelsPath, JSON.stringify(root, null, 2), "utf-8");
887
+ }).catch(() => {
888
+ // If no compiled root, save the update as minimal labels.json
889
+ writeFileSync(labelsPath, body, "utf-8");
890
+ });
891
+ } else {
892
+ // Full labels.json body — save directly
893
+ writeFileSync(labelsPath, body, "utf-8");
894
+ }
703
895
  res.writeHead(200, { "Content-Type": "application/json" });
704
896
  res.end(JSON.stringify({ saved: true, path: labelsPath }));
705
897
  } catch (e) {
@@ -737,163 +929,60 @@ const server = createServer(async (req, res) => {
737
929
  return;
738
930
  }
739
931
 
740
- // API: Edit — call pi one-shot to edit the JSON, player auto-reloads
932
+ // API: Edit — spawn agent CLI to edit the .md source file
933
+ // Uses the configured agent CLI (DEFAULT_AGENT_CLI) in one-shot mode.
934
+ // The agent receives the system prompt + edit request and outputs JSON.
741
935
  if (path === "/api/edit" && req.method === "POST") {
742
936
  let body = "";
743
937
  req.on("data", c => body += c);
744
- req.on("end", () => {
938
+ req.on("end", async () => {
745
939
  try {
746
- const { text } = JSON.parse(body);
940
+ const { text, currentTime, activeScene } = JSON.parse(body);
747
941
  if (!text) { res.writeHead(400); res.end(JSON.stringify({ error: "empty text" })); return; }
748
942
 
749
943
  editHistory.push(text);
750
944
 
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
-
945
+ // Build user prompt with context (system prompt is fixed at agent startup)
946
+ const timeStr = currentTime !== undefined ? `Current playback time: ${Number(currentTime).toFixed(1)}s` : "";
947
+ const sceneStr = activeScene ? `Active scene: ${activeScene}` : "";
948
+ const contextBlock = [timeStr, sceneStr].filter(Boolean).join("\n");
833
949
  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"
950
+ ? "Previous edits (in order):\n" + editHistory.slice(0, -1).map((e, i) => `${i+1}. ${e}`).join("\n") + "\n"
835
951
  : "";
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)"}
952
+ const userPrompt = `${contextBlock}
841
953
  ${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() }));
954
+ Edit request: ${text}`;
955
+
956
+ console.log(` 🤖 edit: ${text} (${currentTime !== undefined ? currentTime.toFixed(1) + "s" : ""} ${activeScene || ""})`);
957
+
958
+ // Send to the persistent rpc agent (one cold start; conversation kept in memory)
959
+ const result = await sendToAgent(userPrompt);
960
+
961
+ if (result.ok) {
962
+ // Parse JSON summary from the agent's final assistant text
963
+ let summary = "";
964
+ let fileChanged = false;
965
+ const raw = (result.output || "").trim();
966
+ const jsonMatch = raw.match(/\{[\s\S]*"summary"[\s\S]*\}/);
967
+ if (jsonMatch) {
968
+ try {
969
+ const parsed = JSON.parse(jsonMatch[0]);
970
+ summary = parsed.summary || raw.substring(0, 120);
971
+ fileChanged = parsed.fileChanged === true;
972
+ } catch { summary = raw.substring(0, 120); }
891
973
  } 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() }));
974
+ summary = raw.substring(0, 120) || "done";
895
975
  }
896
- });
976
+ console.log(` ✅ ${fileChanged ? "edited" : "no change"}: ${summary}`);
977
+ console.log(` 📤 response summary="${summary}" fileChanged=${fileChanged}`);
978
+ res.writeHead(200, { "Content-Type": "application/json" });
979
+ res.end(JSON.stringify({ done: true, summary, fileChanged, output: "" }));
980
+ } else {
981
+ const msg = result.error || "failed";
982
+ console.error(` ❌ edit ${msg}: ${(result.output || "").trim().substring(0, 100)}`);
983
+ res.writeHead(500, { "Content-Type": "application/json" });
984
+ res.end(JSON.stringify({ error: msg, output: (result.output || "").trim().substring(0, 200) }));
985
+ }
897
986
  } catch (e) {
898
987
  res.writeHead(500, { "Content-Type": "application/json" });
899
988
  res.end(JSON.stringify({ error: e.message }));
@@ -902,7 +991,7 @@ IMPORTANT: Read the full existing JSON file before editing. Only edit the JSON f
902
991
  return;
903
992
  }
904
993
 
905
- // API: SSE stream for reload notifications
994
+ // API: SSE stream for reload notifications + server-liveness monitor
906
995
  // When the browser tab closes, this connection drops → server shuts down
907
996
  // Grace period: wait 3s for reconnection (page reload), then exit
908
997
  if (path === "/api/events") {
@@ -911,6 +1000,8 @@ IMPORTANT: Read the full existing JSON file before editing. Only edit the JSON f
911
1000
  "Cache-Control": "no-cache",
912
1001
  "Connection": "keep-alive",
913
1002
  });
1003
+ // Flush headers to client so EventSource.onopen fires
1004
+ res.write(":ok\n\n");
914
1005
  sseClients.add(res);
915
1006
  if (shutdownTimer) {
916
1007
  clearTimeout(shutdownTimer);
@@ -993,6 +1084,12 @@ server.listen(PORT, async () => {
993
1084
  } catch {
994
1085
  // Pipeline failed — still announce ready so the player can show an error state
995
1086
  }
1087
+
1088
+ // Start persistent agent process for edit mode (after initial compilation)
1089
+ if (MODE_EDIT) {
1090
+ startAgentProcess();
1091
+ }
1092
+
996
1093
  const mode = MODE_LABEL ? " --label" : MODE_EDIT ? " --edit" : "";
997
1094
  console.log(`\n🎬 Player ready at http://localhost:${PORT}${mode}`);
998
1095
  if (VARIANT_CONFIGS.length > 1) {