creator-editing-studio 1.4.1 → 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.
@@ -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 for text colour, in front of /
148
- behind them, and hide. See `preview/README.md` for what each video needs (`preview.tsx`, `TweaksProvider`).
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
- - **Save** and **Render** arrive as one message each, carrying their adjustments as JSON for the video's
156
- `tweaks.json` — write it exactly.
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.
@@ -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 *Edit.tsx beside it, the asset folders its code mentions, its stems.
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
- // The edit is the file named *Edit.tsx; its composition id is that name (as registered in Root.tsx).
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: editFile?.replace(/\.tsx$/, '') ?? null,
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
- const r = await esbuild.build({...common, entryPoints: [join(HERE, 'ui', 'app.tsx')], format: 'iife', write: false, minify: true, define: {'process.env.NODE_ENV': '"production"'}});
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 `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
321
- <body><div id="root"></div><script>${js.replace(/<\/script>/g, '<\\/script>')}</script></body></html>`;
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
  }