ffmpeg-skill 0.2.0 → 0.4.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.
Files changed (35) hide show
  1. package/README.md +15 -4
  2. package/SKILL.md +80 -6
  3. package/package.json +2 -2
  4. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  5. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  6. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  7. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  8. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  9. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  10. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  11. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  12. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  13. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  14. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  15. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  16. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  17. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  18. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  19. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
  20. package/scripts/_common.py +137 -3
  21. package/scripts/audio.py +4 -2
  22. package/scripts/caption.py +73 -6
  23. package/scripts/color.py +24 -3
  24. package/scripts/cut.py +5 -3
  25. package/scripts/export.py +5 -3
  26. package/scripts/fit.py +5 -2
  27. package/scripts/join.py +111 -0
  28. package/scripts/look.py +101 -0
  29. package/scripts/loudness.py +4 -2
  30. package/scripts/multicam.py +196 -0
  31. package/scripts/overlay.py +4 -2
  32. package/scripts/probe.py +10 -1
  33. package/scripts/silence.py +123 -0
  34. package/scripts/sync.py +4 -3
  35. package/scripts/verify.py +157 -0
package/scripts/probe.py CHANGED
@@ -12,7 +12,7 @@ Examples:
12
12
  import argparse
13
13
  import sys
14
14
 
15
- from _common import print_json, probe
15
+ from _common import analyze_levels, print_json, probe
16
16
 
17
17
 
18
18
  def main() -> int:
@@ -20,9 +20,14 @@ def main() -> int:
20
20
  ap.add_argument("inputs", nargs="+", help="media file(s) to inspect")
21
21
  ap.add_argument("--compact", action="store_true", help="one human-readable line per file instead of JSON")
22
22
  ap.add_argument("--field", help="print only this top-level field (e.g. duration) or dotted path (video.fps)")
23
+ ap.add_argument("--analyze", action="store_true", help="also sample picture levels (first 20 s) and flag Log-looking footage")
23
24
  args = ap.parse_args()
24
25
 
25
26
  results = [probe(p) for p in args.inputs]
27
+ if args.analyze:
28
+ for r in results:
29
+ if r.get("video"):
30
+ r["levels"] = analyze_levels(r["file"])
26
31
 
27
32
  if args.field:
28
33
  for r in results:
@@ -41,6 +46,10 @@ def main() -> int:
41
46
  line += f" | {v.get('width')}x{v.get('height')} @ {v.get('fps')}fps {v.get('codec')} {v.get('pix_fmt')}"
42
47
  if v.get("variable_frame_rate_suspected"):
43
48
  line += " (VFR?)"
49
+ if v.get("hdr"):
50
+ line += f" [{v.get('hdr_format')}]"
51
+ if r.get("levels", {}).get("looks_like_log"):
52
+ line += " [Log?]"
44
53
  else:
45
54
  line += " | no video"
