reel-studio 0.2.0 → 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/.claude/skills/scene-3d/SKILL.md +5 -0
- package/README.md +38 -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/app.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Reel Studio desk page. Drives a composition through the same functions the renderer uses
|
|
2
|
+
// (window.__ready, __meta, __seek): the desk never runs its own clock inside the composition.
|
|
3
|
+
const $ = (id) => document.getElementById(id);
|
|
4
|
+
const api = async (url, opts = {}) => {
|
|
5
|
+
const r = await fetch(url, { headers: { "content-type": "application/json" }, ...opts,
|
|
6
|
+
body: opts.body && JSON.stringify(opts.body) });
|
|
7
|
+
const data = r.headers.get("content-type")?.includes("json") ? await r.json() : await r.text();
|
|
8
|
+
if (!r.ok) throw new Error(data.error || r.statusText);
|
|
9
|
+
return data;
|
|
10
|
+
};
|
|
11
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
12
|
+
const store = { get: (k) => { try { return localStorage.getItem(k); } catch { return null; } },
|
|
13
|
+
set: (k, v) => { try { localStorage.setItem(k, v); } catch {} } };
|
|
14
|
+
|
|
15
|
+
const S = { list: [], prod: null, meta: null, win: null, t: 0, playing: false, busy: false, review: null, noteFilter: "open" };
|
|
16
|
+
|
|
17
|
+
// ---------- productions ----------
|
|
18
|
+
async function loadList() {
|
|
19
|
+
S.list = await api("/api/productions");
|
|
20
|
+
renderList();
|
|
21
|
+
}
|
|
22
|
+
function renderList() {
|
|
23
|
+
const f = $("filter").value.toLowerCase();
|
|
24
|
+
const items = S.list.filter((p) => !f || (p.id + p.title).toLowerCase().includes(f));
|
|
25
|
+
let brand = "", html = "";
|
|
26
|
+
for (const p of items) {
|
|
27
|
+
if (p.brand !== brand) { brand = p.brand; html += `<li class="brandname">${esc(brand)}</li>`; }
|
|
28
|
+
const ok = p.approvals.some((a) => a.stage === "final") ? `<span class="pill ok">✓ final</span>` : "";
|
|
29
|
+
html += `<li><button data-id="${esc(p.id)}" class="${S.prod?.id === p.id ? "on" : ""}">
|
|
30
|
+
<span class="t">${esc(p.title.replace(/^[A-Z]+-\d+\s+[—-]\s+/, ""))}</span>
|
|
31
|
+
<span class="s">${esc(p.id.split("/")[1].split("-").slice(0, 2).join("-"))} · ${esc(p.status.split("(")[0].trim() || "—")}
|
|
32
|
+
${p.openNotes ? `<span class="pill notes">${p.openNotes}</span>` : ""} ${ok} ${p.composition ? "" : `<span class="pill">no comp</span>`}</span>
|
|
33
|
+
</button></li>`;
|
|
34
|
+
}
|
|
35
|
+
$("productions").innerHTML = html || `<li class="muted small">No productions yet. Ask Claude: “make a reel for <brand>”.</li>`;
|
|
36
|
+
}
|
|
37
|
+
$("productions").onclick = (e) => {
|
|
38
|
+
const b = e.target.closest("button[data-id]");
|
|
39
|
+
if (b) open(b.dataset.id);
|
|
40
|
+
};
|
|
41
|
+
$("filter").oninput = renderList;
|
|
42
|
+
|
|
43
|
+
async function open(id, keepT = false) {
|
|
44
|
+
const p = S.list.find((x) => x.id === id);
|
|
45
|
+
if (!p) return;
|
|
46
|
+
S.prod = p; store.set("desk.prod", id);
|
|
47
|
+
history.replaceState(null, "", `#${id}`);
|
|
48
|
+
$("crumb").textContent = p.title;
|
|
49
|
+
$("panel").hidden = false;
|
|
50
|
+
renderList();
|
|
51
|
+
pause();
|
|
52
|
+
await Promise.all([loadReview(), loadReadme()]);
|
|
53
|
+
renderExports();
|
|
54
|
+
const frame = $("comp");
|
|
55
|
+
if (!p.composition) {
|
|
56
|
+
frame.hidden = true; frame.removeAttribute("src"); $("transport").hidden = true; $("empty").hidden = false;
|
|
57
|
+
$("empty").innerHTML = `No composition yet.<br><span class="muted">Read the storyboard on the right. After you approve it, Claude builds <code>composition/index.html</code>.</span>`;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const t = keepT ? S.t : 0;
|
|
61
|
+
$("empty").hidden = false; $("empty").textContent = "Loading…";
|
|
62
|
+
// Load exactly as core/render.mjs does: laid out (never display:none) in a 1080×1920 viewport, then resized to
|
|
63
|
+
// the composition's size. Code that measures layout at load (line splitting, fitting) runs the same way as in a render.
|
|
64
|
+
frame.hidden = false; frame.classList.add("loading");
|
|
65
|
+
frame.style.width = "1080px"; frame.style.height = "1920px";
|
|
66
|
+
frame.src = `${p.composition}?render=1&desk=${Date.now()}`;
|
|
67
|
+
frame.onload = async () => {
|
|
68
|
+
try {
|
|
69
|
+
S.win = frame.contentWindow;
|
|
70
|
+
await S.win.__ready;
|
|
71
|
+
S.meta = S.win.__meta();
|
|
72
|
+
frame.style.width = S.meta.width + "px"; frame.style.height = S.meta.height + "px";
|
|
73
|
+
fit(); frame.classList.remove("loading"); $("empty").hidden = true; $("transport").hidden = false;
|
|
74
|
+
setSafe($("safe").checked);
|
|
75
|
+
renderNotes();
|
|
76
|
+
await seek(Math.min(t, S.meta.duration - 1 / S.meta.fps));
|
|
77
|
+
} catch (e) {
|
|
78
|
+
$("empty").hidden = false; $("empty").innerHTML = `Composition failed to load.<br><span class="muted">${esc(e.message || e)}</span>`;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function fit() {
|
|
84
|
+
if (!S.meta) return;
|
|
85
|
+
const box = $("stageWrap").getBoundingClientRect();
|
|
86
|
+
const k = Math.min((box.width - 32) / S.meta.width, (box.height - 32) / S.meta.height);
|
|
87
|
+
$("comp").style.transform = `scale(${k}) translate(-50%, -50%)`;
|
|
88
|
+
}
|
|
89
|
+
addEventListener("resize", fit);
|
|
90
|
+
|
|
91
|
+
// ---------- clock ----------
|
|
92
|
+
const frameOf = (t) => Math.round(t * S.meta.fps);
|
|
93
|
+
async function seek(t) {
|
|
94
|
+
if (!S.win || !S.meta) return;
|
|
95
|
+
S.t = Math.max(0, Math.min(t, S.meta.duration));
|
|
96
|
+
drawHead();
|
|
97
|
+
if (S.busy) return; // the next tick or the trailing seek below catches up
|
|
98
|
+
S.busy = true;
|
|
99
|
+
let done;
|
|
100
|
+
do { done = S.t; await S.win.__seek(done); } while (done !== S.t && !S.playing);
|
|
101
|
+
S.busy = false;
|
|
102
|
+
reportState();
|
|
103
|
+
}
|
|
104
|
+
function drawHead() {
|
|
105
|
+
const k = S.t / S.meta.duration;
|
|
106
|
+
$("head").style.left = k * 100 + "%";
|
|
107
|
+
$("fill").style.width = k * 100 + "%";
|
|
108
|
+
$("time").textContent = `${S.t.toFixed(2)} s · f${frameOf(S.t)}`;
|
|
109
|
+
$("noteAt").textContent = `at ${S.t.toFixed(2)} s (frame ${frameOf(S.t)})`;
|
|
110
|
+
}
|
|
111
|
+
let t0 = 0;
|
|
112
|
+
function play() {
|
|
113
|
+
if (!S.meta) return;
|
|
114
|
+
S.playing = true; $("play").textContent = "❚❚";
|
|
115
|
+
if (S.t >= S.meta.duration - 1 / S.meta.fps) S.t = 0;
|
|
116
|
+
t0 = performance.now() - S.t * 1000;
|
|
117
|
+
const tick = async () => {
|
|
118
|
+
if (!S.playing) return;
|
|
119
|
+
if (!S.busy) await seek(((performance.now() - t0) / 1000) % S.meta.duration);
|
|
120
|
+
requestAnimationFrame(tick);
|
|
121
|
+
};
|
|
122
|
+
tick();
|
|
123
|
+
}
|
|
124
|
+
function pause() { S.playing = false; $("play").textContent = "▶"; }
|
|
125
|
+
const step = (n) => { pause(); seek((frameOf(S.t) + n) / S.meta.fps); };
|
|
126
|
+
$("play").onclick = () => (S.playing ? pause() : play());
|
|
127
|
+
$("prev").onclick = () => step(-1);
|
|
128
|
+
$("next").onclick = () => step(1);
|
|
129
|
+
|
|
130
|
+
const scrub = $("scrub");
|
|
131
|
+
const scrubTo = (e) => { const r = scrub.getBoundingClientRect(); seek(((e.clientX - r.left) / r.width) * S.meta.duration); };
|
|
132
|
+
scrub.onpointerdown = (e) => {
|
|
133
|
+
if (e.target.classList.contains("m")) return;
|
|
134
|
+
pause(); scrub.setPointerCapture(e.pointerId); scrubTo(e);
|
|
135
|
+
scrub.onpointermove = scrubTo;
|
|
136
|
+
scrub.onpointerup = () => { scrub.onpointermove = null; };
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
function setSafe(on) { S.win?.document.documentElement.classList.toggle("show-safe", on); }
|
|
140
|
+
$("safe").onchange = (e) => setSafe(e.target.checked);
|
|
141
|
+
|
|
142
|
+
addEventListener("keydown", (e) => {
|
|
143
|
+
if (e.target.matches("input, textarea") || !S.meta) return;
|
|
144
|
+
if (e.key === " ") { e.preventDefault(); S.playing ? pause() : play(); }
|
|
145
|
+
else if (e.key === "ArrowLeft") step(e.shiftKey ? -S.meta.fps : -1);
|
|
146
|
+
else if (e.key === "ArrowRight") step(e.shiftKey ? S.meta.fps : 1);
|
|
147
|
+
else if (e.key === "Home") { pause(); seek(0); }
|
|
148
|
+
else if (e.key.toLowerCase() === "s") { $("safe").checked = !$("safe").checked; setSafe($("safe").checked); }
|
|
149
|
+
else if (e.key.toLowerCase() === "n") { e.preventDefault(); tab("notes"); pause(); $("noteText").focus(); }
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
let stateTimer;
|
|
153
|
+
function reportState() {
|
|
154
|
+
clearTimeout(stateTimer);
|
|
155
|
+
stateTimer = setTimeout(() => api("/api/state", { method: "POST",
|
|
156
|
+
body: { production: S.prod?.id, t: +S.t.toFixed(3), frame: S.meta ? frameOf(S.t) : 0, playing: S.playing } }).catch(() => {}), 400);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------- tabs ----------
|
|
160
|
+
function tab(name) {
|
|
161
|
+
document.querySelectorAll(".tabs button").forEach((b) => b.classList.toggle("on", b.dataset.tab === name));
|
|
162
|
+
document.querySelectorAll(".tab").forEach((t) => (t.hidden = t.dataset.tab !== name));
|
|
163
|
+
}
|
|
164
|
+
document.querySelector(".tabs").onclick = (e) => { const b = e.target.closest("button"); if (b) tab(b.dataset.tab); };
|
|
165
|
+
|
|
166
|
+
// ---------- notes ----------
|
|
167
|
+
async function loadReview() {
|
|
168
|
+
S.review = await api(`/api/review?p=${encodeURIComponent(S.prod.id)}`);
|
|
169
|
+
renderNotes();
|
|
170
|
+
}
|
|
171
|
+
function renderNotes() {
|
|
172
|
+
const notes = S.review.notes.slice().sort((a, b) => a.t - b.t);
|
|
173
|
+
const open = notes.filter((n) => n.status === "open");
|
|
174
|
+
$("noteCount").textContent = open.length || "";
|
|
175
|
+
const shown = notes.filter((n) => S.noteFilter === "all" || n.status === S.noteFilter);
|
|
176
|
+
$("notes").innerHTML = shown.map((n) => `<li class="note ${n.status}" data-id="${n.id}">
|
|
177
|
+
<div class="h"><a data-t="${n.t}">${n.t.toFixed(2)} s</a> #${n.id} · ${n.by}<span class="sp"></span>
|
|
178
|
+
${n.status === "open" ? `<button data-act="closed" title="Close without a change">Close</button>` : ""}
|
|
179
|
+
${n.status === "fixed" ? `<button data-act="closed" title="Checked, looks right">✓ OK</button><button data-act="open">Reopen</button>` : ""}
|
|
180
|
+
${n.status === "closed" ? `<button data-act="open">Reopen</button>` : ""}
|
|
181
|
+
<button data-act="delete" title="Delete">✕</button></div>
|
|
182
|
+
<p>${esc(n.text)}</p>${n.reply ? `<div class="reply">Claude: ${esc(n.reply)}</div>` : ""}</li>`).join("")
|
|
183
|
+
|| `<li class="muted small">${S.noteFilter === "open" ? "No open notes. Scrub to a frame and pin one." : "Nothing here."}</li>`;
|
|
184
|
+
$("marks").innerHTML = S.meta ? notes.filter((n) => n.status !== "closed").map((n) =>
|
|
185
|
+
`<span class="m ${n.status}" style="left:${(n.t / S.meta.duration) * 100}%" data-t="${n.t}" title="#${n.id} ${esc(n.text)}"></span>`).join("") : "";
|
|
186
|
+
$("sendNotes").disabled = !open.length;
|
|
187
|
+
}
|
|
188
|
+
$("marks").onclick = (e) => { if (e.target.dataset.t) { pause(); seek(+e.target.dataset.t); } };
|
|
189
|
+
$("noteFilter").onclick = (e) => {
|
|
190
|
+
const b = e.target.closest("button"); if (!b) return;
|
|
191
|
+
S.noteFilter = b.dataset.f;
|
|
192
|
+
$("noteFilter").querySelectorAll("button").forEach((x) => x.classList.toggle("on", x === b));
|
|
193
|
+
renderNotes();
|
|
194
|
+
};
|
|
195
|
+
$("notes").onclick = async (e) => {
|
|
196
|
+
if (e.target.dataset.t) { pause(); return seek(+e.target.dataset.t); }
|
|
197
|
+
const act = e.target.dataset.act; if (!act) return;
|
|
198
|
+
const id = e.target.closest(".note").dataset.id;
|
|
199
|
+
const p = encodeURIComponent(S.prod.id);
|
|
200
|
+
if (act === "delete") await api(`/api/notes/${id}?p=${p}`, { method: "DELETE" });
|
|
201
|
+
else await api(`/api/notes/${id}?p=${p}`, { method: "PATCH", body: { status: act } });
|
|
202
|
+
await refreshCounts();
|
|
203
|
+
};
|
|
204
|
+
$("noteForm").onsubmit = async (e) => {
|
|
205
|
+
e.preventDefault();
|
|
206
|
+
const text = $("noteText").value.trim(); if (!text) return;
|
|
207
|
+
await api(`/api/notes?p=${encodeURIComponent(S.prod.id)}`, { method: "POST", body: { t: S.t, text } });
|
|
208
|
+
$("noteText").value = "";
|
|
209
|
+
await refreshCounts();
|
|
210
|
+
};
|
|
211
|
+
$("noteText").onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); $("noteForm").requestSubmit(); } };
|
|
212
|
+
async function refreshCounts() {
|
|
213
|
+
await loadReview();
|
|
214
|
+
S.list = await api("/api/productions");
|
|
215
|
+
S.prod = S.list.find((x) => x.id === S.prod.id);
|
|
216
|
+
renderList(); renderApprovals();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------- jobs (renders, Claude) ----------
|
|
220
|
+
function follow(job, logEl, onDone) {
|
|
221
|
+
logEl.textContent = "";
|
|
222
|
+
const es = new EventSource(`/api/jobs/${job.id}/events`);
|
|
223
|
+
es.onmessage = (m) => {
|
|
224
|
+
const ev = JSON.parse(m.data);
|
|
225
|
+
if (ev.line !== undefined) { logEl.textContent += ev.line + "\n"; logEl.scrollTop = logEl.scrollHeight; }
|
|
226
|
+
if (ev.done) { es.close(); onDone(ev); }
|
|
227
|
+
};
|
|
228
|
+
es.onerror = () => { es.close(); onDone({ code: -1 }); };
|
|
229
|
+
}
|
|
230
|
+
function busy(on, text = "") { $("status").textContent = text; document.body.classList.toggle("busy", on); }
|
|
231
|
+
|
|
232
|
+
async function sendToClaude(request, withNotes) {
|
|
233
|
+
const openIds = S.review.notes.filter((n) => n.status === "open").map((n) => n.id);
|
|
234
|
+
tab("claude");
|
|
235
|
+
try {
|
|
236
|
+
const job = await api(`/api/claude?p=${encodeURIComponent(S.prod.id)}`, { method: "POST",
|
|
237
|
+
body: { notes: withNotes ? openIds : [-1], request } });
|
|
238
|
+
$("sendAsk").disabled = $("sendNotes").disabled = true; $("stopClaude").hidden = false;
|
|
239
|
+
busy(true, "Claude is working…");
|
|
240
|
+
$("stopClaude").onclick = () => api(`/api/jobs/${job.id}/stop`, { method: "POST" });
|
|
241
|
+
follow(job, $("claudeLog"), async (ev) => {
|
|
242
|
+
$("sendAsk").disabled = false; $("stopClaude").hidden = true;
|
|
243
|
+
busy(false, ev.code === 0 ? "Claude finished. Reloaded the composition." : `Claude stopped (exit ${ev.code}).`);
|
|
244
|
+
await refreshCounts();
|
|
245
|
+
await open(S.prod.id, true);
|
|
246
|
+
});
|
|
247
|
+
} catch (e) { $("claudeLog").textContent = e.message; }
|
|
248
|
+
}
|
|
249
|
+
$("sendNotes").onclick = () => sendToClaude("", true);
|
|
250
|
+
$("sendAsk").onclick = () => sendToClaude($("ask").value, $("withNotes").checked);
|
|
251
|
+
|
|
252
|
+
async function render(kind) {
|
|
253
|
+
const out = $("renderOut");
|
|
254
|
+
out.innerHTML = "";
|
|
255
|
+
try {
|
|
256
|
+
const job = await api(`/api/render?p=${encodeURIComponent(S.prod.id)}`, { method: "POST",
|
|
257
|
+
body: { kind, t: +S.t.toFixed(3), safe: $("safe").checked } });
|
|
258
|
+
["rStill", "rVideo", "rQa"].forEach((id) => ($(id).disabled = true));
|
|
259
|
+
busy(true, `Rendering ${kind}…`);
|
|
260
|
+
follow(job, $("renderLog"), (ev) => {
|
|
261
|
+
["rStill", "rVideo", "rQa"].forEach((id) => ($(id).disabled = false));
|
|
262
|
+
busy(false, ev.code === 0 ? `${kind} done` : `${kind} failed (exit ${ev.code})`);
|
|
263
|
+
if (ev.code !== 0) return;
|
|
264
|
+
const url = `${ev.file}?v=${Date.now()}`;
|
|
265
|
+
if (kind === "still") out.innerHTML = `<a href="${url}" target="_blank"><img src="${url}" alt="Rendered still"></a>`;
|
|
266
|
+
if (kind === "video") out.innerHTML = `<video src="${url}" controls muted></video>`;
|
|
267
|
+
if (kind === "qa") out.innerHTML = `<a href="${url}" target="_blank">Open the QA report</a>`;
|
|
268
|
+
});
|
|
269
|
+
} catch (e) { $("renderLog").textContent = e.message; }
|
|
270
|
+
}
|
|
271
|
+
$("rStill").onclick = () => render("still");
|
|
272
|
+
$("rVideo").onclick = () => render("video");
|
|
273
|
+
$("rQa").onclick = () => render("qa");
|
|
274
|
+
|
|
275
|
+
function renderExports() {
|
|
276
|
+
$("exports").innerHTML = S.prod.exports.map((u) => `<li><a href="${esc(u)}" target="_blank">${esc(u.split("/").pop())}</a></li>`).join("")
|
|
277
|
+
|| `<li class="muted small">No exports yet.</li>`;
|
|
278
|
+
renderApprovals();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------- approvals (people only) ----------
|
|
282
|
+
$("approver").value = store.get("desk.name") || "";
|
|
283
|
+
$("approver").onchange = (e) => store.set("desk.name", e.target.value);
|
|
284
|
+
function renderApprovals() {
|
|
285
|
+
$("approvals").innerHTML = (S.review?.approvals || []).map((a) =>
|
|
286
|
+
`<li>✓ ${a.stage === "final" ? "Final cut" : "Storyboard"} approved by ${esc(a.by)}, ${a.at.slice(0, 10)}</li>`).join("");
|
|
287
|
+
}
|
|
288
|
+
async function approveStage(stage) {
|
|
289
|
+
const by = $("approver").value.trim();
|
|
290
|
+
if (!by) { $("approver").focus(); return; }
|
|
291
|
+
if (!confirm(`Approve the ${stage === "final" ? "final cut" : "storyboard"} of ${S.prod.title} as ${by}? This ticks the box in its README.`)) return;
|
|
292
|
+
await api(`/api/approve?p=${encodeURIComponent(S.prod.id)}`, { method: "POST", body: { stage, by } });
|
|
293
|
+
await refreshCounts(); await loadReadme();
|
|
294
|
+
}
|
|
295
|
+
$("okBoard").onclick = () => approveStage("storyboard");
|
|
296
|
+
$("okFinal").onclick = () => approveStage("final");
|
|
297
|
+
|
|
298
|
+
// ---------- storyboard (README) ----------
|
|
299
|
+
async function loadReadme() {
|
|
300
|
+
$("readme").innerHTML = md(await api(`/api/readme?p=${encodeURIComponent(S.prod.id)}`));
|
|
301
|
+
}
|
|
302
|
+
function inline(s) {
|
|
303
|
+
return esc(s).replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
|
|
304
|
+
.replace(/\*([^*]+)\*/g, "<i>$1</i>").replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1");
|
|
305
|
+
}
|
|
306
|
+
function md(src) {
|
|
307
|
+
const out = [], lines = src.split("\n");
|
|
308
|
+
for (let i = 0; i < lines.length; i++) {
|
|
309
|
+
const l = lines[i];
|
|
310
|
+
if (/^```/.test(l)) { const buf = []; while (++i < lines.length && !/^```/.test(lines[i])) buf.push(esc(lines[i])); out.push(`<pre>${buf.join("\n")}</pre>`); }
|
|
311
|
+
else if (/^#{1,4} /.test(l)) { const n = l.match(/^#+/)[0].length; out.push(`<h${n}>${inline(l.slice(n + 1))}</h${n}>`); }
|
|
312
|
+
else if (/^\|/.test(l)) {
|
|
313
|
+
const rows = []; for (; i < lines.length && /^\|/.test(lines[i]); i++) rows.push(lines[i]); i--;
|
|
314
|
+
const cells = (r) => r.replace(/^\||\|$/g, "").split("|").map((c) => inline(c.trim()));
|
|
315
|
+
const body = rows.filter((r) => !/^\|[\s:|-]+\|$/.test(r));
|
|
316
|
+
out.push(`<table>${body.map((r, k) => `<tr>${cells(r).map((c) => (k ? `<td>${c}</td>` : `<th>${c}</th>`)).join("")}</tr>`).join("")}</table>`);
|
|
317
|
+
}
|
|
318
|
+
else if (/^> ?/.test(l)) out.push(`<blockquote>${inline(l.replace(/^> ?/, ""))}</blockquote>`);
|
|
319
|
+
else if (/^\s*- /.test(l)) out.push(`<div>• ${inline(l.replace(/^\s*- /, "").replace(/^\[x\]/, "☑").replace(/^\[ \]/, "☐"))}</div>`);
|
|
320
|
+
else if (l.trim()) out.push(`<p>${inline(l)}</p>`);
|
|
321
|
+
}
|
|
322
|
+
return out.join("\n");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------- start ----------
|
|
326
|
+
await loadList();
|
|
327
|
+
const want = decodeURIComponent(location.hash.slice(1)) || store.get("desk.prod");
|
|
328
|
+
if (want && S.list.some((p) => p.id === want)) open(want);
|
|
329
|
+
addEventListener("hashchange", () => {
|
|
330
|
+
const id = decodeURIComponent(location.hash.slice(1));
|
|
331
|
+
if (id !== S.prod?.id && S.list.some((p) => p.id === id)) open(id);
|
|
332
|
+
});
|
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>
|