creator-editing-studio 1.3.5 → 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/bin/init.mjs +18 -1
- package/bin/update.mjs +146 -0
- package/history.json +1 -0
- package/package.json +2 -1
- package/template/CLAUDE.md +292 -236
- package/template/STYLE.md +179 -179
- 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 +27 -23
- package/template/scripts/align-script.py +138 -0
- package/template/scripts/check_pair.py +90 -90
- package/template/scripts/doctor.mjs +167 -141
- package/template/scripts/find_cuts.py +45 -45
- package/template/scripts/lib/languages.py +68 -0
- package/template/scripts/lib/media.mjs +228 -228
- 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/takes.mjs +424 -424
- package/template/scripts/track_head.py +157 -0
- package/template/scripts/transcribe.py +172 -103
- package/template/src/components/CommandCard.tsx +12 -11
- package/template/src/components/Cursor.tsx +153 -153
- package/template/src/components/HeadPin.tsx +42 -0
- package/template/src/compositions/BeatTest.tsx +16 -15
- package/template/src/lib/track.ts +67 -0
- package/template/src/lib/tweaks.tsx +98 -0
- package/template/templates/BRIEF.md +97 -97
- package/template/templates/STYLE.md +179 -179
|
@@ -1,141 +1,167 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Does this machine actually have everything the studio claims to do?
|
|
4
|
-
*
|
|
5
|
-
* npm run doctor
|
|
6
|
-
*
|
|
7
|
-
* This exists because a working setup went quiet. WhisperX had been installed and used
|
|
8
|
-
* (there are words.json files from a fortnight earlier), then the Python environment
|
|
9
|
-
* underneath it was emptied, and nothing noticed until a job needed a transcript. The
|
|
10
|
-
* code was fine the whole time. The environment was not, and there was no way to tell
|
|
11
|
-
* except by running something and watching it fail.
|
|
12
|
-
*
|
|
13
|
-
* So this checks CAPABILITIES rather than files: it runs the binaries, imports the
|
|
14
|
-
* modules, and reports what you can and cannot do right now. A capability that is
|
|
15
|
-
* missing is reported with the command that fixes it, because "not installed" with no
|
|
16
|
-
* next step is what turns a five-minute problem into a phone call.
|
|
17
|
-
*
|
|
18
|
-
* Exit code is 0 when everything the studio needs for a render is present, 1 when
|
|
19
|
-
* something core is broken. Optional pieces report but never fail the run.
|
|
20
|
-
*/
|
|
21
|
-
import {execFileSync} from 'node:child_process';
|
|
22
|
-
import {existsSync, readdirSync} from 'node:fs';
|
|
23
|
-
import {join, dirname, resolve} from 'node:path';
|
|
24
|
-
import {fileURLToPath} from 'node:url';
|
|
25
|
-
|
|
26
|
-
// fileURLToPath, not URL.pathname: a path with a space in it comes back percent-encoded
|
|
27
|
-
// from pathname, so every existsSync below silently answers false and the report claims
|
|
28
|
-
// a working studio is broken.
|
|
29
|
-
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
-
const VENV = process.platform === 'win32'
|
|
31
|
-
? join(ROOT, '.venv', 'Scripts', 'python.exe')
|
|
32
|
-
: join(ROOT, '.venv', 'bin', 'python');
|
|
33
|
-
|
|
34
|
-
const results = [];
|
|
35
|
-
const record = (area, name, ok, detail, fix, core = true) =>
|
|
36
|
-
results.push({area, name, ok, detail, fix, core});
|
|
37
|
-
|
|
38
|
-
const tryRun = (bin, args, pick = (s) => s.trim().split('\n')[0]) => {
|
|
39
|
-
try {
|
|
40
|
-
return {ok: true, out: pick(execFileSync(bin, args, {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}))};
|
|
41
|
-
} catch (e) {
|
|
42
|
-
return {ok: false, out: (e.stderr || e.message || '').toString().trim().split('\n')[0]};
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
/* ------------------------------------------------------------------ the engine */
|
|
47
|
-
const node = process.versions.node;
|
|
48
|
-
const nodeMajor = Number(node.split('.')[0]);
|
|
49
|
-
record('engine', 'Node', nodeMajor >= 20, `v${node}`, 'Install the LTS build from nodejs.org');
|
|
50
|
-
|
|
51
|
-
const hasModules = existsSync(join(ROOT, 'node_modules', 'remotion'));
|
|
52
|
-
const pkgCount = existsSync(join(ROOT, 'node_modules')) ? readdirSync(join(ROOT, 'node_modules')).length : 0;
|
|
53
|
-
record('engine', 'Video engine', hasModules, hasModules ? `${pkgCount} packages` : 'node_modules/remotion missing', 'npm install');
|
|
54
|
-
|
|
55
|
-
/* ------------------------------------------------------------- media handling */
|
|
56
|
-
let FFMPEG = 'ffmpeg', FFPROBE = 'ffprobe';
|
|
57
|
-
try {
|
|
58
|
-
const media = await import('./lib/media.mjs');
|
|
59
|
-
FFMPEG = media.FFMPEG;
|
|
60
|
-
FFPROBE = media.FFPROBE;
|
|
61
|
-
} catch { /* fall back to PATH and let the run below report it */ }
|
|
62
|
-
|
|
63
|
-
for (const [name, bin] of [['ffmpeg', FFMPEG], ['ffprobe', FFPROBE]]) {
|
|
64
|
-
const r = tryRun(bin, ['-version']);
|
|
65
|
-
record('media', name, r.ok, r.ok ? r.out.split(' ').slice(0, 3).join(' ') : 'not runnable',
|
|
66
|
-
'Set $FFMPEG / $FFPROBE, or install ffmpeg so it is on PATH');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/* ------------------------------------------------------ the transcriber (optional) */
|
|
70
|
-
const venvOk = existsSync(VENV);
|
|
71
|
-
record('transcriber', 'Python 3.11/3.12 venv', venvOk, venvOk ? VENV.replace(ROOT, '.') : '.venv missing',
|
|
72
|
-
'py -3.12 -m venv .venv (3.13+ will not build WhisperX)', false);
|
|
73
|
-
|
|
74
|
-
if (venvOk) {
|
|
75
|
-
const ver = tryRun(VENV, ['-c', 'import sys;print(sys.version.split()[0])']);
|
|
76
|
-
const minor = Number((ver.out || '0.0').split('.')[1]);
|
|
77
|
-
record('transcriber', 'Python version', ver.ok && (minor === 11 || minor === 12),
|
|
78
|
-
ver.out, 'Rebuild the venv with python 3.11 or 3.12', false);
|
|
79
|
-
|
|
80
|
-
// Every module the Python half imports, not just the headline one.
|
|
81
|
-
for (const mod of ['whisperx', 'torch', 'numpy', 'PIL', 'certifi']) {
|
|
82
|
-
const r = tryRun(VENV, ['-c', `import ${mod}`], () => 'ok');
|
|
83
|
-
record('transcriber', mod, r.ok, r.ok ? 'ok' : 'not importable',
|
|
84
|
-
'.venv/Scripts/pip install -r requirements.txt', false);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/*
|
|
89
|
-
if (venvOk) {
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Does this machine actually have everything the studio claims to do?
|
|
4
|
+
*
|
|
5
|
+
* npm run doctor
|
|
6
|
+
*
|
|
7
|
+
* This exists because a working setup went quiet. WhisperX had been installed and used
|
|
8
|
+
* (there are words.json files from a fortnight earlier), then the Python environment
|
|
9
|
+
* underneath it was emptied, and nothing noticed until a job needed a transcript. The
|
|
10
|
+
* code was fine the whole time. The environment was not, and there was no way to tell
|
|
11
|
+
* except by running something and watching it fail.
|
|
12
|
+
*
|
|
13
|
+
* So this checks CAPABILITIES rather than files: it runs the binaries, imports the
|
|
14
|
+
* modules, and reports what you can and cannot do right now. A capability that is
|
|
15
|
+
* missing is reported with the command that fixes it, because "not installed" with no
|
|
16
|
+
* next step is what turns a five-minute problem into a phone call.
|
|
17
|
+
*
|
|
18
|
+
* Exit code is 0 when everything the studio needs for a render is present, 1 when
|
|
19
|
+
* something core is broken. Optional pieces report but never fail the run.
|
|
20
|
+
*/
|
|
21
|
+
import {execFileSync} from 'node:child_process';
|
|
22
|
+
import {existsSync, readdirSync, readFileSync} from 'node:fs';
|
|
23
|
+
import {join, dirname, resolve} from 'node:path';
|
|
24
|
+
import {fileURLToPath} from 'node:url';
|
|
25
|
+
|
|
26
|
+
// fileURLToPath, not URL.pathname: a path with a space in it comes back percent-encoded
|
|
27
|
+
// from pathname, so every existsSync below silently answers false and the report claims
|
|
28
|
+
// a working studio is broken.
|
|
29
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
const VENV = process.platform === 'win32'
|
|
31
|
+
? join(ROOT, '.venv', 'Scripts', 'python.exe')
|
|
32
|
+
: join(ROOT, '.venv', 'bin', 'python');
|
|
33
|
+
|
|
34
|
+
const results = [];
|
|
35
|
+
const record = (area, name, ok, detail, fix, core = true) =>
|
|
36
|
+
results.push({area, name, ok, detail, fix, core});
|
|
37
|
+
|
|
38
|
+
const tryRun = (bin, args, pick = (s) => s.trim().split('\n')[0]) => {
|
|
39
|
+
try {
|
|
40
|
+
return {ok: true, out: pick(execFileSync(bin, args, {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}))};
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return {ok: false, out: (e.stderr || e.message || '').toString().trim().split('\n')[0]};
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/* ------------------------------------------------------------------ the engine */
|
|
47
|
+
const node = process.versions.node;
|
|
48
|
+
const nodeMajor = Number(node.split('.')[0]);
|
|
49
|
+
record('engine', 'Node', nodeMajor >= 20, `v${node}`, 'Install the LTS build from nodejs.org');
|
|
50
|
+
|
|
51
|
+
const hasModules = existsSync(join(ROOT, 'node_modules', 'remotion'));
|
|
52
|
+
const pkgCount = existsSync(join(ROOT, 'node_modules')) ? readdirSync(join(ROOT, 'node_modules')).length : 0;
|
|
53
|
+
record('engine', 'Video engine', hasModules, hasModules ? `${pkgCount} packages` : 'node_modules/remotion missing', 'npm install');
|
|
54
|
+
|
|
55
|
+
/* ------------------------------------------------------------- media handling */
|
|
56
|
+
let FFMPEG = 'ffmpeg', FFPROBE = 'ffprobe';
|
|
57
|
+
try {
|
|
58
|
+
const media = await import('./lib/media.mjs');
|
|
59
|
+
FFMPEG = media.FFMPEG;
|
|
60
|
+
FFPROBE = media.FFPROBE;
|
|
61
|
+
} catch { /* fall back to PATH and let the run below report it */ }
|
|
62
|
+
|
|
63
|
+
for (const [name, bin] of [['ffmpeg', FFMPEG], ['ffprobe', FFPROBE]]) {
|
|
64
|
+
const r = tryRun(bin, ['-version']);
|
|
65
|
+
record('media', name, r.ok, r.ok ? r.out.split(' ').slice(0, 3).join(' ') : 'not runnable',
|
|
66
|
+
'Set $FFMPEG / $FFPROBE, or install ffmpeg so it is on PATH');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ------------------------------------------------------ the transcriber (optional) */
|
|
70
|
+
const venvOk = existsSync(VENV);
|
|
71
|
+
record('transcriber', 'Python 3.11/3.12 venv', venvOk, venvOk ? VENV.replace(ROOT, '.') : '.venv missing',
|
|
72
|
+
'py -3.12 -m venv .venv (3.13+ will not build WhisperX)', false);
|
|
73
|
+
|
|
74
|
+
if (venvOk) {
|
|
75
|
+
const ver = tryRun(VENV, ['-c', 'import sys;print(sys.version.split()[0])']);
|
|
76
|
+
const minor = Number((ver.out || '0.0').split('.')[1]);
|
|
77
|
+
record('transcriber', 'Python version', ver.ok && (minor === 11 || minor === 12),
|
|
78
|
+
ver.out, 'Rebuild the venv with python 3.11 or 3.12', false);
|
|
79
|
+
|
|
80
|
+
// Every module the Python half imports, not just the headline one.
|
|
81
|
+
for (const mod of ['whisperx', 'torch', 'numpy', 'PIL', 'certifi']) {
|
|
82
|
+
const r = tryRun(VENV, ['-c', `import ${mod}`], () => 'ok');
|
|
83
|
+
record('transcriber', mod, r.ok, r.ok ? 'ok' : 'not importable',
|
|
84
|
+
'.venv/Scripts/pip install -r requirements.txt', false);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* ------------------------------------------- language models and motion tracking (setup) */
|
|
89
|
+
if (venvOk) {
|
|
90
|
+
// The language chosen at setup decides which models must already be here (scripts/setup_models.py).
|
|
91
|
+
// Missing ones are not an error until a video needs them — but a big download mid-edit is the thing setup
|
|
92
|
+
// exists to prevent, so say so now.
|
|
93
|
+
let language = null;
|
|
94
|
+
try {
|
|
95
|
+
language = JSON.parse(readFileSync(join(ROOT, 'studio.config.json'), 'utf8')).language;
|
|
96
|
+
} catch {}
|
|
97
|
+
record('languages', 'Video language', !!language, language ? language : 'not chosen yet',
|
|
98
|
+
'.venv/Scripts/python scripts/setup_models.py --language <code> (en hi hinglish te ml ta kn mr bn …)', false);
|
|
99
|
+
const hub = join(ROOT, '.models', 'huggingface', 'hub');
|
|
100
|
+
const cached = existsSync(hub) ? readdirSync(hub) : [];
|
|
101
|
+
if (language) {
|
|
102
|
+
const want = language === 'en' ? 'small' : 'large-v3';
|
|
103
|
+
const has = cached.some((d) => d.includes(`faster-whisper-${want}`));
|
|
104
|
+
record('languages', `Transcriber (${want})`, has, has ? 'downloaded' : 'not downloaded yet',
|
|
105
|
+
`.venv/Scripts/python scripts/setup_models.py --language ${language}`, false);
|
|
106
|
+
}
|
|
107
|
+
const cv = tryRun(VENV, ['-c', 'import cv2'], () => 'ok');
|
|
108
|
+
const seg = cached.some((d) => d.includes('BiRefNet'));
|
|
109
|
+
record('tracking', 'Motion tracking', cv.ok && seg,
|
|
110
|
+
cv.ok ? (seg ? 'ready' : 'person-finding model not downloaded') : 'OpenCV not installed',
|
|
111
|
+
cv.ok ? '.venv/Scripts/python scripts/setup_models.py --language <code>' : '.venv/Scripts/pip install -r requirements.txt', false);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* ------------------------------------------------- the python half, script by script */
|
|
115
|
+
if (venvOk) {
|
|
116
|
+
// Importing whisperx is not the same as the scripts running. Compile each one, which
|
|
117
|
+
// catches a missing dependency or a syntax error without doing any of the slow work.
|
|
118
|
+
const py = readdirSync(join(ROOT, 'scripts')).filter((f) => f.endsWith('.py'));
|
|
119
|
+
const bad = [];
|
|
120
|
+
for (const f of py) {
|
|
121
|
+
const r = tryRun(VENV, ['-m', 'py_compile', join(ROOT, 'scripts', f)], () => 'ok');
|
|
122
|
+
if (!r.ok) bad.push(f);
|
|
123
|
+
}
|
|
124
|
+
record('transcriber', 'Python scripts', bad.length === 0,
|
|
125
|
+
bad.length ? `${bad.join(', ')} will not compile` : `${py.length} compile clean`,
|
|
126
|
+
'.venv/Scripts/pip install -r requirements.txt', false);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ----------------------------------------------------------------------- brand */
|
|
130
|
+
const tokens = existsSync(join(ROOT, 'src', 'design', 'tokens.ts'));
|
|
131
|
+
record('brand', 'Design tokens', tokens, tokens ? 'src/design/tokens.ts' : 'missing',
|
|
132
|
+
'Restore src/design/tokens.ts, which is where the whole look lives');
|
|
133
|
+
|
|
134
|
+
// A missing font file does not fail a render: it silently swaps in a fallback, and the
|
|
135
|
+
// video comes out looking almost right, which is worse than an error.
|
|
136
|
+
const fontDir = join(ROOT, 'assets', 'fonts');
|
|
137
|
+
const fonts = existsSync(fontDir) ? readdirSync(fontDir).filter((f) => /\.(ttf|otf|woff2?)$/i.test(f)) : [];
|
|
138
|
+
record('brand', 'Fonts', fonts.length > 0,
|
|
139
|
+
fonts.length ? `${fonts.length} file(s): ${fonts.slice(0, 2).join(', ')}` : 'assets/fonts is empty',
|
|
140
|
+
'Put the brand font files back in assets/fonts, or a render falls back to a system face');
|
|
141
|
+
|
|
142
|
+
/* --------------------------------------------------------------------- report */
|
|
143
|
+
const W = 22;
|
|
144
|
+
let lastArea = '';
|
|
145
|
+
for (const r of results) {
|
|
146
|
+
if (r.area !== lastArea) { console.log(`\n${r.area.toUpperCase()}`); lastArea = r.area; }
|
|
147
|
+
const mark = r.ok ? ' ok ' : (r.core ? ' FAIL ' : ' -- ');
|
|
148
|
+
console.log(`${mark} ${r.name.padEnd(W)} ${r.detail}`);
|
|
149
|
+
if (!r.ok) console.log(` ${''.padEnd(W)} fix: ${r.fix}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const brokenCore = results.filter((r) => !r.ok && r.core);
|
|
153
|
+
const brokenOpt = results.filter((r) => !r.ok && !r.core);
|
|
154
|
+
|
|
155
|
+
console.log('\n' + '-'.repeat(64));
|
|
156
|
+
if (!brokenCore.length && !brokenOpt.length) {
|
|
157
|
+
console.log('Everything is present. You can cut, render and caption.');
|
|
158
|
+
} else if (!brokenCore.length) {
|
|
159
|
+
console.log(`Rendering works. ${brokenOpt.length} optional piece(s) missing:`);
|
|
160
|
+
console.log(` ${[...new Set(brokenOpt.map((r) => r.fix))].join('\n ')}`);
|
|
161
|
+
console.log('Without these: no word-level captions, no auto cut detection, no green-screen cutouts.');
|
|
162
|
+
} else {
|
|
163
|
+
console.log(`${brokenCore.length} thing(s) needed for a render are missing. Fix these first:`);
|
|
164
|
+
console.log(` ${[...new Set(brokenCore.map((r) => r.fix))].join('\n ')}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
process.exit(brokenCore.length ? 1 : 0);
|
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
"""Find jump cuts in a talking-head clip (frames where the picture jumps instead of moving).
|
|
2
|
-
|
|
3
|
-
Usage (from the project root):
|
|
4
|
-
python scripts/find_cuts.py <video> <out.json>
|
|
5
|
-
|
|
6
|
-
Writes a JSON array of frame numbers: each is the first frame AFTER a cut. Used to
|
|
7
|
-
- punch in/out on each cut (the Stage `punchIns` prop), so cuts read as intentional camera changes
|
|
8
|
-
- tell motion-check these changes are in the footage (--allow-file)
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
import json
|
|
12
|
-
import subprocess
|
|
13
|
-
import sys
|
|
14
|
-
|
|
15
|
-
import numpy as np
|
|
16
|
-
import sys as _sys, pathlib as _pl
|
|
17
|
-
_sys.path.insert(0, str(_pl.Path(__file__).resolve().parent / 'lib'))
|
|
18
|
-
from media import FFMPEG, FFPROBE # resolved per platform - never hardcode a tool path
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def main() -> None:
|
|
23
|
-
video, out_path = sys.argv[1], sys.argv[2]
|
|
24
|
-
raw = subprocess.run(
|
|
25
|
-
[FFMPEG, "-v", "error", "-i", video, "-vf", "scale=320:180", "-f", "rawvideo", "-pix_fmt", "gray", "-"],
|
|
26
|
-
capture_output=True,
|
|
27
|
-
check=True,
|
|
28
|
-
).stdout
|
|
29
|
-
frames = np.frombuffer(raw, np.uint8).reshape(-1, 180, 320).astype(np.float32)
|
|
30
|
-
change = np.abs(np.diff(frames, axis=0)).mean(axis=(1, 2))
|
|
31
|
-
typical = float(np.median(change))
|
|
32
|
-
|
|
33
|
-
cuts = []
|
|
34
|
-
for i in range(2, len(change) - 2):
|
|
35
|
-
neighbours = float(np.median(np.concatenate([change[i - 2 : i], change[i + 1 : i + 3]])))
|
|
36
|
-
# A cut: one transition far larger than both the clip's typical motion and its neighbours.
|
|
37
|
-
if change[i] > 6 and change[i] > 4 * max(neighbours, typical):
|
|
38
|
-
cuts.append(i + 1)
|
|
39
|
-
|
|
40
|
-
json.dump(cuts, open(out_path, "w"), indent=0)
|
|
41
|
-
print(f"{len(cuts)} cuts -> {out_path}: {cuts}")
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if __name__ == "__main__":
|
|
45
|
-
main()
|
|
1
|
+
"""Find jump cuts in a talking-head clip (frames where the picture jumps instead of moving).
|
|
2
|
+
|
|
3
|
+
Usage (from the project root):
|
|
4
|
+
python scripts/find_cuts.py <video> <out.json>
|
|
5
|
+
|
|
6
|
+
Writes a JSON array of frame numbers: each is the first frame AFTER a cut. Used to
|
|
7
|
+
- punch in/out on each cut (the Stage `punchIns` prop), so cuts read as intentional camera changes
|
|
8
|
+
- tell motion-check these changes are in the footage (--allow-file)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import sys as _sys, pathlib as _pl
|
|
17
|
+
_sys.path.insert(0, str(_pl.Path(__file__).resolve().parent / 'lib'))
|
|
18
|
+
from media import FFMPEG, FFPROBE # resolved per platform - never hardcode a tool path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> None:
|
|
23
|
+
video, out_path = sys.argv[1], sys.argv[2]
|
|
24
|
+
raw = subprocess.run(
|
|
25
|
+
[FFMPEG, "-v", "error", "-i", video, "-vf", "scale=320:180", "-f", "rawvideo", "-pix_fmt", "gray", "-"],
|
|
26
|
+
capture_output=True,
|
|
27
|
+
check=True,
|
|
28
|
+
).stdout
|
|
29
|
+
frames = np.frombuffer(raw, np.uint8).reshape(-1, 180, 320).astype(np.float32)
|
|
30
|
+
change = np.abs(np.diff(frames, axis=0)).mean(axis=(1, 2))
|
|
31
|
+
typical = float(np.median(change))
|
|
32
|
+
|
|
33
|
+
cuts = []
|
|
34
|
+
for i in range(2, len(change) - 2):
|
|
35
|
+
neighbours = float(np.median(np.concatenate([change[i - 2 : i], change[i + 1 : i + 3]])))
|
|
36
|
+
# A cut: one transition far larger than both the clip's typical motion and its neighbours.
|
|
37
|
+
if change[i] > 6 and change[i] > 4 * max(neighbours, typical):
|
|
38
|
+
cuts.append(i + 1)
|
|
39
|
+
|
|
40
|
+
json.dump(cuts, open(out_path, "w"), indent=0)
|
|
41
|
+
print(f"{len(cuts)} cuts -> {out_path}: {cuts}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
main()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The languages the studio is set up for, and what each one needs.
|
|
2
|
+
|
|
3
|
+
One table, read by setup (scripts/setup_models.py), the transcriber and the script aligner, so they can
|
|
4
|
+
never disagree about which model a language uses.
|
|
5
|
+
|
|
6
|
+
whisper the transcription model. "small" is fine for English; Indian languages need "large-v3" (on a
|
|
7
|
+
Malayalam take the small model ignored the speech and invented English).
|
|
8
|
+
align the word-timing model: None = WhisperX's built-in one for that language; a name = a public
|
|
9
|
+
wav2vec2 model for languages WhisperX does not cover; False = none exists, use the transcriber's
|
|
10
|
+
own (looser) timings.
|
|
11
|
+
tested True where that combination has been run end to end on real footage. Untested ones are offered
|
|
12
|
+
as "word timing: experimental" and fall back to the transcriber's timings if they fail.
|
|
13
|
+
|
|
14
|
+
The person's choice is saved in studio.config.json at the studio root: {"language": "ml"}.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
LANGS = {
|
|
21
|
+
"en": {"name": "English", "whisper": "small", "align": None, "tested": True},
|
|
22
|
+
"hi": {"name": "Hindi", "whisper": "large-v3", "align": None, "tested": False},
|
|
23
|
+
# Hinglish is transcribed as Hindi (Whisper writes it in Devanagari) — for captions in English letters,
|
|
24
|
+
# share the script: aligning a known script is how code-mixed speech comes out right.
|
|
25
|
+
"hinglish": {"name": "Hinglish", "whisper": "large-v3", "align": None, "code": "hi", "tested": False},
|
|
26
|
+
"te": {"name": "Telugu", "whisper": "large-v3", "align": None, "tested": False},
|
|
27
|
+
"ml": {"name": "Malayalam", "whisper": "large-v3", "align": None, "tested": True},
|
|
28
|
+
"ur": {"name": "Urdu", "whisper": "large-v3", "align": None, "tested": False},
|
|
29
|
+
"ta": {"name": "Tamil", "whisper": "large-v3", "align": "Harveenchadha/vakyansh-wav2vec2-tamil-tam-250", "tested": False},
|
|
30
|
+
"kn": {"name": "Kannada", "whisper": "large-v3", "align": "Harveenchadha/vakyansh-wav2vec2-kannada-knm-560", "tested": False},
|
|
31
|
+
"mr": {"name": "Marathi", "whisper": "large-v3", "align": "Harveenchadha/vakyansh-wav2vec2-marathi-mrm-100", "tested": False},
|
|
32
|
+
"bn": {"name": "Bengali", "whisper": "large-v3", "align": "arijitx/wav2vec2-xls-r-300m-bengali", "tested": False},
|
|
33
|
+
"gu": {"name": "Gujarati", "whisper": "large-v3", "align": "Harveenchadha/vakyansh-wav2vec2-gujarati-gnm-100", "tested": False},
|
|
34
|
+
"pa": {"name": "Punjabi", "whisper": "large-v3", "align": "Harveenchadha/vakyansh-wav2vec2-punjabi-pam-10", "tested": False},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
CONFIG = "studio.config.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def lang(code: str | None) -> dict:
|
|
41
|
+
"""What a language needs. Any other language: the large model, WhisperX's aligner if it has one."""
|
|
42
|
+
code = (code or "en").strip().lower()
|
|
43
|
+
info = dict(LANGS.get(code) or {"name": code, "whisper": "large-v3", "align": None, "tested": False})
|
|
44
|
+
info.setdefault("code", code)
|
|
45
|
+
return info
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def studio_language(root: Path | None = None) -> str | None:
|
|
49
|
+
"""The language chosen at setup, if any."""
|
|
50
|
+
path = (root or Path.cwd()) / CONFIG
|
|
51
|
+
try:
|
|
52
|
+
return json.loads(path.read_text(encoding="utf-8")).get("language")
|
|
53
|
+
except (OSError, ValueError):
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_aligner(whisperx, code: str):
|
|
58
|
+
"""The word-timing model for a language, or (None, None) when there is none / it cannot load."""
|
|
59
|
+
info = lang(code)
|
|
60
|
+
if info["align"] is False:
|
|
61
|
+
return None, None
|
|
62
|
+
try:
|
|
63
|
+
if info["align"]:
|
|
64
|
+
return whisperx.load_align_model(language_code=info["code"], device="cpu", model_name=info["align"])
|
|
65
|
+
return whisperx.load_align_model(language_code=info["code"], device="cpu")
|
|
66
|
+
except Exception as exc: # noqa: BLE001 — any failure here means "no aligner", not a crash
|
|
67
|
+
print(f"note: no word-timing model for {info['name']} ({exc.__class__.__name__}); using the transcriber's own timings.")
|
|
68
|
+
return None, None
|