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.
@@ -15,6 +15,7 @@
15
15
  "new-video": "node scripts/new-video.mjs",
16
16
  "prep": "node scripts/prep.mjs",
17
17
  "stills": "node scripts/stills.mjs",
18
+ "final-check": "node scripts/final-check.mjs",
18
19
  "refs": "node scripts/refs.mjs",
19
20
  "refstyle": "node scripts/refstyle.mjs",
20
21
  "refmotion": "node scripts/refmotion.mjs",
@@ -23,7 +24,9 @@
23
24
  "ramp": "node scripts/ramp.mjs",
24
25
  "trim": "node scripts/trim.mjs",
25
26
  "assemble": "node scripts/assemble.mjs",
26
- "sound": "node scripts/sounddesign.mjs"
27
+ "sound": "node scripts/sounddesign.mjs",
28
+ "plan-sheet": "node scripts/plan-sheet.mjs",
29
+ "approve": "node scripts/approve.mjs"
27
30
  },
28
31
  "dependencies": {
29
32
  "@modelcontextprotocol/ext-apps": "1.7.5",
@@ -6,10 +6,10 @@
6
6
  * For a video it finds: the preview module (Comp, META, BEATS), every file the edit reads with
7
7
  * staticFile() as a small preview copy, and the edit compiled to one script.
8
8
  */
9
- import {spawnSync} from 'node:child_process';
9
+ import {spawn, spawnSync} from 'node:child_process';
10
10
  import {createHash} from 'node:crypto';
11
11
  import {createRequire} from 'node:module';
12
- import {existsSync, mkdirSync, readdirSync, readFileSync, statSync} from 'node:fs';
12
+ import {existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync} from 'node:fs';
13
13
  import {dirname, extname, join, resolve} from 'node:path';
14
14
  import {fileURLToPath, pathToFileURL} from 'node:url';
15
15
 
@@ -76,8 +76,65 @@ export function configFor(name) {
76
76
 
77
77
  /* ----------------------------------------------------------------------------- the footage */
78
78
 
79
+ const MEDIA = /\.(mp4|mov|webm|png|jpe?g|webp|gif|svg|mp3|wav|m4a|ttf|otf|woff2?)$/i;
80
+ const isFile = (rel) => {
81
+ try {
82
+ return statSync(join(PUBLIC, rel)).isFile();
83
+ } catch {
84
+ return false;
85
+ }
86
+ };
87
+ /** Every file under a folder of assets/, as names relative to assets/ (cached per build: cutout folders hold thousands). */
88
+ const walked = new Map();
89
+ const walk = (rel) => {
90
+ if (walked.has(rel)) return walked.get(rel);
91
+ const dir = join(PUBLIC, rel);
92
+ const list = !existsSync(dir) || !statSync(dir).isDirectory() ? [] : readdirSync(dir, {recursive: true}).map((f) => `${rel}/${String(f).replace(/\\/g, '/')}`.replace(/^\//, '')).filter(isFile);
93
+ walked.set(rel, list);
94
+ return list;
95
+ };
96
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
97
+
98
+ /**
99
+ * The files the edit's code names, found in the code itself (the compiled edit, so every component it imports
100
+ * is included). Edits name files three ways, and customers' edits use all of them:
101
+ * staticFile('brand/logo.png') a whole name
102
+ * P('Cake photo 1.jpg') with P = (p) => staticFile(`projects/cake/${p}`) a name under a helper's folder
103
+ * staticFile(`projects/x/cutouts/${shot}/f${n}.png`) a pattern
104
+ * Missing these left customers' own photos and logo out of the preview (2026-11-01).
105
+ */
106
+ function namedInCode(text) {
107
+ const found = new Set();
108
+ // Helper folders: a template that is a folder followed only by a value, like `projects/cake/${p}`.
109
+ const prefixes = new Set(['']);
110
+ for (const m of text.matchAll(/`([\w ./@()-]*\/)\$\{[^}]+\}`/g)) prefixes.add(m[1].replace(/^\.?\//, ''));
111
+ // Whole names: quoted (may contain spaces) or bare words ending in a media extension.
112
+ const names = new Set();
113
+ for (const m of text.matchAll(/["'`]([^"'`\n$]{1,200})["'`]/g)) if (MEDIA.test(m[1])) names.add(m[1].trim());
114
+ for (const m of text.matchAll(/[\w./@()-]+\.(?:mp4|mov|webm|png|jpe?g|webp|gif|svg|mp3|wav|m4a|ttf|otf|woff2?)\b/gi)) names.add(m[0]);
115
+ for (const n of names) {
116
+ const name = n.replace(/^\.?\/+/, '');
117
+ for (const p of prefixes) if (isFile(p + name)) found.add(p + name);
118
+ }
119
+ // Patterns: a template with a value in the middle and something fixed after it, like `sfx/${name}.mp3`.
120
+ for (const m of text.matchAll(/`([^`]*\$\{[^`]*)`/g)) {
121
+ const t = m[1];
122
+ if (/\$\{[^}]+\}$/.test(t) || !MEDIA.test(t.replace(/\$\{[^}]+\}/g, 'x'))) continue;
123
+ const parts = t.split(/\$\{[^}]+\}/);
124
+ const re = parts.map(escapeRe).join('[^/]*?');
125
+ const root = parts[0].includes('/') ? parts[0].slice(0, parts[0].lastIndexOf('/')) : '';
126
+ for (const p of prefixes) {
127
+ const base = (p + root).replace(/\/$/, '');
128
+ if (!base && !p) continue; // never walk all of assets/ for a pattern with no folder
129
+ const full = new RegExp(`^${escapeRe(p)}${re}$`);
130
+ for (const f of walk(base)) if (full.test(f)) found.add(f);
131
+ }
132
+ }
133
+ return found;
134
+ }
135
+
79
136
  /** Every file the edit asks for by name (staticFile), plus the stems. Keys are the names the edit uses. */
80
- function assetList(c) {
137
+ function assetList(c, code = '', extra = []) {
81
138
  const files = {};
82
139
  const addDir = (rel) => {
83
140
  const dir = join(PUBLIC, rel);
@@ -88,42 +145,78 @@ function assetList(c) {
88
145
  }
89
146
  };
90
147
  for (const d of c.assets) addDir(d);
148
+ walked.clear();
149
+ // A font only when named outright — a pattern like `fonts/${f}` would pull in every font (~1 MB each).
150
+ const fontOk = (f) => !f.startsWith('fonts/') || c.fonts?.includes(f.slice(6)) || code.includes(f.slice(6));
151
+ for (const f of [...namedInCode(code), ...extra]) if (fontOk(f) && isFile(f)) files[f] = join(PUBLIC, f);
91
152
  for (const [k, f] of Object.entries(c.stems ?? {})) if (existsSync(join(STUDIO, f))) files[`__stems/${k}.mp3`] = join(STUDIO, f);
92
- return Object.fromEntries(Object.entries(files).filter(([k]) => /\.(mp4|mov|png|jpe?g|webp|svg|ttf|woff2?|mp3)$/i.test(k)));
153
+ return Object.fromEntries(Object.entries(files).filter(([k]) => MEDIA.test(k)));
93
154
  }
94
155
 
95
156
  /**
96
157
  * Preview copies: small enough to travel inside the player page, good enough to judge the edit in a chat-
97
158
  * sized player. Anything fits a 540px box on its longest side. The final render always uses the originals.
98
159
  */
99
- const FIT = "scale='if(gt(iw,ih),min(540,iw),-2)':'if(gt(iw,ih),-2,min(540,ih))'";
100
- const PROXY = {
101
- video: ['-vf', FIT, '-an', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '31', '-g', '15', '-pix_fmt', 'yuv420p', '-movflags', '+faststart'],
102
- image: ['-vf', FIT, '-c:v', 'libwebp', '-quality', '72'],
103
- audio: ['-vn', '-ac', '2', '-ar', '44100', '-c:a', 'libmp3lame', '-b:a', '128k'],
104
- };
105
- const MIME = {'.mp4': 'video/mp4', '.webp': 'image/webp', '.mp3': 'audio/mpeg', '.svg': 'image/svg+xml', '.ttf': 'font/ttf', '.woff': 'font/woff', '.woff2': 'font/woff2', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png'};
160
+ const fit = (px) => `scale='if(gt(iw,ih),min(${px},iw),-2)':'if(gt(iw,ih),-2,min(${px},ih))'`;
161
+ /**
162
+ * Two sizes. The page travels as ONE message, and the MCP connection drops any message over 10 MB
163
+ * (STDIO_DEFAULT_MAX_BUFFER_SIZE) — on 2026-10-22 a 13 MB page never arrived and the chat showed an older
164
+ * one instead. So the server tries the normal copies first and falls back to the small ones (see PAGE_LIMIT).
165
+ */
166
+ export const PROXY_TIERS = [
167
+ {
168
+ video: ['-vf', fit(540), '-an', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '31', '-g', '15', '-pix_fmt', 'yuv420p', '-movflags', '+faststart'],
169
+ image: ['-vf', fit(540), '-c:v', 'libwebp', '-quality', '72'],
170
+ audio: ['-vn', '-ac', '1', '-ar', '44100', '-c:a', 'libmp3lame', '-b:a', '96k'],
171
+ },
172
+ {
173
+ video: ['-vf', fit(400), '-an', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '35', '-g', '15', '-pix_fmt', 'yuv420p', '-movflags', '+faststart'],
174
+ image: ['-vf', fit(400), '-c:v', 'libwebp', '-quality', '55'],
175
+ audio: ['-vn', '-ac', '1', '-ar', '32000', '-c:a', 'libmp3lame', '-b:a', '56k'],
176
+ alpha: '450k',
177
+ },
178
+ {
179
+ video: ['-vf', fit(320), '-an', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '38', '-g', '15', '-pix_fmt', 'yuv420p', '-movflags', '+faststart'],
180
+ image: ['-vf', fit(320), '-c:v', 'libwebp', '-quality', '45'],
181
+ audio: ['-vn', '-ac', '1', '-ar', '24000', '-c:a', 'libmp3lame', '-b:a', '40k'],
182
+ alpha: '250k',
183
+ },
184
+ ];
185
+ // Cut-out (transparent) videos were 2 Mbit/s at every tier — one 30 s cut-out alone filled the page (ceos-ad-2: 16 MB,
186
+ // 2026-11-01). Their rate now shrinks with the tier; tier 0 keeps the old rate so existing copies stay valid.
187
+ /** The biggest page the chat can be sent, with room for the message around it. */
188
+ export const PAGE_LIMIT = 9.5e6;
189
+ const MIME = {'.mp4': 'video/mp4', '.webm': 'video/webm', '.webp': 'image/webp', '.mp3': 'audio/mpeg', '.svg': 'image/svg+xml', '.ttf': 'font/ttf', '.otf': 'font/otf', '.woff': 'font/woff', '.woff2': 'font/woff2', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif'};
106
190
 
107
- function proxyOf(name, original) {
191
+ export function proxyOf(name, original, PROXY = PROXY_TIERS[0]) {
108
192
  const st = statSync(original);
109
193
  // The settings are part of the key, so changing them makes new copies instead of reusing old ones.
110
- const key = createHash('sha1').update(`${original}:${st.size}:${st.mtimeMs}:${JSON.stringify(PROXY)}`).digest('hex').slice(0, 16);
111
194
  const ext = extname(name).toLowerCase();
112
- const kind = /\.(mp4|mov)$/.test(ext) ? 'video' : /\.(png|jpe?g)$/.test(ext) ? 'image' : ext === '.mp3' ? 'audio' : null;
195
+ const kind = /\.(mp4|mov)$/.test(ext) ? 'video' : ext === '.webm' ? 'alpha' : /\.(png|jpe?g)$/.test(ext) ? 'image' : /\.(mp3|wav|m4a)$/.test(ext) ? 'audio' : null;
196
+ const alphaRate = PROXY.alpha ?? '2M';
197
+ const key = createHash('sha1')
198
+ .update(`${original}:${st.size}:${st.mtimeMs}:${JSON.stringify({...PROXY, alpha: undefined})}${kind === 'alpha' && PROXY.alpha ? `:${alphaRate}` : ''}`)
199
+ .digest('hex')
200
+ .slice(0, 16);
113
201
  if (!kind) return {file: original, mime: MIME[ext] ?? 'application/octet-stream'};
114
- const outExt = kind === 'video' ? '.mp4' : kind === 'image' ? '.webp' : '.mp3';
202
+ const outExt = kind === 'video' ? '.mp4' : kind === 'alpha' ? '.webm' : kind === 'image' ? '.webp' : '.mp3';
115
203
  const out = join(CACHE, `${key}${outExt}`);
116
204
  if (!existsSync(out)) {
117
- const r = spawnSync(FFMPEG, ['-y', '-v', 'error', '-i', original, ...PROXY[kind], out], {encoding: 'utf8'});
205
+ // a transparent video (a keyed subject): read with libvpx so the alpha survives, and keep it transparent
206
+ const args =
207
+ kind === 'alpha'
208
+ ? ['-c:v', 'libvpx-vp9', '-i', original, '-vf', PROXY.video[1], '-an', '-c:v', 'libvpx-vp9', '-pix_fmt', 'yuva420p', '-b:v', alphaRate, '-deadline', 'realtime', '-cpu-used', '8', '-row-mt', '1', '-auto-alt-ref', '0', '-g', '15']
209
+ : ['-i', original, ...PROXY[kind]];
210
+ const r = spawnSync(FFMPEG, ['-y', '-v', 'error', ...args, out], {encoding: 'utf8'});
118
211
  if (r.status !== 0) throw new Error(`could not make a preview copy of ${name}: ${r.stderr}`);
119
212
  }
120
213
  return {file: out, mime: MIME[outExt]};
121
214
  }
122
215
 
123
216
  /** [{name, file, mime, size, hash}] — hash is of the preview copy, so unchanged files are never re-sent. */
124
- export function buildManifest(c) {
125
- return Object.entries(assetList(c)).map(([name, original]) => {
126
- const p = proxyOf(name, original);
217
+ export function buildManifest(c, tier = 0, code = '', extra = []) {
218
+ return Object.entries(assetList(c, code, extra)).map(([name, original]) => {
219
+ const p = proxyOf(name, original, PROXY_TIERS[tier]);
127
220
  const hash = createHash('sha256').update(readFileSync(p.file)).digest('hex').slice(0, 32);
128
221
  return {name, file: p.file, mime: p.mime, size: statSync(p.file).size, hash};
129
222
  });
@@ -162,6 +255,55 @@ export async function compileEdit(c) {
162
255
  }
163
256
  }
164
257
 
258
+ /* ----------------------------------------------------------------------------- the check before the chat sees it */
259
+
260
+ /** Remotion's own headless Chrome (every studio has it: renders need it), else Edge or Chrome. */
261
+ async function headlessBrowser() {
262
+ try {
263
+ const r = await require('@remotion/renderer').ensureBrowser();
264
+ if (r?.path && existsSync(r.path)) return r.path;
265
+ } catch {}
266
+ const pf = [process.env['ProgramFiles(x86)'], process.env.ProgramFiles, process.env.LOCALAPPDATA].filter(Boolean);
267
+ const found = [
268
+ ...pf.map((p) => join(p, 'Microsoft', 'Edge', 'Application', 'msedge.exe')),
269
+ ...pf.map((p) => join(p, 'Google', 'Chrome', 'Application', 'chrome.exe')),
270
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
271
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
272
+ ].find((p) => existsSync(p));
273
+ return found ?? null;
274
+ }
275
+
276
+ /**
277
+ * Opens the finished page headless (#lpcheck), lets it step through 13 points of the video, and returns what the
278
+ * page reported: {ok, missing: [files the edit asked for that aren't in the page], errors}. null = could not check
279
+ * (no browser), which never blocks the preview.
280
+ */
281
+ export async function checkPage(html, budget = 40000) {
282
+ const browser = await headlessBrowser();
283
+ if (!browser) return null;
284
+ const file = join(CACHE, `check-${process.pid}.html`);
285
+ writeFileSync(file, html);
286
+ const args = ['--headless', '--disable-gpu', '--no-sandbox', '--autoplay-policy=no-user-gesture-required', '--mute-audio', '--virtual-time-budget=20000', '--dump-dom', `${pathToFileURL(file).href}#lpcheck`];
287
+ let outOfTime = false;
288
+ const dom = await new Promise((done) => {
289
+ const p = spawn(browser, args, {stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true});
290
+ let out = '';
291
+ p.stdout.on('data', (d) => (out += d));
292
+ const kill = setTimeout(() => ((outOfTime = true), p.kill()), budget);
293
+ p.on('close', () => (clearTimeout(kill), done(out)));
294
+ p.on('error', () => (clearTimeout(kill), done('')));
295
+ });
296
+ rmSync(file, {force: true});
297
+ if (outOfTime) return null; // slow machine: no verdict, never a false alarm
298
+ const m = /<pre id="lpcheck-result">([\s\S]*?)<\/pre>/.exec(dom);
299
+ if (!m) return {ok: false, missing: [], errors: ['The check page never finished — the preview may not load.'], unfinished: true};
300
+ try {
301
+ return JSON.parse(m[1].replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&'));
302
+ } catch {
303
+ return {ok: false, missing: [], errors: ['The check result could not be read.'], unfinished: true};
304
+ }
305
+ }
306
+
165
307
  /**
166
308
  * The player page. Customers get it pre-built (player.html, made when the studio ships), so they need none of
167
309
  * the player's build tools; in the source studio it is built from ui/app.tsx.