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
package/template/package.json
CHANGED
|
@@ -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",
|
package/template/preview/lib.mjs
CHANGED
|
@@ -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]) =>
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
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
|
|
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
|
-
|
|
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(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/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.
|