reel-studio 0.2.1 → 0.2.3
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/.claude/skills/make-video/SKILL.md +2 -1
- package/.claude/skills/review-desk/SKILL.md +52 -0
- package/README.md +31 -4
- package/bin/reel-studio.mjs +16 -2
- package/core/lib/runtime.js +11 -1
- package/core/package-lock.json +9 -9
- package/core/package.json +2 -2
- package/core/render.mjs +4 -2
- package/desk/README.md +47 -0
- package/desk/app.js +332 -0
- package/desk/desk.css +120 -0
- package/desk/index.html +102 -0
- package/desk/lib.mjs +190 -0
- package/desk/mcp.mjs +99 -0
- package/desk/server.mjs +222 -0
- package/package.json +9 -3
- package/scripts/init-project.sh +2 -1
- package/templates/project/CLAUDE.md +2 -0
- package/templates/project/gitignore +3 -0
package/desk/desk.css
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #f4f3ef; --surface: #ffffff; --sunk: #eceae4; --line: #dcd9d1; --ink: #1d1c1a; --muted: #6f6c65;
|
|
3
|
+
--accent: #d9481f; --accent-ink: #fff; --ok: #2f7d4f; --warn: #a86a00; --stage: #16161a;
|
|
4
|
+
--mono: ui-monospace, "SF Mono", Menlo, monospace;
|
|
5
|
+
color-scheme: light;
|
|
6
|
+
}
|
|
7
|
+
@media (prefers-color-scheme: dark) {
|
|
8
|
+
:root:not([data-theme="light"]) {
|
|
9
|
+
--bg: #121214; --surface: #1b1b1f; --sunk: #232328; --line: #303036; --ink: #ecebe8; --muted: #9a978f;
|
|
10
|
+
--accent: #ff6a3d; --accent-ink: #160a05; --ok: #59c184; --warn: #e0a640; --stage: #0a0a0c;
|
|
11
|
+
color-scheme: dark;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
:root[data-theme="dark"] {
|
|
15
|
+
--bg: #121214; --surface: #1b1b1f; --sunk: #232328; --line: #303036; --ink: #ecebe8; --muted: #9a978f;
|
|
16
|
+
--accent: #ff6a3d; --accent-ink: #160a05; --ok: #59c184; --warn: #e0a640; --stage: #0a0a0c;
|
|
17
|
+
color-scheme: dark;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
* { box-sizing: border-box; }
|
|
21
|
+
[hidden] { display: none !important; }
|
|
22
|
+
html, body { margin: 0; height: 100%; }
|
|
23
|
+
body { background: var(--bg); color: var(--ink); font: 14px/1.45 system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; }
|
|
24
|
+
button, input, textarea { font: inherit; color: inherit; }
|
|
25
|
+
button { background: var(--sunk); border: 1px solid var(--line); border-radius: 7px; padding: 6px 11px; cursor: pointer; }
|
|
26
|
+
button:hover { border-color: var(--muted); }
|
|
27
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
28
|
+
button.primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; font-weight: 600; }
|
|
29
|
+
button.wide { width: 100%; margin-top: 10px; }
|
|
30
|
+
input, textarea { background: var(--surface); border: 1px solid var(--line); border-radius: 7px; padding: 7px 9px; width: 100%; }
|
|
31
|
+
textarea { resize: vertical; }
|
|
32
|
+
code, .mono { font-family: var(--mono); font-size: 12px; }
|
|
33
|
+
.muted { color: var(--muted); }
|
|
34
|
+
.small { font-size: 12px; }
|
|
35
|
+
.row { display: flex; gap: 8px; align-items: center; margin: 8px 0; }
|
|
36
|
+
.wrap { flex-wrap: wrap; }
|
|
37
|
+
h4 { margin: 18px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
|
|
38
|
+
|
|
39
|
+
.top { display: flex; align-items: center; gap: 18px; padding: 10px 16px; border-bottom: 1px solid var(--line); background: var(--surface); }
|
|
40
|
+
.brand { font-weight: 700; white-space: nowrap; }
|
|
41
|
+
.logo { color: var(--accent); }
|
|
42
|
+
.crumb { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
|
|
43
|
+
.status { font-size: 12px; color: var(--muted); white-space: nowrap; }
|
|
44
|
+
|
|
45
|
+
.shell { flex: 1; min-height: 0; display: grid; grid-template-columns: 250px 1fr 360px; }
|
|
46
|
+
.side { border-right: 1px solid var(--line); padding: 12px; overflow: auto; }
|
|
47
|
+
.filter { margin-bottom: 10px; }
|
|
48
|
+
.plist { list-style: none; margin: 0; padding: 0; }
|
|
49
|
+
.plist .brandname { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 12px 4px 4px; }
|
|
50
|
+
.plist button { display: block; width: 100%; text-align: left; background: none; border: 1px solid transparent; padding: 7px 8px; }
|
|
51
|
+
.plist button.on { background: var(--surface); border-color: var(--line); }
|
|
52
|
+
.plist .t { display: block; font-weight: 600; font-size: 13px; }
|
|
53
|
+
.plist .s { display: flex; gap: 6px; align-items: center; font-size: 11px; color: var(--muted); }
|
|
54
|
+
.pill { font-size: 10px; padding: 1px 6px; border-radius: 99px; background: var(--sunk); color: var(--muted); }
|
|
55
|
+
.pill.notes { background: var(--accent); color: var(--accent-ink); }
|
|
56
|
+
.pill.ok { color: var(--ok); }
|
|
57
|
+
|
|
58
|
+
.viewer { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--stage); }
|
|
59
|
+
.stage-wrap { flex: 1; min-height: 0; position: relative; overflow: hidden; }
|
|
60
|
+
.stage-wrap iframe { position: absolute; left: 50%; top: 50%; border: 0; transform-origin: 0 0; background: #000; box-shadow: 0 6px 30px rgba(0,0,0,.4); }
|
|
61
|
+
.stage-wrap iframe.loading { visibility: hidden; }
|
|
62
|
+
.empty { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; color: #cfcdc7; padding: 24px; }
|
|
63
|
+
.empty .muted { color: #8c8a84; }
|
|
64
|
+
.transport { display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: var(--surface); border-top: 1px solid var(--line); }
|
|
65
|
+
.icon { width: 34px; padding: 6px 0; }
|
|
66
|
+
.time { width: 110px; text-align: right; color: var(--muted); }
|
|
67
|
+
.toggle { display: flex; gap: 4px; align-items: center; font-size: 12px; color: var(--muted); }
|
|
68
|
+
.toggle input { width: auto; }
|
|
69
|
+
.scrub { flex: 1; position: relative; height: 28px; cursor: pointer; touch-action: none; }
|
|
70
|
+
.track { position: absolute; left: 0; right: 0; top: 12px; height: 4px; background: var(--sunk); border-radius: 2px; overflow: hidden; }
|
|
71
|
+
.fill { height: 100%; background: var(--muted); width: 0; }
|
|
72
|
+
.head { position: absolute; top: 5px; width: 2px; height: 18px; background: var(--ink); transform: translateX(-1px); pointer-events: none; }
|
|
73
|
+
.marks .m { position: absolute; top: 2px; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); transform: translateX(-4px); border: 2px solid var(--surface); }
|
|
74
|
+
.marks .m.fixed { background: var(--ok); }
|
|
75
|
+
|
|
76
|
+
.panel { border-left: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; background: var(--surface); }
|
|
77
|
+
.tabs { display: flex; border-bottom: 1px solid var(--line); }
|
|
78
|
+
.tabs button { flex: 1; background: none; border: 0; border-bottom: 2px solid transparent; border-radius: 0; padding: 10px 4px; color: var(--muted); }
|
|
79
|
+
.tabs button.on { color: var(--ink); border-bottom-color: var(--accent); font-weight: 600; }
|
|
80
|
+
.count { font-size: 10px; background: var(--accent); color: var(--accent-ink); border-radius: 99px; padding: 0 6px; }
|
|
81
|
+
.count:empty { display: none; }
|
|
82
|
+
.tab { padding: 12px; overflow: auto; flex: 1; }
|
|
83
|
+
.noteform { display: grid; gap: 6px; }
|
|
84
|
+
.at { color: var(--accent); }
|
|
85
|
+
.seg { display: flex; margin: 12px 0 8px; }
|
|
86
|
+
.seg button { flex: 1; border-radius: 0; font-size: 12px; padding: 4px; }
|
|
87
|
+
.seg button:first-child { border-radius: 7px 0 0 7px; }
|
|
88
|
+
.seg button:last-child { border-radius: 0 7px 7px 0; }
|
|
89
|
+
.seg button.on { background: var(--ink); color: var(--surface); }
|
|
90
|
+
.notes { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
|
91
|
+
.note { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
|
|
92
|
+
.note .h { display: flex; gap: 6px; align-items: center; font-size: 11px; color: var(--muted); }
|
|
93
|
+
.note .h a { color: var(--accent); cursor: pointer; font-family: var(--mono); }
|
|
94
|
+
.note .h .sp { flex: 1; }
|
|
95
|
+
.note .h button { padding: 1px 6px; font-size: 11px; }
|
|
96
|
+
.note p { margin: 4px 0 0; }
|
|
97
|
+
.note .reply { margin-top: 6px; padding-left: 8px; border-left: 2px solid var(--ok); color: var(--muted); font-size: 13px; }
|
|
98
|
+
.note.fixed { border-color: color-mix(in srgb, var(--ok) 50%, var(--line)); }
|
|
99
|
+
.note.closed { opacity: .6; }
|
|
100
|
+
.log { background: var(--sunk); border-radius: 8px; padding: 10px; font: 11.5px/1.5 var(--mono); white-space: pre-wrap; word-break: break-word; max-height: 50vh; overflow: auto; margin: 10px 0 0; }
|
|
101
|
+
.log:empty { display: none; }
|
|
102
|
+
.renderout img { max-width: 100%; border-radius: 6px; margin-top: 8px; }
|
|
103
|
+
.renderout video { max-width: 100%; margin-top: 8px; }
|
|
104
|
+
.exports, .approvals { list-style: none; padding: 0; margin: 0; font-size: 13px; }
|
|
105
|
+
.exports a { color: var(--accent); }
|
|
106
|
+
.approvals li { color: var(--ok); }
|
|
107
|
+
|
|
108
|
+
.md { font-size: 13px; }
|
|
109
|
+
.md h1 { font-size: 17px; } .md h2 { font-size: 15px; margin-top: 18px; } .md h3 { font-size: 14px; }
|
|
110
|
+
.md table { border-collapse: collapse; width: 100%; font-size: 12px; display: block; overflow-x: auto; }
|
|
111
|
+
.md td, .md th { border: 1px solid var(--line); padding: 4px 6px; vertical-align: top; }
|
|
112
|
+
.md blockquote { margin: 6px 0; padding-left: 10px; border-left: 3px solid var(--line); color: var(--muted); }
|
|
113
|
+
.md pre { background: var(--sunk); padding: 8px; border-radius: 6px; overflow: auto; }
|
|
114
|
+
|
|
115
|
+
@media (max-width: 1000px) {
|
|
116
|
+
.shell { grid-template-columns: 1fr; grid-template-rows: auto 60vh auto; }
|
|
117
|
+
.side { border-right: 0; border-bottom: 1px solid var(--line); max-height: 30vh; }
|
|
118
|
+
.panel { border-left: 0; border-top: 1px solid var(--line); }
|
|
119
|
+
.crumb { display: none; }
|
|
120
|
+
}
|
package/desk/index.html
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Reel Studio Desk</title>
|
|
7
|
+
<link rel="stylesheet" href="/desk/desk.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header class="top">
|
|
11
|
+
<div class="brand"><span class="logo">●</span> Reel Studio <span class="muted">desk</span></div>
|
|
12
|
+
<div class="crumb" id="crumb">Pick a production</div>
|
|
13
|
+
<div class="status" id="status"></div>
|
|
14
|
+
</header>
|
|
15
|
+
|
|
16
|
+
<main class="shell">
|
|
17
|
+
<nav class="side">
|
|
18
|
+
<input id="filter" class="filter" placeholder="Filter productions" autocomplete="off">
|
|
19
|
+
<ul id="productions" class="plist"></ul>
|
|
20
|
+
</nav>
|
|
21
|
+
|
|
22
|
+
<section class="viewer">
|
|
23
|
+
<div class="stage-wrap" id="stageWrap">
|
|
24
|
+
<div class="empty" id="empty">Pick a production on the left.<br><span class="muted">Compositions play here, frame-exact, through the same clock the renderer uses.</span></div>
|
|
25
|
+
<iframe id="comp" title="Composition" hidden></iframe>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="transport" id="transport" hidden>
|
|
28
|
+
<button id="play" class="icon" title="Play / pause (space)">▶</button>
|
|
29
|
+
<button id="prev" class="icon" title="Previous frame (←)">‹</button>
|
|
30
|
+
<button id="next" class="icon" title="Next frame (→)">›</button>
|
|
31
|
+
<div class="scrub" id="scrub">
|
|
32
|
+
<div class="track"><div class="fill" id="fill"></div></div>
|
|
33
|
+
<div class="marks" id="marks"></div>
|
|
34
|
+
<div class="head" id="head"></div>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="time mono" id="time">0.00 s · f0</div>
|
|
37
|
+
<label class="toggle" title="Safe zones (S)"><input type="checkbox" id="safe"> Safe</label>
|
|
38
|
+
</div>
|
|
39
|
+
</section>
|
|
40
|
+
|
|
41
|
+
<aside class="panel" id="panel" hidden>
|
|
42
|
+
<div class="tabs" role="tablist">
|
|
43
|
+
<button role="tab" data-tab="notes" class="on">Notes <span class="count" id="noteCount"></span></button>
|
|
44
|
+
<button role="tab" data-tab="claude">Claude</button>
|
|
45
|
+
<button role="tab" data-tab="render">Render</button>
|
|
46
|
+
<button role="tab" data-tab="board">Storyboard</button>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div class="tab" data-tab="notes">
|
|
50
|
+
<form id="noteForm" class="noteform">
|
|
51
|
+
<div class="at mono" id="noteAt">at 0.00 s</div>
|
|
52
|
+
<textarea id="noteText" rows="2" placeholder="What should change on this frame? (N)"></textarea>
|
|
53
|
+
<button class="primary">Pin note</button>
|
|
54
|
+
</form>
|
|
55
|
+
<div class="seg" id="noteFilter">
|
|
56
|
+
<button data-f="open" class="on">Open</button><button data-f="fixed">Fixed</button><button data-f="all">All</button>
|
|
57
|
+
</div>
|
|
58
|
+
<ul class="notes" id="notes"></ul>
|
|
59
|
+
<button class="primary wide" id="sendNotes">Send open notes to Claude</button>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div class="tab" data-tab="claude" hidden>
|
|
63
|
+
<p class="muted small">Runs your local Claude Code (<code>claude -p</code>) on this production with your existing login, no API key.
|
|
64
|
+
It may read and edit files, render stills and run QA, nothing else. It keeps one session per production.</p>
|
|
65
|
+
<textarea id="ask" rows="3" placeholder="Ask for a change, e.g. “make the end card hold 0.5 s longer”"></textarea>
|
|
66
|
+
<label class="small"><input type="checkbox" id="withNotes" checked> Include open notes</label>
|
|
67
|
+
<div class="row">
|
|
68
|
+
<button class="primary" id="sendAsk">Send to Claude</button>
|
|
69
|
+
<button id="stopClaude" hidden>Stop</button>
|
|
70
|
+
</div>
|
|
71
|
+
<pre class="log" id="claudeLog"></pre>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div class="tab" data-tab="render" hidden>
|
|
75
|
+
<p class="muted small">Desk renders go to <code>qa/desk/</code> so a delivered export is never overwritten.</p>
|
|
76
|
+
<div class="row wrap">
|
|
77
|
+
<button id="rStill">Still at this frame</button>
|
|
78
|
+
<button id="rVideo">Preview video</button>
|
|
79
|
+
<button id="rQa">QA the preview</button>
|
|
80
|
+
</div>
|
|
81
|
+
<div id="renderOut" class="renderout"></div>
|
|
82
|
+
<pre class="log" id="renderLog"></pre>
|
|
83
|
+
<h4>Exports</h4>
|
|
84
|
+
<ul class="exports" id="exports"></ul>
|
|
85
|
+
<h4>Approve</h4>
|
|
86
|
+
<p class="muted small">Approvals are yours alone. Claude can't record them.</p>
|
|
87
|
+
<input id="approver" placeholder="Your name" autocomplete="name">
|
|
88
|
+
<div class="row wrap">
|
|
89
|
+
<button id="okBoard">Approve storyboard</button>
|
|
90
|
+
<button id="okFinal">Approve final cut</button>
|
|
91
|
+
</div>
|
|
92
|
+
<ul class="approvals" id="approvals"></ul>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="tab board" data-tab="board" hidden>
|
|
96
|
+
<div id="readme" class="md"></div>
|
|
97
|
+
</div>
|
|
98
|
+
</aside>
|
|
99
|
+
</main>
|
|
100
|
+
<script type="module" src="/desk/app.js"></script>
|
|
101
|
+
</body>
|
|
102
|
+
</html>
|
package/desk/lib.mjs
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Shared by the desk server and the desk MCP server: productions, review notes, approvals, renders.
|
|
2
|
+
// A production's review lives in productions/<brand>/<ID>/review.json:
|
|
3
|
+
// { "notes": [{ id, t, text, by: "human"|"claude", status: "open"|"fixed"|"closed", reply, at }],
|
|
4
|
+
// "approvals": [{ stage: "storyboard"|"final", by, at }],
|
|
5
|
+
// "claude": { "session": "<id of the last headless Claude run>" } }
|
|
6
|
+
// Approvals are written by people only (the desk's buttons), never by Claude or the MCP server.
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
export const ROOT = process.env.REEL_STUDIO_ROOT
|
|
13
|
+
? path.resolve(process.env.REEL_STUDIO_ROOT)
|
|
14
|
+
: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
+
export const STATE = path.join(ROOT, "desk", ".state.json");
|
|
16
|
+
|
|
17
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
18
|
+
|
|
19
|
+
/** `brand/ID-slug` → absolute folder, refusing anything outside productions/. */
|
|
20
|
+
export function prodDir(id) {
|
|
21
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(id || "") || id.startsWith("_")) throw new Error(`bad production id: ${id}`);
|
|
22
|
+
const dir = path.join(ROOT, "productions", id);
|
|
23
|
+
if (!existsSync(path.join(dir, "README.md"))) throw new Error(`no production ${id}`);
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function field(readme, name) {
|
|
28
|
+
const m = new RegExp(`^\\|\\s*${name}\\s*\\|\\s*(.*?)\\s*\\|\\s*$`, "mi").exec(readme);
|
|
29
|
+
return m ? m[1].replace(/`/g, "") : "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function listProductions() {
|
|
33
|
+
const base = path.join(ROOT, "productions");
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const brand of existsSync(base) ? readdirSync(base).sort() : []) {
|
|
36
|
+
if (brand.startsWith("_") || brand.startsWith(".") || !statSync(path.join(base, brand)).isDirectory()) continue;
|
|
37
|
+
for (const name of readdirSync(path.join(base, brand)).sort()) {
|
|
38
|
+
const dir = path.join(base, brand, name);
|
|
39
|
+
if (!existsSync(path.join(dir, "README.md"))) continue;
|
|
40
|
+
const readme = readFileSync(path.join(dir, "README.md"), "utf8");
|
|
41
|
+
const comp = path.join(dir, "composition", "index.html");
|
|
42
|
+
const exportDir = path.join(dir, "export");
|
|
43
|
+
const review = readReview(`${brand}/${name}`);
|
|
44
|
+
out.push({
|
|
45
|
+
id: `${brand}/${name}`, brand,
|
|
46
|
+
title: (/^#\s+(.+)$/m.exec(readme)?.[1] || name).trim(),
|
|
47
|
+
status: field(readme, "Status"),
|
|
48
|
+
composition: existsSync(comp) ? `/productions/${brand}/${name}/composition/index.html` : null,
|
|
49
|
+
exports: existsSync(exportDir) ? readdirSync(exportDir).filter((f) => /\.(mp4|png|jpg)$/i.test(f))
|
|
50
|
+
.map((f) => `/productions/${brand}/${name}/export/${f}`) : [],
|
|
51
|
+
openNotes: review.notes.filter((n) => n.status === "open").length,
|
|
52
|
+
approvals: review.approvals,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readReview(id) {
|
|
60
|
+
const file = path.join(prodDir(id), "review.json");
|
|
61
|
+
const r = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
62
|
+
return { notes: r.notes || [], approvals: r.approvals || [], claude: r.claude || {} };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function writeReview(id, review) {
|
|
66
|
+
writeFileSync(path.join(prodDir(id), "review.json"), JSON.stringify(review, null, 2) + "\n");
|
|
67
|
+
return review;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function addNote(id, { t, text, by = "human" }) {
|
|
71
|
+
if (!text?.trim()) throw new Error("note needs text");
|
|
72
|
+
const r = readReview(id);
|
|
73
|
+
const note = { id: Math.max(0, ...r.notes.map((n) => n.id)) + 1, t: +(+t || 0).toFixed(3), text: text.trim(), by,
|
|
74
|
+
status: "open", reply: "", at: new Date().toISOString() };
|
|
75
|
+
r.notes.push(note);
|
|
76
|
+
writeReview(id, r);
|
|
77
|
+
return note;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function updateNote(id, noteId, patch) {
|
|
81
|
+
const r = readReview(id);
|
|
82
|
+
const note = r.notes.find((n) => n.id === +noteId);
|
|
83
|
+
if (!note) throw new Error(`no note ${noteId}`);
|
|
84
|
+
for (const k of ["status", "reply", "text"]) if (patch[k] !== undefined) note[k] = patch[k];
|
|
85
|
+
if (!["open", "fixed", "closed"].includes(note.status)) throw new Error(`bad status ${note.status}`);
|
|
86
|
+
writeReview(id, r);
|
|
87
|
+
return note;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function deleteNote(id, noteId) {
|
|
91
|
+
const r = readReview(id);
|
|
92
|
+
r.notes = r.notes.filter((n) => n.id !== +noteId);
|
|
93
|
+
writeReview(id, r);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A person approves the storyboard (ticks the README box) or the final cut. */
|
|
97
|
+
export function approve(id, stage, by) {
|
|
98
|
+
if (!["storyboard", "final"].includes(stage)) throw new Error(`bad stage ${stage}`);
|
|
99
|
+
if (!by?.trim()) throw new Error("approval needs a name");
|
|
100
|
+
const dir = prodDir(id);
|
|
101
|
+
const file = path.join(dir, "README.md");
|
|
102
|
+
let readme = readFileSync(file, "utf8");
|
|
103
|
+
const stamp = `${today()}, ${by.trim()}, in the desk`;
|
|
104
|
+
if (stage === "storyboard") {
|
|
105
|
+
readme = readme.replace(/^- \[ \] Storyboard approved\./m, `- [x] Storyboard approved (${stamp}).`);
|
|
106
|
+
readme = readme.replace(/^(\|\s*Approved by \/ date\s*\|)\s*\|\s*$/m, `$1 ${by.trim()}, ${today()} |`);
|
|
107
|
+
} else {
|
|
108
|
+
const line = `- [x] Final cut approved for delivery (${stamp}).`;
|
|
109
|
+
readme = /^- \[x\] Storyboard approved.*$/m.test(readme)
|
|
110
|
+
? readme.replace(/^- \[x\] Storyboard approved.*$/m, (m) => `${m}\n${line}`)
|
|
111
|
+
: readme.trimEnd() + `\n\n${line}\n`;
|
|
112
|
+
}
|
|
113
|
+
writeFileSync(file, readme);
|
|
114
|
+
const r = readReview(id);
|
|
115
|
+
r.approvals.push({ stage, by: by.trim(), at: new Date().toISOString() });
|
|
116
|
+
writeReview(id, r);
|
|
117
|
+
return r.approvals;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What the person has open in the desk, for Claude (desk_state). */
|
|
121
|
+
export function readState() {
|
|
122
|
+
return existsSync(STATE) ? JSON.parse(readFileSync(STATE, "utf8")) : {};
|
|
123
|
+
}
|
|
124
|
+
export function writeState(s) {
|
|
125
|
+
writeFileSync(STATE, JSON.stringify({ ...s, at: new Date().toISOString() }, null, 2) + "\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Render outputs from the desk go to qa/desk/ so a delivered export is never overwritten. */
|
|
129
|
+
export function renderArgs(id, kind, t = 0, safe = false) {
|
|
130
|
+
const dir = prodDir(id);
|
|
131
|
+
const comp = path.join(dir, "composition", "index.html");
|
|
132
|
+
if (!existsSync(comp)) throw new Error(`${id} has no composition yet`);
|
|
133
|
+
const out = path.join(dir, "qa", "desk");
|
|
134
|
+
mkdirSync(out, { recursive: true });
|
|
135
|
+
const rel = (p) => path.relative(ROOT, p);
|
|
136
|
+
const sf = safe ? ["--safe"] : [];
|
|
137
|
+
if (kind === "still") {
|
|
138
|
+
const file = path.join(out, `still_t${(+t).toFixed(2)}${safe ? "_safe" : ""}.png`);
|
|
139
|
+
return { cmd: process.execPath, args: ["core/render.mjs", "still", rel(comp), rel(file), "--t", String(+t), ...sf], file: rel(file) };
|
|
140
|
+
}
|
|
141
|
+
if (kind === "video") {
|
|
142
|
+
const file = path.join(out, "preview.mp4");
|
|
143
|
+
return { cmd: process.execPath, args: ["core/render.mjs", "video", rel(comp), rel(file), ...sf], file: rel(file) };
|
|
144
|
+
}
|
|
145
|
+
if (kind === "qa") {
|
|
146
|
+
const video = path.join(out, "preview.mp4");
|
|
147
|
+
if (!existsSync(video)) throw new Error("render a preview video first");
|
|
148
|
+
return { cmd: "bash", args: ["scripts/qa-video.sh", rel(video), rel(path.join(out, "qa"))], file: rel(path.join(out, "qa", "report.md")) };
|
|
149
|
+
}
|
|
150
|
+
throw new Error(`bad render kind ${kind}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Run a command from the repo root; onLine gets each output line. `done` resolves to the exit code. */
|
|
154
|
+
export function runLines(cmd, args, onLine) {
|
|
155
|
+
const p = spawn(cmd, args, { cwd: ROOT, env: process.env, stdio: ["ignore", "pipe", "pipe"] });
|
|
156
|
+
const done = new Promise((resolve) => {
|
|
157
|
+
let buf = "";
|
|
158
|
+
const feed = (d) => {
|
|
159
|
+
buf += d;
|
|
160
|
+
const parts = buf.split(/\r?\n|\r/);
|
|
161
|
+
buf = parts.pop();
|
|
162
|
+
for (const l of parts) if (l.trim()) onLine(l);
|
|
163
|
+
};
|
|
164
|
+
p.stdout.on("data", feed);
|
|
165
|
+
p.stderr.on("data", feed);
|
|
166
|
+
p.on("error", (e) => { onLine(String(e)); resolve(127); });
|
|
167
|
+
p.on("close", (code) => { if (buf.trim()) onLine(buf); resolve(code ?? 1); });
|
|
168
|
+
});
|
|
169
|
+
return { proc: p, done };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The prompt a headless Claude run gets from the desk's "Send to Claude". */
|
|
173
|
+
export function claudePrompt(id, notes, extra = "") {
|
|
174
|
+
const lines = [
|
|
175
|
+
`You are working on the Reel Studio production productions/${id}/ (follow CLAUDE.md, STATUS.md and the make-video skill).`,
|
|
176
|
+
`The person reviewing it in the desk asked for this. Composition: productions/${id}/composition/index.html.`,
|
|
177
|
+
];
|
|
178
|
+
if (notes.length) {
|
|
179
|
+
lines.push("", "Review notes pinned to frames (from review.json):");
|
|
180
|
+
for (const n of notes) lines.push(`- #${n.id} at ${n.t.toFixed(2)} s: ${n.text}`);
|
|
181
|
+
lines.push("", "For each note: change the composition, render a still at that time to check it " +
|
|
182
|
+
"(node core/render.mjs still ... --t <t>, or the reel-desk render_frame tool), then call the reel-desk tool " +
|
|
183
|
+
"resolve_note with a one-line reply saying what you changed. If a note needs a decision or a fact you do not have, " +
|
|
184
|
+
"leave it open and reply with the question.");
|
|
185
|
+
}
|
|
186
|
+
if (extra.trim()) lines.push("", "Request: " + extra.trim());
|
|
187
|
+
lines.push("", "Rules: never record or tick an approval (only the person does that in the desk); never invent product facts; " +
|
|
188
|
+
"do not render the final export, the person renders previews from the desk. End with a two-line summary.");
|
|
189
|
+
return lines.join("\n");
|
|
190
|
+
}
|
package/desk/mcp.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* reel-desk: an optional MCP server (stdio, zero dependencies) that lets Claude Code see the desk.
|
|
3
|
+
* Not registered by default. To add it to a project:
|
|
4
|
+
* claude mcp add reel-desk --scope project -- node desk/mcp.mjs
|
|
5
|
+
* (The desk's own "Send to Claude" passes it with --mcp-config, so that works without registering.)
|
|
6
|
+
*
|
|
7
|
+
* Tools: desk_state (what the person has open), list_productions, list_notes, add_note (Claude's own QA findings),
|
|
8
|
+
* resolve_note (mark a note fixed with a reply; the person closes it after checking), render_frame (a still, returned
|
|
9
|
+
* as an image). There is deliberately no approval tool: approvals are made by people in the desk.
|
|
10
|
+
* Talks to no network service; renders run core/render.mjs locally.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { createInterface } from "node:readline";
|
|
15
|
+
import * as desk from "./lib.mjs";
|
|
16
|
+
|
|
17
|
+
let VERSION = "0";
|
|
18
|
+
try { VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version || VERSION; } catch {}
|
|
19
|
+
const prod = { type: "string", description: "Production id, brand/ID-slug, e.g. bike-logger/REEL-002-engines" };
|
|
20
|
+
|
|
21
|
+
const tools = {
|
|
22
|
+
desk_state: {
|
|
23
|
+
description: "What the person has open in the Reel Studio desk right now: production and time (s). Use it to resolve 'this frame' or 'here'.",
|
|
24
|
+
inputSchema: { type: "object", properties: {} },
|
|
25
|
+
run: () => {
|
|
26
|
+
const s = desk.readState();
|
|
27
|
+
return s.production ? s : { note: "The desk has not been opened yet (node desk/server.mjs)." };
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
list_productions: {
|
|
31
|
+
description: "Every production with its status, composition, exports, open note count and approvals.",
|
|
32
|
+
inputSchema: { type: "object", properties: {} },
|
|
33
|
+
run: () => desk.listProductions(),
|
|
34
|
+
},
|
|
35
|
+
list_notes: {
|
|
36
|
+
description: "Review notes pinned to frames of a production. Each has id, t (seconds), text, by, status (open|fixed|closed) and reply.",
|
|
37
|
+
inputSchema: { type: "object", properties: { production: prod, status: { type: "string", enum: ["open", "fixed", "closed", "all"] } }, required: ["production"] },
|
|
38
|
+
run: ({ production, status = "open" }) => desk.readReview(production).notes.filter((n) => status === "all" || n.status === status),
|
|
39
|
+
},
|
|
40
|
+
add_note: {
|
|
41
|
+
description: "Pin your own finding (e.g. from frame-by-frame QA) to a time so the person sees it on the desk timeline.",
|
|
42
|
+
inputSchema: { type: "object", properties: { production: prod, t: { type: "number" }, text: { type: "string" } }, required: ["production", "t", "text"] },
|
|
43
|
+
run: ({ production, t, text }) => desk.addNote(production, { t, text, by: "claude" }),
|
|
44
|
+
},
|
|
45
|
+
resolve_note: {
|
|
46
|
+
description: "After changing the composition for a note, mark it fixed with a one-line reply. To ask a question instead, keep status open and put the question in reply. Only the person closes notes.",
|
|
47
|
+
inputSchema: { type: "object", properties: { production: prod, id: { type: "number" }, reply: { type: "string" },
|
|
48
|
+
status: { type: "string", enum: ["fixed", "open"], default: "fixed" } }, required: ["production", "id", "reply"] },
|
|
49
|
+
run: ({ production, id, reply, status = "fixed" }) => desk.updateNote(production, id, { status, reply }),
|
|
50
|
+
},
|
|
51
|
+
render_frame: {
|
|
52
|
+
description: "Render one frame of a production's composition with the real renderer and return it as an image. Saved in qa/desk/.",
|
|
53
|
+
inputSchema: { type: "object", properties: { production: prod, t: { type: "number" }, safe: { type: "boolean", description: "Draw the safe zones" } }, required: ["production", "t"] },
|
|
54
|
+
run: async ({ production, t, safe = false }) => {
|
|
55
|
+
const { cmd, args, file } = desk.renderArgs(production, "still", t, safe);
|
|
56
|
+
const out = [];
|
|
57
|
+
const code = await desk.runLines(cmd, args, (l) => out.push(l)).done;
|
|
58
|
+
if (code !== 0) throw new Error(`render failed (exit ${code}):\n${out.join("\n")}`);
|
|
59
|
+
return { content: [
|
|
60
|
+
{ type: "text", text: `${file} (t=${t}s)${out.length > 1 ? "\n" + out.slice(1).join("\n") : ""}` },
|
|
61
|
+
{ type: "image", mimeType: "image/png", data: readFileSync(path.join(desk.ROOT, file)).toString("base64") },
|
|
62
|
+
] };
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const send = (msg) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n");
|
|
68
|
+
|
|
69
|
+
async function handle(msg) {
|
|
70
|
+
const { id, method, params = {} } = msg;
|
|
71
|
+
if (id === undefined) return; // notifications (initialized, cancelled)
|
|
72
|
+
if (method === "initialize") {
|
|
73
|
+
return send({ id, result: { protocolVersion: params.protocolVersion || "2025-06-18", capabilities: { tools: {} },
|
|
74
|
+
serverInfo: { name: "reel-desk", version: VERSION },
|
|
75
|
+
instructions: "Reel Studio desk: review notes pinned to frames, and frame renders. Never approve on the person's behalf." } });
|
|
76
|
+
}
|
|
77
|
+
if (method === "ping") return send({ id, result: {} });
|
|
78
|
+
if (method === "tools/list") {
|
|
79
|
+
return send({ id, result: { tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema })) } });
|
|
80
|
+
}
|
|
81
|
+
if (method === "tools/call") {
|
|
82
|
+
const tool = tools[params.name];
|
|
83
|
+
if (!tool) return send({ id, error: { code: -32602, message: `unknown tool ${params.name}` } });
|
|
84
|
+
try {
|
|
85
|
+
const r = await tool.run(params.arguments || {});
|
|
86
|
+
return send({ id, result: r?.content ? r : { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] } });
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return send({ id, result: { isError: true, content: [{ type: "text", text: e.message }] } });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
send({ id, error: { code: -32601, message: `method not found: ${method}` } });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
createInterface({ input: process.stdin }).on("line", (line) => {
|
|
95
|
+
if (!line.trim()) return;
|
|
96
|
+
let msg;
|
|
97
|
+
try { msg = JSON.parse(line); } catch { return send({ id: null, error: { code: -32700, message: "parse error" } }); }
|
|
98
|
+
handle(msg);
|
|
99
|
+
});
|