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.
@@ -18,7 +18,8 @@ Fill sections 1–4 of the production README: the problem, the benefit, 3 hook v
18
18
  visual, **engine**, on-screen text, sound), caption. Pick engines with the `pick-engine` skill and say why.
19
19
  State size, length, placement and sound (VO / music / silent).
20
20
 
21
- **Stop and ask for approval.** No plates, audio or composition before the box is ticked.
21
+ **Stop and ask for approval** in chat. No plates, audio or composition before the box is ticked. (If the person uses
22
+ the optional desk, they may approve there instead: `review-desk` skill. Never tick the box on your own.)
22
23
 
23
24
  ## 2. Produce
24
25
 
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: review-desk
3
+ description: Optional. Open the Reel Studio desk (a local review app) and work from its review notes - notes pinned to frames, renders, approvals. Use only when the person asks for the desk or already uses it - "open the desk", "apply my desk notes", "fix what I marked", "what's on this frame" - or a production has a review.json. Never required: reviewing and approving in chat works the same.
4
+ ---
5
+
6
+ # Review desk (optional)
7
+
8
+ The kit works the same without it: storyboards are approved in chat, and QA is the `make-video` steps. Don't start
9
+ the desk or suggest it unless the person asks, or they already use it (a `review.json` in the production).
10
+
11
+ The desk (`desk/`) is a local page for the person: it plays a production's composition frame-exact through the
12
+ renderer's own clock (`__seek`), and lets them pin notes to frames, render stills, preview videos and QA, read the
13
+ storyboard, approve, and send notes to Claude. It only reads and writes files in the project, so everything it does
14
+ reaches you as files (and, if it is registered, through the `reel-desk` MCP server).
15
+
16
+ ## Open it
17
+
18
+ `node desk/server.mjs` (run it in the background), then the person opens <http://127.0.0.1:4100>. In the Claude
19
+ desktop app, open that URL in the browser pane. `npx reel-studio desk` does the same. Port: `--port <n>`.
20
+
21
+ The `reel-desk` MCP tools below are not registered by default. If they aren't available and the person wants them:
22
+ `claude mcp add reel-desk --scope project -- node desk/mcp.mjs` (then restart the session). Without them, read and
23
+ write `review.json` directly.
24
+
25
+ ## Where things are
26
+
27
+ | | |
28
+ |---|---|
29
+ | Notes and approvals | `productions/<brand>/<ID>/review.json` (tracked in git: it is the review record) |
30
+ | What the person has open | `desk/.state.json`, or the `desk_state` tool |
31
+ | Desk renders | `productions/<brand>/<ID>/qa/desk/` (stills, `preview.mp4`, `qa/`); never the delivered export |
32
+
33
+ ## Working from notes
34
+
35
+ 1. `desk_state` tells you which production and time "this frame" means. `list_notes` gives the open notes
36
+ (`id`, `t` in seconds, `text`). Without the MCP server, read `review.json`.
37
+ 2. For each note: change the composition, check it with `render_frame` at the note's `t` (or
38
+ `node core/render.mjs still … --t <t>`) and **look at the image**.
39
+ 3. `resolve_note` with a one-line reply saying what changed. If a note needs a fact, an asset or a decision you
40
+ don't have, keep it `open` and put the question in the reply. Only the person closes notes (after checking).
41
+ 4. Your own QA findings can go on the timeline too: `add_note` (they show as by `claude`).
42
+
43
+ The desk's **Send to Claude** button runs exactly this loop headlessly (`claude -p` with the person's own Claude Code
44
+ login, `ANTHROPIC_API_KEY` removed, tools limited to read/edit/render/QA and `reel-desk`), one session per production
45
+ (`review.json` → `claude.session`).
46
+
47
+ ## Approvals are the person's
48
+
49
+ The desk's **Approve storyboard** ticks `- [x] Storyboard approved` in the production README and fills
50
+ *Approved by / date*; **Approve final cut** adds a final-cut line. Both are logged in `review.json` → `approvals`.
51
+ **Never write an approval yourself**, in the README or in `review.json`, and never treat a fixed note as approval.
52
+ When a storyboard is ready, say "it's in the desk for approval" and stop (golden rule 2).
package/README.md CHANGED
@@ -1,13 +1,20 @@
1
1
  <h1 align="center">🎬 Reel Studio</h1>
2
2
 
3
3
  <p align="center">
4
+ <a href="https://github.com/murathany7/reel-studio/actions/workflows/ci.yml"><img src="https://github.com/murathany7/reel-studio/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
4
5
  <a href="https://www.npmjs.com/package/reel-studio"><img src="https://img.shields.io/npm/v/reel-studio?color=e8a33d" alt="npm"></a>
