ffmpeg-skill 0.12.0 → 0.16.12

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.
@@ -9,12 +9,18 @@ this run only -- it is not a caller-facing artifact.
9
9
  --shakiness (1 = barely shaky, fast; 10 = very shaky, slow analysis) and
10
10
  --smoothing (how many neighbouring frames to average the camera path over)
11
11
  are the two knobs that matter most; --zoom crops in slightly to hide the
12
- black edges stabilizing can introduce (0 = keep the original framing and let
13
- edges show; ffmpeg's own --crop-mode is not exposed as a raw flag here).
12
+ edges stabilizing can introduce (0 = keep the original framing and let edges
13
+ show). --crop chooses what happens to any edge vidstab reveals that --zoom
14
+ doesn't crop away: "keep" (default) stretches the border pixels, "black"
15
+ fills it in solid black instead. --tripod locks the frame fully still
16
+ against a single reference frame (e.g. a camera meant to be static but
17
+ nudged, or a shot you want dead-locked rather than merely smoothed) instead
18
+ of following the camera's intended motion.
14
19
 
15
20
  Examples:
16
21
  python3 stabilize.py shaky.mp4
17
22
  python3 stabilize.py shaky.mp4 --shakiness 8 --smoothing 20 --zoom 5
23
+ python3 stabilize.py locked-off.mp4 --tripod --crop black
18
24
  """
19
25
  import argparse
20
26
  import sys
@@ -31,6 +37,8 @@ def main() -> int:
31
37
  ap.add_argument("--shakiness", type=int, default=5, help="1 (barely shaky) .. 10 (very shaky), default 5")
32
38
  ap.add_argument("--smoothing", type=int, default=15, help="frames of camera-path smoothing on each side, default 15")
33
39
  ap.add_argument("--zoom", type=float, default=0.0, help="percent to zoom in to hide stabilization edges, 0..100 (default 0)")
40
+ ap.add_argument("--crop", choices=["keep", "black"], default="keep", help="edges --zoom doesn't crop away: keep (stretch border pixels, default) or black (fill solid black)")
41
+ ap.add_argument("--tripod", action="store_true", help="lock the frame fully still against a single reference frame instead of smoothing the camera's motion")
34
42
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
35
43
  ap.add_argument("--preset", default="medium", help="x264 preset")
36
44
  add_common(ap)
@@ -56,13 +64,23 @@ def main() -> int:
56
64
 
57
65
  if not STATE["dry_run"]:
58
66
  ffmpeg = require_tool("ffmpeg")
67
+ detect_vf = f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
68
+ if args.tripod:
69
+ # A frame number, not a boolean: frame 1 is the standard reference for "lock to
70
+ # this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
71
+ detect_vf += ":tripod=1"
59
72
  detect_cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", args.input,
60
- "-vf", f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}", "-f", "null", "-"]
73
+ "-vf", detect_vf, "-f", "null", "-"]
61
74
  proc = run(detect_cmd, check=False)
62
75
  if proc.returncode != 0:
63
76
  die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}")
64
77
 
65
- transform_vf = f"vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:zoom={args.zoom:g}:optzoom=1"
78
+ crop_mode = {"keep": 0, "black": 1}[args.crop]
79
+ transform_vf = f"vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
80
+ if args.tripod:
81
+ # Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
82
+ # camera path makes no sense once every frame is locked to one fixed reference.
83
+ transform_vf += ":tripod=1"
66
84
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", transform_vf]
67
85
  cmd += video_args(meta, args.crf, args.preset)
68
86
  cmd += cfr_args(meta)
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+ """Rotate a video by an arbitrary angle (horizon correction, not a 90/180/270 turn).
3
+
4
+ Distinct from fit.py --rotate, which only turns the picture in exact 90-degree
5
+ steps (swapping width/height, lossless in intent). This tool wraps FFmpeg's
6
+ rotate filter for a small corrective tilt -- "the horizon is 2 degrees off" --
7
+ which necessarily crops or pads the corners: rotating a rectangle by a
8
+ non-90-degree angle leaves triangular gaps at the corners. --fit crop scales
9
+ up just enough to fill the frame with no visible gap (losing a thin border
10
+ of the original picture); --fit pad keeps the full original picture inside
11
+ the rotated frame and fills the gaps with --fill-color.
12
+
13
+ This tool does not measure the tilt itself -- it has no way to find a
14
+ horizon line in a frame; that is a look.py/vision judgement call. Give the
15
+ degrees once you can see how far off it is.
16
+
17
+ Examples:
18
+ python3 straighten.py tilted.mp4 --degrees -2.5
19
+ python3 straighten.py handheld.mp4 --degrees 1.2 --fit pad --fill-color 0x101010
20
+ """
21
+ import argparse
22
+ import math
23
+ import sys
24
+
25
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args
26
+
27
+
28
+ def main() -> int:
29
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
30
+ ap.add_argument("input")
31
+ ap.add_argument("-o", "--output", help="output file (default: <name>_straighten.<ext>)")
32
+ ap.add_argument("--degrees", type=float, required=True, help="rotation angle in degrees, -45..45, positive = clockwise")
33
+ ap.add_argument("--fit", choices=["crop", "pad"], default="crop",
34
+ help="crop (default): scale up to fill the frame, no visible corner gap; pad: keep the full picture, fill the corner gaps with --fill-color")
35
+ ap.add_argument("--fill-color", default="black", help="corner fill colour with --fit pad (default black)")
36
+ ap.add_argument("--audio-stream", type=int, default=0,
37
+ help="which audio stream of the input to keep, 0-based in file order (default 0)")
38
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
39
+ ap.add_argument("--preset", default="medium", help="x264 preset")
40
+ ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
41
+ add_common(ap)
42
+ args = ap.parse_args()
43
+ apply_common(args)
44
+ if args.fps is not None and args.fps <= 0:
45
+ die(f"--fps must be positive, got {args.fps:g}")
46
+
47
+ if not -45 <= args.degrees <= 45:
48
+ die(f"--degrees must be -45..45, got {args.degrees:g}")
49
+ if args.degrees == 0:
50
+ die("--degrees must be nonzero (nothing to straighten)")
51
+ validate_color(args.fill_color, "--fill-color")
52
+
53
+ meta = probe(args.input)
54
+ if not meta.get("video"):
55
+ die("input has no video stream")
56
+ has_audio = bool(meta.get("audio"))
57
+ audio_streams = meta.get("audio_streams") or []
58
+ if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
59
+ die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
60
+ if args.audio_stream and not audio_streams:
61
+ die("--audio-stream needs an input with audio streams")
62
+ output = args.output or default_output(args.input, "straighten")
63
+
64
+ radians = math.radians(args.degrees)
65
+ if args.fit == "pad":
66
+ rotate = (f"rotate={radians:.8f}:fillcolor={args.fill_color}:"
67
+ f"ow=trunc(rotw({radians:.8f})/2)*2:oh=trunc(roth({radians:.8f})/2)*2")
68
+ else:
69
+ # Pre-scale the frame up by a safe, conservative factor (|cos|+|sin|, the exact growth
70
+ # factor for a square, over-generous for a rectangle) so the rotated content fully
71
+ # covers the original W:H window with no black corner, then rotate in place (canvas
72
+ # stays at the scaled size) and crop back down to the original W:H, centred.
73
+ s = abs(math.cos(radians)) + abs(math.sin(radians))
74
+ # crop dimensions must round down to even (4:2:0 chroma); trunc(.../2)*2 floors to the
75
+ # nearest even value instead of leaving an odd width/height that the encoder would refuse.
76
+ rotate = f"scale=iw*{s:.8f}:ih*{s:.8f},rotate={radians:.8f},crop=trunc(iw/{s:.8f}/2)*2:trunc(ih/{s:.8f}/2)*2"
77
+
78
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", rotate, "-map", "0:v:0"]
79
+ if has_audio:
80
+ cmd += ["-map", f"0:a:{args.audio_stream}?"]
81
+ cmd += video_args(meta, args.crf, args.preset)
82
+ cmd += cfr_args(meta, args.fps)
83
+ if has_audio:
84
+ cmd += aac_args()
85
+ else:
86
+ cmd += ["-an"]
87
+ dropped_streams = run_keeping_subtitles(cmd, output)
88
+
89
+ result = probe(output, role="output")
90
+ v = result["video"]
91
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, degrees={args.degrees:g}, fit={args.fit})")
92
+ emit(output, dropped_non_av_streams=dropped_streams)
93
+ return 0
94
+
95
+
96
+ if __name__ == "__main__":
97
+ sys.exit(main())
package/scripts/verify.py CHANGED
@@ -30,6 +30,28 @@ HERE = Path(__file__).resolve().parent
30
30
  MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac", ".aac"}
31
31
 
32
32
 
33
+ def unique_stems(files: List[Path]) -> Dict[Path, str]:
34
+ """Every file's outputs share one flat --out directory (stem = outdir / f.stem), so two files
35
+ with the same basename from different folders -- entirely normal for real footage collected
36
+ from multiple cameras/SD cards, e.g. two "clip.mp4"s in separate campaign folders -- used to
37
+ resolve to the identical output prefix. Each file's own steps still ran correctly in isolation,
38
+ but with --keep the second file's outputs silently overwrote the first file's on disk, and the
39
+ report showed PASS for both without ever flagging the collision (same bug class already fixed
40
+ in batch.py). Disambiguate every colliding stem with a stable per-collision index instead."""
41
+ counts: Dict[str, int] = {}
42
+ for f in files:
43
+ counts[f.stem] = counts.get(f.stem, 0) + 1
44
+ seen: Dict[str, int] = {}
45
+ stems: Dict[Path, str] = {}
46
+ for f in files:
47
+ if counts[f.stem] > 1:
48
+ seen[f.stem] = seen.get(f.stem, 0) + 1
49
+ stems[f] = f"{f.stem}_{seen[f.stem]}"
50
+ else:
51
+ stems[f] = f.stem
52
+ return stems
53
+
54
+
33
55
  def collect(paths: List[str]) -> List[Path]:
34
56
  files: List[Path] = []
35
57
  for p in paths:
@@ -86,6 +108,7 @@ def main() -> int:
86
108
  tmp = tempfile.TemporaryDirectory(prefix="ffskill_verify_")
87
109
  outdir = Path(tmp.name)
88
110
 
111
+ stem_for = unique_stems(files)
89
112
  results = []
90
113
  for f in files:
91
114
  info(f"=== {f}")
@@ -102,7 +125,7 @@ def main() -> int:
102
125
  entry["steps"].append({"step": "probe", "ok": True, "seconds": 0, "error": ""})
103
126
  dur = meta.get("duration") or 0.0
104
127
  has_v, has_a = bool(meta.get("video")), bool(meta.get("audio"))
105
- stem = outdir / f.stem
128
+ stem = outdir / stem_for[f]
106
129
  cut = f"{stem}_cut.mp4"
107
130
  seg_end = min(dur, args.seconds) if dur else args.seconds
108
131
  fast = ["--fast"]
@@ -111,7 +134,7 @@ def main() -> int:
111
134
  if has_v:
112
135
  plan.append(("cut accurate", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "--accurate", "-o", f"{stem}_acc.mp4"] + fast))
113
136
  plan.append(("fit 9:16", ["fit.py", cut, "--aspect", "9:16", "--width", "720", "-o", f"{stem}_fit.mp4"] + fast))
114
- cues = outdir / f"{f.stem}_cues.txt"
137
+ cues = outdir / f"{stem_for[f]}_cues.txt"
115
138
  cues.write_text("0:00-0:02 Verification caption\n0:02-0:04 Second | line\n", encoding="utf-8")
116
139
  plan.append(("caption", ["caption.py", cut, "--text", str(cues), "--animate", "pop", "--karaoke", "-o", f"{stem}_cap.mp4"] + fast))
117
140
  if not args.quick:
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env python3
2
+ """Render an audio track as a waveform or spectrum visualization video.
3
+
4
+ Wraps FFmpeg's showwaves (--style waveform, the default) or showspectrum
5
+ (--style spectrum) source filter over the input's audio -- for a podcast
6
+ episode, a music release, or any clip that has no picture worth showing.
7
+ The rendered clip always carries the same audio it visualizes; the video is
8
+ generated fresh, there is no source picture involved.
9
+
10
+ --style waveform draws the amplitude over time; --style spectrum draws a
11
+ frequency-over-time heatmap instead, which reads more information out of
12
+ dense mixes at the cost of being less immediately readable to a general
13
+ audience. Both accept --width/--height and --color; waveform additionally
14
+ takes --waveform-mode (how each sample is drawn) and --split-channels
15
+ (stereo drawn as two separate lanes instead of summed to one).
16
+
17
+ Examples:
18
+ python3 waveform.py podcast.wav -o waveform.mp4
19
+ python3 waveform.py track.wav --style spectrum --width 1920 --height 1080 -o spectrum.mp4
20
+ python3 waveform.py interview.mp4 --split-channels --color cyan|magenta
21
+ """
22
+ import argparse
23
+ import sys
24
+
25
+ from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color
26
+
27
+ WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
28
+
29
+
30
+ def main() -> int:
31
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
32
+ ap.add_argument("input")
33
+ ap.add_argument("-o", "--output", help="output file (default: <name>_waveform.<ext>)")
34
+ ap.add_argument("--style", choices=["waveform", "spectrum"], default="waveform", help="waveform (default) or spectrum visualization")
35
+ ap.add_argument("--width", type=int, default=1920, help="output width in px, must be even (default 1920)")
36
+ ap.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
37
+ ap.add_argument("--fps", type=float, default=25.0, help="output frame rate (default 25)")
38
+ ap.add_argument("--color", default="lime", help="channel colour(s), pipe-separated per channel, e.g. 'lime' or 'cyan|magenta' (default lime)")
39
+ ap.add_argument("--background", default="black", help="background colour (default black)")
40
+ ap.add_argument("--waveform-mode", choices=WAVEFORM_MODES, default="line", help="--style waveform only: how each sample is drawn (default line)")
41
+ ap.add_argument("--split-channels", action="store_true", help="draw each channel in its own lane instead of summing to one")
42
+ ap.add_argument("--audio-stream", type=int, default=0,
43
+ help="which audio stream of the input to render, 0-based in file order (default 0)")
44
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
45
+ ap.add_argument("--preset", default="medium", help="x264 preset")
46
+ add_common(ap)
47
+ args = ap.parse_args()
48
+ apply_common(args)
49
+
50
+ if args.width <= 0 or args.height <= 0:
51
+ die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
52
+ if args.width % 2 or args.height % 2:
53
+ die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
54
+ for token in args.color.split("|"):
55
+ validate_color(token, "--color")
56
+ validate_color(args.background, "--background")
57
+ if args.fps <= 0:
58
+ die(f"--fps must be > 0, got {args.fps:g}")
59
+
60
+ meta = probe(args.input)
61
+ if not meta.get("audio"):
62
+ die("input has no audio stream")
63
+ audio_streams = meta.get("audio_streams") or []
64
+ if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
65
+ die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
66
+ output = args.output or default_output(args.input, "waveform")
67
+
68
+ if args.style == "waveform":
69
+ vf = (f"showwaves=s={args.width}x{args.height}:mode={args.waveform_mode}:rate={args.fps:g}:"
70
+ f"split_channels={1 if args.split_channels else 0}:colors={args.color}")
71
+ else:
72
+ vf = f"showspectrum=s={args.width}x{args.height}:mode={'separate' if args.split_channels else 'combined'}:fps={args.fps:g}"
73
+ # showwaves/showspectrum paint the visualization on a transparent-black canvas; composite
74
+ # it over an explicit solid background instead of assuming that canvas already matches
75
+ # --background.
76
+ vf = f"color=c={args.background}:s={args.width}x{args.height}:r={args.fps:g}[bg];[0:a:{args.audio_stream}]{vf}[vis];[bg][vis]overlay=format=auto"
77
+
78
+ cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", vf, "-map", f"0:a:{args.audio_stream}"]
79
+ cmd += ["-c:v", "libx264", "-preset", args.preset, "-crf", str(args.crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
80
+ cmd += aac_args()
81
+ cmd += ["-shortest", output]
82
+ run(cmd)
83
+
84
+ result = probe(output, role="output")
85
+ v = result["video"]
86
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {args.style})")
87
+ emit(output)
88
+ return 0
89
+
90
+
91
+ if __name__ == "__main__":
92
+ sys.exit(main())