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