ffmpeg-skill 0.9.0 → 0.12.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 +315 -122
- package/SKILL.md +115 -18
- package/bin/install.js +16 -2
- package/mcp/server.py +2 -0
- package/package.json +15 -3
- package/references/ci-platform-pitfalls.md +111 -0
- package/references/process-pitfalls.md +85 -0
- package/references/scripts.md +122 -11
- package/scripts/_common.py +247 -15
- package/scripts/_contract.py +420 -48
- package/scripts/audio.py +101 -8
- package/scripts/background.py +73 -0
- package/scripts/caption.py +97 -18
- package/scripts/check.py +21 -7
- package/scripts/color.py +104 -13
- package/scripts/crop.py +79 -0
- package/scripts/cut.py +85 -11
- package/scripts/export.py +16 -7
- package/scripts/fit.py +76 -12
- package/scripts/graphics.py +12 -3
- package/scripts/insert.py +128 -0
- package/scripts/join.py +88 -8
- package/scripts/loudness.py +3 -3
- package/scripts/multicam.py +11 -1
- package/scripts/overlay.py +64 -5
- package/scripts/proxy.py +82 -0
- package/scripts/render.py +13 -2
- package/scripts/reverse.py +56 -0
- package/scripts/scenes.py +15 -3
- package/scripts/sequence.py +124 -0
- package/scripts/silence.py +2 -2
- package/scripts/stabilize.py +83 -0
- package/scripts/sync.py +9 -1
- 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/audio.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Audio post: denoise, voice clean-up,
|
|
3
|
-
fades and stereo/mono handling.
|
|
2
|
+
"""Audio post: denoise, voice clean-up, typed dynamics (compressor, limiter,
|
|
3
|
+
gate), background music with auto-ducking, fades and stereo/mono handling.
|
|
4
|
+
Video is stream-copied; an audio output extension (.wav/.flac/.mp3/.m4a/...)
|
|
5
|
+
drops the picture, so `audio.py talk.mp4 -o talk.wav` is an extraction.
|
|
4
6
|
|
|
5
7
|
Examples:
|
|
6
8
|
python3 audio.py interview.mp4 --denoise # FFT noise reduction
|
|
@@ -10,15 +12,65 @@ Examples:
|
|
|
10
12
|
python3 audio.py clip.mp4 --fade-in 0.5 --fade-out 1 --stereo
|
|
11
13
|
python3 audio.py surround.mov --downmix # 5.1 -> stereo with proper centre/LFE weights
|
|
12
14
|
python3 audio.py clip.mp4 --replace narration.wav # swap the audio track entirely
|
|
15
|
+
python3 audio.py interview.mp4 -o interview.wav # extract the audio (no video in the output)
|
|
16
|
+
python3 audio.py multi.mkv --audio-stream 1 --voice -o lav.m4a # pick the second audio track, clean it, write M4A
|
|
17
|
+
python3 audio.py talk.wav --compress --comp-threshold -20 --comp-ratio 4 --limit --limit-ceiling -1 -o talk_dyn.wav
|
|
13
18
|
"""
|
|
14
19
|
import argparse
|
|
15
20
|
import sys
|
|
16
21
|
from typing import List
|
|
17
22
|
|
|
18
|
-
from _common import add_common, apply_common,
|
|
23
|
+
from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run
|
|
19
24
|
|
|
20
25
|
VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
|
|
21
26
|
|
|
27
|
+
# Typed dynamics: every flag maps to one real option of one ffmpeg filter, validated against the
|
|
28
|
+
# range that filter documents (ffmpeg -h filter=acompressor / alimiter / agate). dB flags are
|
|
29
|
+
# converted to the linear value the filter takes, so no string reaches the graph unchecked.
|
|
30
|
+
DYNAMICS = {
|
|
31
|
+
"acompressor": {
|
|
32
|
+
"comp_threshold": ("threshold", "dB", -60.0, 0.0), # 0.000976563..1 linear
|
|
33
|
+
"comp_ratio": ("ratio", "x", 1.0, 20.0),
|
|
34
|
+
"comp_attack": ("attack", "ms", 0.01, 2000.0),
|
|
35
|
+
"comp_release": ("release", "ms", 0.01, 9000.0),
|
|
36
|
+
"comp_makeup": ("makeup", "dB", 0.0, 36.0), # 1..64 linear
|
|
37
|
+
"comp_knee": ("knee", "dB", 1.0, 8.0),
|
|
38
|
+
},
|
|
39
|
+
"alimiter": {
|
|
40
|
+
"limit_ceiling": ("limit", "dB", -24.0, 0.0), # 0.0625..1 linear
|
|
41
|
+
"limit_attack": ("attack", "ms", 0.1, 80.0),
|
|
42
|
+
"limit_release": ("release", "ms", 1.0, 8000.0),
|
|
43
|
+
},
|
|
44
|
+
"agate": {
|
|
45
|
+
"gate_threshold": ("threshold", "dB", -60.0, 0.0), # 0..1 linear
|
|
46
|
+
"gate_ratio": ("ratio", "x", 1.0, 9000.0),
|
|
47
|
+
"gate_attack": ("attack", "ms", 0.01, 9000.0),
|
|
48
|
+
"gate_release": ("release", "ms", 0.01, 9000.0),
|
|
49
|
+
"gate_range": ("range", "dB", -90.0, 0.0), # 0..1 linear: how far the gate closes
|
|
50
|
+
"gate_knee": ("knee", "dB", 1.0, 8.0),
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def dynamics_filter(name: str, args: argparse.Namespace) -> str:
|
|
56
|
+
"""One validated `acompressor=...` / `alimiter=...` / `agate=...` filter string from typed flags."""
|
|
57
|
+
opts = []
|
|
58
|
+
for flag, (opt, unit, lo, hi) in DYNAMICS[name].items():
|
|
59
|
+
value = getattr(args, flag)
|
|
60
|
+
if value is None:
|
|
61
|
+
continue
|
|
62
|
+
if not (lo <= value <= hi):
|
|
63
|
+
die(f"--{flag.replace('_', '-')} {value:g} is outside {lo:g}..{hi:g} {unit if unit != 'x' else ''}".rstrip()
|
|
64
|
+
+ f" (the range ffmpeg's {name} accepts)")
|
|
65
|
+
if unit == "dB":
|
|
66
|
+
# the filters take linear amplitude (agate range: -90 dB -> 0.00003 closed, 0 dB -> 1 open)
|
|
67
|
+
opts.append(f"{opt}={db_to_linear(value):.6g}")
|
|
68
|
+
else:
|
|
69
|
+
opts.append(f"{opt}={value:g}")
|
|
70
|
+
if name == "alimiter":
|
|
71
|
+
opts.append("level=disabled") # keep the level: a limiter must not normalise the whole track upwards
|
|
72
|
+
return name + ("=" + ":".join(opts) if opts else "")
|
|
73
|
+
|
|
22
74
|
|
|
23
75
|
def main() -> int:
|
|
24
76
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
@@ -43,10 +95,33 @@ def main() -> int:
|
|
|
43
95
|
fades.add_argument("--mono", action="store_true", help="force 1-channel output")
|
|
44
96
|
fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo using standard weights")
|
|
45
97
|
fades.add_argument("--replace", help="replace the audio with this file (trimmed/padded to the video)")
|
|
98
|
+
dyn = ap.add_argument_group("dynamics (typed; each flag is one option of ffmpeg's acompressor / alimiter / agate)")
|
|
99
|
+
dyn.add_argument("--compress", action="store_true", help="compressor (acompressor); order: gate -> compressor -> limiter")
|
|
100
|
+
dyn.add_argument("--comp-threshold", type=float, help="dBFS above which gain is reduced, -60..0 (ffmpeg default -12.4)")
|
|
101
|
+
dyn.add_argument("--comp-ratio", type=float, help="ratio 1..20 (default 2)")
|
|
102
|
+
dyn.add_argument("--comp-attack", type=float, help="ms 0.01..2000 (default 20)")
|
|
103
|
+
dyn.add_argument("--comp-release", type=float, help="ms 0.01..9000 (default 250)")
|
|
104
|
+
dyn.add_argument("--comp-makeup", type=float, help="make-up gain dB 0..36 (default 0)")
|
|
105
|
+
dyn.add_argument("--comp-knee", type=float, help="knee dB 1..8 (default 2.83)")
|
|
106
|
+
dyn.add_argument("--limit", action="store_true", help="look-ahead limiter (alimiter), level left as is")
|
|
107
|
+
dyn.add_argument("--limit-ceiling", type=float, help="ceiling dBFS -24..0 (default 0)")
|
|
108
|
+
dyn.add_argument("--limit-attack", type=float, help="ms 0.1..80 (default 5)")
|
|
109
|
+
dyn.add_argument("--limit-release", type=float, help="ms 1..8000 (default 50)")
|
|
110
|
+
dyn.add_argument("--gate", action="store_true", help="noise gate (agate)")
|
|
111
|
+
dyn.add_argument("--gate-threshold", type=float, help="dBFS below which the gate closes, -60..0 (default -18.1)")
|
|
112
|
+
dyn.add_argument("--gate-ratio", type=float, help="ratio 1..9000 (default 2)")
|
|
113
|
+
dyn.add_argument("--gate-attack", type=float, help="ms 0.01..9000 (default 20)")
|
|
114
|
+
dyn.add_argument("--gate-release", type=float, help="ms 0.01..9000 (default 250)")
|
|
115
|
+
dyn.add_argument("--gate-range", type=float, help="attenuation when closed, dB -90..0 (default -6.1)")
|
|
116
|
+
dyn.add_argument("--gate-knee", type=float, help="knee dB 1..8 (default 2.83)")
|
|
117
|
+
ap.add_argument("--audio-stream", type=int, default=0, help="which audio stream of the input to process, 0-based in file order (probe lists them under audio_streams)")
|
|
46
118
|
ap.add_argument("--bitrate", default="192k")
|
|
47
119
|
add_common(ap)
|
|
48
120
|
args = ap.parse_args()
|
|
49
121
|
apply_common(args)
|
|
122
|
+
for flag_group, switch in (("acompressor", "compress"), ("alimiter", "limit"), ("agate", "gate")):
|
|
123
|
+
if not getattr(args, switch) and any(getattr(args, f) is not None for f in DYNAMICS[flag_group]):
|
|
124
|
+
die(f"--{switch} is off but one of its parameters was given; add --{switch}")
|
|
50
125
|
|
|
51
126
|
meta = probe(args.input)
|
|
52
127
|
dur = meta.get("duration") or 0.0
|
|
@@ -54,9 +129,15 @@ def main() -> int:
|
|
|
54
129
|
if not meta.get("audio") and not args.replace:
|
|
55
130
|
die("input has no audio stream (use --replace to add one)")
|
|
56
131
|
output = args.output or default_output(args.input, "audio")
|
|
132
|
+
audio_out = is_audio_output(output)
|
|
133
|
+
streams = meta.get("audio_streams") or []
|
|
134
|
+
if streams and not (0 <= args.audio_stream < len(streams)) and not STATE["dry_run"]:
|
|
135
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(streams)} audio stream(s), 0..{len(streams) - 1}")
|
|
136
|
+
if args.audio_stream and not streams and not STATE["dry_run"]:
|
|
137
|
+
die("--audio-stream needs an input with audio streams")
|
|
57
138
|
|
|
58
139
|
inputs: List[str] = ["-i", args.input]
|
|
59
|
-
main_src = "0:a:
|
|
140
|
+
main_src = f"0:a:{args.audio_stream}"
|
|
60
141
|
idx = 1
|
|
61
142
|
if args.replace:
|
|
62
143
|
probe(args.replace)
|
|
@@ -73,6 +154,12 @@ def main() -> int:
|
|
|
73
154
|
fx.append(f"afftdn=nf=-{args.denoise_strength:g}:tn=1")
|
|
74
155
|
if args.gain:
|
|
75
156
|
fx.append(f"volume={args.gain:g}dB")
|
|
157
|
+
if args.gate:
|
|
158
|
+
fx.append(dynamics_filter("agate", args))
|
|
159
|
+
if args.compress:
|
|
160
|
+
fx.append(dynamics_filter("acompressor", args))
|
|
161
|
+
if args.limit:
|
|
162
|
+
fx.append(dynamics_filter("alimiter", args))
|
|
76
163
|
if args.mono:
|
|
77
164
|
fx.append("pan=mono|c0=0.5*c0+0.5*c1")
|
|
78
165
|
elif args.stereo:
|
|
@@ -116,14 +203,20 @@ def main() -> int:
|
|
|
116
203
|
last = "out"
|
|
117
204
|
|
|
118
205
|
cmd = ffmpeg_base() + inputs + ["-filter_complex", ";".join(graph), "-map", f"[{last}]"]
|
|
119
|
-
if has_video:
|
|
206
|
+
if has_video and not audio_out:
|
|
120
207
|
cmd += ["-map", "0:v:0", "-c:v", "copy"]
|
|
208
|
+
elif has_video:
|
|
209
|
+
cmd += ["-vn"] # audio extension: the picture is dropped, not copied into a container that cannot hold it
|
|
121
210
|
cmd += audio_codec_for(output, args.bitrate) + ["-shortest", output]
|
|
122
211
|
run(cmd)
|
|
123
|
-
r = probe(output)
|
|
212
|
+
r = probe(output, role="output")
|
|
124
213
|
a = r["audio"]
|
|
125
|
-
|
|
126
|
-
|
|
214
|
+
if r.get("video") and audio_out and not STATE["dry_run"]:
|
|
215
|
+
die(f"{output} unexpectedly contains a video stream")
|
|
216
|
+
info(f"wrote {output} ({r['duration']:.3f}s, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz"
|
|
217
|
+
+ (", video stream-copied" if has_video and not audio_out else ", video dropped" if has_video else "") + ")")
|
|
218
|
+
emit(output, video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
|
|
219
|
+
dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f])
|
|
127
220
|
return 0
|
|
128
221
|
|
|
129
222
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate a solid-colour or two-colour gradient background clip.
|
|
3
|
+
|
|
4
|
+
No input file: a silent, fixed-duration, exact-size clip generated entirely
|
|
5
|
+
by ffmpeg's own source filters (`color` for solid, `gradients` for a
|
|
6
|
+
two-colour gradient) -- for a title card background, a placeholder behind a
|
|
7
|
+
logo, or a base layer for overlay.py to composite onto.
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
python3 background.py --duration 3 --width 1920 --height 1080 --color 0x101010
|
|
11
|
+
python3 background.py --duration 5 --width 1080 --height 1920 --gradient 0xff6a00:0x0057ff --angle 45
|
|
12
|
+
"""
|
|
13
|
+
import argparse
|
|
14
|
+
import math
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> int:
|
|
21
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
22
|
+
ap.add_argument("-o", "--output", required=True, help="output file")
|
|
23
|
+
ap.add_argument("--duration", required=True, help="clip duration (seconds or mm:ss)")
|
|
24
|
+
ap.add_argument("--width", type=int, required=True, help="output width in px (must be even)")
|
|
25
|
+
ap.add_argument("--height", type=int, required=True, help="output height in px (must be even)")
|
|
26
|
+
ap.add_argument("--fps", type=float, default=30.0, help="output frame rate (default 30)")
|
|
27
|
+
src = ap.add_mutually_exclusive_group()
|
|
28
|
+
src.add_argument("--color", default="black", help="solid background colour, e.g. black, 0x101010 (default black)")
|
|
29
|
+
src.add_argument("--gradient", help="two colours as C1:C2 for a linear gradient, e.g. 0xff6a00:0x0057ff")
|
|
30
|
+
ap.add_argument("--angle", type=float, default=0.0, help="gradient angle in degrees (with --gradient, default 0 = left to right)")
|
|
31
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
32
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
33
|
+
add_common(ap)
|
|
34
|
+
args = ap.parse_args()
|
|
35
|
+
apply_common(args)
|
|
36
|
+
|
|
37
|
+
target = parse_time(args.duration)
|
|
38
|
+
if target <= 0:
|
|
39
|
+
die("--duration must be > 0")
|
|
40
|
+
if args.fps <= 0:
|
|
41
|
+
die("--fps must be > 0")
|
|
42
|
+
if args.width <= 0 or args.height <= 0:
|
|
43
|
+
die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
|
|
44
|
+
if args.width % 2 or args.height % 2:
|
|
45
|
+
die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
|
|
46
|
+
|
|
47
|
+
if args.gradient:
|
|
48
|
+
try:
|
|
49
|
+
c0, c1 = args.gradient.split(":")
|
|
50
|
+
except ValueError:
|
|
51
|
+
die(f"--gradient needs two colours as C1:C2, got '{args.gradient}'")
|
|
52
|
+
rad = math.radians(args.angle)
|
|
53
|
+
x1 = round(args.width * math.cos(rad))
|
|
54
|
+
y1 = round(args.width * math.sin(rad))
|
|
55
|
+
src_filter = f"gradients=size={args.width}x{args.height}:rate={args.fps:g}:c0={c0}:c1={c1}:x0=0:y0=0:x1={x1}:y1={y1}"
|
|
56
|
+
else:
|
|
57
|
+
src_filter = f"color=c={args.color}:size={args.width}x{args.height}:rate={args.fps:g}"
|
|
58
|
+
|
|
59
|
+
output = args.output
|
|
60
|
+
cmd = ffmpeg_base() + ["-f", "lavfi", "-i", src_filter, "-t", f"{target:.3f}"]
|
|
61
|
+
cmd += video_args(None, args.crf, args.preset)
|
|
62
|
+
cmd += ["-an", output]
|
|
63
|
+
run(cmd)
|
|
64
|
+
|
|
65
|
+
result = probe(output, role="output")
|
|
66
|
+
v = result["video"]
|
|
67
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['fps']:g}fps)")
|
|
68
|
+
emit(output)
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
sys.exit(main())
|
package/scripts/caption.py
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Burn SRT/ASS subtitles into a video, or
|
|
2
|
+
"""Burn SRT/ASS subtitles into a video, or mux one in as a soft (toggleable)
|
|
3
|
+
subtitle stream, or generate an SRT from plain text.
|
|
3
4
|
|
|
4
5
|
Styling (font, size, colour, outline, position) applies to SRT input via
|
|
5
6
|
libass force_style. ASS files carry their own styles and are rendered as-is.
|
|
7
|
+
Styling and animation only apply to --mode burn (the default): they render
|
|
8
|
+
pixels, so they have no meaning for a soft subtitle stream.
|
|
9
|
+
|
|
10
|
+
--mode mux copies the video and audio streams untouched (see contract --json:
|
|
11
|
+
reencodes_video/reencodes_audio are "never" for this mode) and adds the SRT
|
|
12
|
+
as a separate subtitle stream a player can toggle -- the source is never
|
|
13
|
+
touched. It takes only a plain SRT (from --srt, --text or --transcribe), not
|
|
14
|
+
--ass: ASS styling has no equivalent soft-subtitle representation across
|
|
15
|
+
containers, so --mode mux --ass is refused with a pointer to --mode burn.
|
|
16
|
+
The subtitle codec is picked from the output container: mov_text for
|
|
17
|
+
.mp4/.m4v/.mov, srt for .mkv, webvtt for .webm.
|
|
6
18
|
|
|
7
19
|
Text-to-SRT input format (one cue per line, blank lines ignored):
|
|
8
20
|
0:00-0:03 Hello and welcome
|
|
9
21
|
00:00:03.500 --> 00:00:06 Second line | with a manual line break
|
|
22
|
+
00:00:03:15 --> 00:00:06:00 SMPTE non-drop-frame timecode (hh:mm:ss:ff, needs --fps)
|
|
10
23
|
Text without a time is auto-timed after the previous cue (--auto-seconds)
|
|
11
24
|
|
|
12
25
|
Examples:
|
|
@@ -23,7 +36,7 @@ import sys
|
|
|
23
36
|
from pathlib import Path
|
|
24
37
|
from typing import List, Optional, Tuple
|
|
25
38
|
|
|
26
|
-
from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, info, parse_time, probe, run, x264_args
|
|
39
|
+
from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args
|
|
27
40
|
|
|
28
41
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
29
42
|
|
|
@@ -32,7 +45,7 @@ TIME_RE = re.compile(
|
|
|
32
45
|
)
|
|
33
46
|
|
|
34
47
|
|
|
35
|
-
def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[float, float, str]]:
|
|
48
|
+
def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[float] = None) -> List[Tuple[float, float, str]]:
|
|
36
49
|
cues: List[Tuple[float, float, str]] = []
|
|
37
50
|
cursor = 0.0
|
|
38
51
|
with open(path, encoding="utf-8") as fh:
|
|
@@ -43,7 +56,9 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[fl
|
|
|
43
56
|
m = TIME_RE.match(line)
|
|
44
57
|
if m:
|
|
45
58
|
try:
|
|
46
|
-
start, end = parse_time(m.group("a")), parse_time(m.group("b"))
|
|
59
|
+
start, end = parse_time(m.group("a"), fps), parse_time(m.group("b"), fps)
|
|
60
|
+
except MissingFpsError as e:
|
|
61
|
+
die(f"cue '{line}': {e} -- pass --fps, or --input's own fps is used automatically when given")
|
|
47
62
|
except ValueError:
|
|
48
63
|
start, end, text = cursor, cursor + auto_seconds, line.strip()
|
|
49
64
|
else:
|
|
@@ -60,7 +75,7 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[fl
|
|
|
60
75
|
return cues
|
|
61
76
|
|
|
62
77
|
|
|
63
|
-
def transcribe(video: str, out_srt: str, language: Optional[str], model: str) -> List[Tuple[float, float, str]]:
|
|
78
|
+
def transcribe(video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int = 0) -> List[Tuple[float, float, str]]:
|
|
64
79
|
"""Optional local ASR bridge. Tries, in order: whisper-cli / main (whisper.cpp), faster-whisper (python),
|
|
65
80
|
whisper (openai-whisper CLI). Produces an SRT with word timings where the engine supports it.
|
|
66
81
|
No engine installed -> clear error with install hints; the skill never depends on one."""
|
|
@@ -71,7 +86,8 @@ def transcribe(video: str, out_srt: str, language: Optional[str], model: str) ->
|
|
|
71
86
|
ffmpeg = require_tool("ffmpeg")
|
|
72
87
|
tmpdir = tempfile.mkdtemp(prefix="ffskill_asr_")
|
|
73
88
|
wav = os.path.join(tmpdir, "audio.wav")
|
|
74
|
-
subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
|
|
89
|
+
subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
|
|
90
|
+
"-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav], check=True)
|
|
75
91
|
# 1. whisper.cpp
|
|
76
92
|
cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp") or shutil.which("main")
|
|
77
93
|
if cli and (shutil.which("whisper-cli") or shutil.which("whisper-cpp")):
|
|
@@ -152,7 +168,7 @@ def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
|
|
|
152
168
|
fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
|
|
153
169
|
|
|
154
170
|
|
|
155
|
-
def word_durations_from_audio(video: str, start: float, end: float, n_words: int) -> List[int]:
|
|
171
|
+
def word_durations_from_audio(video: str, start: float, end: float, n_words: int, audio_stream: int = 0) -> List[int]:
|
|
156
172
|
"""Split a cue's time across n_words in proportion to speech energy (centiseconds each).
|
|
157
173
|
|
|
158
174
|
Decodes the cue window to 8 kHz mono, builds a 10 ms RMS envelope, removes the noise floor,
|
|
@@ -167,7 +183,7 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
|
|
|
167
183
|
return [total_cs]
|
|
168
184
|
ffmpeg = require_tool("ffmpeg")
|
|
169
185
|
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", video,
|
|
170
|
-
"-t", f"{end - start:.3f}", "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
|
|
186
|
+
"-map", f"0:a:{audio_stream}", "-t", f"{end - start:.3f}", "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
|
|
171
187
|
proc = sp.run(cmd, stdout=sp.PIPE, stderr=sp.PIPE)
|
|
172
188
|
n = len(proc.stdout) // 2
|
|
173
189
|
if proc.returncode != 0 or n < 800:
|
|
@@ -251,7 +267,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
251
267
|
segments = body.split("\\N")
|
|
252
268
|
words = [w for seg in segments for w in seg.split(" ") if w]
|
|
253
269
|
if getattr(args, "karaoke_timing", "even") == "energy" and video:
|
|
254
|
-
durs = word_durations_from_audio(video, start, end, len(words))
|
|
270
|
+
durs = word_durations_from_audio(video, start, end, len(words), getattr(args, "audio_stream", 0))
|
|
255
271
|
else:
|
|
256
272
|
per = max(1, dur_cs // max(1, len(words)))
|
|
257
273
|
durs = [per] * len(words)
|
|
@@ -266,6 +282,18 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
266
282
|
fh.write("\n".join(header + lines) + "\n")
|
|
267
283
|
|
|
268
284
|
|
|
285
|
+
def mux_subtitle_codec(output: str) -> str:
|
|
286
|
+
ext = Path(output).suffix.lower()
|
|
287
|
+
if ext in (".mp4", ".m4v", ".mov"):
|
|
288
|
+
return "mov_text"
|
|
289
|
+
if ext == ".mkv":
|
|
290
|
+
return "srt"
|
|
291
|
+
if ext == ".webm":
|
|
292
|
+
return "webvtt"
|
|
293
|
+
die(f"--mode mux: don't know a soft-subtitle codec for '{ext}' output "
|
|
294
|
+
"(know .mp4/.m4v/.mov, .mkv, .webm) -- use --mode burn, or pick one of those containers with -o")
|
|
295
|
+
|
|
296
|
+
|
|
269
297
|
def ass_color(hex_rgb: str, alpha: int = 0) -> str:
|
|
270
298
|
h = hex_rgb.lstrip("#")
|
|
271
299
|
if len(h) != 6:
|
|
@@ -278,16 +306,26 @@ def main() -> int:
|
|
|
278
306
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
279
307
|
ap.add_argument("input", nargs="?", help="video to burn captions into (omit with --write-srt to only generate)")
|
|
280
308
|
ap.add_argument("-o", "--output", help="output video (default: <name>_captioned.<ext>)")
|
|
309
|
+
ap.add_argument("--mode", choices=["burn", "mux"], default="burn",
|
|
310
|
+
help="'burn' renders subtitles into the picture (default); "
|
|
311
|
+
"'mux' copies video/audio untouched and adds the SRT as a soft, toggleable subtitle stream")
|
|
312
|
+
ap.add_argument("--audio-stream", type=int, default=0,
|
|
313
|
+
help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
|
|
314
|
+
"audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
|
|
315
|
+
"the first track, same as leaving it unset always did")
|
|
281
316
|
src = ap.add_argument_group("subtitle source")
|
|
282
317
|
src.add_argument("--srt", help="SRT file to burn")
|
|
283
318
|
src.add_argument("--ass", help="ASS file to burn (styles inside the file are used)")
|
|
284
319
|
src.add_argument("--text", help="plain text cue file to convert into SRT (see format above)")
|
|
285
320
|
src.add_argument("--transcribe", action="store_true", help="generate the SRT from the audio with a local speech-to-text engine if one is installed (whisper-cli / whisper / faster-whisper); never required")
|
|
286
|
-
src.add_argument("--language", help="language code for --transcribe (e.g. en, ja
|
|
321
|
+
src.add_argument("--language", help="language code for --transcribe (e.g. en, ja; default auto), also tagged on the subtitle stream with --mode mux")
|
|
287
322
|
src.add_argument("--model", default="base", help="whisper model name/path for --transcribe (default base)")
|
|
288
323
|
src.add_argument("--write-srt", help="where to save the generated SRT (default: <text>.srt)")
|
|
289
324
|
src.add_argument("--auto-seconds", type=float, default=3.0, help="duration for cues without timing (default 3)")
|
|
290
325
|
src.add_argument("--gap", type=float, default=0.0, help="gap after auto-timed cues in seconds")
|
|
326
|
+
src.add_argument("--fps", type=float, default=None,
|
|
327
|
+
help="frame rate for interpreting hh:mm:ss:ff SMPTE timecode cues in --text (non-drop-frame); "
|
|
328
|
+
"defaults to the input video's own fps when --input is given, required otherwise")
|
|
291
329
|
sty = ap.add_argument_group("style (SRT only)")
|
|
292
330
|
sty.add_argument("--brand", help="brand.json: font, colours, caption size/position/animation defaults")
|
|
293
331
|
sty.add_argument("--font", default=None, help="font family, e.g. 'Noto Sans CJK JP' for Japanese (default DejaVu Sans or brand font)")
|
|
@@ -333,17 +371,37 @@ def main() -> int:
|
|
|
333
371
|
args.fonts_dir = str(Path(brand["font_file"]).parent)
|
|
334
372
|
if not (args.srt or args.ass or args.text or args.transcribe):
|
|
335
373
|
die("give one of --srt, --ass, --text or --transcribe")
|
|
374
|
+
if args.mode == "mux":
|
|
375
|
+
if args.ass:
|
|
376
|
+
die("--mode mux takes --srt (or --text/--transcribe), not --ass -- "
|
|
377
|
+
"ASS carries burn-only styling with no soft-subtitle equivalent; use --mode burn for an ASS file")
|
|
378
|
+
if args.animate != "none" or args.karaoke:
|
|
379
|
+
die("--animate/--karaoke render pixels into the picture and require --mode burn")
|
|
380
|
+
|
|
381
|
+
meta = None
|
|
382
|
+
if args.input:
|
|
383
|
+
meta = probe(args.input)
|
|
384
|
+
if not meta.get("video"):
|
|
385
|
+
die("input has no video stream")
|
|
386
|
+
audio_streams = meta.get("audio_streams") or []
|
|
387
|
+
if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
|
|
388
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
389
|
+
if args.audio_stream and not audio_streams:
|
|
390
|
+
die("--audio-stream needs an input with audio streams")
|
|
391
|
+
fps_for_tc = args.fps
|
|
392
|
+
if fps_for_tc is None and meta is not None:
|
|
393
|
+
fps_for_tc = meta.get("video", {}).get("fps")
|
|
336
394
|
|
|
337
395
|
srt_path = args.srt
|
|
338
396
|
if args.transcribe:
|
|
339
397
|
if not args.input:
|
|
340
398
|
die("--transcribe needs the input video")
|
|
341
399
|
srt_path = args.write_srt or os.path.splitext(args.input)[0] + ".srt"
|
|
342
|
-
cues = transcribe(args.input, srt_path, args.language, args.model)
|
|
400
|
+
cues = transcribe(args.input, srt_path, args.language, args.model, args.audio_stream)
|
|
343
401
|
info(f"wrote {srt_path} ({len(cues)} cues)")
|
|
344
402
|
args.text = None
|
|
345
403
|
if args.text:
|
|
346
|
-
cues = parse_text_cues(args.text, args.auto_seconds, args.gap)
|
|
404
|
+
cues = parse_text_cues(args.text, args.auto_seconds, args.gap, fps_for_tc)
|
|
347
405
|
if args.write_srt:
|
|
348
406
|
srt_path = args.write_srt
|
|
349
407
|
elif args.input:
|
|
@@ -354,18 +412,36 @@ def main() -> int:
|
|
|
354
412
|
srt_path = os.path.splitext(args.text)[0] + ".srt"
|
|
355
413
|
if not STATE.dry_run:
|
|
356
414
|
write_srt(cues, srt_path)
|
|
357
|
-
|
|
415
|
+
tc_range = f", {fmt_smpte_time(cues[0][0], fps_for_tc)}-{fmt_smpte_time(cues[-1][1], fps_for_tc)} @ {fps_for_tc:g}fps" if fps_for_tc else ""
|
|
416
|
+
info(f"wrote {srt_path} ({len(cues)} cues{tc_range})")
|
|
358
417
|
if not args.input:
|
|
359
418
|
print(srt_path)
|
|
360
419
|
return 0
|
|
361
420
|
|
|
362
421
|
if not args.input:
|
|
363
422
|
die("input video is required unless you only use --text/--write-srt")
|
|
364
|
-
meta = probe(args.input)
|
|
365
|
-
if not meta.get("video"):
|
|
366
|
-
die("input has no video stream")
|
|
367
423
|
|
|
368
424
|
output = args.output or default_output(args.input, "captioned")
|
|
425
|
+
|
|
426
|
+
if args.mode == "mux":
|
|
427
|
+
if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
|
|
428
|
+
die(f"SRT file not found: {srt_path}")
|
|
429
|
+
codec = mux_subtitle_codec(output)
|
|
430
|
+
maps = ["-map", "0:v:0"]
|
|
431
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-i", srt_path]
|
|
432
|
+
if meta.get("audio"):
|
|
433
|
+
maps += ["-map", f"0:a:{args.audio_stream}"]
|
|
434
|
+
maps += ["-map", "1:0"]
|
|
435
|
+
cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else []) + ["-c:s", codec]
|
|
436
|
+
if args.language:
|
|
437
|
+
cmd += ["-metadata:s:s:0", f"language={args.language}"]
|
|
438
|
+
cmd += [output]
|
|
439
|
+
run(cmd)
|
|
440
|
+
result = probe(output, role="output")
|
|
441
|
+
info(f"wrote {output} ({result.get('duration'):.3f}s, mux, subtitle codec {codec})")
|
|
442
|
+
emit(output)
|
|
443
|
+
return 0
|
|
444
|
+
|
|
369
445
|
if (args.animate != "none" or args.karaoke) and not args.ass:
|
|
370
446
|
cues_for_ass = cues if args.text else parse_srt(srt_path)
|
|
371
447
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
@@ -403,10 +479,13 @@ def main() -> int:
|
|
|
403
479
|
if args.fonts_dir:
|
|
404
480
|
vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
|
|
405
481
|
|
|
406
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-
|
|
482
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0"]
|
|
483
|
+
if meta.get("audio"):
|
|
484
|
+
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
485
|
+
cmd += ["-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
407
486
|
cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
|
|
408
487
|
run(cmd)
|
|
409
|
-
result = probe(output)
|
|
488
|
+
result = probe(output, role="output")
|
|
410
489
|
info(f"wrote {output} ({result.get('duration'):.3f}s)")
|
|
411
490
|
emit(output)
|
|
412
491
|
return 0
|
package/scripts/check.py
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
|
|
4
4
|
Checks duration, frame size / aspect, fps, codec, pixel format, colour tags,
|
|
5
5
|
file size, integrated loudness and true peak against the chosen platform
|
|
6
|
-
and prints a PASS/WARN/FAIL table. Exit code 1 when anything FAILs.
|
|
6
|
+
and prints a PASS/WARN/FAIL table. Exit code 1 when anything FAILs. Each
|
|
7
|
+
row's `fix` is the command that resolves it; a few of the less obvious FAILs
|
|
8
|
+
(video codec, pixel format, HDR colour, loudness) also carry a plain-language
|
|
9
|
+
`reason` -- "QuickTime and iOS commonly reject video that isn't 8-bit 4:2:0",
|
|
10
|
+
not a restatement of the spec value -- for a caller reporting this to someone
|
|
11
|
+
who doesn't already know why the spec says what it says.
|
|
7
12
|
|
|
8
13
|
Platforms: youtube, shorts, reels, tiktok, x, linkedin, broadcast (EBU R128), podcast, custom
|
|
9
14
|
|
|
@@ -88,10 +93,13 @@ def main() -> int:
|
|
|
88
93
|
|
|
89
94
|
JUDGEMENT = {"duration", "aspect", "loudness", "fps", "resolution"}
|
|
90
95
|
|
|
91
|
-
def row(name: str, status: str, value: Any, expect: Any, fix: str = "") -> None:
|
|
96
|
+
def row(name: str, status: str, value: Any, expect: Any, fix: str = "", reason: str = "") -> None:
|
|
92
97
|
# "format" rows are safe to fix mechanically; "judgement" rows change the content
|
|
93
|
-
# (what is cut, what is cropped, how loud ambience gets) and need a decision
|
|
98
|
+
# (what is cut, what is cropped, how loud ambience gets) and need a decision.
|
|
99
|
+
# "fix" is the command that resolves it; "reason" (only on the FAILs a non-technical
|
|
100
|
+
# person would ask "so what?" about) is why it matters in plain terms, not the spec clause.
|
|
94
101
|
rows.append({"check": name, "status": status, "value": value, "expected": expect, "fix": fix,
|
|
102
|
+
"reason": reason if status != "PASS" else "",
|
|
95
103
|
"kind": "judgement" if name in JUDGEMENT else "format"})
|
|
96
104
|
|
|
97
105
|
dur = meta.get("duration") or 0.0
|
|
@@ -117,12 +125,15 @@ def main() -> int:
|
|
|
117
125
|
row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30 (drops half the frames of 60 fps motion; fine for talking heads, visible on sports/gaming)")
|
|
118
126
|
row("vfr", "PASS" if not v.get("variable_frame_rate_suspected") else "WARN", "variable" if v.get("variable_frame_rate_suspected") else "constant", "constant", "fit.py --fps N (any re-encode conforms it)")
|
|
119
127
|
if spec["codecs"]:
|
|
120
|
-
row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + args.platform.replace("shorts", "reels").replace("tiktok", "reels").replace("linkedin", "youtube")
|
|
128
|
+
row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + args.platform.replace("shorts", "reels").replace("tiktok", "reels").replace("linkedin", "youtube"),
|
|
129
|
+
reason="the platform's player may refuse to decode this codec at all, not just look worse")
|
|
121
130
|
pf = v.get("pix_fmt") or ""
|
|
122
131
|
if args.platform in ("reels", "tiktok", "x", "linkedin"):
|
|
123
|
-
row("pixel format", "PASS" if pf == "yuv420p" else "FAIL", pf, "yuv420p (8-bit 4:2:0)", "export.py preset re-encodes to yuv420p"
|
|
132
|
+
row("pixel format", "PASS" if pf == "yuv420p" else "FAIL", pf, "yuv420p (8-bit 4:2:0)", "export.py preset re-encodes to yuv420p",
|
|
133
|
+
reason="QuickTime and iOS commonly reject video that isn't 8-bit 4:2:0")
|
|
124
134
|
if spec["sdr_only"] and v.get("hdr"):
|
|
125
|
-
row("colour", "FAIL", v.get("hdr_format"), "SDR BT.709", "color.py --to-sdr"
|
|
135
|
+
row("colour", "FAIL", v.get("hdr_format"), "SDR BT.709", "color.py --to-sdr",
|
|
136
|
+
reason="a platform or player without HDR support will show this washed-out, too dark, or with wrong colours -- not a rendering glitch, a colour space mismatch")
|
|
126
137
|
else:
|
|
127
138
|
tags = (v.get("color_primaries"), v.get("color_transfer"))
|
|
128
139
|
untagged = not tags[0] and not tags[1]
|
|
@@ -148,7 +159,8 @@ def main() -> int:
|
|
|
148
159
|
lm = measure_loudness(args.input)
|
|
149
160
|
if lm:
|
|
150
161
|
diff = abs(lm["lufs"] - spec["lufs"])
|
|
151
|
-
row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g} for speech or music; leave ambience/near-silence (<= -40 LUFS) alone and say so"
|
|
162
|
+
row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g} for speech or music; leave ambience/near-silence (<= -40 LUFS) alone and say so",
|
|
163
|
+
reason="the platform will auto-normalise it to its own target anyway, which can pump or duck the mix in ways you did not choose")
|
|
152
164
|
row("true peak", "PASS" if lm["tp"] <= spec["tp"] + 0.05 else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP", f"loudness.py --tp {spec['tp']:g}")
|
|
153
165
|
elif args.platform in ("podcast",):
|
|
154
166
|
row("audio", "FAIL", "none", "audio stream", "audio.py --replace")
|
|
@@ -164,6 +176,8 @@ def main() -> int:
|
|
|
164
176
|
line = f" {r['status']:4s} {r['check']:{width}s} {r['value']} (expected {r['expected']})"
|
|
165
177
|
if r["status"] != "PASS" and r["kind"] == "judgement":
|
|
166
178
|
line += " [judgement]"
|
|
179
|
+
if r["status"] != "PASS" and r["reason"]:
|
|
180
|
+
line += f" ({r['reason']})"
|
|
167
181
|
if r["status"] != "PASS" and r["fix"]:
|
|
168
182
|
line += f" -> {r['fix']}"
|
|
169
183
|
print(line)
|