6
+ <a href="https://pypi.org/project/reel-studio/"><img src="https://img.shields.io/pypi/v/reel-studio?color=e8a33d" alt="PyPI"></a>
7
+ <a href="https://github.com/murathany7/homebrew-reel-studio"><img src="https://img.shields.io/badge/homebrew-tap-e8a33d" alt="Homebrew"></a>
5
8
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3ecf7a" alt="MIT"></a>
6
9
  <img src="https://img.shields.io/badge/Claude%20Code-plugin-8b93a1" alt="Claude Code plugin">
7
10
  </p>
8
11
 
9
- <p align="center"><b>Make marketing reels, ads and posts with code, for any brand, with Claude Code.</b><br>
10
- Plain HTML pages on one clock → headless Chrome → MP4. Every frame is reproducible.</p>
12
+ <p align="center"><b>Agency-quality reels, ads and posts, made by Claude Code.</b><br>
13
+ Open source and free. Your Claude subscription does the rest.<br>
14
+ No editor, no timeline, no per-video fees. Every frame is code you own.</p>
15
+
16
+ <p align="center"><sub>Plain HTML pages on one clock → headless Chrome → MP4, for any brand. Every frame is reproducible.<br>
17
+ AI voiceover (ElevenLabs) is optional and billed by its provider.</sub></p>
11
18
 
12
19
  <p align="center">
13
20
  <img src="docs/media/bike-logger-reel.gif" width="240" alt="Bike Logger reel">
@@ -88,6 +95,13 @@ pipx install reel-studio && reel-studio init # or: uvx reel-studio init
88
95
  brew install murathany7/reel-studio/reel-studio && reel-studio init # brings Node and ffmpeg
89
96
  ```
90
97
 
98
+ **Or with Docker**, with Node, Chromium, ffmpeg and fonts inside, nothing to install (Linux renders use software WebGL):
99
+
100
+ ```bash
101
+ docker run --rm -it -v "$PWD":/work ghcr.io/murathany7/reel-studio reel-studio init
102
+ docker run --rm -v "$PWD":/work ghcr.io/murathany7/reel-studio node core/render.mjs video <page>.html out.mp4
103
+ ```
104
+
91
105
  **Or clone it** and work inside the repo:
92
106
 
93
107
  ```bash
@@ -100,6 +114,14 @@ cd core && npm install && cd ..
100
114
  > *Add my product as a new brand: https://my-product.com* → facts, colours, fonts, voice, rules
101
115
  > *Make a 15 s reel for &lt;brand&gt; about &lt;feature&gt;* → brief and storyboard, then the video once you approve
102
116
 
117
+ That's the whole product: you talk to Claude Code, it writes the storyboard, waits for your OK in chat, then makes,
118
+ renders and checks the video.
119
+
120
+ **Optional: the desk.** If you'd rather review on a page than in chat, run `npx reel-studio desk` (or
121
+ `node desk/server.mjs`) and open <http://127.0.0.1:4100>. You can scrub frame by frame, pin notes to frames, send them
122
+ to Claude Code (your own login, no API key), render stills and previews, and approve the storyboard and the final cut.
123
+ Nothing else needs it, and nothing changes if you never open it. See [`desk/README.md`](desk/README.md).
124
+
103
125
  ## What's inside
104
126
 
105
127
  ```
@@ -108,12 +130,17 @@ engines/ one adapter per engine
108
130
  brands/ one folder per brand (_template to start)
109
131
  productions/ one folder per piece (brief, composition, audio, QA, export)
110
132
  scripts/ audio mix, transcription, QA, frame tools
111
- .claude/skills setup · make-video · new-brand · pick-engine · scene-3d · character-2d · capture-site
133
+ desk/ optional local review app (and its optional reel-desk MCP server)
134
+ .claude/skills setup · make-video · new-brand · pick-engine · scene-3d · character-2d · capture-site · review-desk (optional)
112
135
  ```
113
136
 
114
137
  ## Contributing
115
138
 
