@lalalic/markcut 2.0.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
CHANGED
|
@@ -188,6 +188,7 @@ node src/render/cli.mjs templates
|
|
|
188
188
|
- `--label` flag starts a labeling UI for selecting and annotating media
|
|
189
189
|
- `--port` flag sets the server port (default 3001)
|
|
190
190
|
- Unified server: `server.mjs` serves all modes (preview, edit, label). React control components in `components/`.
|
|
191
|
+
- **Persistent AI agent via `pi --mode rpc`** (one cold start, many edits): at boot, `startAgentProcess()` spawns the configured agent CLI (`DEFAULT_AGENT_CLI`) with `--mode rpc` appended (the `-p/--prompt` flag is stripped — prompts arrive over stdin). Each `/api/edit` POST writes `{"id","type":"prompt","message"}` to stdin and resolves on the streamed `agent_settled` event (NOT idle-timeout guessing — pi's default interactive mode is a TUI, not a stdin REPL, so text/stdin mode does not work). Assistant text is collected from `message_end` events (`message.role==="assistant"`, text content blocks). `--session-id` (derived from the file path) resumes conversation history from disk across restarts. Do not revert to one-shot `spawn` per edit — that re-incurs the full cold-start every request.
|
|
191
192
|
|
|
192
193
|
## Testing
|
|
193
194
|
|
|
@@ -12,5 +12,8 @@ TASKS:
|
|
|
12
12
|
1. Read the .md file directly using your file tools to see its current content
|
|
13
13
|
2. Edit the .md file directly — change content, add/remove sections, update frontmatter
|
|
14
14
|
3. Save the changes to the .md file using your write/edit tools
|
|
15
|
-
4. Output ONLY a
|
|
16
|
-
|
|
15
|
+
4. Output ONLY a JSON object on a single line:
|
|
16
|
+
{"summary":"what specific change you made","fileChanged":true}
|
|
17
|
+
- `summary`: short description of the change
|
|
18
|
+
- `fileChanged`: true if you edited the file, false if no change was needed
|
|
19
|
+
5. Do NOT output any other text, explanations, or reasoning — only the JSON line
|
package/package.json
CHANGED
|
@@ -60017,7 +60017,7 @@ function EditControls({ onStatusChange, suppressReloadRef, currentTime, activeSc
|
|
|
60017
60017
|
});
|
|
60018
60018
|
const data2 = await res.json();
|
|
60019
60019
|
if (res.ok) {
|
|
60020
|
-
const summary = (data2.output || "done").split("\n")[0].slice(0, 65);
|
|
60020
|
+
const summary = data2.summary || (data2.output || "done").split("\n")[0].slice(0, 65);
|
|
60021
60021
|
onStatusChange?.(summary);
|
|
60022
60022
|
setTimeout(() => {
|
|
60023
60023
|
if (suppressReloadRef) suppressReloadRef.current = false;
|
|
@@ -60348,15 +60348,6 @@ function LabelControls({ playerRef, currentTime, onSceneChange }) {
|
|
|
60348
60348
|
// src/player/components/SceneThumbnails.tsx
|
|
60349
60349
|
var React51 = __toESM(require_react(), 1);
|
|
60350
60350
|
var import_jsx_runtime93 = __toESM(require_jsx_runtime(), 1);
|
|
60351
|
-
var VIDEO_EXT2 = {
|
|
60352
|
-
".mov": 1,
|
|
60353
|
-
".mp4": 1,
|
|
60354
|
-
".avi": 1,
|
|
60355
|
-
".mkv": 1,
|
|
60356
|
-
".webm": 1,
|
|
60357
|
-
".m4v": 1,
|
|
60358
|
-
".wmv": 1
|
|
60359
|
-
};
|
|
60360
60351
|
function SceneThumbnails({ currentTime, onSeek }) {
|
|
60361
60352
|
const [scenes, setScenes] = React51.useState([]);
|
|
60362
60353
|
React51.useEffect(() => {
|
|
@@ -60375,22 +60366,12 @@ function SceneThumbnails({ currentTime, onSeek }) {
|
|
|
60375
60366
|
}
|
|
60376
60367
|
return /* @__PURE__ */ (0, import_jsx_runtime93.jsx)("div", { id: "scene-thumbnails", children: scenes.map((scene2, i3) => {
|
|
60377
60368
|
const isActive = i3 === activeIdx;
|
|
60378
|
-
|
|
60379
|
-
|
|
60380
|
-
const hasMedia = !!scene2.src;
|
|
60381
|
-
return /* @__PURE__ */ (0, import_jsx_runtime93.jsxs)(
|
|
60382
|
-
"div",
|
|
60369
|
+
return /* @__PURE__ */ (0, import_jsx_runtime93.jsx)(
|
|
60370
|
+
"span",
|
|
60383
60371
|
{
|
|
60384
|
-
className: "
|
|
60372
|
+
className: "scene-pill" + (isActive ? " active" : ""),
|
|
60385
60373
|
onClick: () => onSeek?.(scene2.start),
|
|
60386
|
-
children:
|
|
60387
|
-
/* @__PURE__ */ (0, import_jsx_runtime93.jsxs)("div", { className: "sthumb-media", children: [
|
|
60388
|
-
hasMedia && !isVideo && /* @__PURE__ */ (0, import_jsx_runtime93.jsx)("img", { src: scene2.src, alt: "", loading: "lazy" }),
|
|
60389
|
-
hasMedia && isVideo && /* @__PURE__ */ (0, import_jsx_runtime93.jsx)("video", { src: scene2.src, muted: true, preload: "metadata" }),
|
|
60390
|
-
!hasMedia && /* @__PURE__ */ (0, import_jsx_runtime93.jsx)("span", { className: "sthumb-fallback", children: (scene2.name || "S" + (i3 + 1)).slice(0, 2).toUpperCase() })
|
|
60391
|
-
] }),
|
|
60392
|
-
/* @__PURE__ */ (0, import_jsx_runtime93.jsx)("span", { className: "sthumb-name", children: scene2.name || "Scene " + (i3 + 1) })
|
|
60393
|
-
]
|
|
60374
|
+
children: scene2.name || "Scene " + (i3 + 1)
|
|
60394
60375
|
},
|
|
60395
60376
|
i3
|
|
60396
60377
|
);
|
|
@@ -40,7 +40,7 @@ export function EditControls({ onStatusChange, suppressReloadRef, currentTime, a
|
|
|
40
40
|
});
|
|
41
41
|
const data = await res.json();
|
|
42
42
|
if (res.ok) {
|
|
43
|
-
const summary = (data.output || "done").split("\n")[0].slice(0, 65);
|
|
43
|
+
const summary = data.summary || (data.output || "done").split("\n")[0].slice(0, 65);
|
|
44
44
|
onStatusChange?.(summary);
|
|
45
45
|
setTimeout(() => {
|
|
46
46
|
if (suppressReloadRef) suppressReloadRef.current = false;
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SceneThumbnails — horizontal scene selector for all modes.
|
|
3
3
|
*
|
|
4
|
-
* Fetches scene info from /api/video-info and renders clickable
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Layout: supported in edit and preview modes (label mode has its own).
|
|
4
|
+
* Fetches scene info from /api/video-info and renders clickable scene name pills.
|
|
5
|
+
* Active scene is highlighted based on currentTime.
|
|
8
6
|
*/
|
|
9
7
|
import * as React from "react";
|
|
10
8
|
|
|
@@ -12,15 +10,8 @@ interface Scene {
|
|
|
12
10
|
name: string;
|
|
13
11
|
start: number;
|
|
14
12
|
end: number;
|
|
15
|
-
duration: number;
|
|
16
|
-
src: string;
|
|
17
|
-
mediaType: string;
|
|
18
13
|
}
|
|
19
14
|
|
|
20
|
-
const VIDEO_EXT: Record<string, number> = {
|
|
21
|
-
".mov": 1, ".mp4": 1, ".avi": 1, ".mkv": 1, ".webm": 1, ".m4v": 1, ".wmv": 1,
|
|
22
|
-
};
|
|
23
|
-
|
|
24
15
|
interface SceneThumbnailsProps {
|
|
25
16
|
/** Current player time in seconds — used to highlight active scene */
|
|
26
17
|
currentTime: number;
|
|
@@ -42,7 +33,6 @@ export function SceneThumbnails({ currentTime, onSeek }: SceneThumbnailsProps) {
|
|
|
42
33
|
|
|
43
34
|
if (scenes.length === 0) return null;
|
|
44
35
|
|
|
45
|
-
// Determine active scene index from currentTime
|
|
46
36
|
let activeIdx = -1;
|
|
47
37
|
for (let i = 0; i < scenes.length; i++) {
|
|
48
38
|
if (currentTime >= scenes[i].start && currentTime < scenes[i].end) {
|
|
@@ -55,31 +45,14 @@ export function SceneThumbnails({ currentTime, onSeek }: SceneThumbnailsProps) {
|
|
|
55
45
|
<div id="scene-thumbnails">
|
|
56
46
|
{scenes.map((scene, i) => {
|
|
57
47
|
const isActive = i === activeIdx;
|
|
58
|
-
const ext = scene.src.substring(scene.src.lastIndexOf(".")).toLowerCase();
|
|
59
|
-
const isVideo = !!VIDEO_EXT[ext];
|
|
60
|
-
const hasMedia = !!scene.src;
|
|
61
|
-
|
|
62
48
|
return (
|
|
63
|
-
<
|
|
49
|
+
<span
|
|
64
50
|
key={i}
|
|
65
|
-
className={"
|
|
51
|
+
className={"scene-pill" + (isActive ? " active" : "")}
|
|
66
52
|
onClick={() => onSeek?.(scene.start)}
|
|
67
53
|
>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
<img src={scene.src} alt="" loading="lazy" />
|
|
71
|
-
)}
|
|
72
|
-
{hasMedia && isVideo && (
|
|
73
|
-
<video src={scene.src} muted preload="metadata" />
|
|
74
|
-
)}
|
|
75
|
-
{!hasMedia && (
|
|
76
|
-
<span className="sthumb-fallback">
|
|
77
|
-
{(scene.name || "S" + (i + 1)).slice(0, 2).toUpperCase()}
|
|
78
|
-
</span>
|
|
79
|
-
)}
|
|
80
|
-
</div>
|
|
81
|
-
<span className="sthumb-name">{scene.name || "Scene " + (i + 1)}</span>
|
|
82
|
-
</div>
|
|
54
|
+
{scene.name || "Scene " + (i + 1)}
|
|
55
|
+
</span>
|
|
83
56
|
);
|
|
84
57
|
})}
|
|
85
58
|
</div>
|
package/src/player/server.mjs
CHANGED
|
@@ -78,6 +78,180 @@ let shutdownTimer = null;
|
|
|
78
78
|
// ─── Label store for label mode ───────────────────────────────────────────
|
|
79
79
|
let labels = [];
|
|
80
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
|
+
|
|
81
255
|
// ─── Edit history for context ─────────────────────────────────────────────
|
|
82
256
|
let editHistory = [];
|
|
83
257
|
|
|
@@ -618,13 +792,9 @@ function getHtml(variantLabel) {
|
|
|
618
792
|
#scene-thumbnails { display: flex; gap: 6px; width: 100%; max-width: 500px; padding: 4px 12px; flex-shrink: 0; overflow-x: auto; scrollbar-width: thin; }
|
|
619
793
|
#scene-thumbnails::-webkit-scrollbar { height: 4px; }
|
|
620
794
|
#scene-thumbnails::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 2px; }
|
|
621
|
-
.
|
|
622
|
-
.
|
|
623
|
-
.
|
|
624
|
-
.sthumb-media { width: 100%; height: 48px; overflow: hidden; position: relative; }
|
|
625
|
-
.sthumb-media img, .sthumb-media video { width: 100%; height: 100%; object-fit: cover; }
|
|
626
|
-
.sthumb-fallback { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,.08); color: rgba(255,255,255,.3); font-size: 16px; font-weight: 600; }
|
|
627
|
-
.sthumb-name { font-size: 9px; color: rgba(255,255,255,.45); text-align: center; padding: 2px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
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; }
|
|
628
798
|
</style>
|
|
629
799
|
</head>
|
|
630
800
|
<body>
|
|
@@ -759,145 +929,60 @@ const server = createServer(async (req, res) => {
|
|
|
759
929
|
return;
|
|
760
930
|
}
|
|
761
931
|
|
|
762
|
-
// API: Edit —
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
// System prompt: role "video director" + markcut skill + @{docs/markdown-descriptive.md}
|
|
766
|
-
// User prompt: current timestamp + active scene + edit request
|
|
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.
|
|
767
935
|
if (path === "/api/edit" && req.method === "POST") {
|
|
768
936
|
let body = "";
|
|
769
937
|
req.on("data", c => body += c);
|
|
770
|
-
req.on("end", () => {
|
|
938
|
+
req.on("end", async () => {
|
|
771
939
|
try {
|
|
772
940
|
const { text, currentTime, activeScene } = JSON.parse(body);
|
|
773
941
|
if (!text) { res.writeHead(400); res.end(JSON.stringify({ error: "empty text" })); return; }
|
|
774
942
|
|
|
775
943
|
editHistory.push(text);
|
|
776
944
|
|
|
777
|
-
//
|
|
778
|
-
const rawContent = readFileSync(VIDEO_JSON, "utf-8");
|
|
779
|
-
const fileLabel = VIDEO_JSON.split("/").pop();
|
|
780
|
-
// Sanitize file path to a valid session ID (alphanumeric, -, _, .)
|
|
781
|
-
const sessionId = VIDEO_JSON.replace(/[^a-zA-Z0-9\-_.]/g, "_").replace(/^[^a-zA-Z0-9]+/, "").replace(/[^a-zA-Z0-9]+$/, "");
|
|
782
|
-
|
|
783
|
-
// Build tree structure description for context
|
|
784
|
-
let treeInfo = "";
|
|
785
|
-
try {
|
|
786
|
-
const parsed = JSON.parse(rawContent);
|
|
787
|
-
const root = parsed.root || parsed;
|
|
788
|
-
|
|
789
|
-
function describeNode(node, depth) {
|
|
790
|
-
const indent = " ".repeat(depth);
|
|
791
|
-
const id = node.id || "";
|
|
792
|
-
const name = node.name || "";
|
|
793
|
-
const label = name || id;
|
|
794
|
-
const type = node.type || "unknown";
|
|
795
|
-
const dur = node.durationInSeconds !== undefined ? `, ${node.durationInSeconds}s` : "";
|
|
796
|
-
let line = `${indent}${type} "${label}"`;
|
|
797
|
-
if (type === "root") line += ` (${node.width}x${node.height}, ${node.fps}fps${node.isSeries ? ", series" : ""}${node.transition ? `, transition:${node.transition}` : ""}${node.theme ? `, theme:${node.theme}` : ""})`;
|
|
798
|
-
else if (type === "folder") line += ` (${node.isSeries ? "series" : "parallel"}${node.transition ? `, transition:${node.transition}` : ""}${dur})`;
|
|
799
|
-
else if (type === "component") {
|
|
800
|
-
const props = node.props ? JSON.stringify(Object.fromEntries(Object.entries(node.props).filter(([k]) => !k.startsWith("_")))) : "{}";
|
|
801
|
-
line += ` ${node.componentName}(${props.slice(0, 100)})${dur}`;
|
|
802
|
-
}
|
|
803
|
-
else if (type === "subtitle") { const txt = (node.src || "").slice(0, 60); line += ` "${txt}"${dur}`; }
|
|
804
|
-
else if (type === "video" || type === "audio" || type === "image") { const src = (node.src || "").slice(0, 50); line += ` "${src}"${dur}`; }
|
|
805
|
-
else if (type === "effect") line += ` animation:${node.animation || "custom"}${dur}`;
|
|
806
|
-
else if (type === "rhythm") line += ` src:"${(node.src || "").slice(0, 40)}"${dur}`;
|
|
807
|
-
else if (type === "map") line += ` waypoints:${(node.waypoints || []).length}${dur}`;
|
|
808
|
-
else if (type === "include") line += ` src:"${(node.src || "").slice(0, 50)}"${dur}`;
|
|
809
|
-
if (typeof node.start === "number" || typeof node.end === "number") {
|
|
810
|
-
const sStart = node.start ?? 0; const sEnd = node.end ?? sStart;
|
|
811
|
-
line += ` [${sStart}→${sEnd}s${node.isBackground ? ", bg" : ""}${node.volume !== undefined ? `, vol:${node.volume}` : ""}${node.loop ? `, loop:${node.loop}` : ""}]`;
|
|
812
|
-
} else if (node.isBackground) line += ` [bg]`;
|
|
813
|
-
const extras = [];
|
|
814
|
-
if (node.componentName) extras.push(node.componentName);
|
|
815
|
-
if (node.fit) extras.push(`fit:${node.fit}`);
|
|
816
|
-
if (node.fontSize) extras.push(`fontSize:${node.fontSize}`);
|
|
817
|
-
if (node.volume !== undefined && type !== "root") extras.push(`vol:${node.volume}`);
|
|
818
|
-
if (node.playbackRate) extras.push(`rate:${node.playbackRate}`);
|
|
819
|
-
if (node.transitionTime !== undefined) extras.push(`transTime:${node.transitionTime}`);
|
|
820
|
-
if (node.visible === false) extras.push("hidden");
|
|
821
|
-
if (extras.length > 0) line += ` {${extras.join(", ")}}`;
|
|
822
|
-
return line;
|
|
823
|
-
}
|
|
824
|
-
function walkTree(node, depth = 0) {
|
|
825
|
-
const lines = [describeNode(node, depth)];
|
|
826
|
-
if (node.children && node.children.length > 0) {
|
|
827
|
-
for (const child of node.children.filter(c => c.visible !== false || c.visible === undefined)) lines.push(...walkTree(child, depth + 1));
|
|
828
|
-
}
|
|
829
|
-
return lines;
|
|
830
|
-
}
|
|
831
|
-
treeInfo = walkTree(root).join("\n");
|
|
832
|
-
} catch {}
|
|
833
|
-
|
|
834
|
-
const historyStr = editHistory.length > 1
|
|
835
|
-
? "Previous edits (in order):\n" + editHistory.slice(0, -1).map((e, i) => `${i+1}. ${e}`).join("\n") + "\n"
|
|
836
|
-
: "";
|
|
837
|
-
|
|
838
|
-
// Build system prompt from template file (resolves @{path} references)
|
|
839
|
-
// System prompt is stable — it contains the role, skills, and format docs.
|
|
840
|
-
// Tree info and history change per-edit, so they go in the user prompt.
|
|
841
|
-
const promptTemplate = readFileSync(join(ROOT, "docs", "system-prompt-edit.md"), "utf-8");
|
|
842
|
-
const systemPrompt = promptTemplate
|
|
843
|
-
.replace(/@\{([^}]+)\}/g, (_, refPath) => {
|
|
844
|
-
try {
|
|
845
|
-
return readFileSync(resolve(ROOT, refPath.trim()), "utf-8");
|
|
846
|
-
} catch {
|
|
847
|
-
console.warn(` ⚠ could not read @{${refPath}}`);
|
|
848
|
-
return `[Missing: ${refPath}]`;
|
|
849
|
-
}
|
|
850
|
-
})
|
|
851
|
-
.replace(/\$\{fileName\}/g, fileLabel)
|
|
852
|
-
.replace(/\$\{filePath\}/g, VIDEO_JSON);
|
|
853
|
-
|
|
854
|
-
// Build user prompt with current context (changes every edit)
|
|
945
|
+
// Build user prompt with context (system prompt is fixed at agent startup)
|
|
855
946
|
const timeStr = currentTime !== undefined ? `Current playback time: ${Number(currentTime).toFixed(1)}s` : "";
|
|
856
947
|
const sceneStr = activeScene ? `Active scene: ${activeScene}` : "";
|
|
857
948
|
const contextBlock = [timeStr, sceneStr].filter(Boolean).join("\n");
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
949
|
+
const historyStr = editHistory.length > 1
|
|
950
|
+
? "Previous edits (in order):\n" + editHistory.slice(0, -1).map((e, i) => `${i+1}. ${e}`).join("\n") + "\n"
|
|
951
|
+
: "";
|
|
952
|
+
const userPrompt = `${contextBlock}
|
|
861
953
|
${historyStr}
|
|
862
|
-
${contextBlock}
|
|
863
954
|
Edit request: ${text}`;
|
|
864
955
|
|
|
865
|
-
// Resolve agent CLI template with all placeholders
|
|
866
|
-
// Each placeholder is its own argument in the template (no shell quotes needed)
|
|
867
|
-
const agentCli = process.env.MARKCUT_AGENT_CLI || DEFAULT_AGENT_CLI;
|
|
868
|
-
const parts = agentCli.split(/\s+/);
|
|
869
|
-
const resolvedParts = parts.map(p =>
|
|
870
|
-
p.replace(/\{sessionid\}/g, sessionId)
|
|
871
|
-
.replace(/\{systemprompt\}/g, systemPrompt)
|
|
872
|
-
.replace(/\{prompt\}/g, userPrompt)
|
|
873
|
-
);
|
|
874
|
-
const cmd = resolvedParts[0];
|
|
875
|
-
const cmdArgs = resolvedParts.slice(1);
|
|
876
|
-
|
|
877
956
|
console.log(` 🤖 edit: ${text} (${currentTime !== undefined ? currentTime.toFixed(1) + "s" : ""} ${activeScene || ""})`);
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
res.end(JSON.stringify({ done: true, output: output.trim() }));
|
|
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); }
|
|
895
973
|
} else {
|
|
896
|
-
|
|
897
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
898
|
-
res.end(JSON.stringify({ error: `agent exited with code ${code}`, output: output.trim() }));
|
|
974
|
+
summary = raw.substring(0, 120) || "done";
|
|
899
975
|
}
|
|
900
|
-
|
|
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
|
+
}
|
|
901
986
|
} catch (e) {
|
|
902
987
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
903
988
|
res.end(JSON.stringify({ error: e.message }));
|
|
@@ -1000,6 +1085,11 @@ server.listen(PORT, async () => {
|
|
|
1000
1085
|
// Pipeline failed — still announce ready so the player can show an error state
|
|
1001
1086
|
}
|
|
1002
1087
|
|
|
1088
|
+
// Start persistent agent process for edit mode (after initial compilation)
|
|
1089
|
+
if (MODE_EDIT) {
|
|
1090
|
+
startAgentProcess();
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1003
1093
|
const mode = MODE_LABEL ? " --label" : MODE_EDIT ? " --edit" : "";
|
|
1004
1094
|
console.log(`\n🎬 Player ready at http://localhost:${PORT}${mode}`);
|
|
1005
1095
|
if (VARIANT_CONFIGS.length > 1) {
|