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.
@@ -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
- /* ------------------------------------------------- the python half, script by script */
89
- if (venvOk) {
90
- // Importing whisperx is not the same as the scripts running. Compile each one, which
91
- // catches a missing dependency or a syntax error without doing any of the slow work.
92
- const py = readdirSync(join(ROOT, 'scripts')).filter((f) => f.endsWith('.py'));
93
- const bad = [];
94
- for (const f of py) {
95
- const r = tryRun(VENV, ['-m', 'py_compile', join(ROOT, 'scripts', f)], () => 'ok');
96
- if (!r.ok) bad.push(f);
97
- }
98
- record('transcriber', 'Python scripts', bad.length === 0,
99
- bad.length ? `${bad.join(', ')} will not compile` : `${py.length} compile clean`,
100
- '.venv/Scripts/pip install -r requirements.txt', false);
101
- }
102
-
103
- /* ----------------------------------------------------------------------- brand */
104
- const tokens = existsSync(join(ROOT, 'src', 'design', 'tokens.ts'));
105
- record('brand', 'Design tokens', tokens, tokens ? 'src/design/tokens.ts' : 'missing',
106
- 'Restore src/design/tokens.ts, which is where the whole look lives');
107
-
108
- // A missing font file does not fail a render: it silently swaps in a fallback, and the
109
- // video comes out looking almost right, which is worse than an error.
110
- const fontDir = join(ROOT, 'assets', 'fonts');
111
- const fonts = existsSync(fontDir) ? readdirSync(fontDir).filter((f) => /\.(ttf|otf|woff2?)$/i.test(f)) : [];
112
- record('brand', 'Fonts', fonts.length > 0,
113
- fonts.length ? `${fonts.length} file(s): ${fonts.slice(0, 2).join(', ')}` : 'assets/fonts is empty',
114
- 'Put the brand font files back in assets/fonts, or a render falls back to a system face');
115
-
116
- /* --------------------------------------------------------------------- report */
117
- const W = 22;
118
- let lastArea = '';
119
- for (const r of results) {
120
- if (r.area !== lastArea) { console.log(`\n${r.area.toUpperCase()}`); lastArea = r.area; }
121
- const mark = r.ok ? ' ok ' : (r.core ? ' FAIL ' : ' -- ');
122
- console.log(`${mark} ${r.name.padEnd(W)} ${r.detail}`);
123
- if (!r.ok) console.log(` ${''.padEnd(W)} fix: ${r.fix}`);
124
- }
125
-
126
- const brokenCore = results.filter((r) => !r.ok && r.core);
127
- const brokenOpt = results.filter((r) => !r.ok && !r.core);
128
-
129
- console.log('\n' + '-'.repeat(64));
130
- if (!brokenCore.length && !brokenOpt.length) {
131
- console.log('Everything is present. You can cut, render and caption.');
132
- } else if (!brokenCore.length) {
133
- console.log(`Rendering works. ${brokenOpt.length} optional piece(s) missing:`);
134
- console.log(` ${[...new Set(brokenOpt.map((r) => r.fix))].join('\n ')}`);
135
- console.log('Without these: no word-level captions, no auto cut detection, no green-screen cutouts.');
136
- } else {
137
- console.log(`${brokenCore.length} thing(s) needed for a render are missing. Fix these first:`);
138
- console.log(` ${[...new Set(brokenCore.map((r) => r.fix))].join('\n ')}`);
139
- }
140
-
141
- process.exit(brokenCore.length ? 1 : 0);
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