46
55
  if a:
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """Remove silences / dead air (jump-cut editing) or just list them.
3
+
4
+ Detects quiet stretches with ffmpeg's silencedetect, keeps a margin on each
5
+ side so words are not clipped, drops gaps shorter than --min-silence, and
6
+ writes a frame-accurate re-encode in one pass (select/aselect filters).
7
+
8
+ Examples:
9
+ python3 silence.py talk.mp4 # -35 dB, gaps >= 0.6 s, 0.15 s margin
10
+ python3 silence.py talk.mp4 --threshold -40 --min-silence 1 --margin 0.25
11
+ python3 silence.py talk.mp4 --list # print the silences and the resulting cut list, no output
12
+ python3 silence.py talk.mp4 --edl keep.txt # also save the kept ranges (START-END per line, cut.py --segments format)
13
+ """
14
+ import argparse
15
+ import re
16
+ import sys
17
+ from typing import List, Tuple
18
+
19
+ from _common import aac_args, add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run, x264_args
20
+
21
+ SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
22
+
23
+
24
+ def detect(path: str, threshold: float, min_silence: float) -> List[Tuple[float, float]]:
25
+ ffmpeg = require_tool("ffmpeg")
26
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af",
27
+ f"silencedetect=noise={threshold}dB:d={min_silence}", "-f", "null", "-"]
28
+ proc = run(cmd, quiet=True, check=False)
29
+ if proc.returncode != 0:
30
+ die(f"silencedetect failed:\n{proc.stderr.strip()[-800:]}")
31
+ silences: List[Tuple[float, float]] = []
32
+ start = None
33
+ for kind, val in SIL_RE.findall(proc.stderr):
34
+ if kind == "start":
35
+ start = float(val)
36
+ elif start is not None:
37
+ silences.append((start, float(val)))
38
+ start = None
39
+ if start is not None: # silence runs to the end
40
+ silences.append((start, float("inf")))
41
+ return silences
42
+
43
+
44
+ def keep_ranges(silences: List[Tuple[float, float]], duration: float, margin: float, min_keep: float) -> List[Tuple[float, float]]:
45
+ keeps: List[Tuple[float, float]] = []
46
+ cursor = 0.0
47
+ for s, e in silences:
48
+ s_adj = max(cursor, s + margin)
49
+ if s_adj - cursor >= min_keep:
50
+ keeps.append((cursor, s_adj))
51
+ cursor = min(duration, e - margin) if e != float("inf") else duration
52
+ if duration - cursor >= min_keep:
53
+ keeps.append((cursor, duration))
54
+ return keeps
55
+
56
+
57
+ def main() -> int:
58
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
59
+ ap.add_argument("input")
60
+ ap.add_argument("-o", "--output", help="output file (default: <name>_tight.<ext>)")
61
+ ap.add_argument("--threshold", type=float, default=-35.0, help="silence level in dBFS (default -35; use -40..-45 for quiet rooms)")
62
+ ap.add_argument("--min-silence", type=float, default=0.6, help="only remove gaps at least this long in seconds (default 0.6)")
63
+ ap.add_argument("--margin", type=float, default=0.15, help="seconds of silence to keep on each side of speech (default 0.15)")
64
+ ap.add_argument("--min-keep", type=float, default=0.2, help="drop kept pieces shorter than this (default 0.2)")
65
+ ap.add_argument("--list", action="store_true", help="only print silences and the kept ranges")
66
+ ap.add_argument("--edl", help="write the kept ranges to this file, one START-END per line")
67
+ ap.add_argument("--crf", type=int, default=18)
68
+ ap.add_argument("--preset", default="medium")
69
+ add_common(ap)
70
+ args = ap.parse_args()
71
+ apply_common(args)
72
+
73
+ meta = probe(args.input)
74
+ if not meta.get("audio"):
75
+ die("input has no audio stream to analyse")
76
+ duration = meta.get("duration") or 0.0
77
+ silences = detect(args.input, args.threshold, args.min_silence)
78
+ keeps = keep_ranges(silences, duration, args.margin, args.min_keep)
79
+ kept = sum(e - s for s, e in keeps)
80
+ removed = max(0.0, duration - kept)
81
+ summary = {
82
+ "silences": [[round(s, 3), None if e == float("inf") else round(e, 3)] for s, e in silences],
83
+ "keep": [[round(s, 3), round(e, 3)] for s, e in keeps],
84
+ "input_duration": round(duration, 3),
85
+ "kept_duration": round(kept, 3),
86
+ "removed_seconds": round(removed, 3),
87
+ }
88
+ info(f"{len(silences)} silences, keeping {len(keeps)} ranges: {kept:.2f}s of {duration:.2f}s (removing {removed:.2f}s)")
89
+
90
+ if args.edl:
91
+ with open(args.edl, "w", encoding="utf-8") as fh:
92
+ for s, e in keeps:
93
+ fh.write(f"{s:.3f}-{e:.3f}\n")
94
+ info(f"wrote {args.edl}")
95
+
96
+ if args.list:
97
+ if args.json:
98
+ emit(None, **summary)
99
+ else:
100
+ print_json(summary)
101
+ return 0
102
+ if not keeps:
103
+ die("nothing would be kept; raise --threshold (e.g. -45) or check the audio")
104
+ if not silences or removed < 0.05:
105
+ info("no removable silence found; output would equal the input")
106
+
107
+ output = args.output or default_output(args.input, "tight")
108
+ expr = "+".join(f"between(t,{s:.3f},{e:.3f})" for s, e in keeps)
109
+ vf = f"select='{expr}',setpts=N/FRAME_RATE/TB"
110
+ af = f"aselect='{expr}',asetpts=N/SR/TB"
111
+ cmd = ffmpeg_base() + ["-i", args.input]
112
+ if meta.get("video"):
113
+ cmd += ["-vf", vf] + x264_args(args.crf, args.preset) + cfr_args(meta)
114
+ cmd += ["-af", af] + aac_args() + [output]
115
+ run(cmd)
116
+ r = probe(output)
117
+ info(f"wrote {output} ({r['duration']:.3f}s, expected ~{kept:.3f}s)")
118
+ emit(output, **summary)
119
+ return 0
120
+
121
+
122
+ if __name__ == "__main__":
123
+ sys.exit(main())
package/scripts/sync.py CHANGED
@@ -26,7 +26,7 @@ import subprocess
26
26
  import sys