116
- Open source and early. New engine adapters, brand examples and QA checks are all welcome; open an issue or a PR.
139
+ Open source and early. New engine adapters, skill fixes and QA checks are all welcome: see
140
+ [CONTRIBUTING.md](CONTRIBUTING.md). `npm test` re-renders the demo twice and checks every frame matches.
141
+
142
+ **Made something?** [Show your reel](https://github.com/murathany7/reel-studio/issues/new?template=show-your-reel.yml):
143
+ good examples get featured here. Questions go to [Discussions](https://github.com/murathany7/reel-studio/discussions).
117
144
 
118
145
  ## License
119
146
 
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // npx reel-studio init [dir] [--update]: copy the Reel Studio kit and its Claude Code skills into a project.
3
+ // npx reel-studio desk [--port 4100]: open the review desk of the project in this folder.
3
4
  import { spawnSync } from "node:child_process";
5
+ import { existsSync } from "node:fs";
4
6
  import path from "node:path";
5
7
  import { fileURLToPath } from "node:url";
6
8
 
@@ -17,15 +19,27 @@ if (cmd === "init") {
17
19
  console.log(`
18
20
  Done. Open this folder in Claude Code and ask:
19
21
  "Add my product as a new brand: https://…"
20
- "Make a 15 s reel for <brand> about <feature>"`);
22
+ "Make a 15 s reel for <brand> about <feature>"
23
+ Optional, to review on a page instead of in chat: npx reel-studio desk`);
21
24
  }
22
25
  process.exit(r.status ?? 1);
23
26
  }
24
27
 
28
+ if (cmd === "desk") {
29
+ // The project's own copy, so the desk serves the project (its brands, productions and kit version)
30
+ const server = path.resolve("desk/server.mjs");
31
+ if (!existsSync(server)) {
32
+ console.error("No desk/ here. Run `npx reel-studio init` in this folder first (or `--update` an older project).");
33
+ process.exit(1);
34
+ }
35
+ process.exit(spawnSync(process.execPath, [server, ...rest], { stdio: "inherit" }).status ?? 1);
36
+ }
37
+
25
38
  console.log(`Reel Studio: marketing videos made with code, run by Claude Code.
26
39
 
27
40
  npx reel-studio init [dir] copy the kit and skills into dir (default: here) and install it
28
- npx reel-studio init [dir] --update refresh core/, engines/, scripts/ and skills from this version
41
+ npx reel-studio init [dir] --update refresh core/, engines/, scripts/, desk/ and skills from this version
42
+ npx reel-studio desk [--port 4100] optional review page: play, pin notes, send them to Claude, approve
29
43
 
30
44
  Claude Code plugin instead: /plugin marketplace add murathany7/reel-studio
31
45
  More: https://github.com/murathany7/reel-studio`);
@@ -86,7 +86,17 @@
86
86
  this.onSeek((t) => new Promise((res) => {
87
87
  const target = Math.max(0, Math.min(t - start, (el.duration || 0) - 0.001));
88
88
  if (Math.abs(el.currentTime - target) < 0.0005) return res();
89
- el.addEventListener("seeked", res, { once: true });
89
+ // "seeked" can fire before the new frame is painted, and a screenshot then catches the old one (seen on a
90
+ // cold CI runner): also wait for the frame to be presented. A seek within the same plate frame presents
91
+ // nothing new, so that wait gives up after a moment; the old frame is the right one then.
92
+ let seeked = false, shown = !el.requestVideoFrameCallback, timer;
93
+ const done = () => seeked && shown && (clearTimeout(timer), res());
94
+ if (!shown) el.requestVideoFrameCallback(() => { shown = true; done(); });
95
+ el.addEventListener("seeked", () => {
96
+ seeked = true;
97
+ timer = setTimeout(() => { shown = true; done(); }, 250);
98
+ done();
99
+ }, { once: true });
90
100
  el.currentTime = target;
91
101
  }));
92
102
  return el;
@@ -9,8 +9,8 @@
9
9
  "gsap": "^3.12.5",
10
10
  "lottie-web": "^5.12.2",
11
11
  "pixi.js": "^8.6.0",
12
- "playwright-core": "1.49.1",
13
- "three": "^0.170.0"
12
+ "playwright-core": "1.64.1",
13
+ "three": "^0.187.0"
14
14
  }
15
15
  },
16
16
  "node_modules/@pixi/colord": {
@@ -118,21 +118,21 @@
118
118
  }
119
119
  },
120
120
  "node_modules/playwright-core": {
121
- "version": "1.49.1",
122
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
123
- "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
121
+ "version": "1.64.1",
122
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
123
+ "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
124
124
  "license": "Apache-2.0",
125
125
  "bin": {
126
126
  "playwright-core": "cli.js"
127
127
  },
128
128
  "engines": {
129
- "node": ">=18"
129
+ "node": ">=20"
130
130
  }
131
131
  },
132
132
  "node_modules/three": {
133
- "version": "0.170.0",
134
- "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
135
- "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
133
+ "version": "0.187.0",
134
+ "resolved": "https://registry.npmjs.org/three/-/three-0.187.0.tgz",
135
+ "integrity": "sha512-Qiu7R+Cmh8CstZsY1uJOSWnVlfo7ZtNLV7mF8Jltk3LB3AtDu9cRZlcnRKkAk8Y4z6KKw35z7Y7Urd7jWY8etA==",
136
136
  "license": "MIT"
137
137
  },
138
138
  "node_modules/tiny-lru": {
package/core/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "gsap": "^3.12.5",
11
11
  "lottie-web": "^5.12.2",
12
12
  "pixi.js": "^8.6.0",
13
- "playwright-core": "1.49.1",
14
- "three": "^0.170.0"
13
+ "playwright-core": "1.64.1",
14
+ "three": "^0.187.0"
15
15
  }
