creator-editing-studio 1.3.8 → 1.4.0

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.
@@ -15,13 +15,13 @@
15
15
  * Which video: preview_edit({video}) — or current.json (written by use.mjs) when it is called without one. Nothing listens on any port. stdout belongs to the MCP
16
16
  * protocol: every log goes to stderr.
17
17
  */
18
- import {cpSync, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync} from 'node:fs';
18
+ import {appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync} from 'node:fs';
19
19
  import {join} from 'node:path';
20
20
  import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
21
21
  import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
22
22
  import {registerAppResource, registerAppTool, RESOURCE_MIME_TYPE} from '@modelcontextprotocol/ext-apps/server';
23
23
  import {z} from 'zod';
24
- import {buildManifest, compileEdit, configFor, HERE, playerHtml, SRC} from './lib.mjs';
24
+ import {buildManifest, checkPage, compileEdit, configFor, HERE, PAGE_LIMIT, PUBLIC, playerHtml, PROXY_TIERS, SRC, STUDIO} from './lib.mjs';
25
25
 
26
26
  const CONFIG = join(HERE, 'current.json');
27
27
  const cfg = () => JSON.parse(readFileSync(CONFIG, 'utf8'));
@@ -72,8 +72,65 @@ let uiHtml = '';
72
72
  let uiSig = '';
73
73
  const media = new Map(); // hash → data URL, so unchanged footage is encoded once
74
74
 
