ffmpeg-skill 0.12.5 → 0.16.13

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 (60) hide show
  1. package/README.md +19 -6
  2. package/SKILL.md +14 -2
  3. package/bin/install.js +12 -3
  4. package/mcp/server.py +8 -0
  5. package/package.json +2 -2
  6. package/references/scripts.md +160 -1
  7. package/scripts/_common.py +57 -3
  8. package/scripts/_contract.py +56 -5
  9. package/scripts/background.py +11 -2
  10. package/scripts/batch.py +58 -5
  11. package/scripts/caption.py +37 -3
  12. package/scripts/check.py +8 -0
  13. package/scripts/color.py +9 -1
  14. package/scripts/crop.py +2 -0
  15. package/scripts/cropdetect.py +106 -0
  16. package/scripts/cut.py +4 -0
  17. package/scripts/deinterlace.py +85 -0
  18. package/scripts/denoise.py +94 -0
  19. package/scripts/export.py +2 -1
  20. package/scripts/fit.py +14 -3
  21. package/scripts/freeze.py +108 -0
  22. package/scripts/graphics.py +1 -1
  23. package/scripts/grid.py +142 -0
  24. package/scripts/join.py +4 -1
  25. package/scripts/loop.py +80 -0
  26. package/scripts/multicam.py +10 -2
  27. package/scripts/overlay.py +7 -2
  28. package/scripts/pad.py +68 -0
  29. package/scripts/redact.py +100 -0
  30. package/scripts/render.py +17 -3
  31. package/scripts/silence.py +6 -3
  32. package/scripts/speedramp.py +123 -0
  33. package/scripts/sphere.py +126 -0
  34. package/scripts/straighten.py +97 -0
  35. package/scripts/verify.py +25 -2
  36. package/scripts/waveform.py +92 -0
  37. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  38. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  39. package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
  40. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  41. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  42. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  43. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  44. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  45. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  46. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  47. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  48. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  49. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  50. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  51. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  52. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  53. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  54. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  55. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  56. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  57. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  58. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  59. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  60. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
@@ -16,7 +16,7 @@ import re
16
16
  import sys
17
17
  from typing import List, Tuple
18
18
 
19
- from _common import video_args, aac_args, add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run, x264_args
19
+ from _common import video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args
20
20
 
21
21
  SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
22
22
 
@@ -109,9 +109,12 @@ def main() -> int:
109
109
  vf = f"select='{expr}',setpts=N/FRAME_RATE/TB"
110
110
  af = f"aselect='{expr}',asetpts=N/SR/TB"
111
111
  cmd = ffmpeg_base() + ["-i", args.input]
