@lalalic/markcut 1.2.0 → 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.
Files changed (61) hide show
  1. package/AGENTS.md +39 -17
  2. package/README.md +7 -3
  3. package/SKILL.md +0 -2
  4. package/docs/edit-mode.md +1 -1
  5. package/docs/label-mode.md +6 -4
  6. package/docs/markdown-descriptive.md +34 -1
  7. package/docs/system-prompt-edit.md +16 -0
  8. package/package.json +1 -1
  9. package/src/config.mjs +8 -3
  10. package/src/descriptive/compiler.test.ts +42 -42
  11. package/src/descriptive/compiler.ts +41 -52
  12. package/src/descriptive/markdown.ts +8 -4
  13. package/src/descriptive/resolve.test.ts +14 -7
  14. package/src/descriptive/resolve.ts +148 -20
  15. package/src/player/browser.tsx +178 -54
  16. package/src/player/bundle/player.js +1168 -566
  17. package/src/player/components/EditControls.tsx +92 -0
  18. package/src/player/components/HeaderBar.tsx +60 -0
  19. package/src/player/components/LabelControls.tsx +367 -0
  20. package/src/player/components/SceneThumbnails.tsx +87 -0
  21. package/src/player/components/VariantBar.tsx +39 -0
  22. package/src/player/components/index.ts +5 -0
  23. package/src/player/pipeline.mjs +130 -66
  24. package/src/player/pipeline.ts +3 -1
  25. package/src/player/server-shared.mjs +5 -7
  26. package/src/player/server.mjs +194 -187
  27. package/src/render/cli.mjs +4 -6
  28. package/src/schema/index.ts +20 -23
  29. package/src/types/Audio.tsx +25 -33
  30. package/src/types/Component.tsx +18 -24
  31. package/src/types/Effect.tsx +31 -39
  32. package/src/types/Image.tsx +23 -30
  33. package/src/types/Include.tsx +70 -76
  34. package/src/types/Map.tsx +48 -44
  35. package/src/types/Rhythm.tsx +19 -27
  36. package/src/types/Video.tsx +40 -47
  37. package/src/utils/index.ts +23 -10
  38. package/src/vision/cli.mjs +6 -6
  39. package/templates/courseware/TEMPLATE.md +16 -10
  40. package/tests/fixtures/audio.json +4 -2
  41. package/tests/fixtures/basic.json +2 -1
  42. package/tests/fixtures/component-all.json +4 -2
  43. package/tests/fixtures/components.json +4 -2
  44. package/tests/fixtures/effects.json +12 -6
  45. package/tests/fixtures/full.json +8 -4
  46. package/tests/fixtures/map.json +2 -1
  47. package/tests/fixtures/md/dialogue.md +12 -0
  48. package/tests/fixtures/md/edge-cases.md +9 -0
  49. package/tests/fixtures/scenes.json +4 -2
  50. package/tests/fixtures/subtitle.json +6 -3
  51. package/tests/fixtures/subvideo.json +11 -6
  52. package/tests/fixtures/templates/courseware.md +61 -4
  53. package/tests/fixtures/tween-visual.json +2 -6
  54. package/tests/fixtures/video-series.json +6 -14
  55. package/tests/md-descriptive.test.ts +170 -0
  56. package/tests/render.test.ts +32 -16
  57. package/tests/schema.test.ts +6 -3
  58. package/tests/server.test.ts +9 -6
  59. package/tests/utils.ts +4 -4
  60. package/scripts/artlist-dl.mjs +0 -190
  61. package/src/player/label-server.mjs +0 -599
@@ -17,6 +17,8 @@ import { fileURLToPath } from "node:url";
17
17
  import { isDescriptiveRoot, resolveAndCompile, resolveAndCompileMarkdown, parseImportsBlock, extractDependencySpecs } from "./pipeline.mjs";
18
18
  import { bundleFromEntries } from "./bundler.mjs";
19
19
  import { extractScenes, MIME, serveFile, handleShutdown } from "./server-shared.mjs";
