ffmpeg-skill 0.7.0 → 0.8.0
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/README.md +16 -0
- package/SKILL.md +12 -3
- package/bin/install.js +5 -1
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/mcp/server.py +178 -0
- package/package.json +2 -1
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
- package/scripts/loudness.py +11 -2
- package/scripts/scenes.py +40 -7
- package/scripts/sync.py +48 -5
- package/scripts/verify.py +1 -1
package/README.md
CHANGED
|
@@ -124,6 +124,22 @@ More examples: [examples/README.md](examples/README.md). To see everything run e
|
|
|
124
124
|
|
|
125
125
|
All scripts: Python 3.9+, standard library only, `--help`, non-zero exit + stderr message on failure.
|
|
126
126
|
|
|
127
|
+
## Measured, not assumed
|
|
128
|
+
|
|
129
|
+
`tests/corpus.py` downloads public real-device footage (GoPro, DJI, iPhone incl. Dolby Vision, Android screen recordings, HDR10, 24p, Tears of Steel) and runs the toolchain on it; `tests/bench_sync.py`, `bench_silence.py` and `bench_scenes.py` score the algorithms against known ground truth.
|
|
130
|
+
|
|
131
|
+
| What | Result (0.8.0, local ffmpeg 6.1) |
|
|
132
|
+
|---|---|
|
|
133
|
+
| Real-device corpus, 10 files | 92 verify steps, all pass after fixes |
|
|
134
|
+
| sync.py, ±30 s offsets, gain/noise/EQ, real dialogue+music | 120 s windows (the documented rule): 40/40 within 10 ms, max 1.1 ms. 60 s stress windows: 95 % within 10 ms, 4 of 5 misses flagged by confidence |
|
|
135
|
+
| silence.py, 20 cases, known gaps | 0 missed gaps, ≤ 1 ms leftover silence |
|
|
136
|
+
| scenes.py, 53 hard cuts between single takes (GoPro/DJI/iPhone/…) | precision 0.95, recall 1.00, F1 0.97 at the default threshold |
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
python3 tests/corpus.py --fetch --verify # ~1.4 GB download, then verify (slow on 4K)
|
|
140
|
+
python3 tests/bench_sync.py --cases 100
|
|
141
|
+
```
|
|
142
|
+
|
|
127
143
|
## MCP
|
|
128
144
|
|
|
129
145
|
```json
|
package/SKILL.md
CHANGED
|
@@ -167,8 +167,12 @@ scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECON
|
|
|
167
167
|
Lists scenes with audio energy, the loudest moments, and (with
|
|
168
168
|
`--highlights`) proposes N ranges that add up to `--target` seconds, biased to
|
|
169
169
|
the loudest window of each scene. Review the sheet + JSON, adjust the EDL, then
|
|
170
|
-
`cut.py --segments`.
|
|
171
|
-
|
|
170
|
+
`cut.py --segments`. Cut detection is a one-frame spike test (benchmark on
|
|
171
|
+
hard cuts between real single takes: precision 0.95, recall 1.00 at the default
|
|
172
|
+
threshold; raise `--threshold` to 12 for 0.98 precision at 0.94 recall).
|
|
173
|
+
Dissolves and very slow fades are not cuts and will be missed. Highlights are
|
|
174
|
+
a proposal engine, not a judgement of content: tell the user what it picked
|
|
175
|
+
and why (energy, scene length).
|
|
172
176
|
|
|
173
177
|
### check.py — pre-delivery compliance
|
|
174
178
|
```
|
|
@@ -300,7 +304,12 @@ video with the second file's audio aligned (video stream copied).
|
|
|
300
304
|
the clock difference in ppm, and resamples the second file so a 60-minute
|
|
301
305
|
take stays in sync (typical consumer devices drift 20-500 ppm = up to 1.8 s/h).
|
|
302
306
|
Use it whenever the recording is longer than ~10 minutes. Check `confidence`
|
|
303
|
-
(0–1); below
|
|
307
|
+
(0–1, normalised correlation with a runner-up penalty); below 0.3 the match is
|
|
308
|
+
doubtful. Benchmark on real dialogue/music (±30 s offsets, gain, noise, EQ):
|
|
309
|
+
with the default 120 s window 40/40 within 10 ms (max 1.1 ms); with a 60 s
|
|
310
|
+
window 95 %, misses flagged below 0.3. Keep `--analyze-seconds` at least 4×
|
|
311
|
+
`--max-offset` (default 120 s vs 30 s): lags with under 35 % overlap are
|
|
312
|
+
ignored, so an offset larger than ~60 % of the window cannot be found.
|
|
304
313
|
|
|
305
314
|
### color.py — HDR to SDR, LUTs, colour tags, Dolby Vision
|
|
306
315
|
```
|
package/bin/install.js
CHANGED
|
@@ -88,7 +88,11 @@ for (const t of targets) {
|
|
|
88
88
|
}
|
|
89
89
|
fs.rmSync(t.dir, { recursive: true, force: true });
|
|
90
90
|
fs.mkdirSync(t.dir, { recursive: true });
|
|
91
|
-
for (const item of PAYLOAD)
|
|
91
|
+
for (const item of PAYLOAD) {
|
|
92
|
+
const src = path.join(ROOT, item);
|
|
93
|
+
if (!fs.existsSync(src)) { if (item !== 'SKILL.md' && item !== 'scripts') continue; throw new Error(`missing ${item} in package`); }
|
|
94
|
+
copyRecursive(src, path.join(t.dir, item));
|
|
95
|
+
}
|
|
92
96
|
console.log(`installed ${t.label}: ${t.dir}`);
|
|
93
97
|
} catch (err) {
|
|
94
98
|
failed = true;
|
|
Binary file
|
package/mcp/server.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ffmpeg-skill as an MCP server (stdio, JSON-RPC 2.0) — standard library only.
|
|
3
|
+
|
|
4
|
+
Every script in ../scripts becomes a tool; arguments are passed as an argv list
|
|
5
|
+
or as a flat object of flags. Results are the script's --json output.
|
|
6
|
+
|
|
7
|
+
Run:
|
|
8
|
+
python3 mcp/server.py # stdio transport
|
|
9
|
+
Claude Desktop / Claude Code config example:
|
|
10
|
+
{"mcpServers": {"ffmpeg-skill": {"command": "python3", "args": ["/path/to/ffmpeg-skill/mcp/server.py"]}}}
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, List
|
|
18
|
+
|
|
19
|
+
HERE = Path(__file__).resolve().parent
|
|
20
|
+
SCRIPTS = HERE.parent / "scripts"
|
|
21
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
22
|
+
|
|
23
|
+
TOOLS: Dict[str, str] = {
|
|
24
|
+
"probe": "Inspect a media file: duration, fps/VFR, resolution, codecs, HDR, rotation, audio. Args: inputs (list), analyze (bool), compact (bool).",
|
|
25
|
+
"cut": "Cut a range or several segments, lossless when possible. Args: input, start, end, duration, segments, accurate, output.",
|
|
26
|
+
"fit": "Fit to a duration (speed/trim) and/or aspect (pad/crop). Args: input, duration, method, aspect, fit, width, fps, smooth, output.",
|
|
27
|
+
"caption": "Burn SRT/ASS or timed text; animated/karaoke styles; brand. Args: input, srt, ass, text, animate, karaoke, font, size, position, brand, output.",
|
|
28
|
+
"overlay": "Composite a logo/image/text with timing and fade. Args: input, image, text, logo, position, start, end, fade, opacity, scale, brand, output.",
|
|
29
|
+
"graphics": "Motion-graphics templates: lower-third, title, chapter, progress, countdown, bug. Args: input, template, name, title, subtitle, start, end, brand, output.",
|
|
30
|
+
"sync": "Detect offset between two recordings by audio, optional drift fix. Args: reference, second, fix_drift, replace_audio, trim_second, output.",
|
|
31
|
+
"multicam": "Align N cameras/recorders and switch between them. Args: inputs (list), switch, auto, audio, fix_drift, output.",
|
|
32
|
+
"audio": "Denoise/voice chain, music bed with ducking, fades, downmix, replace. Args: input, voice, denoise, music, duck, music_volume, fade_in, fade_out, downmix, replace, output.",
|
|
33
|
+
"loudness": "Two-pass EBU R128 normalisation. Args: input, lufs, tp, measure_only, output.",
|
|
34
|
+
"silence": "Remove dead air / list silences. Args: input, threshold, min_silence, margin, list, edl, output.",
|
|
35
|
+
"join": "Concatenate clips with transitions, normalising size/fps/audio. Args: inputs (list), transition, duration, width, height, fps, output.",
|
|
36
|
+
"color": "HDR→SDR tone mapping, LUTs, retag, strip Dolby Vision. Args: input, to_sdr, lut, retag, strip_dovi, tonemap, output.",
|
|
37
|
+
"export": "Platform presets: youtube, youtube4k, reels, x, prores, h265, gif. Args: input, preset, fit, output.",
|
|
38
|
+
"check": "Pre-delivery compliance per platform. Args: input, platform, no_loudness.",
|
|
39
|
+
"scenes": "Scene changes, audio peaks, highlight proposals. Args: input, highlights, target, edl, sheet.",
|
|
40
|
+
"look": "Contact sheet / frames / before-after PNG for visual checks. Args: input, at (list), tiles, compare, output.",
|
|
41
|
+
"render": "Render a whole edit from project.json. Args: project, fast, stop_after, init.",
|
|
42
|
+
"verify": "Run the toolchain on real files and report PASS/FAIL. Args: paths (list), quick, report.",
|
|
43
|
+
"report": "HTML delivery report. Args: after, before, platform, commands, notes, title, output.",
|
|
44
|
+
}
|
|
45
|
+
POSITIONAL = {"probe": ["inputs"], "sync": ["reference", "second"], "multicam": ["inputs"], "join": ["inputs"], "verify": ["paths"], "render": ["project"]}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_argv(name: str, args: Dict[str, Any]) -> List[str]:
|
|
49
|
+
if isinstance(args.get("argv"), list):
|
|
50
|
+
argv = [str(a) for a in args["argv"]]
|
|
51
|
+
if name not in ("look", "probe") and "--json" not in argv and "--help" not in argv:
|
|
52
|
+
argv.append("--json")
|
|
53
|
+
return argv
|
|
54
|
+
argv: List[str] = []
|
|
55
|
+
args = dict(args)
|
|
56
|
+
pos = POSITIONAL.get(name, ["input"])
|
|
57
|
+
for key in pos:
|
|
58
|
+
val = args.pop(key, None)
|
|
59
|
+
if val is None:
|
|
60
|
+
continue
|
|
61
|
+
if isinstance(val, list):
|
|
62
|
+
argv += [str(v) for v in val]
|
|
63
|
+
else:
|
|
64
|
+
argv.append(str(val))
|
|
65
|
+
for key, val in args.items():
|
|
66
|
+
if val is None or val is False:
|
|
67
|
+
continue
|
|
68
|
+
flag = "--" + key.replace("_", "-")
|
|
69
|
+
if key == "output":
|
|
70
|
+
flag = "-o"
|
|
71
|
+
if key == "lufs" and name == "loudness":
|
|
72
|
+
flag = "-I"
|
|
73
|
+
if val is True:
|
|
74
|
+
argv.append(flag)
|
|
75
|
+
elif isinstance(val, list):
|
|
76
|
+
for v in val:
|
|
77
|
+
argv += [flag, str(v)]
|
|
78
|
+
else:
|
|
79
|
+
argv += [flag, str(val)]
|
|
80
|
+
if name not in ("look", "probe") and "--json" not in argv and "--help" not in argv:
|
|
81
|
+
argv.append("--json")
|
|
82
|
+
return argv
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
86
|
+
script = SCRIPTS / f"{name}.py"
|
|
87
|
+
if not script.exists():
|
|
88
|
+
return {"isError": True, "content": [{"type": "text", "text": f"unknown tool {name}"}]}
|
|
89
|
+
argv = build_argv(name, args or {})
|
|
90
|
+
proc = subprocess.run([sys.executable, str(script)] + argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
91
|
+
stdout = proc.stdout.strip()
|
|
92
|
+
text = stdout
|
|
93
|
+
structured = None
|
|
94
|
+
try:
|
|
95
|
+
structured = json.loads(stdout) if stdout.startswith("{") or stdout.startswith("[") else None
|
|
96
|
+
except ValueError:
|
|
97
|
+
structured = None
|
|
98
|
+
if proc.returncode != 0:
|
|
99
|
+
err = proc.stderr.strip().splitlines()
|
|
100
|
+
tail = "\n".join(err[-12:])
|
|
101
|
+
return {"isError": True, "content": [{"type": "text", "text": f"{name} failed (exit {proc.returncode})\n{tail}"}]}
|
|
102
|
+
if structured is None:
|
|
103
|
+
text = stdout or "\n".join(proc.stderr.strip().splitlines()[-5:])
|
|
104
|
+
result: Dict[str, Any] = {"content": [{"type": "text", "text": text}]}
|
|
105
|
+
if structured is not None:
|
|
106
|
+
result["structuredContent"] = structured if isinstance(structured, dict) else {"result": structured}
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def tool_list() -> List[Dict[str, Any]]:
|
|
111
|
+
out = []
|
|
112
|
+
for name, desc in TOOLS.items():
|
|
113
|
+
out.append({
|
|
114
|
+
"name": name,
|
|
115
|
+
"description": desc + " Pass either named args (flags without dashes, underscores for hyphens) or argv (raw CLI list). Media paths must be absolute.",
|
|
116
|
+
"inputSchema": {"type": "object", "properties": {"argv": {"type": "array", "items": {"type": "string"}, "description": "raw CLI arguments"}}, "additionalProperties": True},
|
|
117
|
+
})
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def handle(req: Dict[str, Any]) -> Dict[str, Any]:
|
|
122
|
+
method = req.get("method")
|
|
123
|
+
params = req.get("params") or {}
|
|
124
|
+
if method == "initialize":
|
|
125
|
+
return {"protocolVersion": PROTOCOL_VERSION, "capabilities": {"tools": {}}, "serverInfo": {"name": "ffmpeg-skill", "version": version()}}
|
|
126
|
+
if method == "tools/list":
|
|
127
|
+
return {"tools": tool_list()}
|
|
128
|
+
if method == "tools/call":
|
|
129
|
+
return call_tool(params.get("name", ""), params.get("arguments") or {})
|
|
130
|
+
if method == "ping":
|
|
131
|
+
return {}
|
|
132
|
+
raise KeyError(method)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def version() -> str:
|
|
136
|
+
try:
|
|
137
|
+
return json.loads((HERE.parent / "package.json").read_text())["version"]
|
|
138
|
+
except Exception:
|
|
139
|
+
return "0"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main() -> int:
|
|
143
|
+
if "--list" in sys.argv:
|
|
144
|
+
for t in tool_list():
|
|
145
|
+
print(f"{t['name']:10s} {t['description'].split(' Pass either')[0]}")
|
|
146
|
+
return 0
|
|
147
|
+
if "--call" in sys.argv: # debugging helper: --call NAME '{"input": "..."}'
|
|
148
|
+
i = sys.argv.index("--call")
|
|
149
|
+
name = sys.argv[i + 1]
|
|
150
|
+
args = json.loads(sys.argv[i + 2]) if len(sys.argv) > i + 2 else {}
|
|
151
|
+
print(json.dumps(call_tool(name, args), indent=2))
|
|
152
|
+
return 0
|
|
153
|
+
stdin = sys.stdin.buffer
|
|
154
|
+
stdout = sys.stdout.buffer
|
|
155
|
+
for raw in stdin:
|
|
156
|
+
line = raw.decode("utf-8", errors="replace").strip()
|
|
157
|
+
if not line:
|
|
158
|
+
continue
|
|
159
|
+
try:
|
|
160
|
+
req = json.loads(line)
|
|
161
|
+
except ValueError:
|
|
162
|
+
continue
|
|
163
|
+
if "id" not in req: # notification
|
|
164
|
+
continue
|
|
165
|
+
try:
|
|
166
|
+
result = handle(req)
|
|
167
|
+
resp = {"jsonrpc": "2.0", "id": req["id"], "result": result}
|
|
168
|
+
except KeyError as exc:
|
|
169
|
+
resp = {"jsonrpc": "2.0", "id": req["id"], "error": {"code": -32601, "message": f"method not found: {exc}"}}
|
|
170
|
+
except Exception as exc: # noqa: BLE001
|
|
171
|
+
resp = {"jsonrpc": "2.0", "id": req["id"], "error": {"code": -32000, "message": str(exc)}}
|
|
172
|
+
stdout.write((json.dumps(resp) + "\n").encode("utf-8"))
|
|
173
|
+
stdout.flush()
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
sys.exit(main())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: MCP server, batch processing, declarative project rendering, brand kits, motion-graphics templates, HTML delivery reports, scene detection, delivery checks, cut, silence removal, transitions, multicam, captions, sync with drift correction, HDR to SDR, LUTs, audio clean-up and ducking, loudness, platform exports. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": ["ffmpeg", "video", "agent-skill", "claude-code", "cursor", "codex", "skill", "video-editing"],
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"bin/",
|
|
19
19
|
"scripts/",
|
|
20
|
+
"mcp/",
|
|
20
21
|
"SKILL.md",
|
|
21
22
|
"README.md",
|
|
22
23
|
"LICENSE"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/scripts/loudness.py
CHANGED
|
@@ -34,7 +34,9 @@ def measure(path: str, I: float, tp: float, lra: float) -> dict:
|
|
|
34
34
|
data = json.loads(m.group(0))
|
|
35
35
|
for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset"):
|
|
36
36
|
if data.get(k) in (None, "-inf", "inf", "nan"):
|
|
37
|
-
|
|
37
|
+
data["silent"] = True
|
|
38
|
+
return data
|
|
39
|
+
data["silent"] = False
|
|
38
40
|
return data
|
|
39
41
|
|
|
40
42
|
|
|
@@ -57,6 +59,12 @@ def main() -> int:
|
|
|
57
59
|
die("input has no audio stream")
|
|
58
60
|
|
|
59
61
|
stats = measure(args.input, args.lufs, args.tp, args.lra)
|
|
62
|
+
if stats.get("silent"):
|
|
63
|
+
info("audio is silent (integrated loudness -inf); nothing to normalise")
|
|
64
|
+
if args.measure_only:
|
|
65
|
+
print(json.dumps({"silent": True, "input_i": "-inf"}, indent=2))
|
|
66
|
+
return 0
|
|
67
|
+
die("input audio is silent; loudness normalisation is meaningless (use audio.py --replace to add a track)")
|
|
60
68
|
info(f"measured: {float(stats['input_i']):.1f} LUFS, TP {float(stats['input_tp']):.1f} dBTP, LRA {float(stats['input_lra']):.1f} LU")
|
|
61
69
|
if args.measure_only:
|
|
62
70
|
print(json.dumps({k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")}, indent=2))
|
|
@@ -79,7 +87,8 @@ def main() -> int:
|
|
|
79
87
|
run(cmd)
|
|
80
88
|
|
|
81
89
|
after = measure(output, args.lufs, args.tp, args.lra)
|
|
82
|
-
|
|
90
|
+
if not after.get("silent"):
|
|
91
|
+
info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS)")
|
|
83
92
|
emit(output)
|
|
84
93
|
return 0
|
|
85
94
|
|
package/scripts/scenes.py
CHANGED
|
@@ -23,17 +23,49 @@ from typing import Dict, List, Tuple
|
|
|
23
23
|
|
|
24
24
|
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def detect_scenes(path: str, threshold: float, min_len: float, duration: float) -> List[float]:
|
|
29
|
+
def detect_scenes(path: str, threshold: float, min_len: float, duration: float, ratio: float = 3.0) -> List[float]:
|
|
30
|
+
"""Scene cuts = frames whose scdet score is above `threshold` AND stands out from its
|
|
31
|
+
neighbourhood (score > ratio x median of the surrounding +-12 frames). Sustained motion,
|
|
32
|
+
flashes and fast pans raise the score on many consecutive frames and are rejected;
|
|
33
|
+
a real cut is a one-frame spike. On real footage this roughly doubles precision at
|
|
34
|
+
equal recall compared with the raw scdet threshold."""
|
|
30
35
|
ffmpeg = require_tool("ffmpeg")
|
|
31
36
|
proc = subprocess.run([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
|
|
32
|
-
|
|
37
|
+
"scale=320:-2,scdet=threshold=0:sc_pass=1,metadata=print:file=-", "-f", "null", "-"],
|
|
33
38
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
34
|
-
times
|
|
39
|
+
times: List[float] = []
|
|
40
|
+
scores: List[float] = []
|
|
41
|
+
cur_t = None
|
|
42
|
+
for line in proc.stdout.splitlines():
|
|
43
|
+
m = SCORE_RE.match(line)
|
|
44
|
+
if m:
|
|
45
|
+
cur_t = float(m.group(2))
|
|
46
|
+
continue
|
|
47
|
+
if line.startswith("lavfi.scd.score=") and cur_t is not None:
|
|
48
|
+
try:
|
|
49
|
+
times.append(cur_t)
|
|
50
|
+
scores.append(float(line.split("=", 1)[1]))
|
|
51
|
+
except ValueError:
|
|
52
|
+
pass
|
|
35
53
|
cuts = [0.0]
|
|
36
|
-
|
|
54
|
+
if not scores:
|
|
55
|
+
return cuts
|
|
56
|
+
w = 12
|
|
57
|
+
for i, sc in enumerate(scores):
|
|
58
|
+
if sc < threshold:
|
|
59
|
+
continue
|
|
60
|
+
lo, hi = max(0, i - w), min(len(scores), i + w + 1)
|
|
61
|
+
neigh = sorted(scores[lo:i] + scores[i + 1:hi])
|
|
62
|
+
med = neigh[len(neigh) // 2] if neigh else 0.0
|
|
63
|
+
if sc < ratio * max(med, 0.5):
|
|
64
|
+
continue
|
|
65
|
+
# keep only the local maximum inside +-2 frames
|
|
66
|
+
if any(scores[j] > sc for j in range(max(0, i - 2), min(len(scores), i + 3)) if j != i):
|
|
67
|
+
continue
|
|
68
|
+
t = times[i]
|
|
37
69
|
if t - cuts[-1] >= min_len:
|
|
38
70
|
cuts.append(t)
|
|
39
71
|
if duration - cuts[-1] < min_len and len(cuts) > 1:
|
|
@@ -60,7 +92,8 @@ def audio_envelope(path: str, step_s: float) -> List[float]:
|
|
|
60
92
|
def main() -> int:
|
|
61
93
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
62
94
|
ap.add_argument("input")
|
|
63
|
-
ap.add_argument("--threshold", type=float, default=
|
|
95
|
+
ap.add_argument("--threshold", type=float, default=8.0, help="minimum scdet score for a cut, 0-100 (default 8)")
|
|
96
|
+
ap.add_argument("--ratio", type=float, default=3.0, help="a cut must exceed this multiple of the neighbouring frames' median score (default 3; lower = more cuts)")
|
|
64
97
|
ap.add_argument("--min-scene", type=float, default=1.0, help="ignore cuts closer than this in seconds (default 1)")
|
|
65
98
|
ap.add_argument("--highlights", type=int, default=0, help="number of highlight ranges to propose")
|
|
66
99
|
ap.add_argument("--target", type=float, help="with --highlights: total seconds the picks should add up to (trims long scenes)")
|
|
@@ -75,7 +108,7 @@ def main() -> int:
|
|
|
75
108
|
if not meta.get("video"):
|
|
76
109
|
die("input has no video stream")
|
|
77
110
|
dur = meta.get("duration") or 0.0
|
|
78
|
-
cuts = detect_scenes(args.input, args.threshold, args.min_scene, dur)
|
|
111
|
+
cuts = detect_scenes(args.input, args.threshold, args.min_scene, dur, args.ratio)
|
|
79
112
|
bounds = cuts + [dur]
|
|
80
113
|
step_s = 0.5
|
|
81
114
|
env = audio_envelope(args.input, step_s) if meta.get("audio") else []
|
package/scripts/sync.py
CHANGED
|
@@ -92,6 +92,14 @@ def ifft(a: List[complex]) -> List[complex]:
|
|
|
92
92
|
|
|
93
93
|
|
|
94
94
|
def cross_correlate(ref: List[float], other: List[float], max_lag: int):
|
|
95
|
+
"""Normalised cross-correlation over the overlapping region only.
|
|
96
|
+
|
|
97
|
+
The raw FFT correlation sum grows with the overlap length, so with a 60 s window a
|
|
98
|
+
correct 28 s offset (32 s overlap) loses to a wrong 2 s offset (58 s overlap) on
|
|
99
|
+
music-like material. Dividing each lag by the energy of the overlapping parts
|
|
100
|
+
(prefix sums, O(1) per lag) makes lags comparable and turns the peak value into a
|
|
101
|
+
real similarity score in 0..1 that doubles as the confidence.
|
|
102
|
+
"""
|
|
95
103
|
n = 1
|
|
96
104
|
while n < len(ref) + len(other):
|
|
97
105
|
n <<= 1
|
|
@@ -99,15 +107,50 @@ def cross_correlate(ref: List[float], other: List[float], max_lag: int):
|
|
|
99
107
|
fb = fft([complex(x) for x in other] + [0j] * (n - len(other)))
|
|
100
108
|
prod = [x * y.conjugate() for x, y in zip(fa, fb)]
|
|
101
109
|
corr = ifft(prod)
|
|
102
|
-
#
|
|
103
|
-
|
|
110
|
+
# prefix sums of squares for overlap energy
|
|
111
|
+
def prefix(v: List[float]) -> List[float]:
|
|
112
|
+
out = [0.0]
|
|
113
|
+
acc = 0.0
|
|
114
|
+
for x in v:
|
|
115
|
+
acc += x * x
|
|
116
|
+
out.append(acc)
|
|
117
|
+
return out
|
|
118
|
+
pr, po = prefix(ref), prefix(other)
|
|
119
|
+
lr, lo = len(ref), len(other)
|
|
104
120
|
max_lag = min(max_lag, n // 2 - 1)
|
|
121
|
+
best_lag, best_val, second = 0, -float("inf"), -float("inf")
|
|
122
|
+
# ignore lags with less than 35 % overlap: with the documented rule (analysis window >= 4x the
|
|
123
|
+
# largest expected offset) true offsets always keep >= 75 % overlap, while short-overlap lags are
|
|
124
|
+
# where coincidental matches on quasi-periodic material (music, tone beds) live
|
|
125
|
+
min_overlap = max(10, int(0.35 * min(lr, lo)))
|
|
126
|
+
scores = []
|
|
105
127
|
for lag in range(-max_lag, max_lag + 1):
|
|
106
|
-
|
|
128
|
+
# corr[lag] = sum_i ref[i] * other[i - lag] -> ref index range and other index range overlap:
|
|
129
|
+
r0, r1 = max(0, lag), min(lr, lo + lag)
|
|
130
|
+
if r1 - r0 < min_overlap:
|
|
131
|
+
continue
|
|
132
|
+
e_ref = pr[r1] - pr[r0]
|
|
133
|
+
e_oth = po[r1 - lag] - po[r0 - lag]
|
|
134
|
+
denom = math.sqrt(e_ref * e_oth)
|
|
135
|
+
if denom <= 0:
|
|
136
|
+
continue
|
|
137
|
+
val = corr[lag % n].real / denom
|
|
138
|
+
# mild preference for longer overlaps: a perfect match over 55 % of the window must not tie
|
|
139
|
+
# with a perfect match over 100 % (quasi-periodic material). Exponent 0.5: with the window rule (>= 4x offset) a true match keeps >= 75 % overlap (x0.87) while a coincidental 55 % match drops to x0.74; keeps large true
|
|
140
|
+
# offsets (28 s in 60 s = 53 % overlap -> x0.94) competitive while still breaking exact ties.
|
|
141
|
+
val *= ((r1 - r0) / min(lr, lo)) ** 0.5
|
|
142
|
+
scores.append((val, lag))
|
|
107
143
|
if val > best_val:
|
|
144
|
+
second = best_val
|
|
108
145
|
best_val, best_lag = val, lag
|
|
109
|
-
|
|
110
|
-
|
|
146
|
+
elif val > second and abs(lag - best_lag) > 5:
|
|
147
|
+
second = val
|
|
148
|
+
# confidence: peak similarity, penalised when a distant runner-up is nearly as good
|
|
149
|
+
conf = max(0.0, min(1.0, best_val))
|
|
150
|
+
if second > -float("inf") and best_val > 0:
|
|
151
|
+
margin = (best_val - second) / best_val
|
|
152
|
+
conf *= min(1.0, 0.5 + margin)
|
|
153
|
+
return best_lag, conf
|
|
111
154
|
|
|
112
155
|
|
|
113
156
|
def refine(ref_s: List[float], oth_s: List[float], coarse_offset: float, fine_step: int, window_s: float) -> float:
|
package/scripts/verify.py
CHANGED
|
@@ -118,7 +118,7 @@ def main() -> int:
|
|
|
118
118
|
plan.append(("overlay text", ["overlay.py", cut, "--text", "verify", "--position", "top-left", "-o", f"{stem}_ovl.mp4"] + fast))
|
|
119
119
|
plan.append(("look sheet", ["look.py", cut, "-o", f"{stem}_sheet.png"]))
|
|
120
120
|
if (meta.get("video") or {}).get("hdr"):
|
|
121
|
-
plan.append(("color to-sdr", ["color.py",
|
|
121
|
+
plan.append(("color to-sdr", ["color.py", f"{stem}_acc.mp4", "--to-sdr", "-o", f"{stem}_sdr.mp4"] + fast))
|
|
122
122
|
plan.append(("hdr preserved", ["__check_hdr__", f"{stem}_acc.mp4"]))
|
|
123
123
|
plan.append(("probe analyze", ["probe.py", cut, "--analyze"]))
|
|
124
124
|
plan.append(("export x", ["export.py", cut, "--preset", "x", "-o", f"{stem}_x.mp4"]))
|