16
16
  }
package/core/render.mjs CHANGED
@@ -78,8 +78,10 @@ function serve() {
78
78
  // WebGL (Three.js, Pixi) on the GPU, but compositing in software: with GPU compositing Chrome
79
79
  // draws <video> plates ~10 levels brighter than ffmpeg decodes them (measured 2026-11-02).
80
80
  // playwright-core is pinned (1.49.1) because 1.64 shifted plate colours the same way.
81
- const CHROME_ARGS = ["--force-color-profile=srgb", "--hide-scrollbars",
82
- "--enable-gpu", "--use-angle=metal", "--ignore-gpu-blocklist", "--disable-gpu-compositing"];
81
+ // WebGL: Metal on macOS; elsewhere (Linux, CI without a GPU) Chrome's software renderer, SwiftShader
82
+ const CHROME_ARGS = ["--force-color-profile=srgb", "--hide-scrollbars", "--enable-gpu", "--ignore-gpu-blocklist",
83
+ "--disable-gpu-compositing",
84
+ ...(process.platform === "darwin" ? ["--use-angle=metal"] : ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"])];
83
85
 
84
86
  async function openPage(input, safe) {
85
87
  const server = await serve();
package/desk/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # Desk (optional)
2
+
3
+ A local review app for productions, **for people who'd rather review on a page than in chat**. Nothing in the kit
4
+ needs it: the skills are the product, and Claude Code writes, renders and checks the pieces either way. If you never
5
+ open the desk, nothing changes. It adds no dependencies and doesn't register itself anywhere.
6
+
7
+ ```bash
8
+ node desk/server.mjs # or: npx reel-studio desk → http://127.0.0.1:4100
9
+ ```
10
+
11
+ | Panel | What it does |
12
+ |---|---|
13
+ | Player | Loads `composition/index.html?render=1` and drives it with `__ready`, `__meta`, `__seek`, the same calls as `core/render.mjs`. Space play/pause, ←/→ one frame (⇧ one second), S safe zones, N new note |
14
+ | Notes | Pin a note to the current frame. Dots on the scrubber. Claude marks notes *fixed* with a reply; you close them after checking |
15
+ | Claude | Sends the open notes and/or a request to a headless Claude Code run and streams what it does. The composition reloads when it's done |
16
+ | Render | Still at this frame, preview video, QA (`scripts/qa-video.sh`). Output goes to `qa/desk/`, never `export/` |
17
+ | Approve | Storyboard (ticks the README box) and final cut. People only: Claude and the MCP server can't approve |
18
+ | Storyboard | The production README |
19
+
20
+ ## How Claude is involved, and what it costs
21
+
22
+ There are three ways, and none needs an API key.
23
+
24
+ 1. **Files.** Notes and approvals live in `productions/<brand>/<ID>/review.json`. In any Claude Code session say
25
+ "apply my desk notes" (the `review-desk` skill).
26
+ 2. **Send to Claude** runs `claude -p` (the Claude Code CLI on this machine, signed in as you) from the project root.
27
+ `ANTHROPIC_API_KEY` is removed from its environment, so it always uses your Claude Code login. Its tools are limited
28
+ to Read/Edit/Write/Glob/Grep, `node core/render.mjs`, `scripts/qa-video.sh`, `scripts/extract-frames.sh` and the
29
+ `reel-desk` tools. It runs one session per production (`--resume`). Set `CLAUDE_BIN` to use another `claude`.
30
+ 3. **`reel-desk` MCP server** (`desk/mcp.mjs`), optional on top of the optional desk, and not registered by default.
31
+ Add it with `claude mcp add reel-desk --scope project -- node desk/mcp.mjs`. It lets a Claude Code session see the desk:
32
+ `desk_state`, `list_productions`, `list_notes`, `add_note`, `resolve_note`, `render_frame` (returns the image).
33
+ It has no approval tool.
34
+
35
+ ## Safety
36
+
37
+ The server listens on 127.0.0.1 only, serves files from the project root only (not `.git` or `.env*`), and refuses
38
+ API calls from other origins, so another site open in your browser can't drive it. It has no dependencies.
39
+
40
+ ## Files
41
+
42
+ | | |
43
+ |---|---|
44
+ | `server.mjs` | HTTP server: static files with range requests (for plates) + the JSON API + job streams (SSE) |
45
+ | `lib.mjs` | Productions, notes, approvals, render commands, the Claude prompt. Shared by the server and the MCP server |
46
+ | `mcp.mjs` | The `reel-desk` MCP server (stdio JSON-RPC) |
47
+ | `index.html`, `app.js`, `desk.css` | The page. No build step |
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 &lt;brand&gt;”.</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
+ });