20
+ import { DEFAULT_AGENT_CLI } from "../config.mjs";
21
+
20
22
 
21
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
22
24
  const ROOT = resolve(__dirname, "..", "..");
@@ -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 hasLabel = MODE_LABEL ? "true" : "false";
542
- const hasWatch = MODE_EDIT ? "true" : "false";
543
- const title = label !== "default" ? ` — ${label}` : MODE_LABEL ? " — Label" : MODE_EDIT ? " — Edit" : "";
544
-
545
- // Build variant switcher links
546
- const variantLinks = VARIANT_CONFIGS.map(c =>
547
- `<a href="/${c.label === "default" ? "" : c.label}" class="variant-link${c.label === label ? " active" : ""}">${c.label}</a>`
548
- ).join("");
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${title}</title>
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: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
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>window.VARIANT = "${label}";</script>
584
- ${MODE_EDIT ? `<div id="header">
585
- <span id="header-status"></span>
586
- <div id="header-actions">
587
- <button id="close-btn" title="Close player and return to terminal">āœ•</button>
588
- </div>
589
- </div>` : ""}
590
- <div id="variant-bar">${variantLinks}</div>
591
- <div id="player-frame">
592
- <div id="root"></div>
593
- </div>
594
- <div id="reload-toast">šŸ”„ JSON changed — reloading...</div>
595
- ${MODE_EDIT ? `<div id="bottom-bar">
596
- <input id="edit-input" placeholder="What should change? e.g. make text bigger" />
597
- <button id="edit-btn" title="Apply edit">&#x2728;</button>
598
- </div>` : ""}
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(data);
691
- } catch (e) {
681
+ res.end(JSON.stringify(tree));
682
+ } catch {
692
683
  res.writeHead(200, { "Content-Type": "application/json" });
693
- res.end(JSON.stringify({ labels: [], scenes: [] }));
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
- writeFileSync(labelsPath, body, "utf-8");
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 pi one-shot to edit the JSON, player auto-reloads
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
- // Build tree structure description from current JSON (recursive, any depth)
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 raw = readFileSync(VIDEO_JSON, "utf-8");
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 === "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") {
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
- // Add notable fields
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 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
- }
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
- ? "\nPrevious edits on this file (in order):\n" + editHistory.slice(0, -1).map((e, i) => `${i+1}. ${e}`).join("\n") + "\n"
835
+ ? "Previous edits (in order):\n" + editHistory.slice(0, -1).map((e, i) => `${i+1}. ${e}`).join("\n") + "\n"
835
836
  : "";
836
837
 
