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.
@@ -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.1",
4
- "description": "Make marketing reels, ads and posts with code, for any brand, with Claude Code. HTML on one clock → MP4.",
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",
@@ -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`) |
@@ -1,6 +1,9 @@
1
1
  node_modules/
2
2
  .DS_Store
3
3
  **/qa/flagged/
4
+ # Desk: renders made from the desk and what it has open (review.json stays tracked)
5
+ **/qa/desk/
6
+ desk/.state.json
4
7
  __pycache__/
5
8
 
6
9
  # Big media stays out of git