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/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`) |
|