837
- const prompt = `You are editing ${VIDEO_JSON.split("/").pop()}, a Remotion stream tree JSON.
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
- The stream tree (indentation shows nesting; timing in seconds):
840
- ${treeInfo || "(could not read tree)"}
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
- Edit request: ${text}
843
-
844
- --- Knowledge ---
845
- You can edit ANY field on ANY node in the JSON. Common fields across all types:
846
- - id, name, type, style (inline CSS string), visible (boolean)
847
-
848
- Stream types:
849
- root: {width, height, fps, isSeries, transition, transitionTime, theme, stylesheet, children}
850
- folder: {isSeries (parallel if false), transition, transitionTime, children}
851
- video: {src, volume, playbackRate, width, height, actions}
852
- audio: {src, volume, foreground (ducks parent video), actions}
853
- image: {src, fit (contain/cover/fill), actions}
854
- subtitle: {src (text or VTT), cues[], fontSize, fontStyle, style, actions}
855
- component: {componentName, props:{}, src (remote URL), actions}
856
- effect: {animation (builtin name or "custom"), animationTimingFunction, animationIterationCount, customKeyframes, children, actions}
857
- rhythm: {src (audio), volume, spots[] (beat timestamps), children, actions}
858
- map: {waypoints[{lat,lng,label?,media?}], routeColor, routeWeight, markerSrc, zoom, actions}
859
- include: src (video JSON file path/URL), volume, actions — embeds an external video composition referenced by src. Falls back to inline children (legacy).
860
-
861
- Actions (on leaf types): [{start, end, style?, volume?, effectId?, loop?}] — start/end in seconds, relative to parent container
862
-
863
- Composition rules:
864
- - isSeries=true → children play sequentially (one after another), with optional transition between them
865
- - isSeries=false → children play in parallel, max duration wins (default)
866
- - isBackground=true → node loops for the full duration of its parent, excluded from duration calc
867
- - transition can be: "fade"|"slide"|"wipe"|"flip"|"clockWipe"
868
-
869
- Subtitle styling: style field supports CSS (e.g. "color:#fff;font-size:48px"). fontSize field for quick sizing. Supports HTML in src for rich text. For word-highlight karaoke: set className:"karaoke" on cue, or provide words[{text,start,end}] array.
870
-
871
- Themes: set root.theme = "cinematic"|"minimal"|"neon"|"corporate" or an inline theme JSON object. Default is "cinematic".
872
- Global stylesheet: root.stylesheet = "CSS string" — selectors use .type and .name class names on each node.
873
-
874
- IMPORTANT: Read the full existing JSON file before editing. Only edit the JSON file. You can change, add, or remove any field on any node. Output ONLY a one-line summary of what specific change you made. Do not add explanations.`;
875
-
876
- console.log(` šŸ¤– pi edit: ${text}`);
877
- const child = spawn("pi", ["-p", prompt], {
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(` āœ… pi edit complete`);
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(` āŒ pi edit failed (exit ${code}): ${output.trim()}`);
896
+ console.error(` āŒ edit failed (exit ${code}): ${output.trim()}`);
893
897
  res.writeHead(500, { "Content-Type": "application/json" });
894
- res.end(JSON.stringify({ error: `pi exited with code ${code}`, output: output.trim() }));
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) {
@@ -243,21 +243,19 @@ async function main() {
243
243
  }
244
244
 
245
245
  if (args.label || args.edit) {
246
- const playerServer = args.label
247
- ? join(__dirname, "..", "player", "label-server.mjs")
248
- : join(__dirname, "..", "player", "server.mjs");
246
+ const playerServer = join(__dirname, "..", "player", "server.mjs");
249
247
  if (!existsSync(playerServer)) {
250
248
  console.error("Player server not found at", playerServer);
251
249
  process.exit(1);
252
250
  }
253
- const modeFlags = args.noBrowser ? "--no-browser" : "";
251
+ const labelFlag = args.label ? "--label" : "";
254
252
  const editFlag = args.edit ? "--edit" : "";
255
253
  const portFlag = `--port=${args.port || 3001}`;
256
254
  const fileFlag = args.file || join(ROOT, "video.json");
257
255
  const port = args.port || 3001;
258
256
  // Pass variant flags to the server
259
257
  const variantFlags = args.variant.map(v => `--variant=${v}`);
260
- const serverArgs = [playerServer, resolve(fileFlag), modeFlags, editFlag, `--port=${port}`, ...variantFlags].filter(Boolean);
258
+ const serverArgs = [playerServer, resolve(fileFlag), labelFlag, editFlag, portFlag, ...variantFlags].filter(Boolean);
261
259
  const child = spawn("node", serverArgs, { cwd: ROOT, stdio: ["ignore", "pipe", "inherit"] });
262
260
  let serverReady = false;
263
261
  let stdoutBuffer = "";
@@ -267,7 +265,7 @@ async function main() {
267
265
  process.stdout.write(chunk);
268
266
  if (!serverReady && !args.noBrowser) {
269
267
  stdoutBuffer += chunk.toString();
270
- if (stdoutBuffer.includes("Player ready") || stdoutBuffer.includes("Label Preview")) {
268
+ if (stdoutBuffer.includes("Player ready")) {
271
269
  serverReady = true;
272
270
 
273
271
  try {