27
27
  from typing import List
28
28
 
29
- from _common import aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
29
+ from _common import add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
30
30
 
31
31
  SR = 8000 # decode sample rate
32
32
 
@@ -160,12 +160,13 @@ def main() -> int:
160
160
  ap.add_argument("--fine-ms", type=float, default=1.0, help="fine resolution in ms for the refinement pass, 0 to skip (default 1)")
161
161
  ap.add_argument("--fix-drift", action="store_true", help="also measure the offset near the END and correct clock drift by resampling the second file")
162
162
  ap.add_argument("--drift-window", type=float, default=60.0, help="seconds of audio analysed at each end for drift (default 60)")
163
- ap.add_argument("--json", action="store_true", help="print the result as JSON")
164
163
  mode = ap.add_mutually_exclusive_group()
165
164
  mode.add_argument("--replace-audio", action="store_true", help="write reference video with the second file's audio, aligned")
166
165
  mode.add_argument("--trim-second", action="store_true", help="write the second file shifted so it lines up with the reference")
167
166
  ap.add_argument("--crf", type=int, default=18)
167
+ add_common(ap)
168
168
  args = ap.parse_args()
169
+ apply_common(args)
169
170
 
170
171
  for p in (args.reference, args.second):
171
172
  if not probe(p).get("audio"):
@@ -286,7 +287,7 @@ def main() -> int:
286
287
  info(f"wrote {output}")
287
288
 
288
289
  if args.json:
289
- print(json.dumps(result, indent=2))
290
+ emit(result.get("output"), **{k: v for k, v in result.items() if k != "output"})
290
291
  else:
291
292
  print(f"offset: {result['offset_seconds']:+.3f}s ({result['meaning']}), confidence {result['confidence']:.2f}")
