@lalalic/markcut 1.1.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +39 -15
- package/README.md +7 -3
- package/SKILL.md +26 -172
- package/docs/edit-mode.md +1 -1
- package/docs/label-mode.md +6 -4
- package/docs/markdown-descriptive.md +46 -1
- package/docs/system-prompt-edit.md +16 -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/entry.tsx +8 -2
- package/src/player/browser.tsx +178 -54
- package/src/player/bundle/player.js +1168 -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 +87 -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 +194 -187
- package/src/render/cli.mjs +66 -13
- 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/component-import-map.ts +93 -0
- package/src/utils/index.ts +23 -10
- package/src/vision/cli.mjs +6 -6
- package/templates/courseware/TEMPLATE.md +317 -0
- package/templates/courseware/agents/reviewer.md +84 -0
- package/templates/courseware/prompts/fix.md +30 -0
- package/templates/courseware/prompts/outline.md +53 -0
- package/templates/courseware/prompts/scene.md +64 -0
- 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/docs/dynamic-components.md +0 -191
- package/docs/templates.md +0 -52
- 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, "..", "..");
|
|
@@ -448,6 +450,29 @@ async function compileAndExtractScenes() {
|
|
|
448
450
|
console.error(` ⚠ Could not extract scenes for "${config.label}":`, e.message);
|
|
449
451
|
}
|
|
450
452
|
}
|
|
453
|
+
|
|
454
|
+
// Merge existing labels from labels.json into the compiled root (label mode)
|
|
455
|
+
if (MODE_LABEL) {
|
|
456
|
+
const labelsPath = join(dirname(VIDEO_JSON), "labels.json");
|
|
457
|
+
try {
|
|
458
|
+
const labelsRaw = readFileSync(labelsPath, "utf-8");
|
|
459
|
+
const labelsData = JSON.parse(labelsRaw);
|
|
460
|
+
const labelsRoot = labelsData.root || labelsData;
|
|
461
|
+
const root = compiledRootCache.get("default");
|
|
462
|
+
if (labelsRoot.children && root?.children) {
|
|
463
|
+
for (let i = 0; i < Math.min(labelsRoot.children.length, root.children.length); i++) {
|
|
464
|
+
const labelMedia = labelsRoot.children[i]?.children?.[0];
|
|
465
|
+
const targetMedia = root.children[i]?.children?.[0];
|
|
466
|
+
if (labelMedia && targetMedia && labelMedia.description) {
|
|
467
|
+
targetMedia.description = labelMedia.description;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
console.log(` 🏷️ Merged ${Object.keys(labelsRoot.children || {}).length} existing labels from labels.json`);
|
|
472
|
+
} catch {
|
|
473
|
+
// No existing labels file — that's fine
|
|
474
|
+
}
|
|
475
|
+
}
|
|
451
476
|
}
|
|
452
477
|
|
|
453
478
|
function getScenes(label) {
|
|
@@ -536,29 +561,25 @@ function resolveAsset(urlPath, variantLabel) {
|
|
|
536
561
|
}
|
|
537
562
|
|
|
538
563
|
// ─── HTML page ───────────────────────────────────────────────────────────
|
|
564
|
+
// Minimal HTML shell. All UI is rendered by React (player.js bundle).
|
|
565
|
+
// Mode-specific controls are in src/player/components/.
|
|
539
566
|
function getHtml(variantLabel) {
|
|
540
567
|
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("");
|
|
568
|
+
const mode = MODE_LABEL ? "label" : MODE_EDIT ? "edit" : "preview";
|
|
549
569
|
|
|
550
570
|
return `<!DOCTYPE html>
|
|
551
571
|
<html lang="en">
|
|
552
572
|
<head>
|
|
553
573
|
<meta charset="UTF-8">
|
|
554
574
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
555
|
-
<title>Remotion Player${
|
|
575
|
+
<title>Remotion Player${mode !== "preview" ? " — " + mode.charAt(0).toUpperCase() + mode.slice(1) : ""}</title>
|
|
556
576
|
<style>
|
|
557
577
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
558
578
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a0a; }
|
|
559
579
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; flex-direction: column; align-items: center; }
|
|
560
580
|
#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:
|
|
581
|
+
#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; }
|
|
582
|
+
#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
583
|
#header-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
|
563
584
|
#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
585
|
#close-btn:hover { background: rgba(255,60,60,.4); border-color: rgba(255,60,60,.5); color: #fff; }
|
|
@@ -571,90 +592,48 @@ function getHtml(variantLabel) {
|
|
|
571
592
|
#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
593
|
#reload-toast.show { opacity: 1; }
|
|
573
594
|
#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; }
|
|
595
|
+
#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; }
|
|
596
|
+
#edit-input:focus, #label-input:focus { border-color: rgba(74,158,255,.5); }
|
|
597
|
+
#edit-input::placeholder, #label-input::placeholder { color: rgba(255,255,255,.25); }
|
|
598
|
+
#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; }
|
|
599
|
+
#edit-btn:hover, #label-btn:hover { background: rgba(74,158,255,.2); border-color: rgba(74,158,255,.4); color: #4a9eff; }
|
|
600
|
+
#edit-btn:disabled, #label-btn:disabled { opacity: 0.3; cursor: wait; }
|
|
601
|
+
#thumbnails { display: flex; gap: 6px; width: 100%; max-width: 500px; padding: 4px 12px; flex-shrink: 0; overflow-x: auto; scrollbar-width: thin; }
|
|
602
|
+
#thumbnails::-webkit-scrollbar { height: 4px; }
|
|
603
|
+
#thumbnails::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 2px; }
|
|
604
|
+
.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); }
|
|
605
|
+
.thumb-item:hover { border-color: rgba(74,158,255,.4); }
|
|
606
|
+
.thumb-item.active { border-color: #4a9eff; box-shadow: 0 0 8px rgba(74,158,255,.3); }
|
|
607
|
+
.thumb-item img { width: 100%; height: 100%; object-fit: cover; }
|
|
608
|
+
.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; }
|
|
609
|
+
.thumb-badge.has-label { display: block; }
|
|
610
|
+
#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; }
|
|
611
|
+
.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); }
|
|
612
|
+
.timed-label .tl-time { flex-shrink: 0; font-family: monospace; font-size: 10px; color: rgba(74,158,255,.7); min-width: 32px; }
|
|
613
|
+
.timed-label .tl-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
614
|
+
.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; }
|
|
615
|
+
.timed-label .tl-del:hover { background: rgba(255,60,60,.3); color: #fff; }
|
|
616
|
+
#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); }
|
|
617
|
+
#saved-toast.show { opacity: 1; }
|
|
618
|
+
#scene-thumbnails { display: flex; gap: 6px; width: 100%; max-width: 500px; padding: 4px 12px; flex-shrink: 0; overflow-x: auto; scrollbar-width: thin; }
|
|
619
|
+
#scene-thumbnails::-webkit-scrollbar { height: 4px; }
|
|
620
|
+
#scene-thumbnails::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 2px; }
|
|
621
|
+
.sthumb-item { flex-shrink: 0; width: 72px; cursor: pointer; border-radius: 6px; overflow: hidden; border: 2px solid transparent; transition: all .15s; background: rgba(255,255,255,.05); display: flex; flex-direction: column; }
|
|
622
|
+
.sthumb-item:hover { border-color: rgba(74,158,255,.4); }
|
|
623
|
+
.sthumb-item.active { border-color: #4a9eff; box-shadow: 0 0 8px rgba(74,158,255,.3); }
|
|
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; }
|
|
580
628
|
</style>
|
|
581
629
|
</head>
|
|
582
630
|
<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>` : ""}
|
|
631
|
+
<script>
|
|
632
|
+
window.VARIANT = "${label}";
|
|
633
|
+
window.MODE = "${mode}";
|
|
634
|
+
</script>
|
|
635
|
+
<div id="root"></div>
|
|
599
636
|
<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
637
|
</body>
|
|
659
638
|
</html>`;
|
|
660
639
|
}
|
|
@@ -684,13 +663,25 @@ const server = createServer(async (req, res) => {
|
|
|
684
663
|
if (path === "/api/labels") {
|
|
685
664
|
const labelsPath = join(dirname(VIDEO_JSON), "labels.json");
|
|
686
665
|
if (req.method === "GET") {
|
|
666
|
+
// Return the stream tree — descriptions on children are the labels
|
|
687
667
|
try {
|
|
688
668
|
const data = readFileSync(labelsPath, "utf-8");
|
|
669
|
+
const parsed = JSON.parse(data);
|
|
670
|
+
if (parsed.type || parsed.root) {
|
|
671
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
672
|
+
res.end(data);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
} catch { /* labels.json missing or invalid — fall through */ }
|
|
676
|
+
// Fall back to compiled root for the default variant
|
|
677
|
+
try {
|
|
678
|
+
const root = await loadCompiledRoot("default");
|
|
679
|
+
const tree = resolveAssetPaths(root);
|
|
689
680
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
690
|
-
res.end(
|
|
691
|
-
} catch
|
|
681
|
+
res.end(JSON.stringify(tree));
|
|
682
|
+
} catch {
|
|
692
683
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
693
|
-
res.end(JSON.stringify({
|
|
684
|
+
res.end(JSON.stringify({ type: "root", children: [] }));
|
|
694
685
|
}
|
|
695
686
|
return;
|
|
696
687
|
}
|
|
@@ -699,7 +690,38 @@ const server = createServer(async (req, res) => {
|
|
|
699
690
|
req.on("data", c => body += c);
|
|
700
691
|
req.on("end", () => {
|
|
701
692
|
try {
|
|
702
|
-
|
|
693
|
+
const data = JSON.parse(body);
|
|
694
|
+
|
|
695
|
+
// Granular label update: { sceneIndex, description, time, overall, removeTimed }
|
|
696
|
+
if (typeof data.sceneIndex === "number") {
|
|
697
|
+
// Load the compiled root to apply labels to the live tree
|
|
698
|
+
loadCompiledRoot("default").then((root) => {
|
|
699
|
+
const child = root?.children?.[data.sceneIndex];
|
|
700
|
+
const media = child?.children?.[0];
|
|
701
|
+
if (media) {
|
|
702
|
+
media.userHints = media.userHints || { overall: "", timed: {} };
|
|
703
|
+
if (data.removeTimed) {
|
|
704
|
+
delete media.userHints.timed[data.removeTimed];
|
|
705
|
+
} else if (typeof data.description === "string") {
|
|
706
|
+
if (data.overall) {
|
|
707
|
+
media.userHints.overall = data.description;
|
|
708
|
+
} else {
|
|
709
|
+
const timeMs = Math.round((data.time || 0) * 1000);
|
|
710
|
+
media.userHints.timed["at_" + timeMs] = data.description;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
media.description = media.userHints.overall || Object.values(media.userHints.timed)[0] || undefined;
|
|
714
|
+
}
|
|
715
|
+
// Save the full tree to labels.json
|
|
716
|
+
writeFileSync(labelsPath, JSON.stringify(root, null, 2), "utf-8");
|
|
717
|
+
}).catch(() => {
|
|
718
|
+
// If no compiled root, save the update as minimal labels.json
|
|
719
|
+
writeFileSync(labelsPath, body, "utf-8");
|
|
720
|
+
});
|
|
721
|
+
} else {
|
|
722
|
+
// Full labels.json body — save directly
|
|
723
|
+
writeFileSync(labelsPath, body, "utf-8");
|
|
724
|
+
}
|
|
703
725
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
704
726
|
res.end(JSON.stringify({ saved: true, path: labelsPath }));
|
|
705
727
|
} catch (e) {
|
|
@@ -737,22 +759,31 @@ const server = createServer(async (req, res) => {
|
|
|
737
759
|
return;
|
|
738
760
|
}
|
|
739
761
|
|
|
740
|
-
// API: Edit — call
|
|
762
|
+
// API: Edit — call configured agent CLI to edit the .md source file
|
|
763
|
+
// Only supports markdown-descriptive (.md) files — the primary authoring format
|
|
764
|
+
// Uses DEFAULT_AGENT_CLI with {sessionid} replaced by sanitized file path for continuity
|
|
765
|
+
// System prompt: role "video director" + markcut skill + @{docs/markdown-descriptive.md}
|
|
766
|
+
// User prompt: current timestamp + active scene + edit request
|
|
741
767
|
if (path === "/api/edit" && req.method === "POST") {
|
|
742
768
|
let body = "";
|
|
743
769
|
req.on("data", c => body += c);
|
|
744
770
|
req.on("end", () => {
|
|
745
771
|
try {
|
|
746
|
-
const { text } = JSON.parse(body);
|
|
772
|
+
const { text, currentTime, activeScene } = JSON.parse(body);
|
|
747
773
|
if (!text) { res.writeHead(400); res.end(JSON.stringify({ error: "empty text" })); return; }
|
|
748
774
|
|
|
749
775
|
editHistory.push(text);
|
|
750
776
|
|
|
751
|
-
//
|
|
777
|
+
// Read the current file content for the agent to edit
|
|
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
|
|
752
784
|
let treeInfo = "";
|
|
753
785
|
try {
|
|
754
|
-
const
|
|
755
|
-
const parsed = JSON.parse(raw);
|
|
786
|
+
const parsed = JSON.parse(rawContent);
|
|
756
787
|
const root = parsed.root || parsed;
|
|
757
788
|
|
|
758
789
|
function describeNode(node, depth) {
|
|
@@ -762,119 +793,92 @@ const server = createServer(async (req, res) => {
|
|
|
762
793
|
const label = name || id;
|
|
763
794
|
const type = node.type || "unknown";
|
|
764
795
|
const dur = node.durationInSeconds !== undefined ? `, ${node.durationInSeconds}s` : "";
|
|
765
|
-
|
|
766
796
|
let line = `${indent}${type} "${label}"`;
|
|
767
|
-
|
|
768
|
-
if (type === "
|
|
769
|
-
|
|
770
|
-
} else if (type === "folder") {
|
|
771
|
-
line += ` (${node.isSeries ? "series" : "parallel"}${node.transition ? `, transition:${node.transition}` : ""}${dur})`;
|
|
772
|
-
} else if (type === "component") {
|
|
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") {
|
|
773
800
|
const props = node.props ? JSON.stringify(Object.fromEntries(Object.entries(node.props).filter(([k]) => !k.startsWith("_")))) : "{}";
|
|
774
801
|
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
802
|
}
|
|
803
|
-
|
|
804
|
-
|
|
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]`;
|
|
805
813
|
const extras = [];
|
|
806
814
|
if (node.componentName) extras.push(node.componentName);
|
|
807
815
|
if (node.fit) extras.push(`fit:${node.fit}`);
|
|
808
816
|
if (node.fontSize) extras.push(`fontSize:${node.fontSize}`);
|
|
809
817
|
if (node.volume !== undefined && type !== "root") extras.push(`vol:${node.volume}`);
|
|
810
818
|
if (node.playbackRate) extras.push(`rate:${node.playbackRate}`);
|
|
811
|
-
if (node.style) extras.push(`style:"${node.style.slice(0, 40)}"`);
|
|
812
819
|
if (node.transitionTime !== undefined) extras.push(`transTime:${node.transitionTime}`);
|
|
813
820
|
if (node.visible === false) extras.push("hidden");
|
|
814
821
|
if (extras.length > 0) line += ` {${extras.join(", ")}}`;
|
|
815
|
-
|
|
816
822
|
return line;
|
|
817
823
|
}
|
|
818
|
-
|
|
819
824
|
function walkTree(node, depth = 0) {
|
|
820
825
|
const lines = [describeNode(node, depth)];
|
|
821
826
|
if (node.children && node.children.length > 0) {
|
|
822
|
-
const
|
|
823
|
-
for (const child of shown) {
|
|
824
|
-
lines.push(...walkTree(child, depth + 1));
|
|
825
|
-
}
|
|
827
|
+
for (const child of node.children.filter(c => c.visible !== false || c.visible === undefined)) lines.push(...walkTree(child, depth + 1));
|
|
826
828
|
}
|
|
827
829
|
return lines;
|
|
828
830
|
}
|
|
829
|
-
|
|
830
831
|
treeInfo = walkTree(root).join("\n");
|
|
831
832
|
} catch {}
|
|
832
833
|
|
|
833
834
|
const historyStr = editHistory.length > 1
|
|
834
|
-
? "
|
|
835
|
+
? "Previous edits (in order):\n" + editHistory.slice(0, -1).map((e, i) => `${i+1}. ${e}`).join("\n") + "\n"
|
|
835
836
|
: "";
|
|
836
837
|
|
|
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);
|
|
838
853
|
|
|
839
|
-
|
|
840
|
-
|
|
854
|
+
// Build user prompt with current context (changes every edit)
|
|
855
|
+
const timeStr = currentTime !== undefined ? `Current playback time: ${Number(currentTime).toFixed(1)}s` : "";
|
|
856
|
+
const sceneStr = activeScene ? `Active scene: ${activeScene}` : "";
|
|
857
|
+
const contextBlock = [timeStr, sceneStr].filter(Boolean).join("\n");
|
|
858
|
+
|
|
859
|
+
const userPrompt = `--- Current stream tree (for reference) ---
|
|
860
|
+
${treeInfo || "(compiled tree)"}
|
|
841
861
|
${historyStr}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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], {
|
|
862
|
+
${contextBlock}
|
|
863
|
+
Edit request: ${text}`;
|
|
864
|
+
|
|
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
|
+
console.log(` 🤖 edit: ${text} (${currentTime !== undefined ? currentTime.toFixed(1) + "s" : ""} ${activeScene || ""})`);
|
|
878
|
+
console.log(` 🎬 session: ${sessionId}`);
|
|
879
|
+
console.log(` 📋 cmd: ${agentCli.split(/\s+/)[0]} ${agentCli.split(/\s+/).slice(1).join(" ").replace(/\{prompt\}/g, "...").substring(0, 120)}`);
|
|
880
|
+
|
|
881
|
+
const child = spawn(cmd, cmdArgs, {
|
|
878
882
|
cwd: ROOT,
|
|
879
883
|
stdio: ["ignore", "pipe", "pipe"],
|
|
880
884
|
});
|
|
@@ -885,13 +889,13 @@ IMPORTANT: Read the full existing JSON file before editing. Only edit the JSON f
|
|
|
885
889
|
|
|
886
890
|
child.on("exit", (code) => {
|
|
887
891
|
if (code === 0) {
|
|
888
|
-
console.log(` ✅
|
|
892
|
+
console.log(` ✅ edit complete`);
|
|
889
893
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
890
894
|
res.end(JSON.stringify({ done: true, output: output.trim() }));
|
|
891
895
|
} else {
|
|
892
|
-
console.error(` ❌
|
|
896
|
+
console.error(` ❌ edit failed (exit ${code}): ${output.trim()}`);
|
|
893
897
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
894
|
-
res.end(JSON.stringify({ error: `
|
|
898
|
+
res.end(JSON.stringify({ error: `agent exited with code ${code}`, output: output.trim() }));
|
|
895
899
|
}
|
|
896
900
|
});
|
|
897
901
|
} catch (e) {
|
|
@@ -902,7 +906,7 @@ IMPORTANT: Read the full existing JSON file before editing. Only edit the JSON f
|
|
|
902
906
|
return;
|
|
903
907
|
}
|
|
904
908
|
|
|
905
|
-
// API: SSE stream for reload notifications
|
|
909
|
+
// API: SSE stream for reload notifications + server-liveness monitor
|
|
906
910
|
// When the browser tab closes, this connection drops → server shuts down
|
|
907
911
|
// Grace period: wait 3s for reconnection (page reload), then exit
|
|
908
912
|
if (path === "/api/events") {
|
|
@@ -911,6 +915,8 @@ IMPORTANT: Read the full existing JSON file before editing. Only edit the JSON f
|
|
|
911
915
|
"Cache-Control": "no-cache",
|
|
912
916
|
"Connection": "keep-alive",
|
|
913
917
|
});
|
|
918
|
+
// Flush headers to client so EventSource.onopen fires
|
|
919
|
+
res.write(":ok\n\n");
|
|
914
920
|
sseClients.add(res);
|
|
915
921
|
if (shutdownTimer) {
|
|
916
922
|
clearTimeout(shutdownTimer);
|
|
@@ -993,6 +999,7 @@ server.listen(PORT, async () => {
|
|
|
993
999
|
} catch {
|
|
994
1000
|
// Pipeline failed — still announce ready so the player can show an error state
|
|
995
1001
|
}
|
|
1002
|
+
|
|
996
1003
|
const mode = MODE_LABEL ? " --label" : MODE_EDIT ? " --edit" : "";
|
|
997
1004
|
console.log(`\n🎬 Player ready at http://localhost:${PORT}${mode}`);
|
|
998
1005
|
if (VARIANT_CONFIGS.length > 1) {
|