creator-editing-studio 1.3.7 → 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.
- package/bin/finish.mjs +132 -0
- package/bin/init.mjs +5 -1
- package/bin/update.mjs +18 -0
- package/history.json +1 -1
- package/package.json +2 -1
- package/template/CLAUDE.md +31 -7
- package/template/package.json +3 -1
- package/template/preview/lib.mjs +164 -19
- package/template/preview/player.html +71 -68
- package/template/preview/server.mjs +246 -23
- package/template/remotion.config.ts +5 -0
- package/template/scripts/approve.mjs +39 -0
- package/template/scripts/lib/plan-gate.mjs +73 -0
- package/template/scripts/plan-sheet.mjs +99 -0
- package/template/src/components/CommentBox.tsx +31 -0
- package/template/src/components/FilmBurn.tsx +48 -0
- package/template/src/components/FlashBloom.tsx +45 -0
- package/template/src/components/MergeToPill.tsx +56 -0
- package/template/src/components/NotificationDrop.tsx +49 -0
- package/template/src/components/PillCaptions.tsx +62 -0
- package/template/src/components/ReelRing.tsx +81 -0
- package/template/src/components/WhipScene.tsx +90 -0
- package/template/src/design/motion.ts +8 -0
- package/template/src/lib/brand.tsx +89 -0
- package/template/src/lib/layout.tsx +113 -0
- package/template/src/lib/recolor.ts +173 -0
- package/template/src/lib/tweaks.tsx +108 -6
- package/template/src/plan/entry.tsx +66 -0
- package/template/src/videos/whats-new/WhatsNewEdit.tsx +177 -0
- package/template/src/videos/whats-new/beats.ts +44 -0
- package/template/src/videos/whats-new/head-track.json +5195 -0
- package/template/src/videos/whats-new/preview.tsx +36 -0
- package/template/src/videos/whats-new/tweaks.json +1 -0
- package/template/src/videos/whats-new/words.json +1 -0
|
@@ -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
|
-
/**
|
|
76
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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: {
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
+
};
|