creator-editing-studio 1.3.8 → 1.4.1
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/history.json +1 -1
- package/package.json +1 -1
- package/template/CLAUDE.md +42 -8
- package/template/package.json +4 -1
- package/template/preview/lib.mjs +161 -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/final-check.mjs +69 -0
- package/template/scripts/lib/plan-gate.mjs +99 -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 +4 -2
|
@@ -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,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Check the FINISHED file — after every render, before saying it is done.
|
|
4
|
+
*
|
|
5
|
+
* npm run final-check -- out/my-video/final.mp4 [--fps=30] [--frames=871]
|
|
6
|
+
*
|
|
7
|
+
* Writes <file>.check.jpg: one tile per second of the delivered video, so a word that is missing,
|
|
8
|
+
* cut off or pushed off-screen by a camera move shows up (it did, 2026-11-05: a word behind the
|
|
9
|
+
* speaker was scaled off the top by the push-in and appeared for 8 frames — no still of the edit
|
|
10
|
+
* showed it, only the finished file did). Then reports and fails on:
|
|
11
|
+
* - frame count (against --frames when given) and a video + audio stream being present
|
|
12
|
+
* - loudness outside -17…-15 LUFS, true peak above -0.9 dBTP
|
|
13
|
+
* - silent gaps over 0.6 s while the video is still running (a dropped voice join)
|
|
14
|
+
* Look at the contact sheet: the numbers cannot see a misplaced word.
|
|
15
|
+
*/
|
|
16
|
+
import {existsSync} from 'node:fs';
|
|
17
|
+
import {FFMPEG, FFPROBE, run} from './lib/media.mjs';
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const file = args.find((a) => !a.startsWith('--'));
|
|
21
|
+
const opt = (k) => args.find((a) => a.startsWith(`--${k}=`))?.split('=')[1];
|
|
22
|
+
if (!file || !existsSync(file)) {
|
|
23
|
+
console.error('Usage: npm run final-check -- <rendered.mp4> [--frames=<expected>]');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
const problems = [];
|
|
27
|
+
|
|
28
|
+
// streams
|
|
29
|
+
const info = JSON.parse(run(FFPROBE, ['-v', 'error', '-count_frames', '-show_entries', 'stream=codec_type,width,height,nb_read_frames,r_frame_rate:format=duration', '-of', 'json', file]).out);
|
|
30
|
+
const v = info.streams.find((s) => s.codec_type === 'video');
|
|
31
|
+
const a = info.streams.find((s) => s.codec_type === 'audio');
|
|
32
|
+
const dur = +info.format.duration;
|
|
33
|
+
if (!v) problems.push('no video stream');
|
|
34
|
+
if (!a) problems.push('no audio stream');
|
|
35
|
+
const frames = v ? +v.nb_read_frames : 0;
|
|
36
|
+
const want = opt('frames') ? +opt('frames') : null;
|
|
37
|
+
if (want && frames !== want) problems.push(`${frames} frames, expected ${want}`);
|
|
38
|
+
console.log(`video ${v ? `${v.width}×${v.height}, ${frames} frames, ${v.r_frame_rate} fps` : '—'} · ${dur.toFixed(2)} s`);
|
|
39
|
+
|
|
40
|
+
// loudness + true peak
|
|
41
|
+
if (a) {
|
|
42
|
+
const out = run(FFMPEG, ['-hide_banner', '-nostats', '-i', file, '-af', 'ebur128=peak=true', '-f', 'null', '-']).err;
|
|
43
|
+
const tail = out.slice(out.lastIndexOf('Summary:'));
|
|
44
|
+
const I = +(/I:\s+(-?[\d.]+) LUFS/.exec(tail)?.[1] ?? NaN);
|
|
45
|
+
const TP = +(/Peak:\s+(-?[\d.]+) dBFS/.exec(tail)?.[1] ?? NaN);
|
|
46
|
+
console.log(`audio ${I} LUFS integrated · true peak ${TP} dBTP`);
|
|
47
|
+
if (!(I >= -17 && I <= -15)) problems.push(`loudness ${I} LUFS (deliver at -16)`);
|
|
48
|
+
if (!(TP <= -0.9)) problems.push(`true peak ${TP} dBTP (keep at or under -1)`);
|
|
49
|
+
|
|
50
|
+
// silences while the picture still runs (the last 4 s may be a silent end card)
|
|
51
|
+
const sil = run(FFMPEG, ['-hide_banner', '-nostats', '-i', file, '-af', 'silencedetect=n=-45dB:d=0.6', '-f', 'null', '-']).err;
|
|
52
|
+
const gaps = [...sil.matchAll(/silence_start: ([\d.]+)/g)].map((m) => +m[1]).filter((t) => t > 0.3 && t < dur - 4);
|
|
53
|
+
if (gaps.length) problems.push(`silent gap at ${gaps.map((t) => t.toFixed(2) + ' s').join(', ')} — check the voice joins`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// contact sheet: one tile per second, 8 across, read left to right (tile n = second n; no burned-in
|
|
57
|
+
// timestamps — ffmpeg's drawtext needs a fontconfig most Windows machines lack)
|
|
58
|
+
const sheet = file.replace(/\.[^.]+$/, '') + '.check.jpg';
|
|
59
|
+
const tiles = Math.ceil(dur);
|
|
60
|
+
const rows = Math.ceil(tiles / 8);
|
|
61
|
+
const made = run(FFMPEG, ['-y', '-v', 'error', '-i', file, '-vf', `fps=1,scale=240:-1,tile=8x${rows}`, '-frames:v', '1', sheet]);
|
|
62
|
+
if (!made.ok) problems.push('contact sheet failed: ' + made.err.trim());
|
|
63
|
+
console.log(`sheet ${sheet} (${tiles} tiles) — LOOK AT IT before reporting`);
|
|
64
|
+
|
|
65
|
+
if (problems.length) {
|
|
66
|
+
console.error('\n✗ Not ready:\n - ' + problems.join('\n - '));
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
console.log('\n✓ File checks pass. Now read the contact sheet.');
|
|
@@ -0,0 +1,99 @@
|
|
|
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, readdirSync, statSync} 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)) 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
|
+
const seen = JSON.parse(readFileSync(previewed, 'utf8'));
|
|
74
|
+
if (seen.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.');
|
|
75
|
+
// A hard gate on the CURRENT edit, not just the plan (2026-11-05): any change to the video's files after the last
|
|
76
|
+
// preview means they have not seen what would render. tweaks.json is excluded — the preview itself writes it.
|
|
77
|
+
const changed = seen.project ? newestChange(resolve('src', 'videos', seen.project)) : null;
|
|
78
|
+
if (changed && changed.at > Date.parse(seen.at)) {
|
|
79
|
+
refuse(`the edit changed after they last saw it (${changed.file}) — open the live preview again (preview_edit) so they see this version, and render only when they press Render.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The most recently changed file of a video's source folder, ignoring the preview's own tweaks.json. */
|
|
84
|
+
function newestChange(dir) {
|
|
85
|
+
if (!existsSync(dir)) return null;
|
|
86
|
+
let best = null;
|
|
87
|
+
const walk = (d) => {
|
|
88
|
+
for (const e of readdirSync(d, {withFileTypes: true})) {
|
|
89
|
+
const p = join(d, e.name);
|
|
90
|
+
if (e.isDirectory()) walk(p);
|
|
91
|
+
else if (e.name !== 'tweaks.json') {
|
|
92
|
+
const at = statSync(p).mtimeMs;
|
|
93
|
+
if (!best || at > best.at) best = {file: p, at};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
walk(dir);
|
|
98
|
+
return best;
|
|
99
|
+
}
|