ffmpeg-skill 0.1.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/LICENSE +21 -0
- package/README.md +111 -0
- package/SKILL.md +165 -0
- package/bin/install.js +103 -0
- package/package.json +31 -0
- package/scripts/_common.py +271 -0
- package/scripts/caption.py +157 -0
- package/scripts/cut.py +135 -0
- package/scripts/export.py +112 -0
- package/scripts/fit.py +161 -0
- package/scripts/loudness.py +84 -0
- package/scripts/overlay.py +171 -0
- package/scripts/probe.py +58 -0
- package/scripts/sync.py +205 -0
package/scripts/fit.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fit a video to a target duration and/or aspect ratio.
|
|
3
|
+
|
|
4
|
+
Duration: --duration N with --method speed (retime video+audio, pitch-preserving
|
|
5
|
+
via atempo chaining) or --method trim (keep the first N seconds, or a centred
|
|
6
|
+
window with --from-center). Aspect: --aspect 16:9|9:16|1:1|4:5|W:H with
|
|
7
|
+
--fit pad (letterbox/pillarbox with --pad-color, default black) or --fit crop
|
|
8
|
+
(centre crop). --width sets the output width; height follows the aspect.
|
|
9
|
+
|
|
10
|
+
Examples:
|
|
11
|
+
python3 fit.py input.mp4 --duration 60 # speed up/down to exactly 60s
|
|
12
|
+
python3 fit.py input.mp4 --duration 30 --method trim
|
|
13
|
+
python3 fit.py input.mp4 --aspect 9:16 --fit pad --width 1080
|
|
14
|
+
python3 fit.py input.mp4 --aspect 1:1 --fit crop --duration 15
|
|
15
|
+
"""
|
|
16
|
+
import argparse
|
|
17
|
+
import math
|
|
18
|
+
import sys
|
|
19
|
+
from fractions import Fraction
|
|
20
|
+
from typing import List
|
|
21
|
+
|
|
22
|
+
from _common import aac_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
23
|
+
|
|
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
|
+
|
|
26
|
+
|
|
27
|
+
def parse_aspect(value: str) -> Fraction:
|
|
28
|
+
if value in ASPECT_PRESETS:
|
|
29
|
+
return ASPECT_PRESETS[value]
|
|
30
|
+
try:
|
|
31
|
+
w, h = value.split(":")
|
|
32
|
+
return Fraction(int(w), int(h))
|
|
33
|
+
except (ValueError, ZeroDivisionError):
|
|
34
|
+
die(f"bad aspect '{value}', use W:H like 16:9")
|
|
35
|
+
return Fraction(1) # unreachable
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def atempo_chain(factor: float) -> str:
|
|
39
|
+
"""atempo accepts 0.5..100 per instance; chain for extreme factors."""
|
|
40
|
+
parts: List[str] = []
|
|
41
|
+
remaining = factor
|
|
42
|
+
while remaining < 0.5:
|
|
43
|
+
parts.append("atempo=0.5")
|
|
44
|
+
remaining /= 0.5
|
|
45
|
+
while remaining > 100.0:
|
|
46
|
+
parts.append("atempo=100.0")
|
|
47
|
+
remaining /= 100.0
|
|
48
|
+
parts.append(f"atempo={remaining:.6f}")
|
|
49
|
+
return ",".join(parts)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def even(n: float) -> int:
|
|
53
|
+
v = int(round(n))
|
|
54
|
+
return v if v % 2 == 0 else v + 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main() -> int:
|
|
58
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
59
|
+
ap.add_argument("input")
|
|
60
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_fit.<ext>)")
|
|
61
|
+
d = ap.add_argument_group("duration")
|
|
62
|
+
d.add_argument("--duration", help="target duration (seconds or mm:ss)")
|
|
63
|
+
d.add_argument("--method", choices=["speed", "trim"], default="speed", help="how to reach the duration (default speed)")
|
|
64
|
+
d.add_argument("--from-center", action="store_true", help="with --method trim, keep the middle instead of the start")
|
|
65
|
+
d.add_argument("--max-speed", type=float, default=4.0, help="refuse speed factors above this (default 4x)")
|
|
66
|
+
a = ap.add_argument_group("aspect")
|
|
67
|
+
a.add_argument("--aspect", help="target aspect ratio, e.g. 16:9, 9:16, 1:1, 4:5")
|
|
68
|
+
a.add_argument("--fit", choices=["pad", "crop"], default="pad", help="pad (letterbox) or crop to reach the aspect (default pad)")
|
|
69
|
+
a.add_argument("--width", type=int, help="output width in px (default: keep source width or the width implied by the aspect)")
|
|
70
|
+
a.add_argument("--pad-color", default="black", help="pad colour, e.g. black, white, 0x101010 (default black)")
|
|
71
|
+
e = ap.add_argument_group("encoding")
|
|
72
|
+
e.add_argument("--crf", type=int, default=18)
|
|
73
|
+
e.add_argument("--preset", default="medium")
|
|
74
|
+
e.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
75
|
+
args = ap.parse_args()
|
|
76
|
+
|
|
77
|
+
if not args.duration and not args.aspect and not args.width and not args.fps:
|
|
78
|
+
die("nothing to do: give --duration, --aspect, --width and/or --fps")
|
|
79
|
+
|
|
80
|
+
meta = probe(args.input)
|
|
81
|
+
if not meta.get("video"):
|
|
82
|
+
die("input has no video stream")
|
|
83
|
+
src_dur = meta["duration"] or 0.0
|
|
84
|
+
sw, sh = meta["video"]["width"], meta["video"]["height"]
|
|
85
|
+
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
86
|
+
sw, sh = sh, sw
|
|
87
|
+
has_audio = bool(meta.get("audio"))
|
|
88
|
+
|
|
89
|
+
vf: List[str] = []
|
|
90
|
+
af: List[str] = []
|
|
91
|
+
pre_input: List[str] = []
|
|
92
|
+
post: List[str] = []
|
|
93
|
+
factor = 1.0
|
|
94
|
+
|
|
95
|
+
# ---- duration
|
|
96
|
+
if args.duration:
|
|
97
|
+
target = parse_time(args.duration)
|
|
98
|
+
if target <= 0:
|
|
99
|
+
die("target duration must be > 0")
|
|
100
|
+
if args.method == "speed":
|
|
101
|
+
factor = src_dur / target # >1 = speed up
|
|
102
|
+
if factor > args.max_speed or factor < 1 / args.max_speed:
|
|
103
|
+
die(f"required speed factor {factor:.2f}x exceeds --max-speed {args.max_speed}x; use --method trim or raise the limit")
|
|
104
|
+
if abs(factor - 1.0) > 1e-4:
|
|
105
|
+
vf.append(f"setpts={1/factor:.8f}*PTS")
|
|
106
|
+
if has_audio:
|
|
107
|
+
af.append(atempo_chain(factor))
|
|
108
|
+
post += ["-t", f"{target:.3f}"]
|
|
109
|
+
else:
|
|
110
|
+
if target < src_dur:
|
|
111
|
+
start = (src_dur - target) / 2 if args.from_center else 0.0
|
|
112
|
+
pre_input += ["-ss", f"{start:.3f}"]
|
|
113
|
+
post += ["-t", f"{target:.3f}"]
|
|
114
|
+
else:
|
|
115
|
+
info(f"source ({src_dur:.2f}s) is already shorter than {target:.2f}s; trim does nothing")
|
|
116
|
+
|
|
117
|
+
# ---- aspect / size
|
|
118
|
+
if args.aspect or args.width:
|
|
119
|
+
src_ratio = Fraction(sw, sh)
|
|
120
|
+
ratio = parse_aspect(args.aspect) if args.aspect else src_ratio
|
|
121
|
+
if args.width:
|
|
122
|
+
out_w = even(args.width)
|
|
123
|
+
else:
|
|
124
|
+
out_w = even(sw if ratio <= src_ratio else sh * ratio)
|
|
125
|
+
out_h = even(out_w / ratio)
|
|
126
|
+
if args.fit == "crop":
|
|
127
|
+
vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=increase")
|
|
128
|
+
vf.append(f"crop={out_w}:{out_h}")
|
|
129
|
+
else:
|
|
130
|
+
vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease")
|
|
131
|
+
vf.append(f"pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}")
|
|
132
|
+
vf.append("setsar=1")
|
|
133
|
+
|
|
134
|
+
if args.fps:
|
|
135
|
+
vf.append(f"fps={args.fps:g}")
|
|
136
|
+
|
|
137
|
+
output = args.output or default_output(args.input, "fit")
|
|
138
|
+
cmd = ffmpeg_base() + pre_input + ["-i", args.input]
|
|
139
|
+
if vf:
|
|
140
|
+
cmd += ["-vf", ",".join(vf)]
|
|
141
|
+
if af:
|
|
142
|
+
cmd += ["-af", ",".join(af)]
|
|
143
|
+
cmd += x264_args(args.crf, args.preset)
|
|
144
|
+
if has_audio:
|
|
145
|
+
cmd += aac_args()
|
|
146
|
+
else:
|
|
147
|
+
cmd += ["-an"]
|
|
148
|
+
cmd += post + [output]
|
|
149
|
+
run(cmd)
|
|
150
|
+
|
|
151
|
+
result = probe(output)
|
|
152
|
+
msg = f"wrote {output} ({result['duration']:.3f}s, {result['video']['width']}x{result['video']['height']})"
|
|
153
|
+
if abs(factor - 1.0) > 1e-4:
|
|
154
|
+
msg += f", speed {factor:.3f}x"
|
|
155
|
+
info(msg)
|
|
156
|
+
print(output)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
sys.exit(main())
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Two-pass EBU R128 loudness normalisation (ffmpeg loudnorm).
|
|
3
|
+
|
|
4
|
+
Pass 1 measures integrated loudness, true peak, LRA and threshold; pass 2
|
|
5
|
+
applies loudnorm in linear mode with those measurements so the result hits
|
|
6
|
+
the target without the pumping of single-pass mode. Video is stream-copied.
|
|
7
|
+
|
|
8
|
+
Common targets: -14 LUFS (YouTube/Spotify), -16 (Apple Podcasts), -23 (EBU broadcast).
|
|
9
|
+
|
|
10
|
+
Examples:
|
|
11
|
+
python3 loudness.py input.mp4 # -14 LUFS, -1 dBTP
|
|
12
|
+
python3 loudness.py podcast.wav -I -16 --tp -1.5 -o podcast_norm.wav
|
|
13
|
+
python3 loudness.py input.mp4 --measure-only
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from _common import AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def measure(path: str, I: float, tp: float, lra: float) -> dict:
|
|
26
|
+
ffmpeg = require_tool("ffmpeg")
|
|
27
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
|
|
28
|
+
proc = run(cmd, check=False)
|
|
29
|
+
m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
|
|
30
|
+
if proc.returncode != 0 or not m:
|
|
31
|
+
die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}")
|
|
32
|
+
data = json.loads(m.group(0))
|
|
33
|
+
for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset"):
|
|
34
|
+
if data.get(k) in (None, "-inf", "inf", "nan"):
|
|
35
|
+
die(f"loudnorm returned unusable value for {k}: {data.get(k)} (silent input?)")
|
|
36
|
+
return data
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main() -> int:
|
|
40
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
41
|
+
ap.add_argument("input")
|
|
42
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_loudnorm.<ext>)")
|
|
43
|
+
ap.add_argument("-I", "--lufs", type=float, default=-14.0, help="integrated loudness target in LUFS (default -14)")
|
|
44
|
+
ap.add_argument("--tp", type=float, default=-1.0, help="true peak ceiling in dBTP (default -1)")
|
|
45
|
+
ap.add_argument("--lra", type=float, default=11.0, help="loudness range target in LU (default 11)")
|
|
46
|
+
ap.add_argument("--measure-only", action="store_true", help="print the measured stats as JSON and exit")
|
|
47
|
+
ap.add_argument("--audio-bitrate", default="192k", help="AAC bitrate when the container is video (default 192k)")
|
|
48
|
+
ap.add_argument("--sample-rate", type=int, help="output sample rate (default: 48000; loudnorm upsamples internally to 192k)")
|
|
49
|
+
args = ap.parse_args()
|
|
50
|
+
|
|
51
|
+
meta = probe(args.input)
|
|
52
|
+
if not meta.get("audio"):
|
|
53
|
+
die("input has no audio stream")
|
|
54
|
+
|
|
55
|
+
stats = measure(args.input, args.lufs, args.tp, args.lra)
|
|
56
|
+
info(f"measured: {float(stats['input_i']):.1f} LUFS, TP {float(stats['input_tp']):.1f} dBTP, LRA {float(stats['input_lra']):.1f} LU")
|
|
57
|
+
if args.measure_only:
|
|
58
|
+
print(json.dumps({k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")}, indent=2))
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
output = args.output or default_output(args.input, "loudnorm")
|
|
62
|
+
af = (
|
|
63
|
+
f"loudnorm=I={args.lufs}:TP={args.tp}:LRA={args.lra}"
|
|
64
|
+
f":measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:measured_LRA={stats['input_lra']}"
|
|
65
|
+
f":measured_thresh={stats['input_thresh']}:offset={stats['target_offset']}:linear=true:print_format=summary"
|
|
66
|
+
)
|
|
67
|
+
sr = args.sample_rate or meta["audio"].get("sample_rate") or 48000
|
|
68
|
+
ext = os.path.splitext(output)[1].lower()
|
|
69
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-af", af, "-ar", str(sr)]
|
|
70
|
+
if ext in AUDIO_CODECS or not meta.get("video"):
|
|
71
|
+
cmd += ["-vn"] + audio_codec_for(output, args.audio_bitrate)
|
|
72
|
+
else:
|
|
73
|
+
cmd += ["-map", "0:v:0", "-map", "0:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", args.audio_bitrate]
|
|
74
|
+
cmd.append(output)
|
|
75
|
+
run(cmd)
|
|
76
|
+
|
|
77
|
+
after = measure(output, args.lufs, args.tp, args.lra)
|
|
78
|
+
info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS)")
|
|
79
|
+
print(output)
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
sys.exit(main())
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Composite a logo/image or a text title onto a video with position, timing,
|
|
3
|
+
opacity and fade in/out.
|
|
4
|
+
|
|
5
|
+
Positions: top-left, top, top-right, left, center, right, bottom-left, bottom,
|
|
6
|
+
bottom-right, or explicit "X,Y" pixels (negative counts from the far edge).
|
|
7
|
+
|
|
8
|
+
Examples:
|
|
9
|
+
python3 overlay.py input.mp4 --image logo.png --position top-right --scale 200 --opacity 0.8
|
|
10
|
+
python3 overlay.py input.mp4 --image lower_third.png --position bottom-left --start 2 --end 8 --fade 0.5
|
|
11
|
+
python3 overlay.py input.mp4 --text "Episode 12" --position bottom --font-size 48 --start 1 --end 5 --fade 0.3
|
|
12
|
+
python3 overlay.py input.mp4 --text "こんにちは" --font-file /path/NotoSansCJK-Bold.ttc --box
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from typing import List, Optional
|
|
17
|
+
|
|
18
|
+
from _common import aac_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
19
|
+
|
|
20
|
+
POS = {
|
|
21
|
+
"top-left": ("{m}", "{m}"),
|
|
22
|
+
"top": ("(W-w)/2", "{m}"),
|
|
23
|
+
"top-right": ("W-w-{m}", "{m}"),
|
|
24
|
+
"left": ("{m}", "(H-h)/2"),
|
|
25
|
+
"center": ("(W-w)/2", "(H-h)/2"),
|
|
26
|
+
"right": ("W-w-{m}", "(H-h)/2"),
|
|
27
|
+
"bottom-left": ("{m}", "H-h-{m}"),
|
|
28
|
+
"bottom": ("(W-w)/2", "H-h-{m}"),
|
|
29
|
+
"bottom-right": ("W-w-{m}", "H-h-{m}"),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def position_exprs(pos: str, margin: int, text_mode: bool):
|
|
34
|
+
if pos in POS:
|
|
35
|
+
x, y = (e.format(m=margin) for e in POS[pos])
|
|
36
|
+
else:
|
|
37
|
+
try:
|
|
38
|
+
xs, ys = pos.split(",")
|
|
39
|
+
xv, yv = int(xs), int(ys)
|
|
40
|
+
except ValueError:
|
|
41
|
+
die(f"bad --position '{pos}'")
|
|
42
|
+
x = f"W-w{xv}" if xv < 0 else str(xv)
|
|
43
|
+
y = f"H-h{yv}" if yv < 0 else str(yv)
|
|
44
|
+
if text_mode:
|
|
45
|
+
# drawtext uses w/h for the text box but lower-case main dims differ: W/H -> w/h, w/h -> text_w/text_h
|
|
46
|
+
x = x.replace("W", "main_w").replace("w", "text_w").replace("H", "main_h").replace("h", "text_h")
|
|
47
|
+
y = y.replace("W", "main_w").replace("w", "text_w").replace("H", "main_h").replace("h", "text_h")
|
|
48
|
+
x = x.replace("main_text_w", "main_w").replace("main_text_h", "main_h")
|
|
49
|
+
y = y.replace("main_text_w", "main_w").replace("main_text_h", "main_h")
|
|
50
|
+
return x, y
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def enable_expr(start: Optional[float], end: Optional[float]) -> str:
|
|
54
|
+
if start is None and end is None:
|
|
55
|
+
return ""
|
|
56
|
+
s = f"{start:.3f}" if start is not None else "0"
|
|
57
|
+
if end is None:
|
|
58
|
+
return f"gte(t,{s})"
|
|
59
|
+
return f"between(t,{s},{end:.3f})"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def alpha_expr(opacity: float, start: Optional[float], end: Optional[float], fade: float) -> str:
|
|
63
|
+
"""Time-varying alpha with linear fade in/out inside [start, end]."""
|
|
64
|
+
if fade <= 0 or (start is None and end is None):
|
|
65
|
+
return f"{opacity:g}"
|
|
66
|
+
s = start if start is not None else 0.0
|
|
67
|
+
parts = [f"{opacity:g}"]
|
|
68
|
+
fin = f"min(1,(t-{s:.3f})/{fade:g})"
|
|
69
|
+
parts.append(fin)
|
|
70
|
+
if end is not None:
|
|
71
|
+
parts.append(f"min(1,({end:.3f}-t)/{fade:g})")
|
|
72
|
+
return "max(0," + "*".join(parts) + ")"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main() -> int:
|
|
76
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
77
|
+
ap.add_argument("input")
|
|
78
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_overlay.<ext>)")
|
|
79
|
+
src = ap.add_mutually_exclusive_group(required=True)
|
|
80
|
+
src.add_argument("--image", help="PNG/JPG (alpha respected) to composite")
|
|
81
|
+
src.add_argument("--text", help="text to draw (drawtext)")
|
|
82
|
+
ap.add_argument("--position", default="top-right", help="named position or X,Y (default top-right)")
|
|
83
|
+
ap.add_argument("--margin", type=int, default=24, help="margin from the edges in px (default 24)")
|
|
84
|
+
ap.add_argument("--start", help="show from this time (default: whole video)")
|
|
85
|
+
ap.add_argument("--end", help="hide after this time")
|
|
86
|
+
ap.add_argument("--fade", type=float, default=0.0, help="fade in/out duration in seconds")
|
|
87
|
+
ap.add_argument("--opacity", type=float, default=1.0, help="0..1 (default 1)")
|
|
88
|
+
img = ap.add_argument_group("image options")
|
|
89
|
+
img.add_argument("--scale", type=int, help="scale the image to this width in px (keeps aspect)")
|
|
90
|
+
img.add_argument("--scale-percent", type=float, help="scale the image to this %% of the video width")
|
|
91
|
+
txt = ap.add_argument_group("text options")
|
|
92
|
+
txt.add_argument("--font", default="DejaVu Sans", help="fontconfig font name")
|
|
93
|
+
txt.add_argument("--font-file", help="explicit .ttf/.otf/.ttc path (use this for CJK fonts)")
|
|
94
|
+
txt.add_argument("--font-size", type=int, default=42)
|
|
95
|
+
txt.add_argument("--font-color", default="white")
|
|
96
|
+
txt.add_argument("--border", type=int, default=2, help="text outline width (default 2)")
|
|
97
|
+
txt.add_argument("--border-color", default="black")
|
|
98
|
+
txt.add_argument("--box", action="store_true", help="draw a translucent box behind the text")
|
|
99
|
+
txt.add_argument("--box-color", default="black@0.5")
|
|
100
|
+
enc = ap.add_argument_group("encoding")
|
|
101
|
+
enc.add_argument("--crf", type=int, default=18)
|
|
102
|
+
enc.add_argument("--preset", default="medium")
|
|
103
|
+
args = ap.parse_args()
|
|
104
|
+
|
|
105
|
+
meta = probe(args.input)
|
|
106
|
+
if not meta.get("video"):
|
|
107
|
+
die("input has no video stream")
|
|
108
|
+
vw = meta["video"]["width"]
|
|
109
|
+
start = parse_time(args.start) if args.start else None
|
|
110
|
+
end = parse_time(args.end) if args.end else None
|
|
111
|
+
if start is not None and end is not None and end <= start:
|
|
112
|
+
die("--end must be after --start")
|
|
113
|
+
if not 0 <= args.opacity <= 1:
|
|
114
|
+
die("--opacity must be within 0..1")
|
|
115
|
+
|
|
116
|
+
output = args.output or default_output(args.input, "overlay")
|
|
117
|
+
enable = enable_expr(start, end)
|
|
118
|
+
cmd = ffmpeg_base() + ["-i", args.input]
|
|
119
|
+
|
|
120
|
+
if args.image:
|
|
121
|
+
probe(args.image)
|
|
122
|
+
chain: List[str] = ["format=rgba"]
|
|
123
|
+
if args.scale_percent:
|
|
124
|
+
chain.append(f"scale={int(vw * args.scale_percent / 100)}:-1")
|
|
125
|
+
elif args.scale:
|
|
126
|
+
chain.append(f"scale={args.scale}:-1")
|
|
127
|
+
if args.opacity < 1:
|
|
128
|
+
chain.append(f"colorchannelmixer=aa={args.opacity:g}")
|
|
129
|
+
if args.fade > 0 and (start is not None or end is not None):
|
|
130
|
+
s = start if start is not None else 0.0
|
|
131
|
+
chain.append(f"fade=t=in:st={s:.3f}:d={args.fade:g}:alpha=1")
|
|
132
|
+
if end is not None:
|
|
133
|
+
chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
|
|
134
|
+
x, y = position_exprs(args.position, args.margin, text_mode=False)
|
|
135
|
+
ov = f"overlay={x}:{y}:format=auto"
|
|
136
|
+
if enable:
|
|
137
|
+
ov += f":enable='{enable}'"
|
|
138
|
+
# -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
|
|
139
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
|
|
140
|
+
fc = f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS[ov];[0:v][ov]{ov}[out]"
|
|
141
|
+
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", "0:a?", "-shortest"]
|
|
142
|
+
else:
|
|
143
|
+
x, y = position_exprs(args.position, args.margin, text_mode=True)
|
|
144
|
+
opts = [f"text='{escape_drawtext(args.text)}'", f"fontsize={args.font_size}", f"x={x}", f"y={y}",
|
|
145
|
+
f"borderw={args.border}", f"bordercolor={args.border_color}"]
|
|
146
|
+
if args.font_file:
|
|
147
|
+
opts.append(f"fontfile={escape_filter_path(args.font_file)}")
|
|
148
|
+
else:
|
|
149
|
+
opts.append(f"font='{args.font}'")
|
|
150
|
+
alpha = alpha_expr(args.opacity, start, end, args.fade)
|
|
151
|
+
opts.append(f"fontcolor={args.font_color}")
|
|
152
|
+
if alpha != "1":
|
|
153
|
+
opts.append(f"alpha='{alpha}'")
|
|
154
|
+
if args.box:
|
|
155
|
+
opts += ["box=1", f"boxcolor={args.box_color}", "boxborderw=12"]
|
|
156
|
+
if enable:
|
|
157
|
+
opts.append(f"enable='{enable}'")
|
|
158
|
+
cmd += ["-vf", "drawtext=" + ":".join(opts)]
|
|
159
|
+
|
|
160
|
+
cmd += x264_args(args.crf, args.preset)
|
|
161
|
+
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
162
|
+
cmd.append(output)
|
|
163
|
+
run(cmd)
|
|
164
|
+
result = probe(output)
|
|
165
|
+
info(f"wrote {output} ({result['duration']:.3f}s)")
|
|
166
|
+
print(output)
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
sys.exit(main())
|
package/scripts/probe.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Inspect a media file and print a compact JSON summary.
|
|
3
|
+
|
|
4
|
+
Reports duration, fps (and whether variable frame rate is suspected), resolution,
|
|
5
|
+
codecs, pixel format / color space, audio channels and sample rate.
|
|
6
|
+
|
|
7
|
+
Examples:
|
|
8
|
+
python3 probe.py input.mp4
|
|
9
|
+
python3 probe.py a.mp4 b.mov --compact
|
|
10
|
+
python3 probe.py input.mp4 --field duration
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from _common import print_json, probe
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> int:
|
|
19
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
20
|
+
ap.add_argument("inputs", nargs="+", help="media file(s) to inspect")
|
|
21
|
+
ap.add_argument("--compact", action="store_true", help="one human-readable line per file instead of JSON")
|
|
22
|
+
ap.add_argument("--field", help="print only this top-level field (e.g. duration) or dotted path (video.fps)")
|
|
23
|
+
args = ap.parse_args()
|
|
24
|
+
|
|
25
|
+
results = [probe(p) for p in args.inputs]
|
|
26
|
+
|
|
27
|
+
if args.field:
|
|
28
|
+
for r in results:
|
|
29
|
+
cur = r
|
|
30
|
+
for key in args.field.split("."):
|
|
31
|
+
cur = cur.get(key) if isinstance(cur, dict) else None
|
|
32
|
+
print(cur if cur is not None else "")
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
if args.compact:
|
|
36
|
+
for r in results:
|
|
37
|
+
v, a = r.get("video") or {}, r.get("audio") or {}
|
|
38
|
+
dur = r.get("duration")
|
|
39
|
+
line = f"{r['file']}: {dur:.3f}s" if dur is not None else f"{r['file']}: ?s"
|
|
40
|
+
if v:
|
|
41
|
+
line += f" | {v.get('width')}x{v.get('height')} @ {v.get('fps')}fps {v.get('codec')} {v.get('pix_fmt')}"
|
|
42
|
+
if v.get("variable_frame_rate_suspected"):
|
|
43
|
+
line += " (VFR?)"
|
|
44
|
+
else:
|
|
45
|
+
line += " | no video"
|
|
46
|
+
if a:
|
|
47
|
+
line += f" | audio {a.get('codec')} {a.get('channels')}ch {a.get('sample_rate')}Hz"
|
|
48
|
+
else:
|
|
49
|
+
line += " | no audio"
|
|
50
|
+
print(line)
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
print_json(results[0] if len(results) == 1 else results)
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
sys.exit(main())
|