292
293
  if drift_info:
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env python3
2
+ """Real-footage verification kit: run the whole toolchain against your own
3
+ files (phone HDR, GoPro, OBS screen captures, Log footage, Zoom recordings)
4
+ and get a pass/fail table. Synthetic test media never shows what a real
5
+ container does; this does.
6
+
7
+ Every file gets: probe, lossless cut, accurate cut, fit (9:16 pad), caption
8
+ burn, overlay text, loudness measurement, silence listing, export (x preset),
9
+ look (contact sheet), plus color --to-sdr when the file is HDR and
10
+ audio --downmix when it has more than 2 channels.
11
+
12
+ Examples:
13
+ python3 verify.py ~/Footage/*.MOV ~/Footage/*.mp4
14
+ python3 verify.py fixtures/ --quick --report verify.md
15
+ python3 verify.py clip.mov --keep --out ./verify_out
16
+ """
17
+ import argparse
18
+ import json
19
+ import os
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import time
24
+ from pathlib import Path
25
+ from typing import Dict, List
26
+
27
+ from _common import add_common, apply_common, die, emit, info, probe
28
+
29
+ HERE = Path(__file__).resolve().parent
30
+ MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac", ".aac"}
31
+
32
+
33
+ def collect(paths: List[str]) -> List[Path]:
34
+ files: List[Path] = []
35
+ for p in paths:
36
+ pp = Path(p)
37
+ if pp.is_dir():
38
+ files += sorted(x for x in pp.rglob("*") if x.suffix.lower() in MEDIA_EXT and x.is_file())
39
+ elif pp.is_file():
40
+ files.append(pp)
41
+ else:
42
+ die(f"not found: {p}")
43
+ if not files:
44
+ die("no media files found")
45
+ return files
46
+
47
+
48
+ def step(name: str, argv: List[str], timeout: float) -> Dict:
49
+ t0 = time.time()
50
+ try:
51
+ proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout)
52
+ ok = proc.returncode == 0
53
+ err = "" if ok else (proc.stderr.strip().splitlines() or ["?"])[-1][:200]
54
+ except subprocess.TimeoutExpired:
55
+ ok, err = False, f"timeout after {timeout:.0f}s"
56
+ return {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": err}
57
+
58
+
59
+ def main() -> int:
60
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
61
+ ap.add_argument("paths", nargs="+", help="media files and/or folders")
62
+ ap.add_argument("--out", help="directory for outputs (default: temp dir, deleted unless --keep)")
63
+ ap.add_argument("--keep", action="store_true", help="keep the outputs")
64
+ ap.add_argument("--quick", action="store_true", help="probe, copy cut, fit, caption, export only")
65
+ ap.add_argument("--seconds", type=float, default=6.0, help="length of the test cut taken from each file (default 6)")
66
+ ap.add_argument("--timeout", type=float, default=600.0, help="per-step timeout in seconds")
67
+ ap.add_argument("--report", help="write a Markdown report here")
68
+ add_common(ap)
69
+ args = ap.parse_args()
70
+ apply_common(args)
71
+
72
+ files = collect(args.paths)
73
+ tmp = None
74
+ if args.out:
75
+ outdir = Path(args.out)
76
+ outdir.mkdir(parents=True, exist_ok=True)
77
+ else:
78
+ tmp = tempfile.TemporaryDirectory(prefix="ffskill_verify_")
79
+ outdir = Path(tmp.name)
80
+
81
+ results = []
82
+ for f in files:
83
+ info(f"=== {f}")
84
+ entry: Dict = {"file": str(f), "steps": []}
85
+ try:
86
+ meta = probe(str(f))
87
+ except SystemExit:
88
+ entry["steps"].append({"step": "probe", "ok": False, "seconds": 0, "error": "ffprobe failed"})
89
+ results.append(entry)
90
+ continue
91
+ entry["probe"] = {k: meta.get(k) for k in ("duration", "format")}
92
+ entry["probe"]["video"] = {k: (meta.get("video") or {}).get(k) for k in ("codec", "width", "height", "fps", "pix_fmt", "hdr_format", "rotation", "variable_frame_rate_suspected")}
93
+ entry["probe"]["audio"] = {k: (meta.get("audio") or {}).get(k) for k in ("codec", "channels", "sample_rate")}
94
+ entry["steps"].append({"step": "probe", "ok": True, "seconds": 0, "error": ""})
95
+ dur = meta.get("duration") or 0.0
96
+ has_v, has_a = bool(meta.get("video")), bool(meta.get("audio"))
97
+ stem = outdir / f.stem
98
+ cut = f"{stem}_cut.mp4"
99
+ seg_end = min(dur, args.seconds) if dur else args.seconds
100
+ fast = ["--fast"]
101
+ plan = []
102
+ plan.append(("cut copy", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "-o", cut]))
103
+ if has_v:
104
+ plan.append(("cut accurate", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "--accurate", "-o", f"{stem}_acc.mp4"] + fast))
105
+ plan.append(("fit 9:16", ["fit.py", cut, "--aspect", "9:16", "--width", "720", "-o", f"{stem}_fit.mp4"] + fast))
106
+ cues = outdir / f"{f.stem}_cues.txt"
107
+ cues.write_text("0:00-0:02 Verification caption\n0:02-0:04 Second | line\n", encoding="utf-8")
108
+ plan.append(("caption", ["caption.py", cut, "--text", str(cues), "--animate", "pop", "--karaoke", "-o", f"{stem}_cap.mp4"] + fast))
109
+ if not args.quick:
110
+ plan.append(("overlay text", ["overlay.py", cut, "--text", "verify", "--position", "top-left", "-o", f"{stem}_ovl.mp4"] + fast))
111
+ plan.append(("look sheet", ["look.py", cut, "-o", f"{stem}_sheet.png"]))
112
+ if (meta.get("video") or {}).get("hdr"):
113
+ plan.append(("color to-sdr", ["color.py", cut, "--to-sdr", "-o", f"{stem}_sdr.mp4"] + fast))
114
+ plan.append(("probe analyze", ["probe.py", cut, "--analyze"]))
115
+ plan.append(("export x", ["export.py", cut, "--preset", "x", "-o", f"{stem}_x.mp4"]))
116
+ if has_a and not args.quick:
117
+ plan.append(("loudness measure", ["loudness.py", str(f), "--measure-only"]))
118
+ plan.append(("silence list", ["silence.py", cut, "--list"]))
119
+ if (meta.get("audio") or {}).get("channels", 0) > 2:
120
+ plan.append(("audio downmix", ["audio.py", cut, "--downmix", "-o", f"{stem}_st.mp4"]))
121
+ for name, argv in plan:
122
+ r = step(name, argv, args.timeout)
123
+ entry["steps"].append(r)
124
+ info(f" {'PASS' if r['ok'] else 'FAIL'} {name:16s} {r['seconds']:6.1f}s {r['error']}")
125
+ results.append(entry)
126
+
127
+ total = sum(len(e["steps"]) for e in results)
128
+ failed = sum(1 for e in results for s in e["steps"] if not s["ok"])
129
+ lines = ["# ffmpeg-skill verification", "", f"{len(files)} files, {total} steps, {failed} failed", ""]
130
+ for e in results:
131
+ p = e.get("probe", {})
132
+ v, a = p.get("video", {}), p.get("audio", {})
133
+ lines.append(f"## {e['file']}")
134
+ lines.append(f"{p.get('duration')}s, {v.get('codec')} {v.get('width')}x{v.get('height')} @ {v.get('fps')} {v.get('pix_fmt')}"
135
+ + (f" [{v.get('hdr_format')}]" if v.get("hdr_format") else "") + (" [VFR?]" if v.get("variable_frame_rate_suspected") else "")
136
+ + (f" rot {v.get('rotation')}" if v.get("rotation") else "") + f", audio {a.get('codec')} {a.get('channels')}ch")
137
+ lines.append("")
138
+ lines.append("| step | result | time | error |")
139
+ lines.append("|---|---|---|---|")
140
+ for s in e["steps"]:
141
+ lines.append(f"| {s['step']} | {'PASS' if s['ok'] else 'FAIL'} | {s['seconds']}s | {s['error']} |")
142
+ lines.append("")
143
+ report = "\n".join(lines)
144
+ if args.report:
145
+ Path(args.report).write_text(report, encoding="utf-8")
146
+ info(f"wrote {args.report}")
147
+ if args.json:
148
+ emit(None, report=args.report, files=results, failed=failed, total=total)
149
+ else:
150
+ print(report)
151
+ if tmp and not args.keep:
152
+ tmp.cleanup()
153
+ return 1 if failed else 0
154
+
155
+
156
+ if __name__ == "__main__":
157
+ sys.exit(main())