ffmpeg-skill 0.7.1 → 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 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`. It is a proposal engine, not a judgement of content:
171
- tell the user what it picked and why (energy, scene length).
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 ~0.3 the match is doubtful — use a window with a clear event.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "0.7.1",
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",
@@ -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
- die(f"loudnorm returned unusable value for {k}: {data.get(k)} (silent input?)")
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
- info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS)")
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
- SCENE_RE = re.compile(r"lavfi\.scd\.time=([0-9.]+)")
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
- f"scale=320:-2,scdet=threshold={threshold}:sc_pass=1,metadata=print:file=-", "-f", "null", "-"],
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 = [float(t) for t in SCENE_RE.findall(proc.stdout)]
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
- for t in times:
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=10.0, help="scdet threshold 0-100 (default 10; lower = more cuts)")
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
- # corr[k] = sum ref[i+k]*other[i] -> lag k means 'other' is delayed by k relative to ref? see below
103
- best_lag, best_val = 0, -float("inf")
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
- val = corr[lag % n].real
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
- energy = math.sqrt(sum(x * x for x in ref) * sum(x * x for x in other)) or 1.0
110
- return best_lag, best_val / energy
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", str(f), "--to-sdr", "-o", f"{stem}_sdr.mp4"] + fast))
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"]))