creator-editing-studio 1.4.0 → 1.4.2
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 +26 -5
- package/template/package.json +1 -0
- package/template/preview/lib.mjs +110 -7
- package/template/preview/player.html +72 -70
- package/template/preview/server.mjs +58 -7
- package/template/preview/shell.html +97 -0
- package/template/scripts/final-check.mjs +69 -0
- package/template/scripts/lib/plan-gate.mjs +28 -2
- package/template/src/lib/fontChoices.ts +72 -0
- package/template/src/lib/swaps.ts +152 -0
- package/template/src/lib/tweaks.tsx +45 -1
package/template/CLAUDE.md
CHANGED
|
@@ -144,16 +144,23 @@ a new yes; making a NEW sheet means a new plan and needs a new yes. Never run ap
|
|
|
144
144
|
|
|
145
145
|
Once they approve, build the edit and **open the live preview yourself** — `preview_edit({video})`. They must
|
|
146
146
|
never have to ask for it, and never get a draft MP4 instead of it. It plays the edit in the chat, and they change
|
|
147
|
-
it right on the video at no message cost: drag to move, corner to resize, a bar
|
|
148
|
-
|
|
147
|
+
it right on the video at no message cost: drag to move, corner to resize, and a bar on whatever they click —
|
|
148
|
+
the font of that text (brand fonts first; this graphic or the whole video), swap an icon from ~6,000, replace an
|
|
149
|
+
image or clip (another from this video or one from their computer), colours, in front of / behind them, hide.
|
|
150
|
+
Draw icons as inline `<svg>` so they can be swapped. See `preview/README.md` for what each video needs (`preview.tsx`, `TweaksProvider`).
|
|
149
151
|
**Every graphic must be draggable:** wrap each headline word, caption layer, icon, logo, image and card in its own
|
|
150
152
|
`<Part id="…">` (never the speaker, the background, or a full-screen card that hides a cut), and with a cut-out
|
|
151
153
|
of the speaker put `<Layer name="behind" />` / `<Layer name="front" />` just before / after it.
|
|
152
154
|
|
|
153
155
|
- A change typed in the preview arrives as a chat message: change only that beat, then call `preview_edit`
|
|
154
156
|
again so the updated preview opens below. **Undo** = `preview_undo`, then `preview_edit`.
|
|
155
|
-
-
|
|
156
|
-
|
|
157
|
+
- The player saves the person's on-video changes (fonts, colours, icons, moves) itself when the chat lets it.
|
|
158
|
+
When it can't, its message asks you to call **`preview_save`** with the adjustments JSON and names a
|
|
159
|
+
fingerprint: pass the JSON exactly, and if the answer's fingerprint differs, call it again. Never hand-write
|
|
160
|
+
`tweaks.json`. The next preview shows the person "✓ kept" or "didn't save", so a skipped save is seen.
|
|
161
|
+
- A **replaced file** comes with old → new names (and, for one from their computer, its name and size — find
|
|
162
|
+
it in their usual folders and copy it into `assets/` at the given path): change the name in the edit's code,
|
|
163
|
+
then call `preview_save` with the JSON the message gives (the same adjustments without the replaced files).
|
|
157
164
|
- Render only when they press **Render final video**, and give the estimated render time only then.
|
|
158
165
|
- If the preview tool is missing (not switched on yet), say so in one line and fall back to rendering with
|
|
159
166
|
the time estimate. Never send them off to fix it mid-edit.
|
|
@@ -458,6 +465,16 @@ background plate (designed, or the original footage)
|
|
|
458
465
|
- Text should animate in while already partly occluded, not appear fully and then get covered.
|
|
459
466
|
- Pull text colour toward the footage's black/white points; pure white over graded footage looks
|
|
460
467
|
pasted on.
|
|
468
|
+
- **A word behind the subject is ALL CAPS and genuinely behind him:** the head covers the lower
|
|
469
|
+
~20–30% of the letters. A word floating clear above the head is not "behind", it is a headline.
|
|
470
|
+
No full stop on it.
|
|
471
|
+
- **Text inside a camera move scales with it.** If the shot is pushed in (scale 1.2–1.3 from near
|
|
472
|
+
the bottom, say), a word at y 225 lands off the top of the screen. Place behind-text by where it
|
|
473
|
+
LANDS after the push, and check a still at the END of the push as well as the start, inside the
|
|
474
|
+
safe zone (x 60–960, y 220–1500).
|
|
475
|
+
- **Never show the same word big and in the captions at the same time.** Keep the one that reads
|
|
476
|
+
better (usually the big word) and drop the other: pass the big words to the captions as `skip`
|
|
477
|
+
(`<StyledCaptions skip={[{text: 'simpler', from: 14.5, to: 16}]} … />`, seconds).
|
|
461
478
|
|
|
462
479
|
---
|
|
463
480
|
|
|
@@ -531,7 +548,11 @@ final call at playback speed. Be explicit about that when reporting.
|
|
|
531
548
|
(blur visible), fully settled (crisp), mid-exit, and the **last frame** (clean).
|
|
532
549
|
4. Check: no text collides with the subject's face, safe zones respected, contrast holds,
|
|
533
550
|
no clipped descenders (g, p, y, commas) in masked text.
|
|
534
|
-
5.
|
|
551
|
+
5. **After every render, check the finished file itself:** `npm run final-check -- <out.mp4>`.
|
|
552
|
+
It writes a contact sheet (one tile per second) and reports frame count, loudness, true peak
|
|
553
|
+
and silent gaps. **Look at the sheet** — a word that is missing, cut off or pushed off-screen
|
|
554
|
+
shows there and in no still of the edit. Not done until it passes and the sheet is read.
|
|
555
|
+
6. Report what was verified and what still needs the user's eyes at full speed.
|
|
535
556
|
|
|
536
557
|
---
|
|
537
558
|
|
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",
|
package/template/preview/lib.mjs
CHANGED
|
@@ -28,7 +28,7 @@ const {FFMPEG} = await import(pathToFileURL(join(STUDIO, 'scripts', 'lib', 'medi
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Everything the preview needs to know about one video, read from its own folder:
|
|
31
|
-
* src/videos/<name>/preview.tsx, the
|
|
31
|
+
* src/videos/<name>/preview.tsx, the edit beside it (and its id in Root.tsx), the asset folders its code mentions, its stems.
|
|
32
32
|
*/
|
|
33
33
|
export function configFor(name) {
|
|
34
34
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(name ?? '')) throw new Error(`"${name}" is not a video name (lowercase letters, digits, dashes)`);
|
|
@@ -61,19 +61,88 @@ export function configFor(name) {
|
|
|
61
61
|
if (m) stems[m[1]] = `out/${name}/${f}`;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
const editFile = readdirSync(code).find((f) => /Edit\.tsx$/.test(f));
|
|
64
|
+
const {editFile, composition} = editAndComposition(name, code);
|
|
66
65
|
return {
|
|
67
66
|
project: name,
|
|
68
67
|
preview: `src/videos/${name}/preview.tsx`,
|
|
69
68
|
edit: `src/videos/${name}/${editFile ?? 'preview.tsx'}`,
|
|
70
|
-
composition
|
|
69
|
+
composition,
|
|
71
70
|
assets: [...folders],
|
|
72
71
|
fonts,
|
|
73
72
|
stems,
|
|
74
73
|
};
|
|
75
74
|
}
|
|
76
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Which file is the edit, and the composition id it renders as — the id the plan gate checks (work/plans/<id>/).
|
|
78
|
+
*
|
|
79
|
+
* The id is what Root.tsx registers, not the file name: the rulebook never told agents to name the edit *Edit.tsx
|
|
80
|
+
* (the shipped sample is Sample.tsx), and with no *Edit.tsx the id was null, previewed.json was never written and the
|
|
81
|
+
* plan gate refused every render even after "Render final video" (an edit named src/videos/<name>/<Name>.tsx, 2026-11-06).
|
|
82
|
+
*
|
|
83
|
+
* Order: the <Composition> in Root.tsx whose component comes from this video's folder — preferring the component
|
|
84
|
+
* preview.tsx plays, then a *Edit.tsx file, then the only one. Not registered: the file preview.tsx imports, then
|
|
85
|
+
* *Edit.tsx, then the only .tsx, named by its file.
|
|
86
|
+
*/
|
|
87
|
+
function editAndComposition(name, code) {
|
|
88
|
+
const tsx = readdirSync(code).filter((f) => f.endsWith('.tsx') && f !== 'preview.tsx');
|
|
89
|
+
const fileOf = (from) => {
|
|
90
|
+
const m = from.match(/^\.\/([\w.-]+?)(?:\.tsx)?$/);
|
|
91
|
+
return m && tsx.includes(`${m[1]}.tsx`) ? `${m[1]}.tsx` : null;
|
|
92
|
+
};
|
|
93
|
+
// What preview.tsx imports from beside it: {ident -> file}
|
|
94
|
+
const played = new Map();
|
|
95
|
+
for (const {names, from} of importsOf(readFileSync(join(code, 'preview.tsx'), 'utf8'))) {
|
|
96
|
+
const f = fileOf(from);
|
|
97
|
+
if (f) for (const n of names) played.set(n, f);
|
|
98
|
+
}
|
|
99
|
+
const playedFiles = new Set(played.values());
|
|
100
|
+
|
|
101
|
+
const ours = registered().filter((c) => c.from.startsWith(`./videos/${name}/`));
|
|
102
|
+
const rank = (c) => (played.get(c.component) === c.file ? 0 : playedFiles.has(c.file) ? 1 : /Edit\.tsx$/.test(c.file ?? '') ? 2 : 3);
|
|
103
|
+
const pick = ours.filter((c) => c.file).sort((a, b) => rank(a) - rank(b));
|
|
104
|
+
if (pick.length && (rank(pick[0]) < 3 || pick.length === 1)) return {editFile: pick[0].file, composition: pick[0].id};
|
|
105
|
+
|
|
106
|
+
const editFile =
|
|
107
|
+
[...playedFiles][0] ?? tsx.find((f) => /Edit\.tsx$/.test(f)) ?? (tsx.length === 1 ? tsx[0] : undefined);
|
|
108
|
+
return {editFile, composition: editFile?.replace(/\.tsx$/, '') ?? null};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `import {A, B as C} from './x'` → [{names: ['A', 'C'], from: './x'}] (default imports too). */
|
|
112
|
+
function importsOf(text) {
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const m of text.matchAll(/import\s+(?:type\s+)?([\w$]+)?\s*,?\s*(?:\{([^}]*)\})?\s*from\s*['"]([^'"]+)['"]/g)) {
|
|
115
|
+
const names = [];
|
|
116
|
+
if (m[1]) names.push(m[1]);
|
|
117
|
+
for (const part of (m[2] ?? '').split(',')) {
|
|
118
|
+
const n = part.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop();
|
|
119
|
+
if (n) names.push(n);
|
|
120
|
+
}
|
|
121
|
+
out.push({names, from: m[3]});
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Every <Composition id="…" component={…}> in src/Root.tsx, with the import its component comes from. */
|
|
127
|
+
function registered() {
|
|
128
|
+
const root = join(SRC, 'Root.tsx');
|
|
129
|
+
if (!existsSync(root)) return [];
|
|
130
|
+
const text = readFileSync(root, 'utf8');
|
|
131
|
+
const from = new Map();
|
|
132
|
+
for (const {names, from: f} of importsOf(text)) for (const n of names) from.set(n, f);
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const chunk of text.split(/<Composition\b/).slice(1)) {
|
|
135
|
+
const tag = chunk.slice(0, chunk.indexOf('/>') + 1 || undefined);
|
|
136
|
+
const id = tag.match(/\bid=(?:\{\s*)?["'`]([^"'`]+)["'`]/)?.[1];
|
|
137
|
+
const component = tag.match(/\bcomponent=\{\s*([\w$]+)\s*\}/)?.[1];
|
|
138
|
+
if (!id || !component || !from.has(component)) continue;
|
|
139
|
+
const f = from.get(component);
|
|
140
|
+
const file = f.match(/\/([\w.-]+?)(?:\.tsx)?$/)?.[1];
|
|
141
|
+
out.push({id, component, from: f, file: file ? `${file}.tsx` : null});
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
77
146
|
/* ----------------------------------------------------------------------------- the footage */
|
|
78
147
|
|
|
79
148
|
const MEDIA = /\.(mp4|mov|webm|png|jpe?g|webp|gif|svg|mp3|wav|m4a|ttf|otf|woff2?)$/i;
|
|
@@ -315,8 +384,42 @@ export async function playerHtml() {
|
|
|
315
384
|
}
|
|
316
385
|
|
|
317
386
|
export async function buildUi() {
|
|
318
|
-
|
|
387
|
+
// the fonts and icon sets the bar offers travel inside the player (ui/make-assets.mjs)
|
|
388
|
+
const assets = await import(pathToFileURL(join(HERE, 'ui', 'make-assets.mjs')).href);
|
|
389
|
+
if (assets.stale()) assets.make();
|
|
390
|
+
// The build stamp shows on every card ("Player 6 Nov 10:40 · edit v5"), so a stale player can never pass unseen.
|
|
391
|
+
const define = {'process.env.NODE_ENV': '"production"', __LP_BUILD__: JSON.stringify(new Date().toISOString())};
|
|
392
|
+
const r = await esbuild.build({...common, entryPoints: [join(HERE, 'ui', 'app.tsx')], format: 'iife', write: false, minify: true, define});
|
|
319
393
|
const js = r.outputFiles[0].text;
|
|
320
|
-
return
|
|
321
|
-
|
|
394
|
+
return wrapJs(js);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const CLOSE = /<\/script>/g;
|
|
398
|
+
const CLOSE_ESC = /<\\\/script>/g;
|
|
399
|
+
const wrapJs = (js) => `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
400
|
+
<body><div id="root"></div><script>${js.replace(CLOSE, '<\\/script>')}</script></body></html>`;
|
|
401
|
+
|
|
402
|
+
/** The player's own script, out of the player page — it travels inside every preview_edit result (see shellHtml). */
|
|
403
|
+
export const playerJs = (html) => {
|
|
404
|
+
// The page's own marker — the player's code itself contains the text "<script>".
|
|
405
|
+
const OPEN = '<div id="root"></div><script>';
|
|
406
|
+
const at = html.indexOf(OPEN);
|
|
407
|
+
return html.slice(at + OPEN.length, html.lastIndexOf('</script>')).replace(CLOSE_ESC, '</script>');
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* THE SHELL (2026-11-06). The chat card's page is this small, never-changing shell: it waits for its own tool
|
|
412
|
+
* result and runs the player that came inside it. The Claude app shows a page it fetched earlier (at startup, or
|
|
413
|
+
* when the person last wrote), so a player inside the page was always one version behind — now the page holds no
|
|
414
|
+
* player at all, and every card plays the newest one. Customers get it pre-built (shell.html).
|
|
415
|
+
*/
|
|
416
|
+
export async function shellHtml() {
|
|
417
|
+
const built = join(HERE, 'shell.html');
|
|
418
|
+
if (existsSync(built) && !existsSync(join(HERE, 'ui', 'shell.ts'))) return readFileSync(built, 'utf8');
|
|
419
|
+
return buildShell();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function buildShell() {
|
|
423
|
+
const r = await esbuild.build({...common, entryPoints: [join(HERE, 'ui', 'shell.ts')], format: 'iife', write: false, minify: true, define: {'process.env.NODE_ENV': '"production"'}});
|
|
424
|
+
return wrapJs(r.outputFiles[0].text);
|
|
322
425
|
}
|