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.
- package/README.md +19 -6
- package/SKILL.md +14 -2
- package/bin/install.js +12 -3
- package/mcp/server.py +8 -0
- package/package.json +2 -2
- package/references/scripts.md +160 -1
- package/scripts/_common.py +57 -3
- package/scripts/_contract.py +56 -5
- package/scripts/background.py +11 -2
- package/scripts/batch.py +58 -5
- package/scripts/caption.py +37 -3
- package/scripts/check.py +8 -0
- package/scripts/color.py +9 -1
- package/scripts/crop.py +2 -0
- package/scripts/cropdetect.py +106 -0
- package/scripts/cut.py +4 -0
- package/scripts/deinterlace.py +85 -0
- package/scripts/denoise.py +94 -0
- package/scripts/export.py +2 -1
- package/scripts/fit.py +14 -3
- package/scripts/freeze.py +108 -0
- package/scripts/graphics.py +1 -1
- package/scripts/grid.py +142 -0
- package/scripts/join.py +4 -1
- package/scripts/loop.py +80 -0
- package/scripts/multicam.py +10 -2
- package/scripts/overlay.py +7 -2
- package/scripts/pad.py +68 -0
- package/scripts/redact.py +100 -0
- package/scripts/render.py +17 -3
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +123 -0
- package/scripts/sphere.py +126 -0
- package/scripts/straighten.py +97 -0
- package/scripts/verify.py +25 -2
- package/scripts/waveform.py +92 -0
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.cpython-311.pyc +0 -0
- package/scripts/__pycache__/report.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.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/silence.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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] +
|
|
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
|
|
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
|
|
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())
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|