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.
- package/README.md +15 -4
- package/SKILL.md +80 -6
- package/package.json +2 -2
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.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__/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__/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/_common.py +137 -3
- package/scripts/audio.py +4 -2
- package/scripts/caption.py +73 -6
- package/scripts/color.py +24 -3
- package/scripts/cut.py +5 -3
- package/scripts/export.py +5 -3
- package/scripts/fit.py +5 -2
- package/scripts/join.py +111 -0
- package/scripts/look.py +101 -0
- package/scripts/loudness.py +4 -2
- package/scripts/multicam.py +196 -0
- package/scripts/overlay.py +4 -2
- package/scripts/probe.py +10 -1
- package/scripts/silence.py +123 -0
- package/scripts/sync.py +4 -3
- package/scripts/verify.py +157 -0
package/scripts/color.py
CHANGED
|
@@ -9,13 +9,15 @@ Examples:
|
|
|
9
9
|
python3 color.py slog3.mp4 --lut SLog3_to_Rec709.cube # apply LUT (any Log -> 709 or a look)
|
|
10
10
|
python3 color.py clip.mp4 --lut look.cube --lut-strength 0.6
|
|
11
11
|
python3 color.py wrongly_tagged.mp4 --retag bt709 # metadata only, stream copy
|
|
12
|
+
python3 color.py iphone_dv.mov --strip-dovi # drop Dolby Vision RPU, keep HLG base layer
|
|
13
|
+
python3 color.py iphone_dv.mov --to-sdr # DV 8.4 = HLG base layer -> tone-mapped SDR
|
|
12
14
|
"""
|
|
13
15
|
import argparse
|
|
14
16
|
import os
|
|
15
17
|
import sys
|
|
16
18
|
from typing import List
|
|
17
19
|
|
|
18
|
-
from _common import aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, x264_args
|
|
20
|
+
from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, x264_args
|
|
19
21
|
|
|
20
22
|
TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
|
|
21
23
|
|
|
@@ -45,6 +47,7 @@ def main() -> int:
|
|
|
45
47
|
mode.add_argument("--to-sdr", action="store_true", help="tone-map HDR (PQ/HLG/BT.2020) to SDR BT.709")
|
|
46
48
|
mode.add_argument("--lut", help=".cube LUT to apply (3D)")
|
|
47
49
|
mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
|
|
50
|
+
mode.add_argument("--strip-dovi", action="store_true", help="remove the Dolby Vision RPU (profile 8.4 iPhone clips) so players use the plain HLG/HDR10 base layer; stream copy")
|
|
48
51
|
ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
|
|
49
52
|
ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
|
|
50
53
|
ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
|
|
@@ -52,7 +55,9 @@ def main() -> int:
|
|
|
52
55
|
ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is not tagged as HDR (treat as PQ)")
|
|
53
56
|
ap.add_argument("--crf", type=int, default=18)
|
|
54
57
|
ap.add_argument("--preset", default="medium")
|
|
58
|
+
add_common(ap)
|
|
55
59
|
args = ap.parse_args()
|
|
60
|
+
apply_common(args)
|
|
56
61
|
|
|
57
62
|
meta = probe(args.input)
|
|
58
63
|
if not meta.get("video"):
|
|
@@ -60,6 +65,22 @@ def main() -> int:
|
|
|
60
65
|
v = meta["video"]
|
|
61
66
|
has_audio = bool(meta.get("audio"))
|
|
62
67
|
|
|
68
|
+
if args.strip_dovi:
|
|
69
|
+
output = args.output or default_output(args.input, "nodv")
|
|
70
|
+
if v.get("codec") != "hevc":
|
|
71
|
+
die("--strip-dovi only applies to HEVC (Dolby Vision) streams")
|
|
72
|
+
if not v.get("dolby_vision"):
|
|
73
|
+
info("note: no Dolby Vision metadata detected; removing unregistered SEI anyway")
|
|
74
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy", "-bsf:v", "filter_units=remove_types=62", "-tag:v", "hvc1"]
|
|
75
|
+
if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
|
|
76
|
+
cmd += ["-movflags", "+faststart"]
|
|
77
|
+
cmd.append(output)
|
|
78
|
+
run(cmd)
|
|
79
|
+
r = probe(output)
|
|
80
|
+
info(f"wrote {output} (dolby_vision={r['video'].get('dolby_vision')})")
|
|
81
|
+
emit(output)
|
|
82
|
+
return 0
|
|
83
|
+
|
|
63
84
|
if args.retag:
|
|
64
85
|
tags = {
|
|
65
86
|
"bt709": ["bt709", "bt709", "bt709"],
|
|
@@ -82,7 +103,7 @@ def main() -> int:
|
|
|
82
103
|
cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
|
|
83
104
|
run(cmd)
|
|
84
105
|
info(f"wrote {output} (tags -> {args.retag})")
|
|
85
|
-
|
|
106
|
+
emit(output)
|
|
86
107
|
return 0
|
|
87
108
|
|
|
88
109
|
if args.to_sdr:
|
|
@@ -109,7 +130,7 @@ def main() -> int:
|
|
|
109
130
|
r = probe(output)
|
|
110
131
|
info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
|
|
111
132
|
f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
|
|
112
|
-
|
|
133
|
+
emit(output)
|
|
113
134
|
return 0
|
|
114
135
|
|
|
115
136
|
|
package/scripts/cut.py
CHANGED
|
@@ -16,7 +16,7 @@ import sys
|
|
|
16
16
|
import tempfile
|
|
17
17
|
from typing import List, Tuple
|
|
18
18
|
|
|
19
|
-
from _common import aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
19
|
+
from _common import STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def parse_segments(spec: str) -> List[Tuple[float, float]]:
|
|
@@ -52,7 +52,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
52
52
|
info("stream copy failed, falling back to re-encode")
|
|
53
53
|
return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
|
|
54
54
|
die(f"ffmpeg failed:\n{proc.stderr.strip()}")
|
|
55
|
-
if not reencode and tolerance >= 0:
|
|
55
|
+
if not reencode and tolerance >= 0 and not STATE["dry_run"]:
|
|
56
56
|
got = probe(dst).get("duration") or 0.0
|
|
57
57
|
if abs(got - dur) > tolerance:
|
|
58
58
|
info(f"stream copy landed on a keyframe {abs(got - dur):.2f}s away from the requested cut "
|
|
@@ -74,7 +74,9 @@ def main() -> int:
|
|
|
74
74
|
ap.add_argument("--tolerance", type=float, default=0.5, help="max seconds a lossless cut may deviate before re-encoding kicks in (default 0.5, -1 = never)")
|
|
75
75
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF when re-encoding (default 18)")
|
|
76
76
|
ap.add_argument("--preset", default="medium", help="x264 preset when re-encoding")
|
|
77
|
+
add_common(ap)
|
|
77
78
|
args = ap.parse_args()
|
|
79
|
+
apply_common(args)
|
|
78
80
|
|
|
79
81
|
meta = probe(args.input)
|
|
80
82
|
total = meta.get("duration") or 0.0
|
|
@@ -131,7 +133,7 @@ def main() -> int:
|
|
|
131
133
|
expected = sum(e - s for s, e in segments)
|
|
132
134
|
info(f"wrote {output} ({result.get('duration'):.3f}s, expected ~{expected:.3f}s, "
|
|
133
135
|
+ ("re-encoded" if reencoded else "lossless stream copy") + ")")
|
|
134
|
-
|
|
136
|
+
emit(output)
|
|
135
137
|
return 0
|
|
136
138
|
|
|
137
139
|
|
package/scripts/export.py
CHANGED
|
@@ -22,7 +22,7 @@ import argparse
|
|
|
22
22
|
import sys
|
|
23
23
|
from typing import Dict, List
|
|
24
24
|
|
|
25
|
-
from _common import cfr_args, default_output, die, ffmpeg_base, info, probe, run
|
|
25
|
+
from _common import add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run
|
|
26
26
|
|
|
27
27
|
PRESETS: Dict[str, Dict] = {
|
|
28
28
|
"youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
|
|
@@ -48,7 +48,9 @@ def main() -> int:
|
|
|
48
48
|
ap.add_argument("--allow-long", action="store_true", help="do not trim to the platform's max duration")
|
|
49
49
|
ap.add_argument("--crf", type=int, help="override CRF")
|
|
50
50
|
ap.add_argument("--list", action="store_true", help="list presets and exit")
|
|
51
|
+
add_common(ap)
|
|
51
52
|
args = ap.parse_args()
|
|
53
|
+
apply_common(args)
|
|
52
54
|
|
|
53
55
|
if args.list:
|
|
54
56
|
for name, p in PRESETS.items():
|
|
@@ -84,7 +86,7 @@ def main() -> int:
|
|
|
84
86
|
cmd += ["-filter_complex", fc, "-loop", "0", output]
|
|
85
87
|
run(cmd)
|
|
86
88
|
info(f"wrote {output}")
|
|
87
|
-
|
|
89
|
+
emit(output)
|
|
88
90
|
return 0
|
|
89
91
|
|
|
90
92
|
if vf:
|
|
@@ -108,7 +110,7 @@ def main() -> int:
|
|
|
108
110
|
result = probe(output)
|
|
109
111
|
v = result["video"]
|
|
110
112
|
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['codec']})")
|
|
111
|
-
|
|
113
|
+
emit(output)
|
|
112
114
|
return 0
|
|
113
115
|
|
|
114
116
|
|
package/scripts/fit.py
CHANGED
|
@@ -19,7 +19,7 @@ import sys
|
|
|
19
19
|
from fractions import Fraction
|
|
20
20
|
from typing import List
|
|
21
21
|
|
|
22
|
-
from _common import aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
22
|
+
from _common import STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
23
23
|
|
|
24
24
|
ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
|
|
25
25
|
|
|
@@ -74,7 +74,9 @@ def main() -> int:
|
|
|
74
74
|
e.add_argument("--crf", type=int, default=18)
|
|
75
75
|
e.add_argument("--preset", default="medium")
|
|
76
76
|
e.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
77
|
+
add_common(ap)
|
|
77
78
|
args = ap.parse_args()
|
|
79
|
+
apply_common(args)
|
|
78
80
|
|
|
79
81
|
if not args.duration and not args.aspect and not args.width and not args.fps:
|
|
80
82
|
die("nothing to do: give --duration, --aspect, --width and/or --fps")
|
|
@@ -114,6 +116,7 @@ def main() -> int:
|
|
|
114
116
|
if has_audio:
|
|
115
117
|
af.append(atempo_chain(factor))
|
|
116
118
|
post += ["-t", f"{target:.3f}"]
|
|
119
|
+
STATE["duration_hint"] = target
|
|
117
120
|
else:
|
|
118
121
|
if target < src_dur:
|
|
119
122
|
start = (src_dur - target) / 2 if args.from_center else 0.0
|
|
@@ -164,7 +167,7 @@ def main() -> int:
|
|
|
164
167
|
if abs(factor - 1.0) > 1e-4:
|
|
165
168
|
msg += f", speed {factor:.3f}x"
|
|
166
169
|
info(msg)
|
|
167
|
-
|
|
170
|
+
emit(output)
|
|
168
171
|
return 0
|
|
169
172
|
|
|
170
173
|
|
package/scripts/join.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Join clips with transitions, normalising resolution, frame rate and audio
|
|
3
|
+
layout so mismatched sources (phone + camera + screen recording) cut together.
|
|
4
|
+
|
|
5
|
+
Transitions (xfade): fade, dissolve, wipeleft, wiperight, wipeup, wipedown,
|
|
6
|
+
slideleft, slideright, circleopen, fadeblack, fadewhite, smoothleft, none.
|
|
7
|
+
|
|
8
|
+
Examples:
|
|
9
|
+
python3 join.py a.mp4 b.mp4 c.mp4 -o final.mp4 # 0.5 s crossfade, size/fps from the first clip
|
|
10
|
+
python3 join.py *.mp4 --transition fadeblack --duration 1 -o reel.mp4
|
|
11
|
+
python3 join.py a.mov b.mp4 --transition none --width 1920 --height 1080 --fps 30
|
|
12
|
+
"""
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from typing import List
|
|
16
|
+
|
|
17
|
+
from _common import aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, x264_args
|
|
18
|
+
|
|
19
|
+
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
20
|
+
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> int:
|
|
24
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
25
|
+
ap.add_argument("inputs", nargs="+", help="two or more clips in order")
|
|
26
|
+
ap.add_argument("-o", "--output", help="output file (default: <first>_joined.mp4)")
|
|
27
|
+
ap.add_argument("--transition", choices=TRANSITIONS, default="fade", help="transition between clips (default fade)")
|
|
28
|
+
ap.add_argument("--duration", type=float, default=0.5, help="transition length in seconds (default 0.5)")
|
|
29
|
+
ap.add_argument("--width", type=int, help="output width (default: first clip)")
|
|
30
|
+
ap.add_argument("--height", type=int, help="output height (default: first clip)")
|
|
31
|
+
ap.add_argument("--fps", type=float, help="output frame rate (default: first clip)")
|
|
32
|
+
ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how clips of another aspect reach the frame (default pad)")
|
|
33
|
+
ap.add_argument("--pad-color", default="black")
|
|
34
|
+
ap.add_argument("--crf", type=int, default=18)
|
|
35
|
+
ap.add_argument("--preset", default="medium")
|
|
36
|
+
add_common(ap)
|
|
37
|
+
args = ap.parse_args()
|
|
38
|
+
apply_common(args)
|
|
39
|
+
|
|
40
|
+
if len(args.inputs) < 2:
|
|
41
|
+
die("give at least two clips")
|
|
42
|
+
metas = [probe(p) for p in args.inputs]
|
|
43
|
+
for p, m in zip(args.inputs, metas):
|
|
44
|
+
if not m.get("video"):
|
|
45
|
+
die(f"{p} has no video stream")
|
|
46
|
+
first = metas[0]["video"]
|
|
47
|
+
w = args.width or first["width"]
|
|
48
|
+
h = args.height or first["height"]
|
|
49
|
+
if first.get("rotation") in (90, -90, 270, -270) and not (args.width or args.height):
|
|
50
|
+
w, h = h, w
|
|
51
|
+
fps = args.fps or first.get("fps") or 30.0
|
|
52
|
+
fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
|
|
53
|
+
w, h = w - (w % 2), h - (h % 2)
|
|
54
|
+
durs = [m.get("duration") or 0.0 for m in metas]
|
|
55
|
+
d = args.duration if args.transition != "none" else 0.0
|
|
56
|
+
for p, dur in zip(args.inputs, durs):
|
|
57
|
+
if d and dur <= d * 2:
|
|
58
|
+
die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
|
|
59
|
+
|
|
60
|
+
cmd = ffmpeg_base()
|
|
61
|
+
extra_inputs: List[str] = []
|
|
62
|
+
parts: List[str] = []
|
|
63
|
+
n = len(args.inputs)
|
|
64
|
+
for i, (p, m) in enumerate(zip(args.inputs, metas)):
|
|
65
|
+
cmd += ["-i", p]
|
|
66
|
+
# silent audio for clips without an audio track
|
|
67
|
+
audio_src: List[str] = []
|
|
68
|
+
for i, m in enumerate(metas):
|
|
69
|
+
if m.get("audio"):
|
|
70
|
+
audio_src.append(f"{i}:a:0")
|
|
71
|
+
else:
|
|
72
|
+
idx = n + len(extra_inputs)
|
|
73
|
+
extra_inputs += ["-f", "lavfi", "-t", f"{durs[i]:.3f}", "-i", "anullsrc=r=48000:cl=stereo"]
|
|
74
|
+
audio_src.append(f"{idx}:a:0")
|
|
75
|
+
cmd += extra_inputs
|
|
76
|
+
|
|
77
|
+
if args.fit == "crop":
|
|
78
|
+
geo = f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
|
|
79
|
+
else:
|
|
80
|
+
geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
|
|
81
|
+
for i in range(n):
|
|
82
|
+
parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format=yuv420p,settb=AVTB[v{i}]")
|
|
83
|
+
parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts=stereo,asetpts=PTS-STARTPTS[a{i}]")
|
|
84
|
+
|
|
85
|
+
if args.transition == "none":
|
|
86
|
+
chain = "".join(f"[v{i}][a{i}]" for i in range(n))
|
|
87
|
+
parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
|
|
88
|
+
else:
|
|
89
|
+
vprev, aprev = "v0", "a0"
|
|
90
|
+
offset = 0.0
|
|
91
|
+
for i in range(1, n):
|
|
92
|
+
offset += durs[i - 1] - d
|
|
93
|
+
vout = f"vx{i}" if i < n - 1 else "vout"
|
|
94
|
+
aout = f"ax{i}" if i < n - 1 else "aout"
|
|
95
|
+
parts.append(f"[{vprev}][v{i}]xfade=transition={args.transition}:duration={d:g}:offset={offset:.3f}[{vout}]")
|
|
96
|
+
parts.append(f"[{aprev}][a{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
|
|
97
|
+
vprev, aprev = vout, aout
|
|
98
|
+
|
|
99
|
+
output = args.output or default_output(args.inputs[0], "joined", "mp4")
|
|
100
|
+
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
|
|
101
|
+
cmd += x264_args(args.crf, args.preset) + aac_args() + [output]
|
|
102
|
+
run(cmd)
|
|
103
|
+
expected = sum(durs) - d * (n - 1)
|
|
104
|
+
r = probe(output)
|
|
105
|
+
info(f"wrote {output} ({r['duration']:.3f}s, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
|
|
106
|
+
emit(output, clips=n, transition=args.transition, expected_duration=round(expected, 3))
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
sys.exit(main())
|
package/scripts/look.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Give the agent eyes: pull frames or a contact sheet out of a video as PNG so
|
|
3
|
+
the result can be inspected (caption placement, logo position, crop, colour).
|
|
4
|
+
|
|
5
|
+
Examples:
|
|
6
|
+
python3 look.py final.mp4 # 12-tile contact sheet with timecodes -> final_sheet.png
|
|
7
|
+
python3 look.py final.mp4 --tiles 4x5 --width 1600
|
|
8
|
+
python3 look.py final.mp4 --at 2.5 --at 7 # single frames -> final_2.500s.png, final_7.000s.png
|
|
9
|
+
python3 look.py before.mp4 --compare after.mp4 --at 4 # side-by-side frame
|
|
10
|
+
Then view the PNG (Read tool / image viewer) and verify before reporting.
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List
|
|
17
|
+
|
|
18
|
+
from _common import add_common, apply_common, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run
|
|
19
|
+
|
|
20
|
+
FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def timecode_filter() -> str:
|
|
24
|
+
return f"drawtext=text='%{{pts\\:hms}}':{FONT}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main() -> int:
|
|
28
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
29
|
+
ap.add_argument("input")
|
|
30
|
+
ap.add_argument("-o", "--output", help="output PNG (contact sheet / compare) or basename for --at frames")
|
|
31
|
+
ap.add_argument("--at", action="append", help="time of a frame to extract (repeatable)")
|
|
32
|
+
ap.add_argument("--tiles", default="4x3", help="contact sheet grid COLSxROWS (default 4x3)")
|
|
33
|
+
ap.add_argument("--width", type=int, default=1280, help="total width of the sheet / compare image (default 1280)")
|
|
34
|
+
ap.add_argument("--compare", help="second video: place its frame next to the first (needs --at)")
|
|
35
|
+
ap.add_argument("--no-timecode", action="store_true")
|
|
36
|
+
add_common(ap)
|
|
37
|
+
args = ap.parse_args()
|
|
38
|
+
apply_common(args)
|
|
39
|
+
|
|
40
|
+
meta = probe(args.input)
|
|
41
|
+
if not meta.get("video"):
|
|
42
|
+
die("input has no video stream")
|
|
43
|
+
dur = meta.get("duration") or 0.0
|
|
44
|
+
stem = Path(args.input).stem
|
|
45
|
+
outdir = str(Path(args.output).parent) if args.output else str(Path(args.input).parent)
|
|
46
|
+
tc = "" if args.no_timecode else "," + timecode_filter()
|
|
47
|
+
outputs: List[str] = []
|
|
48
|
+
|
|
49
|
+
if args.compare:
|
|
50
|
+
if not args.at:
|
|
51
|
+
die("--compare needs --at TIME")
|
|
52
|
+
probe(args.compare)
|
|
53
|
+
for t in args.at:
|
|
54
|
+
sec = parse_time(t)
|
|
55
|
+
out = args.output or os.path.join(outdir, f"{stem}_vs_{Path(args.compare).stem}_{sec:.3f}s.png")
|
|
56
|
+
half = args.width // 2
|
|
57
|
+
fc = (f"[0:v]scale={half}:-2{tc}[a];[1:v]scale={half}:-2{tc}[b];"
|
|
58
|
+
f"[a][b]scale2ref=w=iw:h=ih[a2][b2];[a2][b2]hstack=inputs=2[out]")
|
|
59
|
+
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-ss", f"{sec:.3f}", "-i", args.compare,
|
|
60
|
+
"-filter_complex", fc, "-map", "[out]", "-frames:v", "1", out]
|
|
61
|
+
run(cmd)
|
|
62
|
+
outputs.append(out)
|
|
63
|
+
elif args.at:
|
|
64
|
+
for t in args.at:
|
|
65
|
+
sec = parse_time(t)
|
|
66
|
+
if dur and sec > dur:
|
|
67
|
+
die(f"--at {t} is beyond the duration ({dur:.2f}s)")
|
|
68
|
+
out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
|
|
69
|
+
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc}", "-frames:v", "1", out]
|
|
70
|
+
run(cmd)
|
|
71
|
+
outputs.append(out)
|
|
72
|
+
else:
|
|
73
|
+
try:
|
|
74
|
+
cols, rows = (int(x) for x in args.tiles.lower().split("x"))
|
|
75
|
+
except ValueError:
|
|
76
|
+
die("--tiles must look like 4x3")
|
|
77
|
+
n = cols * rows
|
|
78
|
+
if not dur:
|
|
79
|
+
die("cannot build a contact sheet without a known duration")
|
|
80
|
+
step = dur / n
|
|
81
|
+
tile_w = max(2, (args.width // cols) // 2 * 2)
|
|
82
|
+
out = args.output or os.path.join(outdir, f"{stem}_sheet.png")
|
|
83
|
+
# sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
|
|
84
|
+
vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',scale={tile_w}:-2{tc},"
|
|
85
|
+
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
86
|
+
cmd = ffmpeg_base() + ["-ss", f"{step / 2:.6f}", "-i", args.input, "-vf", vf, "-frames:v", "1", out]
|
|
87
|
+
run(cmd)
|
|
88
|
+
outputs.append(out)
|
|
89
|
+
info(f"contact sheet: {n} frames every {step:.2f}s")
|
|
90
|
+
|
|
91
|
+
for o in outputs:
|
|
92
|
+
info(f"wrote {o}")
|
|
93
|
+
emit(outputs[0] if len(outputs) == 1 else None, outputs=outputs)
|
|
94
|
+
if len(outputs) > 1 and not args.json:
|
|
95
|
+
for o in outputs:
|
|
96
|
+
print(o)
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
sys.exit(main())
|
package/scripts/loudness.py
CHANGED
|
@@ -18,7 +18,7 @@ import os
|
|
|
18
18
|
import re
|
|
19
19
|
import sys
|
|
20
20
|
|
|
21
|
-
from _common import AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
|
|
21
|
+
from _common import add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
|
|
@@ -46,7 +46,9 @@ def main() -> int:
|
|
|
46
46
|
ap.add_argument("--measure-only", action="store_true", help="print the measured stats as JSON and exit")
|
|
47
47
|
ap.add_argument("--audio-bitrate", default="192k", help="AAC bitrate when the container is video (default 192k)")
|
|
48
48
|
ap.add_argument("--sample-rate", type=int, help="output sample rate (default: 48000; loudnorm upsamples internally to 192k)")
|
|
49
|
+
add_common(ap)
|
|
49
50
|
args = ap.parse_args()
|
|
51
|
+
apply_common(args)
|
|
50
52
|
|
|
51
53
|
meta = probe(args.input)
|
|
52
54
|
if not meta.get("audio"):
|
|
@@ -76,7 +78,7 @@ def main() -> int:
|
|
|
76
78
|
|
|
77
79
|
after = measure(output, args.lufs, args.tp, args.lra)
|
|
78
80
|
info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS)")
|
|
79
|
-
|
|
81
|
+
emit(output)
|
|
80
82
|
return 0
|
|
81
83
|
|
|
82
84
|
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Multicam: align two or more cameras (and an optional external recorder) by
|
|
3
|
+
audio, then cut between them from a simple switch list.
|
|
4
|
+
|
|
5
|
+
All sources are aligned to the FIRST input (the reference) using the same
|
|
6
|
+
cross-correlation as sync.py. The output takes video from whichever camera the
|
|
7
|
+
switch list names for each time range (reference timeline), and audio from the
|
|
8
|
+
reference unless --audio picks another source.
|
|
9
|
+
|
|
10
|
+
Switch list format: "START-END:CAM,START-END:CAM,..." with times on the
|
|
11
|
+
reference timeline (seconds or mm:ss) and CAM = input index (0 = reference).
|
|
12
|
+
Gaps fall back to camera 0.
|
|
13
|
+
|
|
14
|
+
Examples:
|
|
15
|
+
python3 multicam.py camA.mp4 camB.mp4 --offsets-only # just report the offsets
|
|
16
|
+
python3 multicam.py camA.mp4 camB.mp4 --switch "0-12:0,12-30:1,30-45:0" -o edit.mp4
|
|
17
|
+
python3 multicam.py camA.mp4 camB.mp4 recorder.wav --audio 2 --switch "0-20:0,20-40:1" --fix-drift
|
|
18
|
+
python3 multicam.py camA.mp4 camB.mp4 --auto 8 -o edit.mp4 # alternate cameras every 8 s
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import sys
|
|
22
|
+
from typing import List, Tuple
|
|
23
|
+
|
|
24
|
+
from _common import aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
25
|
+
from sync import measure_offset
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_switch(spec: str, n: int) -> List[Tuple[float, float, int]]:
|
|
29
|
+
out = []
|
|
30
|
+
for raw in spec.split(","):
|
|
31
|
+
raw = raw.strip()
|
|
32
|
+
if not raw:
|
|
33
|
+
continue
|
|
34
|
+
try:
|
|
35
|
+
rng, cam = raw.rsplit(":", 1)
|
|
36
|
+
a, b = rng.rsplit("-", 1)
|
|
37
|
+
s, e, c = parse_time(a), parse_time(b), int(cam)
|
|
38
|
+
except ValueError:
|
|
39
|
+
die(f"bad switch entry '{raw}' (want START-END:CAM)")
|
|
40
|
+
if not 0 <= c < n:
|
|
41
|
+
die(f"camera {c} does not exist (inputs are 0..{n - 1})")
|
|
42
|
+
if e <= s:
|
|
43
|
+
die(f"switch entry '{raw}': end must be after start")
|
|
44
|
+
out.append((s, e, c))
|
|
45
|
+
out.sort()
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main() -> int:
|
|
50
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
51
|
+
ap.add_argument("inputs", nargs="+", help="reference camera first, then other cameras / recorders")
|
|
52
|
+
ap.add_argument("-o", "--output", help="output file (default: <reference>_multicam.mp4)")
|
|
53
|
+
ap.add_argument("--switch", help="switch list START-END:CAM,... on the reference timeline")
|
|
54
|
+
ap.add_argument("--auto", type=float, help="no switch list: alternate through the cameras every N seconds")
|
|
55
|
+
ap.add_argument("--audio", type=int, default=0, help="input index to take audio from (default 0 = reference)")
|
|
56
|
+
ap.add_argument("--offsets-only", action="store_true", help="print the measured offsets and exit")
|
|
57
|
+
ap.add_argument("--max-offset", type=float, default=30.0)
|
|
58
|
+
ap.add_argument("--analyze-seconds", type=float, default=120.0)
|
|
59
|
+
ap.add_argument("--fix-drift", action="store_true", help="also correct clock drift of each source (long recordings)")
|
|
60
|
+
ap.add_argument("--width", type=int, help="output width (default: reference)")
|
|
61
|
+
ap.add_argument("--height", type=int, help="output height (default: reference)")
|
|
62
|
+
ap.add_argument("--fps", type=float, help="output fps (default: reference)")
|
|
63
|
+
ap.add_argument("--crf", type=int, default=18)
|
|
64
|
+
ap.add_argument("--preset", default="medium")
|
|
65
|
+
add_common(ap)
|
|
66
|
+
args = ap.parse_args()
|
|
67
|
+
apply_common(args)
|
|
68
|
+
|
|
69
|
+
n = len(args.inputs)
|
|
70
|
+
if n < 2:
|
|
71
|
+
die("give at least two inputs")
|
|
72
|
+
metas = [probe(p) for p in args.inputs]
|
|
73
|
+
for p, m in zip(args.inputs, metas):
|
|
74
|
+
if not m.get("audio"):
|
|
75
|
+
die(f"{p} has no audio to align with")
|
|
76
|
+
if not metas[0].get("video"):
|
|
77
|
+
die("the reference (first input) must have video")
|
|
78
|
+
|
|
79
|
+
offsets = [0.0]
|
|
80
|
+
ratios = [1.0]
|
|
81
|
+
conf = [1.0]
|
|
82
|
+
for p in args.inputs[1:]:
|
|
83
|
+
off, score = measure_offset(args.inputs[0], p, 0.0, args.analyze_seconds, 20.0, args.max_offset, 1.0)
|
|
84
|
+
ratio = 1.0
|
|
85
|
+
if args.fix_drift:
|
|
86
|
+
ref_dur = metas[0]["duration"] or 0.0
|
|
87
|
+
sec_dur = probe(p)["duration"] or 0.0
|
|
88
|
+
overlap_end = min(ref_dur, sec_dur + off)
|
|
89
|
+
window = 60.0
|
|
90
|
+
head_len = min(args.analyze_seconds, overlap_end)
|
|
91
|
+
tail_start = overlap_end - window
|
|
92
|
+
if tail_start > head_len / 2 + 5:
|
|
93
|
+
ref_start, sec_start = tail_start, tail_start - off
|
|
94
|
+
if sec_start < 0:
|
|
95
|
+
ref_start -= sec_start
|
|
96
|
+
sec_start = 0.0
|
|
97
|
+
from sync import decode_mono, envelope, cross_correlate, refine, SR
|
|
98
|
+
ref_s = decode_mono(args.inputs[0], window, ref_start)
|
|
99
|
+
oth_s = decode_mono(p, window, sec_start)
|
|
100
|
+
step = int(SR * 0.02)
|
|
101
|
+
lag, sc = cross_correlate(envelope(ref_s, step), envelope(oth_s, step), int(2.0 * SR / step))
|
|
102
|
+
residual = refine(ref_s, oth_s, lag * step / SR, int(SR * 0.001), 0.04)
|
|
103
|
+
elapsed = (ref_start + window / 2) - head_len / 2
|
|
104
|
+
if elapsed > 0 and sc > 0.1:
|
|
105
|
+
ratio = 1.0 - residual / elapsed
|
|
106
|
+
off = off + (ratio - 1.0) * (head_len / 2)
|
|
107
|
+
offsets.append(off)
|
|
108
|
+
ratios.append(ratio)
|
|
109
|
+
conf.append(score)
|
|
110
|
+
info(f"{p}: offset {off:+.3f}s (confidence {score:.2f})" + (f", drift {(ratio - 1) * 1e6:+.0f} ppm" if args.fix_drift else ""))
|
|
111
|
+
|
|
112
|
+
report = {"inputs": args.inputs, "offsets_seconds": [round(o, 4) for o in offsets],
|
|
113
|
+
"confidence": [round(c, 3) for c in conf]}
|
|
114
|
+
if args.fix_drift:
|
|
115
|
+
report["drift_ppm"] = [round((r - 1) * 1e6, 1) for r in ratios]
|
|
116
|
+
if args.offsets_only:
|
|
117
|
+
emit(None, **report)
|
|
118
|
+
if not args.json:
|
|
119
|
+
for p, o, c in zip(args.inputs, offsets, conf):
|
|
120
|
+
print(f"{p}: {o:+.3f}s (confidence {c:.2f})")
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
ref_dur = metas[0]["duration"] or 0.0
|
|
124
|
+
if args.switch:
|
|
125
|
+
cuts = parse_switch(args.switch, n)
|
|
126
|
+
elif args.auto:
|
|
127
|
+
cams = [i for i, m in enumerate(metas) if m.get("video")]
|
|
128
|
+
cuts, t, k = [], 0.0, 0
|
|
129
|
+
while t < ref_dur:
|
|
130
|
+
cuts.append((t, min(ref_dur, t + args.auto), cams[k % len(cams)]))
|
|
131
|
+
t += args.auto
|
|
132
|
+
k += 1
|
|
133
|
+
else:
|
|
134
|
+
die("give --switch or --auto (or --offsets-only)")
|
|
135
|
+
# fill gaps with camera 0 and clip to the reference length
|
|
136
|
+
filled: List[Tuple[float, float, int]] = []
|
|
137
|
+
cursor = 0.0
|
|
138
|
+
for s, e, c in cuts:
|
|
139
|
+
s, e = max(0.0, s), min(ref_dur, e)
|
|
140
|
+
if s > cursor:
|
|
141
|
+
filled.append((cursor, s, 0))
|
|
142
|
+
if e > s:
|
|
143
|
+
filled.append((s, e, c))
|
|
144
|
+
cursor = max(cursor, e)
|
|
145
|
+
if cursor < ref_dur:
|
|
146
|
+
filled.append((cursor, ref_dur, 0))
|
|
147
|
+
for s, e, c in filled:
|
|
148
|
+
if not metas[c].get("video"):
|
|
149
|
+
die(f"camera {c} ({args.inputs[c]}) has no video; it can only be used with --audio")
|
|
150
|
+
|
|
151
|
+
v0 = metas[0]["video"]
|
|
152
|
+
w, h = args.width or v0["width"], args.height or v0["height"]
|
|
153
|
+
if v0.get("rotation") in (90, -90, 270, -270) and not (args.width or args.height):
|
|
154
|
+
w, h = h, w
|
|
155
|
+
fps = args.fps or v0.get("fps") or 30.0
|
|
156
|
+
fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
|
|
157
|
+
geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps:g},format=yuv420p"
|
|
158
|
+
|
|
159
|
+
cmd = ffmpeg_base()
|
|
160
|
+
for p in args.inputs:
|
|
161
|
+
cmd += ["-i", p]
|
|
162
|
+
parts: List[str] = []
|
|
163
|
+
labels: List[str] = []
|
|
164
|
+
for i, (s, e, c) in enumerate(filled):
|
|
165
|
+
# reference time t maps to source time (t - offset_c) * ratio_c
|
|
166
|
+
src_s = (s - offsets[c]) * ratios[c]
|
|
167
|
+
src_e = (e - offsets[c]) * ratios[c]
|
|
168
|
+
if src_s < 0:
|
|
169
|
+
info(f"warning: camera {c} has not started at reference {s:.2f}s; using camera 0 for that range")
|
|
170
|
+
c, src_s, src_e = 0, s, e
|
|
171
|
+
parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{geo}[v{i}]")
|
|
172
|
+
labels.append(f"[v{i}]")
|
|
173
|
+
parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
|
|
174
|
+
a = args.audio
|
|
175
|
+
a_start = -offsets[a] if offsets[a] < 0 else 0.0
|
|
176
|
+
afx = []
|
|
177
|
+
if abs(ratios[a] - 1.0) > 1e-7:
|
|
178
|
+
sr = metas[a]["audio"].get("sample_rate") or 48000
|
|
179
|
+
afx += [f"asetrate={sr * ratios[a]:.6f}", f"aresample={sr}"]
|
|
180
|
+
if offsets[a] > 0:
|
|
181
|
+
afx.append(f"adelay={int(round(offsets[a] * 1000))}:all=1")
|
|
182
|
+
afx += [f"atrim=start={a_start:.4f}", "asetpts=PTS-STARTPTS", f"atrim=0:{ref_dur:.3f}", "aformat=sample_rates=48000:channel_layouts=stereo"]
|
|
183
|
+
parts.append(f"[{a}:a]{','.join(afx)}[aout]")
|
|
184
|
+
|
|
185
|
+
output = args.output or default_output(args.inputs[0], "multicam", "mp4")
|
|
186
|
+
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
|
|
187
|
+
cmd += x264_args(args.crf, args.preset) + aac_args() + ["-shortest", output]
|
|
188
|
+
run(cmd)
|
|
189
|
+
r = probe(output)
|
|
190
|
+
info(f"wrote {output} ({r['duration']:.3f}s, {len(filled)} cuts, audio from input {a})")
|
|
191
|
+
emit(output, cuts=[[round(s, 3), round(e, 3), c] for s, e, c in filled], **report)
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
sys.exit(main())
|
package/scripts/overlay.py
CHANGED
|
@@ -15,7 +15,7 @@ import argparse
|
|
|
15
15
|
import sys
|
|
16
16
|
from typing import List, Optional
|
|
17
17
|
|
|
18
|
-
from _common import aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
18
|
+
from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
19
19
|
|
|
20
20
|
POS = {
|
|
21
21
|
"top-left": ("{m}", "{m}"),
|
|
@@ -100,7 +100,9 @@ def main() -> int:
|
|
|
100
100
|
enc = ap.add_argument_group("encoding")
|
|
101
101
|
enc.add_argument("--crf", type=int, default=18)
|
|
102
102
|
enc.add_argument("--preset", default="medium")
|
|
103
|
+
add_common(ap)
|
|
103
104
|
args = ap.parse_args()
|
|
105
|
+
apply_common(args)
|
|
104
106
|
|
|
105
107
|
meta = probe(args.input)
|
|
106
108
|
if not meta.get("video"):
|
|
@@ -163,7 +165,7 @@ def main() -> int:
|
|
|
163
165
|
run(cmd)
|
|
164
166
|
result = probe(output)
|
|
165
167
|
info(f"wrote {output} ({result['duration']:.3f}s)")
|
|
166
|
-
|
|
168
|
+
emit(output)
|
|
167
169
|
return 0
|
|
168
170
|
|
|
169
171
|
|