@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.
- package/AGENTS.md +40 -17
- package/README.md +7 -3
- package/SKILL.md +0 -2
- package/docs/edit-mode.md +1 -1
- package/docs/label-mode.md +6 -4
- package/docs/markdown-descriptive.md +34 -1
- package/docs/system-prompt-edit.md +19 -0
- package/package.json +1 -1
- package/src/config.mjs +8 -3
- package/src/descriptive/compiler.test.ts +42 -42
- package/src/descriptive/compiler.ts +41 -52
- package/src/descriptive/markdown.ts +8 -4
- package/src/descriptive/resolve.test.ts +14 -7
- package/src/descriptive/resolve.ts +148 -20
- package/src/player/browser.tsx +178 -54
- package/src/player/bundle/player.js +1149 -566
- package/src/player/components/EditControls.tsx +92 -0
- package/src/player/components/HeaderBar.tsx +60 -0
- package/src/player/components/LabelControls.tsx +367 -0
- package/src/player/components/SceneThumbnails.tsx +60 -0
- package/src/player/components/VariantBar.tsx +39 -0
- package/src/player/components/index.ts +5 -0
- package/src/player/pipeline.mjs +130 -66
- package/src/player/pipeline.ts +3 -1
- package/src/player/server-shared.mjs +5 -7
- package/src/player/server.mjs +336 -239
- package/src/render/cli.mjs +4 -6
- package/src/schema/index.ts +20 -23
- package/src/types/Audio.tsx +25 -33
- package/src/types/Component.tsx +18 -24
- package/src/types/Effect.tsx +31 -39
- package/src/types/Image.tsx +23 -30
- package/src/types/Include.tsx +70 -76
- package/src/types/Map.tsx +48 -44
- package/src/types/Rhythm.tsx +19 -27
- package/src/types/Video.tsx +40 -47
- package/src/utils/index.ts +23 -10
- package/src/vision/cli.mjs +6 -6
- package/templates/courseware/TEMPLATE.md +16 -10
- package/tests/fixtures/audio.json +4 -2
- package/tests/fixtures/basic.json +2 -1
- package/tests/fixtures/component-all.json +4 -2
- package/tests/fixtures/components.json +4 -2
- package/tests/fixtures/effects.json +12 -6
- package/tests/fixtures/full.json +8 -4
- package/tests/fixtures/map.json +2 -1
- package/tests/fixtures/md/dialogue.md +12 -0
- package/tests/fixtures/md/edge-cases.md +9 -0
- package/tests/fixtures/scenes.json +4 -2
- package/tests/fixtures/subtitle.json +6 -3
- package/tests/fixtures/subvideo.json +11 -6
- package/tests/fixtures/templates/courseware.md +61 -4
- package/tests/fixtures/tween-visual.json +2 -6
- package/tests/fixtures/video-series.json +6 -14
- package/tests/md-descriptive.test.ts +170 -0
- package/tests/render.test.ts +32 -16
- package/tests/schema.test.ts +6 -3
- package/tests/server.test.ts +9 -6
- package/tests/utils.ts +4 -4
- package/scripts/artlist-dl.mjs +0 -190
- package/src/player/label-server.mjs +0 -599
package/src/player/server.mjs
CHANGED
|
@@ -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
|
|
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${
|
|
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:
|
|
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>
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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">✨</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(
|
|
691
|
-
} catch
|
|
851
|
+
res.end(JSON.stringify(tree));
|
|
852
|
+
} catch {
|
|
692
853
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
693
|
-
res.end(JSON.stringify({
|
|
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
|
-
|
|
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 —
|
|
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
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
? "
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
|
|
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) {
|