75
- /** The player page with the current edit and its footage inside it. */
76
- async function page() {
75
+ /**
76
+ * The page preview_edit built and checked, served to the chat as is. Building it again on the chat's read could
77
+ * differ from what was checked; it is dropped as soon as the edit changes (the next preview_edit builds anew).
78
+ */
79
+ let ready = null;
80
+ /**
81
+ * The data inside the newest built page and inside `ready`, and the file hashes of the page the chat last fetched.
82
+ * The chat shows the page it fetched when the person last wrote, not this open's — so each result also carries the
83
+ * edit (_meta.lp): the code plus the files that fetched page lacks, and the card plays that (ui/app.tsx connectApp).
84
+ */
85
+ let lastBuilt = null;
86
+ let readyBuilt = null;
87
+ let pageHashes = {};
88
+ const deltaFor = (b) => b && {...b.data, files: Object.fromEntries(Object.entries(b.data.files).filter(([k]) => pageHashes[k] !== b.hashes[k]))};
89
+ /** The newest preview_edit call. Each page carries the open that made it, so old players can see they are old. */
90
+ let opens = 0;
91
+ let latest = null;
92
+
93
+ /**
94
+ * Build the page, then play it headless (#lpcheck) before the chat sees it. Files the edit asked for that exist but
95
+ * weren't packed are added and the page is built again once; anything still wrong goes back to Claude in the tool
96
+ * result, so it is fixed before the person ever sees a blank or a black frame.
97
+ */
98
+ async function preparePage() {
99
+ // The chat gives a tool call about 60 s. A first build that made the preview copies can take most of that, so
100
+ // the check only runs when there is time left — a skipped check never holds the preview back.
101
+ const t0 = Date.now();
102
+ const left = () => 50000 - (Date.now() - t0);
103
+ let html = await page();
104
+ let report = left() > 20000 ? await checkPage(html, left() - 5000).catch(() => null) : null;
105
+ const packable = (report?.missing ?? []).filter((f) => existsSync(join(PUBLIC, f)));
106
+ if (packable.length && left() > 25000) {
107
+ html = await page(packable);
108
+ report = await checkPage(html, left() - 5000).catch(() => null);
109
+ }
110
+ ready = html;
111
+ readyBuilt = lastBuilt;
112
+ log(`check: ${report ? JSON.stringify(report) : 'skipped (no time left after building, or no headless browser)'}`);
113
+ return {html, report};
114
+ }
115
+
116
+ /** One plain line for Claude about the check, or '' when all is well. */
117
+ function checkNote(report) {
118
+ if (!report || report.ok) return report ? ' Checked before opening: every file is inside and it plays start to end.' : '';
119
+ const parts = [];
120
+ if (report.missing?.length) parts.push(`these files the edit uses are not under assets/, so they will show blank: ${report.missing.join(', ')} — fix the path in the edit or put the file there`);
121
+ if (report.errors?.length) parts.push(`it does not play cleanly: ${report.errors.map((e) => e.replace(/\.\s*$/, '')).join(' | ')}`);
122
+ return ` PROBLEM found by the check before opening — ${parts.join('; ')}. Fix it, then call preview_edit again; tell the person in one line what you fixed.`;
123
+ }
124
+
125
+ /** The page the app loads at startup, before any open: no video, only a line saying the preview opens below. */
126
+ async function startupPage() {
127
+ if (!uiHtml) [uiHtml, uiSig] = [await playerHtml(), 'built'];
128
+ const data = {open: null, version: 0, canUndo: false, project: '', edit: '', composition: '', error: 'Opening the preview… it appears in the next card below.', code: null, files: {}};
129
+ return uiHtml.replace('<head>', `<head><script>window.__LP_DATA=${JSON.stringify(data)}</script>`);
130
+ }
131
+
132
+ /** The player page with the current edit and its footage inside it. `extra`: more files under assets/ to pack. */
133
+ async function page(extra = []) {
77
134
  const c = cfg();
78
135
  const UI = join(HERE, 'ui');
79
136
  const sig = existsSync(UI) ? readdirSync(UI).map((f) => `${f}:${statSync(join(UI, f)).mtimeMs}`).join('|') : 'built';
@@ -87,27 +144,77 @@ async function page() {
87
144
  } catch (e) {
88
145
  error = String(e.message ?? e);
89
146
  }
90
- const files = {};
91
- for (const a of buildManifest(c)) {
92
- if (!media.has(a.hash)) {
93
- // Remotion's font loader reads the type from the text after the last dot, so fonts get it as a fragment.
94
- const ext = /\.(ttf|otf|woff2?)$/i.exec(a.name)?.[1];
95
- media.set(a.hash, `data:${a.mime};base64,${readFileSync(a.file).toString('base64')}${ext ? `#.${ext}` : ''}`);
147
+ const build = (tier) => {
148
+ const files = {};
149
+ const hashes = {};
150
+ for (const a of buildManifest(c, tier, code ?? '', extra)) {
151
+ if (!media.has(a.hash)) {
152
+ // Remotion's font loader reads the type from the text after the last dot, so fonts get it as a fragment.
153
+ const ext = /\.(ttf|otf|woff2?)$/i.exec(a.name)?.[1];
154
+ media.set(a.hash, `data:${a.mime};base64,${readFileSync(a.file).toString('base64')}${ext ? `#.${ext}` : ''}`);
155
+ }
156
+ files[a.name] = media.get(a.hash);
157
+ hashes[a.name] = a.hash;
96
158
  }
97
- files[a.name] = media.get(a.hash);
159
+ const data = {open: latest, version, canUndo: versions(c.project).length > 1, project: c.project, edit: c.edit, composition: c.composition, error, code, files};
160
+ lastBuilt = {data, hashes};
161
+ return uiHtml.replace('<head>', `<head><script>window.__LP_DATA=${JSON.stringify(data).replace(/</g, '\\u003c')}</script>`);
162
+ };
163
+ // Over the limit the page never reaches the chat (and an older one shows instead) — so shrink, or say so.
164
+ let tier = 0;
165
+ let html = build(tier);
166
+ while (html.length > PAGE_LIMIT && tier < PROXY_TIERS.length - 1) html = build(++tier);
167
+ if (html.length > PAGE_LIMIT) {
168
+ const mb = (html.length / 1e6).toFixed(1);
169
+ error = `This edit's preview is ${mb} MB even with small copies — the chat can only take ${PAGE_LIMIT / 1e6} MB. Use fewer cutout frames or shorter clips in the edit, then open the preview again.`;
170
+ code = null;
171
+ html = uiHtml.replace('<head>', `<head><script>window.__LP_DATA=${JSON.stringify({open: latest, version, canUndo: false, project: c.project, edit: c.edit, composition: c.composition, error, code, files: {}}).replace(/</g, '\\u003c')}</script>`);
98
172
  }
99
- const data = {version, canUndo: versions(c.project).length > 1, project: c.project, edit: c.edit, composition: c.composition, error, code, files};
100
- const html = uiHtml.replace('<head>', `<head><script>window.__LP_DATA=${JSON.stringify(data).replace(/</g, '\\u003c')}</script>`);
101
- log(`page for ${c.project} version ${version}: ${(html.length / 1e6).toFixed(1)} MB${error ? ' (the edit does not compile)' : ''}`);
173
+ const mb = (html.length / 1e6).toFixed(1);
174
+ log(`page for ${c.project} version ${version}: ${mb} MB (copies: ${tier ? 'small' : 'normal'})${error ? ` — ${error}` : ''}`);
175
+ // stderr is not kept after startup, so page loads are also written here — the proof the chat asked for a fresh page.
176
+ appendFileSync(join(HERE, '.reads.log'), `${new Date().toISOString()} ${c.project} v${version} ${mb}MB tier${tier}${error ? ' ERROR' : ''}\n`);
102
177
  return html;
103
178
  }
104
179
 
105
180
  /* ----------------------------------------------------------------------------- MCP */
106
181
 
107
- const URI = 'ui://editing-os/live-preview.html';
182
+ /*
183
+ * A NEW PAGE ADDRESS FOR EVERY OPEN. The Claude desktop app keeps the page it got for an address and shows that
184
+ * copy again when the same address comes back — on 2026-10-22 three opens of an updated edit all showed the
185
+ * first version (no buttons, black at 0:35) and this server was never asked for the page. So each open gets its
186
+ * own address: the tool names slot n, and after each call the next slot is registered and the tool re-points to it.
187
+ */
188
+ const BOOT = Date.now().toString(36);
189
+ const uriFor = (n) => `ui://editing-os/live-preview-${BOOT}-${n}.html`;
190
+ let slot = 0;
191
+ // Slots the chat actually fetched. On 2026-11-01 an older chat kept showing its first page after a new open (the
192
+ // page was built, never fetched) and he saw "no change at all" — so the next open says so instead of staying silent.
193
+ const fetched = new Set();
194
+ const uiMeta = (n) => ({ui: {resourceUri: uriFor(n)}, 'ui/resourceUri': uriFor(n)});
195
+ const addPage = (n) =>
196
+ registerAppResource(server, `Live preview ${n}`, uriFor(n), {description: 'Player and beat breakdown for the current edit'}, async () => ({
197
+ contents: [
198
+ {
199
+ uri: uriFor(n),
200
+ mimeType: RESOURCE_MIME_TYPE,
201
+ // The build is logged when preview_edit makes it; this line is the proof the chat itself fetched the page.
202
+ text: await (async () => {
203
+ // Before any open, the only fetch is the app's own at startup, and it keeps that copy for the first open of
204
+ // the chat — so it must never be a video (2026-11-05: a different video showed twice for the edit asked for).
205
+ const html = !opens ? await startupPage() : (ready ?? (await page()));
206
+ pageHashes = !opens ? {} : ((ready ? readyBuilt : lastBuilt)?.hashes ?? {});
207
+ fetched.add(n);
208
+ appendFileSync(join(HERE, '.reads.log'), `${new Date().toISOString()} FETCHED by the chat: slot ${n}, ${(html.length / 1e6).toFixed(1)}MB\n`);
209
+ return html;
210
+ })(),
211
+ _meta: {ui: {prefersBorder: false}},
212
+ },
213
+ ],
214
+ }));
108
215
  const server = new McpServer({name: 'editing-os-live-preview', version: '0.1.0'});
109
216
 
110
- registerAppTool(
217
+ const previewTool = registerAppTool(
111
218
  server,
112
219
  'preview_edit',
113
220
  {
@@ -117,15 +224,43 @@ registerAppTool(
117
224
  'OPEN IT YOURSELF as a step of the work — right after the person approves the plan, and again after every change you make to the edit — never wait to be asked. ' +
118
225
  'Changes typed in the preview arrive as chat messages: change only that beat in the edit file, then call preview_edit again so the updated preview appears. ' +
119
226
  'Render the final video only when they press "Render final video", and give the estimated render time only then.',
120
- inputSchema: {video: z.string().optional().describe('The video folder name (src/videos/<video>). Needed the first time for each video.')},
121
- _meta: {ui: {resourceUri: URI}},
227
+ inputSchema: {
228
+ video: z.string().optional().describe('The video folder name (src/videos/<video>). Needed the first time for each video.'),
229
+ again: z.boolean().optional().describe('Only when the previous open failed or showed the wrong video: open again within 30 s anyway.'),
230
+ },
231
+ _meta: {ui: {resourceUri: uriFor(0)}},
122
232
  },
123
- async ({video}) => {
233
+ async ({video, again}) => {
234
+ // ONE PLAYER PER REPLY: two opens in one reply put two players in the chat and the person watched the wrong one
235
+ // (2026-11-03). A real change round takes longer than 30 s, so an open that soon after the last is refused.
236
+ const since = latest ? Date.now() - Date.parse(latest.at) : Infinity;
237
+ if (since < 30000 && !again)
238
+ return {
239
+ content: [{type: 'text', text: `Not opened: a preview (${latest.project} version ${latest.version}) opened ${Math.round(since / 1000)} s ago, and two players in one reply confuse the person. Open only the one they are reviewing; if that last open was the wrong video or failed, call preview_edit again with again: true.`}],
240
+ isError: true,
241
+ };
124
242
  if (video) writeFileSync(CONFIG, JSON.stringify(configFor(video), null, 2) + '\n');
125
243
  if (!existsSync(CONFIG)) return {content: [{type: 'text', text: 'Which video? Call preview_edit with {video: "<folder name in src/videos>"}.'}], isError: true};
126
244
  const c = cfg();
127
245
  const v = saveVersion(c.project);
128
- return {content: [{type: 'text', text: `Live preview of ${c.project} (version ${v}) is showing in the chat.`}], structuredContent: {project: c.project, version: v}};
246
+ // The card may get a page the app fetched earlier (at startup, or when the person last wrote) — that's fine now:
247
+ // this result carries the edit itself (_meta.lp) and the card plays that. See deltaFor and ui/app.tsx connectApp.
248
+ latest = {n: ++opens, boot: BOOT, at: new Date().toISOString(), project: c.project, version: v};
249
+ const {report} = await preparePage();
250
+
251
+ // The plan gate (scripts/lib/plan-gate.mjs): a render needs this preview to have been shown AFTER the yes.
252
+ if (c.composition) {
253
+ const dir = join(STUDIO, 'work', 'plans', c.composition);
254
+ mkdirSync(dir, {recursive: true});
255
+ writeFileSync(join(dir, 'previewed.json'), JSON.stringify({composition: c.composition, project: c.project, version: v, at: new Date().toISOString()}, null, 2));
256
+ }
257
+ // The next open gets a fresh address (after this result is out, so this open still finds its own page).
258
+ setTimeout(() => {
259
+ slot += 1;
260
+ addPage(slot);
261
+ previewTool.update({_meta: uiMeta(slot)});
262
+ }, 50);
263
+ return {content: [{type: 'text', text: `Live preview of ${c.project} (version ${v}) sent to the chat, with this version's edit inside the result.${checkNote(report)} Before telling the person it's ready, check read_widget_context says "${c.project} v${v}".`}], structuredContent: {project: c.project, version: v, check: report, open: latest}, _meta: {lp: deltaFor(readyBuilt)}};
129
264
  },
130
265
  );
131
266
 
@@ -153,9 +288,97 @@ server.registerTool(
153
288
  },
154
289
  );
155
290
 
156
- registerAppResource(server, 'Live preview', URI, {description: 'Player and beat breakdown for the current edit'}, async () => ({
157
- contents: [{uri: URI, mimeType: RESOURCE_MIME_TYPE, text: await page(), _meta: {ui: {prefersBorder: false}}}],
158
- }));
291
+ // Asked by every open player (never by Claude): which open is the newest? An older player switches itself off.
292
+ registerAppTool(
293
+ server,
294
+ 'preview_latest',
295
+ {title: 'Newest preview', description: 'Used by the live preview player only.', inputSchema: {}, _meta: {ui: {resourceUri: uriFor(0), visibility: ['app']}}},
296
+ async () => ({content: [{type: 'text', text: latest ? `Newest preview: open ${latest.n}, ${latest.project} version ${latest.version}` : 'No preview opened yet.'}], structuredContent: {latest}}),
297
+ );
298
+
299
+ addPage(0);
300
+
301
+ /* ----------------------------------------------------------------------------- the browser editor (editor/) */
302
+
303
+ const editorInfo = (video) => {
304
+ const f = join(STUDIO, 'work', video, 'editor.json');
305
+ return existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null;
306
+ };
307
+ const alive = async (url) => {
308
+ try {
309
+ return (await fetch(`${url}api/state`, {signal: AbortSignal.timeout(1500)})).ok;
310
+ } catch {
311
+ return false;
312
+ }
313
+ };
314
+
315
+ // The browser editor is PARKED and not shipped (his call, 2026-11-05: "don't do the browser editor" in 1.4.0) — a
316
+ // studio without editor/ never offers its tools.
317
+ const HAS_EDITOR = existsSync(join(STUDIO, 'editor', 'server.mjs'));
318
+ if (HAS_EDITOR) server.registerTool(
319
+ 'editor_open',
320
+ {
321
+ title: 'Open the browser editor',
322
+ description:
323
+ "Open the edit in the person's browser: the real video with their footage, every on-screen piece draggable and resizable, effect dropdowns, and a Render button with progress. Changes save the moment they're made (layout.json) — no chat needed. " +
324
+ 'Things they type under "Ask Claude" wait in a list; when they say "apply", call editor_requests. Use this instead of preview_edit whenever the edit is big or they want to move things around.',
325
+ inputSchema: {video: z.string().describe('The video folder name (src/videos/<video>).')},
326
+ },
327
+ async ({video}) => {
328
+ configFor(video); // throws a readable error if the video isn't set up for the editor
329
+ const info = editorInfo(video);
330
+ if (info && (await alive(info.url))) {
331
+ const {spawn} = await import('node:child_process');
332
+ const opener = process.platform === 'win32' ? ['cmd', ['/c', 'start', '', info.url]] : process.platform === 'darwin' ? ['open', [info.url]] : ['xdg-open', [info.url]];
333
+ spawn(opener[0], opener[1], {detached: true, stdio: 'ignore'}).unref();
334
+ return {content: [{type: 'text', text: `The editor for ${video} was already running — opened it again in the browser: ${info.url}`}]};
335
+ }
336
+ const {spawn} = await import('node:child_process');
337
+ const started = Date.now();
338
+ spawn(process.execPath, [join(STUDIO, 'editor', 'server.mjs'), video], {cwd: STUDIO, detached: true, stdio: 'ignore'}).unref();
339
+ for (let i = 0; i < 60; i++) {
340
+ await new Promise((ok) => setTimeout(ok, 250));
341
+ const now = editorInfo(video);
342
+ if (now && Date.parse(now.at) >= started - 1000 && (await alive(now.url)))
343
+ return {content: [{type: 'text', text: `The editor for ${video} is open in the browser: ${now.url}\nTell them: drag anything to move it, the corner to resize, switch effects on the right, and press Render when it's right. For anything else, type it under "Ask Claude" and then say "apply" here.`}]};
344
+ }
345
+ return {content: [{type: 'text', text: `The editor did not start within 15 s. Run it yourself to see why: npm run editor -- ${video}`}], isError: true};
346
+ },
347
+ );
348
+
349
+ if (HAS_EDITOR) server.registerTool(
350
+ 'editor_requests',
351
+ {
352
+ title: 'Read what they asked for in the browser editor',
353
+ description:
354
+ 'The changes the person typed under "Ask Claude" in the browser editor, each with the moment in the video it is about (time, frame, beat) and any screenshots. Call it when they say "apply". ' +
355
+ 'Make each change in the edit (the open editor reloads by itself), then call this again with done: [ids] to tick them off.',
356
+ inputSchema: {
357
+ video: z.string().optional().describe('The video folder name; defaults to the one the preview/editor last showed.'),
358
+ done: z.array(z.string()).optional().describe('Request ids you have finished — marks them done.'),
359
+ },
360
+ },
361
+ async ({video, done}) => {
362
+ const v = video ?? cfg().project;
363
+ const file = join(STUDIO, 'work', v, 'editor-requests.json');
364
+ const list = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : [];
365
+ if (done?.length) {
366
+ for (const r of list) if (done.includes(r.id)) r.status = 'done';
367
+ writeFileSync(file, JSON.stringify(list, null, 2) + '\n');
368
+ }
369
+ const waiting = list.filter((r) => r.status === 'waiting');
370
+ if (!waiting.length) return {content: [{type: 'text', text: done?.length ? `Marked ${done.length} done. Nothing else is waiting.` : `Nothing is waiting in the editor for ${v}.`}]};
371
+ const lines = waiting.map((r) => `- [${r.id}] at ${r.time} (frame ${r.frame}, beat ${r.beat}): "${r.text}"${r.images.length ? ` — screenshots: ${r.images.join(', ')} (open them with Read)` : ''}`);
372
+ return {
373
+ content: [
374
+ {
375
+ type: 'text',
376
+ text: `${waiting.length} change(s) waiting for ${v}:\n${lines.join('\n')}\n\nThe person's own on-screen adjustments (moved/resized pieces, chosen effects) are in src/videos/${v}/layout.json — keep them. Make each change in the edit, then call editor_requests with done: [ids].`,
377
+ },
378
+ ],
379
+ };
380
+ },
381
+ );
159
382
 
160
383
  await server.connect(new StdioServerTransport());
161
384
  log('MCP server ready');
@@ -1,4 +1,9 @@
1
1
  import {Config} from '@remotion/cli/config';
2
+ // @ts-expect-error -- plain JS module (scripts/ ships as .mjs only); checkPlanGate(argv: string[]): void
3
+ import {checkPlanGate} from './scripts/lib/plan-gate.mjs';
4
+
5
+ // Every render of a video waits for the person's yes to its plan (motion sheet). See scripts/lib/plan-gate.mjs.
6
+ checkPlanGate(process.argv.slice(2));
2
7
 
3
8
  Config.setEntryPoint('./src/index.ts');
4
9
  // Everything in assets/ is reachable with staticFile('footage/clip.mp4') etc.
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Record the person's yes to a plan, so the render gate (remotion.config.ts) lets that composition render.
4
+ *
5
+ * npm run approve -- <CompositionId> --said "go ahead" approves the current motion sheet
6
+ * npm run approve -- <CompositionId> --said "just render it" --skip-plan they chose to skip the plan
7
+ *
8
+ * --said is THEIR words, copied from the chat — never yours, never a paraphrase. It is the record of who
9
+ * decided. Only run this after they have actually said yes; silence is not a yes.
10
+ *
11
+ * The approval covers that sheet. Fixes to an approved edit render without a new approval; a NEW sheet
12
+ * (npm run plan-sheet) means a new plan, and the gate asks for a new yes.
13
+ */
14
+ import {createHash} from 'node:crypto';
15
+ import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
16
+ import {join, resolve} from 'node:path';
17
+
18
+ const args = process.argv.slice(2);
19
+ const flag = (name) => (args.includes(`--${name}`) ? args[args.indexOf(`--${name}`) + 1] : null);
20
+ const comp = args.find((a, i) => !a.startsWith('--') && !(i > 0 && args[i - 1] === '--said'));
21
+ const said = flag('said');
22
+ const skip = args.includes('--skip-plan');
23
+ if (!comp || !said) {
24
+ console.error('Usage: npm run approve -- <CompositionId> --said "<their exact words>" [--skip-plan]');
25
+ process.exit(1);
26
+ }
27
+ const dir = resolve('work', 'plans', comp);
28
+ mkdirSync(dir, {recursive: true});
29
+ let sheetHash = null;
30
+ if (!skip) {
31
+ const sheet = join(dir, 'motion-sheet.png');
32
+ if (!existsSync(sheet)) {
33
+ console.error(`No motion sheet for ${comp}. Make it first (npm run plan-sheet -- ${comp} --beats <beats.json>), show it, then ask.\nIf they explicitly said to skip the plan, add --skip-plan.`);
34
+ process.exit(1);
35
+ }
36
+ sheetHash = createHash('sha256').update(readFileSync(sheet)).digest('hex').slice(0, 16);
37
+ }
38
+ writeFileSync(join(dir, 'approved.json'), JSON.stringify({composition: comp, said, skipPlan: skip, sheetHash, at: new Date().toISOString()}, null, 2) + '\n');
39
+ console.log(skip ? `Recorded: they chose to skip the plan for ${comp} ("${said}").` : `Approved ${comp} on its motion sheet ("${said}"). Rendering is unlocked.`);
@@ -0,0 +1,73 @@
1
+ /**
2
+ * THE PLAN GATE — called from remotion.config.ts, which every `remotion render` loads first.
3
+ *
4
+ * A render of a video is refused until the person has approved its plan:
5
+ * - a motion sheet exists for that composition (npm run plan-sheet), AND they said yes to THAT sheet
6
+ * (npm run approve -- <Comp> --said "…"), and then the LIVE PREVIEW has been opened for it after that
7
+ * yes (preview/server.mjs records it), or
8
+ * - they explicitly chose to skip the plan (npm run approve -- <Comp> --said "…" --skip-plan).
9
+ *
10
+ * Why it exists: on 2026-10-22 a plan went out as a table only, without the motion sheet, against a rule
11
+ * that had been in the rulebook for weeks. A rule that is only text gets skipped — on our machine and so on
12
+ * every customer's. This makes the step impossible to skip by accident, without adding work for the person:
13
+ * they still just look at the sheet and say "go ahead".
14
+ *
15
+ * Not gated: test and sample compositions (…Test, …Board, Sample, PlanSheet), stills, the live preview.
16
+ * Fixes to an approved edit render freely; only a NEW sheet (a new plan) asks for a new yes.
17
+ */
18
+ import {createHash} from 'node:crypto';
19
+ import {existsSync, readFileSync} from 'node:fs';
20
+ import {join, resolve} from 'node:path';
21
+
22
+ const EXEMPT = /(Test|Board|Sheet)$|^Sample$/;
23
+ // Flags that take a separate value — their value is not the composition id.
24
+ const VALUE_FLAGS = new Set(['--codec', '--props', '--frames', '--concurrency', '--crf', '--pixel-format', '--image-format', '--jpeg-quality', '--quality', '--browser-executable', '--gl', '--log', '--every-nth-frame', '--audio-codec', '--audio-bitrate', '--video-bitrate', '--public-dir', '--config', '--env-file', '--height', '--width', '--scale', '--port', '--timeout', '--muted', '--bundle-cache', '--output', '--sequence', '--color-space', '--x264-preset']);
25
+
26
+ /** The composition id from `remotion render [entry] <comp> [output] [flags]`, or null. */
27
+ export function renderedComposition(argv = process.argv.slice(2)) {
28
+ const i = argv.indexOf('render');
29
+ if (i < 0) return null;
30
+ const positional = [];
31
+ for (let k = i + 1; k < argv.length; k++) {
32
+ const a = argv[k];
33
+ if (a.startsWith('-')) {
34
+ if (VALUE_FLAGS.has(a) && !a.includes('=')) k++;
35
+ continue;
36
+ }
37
+ positional.push(a);
38
+ }
39
+ // Entry files and output paths have a path separator or an extension; the composition id has neither.
40
+ return positional.find((p) => !/[\\/]/.test(p) && !/\.[a-z0-9]{2,4}$/i.test(p)) ?? null;
41
+ }
42
+
43
+ export function checkPlanGate(argv) {
44
+ const comp = renderedComposition(argv);
45
+ if (!comp || EXEMPT.test(comp)) return;
46
+ // the editor's low-res PLAYBACK copy (editor/server.mjs, smooth preview) is part of the live preview, not a
47
+ // deliverable: it never leaves work/<video>/preview/
48
+ if (process.env.STUDIO_PREVIEW_RENDER === '1') return;
49
+ const dir = resolve('work', 'plans', comp);
50
+ const approvedFile = join(dir, 'approved.json');
51
+ const refuse = (why) => {
52
+ console.error(
53
+ `\n✋ Not rendering ${comp}: ${why}\n\n` +
54
+ 'The plan comes first: show the plan table AND the motion sheet, wait for their yes, then record it.\n' +
55
+ ` 1. npm run plan-sheet -- ${comp} --beats <beats.json> (one real frame per beat → work/plans/${comp}/motion-sheet.png)\n` +
56
+ ` 2. show the table + sheet, and wait for "go ahead"\n` +
57
+ ` 3. npm run approve -- ${comp} --said "<their exact words>"\n` +
58
+ ` 4. open the live preview (preview_edit) and render only when they press Render\n` +
59
+ `If they explicitly said to skip the plan: npm run approve -- ${comp} --said "<their words>" --skip-plan\n`,
60
+ );
61
+ process.exit(1);
62
+ };
63
+ if (!existsSync(approvedFile)) refuse('nobody has approved its plan yet.');
64
+ const approved = JSON.parse(readFileSync(approvedFile, 'utf8'));
65
+ if (approved.skipPlan) return;
66
+ const sheet = join(dir, 'motion-sheet.png');
67
+ if (!existsSync(sheet)) refuse('the approved motion sheet is missing.');
68
+ const hash = createHash('sha256').update(readFileSync(sheet)).digest('hex').slice(0, 16);
69
+ if (hash !== approved.sheetHash) refuse('the motion sheet changed after it was approved — that is a new plan, and it needs a new yes.');
70
+ // Then they must have SEEN it move: the live preview, opened after the yes (preview/server.mjs writes this).
71
+ const previewed = join(dir, 'previewed.json');
72
+ if (!existsSync(previewed) || JSON.parse(readFileSync(previewed, 'utf8')).at < approved.at) refuse('they approved the plan but have not seen it in the live preview since — open it (preview_edit) and render only when they press Render.');
73
+ }
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The motion sheet for a plan: one real rendered frame per beat, numbered and labelled, as one PNG.
4
+ *
5
+ * npm run plan-sheet -- <CompositionId> --beats <beats.json> [--entry src/index.ts] [--public-dir assets]
6
+ *
7
+ * beats.json: [{"frame": 105, "says": "same handful of tools", "what": "...", "beat": "Text behind me"}, ...]
8
+ * (or just a list of frame numbers).
9
+ *
10
+ * WHY THIS IS A COMMAND AND NOT A SENTENCE IN THE RULEBOOK. The rule "show a motion sheet with the plan"
11
+ * existed for weeks, and on 2026-10-22 the plan still went out as a table only — a rule that is only text
12
+ * gets skipped. This makes the sheet one command, and remotion.config.ts refuses a render until the person
13
+ * has approved the sheet this command made (scripts/approve.mjs). See "The plan gate" in the rulebook.
14
+ *
15
+ * Renders every still in ONE browser session (one copy of the assets — a copy per still filled a disk).
16
+ * Writes work/plans/<Comp>/motion-sheet.png and work/plans/<Comp>/sheet.json (with the sheet's hash).
17
+ */
18
+ import {createHash} from 'node:crypto';
19
+ import {existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync} from 'node:fs';
20
+ import {tmpdir} from 'node:os';
21
+ import {dirname, join, resolve} from 'node:path';
22
+ import {spawnSync} from 'node:child_process';
23
+ import {createRequire} from 'node:module';
24
+ import {bundle} from '@remotion/bundler';
25
+ import {openBrowser, renderStill, selectComposition} from '@remotion/renderer';
26
+
27
+ const args = process.argv.slice(2);
28
+ const flag = (name, fallback) => (args.includes(`--${name}`) ? args[args.indexOf(`--${name}`) + 1] : fallback);
29
+ const comp = args.find((a, i) => !a.startsWith('--') && !(i > 0 && args[i - 1].startsWith('--')));
30
+ const beatsFile = flag('beats', null);
31
+ if (!comp || !beatsFile) {
32
+ console.error('Usage: npm run plan-sheet -- <CompositionId> --beats <beats.json> [--entry src/index.ts] [--public-dir assets]');
33
+ process.exit(1);
34
+ }
35
+ const raw = JSON.parse(readFileSync(beatsFile, 'utf8'));
36
+ const beats = raw.map((b) => (typeof b === 'number' ? {frame: b} : b));
37
+ const entry = resolve(flag('entry', 'src/index.ts'));
38
+ const publicDir = resolve(flag('public-dir', 'assets'));
39
+ const dir = resolve('work', 'plans', comp);
40
+ const stillsDir = join(dir, 'stills');
41
+ rmSync(stillsDir, {recursive: true, force: true});
42
+ mkdirSync(stillsDir, {recursive: true});
43
+
44
+ // The CLI file is not in the package's exports map, so find it next to the package's main entry.
45
+ const CLI = join(dirname(createRequire(import.meta.url).resolve('@remotion/cli')), '..', 'remotion-cli.js');
46
+ const STARTED = Date.now() - 1000;
47
+ /** Remotion copies the media into a fresh temp folder for EVERY still (400 MB each for a long clip) and never
48
+ * removes it — 15 stills filled a disk on 2026-10-22. Delete the copies this run made, after each still. */
49
+ const cleanTemp = () => {
50
+ for (const d of readdirSync(tmpdir())) {
51
+ const full = join(tmpdir(), d);
52
+ try {
53
+ if (/^remotion-v[0-9.]+-assets/.test(d) && statSync(full).mtimeMs >= STARTED) rmSync(full, {recursive: true, force: true});
54
+ } catch {
55
+ /* still in use — the next pass gets it */
56
+ }
57
+ }
58
+ };
59
+ const tc = (frame, fps) => {
60
+ const s = frame / fps;
61
+ return `${Math.floor(s / 60)}:${(s % 60).toFixed(1).padStart(4, '0')}`;
62
+ };
63
+
64
+ console.log(`Bundling ${entry}…`);
65
+ // Bundles go next to the sheet and are deleted at the end: each one copies the whole assets folder, and
66
+ // leaving them behind is how a nearly full disk ran out mid-render.
67
+ const serveUrl = await bundle({entryPoint: entry, publicDir, outDir: join(dir, '.bundle')});
68
+ const browser = await openBrowser('chrome');
69
+ try {
70
+ const composition = await selectComposition({serveUrl, id: comp, puppeteerInstance: browser});
71
+ const tiles = [];
72
+ for (const [i, b] of beats.entries()) {
73
+ const file = `s${String(i + 1).padStart(2, '0')}.jpg`;
74
+ // Each still in its own short process: Remotion copies the media into temp for every still and holds the
75
+ // copy until the process ends, so one process for all stills piled up 14 x 400 MB and filled the disk.
76
+ const r = spawnSync(process.execPath, [CLI, 'still', serveUrl, comp, join(stillsDir, file), `--frame=${b.frame}`, '--scale=0.5', '--log=error'], {stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8'});
77
+ if (r.status !== 0 || !existsSync(join(stillsDir, file))) throw new Error(`still at frame ${b.frame} failed: ${(r.stderr || '').slice(0, 400)}`);
78
+ cleanTemp();
79
+ tiles.push({file, n: i + 1, time: tc(b.frame, composition.fps), frame: b.frame, says: b.says, what: b.what, beat: b.beat});
80
+ console.log(` ${i + 1}/${beats.length} frame ${b.frame}`);
81
+ }
82
+
83
+ // The sheet itself, drawn by Remotion with the stills folder as its public dir.
84
+ const sheetUrl = await bundle({entryPoint: resolve('src/plan/entry.tsx'), publicDir: stillsDir, outDir: join(dir, '.bundle-sheet')});
85
+ const props = {title: flag('title', comp), subtitle: flag('subtitle', 'One real rendered frame per beat. Point at a number to change it.'), tiles, vertical: composition.height > composition.width};
86
+ const sheetComp = await selectComposition({serveUrl: sheetUrl, id: 'PlanSheet', inputProps: props, puppeteerInstance: browser});
87
+ const out = join(dir, 'motion-sheet.png');
88
+ await renderStill({serveUrl: sheetUrl, composition: sheetComp, frame: 0, output: out, imageFormat: 'png', inputProps: props, puppeteerInstance: browser});
89
+
90
+ const hash = createHash('sha256').update(readFileSync(out)).digest('hex').slice(0, 16);
91
+ writeFileSync(join(dir, 'sheet.json'), JSON.stringify({composition: comp, hash, beats: beats.length, frames: beats.map((b) => b.frame), at: new Date().toISOString()}, null, 2) + '\n');
92
+ // A new sheet means a new plan: any earlier approval no longer covers it.
93
+ if (existsSync(join(dir, 'approved.json'))) console.log('A new sheet replaces the approved one — ask for a yes on this sheet before rendering.');
94
+ console.log(`\nMotion sheet: ${out}\nShow it to them with the plan table, then wait for a yes and record it: npm run approve -- ${comp} --said "<their words>"`);
95
+ } finally {
96
+ await browser.close({silent: true});
97
+ for (const b of ['.bundle', '.bundle-sheet']) rmSync(join(dir, b), {recursive: true, force: true});
98
+ cleanTemp();
99
+ }
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+ import {AbsoluteFill, useCurrentFrame} from 'remotion';
3
+ import {EASE} from '../design/motion';
4
+ import {FONT} from '../design/tokens';
5
+ import {progress} from '../lib/animate';
6
+
7
+ /**
8
+ * Comment box — the "comment WORD" call to action as a real comment field: it rises over a soft bottom
9
+ * gradient, the word types itself in (accent colour), and "Post" lights up when it's complete.
10
+ * Sound: a key click per letter, a click on Post.
11
+ *
12
+ * <CommentBox from={2474} to={2545} typeAt={2496} word="EDIT" />
13
+ */
14
+ export const CommentBox: React.FC<{from: number; to: number; typeAt: number; word: string; accent?: string}> = ({from, to, typeAt, word, accent = 'var(--accent, your accent)'}) => {
15
+ const f = useCurrentFrame();
16
+ if (f < from || f >= to) return null;
17
+ const a = progress(f, from, 12, EASE.out);
18
+ const o = Math.min(a, 1 - progress(f, to - 6, 6, EASE.in));
19
+ const typed = word.slice(0, Math.max(0, Math.min(word.length, Math.floor((f - typeAt + 3) / 3))));
20
+ const done = typed.length === word.length;
21
+ return (
22
+ <>
23
+ <AbsoluteFill style={{background: 'linear-gradient(to top, rgba(15,15,13,0.85), rgba(15,15,13,0) 45%)', opacity: o}} />
24
+ <div style={{position: 'absolute', left: 50, right: 50, bottom: 300 - (1 - a) * 24, opacity: o, borderRadius: 999, padding: '22px 30px', display: 'flex', alignItems: 'center', gap: 18, background: 'rgba(28,28,25,0.92)', border: '1px solid rgba(255,255,255,0.09)', boxShadow: '0 30px 60px rgba(0,0,0,0.45)', fontFamily: FONT.sans}}>
25
+ <div style={{width: 60, height: 60, borderRadius: 999, background: 'linear-gradient(135deg,#f9ce34,#ee2a7b,#6228d7)'}} />
26
+ <div style={{flex: 1, fontSize: 52, fontWeight: 800, color: typed ? accent : 'rgba(255,255,255,0.35)', letterSpacing: '0.02em'}}>{typed || 'Add a comment…'}</div>
27
+ <div style={{fontSize: 34, fontWeight: 700, color: done ? accent : 'rgba(255,255,255,0.3)', transform: `scale(${done ? 1.06 : 1})`}}>Post</div>
28
+ </div>
29
+ </>
30
+ );
31
+ };
@@ -0,0 +1,48 @@
1
+ import React from 'react';
2
+ import {AbsoluteFill, interpolate, useCurrentFrame} from 'remotion';
3
+
4
+ /**
5
+ * Film burn — a warm light leak that flares across the frame and burns out, the old "end of the reel" flash.
6
+ * Use it to hide a join or a short pause: the eye goes to the flare, and the shot after it reads as a new one.
7
+ * First built (2026-10-22) over a 0.3 s hesitation after the hook, with a camera shutter on it.
8
+ *
9
+ * <FilmBurn at={138} frames={20} /> // above the footage and the graphics, below the captions
10
+ *
11
+ * Peak brightness lands at 40% of its length — put the join or the pause there. Sound: a shutter click at
12
+ * `at`, a soft whoosh under the peak.
13
+ */
14
+ export const FilmBurn: React.FC<{at: number; frames?: number; from?: 'left' | 'right'}> = ({at, frames = 20, from = 'left'}) => {
15
+ const f = useCurrentFrame();
16
+ const t = (f - at) / frames;
17
+ if (t < 0 || t > 1) return null;
18
+ const peak = 0.4;
19
+ const a = t < peak ? interpolate(t, [0, peak], [0, 1]) : interpolate(t, [peak, 1], [1, 0]);
20
+ const ease = a * a * (3 - 2 * a);
21
+ const dir = from === 'left' ? 1 : -1;
22
+ // the leak travels across the frame while it burns
23
+ const x = 50 + dir * interpolate(t, [0, 1], [-45, 45]);
24
+ return (
25
+ <AbsoluteFill style={{pointerEvents: 'none'}}>
26
+ {/* the burn: hot core, orange body, deep red edge — screened onto the picture */}
27
+ <AbsoluteFill
28
+ style={{
29
+ mixBlendMode: 'screen',
30
+ opacity: ease,
31
+ background: `radial-gradient(ellipse 55% 70% at ${x}% 45%, rgba(255,246,214,1) 0%, rgba(255,170,70,0.95) 28%, rgba(226,72,24,0.75) 55%, rgba(120,20,10,0) 80%)`,
32
+ filter: 'blur(18px)',
33
+ }}
34
+ />
35
+ {/* a second, offset flare so it never looks like one clean circle */}
36
+ <AbsoluteFill
37
+ style={{
38
+ mixBlendMode: 'screen',
39
+ opacity: ease * 0.8,
40
+ background: `radial-gradient(ellipse 35% 45% at ${100 - x}% 75%, rgba(255,214,140,0.9) 0%, rgba(240,110,40,0.6) 40%, rgba(0,0,0,0) 75%)`,
41
+ filter: 'blur(28px)',
42
+ }}
43
+ />
44
+ {/* the burnt-out moment: everything goes warm white for a beat */}
45
+ <AbsoluteFill style={{background: 'rgb(255,236,205)', opacity: Math.max(0, ease - 0.55) * 1.6}} />
46
+ </AbsoluteFill>
47
+ );
48
+ };