creator-editing-studio 1.3.6 → 1.3.7
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 +38 -0
- package/template/package.json +8 -3
- package/template/preview/README.md +34 -0
- package/template/preview/lib.mjs +177 -0
- package/template/preview/player.html +255 -0
- package/template/preview/server.mjs +161 -0
- package/template/preview/use.mjs +22 -0
- package/template/requirements.txt +4 -0
- package/template/scripts/align-script.py +138 -0
- package/template/scripts/doctor.mjs +27 -1
- package/template/scripts/lib/languages.py +68 -0
- package/template/scripts/refmotion.mjs +349 -0
- package/template/scripts/setup_models.py +89 -0
- package/template/scripts/stills.mjs +1 -1
- package/template/scripts/track_head.py +157 -0
- package/template/scripts/transcribe.py +74 -5
- package/template/src/components/HeadPin.tsx +42 -0
- package/template/src/lib/track.ts +67 -0
- package/template/src/lib/tweaks.tsx +98 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the live preview needs from a video, worked out once and used by both halves:
|
|
3
|
+
* server.mjs — the local preview server (our machine, reads files directly)
|
|
4
|
+
* publish.mjs — sends the same things to the online preview, so it works in any chat with no restart
|
|
5
|
+
*
|
|
6
|
+
* For a video it finds: the preview module (Comp, META, BEATS), every file the edit reads with
|
|
7
|
+
* staticFile() as a small preview copy, and the edit compiled to one script.
|
|
8
|
+
*/
|
|
9
|
+
import {spawnSync} from 'node:child_process';
|
|
10
|
+
import {createHash} from 'node:crypto';
|
|
11
|
+
import {createRequire} from 'node:module';
|
|
12
|
+
import {existsSync, mkdirSync, readdirSync, readFileSync, statSync} from 'node:fs';
|
|
13
|
+
import {dirname, extname, join, resolve} from 'node:path';
|
|
14
|
+
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
15
|
+
|
|
16
|
+
export const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
export const STUDIO = resolve(HERE, '..');
|
|
18
|
+
export const PUBLIC = join(STUDIO, 'assets');
|
|
19
|
+
export const SRC = join(STUDIO, 'src');
|
|
20
|
+
const CACHE = join(HERE, '.cache');
|
|
21
|
+
mkdirSync(CACHE, {recursive: true});
|
|
22
|
+
|
|
23
|
+
const require = createRequire(join(STUDIO, 'package.json'));
|
|
24
|
+
const esbuild = require('esbuild');
|
|
25
|
+
const {FFMPEG} = await import(pathToFileURL(join(STUDIO, 'scripts', 'lib', 'media.mjs')).href);
|
|
26
|
+
|
|
27
|
+
/* ----------------------------------------------------------------------------- which video */
|
|
28
|
+
|
|
29
|
+
/**
|
|
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.
|
|
32
|
+
*/
|
|
33
|
+
export function configFor(name) {
|
|
34
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name ?? '')) throw new Error(`"${name}" is not a video name (lowercase letters, digits, dashes)`);
|
|
35
|
+
const code = join(SRC, 'videos', name);
|
|
36
|
+
if (!existsSync(join(code, 'preview.tsx'))) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`No src/videos/${name}/preview.tsx. Write it first: export Comp (the edit + its voice as <Audio src={staticFile('__stems/voice.mp3')} />), META and BEATS — copy src/videos/noufal/preview.tsx.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const source = readdirSync(code)
|
|
42
|
+
.filter((f) => /\.(tsx?|json)$/.test(f))
|
|
43
|
+
.map((f) => readFileSync(join(code, f), 'utf8'))
|
|
44
|
+
.join('\n');
|
|
45
|
+
const folders = new Set(['fonts']);
|
|
46
|
+
// Only the fonts this edit names — the studio has many, and each one is ~1 MB in the page.
|
|
47
|
+
const fonts = [...new Set([...source.matchAll(/fonts\/([\w.-]+\.(?:ttf|otf|woff2?))/g)].map((m) => m[1]))];
|
|
48
|
+
// The whole folder path up to the file name — cutouts live a level deeper (cutouts/<shot>/f123.png).
|
|
49
|
+
for (const m of source.matchAll(new RegExp(`projects/${name}/((?:[\\w-]+/)+)`, 'g'))) folders.add(`projects/${name}/${m[1].replace(/\/$/, '')}`);
|
|
50
|
+
for (const f of folders) if (!existsSync(join(PUBLIC, f))) folders.delete(f);
|
|
51
|
+
|
|
52
|
+
const stems = {};
|
|
53
|
+
if (existsSync(join(STUDIO, 'work', name, 'voice.wav'))) stems.voice = `work/${name}/voice.wav`;
|
|
54
|
+
const out = join(STUDIO, 'out', name);
|
|
55
|
+
if (existsSync(out))
|
|
56
|
+
for (const f of readdirSync(out)) {
|
|
57
|
+
const m = f.match(/^stem-(\w+)\.wav$/);
|
|
58
|
+
if (m) stems[m[1]] = `out/${name}/${f}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The edit is the file named *Edit.tsx; its composition id is that name (as registered in Root.tsx).
|
|
62
|
+
const editFile = readdirSync(code).find((f) => /Edit\.tsx$/.test(f));
|
|
63
|
+
return {
|
|
64
|
+
project: name,
|
|
65
|
+
preview: `src/videos/${name}/preview.tsx`,
|
|
66
|
+
edit: `src/videos/${name}/${editFile ?? 'preview.tsx'}`,
|
|
67
|
+
composition: editFile?.replace(/\.tsx$/, '') ?? null,
|
|
68
|
+
assets: [...folders],
|
|
69
|
+
fonts,
|
|
70
|
+
stems,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ----------------------------------------------------------------------------- the footage */
|
|
75
|
+
|
|
76
|
+
/** Every file the edit asks for by name (staticFile), plus the stems. Keys are the names the edit uses. */
|
|
77
|
+
function assetList(c) {
|
|
78
|
+
const files = {};
|
|
79
|
+
const addDir = (rel) => {
|
|
80
|
+
const dir = join(PUBLIC, rel);
|
|
81
|
+
if (!existsSync(dir)) return;
|
|
82
|
+
for (const f of readdirSync(dir)) {
|
|
83
|
+
if (rel === 'fonts' && c.fonts && !c.fonts.includes(f)) continue;
|
|
84
|
+
if (statSync(join(dir, f)).isFile()) files[`${rel}/${f}`] = join(dir, f);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
for (const d of c.assets) addDir(d);
|
|
88
|
+
for (const [k, f] of Object.entries(c.stems ?? {})) if (existsSync(join(STUDIO, f))) files[`__stems/${k}.mp3`] = join(STUDIO, f);
|
|
89
|
+
return Object.fromEntries(Object.entries(files).filter(([k]) => /\.(mp4|mov|png|jpe?g|webp|svg|ttf|woff2?|mp3)$/i.test(k)));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Preview copies: small enough to travel inside the player page, good enough to judge the edit in a chat-
|
|
94
|
+
* sized player. Anything fits a 540px box on its longest side. The final render always uses the originals.
|
|
95
|
+
*/
|
|
96
|
+
const FIT = "scale='if(gt(iw,ih),min(540,iw),-2)':'if(gt(iw,ih),-2,min(540,ih))'";
|
|
97
|
+
const PROXY = {
|
|
98
|
+
video: ['-vf', FIT, '-an', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '31', '-g', '15', '-pix_fmt', 'yuv420p', '-movflags', '+faststart'],
|
|
99
|
+
image: ['-vf', FIT, '-c:v', 'libwebp', '-quality', '72'],
|
|
100
|
+
audio: ['-vn', '-ac', '2', '-ar', '44100', '-c:a', 'libmp3lame', '-b:a', '128k'],
|
|
101
|
+
};
|
|
102
|
+
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'};
|
|
103
|
+
|
|
104
|
+
function proxyOf(name, original) {
|
|
105
|
+
const st = statSync(original);
|
|
106
|
+
// The settings are part of the key, so changing them makes new copies instead of reusing old ones.
|
|
107
|
+
const key = createHash('sha1').update(`${original}:${st.size}:${st.mtimeMs}:${JSON.stringify(PROXY)}`).digest('hex').slice(0, 16);
|
|
108
|
+
const ext = extname(name).toLowerCase();
|
|
109
|
+
const kind = /\.(mp4|mov)$/.test(ext) ? 'video' : /\.(png|jpe?g)$/.test(ext) ? 'image' : ext === '.mp3' ? 'audio' : null;
|
|
110
|
+
if (!kind) return {file: original, mime: MIME[ext] ?? 'application/octet-stream'};
|
|
111
|
+
const outExt = kind === 'video' ? '.mp4' : kind === 'image' ? '.webp' : '.mp3';
|
|
112
|
+
const out = join(CACHE, `${key}${outExt}`);
|
|
113
|
+
if (!existsSync(out)) {
|
|
114
|
+
const r = spawnSync(FFMPEG, ['-y', '-v', 'error', '-i', original, ...PROXY[kind], out], {encoding: 'utf8'});
|
|
115
|
+
if (r.status !== 0) throw new Error(`could not make a preview copy of ${name}: ${r.stderr}`);
|
|
116
|
+
}
|
|
117
|
+
return {file: out, mime: MIME[outExt]};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** [{name, file, mime, size, hash}] — hash is of the preview copy, so unchanged files are never re-sent. */
|
|
121
|
+
export function buildManifest(c) {
|
|
122
|
+
return Object.entries(assetList(c)).map(([name, original]) => {
|
|
123
|
+
const p = proxyOf(name, original);
|
|
124
|
+
const hash = createHash('sha256').update(readFileSync(p.file)).digest('hex').slice(0, 32);
|
|
125
|
+
return {name, file: p.file, mime: p.mime, size: statSync(p.file).size, hash};
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ----------------------------------------------------------------------------- the edit, compiled */
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The edit runs inside the player, so it must use the player's own React and Remotion — two copies
|
|
133
|
+
* would break every hook. Those imports become reads of window.__shared.
|
|
134
|
+
*/
|
|
135
|
+
const SHARED = ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'remotion'];
|
|
136
|
+
const sharedPlugin = {
|
|
137
|
+
name: 'shared',
|
|
138
|
+
setup(b) {
|
|
139
|
+
b.onResolve({filter: new RegExp(`^(${SHARED.map((s) => s.replace('/', '\\/')).join('|')})$`)}, (a) => ({path: a.path, namespace: 'shared'}));
|
|
140
|
+
b.onLoad({filter: /.*/, namespace: 'shared'}, (a) => {
|
|
141
|
+
const keys = Object.keys(require(a.path)).filter((k) => /^[A-Za-z_$][\w$]*$/.test(k) && k !== 'default');
|
|
142
|
+
return {
|
|
143
|
+
contents: `const m = window.__shared[${JSON.stringify(a.path)}];\nexport default m;\n${keys.map((k) => `export const ${k} = m.${k};`).join('\n')}`,
|
|
144
|
+
loader: 'js',
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const common = {bundle: true, jsx: 'automatic', logLevel: 'silent', loader: {'.png': 'dataurl', '.svg': 'dataurl'}};
|
|
151
|
+
|
|
152
|
+
/** The edit as one script that sets window.__lpComp. Throws with a readable message if it doesn't compile. */
|
|
153
|
+
export async function compileEdit(c) {
|
|
154
|
+
try {
|
|
155
|
+
const r = await esbuild.build({...common, entryPoints: [join(STUDIO, c.preview)], format: 'iife', globalName: '__lpComp', write: false, plugins: [sharedPlugin], define: {'process.env.NODE_ENV': '"development"'}});
|
|
156
|
+
return r.outputFiles[0].text;
|
|
157
|
+
} catch (e) {
|
|
158
|
+
throw new Error((e.errors ?? []).map((x) => `${x.location?.file ?? ''}:${x.location?.line ?? ''} ${x.text}`).join('\n') || String(e));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The player page. Customers get it pre-built (player.html, made when the studio ships), so they need none of
|
|
164
|
+
* the player's build tools; in the source studio it is built from ui/app.tsx.
|
|
165
|
+
*/
|
|
166
|
+
export async function playerHtml() {
|
|
167
|
+
const built = join(HERE, 'player.html');
|
|
168
|
+
if (existsSync(built) && !existsSync(join(HERE, 'ui', 'app.tsx'))) return readFileSync(built, 'utf8');
|
|
169
|
+
return buildUi();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function buildUi() {
|
|
173
|
+
const r = await esbuild.build({...common, entryPoints: [join(HERE, 'ui', 'app.tsx')], format: 'iife', write: false, minify: true, define: {'process.env.NODE_ENV': '"production"'}});
|
|
174
|
+
const js = r.outputFiles[0].text;
|
|
175
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
176
|
+
<body><div id="root"></div><script>${js.replace(/<\/script>/g, '<\\/script>')}</script></body></html>`;
|
|
177
|
+
}
|