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/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
|
+
});
|
package/desk/server.mjs
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* Reel Studio desk: an optional local review app for productions. Nothing else in the kit needs it.
|
|
3
|
+
*
|
|
4
|
+
* node desk/server.mjs [--port 4100] then open http://127.0.0.1:4100
|
|
5
|
+
*
|
|
6
|
+
* Serves the repo root (so compositions load exactly as the renderer loads them) plus a small JSON API:
|
|
7
|
+
* productions, frame notes, approvals, renders (still / preview video / QA) and "Send to Claude", which runs the
|
|
8
|
+
* local Claude Code CLI headlessly (`claude -p`) with your existing login. No API key: ANTHROPIC_API_KEY is removed
|
|
9
|
+
* from Claude's environment so a key in your shell is never billed by accident.
|
|
10
|
+
* Listens on 127.0.0.1 only. Zero dependencies.
|
|
11
|
+
*/
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import { readFile, stat } from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import * as desk from "./lib.mjs";
|
|
18
|
+
|
|
19
|
+
const { ROOT } = desk;
|
|
20
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const argv = process.argv.slice(2);
|
|
22
|
+
const PORT = +(argv.includes("--port") ? argv[argv.indexOf("--port") + 1] : process.env.PORT ?? 4100);
|
|
23
|
+
const CLAUDE = process.env.CLAUDE_BIN || "claude";
|
|
24
|
+
// What a desk-started Claude may do: read and edit the project, render, run QA, use the desk tools. Nothing else.
|
|
25
|
+
const ALLOWED = ["Read", "Edit", "Write", "Glob", "Grep", "Bash(node core/render.mjs *)", "Bash(scripts/qa-video.sh *)",
|
|
26
|
+
"Bash(scripts/extract-frames.sh *)", "mcp__reel-desk"];
|
|
27
|
+
const TYPES = {
|
|
28
|
+
".html": "text/html; charset=utf-8", ".js": "text/javascript", ".mjs": "text/javascript", ".css": "text/css",
|
|
29
|
+
".json": "application/json", ".md": "text/markdown; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png",
|
|
30
|
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".ttf": "font/ttf", ".otf": "font/otf",
|
|
31
|
+
".woff2": "font/woff2", ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime", ".mp3": "audio/mpeg",
|
|
32
|
+
".wav": "audio/wav", ".txt": "text/plain; charset=utf-8", ".csv": "text/plain; charset=utf-8",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// ---- jobs: renders and Claude runs, streamed to the page over server-sent events ----
|
|
36
|
+
const jobs = new Map();
|
|
37
|
+
let nextJob = 1;
|
|
38
|
+
function newJob(kind, production) {
|
|
39
|
+
const job = { id: nextJob++, kind, production, lines: [], done: false, code: null, file: null, proc: null, subs: new Set() };
|
|
40
|
+
jobs.set(job.id, job);
|
|
41
|
+
return job;
|
|
42
|
+
}
|
|
43
|
+
function emit(job, event) {
|
|
44
|
+
if (event.line !== undefined) job.lines.push(event.line);
|
|
45
|
+
for (const res of job.subs) res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
46
|
+
}
|
|
47
|
+
function finish(job, code) {
|
|
48
|
+
job.done = true; job.code = code; job.proc = null;
|
|
49
|
+
emit(job, { done: true, code, file: job.file });
|
|
50
|
+
for (const res of job.subs) res.end();
|
|
51
|
+
job.subs.clear();
|
|
52
|
+
}
|
|
53
|
+
const publicJob = (j) => ({ id: j.id, kind: j.kind, production: j.production, done: j.done, code: j.code, file: j.file });
|
|
54
|
+
|
|
55
|
+
function startRender(production, kind, t, safe) {
|
|
56
|
+
const { cmd, args, file } = desk.renderArgs(production, kind, t, safe);
|
|
57
|
+
const job = newJob(kind, production);
|
|
58
|
+
job.file = "/" + file;
|
|
59
|
+
emit(job, { line: `$ ${path.basename(cmd)} ${args.join(" ")}` });
|
|
60
|
+
const { proc, done } = desk.runLines(cmd, args, (line) => emit(job, { line }));
|
|
61
|
+
job.proc = proc;
|
|
62
|
+
done.then((code) => finish(job, code));
|
|
63
|
+
return job;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One line per stream-json event, enough to follow the run. */
|
|
67
|
+
function describe(ev) {
|
|
68
|
+
if (ev.type === "system" && ev.subtype === "init") return `session ${ev.session_id} · ${ev.model || ""}`;
|
|
69
|
+
if (ev.type === "assistant") {
|
|
70
|
+
return (ev.message?.content || []).map((b) => {
|
|
71
|
+
if (b.type === "text") return b.text.trim();
|
|
72
|
+
if (b.type === "tool_use") {
|
|
73
|
+
const i = b.input || {};
|
|
74
|
+
const arg = i.command || i.file_path || i.pattern || (i.t !== undefined ? `t=${i.t}` : "") || i.id || "";
|
|
75
|
+
return `→ ${b.name.replace(/^mcp__reel-desk__/, "desk.")} ${String(arg).slice(0, 160)}`;
|
|
76
|
+
}
|
|
77
|
+
return "";
|
|
78
|
+
}).filter(Boolean).join("\n");
|
|
79
|
+
}
|
|
80
|
+
if (ev.type === "result") return `${ev.is_error ? "✗" : "✓"} done in ${((ev.duration_ms || 0) / 1000).toFixed(0)} s, ${ev.num_turns ?? "?"} turns`;
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function startClaude(production, noteIds, extra) {
|
|
85
|
+
const review = desk.readReview(production);
|
|
86
|
+
const notes = review.notes.filter((n) => (noteIds?.length ? noteIds.includes(n.id) : n.status === "open"));
|
|
87
|
+
if (!notes.length && !extra?.trim()) throw new Error("nothing to send: no open notes and no request");
|
|
88
|
+
const prompt = desk.claudePrompt(production, notes, extra);
|
|
89
|
+
const mcp = JSON.stringify({ mcpServers: { "reel-desk": { command: process.execPath, args: [path.join(HERE, "mcp.mjs")],
|
|
90
|
+
env: { REEL_STUDIO_ROOT: ROOT } } } });
|
|
91
|
+
const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--mcp-config", mcp,
|
|
92
|
+
"--allowedTools", ALLOWED.join(","), "--permission-mode", "acceptEdits"];
|
|
93
|
+
if (review.claude.session) args.push("--resume", review.claude.session);
|
|
94
|
+
const job = newJob("claude", production);
|
|
95
|
+
emit(job, { line: `Sending ${notes.length} note(s)${extra?.trim() ? " and a request" : ""} to Claude Code${review.claude.session ? " (same session as last time)" : ""}…` });
|
|
96
|
+
const env = { ...process.env };
|
|
97
|
+
delete env.ANTHROPIC_API_KEY; // use the Claude Code login, never a stray key
|
|
98
|
+
const proc = spawn(CLAUDE, args, { cwd: ROOT, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
99
|
+
job.proc = proc;
|
|
100
|
+
let buf = "", session = null;
|
|
101
|
+
proc.stdout.on("data", (d) => {
|
|
102
|
+
buf += d;
|
|
103
|
+
const parts = buf.split("\n");
|
|
104
|
+
buf = parts.pop();
|
|
105
|
+
for (const l of parts) {
|
|
106
|
+
if (!l.trim()) continue;
|
|
107
|
+
let ev;
|
|
108
|
+
try { ev = JSON.parse(l); } catch { emit(job, { line: l }); continue; }
|
|
109
|
+
if (ev.session_id) session = ev.session_id;
|
|
110
|
+
const text = describe(ev);
|
|
111
|
+
if (text) emit(job, { line: text });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
proc.stderr.on("data", (d) => String(d).split("\n").filter((l) => l.trim()).forEach((line) => emit(job, { line })));
|
|
115
|
+
proc.on("error", (e) => emit(job, { line: e.code === "ENOENT" ? `Claude Code CLI not found (${CLAUDE}). Install it and sign in once with \`claude\`.` : String(e) }));
|
|
116
|
+
proc.on("close", (code) => {
|
|
117
|
+
if (session) {
|
|
118
|
+
const r = desk.readReview(production); // re-read: Claude resolved notes while it ran
|
|
119
|
+
r.claude = { ...r.claude, session };
|
|
120
|
+
desk.writeReview(production, r);
|
|
121
|
+
}
|
|
122
|
+
finish(job, code ?? 1);
|
|
123
|
+
});
|
|
124
|
+
return job;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- HTTP ----
|
|
128
|
+
const json = (res, code, body) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
|
|
129
|
+
const body = (req) => new Promise((resolve, reject) => {
|
|
130
|
+
let s = "";
|
|
131
|
+
req.on("data", (d) => (s += d)).on("end", () => { try { resolve(s ? JSON.parse(s) : {}); } catch (e) { reject(e); } });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
async function api(req, res, url) {
|
|
135
|
+
const q = url.searchParams;
|
|
136
|
+
const parts = url.pathname.split("/").filter(Boolean).slice(1); // after "api"
|
|
137
|
+
const m = req.method;
|
|
138
|
+
if (parts[0] === "productions" && m === "GET") return json(res, 200, desk.listProductions());
|
|
139
|
+
if (parts[0] === "review" && m === "GET") return json(res, 200, desk.readReview(q.get("p")));
|
|
140
|
+
if (parts[0] === "readme" && m === "GET") {
|
|
141
|
+
res.writeHead(200, { "content-type": "text/markdown; charset=utf-8" });
|
|
142
|
+
return res.end(await readFile(path.join(desk.prodDir(q.get("p")), "README.md")));
|
|
143
|
+
}
|
|
144
|
+
if (parts[0] === "notes") {
|
|
145
|
+
const p = q.get("p");
|
|
146
|
+
if (m === "POST") return json(res, 200, desk.addNote(p, await body(req)));
|
|
147
|
+
if (m === "PATCH") return json(res, 200, desk.updateNote(p, parts[1], await body(req)));
|
|
148
|
+
if (m === "DELETE") { desk.deleteNote(p, parts[1]); return json(res, 200, { ok: true }); }
|
|
149
|
+
}
|
|
150
|
+
if (parts[0] === "approve" && m === "POST") {
|
|
151
|
+
const b = await body(req);
|
|
152
|
+
return json(res, 200, desk.approve(q.get("p"), b.stage, b.by));
|
|
153
|
+
}
|
|
154
|
+
if (parts[0] === "state" && m === "POST") { desk.writeState(await body(req)); return json(res, 200, { ok: true }); }
|
|
155
|
+
if (parts[0] === "render" && m === "POST") {
|
|
156
|
+
const b = await body(req);
|
|
157
|
+
return json(res, 200, publicJob(startRender(q.get("p"), b.kind, b.t, b.safe)));
|
|
158
|
+
}
|
|
159
|
+
if (parts[0] === "claude" && m === "POST") {
|
|
160
|
+
const b = await body(req);
|
|
161
|
+
return json(res, 200, publicJob(startClaude(q.get("p"), b.notes, b.request)));
|
|
162
|
+
}
|
|
163
|
+
if (parts[0] === "jobs") {
|
|
164
|
+
const job = jobs.get(+parts[1]);
|
|
165
|
+
if (!parts[1]) return json(res, 200, [...jobs.values()].map(publicJob).reverse());
|
|
166
|
+
if (!job) return json(res, 404, { error: "no such job" });
|
|
167
|
+
if (parts[2] === "stop" && m === "POST") { job.proc?.kill("SIGTERM"); return json(res, 200, { ok: true }); }
|
|
168
|
+
if (parts[2] === "events") {
|
|
169
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
170
|
+
for (const line of job.lines) res.write(`data: ${JSON.stringify({ line })}\n\n`);
|
|
171
|
+
if (job.done) { res.write(`data: ${JSON.stringify({ done: true, code: job.code, file: job.file })}\n\n`); return res.end(); }
|
|
172
|
+
job.subs.add(res);
|
|
173
|
+
req.on("close", () => job.subs.delete(res));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
return json(res, 200, publicJob(job));
|
|
177
|
+
}
|
|
178
|
+
json(res, 404, { error: "unknown endpoint" });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function files(req, res, url) {
|
|
182
|
+
let rel = decodeURIComponent(url.pathname);
|
|
183
|
+
if (rel === "/") rel = "/desk/index.html";
|
|
184
|
+
// The desk's own page comes from this folder; everything else from the project root
|
|
185
|
+
const [base, sub] = rel.startsWith("/desk/") ? [HERE, rel.slice(5)] : [ROOT, rel];
|
|
186
|
+
const file = path.join(base, sub);
|
|
187
|
+
if (!file.startsWith(base + path.sep) || /\/\.(git|env)/.test(rel)) throw Object.assign(new Error("forbidden"), { code: 403 });
|
|
188
|
+
const s = await stat(file);
|
|
189
|
+
const data = await readFile(s.isDirectory() ? path.join(file, "index.html") : file);
|
|
190
|
+
const type = TYPES[path.extname(s.isDirectory() ? "x.html" : file).toLowerCase()] || "application/octet-stream";
|
|
191
|
+
// Range requests: Chrome needs them to seek inside <video> plates (same as core/render.mjs)
|
|
192
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || "");
|
|
193
|
+
if (range) {
|
|
194
|
+
const start = range[1] ? +range[1] : data.length - +range[2];
|
|
195
|
+
const end = range[1] && range[2] ? Math.min(+range[2], data.length - 1) : data.length - 1;
|
|
196
|
+
res.writeHead(206, { "content-type": type, "accept-ranges": "bytes", "content-range": `bytes ${start}-${end}/${data.length}`, "content-length": end - start + 1 });
|
|
197
|
+
return res.end(data.subarray(start, end + 1));
|
|
198
|
+
}
|
|
199
|
+
res.writeHead(200, { "content-type": type, "accept-ranges": "bytes", "cache-control": "no-store" });
|
|
200
|
+
res.end(data);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const server = createServer(async (req, res) => {
|
|
204
|
+
const url = new URL(req.url, "http://x");
|
|
205
|
+
try {
|
|
206
|
+
if (url.pathname.startsWith("/api/")) {
|
|
207
|
+
// Only the desk page itself may call the API (blocks other sites in your browser from driving it)
|
|
208
|
+
const origin = req.headers.origin;
|
|
209
|
+
if (origin && origin !== `http://${req.headers.host}`) return json(res, 403, { error: "cross-origin" });
|
|
210
|
+
return await api(req, res, url);
|
|
211
|
+
}
|
|
212
|
+
await files(req, res, url);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
if (url.pathname.startsWith("/api/")) return json(res, e.code === 403 ? 403 : 400, { error: e.message });
|
|
215
|
+
res.writeHead(e.code === 403 ? 403 : 404); res.end(e.code === 403 ? "forbidden" : "not found");
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
220
|
+
console.log(`Reel Studio desk: http://127.0.0.1:${server.address().port} (serving ${ROOT})`);
|
|
221
|
+
console.log("Optional: let your Claude Code sessions see the desk with claude mcp add reel-desk --scope project -- node desk/mcp.mjs");
|
|
222
|
+
});
|
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reel-studio",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "Agency-quality marketing reels, ads and posts made by Claude Code. Free and open source; no per-video fees.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": { "reel-studio": "bin/reel-studio.mjs" },
|
|
7
7
|
"files": [
|
|
8
8
|
"bin/",
|
|
9
|
+
"desk/",
|
|
10
|
+
"!desk/.state.json",
|
|
9
11
|
"core/README.md",
|
|
10
12
|
"core/package.json",
|
|
11
13
|
"core/package-lock.json",
|
|
@@ -21,8 +23,12 @@
|
|
|
21
23
|
"examples/engines-demo/plates/blob.mp4",
|
|
22
24
|
".claude/skills/"
|
|
23
25
|
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test test/*.test.mjs",
|
|
28
|
+
"test:python": "packaging/build-python.sh && cd python && uv run --isolated --with . python -m unittest discover -s tests -v"
|
|
29
|
+
},
|
|
24
30
|
"engines": { "node": ">=18" },
|
|
25
|
-
"keywords": ["video", "reels", "marketing", "claude-code", "gsap", "threejs", "pixi", "blender", "ffmpeg", "remotion-alternative"],
|
|
31
|
+
"keywords": ["video", "reels", "marketing", "claude-code", "claude-code-plugin", "claude-skills", "ai-video", "video-generation", "video-ads", "motion-graphics", "instagram-reels", "tiktok", "gsap", "threejs", "pixi", "blender", "ffmpeg", "headless-chrome", "remotion-alternative"],
|
|
26
32
|
"author": "Murathan Yıldırım (https://github.com/murathany7)",
|
|
27
33
|
"license": "MIT",
|
|
28
34
|
"homepage": "https://github.com/murathany7/reel-studio#readme",
|
package/scripts/init-project.sh
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Copy the Reel Studio kit into a project so its skills can render there.
|
|
3
3
|
# scripts/init-project.sh <kit_root> [project_dir] [--update] [--with-skills]
|
|
4
|
-
# Copies core/, engines/, scripts/, the engines demo and the brand/production templates, adds CLAUDE.md,
|
|
4
|
+
# Copies core/, engines/, scripts/, desk/ (the optional review app), the engines demo and the brand/production templates, adds CLAUDE.md,
|
|
5
5
|
# STATUS.md and .gitignore if missing, then runs npm install in core/ and checks the tools.
|
|
6
6
|
# Existing files are kept; --update refreshes core/, engines/ and scripts/ (and the skills) from the kit (brands and
|
|
7
7
|
# productions are never touched). --with-skills also copies the skills into .claude/skills/ (for installs without
|
|
@@ -36,6 +36,7 @@ else
|
|
|
36
36
|
copy core core $mode
|
|
37
37
|
copy engines engines $mode
|
|
38
38
|
copy scripts scripts $mode
|
|
39
|
+
copy desk desk $mode
|
|
39
40
|
copy brands/_template brands/_template
|
|
40
41
|
copy productions/_TEMPLATE productions/_TEMPLATE
|
|
41
42
|
[ -n "$skills" ] && copy .claude/skills .claude/skills $mode
|
|
@@ -14,6 +14,7 @@ per brand, one folder per production.
|
|
|
14
14
|
| 3D (Three.js, Blender) | `scene-3d` skill |
|
|
15
15
|
| A cartoon character or mascot | `character-2d` skill |
|
|
16
16
|
| Recording a brand's live website | `capture-site` skill |
|
|
17
|
+
| *Optional:* the person reviews in the desk (frame notes, approvals) | `review-desk` skill, `desk/README.md` |
|
|
17
18
|
| Render, audio and QA commands | `core/README.md` |
|
|
18
19
|
| What is in progress, decided, learned | `STATUS.md` |
|
|
19
20
|
|
|
@@ -21,6 +22,7 @@ per brand, one folder per production.
|
|
|
21
22
|
|---|---|
|
|
22
23
|
| `core/` | `render.mjs` (HTML → MP4/PNG), `lib/runtime.js` (the `Studio` clock), `lib/base.css` |
|
|
23
24
|
| `engines/` | One adapter per engine |
|
|
25
|
+
| `desk/` | *Optional* review app (`node desk/server.mjs`): frame notes, renders, approvals. Nothing else depends on it |
|
|
24
26
|
| `scripts/` | Audio mix, transcription, video QA, frame tools |
|
|
25
27
|
| `brands/<slug>/` | Facts and their sources, look, voice, `compliance.md` (start from `brands/_template`) |
|
|
26
28
|
| `productions/<brand>/<ID>-<slug>/` | One piece (start from `productions/_TEMPLATE`) |
|