112
- if meta.get("video"):
112
+ audio_only = is_audio_output(output) or not meta.get("video")
113
+ if audio_only:
114
+ cmd += ["-vn"]
115
+ else:
113
116
  cmd += ["-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
114
- cmd += ["-af", af] + aac_args() + [output]
117
+ cmd += ["-af", af] + audio_codec_for(output) + [output]
115
118
  run(cmd)
116
119
  r = probe(output, role="output")
117
120
  info(f"wrote {output} ({r['duration']:.3f}s, expected ~{kept:.3f}s)")
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """Step through different constant speeds across a clip's timeline (a speed ramp).
3
+
4
+ Distinct from fit.py --duration --method speed, which applies one constant
5
+ factor to the whole clip. This tool takes a list of --segment START-END:FACTOR
6
+ pieces covering the clip start to end with no gaps or overlaps, each played
7
+ at its own constant speed (pitch-preserving audio, matching fit.py), then
8
+ concatenates them back together -- the classic "speed up, then slow way
9
+ down for the punch, then speed back up" edit, built from a few constant
10
+ segments rather than a continuous curve (which this tool does not attempt:
11
+ picking exactly where a ramp should ease in or out is a judgement call for
12
+ the calling agent, made concrete here as segment boundaries it supplies).
13
+
14
+ Examples:
15
+ python3 speedramp.py action.mp4 --segment 0-3:1.0 --segment 3-4:0.25 --segment 4-8:2.0
16
+ python3 speedramp.py clip.mp4 --segment 0-2:2.0 --segment 2-6:1.0
17
+ """
18
+ import argparse
19
+ import sys
20
+ from typing import List, Tuple
21
+
22
+ from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
23
+
24
+ MAX_SPEED = 20.0
25
+ MIN_SPEED = 0.05
26
+
27
+
28
+ def atempo_chain(factor: float) -> str:
29
+ """atempo accepts 0.5..100 per instance; chain for factors outside that range."""
30
+ parts: List[str] = []
31
+ remaining = factor
32
+ while remaining < 0.5:
33
+ parts.append("atempo=0.5")
34
+ remaining /= 0.5
35
+ while remaining > 100.0:
36
+ parts.append("atempo=100.0")
37
+ remaining /= 100.0
38
+ parts.append(f"atempo={remaining:.6f}")
39
+ return ",".join(parts)
40
+
41
+
42
+ def parse_segment(raw: str) -> Tuple[float, float, float]:
43
+ try:
44
+ span, factor_s = raw.split(":")
45
+ start_s, end_s = span.split("-")
46
+ start, end, factor = float(start_s), float(end_s), float(factor_s)
47
+ except ValueError:
48
+ die(f"--segment must look like START-END:FACTOR, got '{raw}'")
49
+ if end <= start:
50
+ die(f"--segment {raw}: END must be after START")
51
+ if not MIN_SPEED <= factor <= MAX_SPEED:
52
+ die(f"--segment {raw}: FACTOR must be {MIN_SPEED}..{MAX_SPEED}")
53
+ return start, end, factor
54
+
55
+
56
+ def main() -> int:
57
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
58
+ ap.add_argument("input")
59
+ ap.add_argument("-o", "--output", help="output file (default: <name>_ramp.<ext>)")
60
+ ap.add_argument("--segment", action="append", required=True, dest="segments",
61
+ help=f"START-END:FACTOR, repeatable; segments must cover 0..duration with no gaps or overlaps, in order. FACTOR is {MIN_SPEED}..{MAX_SPEED} (2.0 = twice as fast, 0.5 = half speed)")
62
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
63
+ ap.add_argument("--preset", default="medium", help="x264 preset")
64
+ add_common(ap)
65
+ args = ap.parse_args()
66
+ apply_common(args)
67
+
68
+ segments = [parse_segment(s) for s in args.segments]
69
+ segments.sort(key=lambda s: s[0])
70
+ meta = probe(args.input)
71
+ if not meta.get("video"):
72
+ die("input has no video stream")
73
+ dur = meta.get("duration") or 0.0
74
+ if abs(segments[0][0] - 0.0) > 0.01:
75
+ die(f"segments must start at 0, first segment starts at {segments[0][0]:g}")
76
+ if abs(segments[-1][1] - dur) > 0.5:
77
+ die(f"segments must cover the whole clip (0..{dur:.3f}), last segment ends at {segments[-1][1]:g}")
78
+ for i in range(len(segments) - 1):
79
+ if abs(segments[i][1] - segments[i + 1][0]) > 0.01:
80
+ die(f"segments must be contiguous with no gap/overlap: segment {i} ends at {segments[i][1]:g}, "
81
+ f"segment {i + 1} starts at {segments[i + 1][0]:g}")
82
+ has_audio = bool(meta.get("audio"))
83
+ output = args.output or default_output(args.input, "ramp")
84
+
85
+ vparts, aparts, labels = [], [], []
86
+ for i, (start, end, factor) in enumerate(segments):
87
+ vlabel, alabel = f"v{i}", f"a{i}"
88
+ end_expr = f"{end:.3f}" if i < len(segments) - 1 else None
89
+ trim = f"trim=start={start:.3f}" + (f":end={end_expr}" if end_expr else "")
90
+ vparts.append(f"[0:v]{trim},setpts=(PTS-STARTPTS)/{factor:.6f}[{vlabel}]")
91
+ labels.append(f"[{vlabel}]")
92
+ if has_audio:
93
+ atrim = f"atrim=start={start:.3f}" + (f":end={end_expr}" if end_expr else "")
94
+ aparts.append(f"[0:a]{atrim},asetpts=PTS-STARTPTS,{atempo_chain(factor)}[{alabel}]")
95
+
96
+ if has_audio:
97
+ concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(len(segments)))
98
+ fc = ";".join(vparts + aparts) + f";{concat_inputs}concat=n={len(segments)}:v=1:a=1[outv][outa]"
99
+ maps = ["-map", "[outv]", "-map", "[outa]"]
100
+ else:
101
+ concat_inputs = "".join(f"[v{i}]" for i in range(len(segments)))
102
+ fc = ";".join(vparts) + f";{concat_inputs}concat=n={len(segments)}:v=1:a=0[outv]"
103
+ maps = ["-map", "[outv]"]
104
+
105
+ cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc] + maps
106
+ cmd += video_args(meta, args.crf, args.preset)
107
+ cmd += ["-fps_mode", "cfr", "-r", f"{meta['video'].get('fps') or 30.0:g}"]
108
+ if has_audio:
109
+ cmd += aac_args()
110
+ else:
111
+ cmd += ["-an"]
112
+ cmd.append(output)
113
+ run(cmd)
114
+
115
+ result = probe(output, role="output")
116
+ v = result["video"]
117
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {len(segments)} speed segments)")
118
+ emit(output)
119
+ return 0
120
+
121
+
122
+ if __name__ == "__main__":
123
+ sys.exit(main())
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env python3
2
+ """Extract a flat (rectilinear) viewport from a 360/spherical video.
3
+
4
+ This wraps FFmpeg's v360 filter for the one job it is most often needed for:
5
+ turning an equirectangular (or other spherical-projection) source into an
6
+ ordinary flat video pointed at a chosen direction -- the same "look this way"
7
+ operation a VR headset or a 360 video player's viewport does, baked into a
8
+ real file. --yaw/--pitch/--roll aim the camera; --h-fov/--v-fov set how wide
9
+ the view is.
10
+
11
+ This tool does not decide WHERE to point the camera -- there is no subject
12
+ detection or tracking here, only the typed rotation/FOV you give it (see this
13
+ skill's design principles: it measures and transforms, it does not judge
14
+ "what's interesting" in a frame). For a shot that follows a moving subject,
15
+ call this once per keyframe viewpoint (or render a short segment per angle)
16
+ from outside this tool.
17
+
18
+ Nor does it detect whether an input actually IS a 360/spherical video --
19
+ probe.py's frame dimensions don't distinguish a 2:1 equirectangular capture
20
+ from an ordinary flat clip that happens to be that aspect ratio. Point
21
+ --input-projection at what the source actually is; wrong information here
22
+ produces a distorted or garbled output, not an error (a real limit of what a
23
+ frame's own pixels can prove about how they were projected -- ffmpeg's own
24
+ v360 filter has the same limit).
25
+
26
+ Examples:
27
+ python3 sphere.py insta360.mp4 --yaw 0 --pitch 0 -o front.mp4
28
+ python3 sphere.py insta360.mp4 --yaw 90 --h-fov 100 --v-fov 70 -o right_wide.mp4
29
+ python3 sphere.py gopro_max.mp4 --input-projection fisheye --yaw -45 --pitch 10 -o angle.mp4
30
+ """
31
+ import argparse
32
+ import sys
33
+
34
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
35
+
36
+ # v360's own AVOption names for the input projections real 360 cameras/exports actually
37
+ # produce (ffmpeg -h filter=v360 documents 24 total; this is the subset a caller is likely
38
+ # to have on hand, not the full list -- narrower, typed choices over an open string).
39
+ INPUT_PROJECTIONS = ["equirect", "fisheye", "dfisheye", "c3x2", "c6x1", "barrel", "cylindrical", "hequirect"]
40
+ INTERP_METHODS = ["nearest", "linear", "cubic", "lanczos", "spline16", "gaussian", "mitchell"]
41
+ STEREO_MODES = {"mono": "2d", "sbs": "sbs", "tb": "tb"}
42
+
43
+
44
+ def main() -> int:
45
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
46
+ ap.add_argument("input")
47
+ ap.add_argument("-o", "--output", help="output file (default: <name>_view.<ext>)")
48
+ ap.add_argument("--audio-stream", type=int, default=0,
49
+ help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
50
+ "audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
51
+ "the first track")
52
+ ap.add_argument("--input-projection", choices=INPUT_PROJECTIONS, default="equirect",
53
+ help="the source's own 360 projection (default equirect, the most common capture/export format)")
54
+ ap.add_argument("--stereo", choices=list(STEREO_MODES), default="mono",
55
+ help="input stereo packing: mono (default), sbs (side-by-side), tb (top-bottom) -- always flattened to a mono output")
56
+ aim = ap.add_argument_group("camera aim")
57
+ aim.add_argument("--yaw", type=float, default=0.0, help="left/right rotation in degrees, -180..180 (default 0, straight ahead)")
58
+ aim.add_argument("--pitch", type=float, default=0.0, help="up/down rotation in degrees, -180..180 (default 0, level)")
59
+ aim.add_argument("--roll", type=float, default=0.0, help="tilt/roll rotation in degrees, -180..180 (default 0)")
60
+ fov = ap.add_argument_group("field of view")
61
+ fov.add_argument("--h-fov", type=float, default=90.0, help="output horizontal field of view in degrees, 1..170 (default 90)")
62
+ fov.add_argument("--v-fov", type=float, default=60.0, help="output vertical field of view in degrees, 1..170 (default 60)")
63
+ out = ap.add_argument_group("output frame")
64
+ out.add_argument("--width", type=int, default=1920, help="output width in px, must be even (default 1920)")
65
+ out.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
66
+ out.add_argument("--interp", choices=INTERP_METHODS, default="lanczos", help="resampling method (default lanczos)")
67
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
68
+ ap.add_argument("--preset", default="medium", help="x264 preset")
69
+ ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
70
+ add_common(ap)
71
+ args = ap.parse_args()
72
+ apply_common(args)
73
+ if args.fps is not None and args.fps <= 0:
74
+ die(f"--fps must be positive, got {args.fps:g}")
75
+
76
+ if not -180 <= args.yaw <= 180:
77
+ die(f"--yaw must be -180..180, got {args.yaw}")
78
+ if not -180 <= args.pitch <= 180:
79
+ die(f"--pitch must be -180..180, got {args.pitch}")
80
+ if not -180 <= args.roll <= 180:
81
+ die(f"--roll must be -180..180, got {args.roll}")
82
+ if not 1 <= args.h_fov <= 170:
83
+ die(f"--h-fov must be 1..170, got {args.h_fov}")
84
+ if not 1 <= args.v_fov <= 170:
85
+ die(f"--v-fov must be 1..170, got {args.v_fov}")
86
+ if args.width <= 0 or args.height <= 0:
87
+ die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
88
+ if args.width % 2 or args.height % 2:
89
+ die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
90
+
91
+ meta = probe(args.input)
92
+ if not meta.get("video"):
93
+ die("input has no video stream")
94
+ has_audio = bool(meta.get("audio"))
95
+ audio_streams = meta.get("audio_streams") or []
96
+ if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
97
+ die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
98
+ if args.audio_stream and not audio_streams:
99
+ die("--audio-stream needs an input with audio streams")
100
+ output = args.output or default_output(args.input, "view")
101
+
102
+ v360 = (f"v360=input={args.input_projection}:output=rectilinear:"
103
+ f"in_stereo={STEREO_MODES[args.stereo]}:out_stereo=2d:"
104
+ f"yaw={args.yaw:g}:pitch={args.pitch:g}:roll={args.roll:g}:"
105
+ f"h_fov={args.h_fov:g}:v_fov={args.v_fov:g}:"
106
+ f"w={args.width}:h={args.height}:interp={args.interp}")
107
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", v360, "-map", "0:v:0"]
108
+ if has_audio:
109
+ cmd += ["-map", f"0:a:{args.audio_stream}?"]
110
+ cmd += video_args(meta, args.crf, args.preset)
111
+ cmd += cfr_args(meta, args.fps)
112
+ if has_audio:
113
+ cmd += aac_args()
114
+ else:
115
+ cmd += ["-an"]
116
+ dropped_streams = run_keeping_subtitles(cmd, output)
117
+
118
+ result = probe(output, role="output")
119
+ v = result["video"]
120
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, yaw={args.yaw:g} pitch={args.pitch:g})")
121
+ emit(output, dropped_non_av_streams=dropped_streams)
122
+ return 0
123
+
124
+
125
+ if __name__ == "__main__":
126
+ sys.exit(main())
@@ -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())