creator-editing-studio 1.3.5 → 1.3.6
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 +254 -236
- package/template/STYLE.md +179 -179
- package/template/requirements.txt +23 -23
- package/template/scripts/check_pair.py +90 -90
- package/template/scripts/doctor.mjs +141 -141
- package/template/scripts/find_cuts.py +45 -45
- package/template/scripts/lib/media.mjs +228 -228
- package/template/scripts/takes.mjs +424 -424
- package/template/scripts/transcribe.py +103 -103
- package/template/src/components/CommandCard.tsx +12 -11
- package/template/src/components/Cursor.tsx +153 -153
- package/template/src/compositions/BeatTest.tsx +16 -15
- package/template/templates/BRIEF.md +97 -97
- package/template/templates/STYLE.md +179 -179
|
@@ -1,141 +1,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} 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} 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,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()
|