ffmpeg-skill 0.8.5 → 0.10.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 +296 -118
- package/SKILL.md +39 -7
- package/bin/install.js +10 -2
- package/mcp/server.py +40 -44
- package/package.json +19 -5
- package/scripts/_common.py +55 -7
- package/scripts/_contract.py +757 -0
- package/scripts/audio.py +100 -7
- package/scripts/caption.py +10 -3
- package/scripts/check.py +21 -7
- package/scripts/color.py +68 -4
- package/scripts/cut.py +83 -9
- package/scripts/export.py +15 -6
- package/scripts/fit.py +17 -3
- package/scripts/join.py +74 -3
- package/scripts/multicam.py +10 -0
- package/scripts/overlay.py +5 -0
- package/scripts/render.py +13 -2
- package/scripts/report.py +6 -3
- package/scripts/scenes.py +15 -3
- package/scripts/sync.py +8 -0
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_common.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
212
|
r = probe(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
|
|
package/scripts/caption.py
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
Styling (font, size, colour, outline, position) applies to SRT input via
|
|
5
5
|
libass force_style. ASS files carry their own styles and are rendered as-is.
|
|
6
6
|
|
|
7
|
+
This only burns subtitles into the picture. There is no soft-subtitle (mux a
|
|
8
|
+
subtitle stream, toggleable by the player) path -- every call re-encodes the
|
|
9
|
+
whole video and its audio (see contract --json: reencodes_video/reencodes_audio
|
|
10
|
+
are both "always" for this tool), even for a source that only needed the
|
|
11
|
+
subtitle track added.
|
|
12
|
+
|
|
7
13
|
Text-to-SRT input format (one cue per line, blank lines ignored):
|
|
8
14
|
0:00-0:03 Hello and welcome
|
|
9
15
|
00:00:03.500 --> 00:00:06 Second line | with a manual line break
|
|
@@ -23,7 +29,7 @@ import sys
|
|
|
23
29
|
from pathlib import Path
|
|
24
30
|
from typing import List, Optional, Tuple
|
|
25
31
|
|
|
26
|
-
from _common import 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
|
|
32
|
+
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
|
|
27
33
|
|
|
28
34
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
29
35
|
|
|
@@ -352,7 +358,8 @@ def main() -> int:
|
|
|
352
358
|
srt_path = os.path.splitext(out_guess)[0] + ".srt"
|
|
353
359
|
else:
|
|
354
360
|
srt_path = os.path.splitext(args.text)[0] + ".srt"
|
|
355
|
-
|
|
361
|
+
if not STATE.dry_run:
|
|
362
|
+
write_srt(cues, srt_path)
|
|
356
363
|
info(f"wrote {srt_path} ({len(cues)} cues)")
|
|
357
364
|
if not args.input:
|
|
358
365
|
print(srt_path)
|
|
@@ -382,7 +389,7 @@ def main() -> int:
|
|
|
382
389
|
if args.fonts_dir:
|
|
383
390
|
vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
|
|
384
391
|
else:
|
|
385
|
-
if not srt_path or not os.path.exists(srt_path):
|
|
392
|
+
if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
|
|
386
393
|
die(f"SRT file not found: {srt_path}")
|
|
387
394
|
style = [
|
|
388
395
|
f"FontName={args.font}",
|
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)
|
package/scripts/color.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Colour management: convert HDR (HDR10/PQ, HLG, BT.2020) to SDR BT.709 with
|
|
3
|
-
real tone mapping, apply a .cube LUT (Log footage, creative grades),
|
|
4
|
-
|
|
3
|
+
real tone mapping, apply a .cube LUT (Log footage, creative grades), fix wrong
|
|
4
|
+
colour tags without re-encoding, or apply typed primary colour correction
|
|
5
|
+
(exposure, contrast, saturation, white balance).
|
|
5
6
|
|
|
6
7
|
Examples:
|
|
7
8
|
python3 color.py iphone_hdr.mov --to-sdr # PQ/HLG -> BT.709 SDR, hable tonemap
|
|
@@ -11,16 +12,61 @@ Examples:
|
|
|
11
12
|
python3 color.py wrongly_tagged.mp4 --retag bt709 # metadata only, stream copy
|
|
12
13
|
python3 color.py iphone_dv.mov --strip-dovi # drop Dolby Vision RPU, keep HLG base layer
|
|
13
14
|
python3 color.py iphone_dv.mov --to-sdr # DV 8.4 = HLG base layer -> tone-mapped SDR
|
|
15
|
+
python3 color.py flat.mp4 --correct --exposure 0.3 --contrast 1.1 --saturation 1.05 --temperature 5600 --tint -0.05
|
|
14
16
|
"""
|
|
15
17
|
import argparse
|
|
16
18
|
import os
|
|
17
19
|
import sys
|
|
18
20
|
from typing import List
|
|
19
21
|
|
|
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
|
|
22
|
+
from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, x264_args
|
|
21
23
|
|
|
22
24
|
TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
|
|
23
25
|
|
|
26
|
+
# Typed primary correction: each flag is one option of one real, always-available libavfilter filter
|
|
27
|
+
# (never a caller-supplied filter string). Range is this script's own safe subset of what the filter
|
|
28
|
+
# documents (`ffmpeg -h filter=<name>`), not the filter's full technical range. default is each filter's
|
|
29
|
+
# own documented no-op value, so every stage below is always emitted and the chain never depends on
|
|
30
|
+
# which flags were actually given.
|
|
31
|
+
CORRECTION = {
|
|
32
|
+
# flag default lo hi unit
|
|
33
|
+
"exposure": (0.0, -3.0, 3.0, "stops"), # exposure filter's own full range (linear-domain stops)
|
|
34
|
+
"contrast": (1.0, 0.0, 2.0, "x"), # eq filter; 0=flat grey, 1=unchanged, 2=double contrast
|
|
35
|
+
"saturation": (1.0, 0.0, 2.0, "x"), # eq filter; 0=grayscale, 1=unchanged, 2=double saturation
|
|
36
|
+
"temperature": (6500.0, 2000.0, 12000.0, "K"), # colortemperature filter; 6500=unchanged (its own default)
|
|
37
|
+
"tint": (0.0, -1.0, 1.0, "x"), # mapped to colorbalance midtones, see correction_chain()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _checked(args: argparse.Namespace, flag: str) -> float:
|
|
42
|
+
_, lo, hi, unit = CORRECTION[flag]
|
|
43
|
+
value = getattr(args, flag)
|
|
44
|
+
if not (lo <= value <= hi):
|
|
45
|
+
die(f"--{flag} {value:g} is outside {lo:g}..{hi:g} {unit} (the safe range this tool guarantees)")
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def correction_chain(args: argparse.Namespace) -> str:
|
|
50
|
+
"""Four always-present filter stages, in a fixed order chosen so each stage sees a picture already
|
|
51
|
+
corrected by the previous one: exposure (linear light level) -> white balance (temperature/tint, so
|
|
52
|
+
contrast/saturation act on colour-balanced footage) -> contrast -> saturation (the most creative-
|
|
53
|
+
adjacent stage, applied last). `tint` (-1 green .. +1 magenta) is not a single ffmpeg option: it is
|
|
54
|
+
expressed as colorbalance's three midtone channels (gm=-tint, rm=bm=tint/2) so a positive tint shifts
|
|
55
|
+
midtones toward magenta and a negative one toward green without changing overall midtone lightness,
|
|
56
|
+
the same balanced-axis convention colour tools use for a one-dial tint control."""
|
|
57
|
+
exposure = _checked(args, "exposure")
|
|
58
|
+
contrast = _checked(args, "contrast")
|
|
59
|
+
saturation = _checked(args, "saturation")
|
|
60
|
+
temperature = _checked(args, "temperature")
|
|
61
|
+
tint = _checked(args, "tint")
|
|
62
|
+
gm, rm, bm = -tint, tint / 2.0, tint / 2.0
|
|
63
|
+
return ",".join([
|
|
64
|
+
f"exposure=exposure={exposure:g}",
|
|
65
|
+
f"colortemperature=temperature={temperature:g}",
|
|
66
|
+
f"colorbalance=rm={rm:g}:gm={gm:g}:bm={bm:g}",
|
|
67
|
+
f"eq=contrast={contrast:g}:saturation={saturation:g}",
|
|
68
|
+
])
|
|
69
|
+
|
|
24
70
|
|
|
25
71
|
def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
|
|
26
72
|
v = meta["video"]
|
|
@@ -48,11 +94,17 @@ def main() -> int:
|
|
|
48
94
|
mode.add_argument("--lut", help=".cube LUT to apply (3D)")
|
|
49
95
|
mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
|
|
50
96
|
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")
|
|
97
|
+
mode.add_argument("--correct", action="store_true", help="typed primary colour correction: --exposure/--contrast/--saturation/--temperature/--tint")
|
|
51
98
|
ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
|
|
52
99
|
ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
|
|
53
100
|
ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
|
|
54
101
|
ap.add_argument("--lut-strength", type=float, default=1.0, help="blend LUT result with the original, 0..1 (default 1)")
|
|
55
102
|
ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is not tagged as HDR (treat as PQ)")
|
|
103
|
+
ap.add_argument("--exposure", type=float, default=CORRECTION["exposure"][0], help="--correct: exposure in stops, -3..3 (default 0)")
|
|
104
|
+
ap.add_argument("--contrast", type=float, default=CORRECTION["contrast"][0], help="--correct: contrast, 0..2, 1=unchanged (default 1)")
|
|
105
|
+
ap.add_argument("--saturation", type=float, default=CORRECTION["saturation"][0], help="--correct: saturation, 0..2, 1=unchanged (default 1)")
|
|
106
|
+
ap.add_argument("--temperature", type=float, default=CORRECTION["temperature"][0], help="--correct: white-balance temperature in Kelvin, 2000..12000, 6500=unchanged (default 6500)")
|
|
107
|
+
ap.add_argument("--tint", type=float, default=CORRECTION["tint"][0], help="--correct: green(-1)/magenta(+1) tint, 0=unchanged (default 0)")
|
|
56
108
|
ap.add_argument("--crf", type=int, default=18)
|
|
57
109
|
ap.add_argument("--preset", default="medium")
|
|
58
110
|
add_common(ap)
|
|
@@ -106,12 +158,20 @@ def main() -> int:
|
|
|
106
158
|
emit(output)
|
|
107
159
|
return 0
|
|
108
160
|
|
|
161
|
+
measurements = None
|
|
109
162
|
if args.to_sdr:
|
|
110
163
|
if not v.get("hdr") and not args.force:
|
|
111
164
|
die(f"{args.input} is not tagged as HDR (transfer={v.get('color_transfer')}, primaries={v.get('color_primaries')}). Use --force to tone-map anyway.")
|
|
112
165
|
vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
|
|
113
166
|
output = args.output or default_output(args.input, "sdr")
|
|
114
167
|
tag = "sdr"
|
|
168
|
+
elif args.correct:
|
|
169
|
+
vf = correction_chain(args)
|
|
170
|
+
output = args.output or default_output(args.input, "correct")
|
|
171
|
+
tag = "correct"
|
|
172
|
+
# OBSERVED technical measurements (signalstats: luma / saturation distribution), never a
|
|
173
|
+
# "looks better" judgement -- the same primitive probe.py --analyze uses for Log detection.
|
|
174
|
+
measurements = {"input": analyze_levels(args.input)}
|
|
115
175
|
else:
|
|
116
176
|
if not os.path.exists(args.lut):
|
|
117
177
|
die(f"LUT not found: {args.lut}")
|
|
@@ -130,7 +190,11 @@ def main() -> int:
|
|
|
130
190
|
r = probe(output)
|
|
131
191
|
info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
|
|
132
192
|
f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
|
|
133
|
-
|
|
193
|
+
if measurements is not None:
|
|
194
|
+
measurements["output"] = analyze_levels(output)
|
|
195
|
+
emit(output, measurements=measurements)
|
|
196
|
+
else:
|
|
197
|
+
emit(output)
|
|
134
198
|
return 0
|
|
135
199
|
|
|
136
200
|
|
package/scripts/cut.py
CHANGED
|
@@ -5,10 +5,24 @@ Lossless stream copy (-c copy) is preferred. Cuts snap to keyframes in that
|
|
|
5
5
|
mode, so if frame accuracy matters pass --accurate to re-encode. Multiple
|
|
6
6
|
segments are cut individually and concatenated with the concat demuxer.
|
|
7
7
|
|
|
8
|
+
Audio: a stream copy lands on a packet boundary (about 21 ms for AAC, one
|
|
9
|
+
demuxer block for WAV); --accurate decodes and trims to the sample. The output
|
|
10
|
+
extension picks the codec: -o out.wav writes PCM (never an AAC packet inside a
|
|
11
|
+
WAV), -o out.m4a writes AAC; an audio extension on a video input drops the
|
|
12
|
+
picture (mp4 -> wav extraction). The result reports `precision`
|
|
13
|
+
(packet / sample / codec_frame / frame) and the measured duration error, plus
|
|
14
|
+
`mode` (copy / accurate / hybrid -- "hybrid" means a lossless cut silently
|
|
15
|
+
re-encoded because the keyframe snap exceeded --tolerance), `keyframe_snapped`,
|
|
16
|
+
`requested_start`/`requested_end` (or `requested_segments` for --segments),
|
|
17
|
+
`requested_duration`, `output_duration` and `duration_delta_seconds` -- so a
|
|
18
|
+
caller never has to trust "it probably cut where I asked" on faith.
|
|
19
|
+
|
|
8
20
|
Examples:
|
|
9
21
|
python3 cut.py input.mp4 --start 00:00:10 --end 00:00:25
|
|
10
22
|
python3 cut.py input.mp4 --segments 0:05-0:12,1:00-1:20 -o highlights.mp4
|
|
11
23
|
python3 cut.py input.mp4 --start 3.5 --duration 10 --accurate
|
|
24
|
+
python3 cut.py talk.wav --start 1.2345 --end 2.3456 --accurate -o part.wav # sample-exact
|
|
25
|
+
python3 cut.py talk.mp4 --start 1:00 --end 2:00 -o part.wav # audio extraction
|
|
12
26
|
"""
|
|
13
27
|
import argparse
|
|
14
28
|
import os
|
|
@@ -16,7 +30,7 @@ import sys
|
|
|
16
30
|
import tempfile
|
|
17
31
|
from typing import List, Tuple
|
|
18
32
|
|
|
19
|
-
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run
|
|
33
|
+
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run
|
|
20
34
|
|
|
21
35
|
|
|
22
36
|
def parse_segments(spec: str) -> List[Tuple[float, float]]:
|
|
@@ -37,15 +51,62 @@ def parse_segments(spec: str) -> List[Tuple[float, float]]:
|
|
|
37
51
|
return segs
|
|
38
52
|
|
|
39
53
|
|
|
54
|
+
def encode_args(meta: dict, dst: str, crf: int, preset: str) -> List[str]:
|
|
55
|
+
"""Codec arguments for a re-encoded cut: video + AAC into a video container, otherwise the codec
|
|
56
|
+
the audio extension names (PCM for .wav, FLAC, MP3, AAC...) with no video stream."""
|
|
57
|
+
if is_audio_output(dst) or not meta.get("video"):
|
|
58
|
+
return ["-vn"] + audio_codec_for(dst)
|
|
59
|
+
return video_args(meta, crf, preset) + cfr_args(meta) + aac_args()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def copy_args(meta: dict, dst: str) -> List[str]:
|
|
63
|
+
"""Stream-copy arguments: everything into a video container, audio only into an audio extension."""
|
|
64
|
+
if is_audio_output(dst) and meta.get("video"):
|
|
65
|
+
return ["-vn", "-c:a", "copy"]
|
|
66
|
+
return ["-c", "copy"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
LOSSLESS_AUDIO = {"pcm_s16le", "pcm_s24le", "pcm_s32le", "pcm_f32le", "flac"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def precision_of(meta: dict, dst: str, reencoded: bool) -> str:
|
|
73
|
+
"""How exact the cut is, measured on what was written:
|
|
74
|
+
|
|
75
|
+
packet stream copy; the cut lands on a packet (audio) or keyframe (video) boundary
|
|
76
|
+
sample decoded audio trimmed to the sample and written losslessly (PCM / FLAC)
|
|
77
|
+
codec_frame decoded audio trimmed to the sample, then framed by a lossy encoder (AAC 1024,
|
|
78
|
+
MP3 1152, Opus 960 samples) which also adds its priming delay to the reported length
|
|
79
|
+
frame re-encoded video: the picture is frame-exact, the audio underneath is sample-trimmed
|
|
80
|
+
"""
|
|
81
|
+
if not reencoded:
|
|
82
|
+
return "packet"
|
|
83
|
+
if is_audio_output(dst) or not meta.get("video"):
|
|
84
|
+
codec = audio_codec_for(dst)[1]
|
|
85
|
+
return "sample" if codec in LOSSLESS_AUDIO else "codec_frame"
|
|
86
|
+
return "frame"
|
|
87
|
+
|
|
88
|
+
|
|
40
89
|
def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: int, preset: str, tolerance: float = 0.5, meta: dict = None) -> bool:
|
|
41
90
|
"""Cut one segment. Returns True if the result was re-encoded."""
|
|
42
91
|
dur = end - start
|
|
43
92
|
meta = meta or probe(src)
|
|
93
|
+
audio_only = is_audio_output(dst) or not meta.get("video")
|
|
94
|
+
if not reencode and audio_only and audio_codec_for(dst)[1].startswith("pcm") and not str((meta.get("audio") or {}).get("codec", "")).startswith("pcm"):
|
|
95
|
+
info(f"{(meta.get('audio') or {}).get('codec')} packets cannot be copied into a PCM container; decoding to PCM")
|
|
96
|
+
reencode = True
|
|
44
97
|
if reencode:
|
|
45
|
-
|
|
46
|
-
|
|
98
|
+
# -ss before -i seeks, then decoding discards samples up to the exact start (accurate_seek);
|
|
99
|
+
# atrim bounds the decoded stream to the requested length at sample resolution.
|
|
100
|
+
cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"]
|
|
101
|
+
if is_audio_output(dst) or not meta.get("video"):
|
|
102
|
+
cmd += ["-af", f"atrim=end={dur:.6f},asetpts=PTS-STARTPTS"]
|
|
103
|
+
cmd += encode_args(meta, dst, crf, preset) + ["-avoid_negative_ts", "make_zero", dst]
|
|
104
|
+
elif audio_only:
|
|
105
|
+
# output-side seek: an input seek on a video file lands on the previous video keyframe and on
|
|
106
|
+
# FLAC/MP3 on a coarse index; reading from the start and dropping packets is exact to the packet
|
|
107
|
+
cmd = ffmpeg_base() + ["-i", src, "-ss", f"{start:.6f}", "-t", f"{dur:.6f}"] + copy_args(meta, dst) + ["-avoid_negative_ts", "make_zero", dst]
|
|
47
108
|
else:
|
|
48
|
-
cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}"
|
|
109
|
+
cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}"] + copy_args(meta, dst) + ["-avoid_negative_ts", "make_zero", dst]
|
|
49
110
|
proc = run(cmd, check=False)
|
|
50
111
|
if proc.returncode != 0:
|
|
51
112
|
if not reencode:
|
|
@@ -70,7 +131,7 @@ def main() -> int:
|
|
|
70
131
|
g.add_argument("--end", help="end time")
|
|
71
132
|
g.add_argument("--duration", help="duration instead of --end")
|
|
72
133
|
ap.add_argument("--segments", help="comma separated START-END list, e.g. '0:05-0:12,1:00-1:20' (joined in order)")
|
|
73
|
-
ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
|
|
134
|
+
ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate (video) / sample-accurate (audio) cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
|
|
74
135
|
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
136
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF when re-encoding (default 18)")
|
|
76
137
|
ap.add_argument("--preset", default="medium", help="x264 preset when re-encoding")
|
|
@@ -126,14 +187,27 @@ def main() -> int:
|
|
|
126
187
|
proc = run(cmd, check=False)
|
|
127
188
|
if proc.returncode != 0:
|
|
128
189
|
info("concat with stream copy failed, re-encoding the join")
|
|
129
|
-
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile] +
|
|
190
|
+
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile] + encode_args(meta, output, args.crf, args.preset) + [output]
|
|
130
191
|
run(cmd)
|
|
131
192
|
|
|
132
193
|
result = probe(output)
|
|
133
194
|
expected = sum(e - s for s, e in segments)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
195
|
+
precision = precision_of(meta, output, reencoded)
|
|
196
|
+
got = result.get("duration")
|
|
197
|
+
error_ms = round((got - expected) * 1000, 3) if got is not None and not STATE["dry_run"] else None
|
|
198
|
+
# mode: "copy" (untouched lossless), "accurate" (--accurate was asked for), "hybrid" (asked for
|
|
199
|
+
# lossless but the keyframe snap exceeded --tolerance so this segment silently re-encoded instead)
|
|
200
|
+
mode = "copy" if not reencoded else ("accurate" if args.accurate else "hybrid")
|
|
201
|
+
keyframe_snapped = precision == "packet"
|
|
202
|
+
info(f"wrote {output} ({got:.3f}s, expected ~{expected:.3f}s, "
|
|
203
|
+
+ ("re-encoded" if reencoded else "lossless stream copy") + f", {precision} precision)")
|
|
204
|
+
emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
|
|
205
|
+
requested_start=round(segments[0][0], 6) if len(segments) == 1 else None,
|
|
206
|
+
requested_end=round(segments[0][1], 6) if len(segments) == 1 else None,
|
|
207
|
+
requested_segments=[[round(s, 6), round(e, 6)] for s, e in segments] if len(segments) > 1 else None,
|
|
208
|
+
requested_duration=round(expected, 6), output_duration=round(got, 6) if got is not None else None,
|
|
209
|
+
duration_delta_seconds=round(error_ms / 1000, 6) if error_ms is not None else None,
|
|
210
|
+
mode=mode, keyframe_snapped=keyframe_snapped)
|
|
137
211
|
return 0
|
|
138
212
|
|
|
139
213
|
|
package/scripts/export.py
CHANGED
|
@@ -11,15 +11,19 @@ Presets:
|
|
|
11
11
|
prores ProRes 422 HQ .mov, PCM 16-bit audio (editing master)
|
|
12
12
|
h265 HEVC CRF 24 (libx265) with hvc1 tag for Apple compatibility
|
|
13
13
|
gif 480px wide palette-optimised GIF at 12 fps (short previews)
|
|
14
|
+
copy stream copy, no re-encode: same codecs, container and colour
|
|
15
|
+
tags as the source (a delivery target with nothing to change)
|
|
14
16
|
|
|
15
17
|
Examples:
|
|
16
18
|
python3 export.py final.mp4 --preset youtube
|
|
17
19
|
python3 export.py final.mp4 --preset reels --fit crop
|
|
18
20
|
python3 export.py final.mp4 --preset prores -o master.mov
|
|
21
|
+
python3 export.py final.mp4 --preset copy -o delivered.mp4
|
|
19
22
|
python3 export.py --list
|
|
20
23
|
"""
|
|
21
24
|
import argparse
|
|
22
25
|
import sys
|
|
26
|
+
from pathlib import Path
|
|
23
27
|
from typing import Dict, List
|
|
24
28
|
|
|
25
29
|
from _common import STATE, add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run
|
|
@@ -32,6 +36,7 @@ PRESETS: Dict[str, Dict] = {
|
|
|
32
36
|
"prores": {"w": None, "h": None, "ext": "mov", "video": ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le"], "audio": ["-c:a", "pcm_s16le"], "max": None, "desc": "ProRes 422 HQ master, PCM audio, source resolution"},
|
|
33
37
|
"h265": {"w": None, "h": None, "ext": "mp4", "video": ["-c:v", "libx265", "-preset", "medium", "-crf", "24", "-pix_fmt", "yuv420p", "-tag:v", "hvc1"], "audio": ["-c:a", "aac", "-b:a", "160k"], "max": None, "desc": "HEVC CRF 24, hvc1 tag, source resolution"},
|
|
34
38
|
"gif": {"w": 480, "h": None, "ext": "gif", "video": [], "audio": [], "max": None, "desc": "480px palette GIF, 12fps"},
|
|
39
|
+
"copy": {"w": None, "h": None, "ext": None, "video": ["-c:v", "copy"], "audio": ["-c:a", "copy"], "max": None, "desc": "stream copy, no re-encode (source codecs/container/colour tags unchanged)"},
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
BT709 = ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
|
|
@@ -63,10 +68,11 @@ def main() -> int:
|
|
|
63
68
|
meta = probe(args.input)
|
|
64
69
|
if not meta.get("video"):
|
|
65
70
|
die("input has no video stream")
|
|
66
|
-
if meta["video"].get("hdr") and args.preset
|
|
71
|
+
if meta["video"].get("hdr") and args.preset not in ("prores", "copy"):
|
|
67
72
|
info("warning: source is HDR (%s). This preset outputs SDR BT.709 tags without tone mapping; run color.py --to-sdr first for correct colours." % meta["video"].get("hdr_format"))
|
|
68
73
|
has_audio = bool(meta.get("audio"))
|
|
69
74
|
output = args.output or default_output(args.input, args.preset, p["ext"])
|
|
75
|
+
out_ext = Path(output).suffix.lstrip(".").lower()
|
|
70
76
|
|
|
71
77
|
vf: List[str] = []
|
|
72
78
|
if p["w"] and not args.no_scale:
|
|
@@ -97,11 +103,14 @@ def main() -> int:
|
|
|
97
103
|
if STATE["fast"] and "-preset" in video:
|
|
98
104
|
video[video.index("-preset") + 1] = "veryfast"
|
|
99
105
|
cmd += video
|
|
100
|
-
if "
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
if args.preset != "copy":
|
|
107
|
+
# a stream copy can't be frame-rate-conformed or retagged without decoding it — that would
|
|
108
|
+
# no longer be a copy, and would silently mislabel colour the agent never actually looked at
|
|
109
|
+
if "-r" not in video:
|
|
110
|
+
cmd += cfr_args(meta)
|
|
111
|
+
if args.preset not in ("prores",):
|
|
112
|
+
cmd += BT709
|
|
113
|
+
if out_ext == "mp4":
|
|
105
114
|
cmd += ["-movflags", "+faststart"]
|
|
106
115
|
cmd += (p["audio"] if has_audio else ["-an"])
|
|
107
116
|
if p["max"] and not args.allow_long and (meta.get("duration") or 0) > p["max"]:
|