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/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,23 @@ 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)")
|
|
108
|
+
ap.add_argument("--audio-stream", type=int, default=0,
|
|
109
|
+
help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
|
|
110
|
+
"audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
|
|
111
|
+
"the first track, same as leaving it unset always did. Only affects modes that re-encode "
|
|
112
|
+
"audio (--to-sdr, --lut, --correct, and --retag's re-encode fallback) -- --strip-dovi and "
|
|
113
|
+
"a successful --retag stream-copy all streams untouched, so the flag has nothing to select there.")
|
|
56
114
|
ap.add_argument("--crf", type=int, default=18)
|
|
57
115
|
ap.add_argument("--preset", default="medium")
|
|
58
116
|
add_common(ap)
|
|
@@ -64,6 +122,11 @@ def main() -> int:
|
|
|
64
122
|
die("input has no video stream")
|
|
65
123
|
v = meta["video"]
|
|
66
124
|
has_audio = bool(meta.get("audio"))
|
|
125
|
+
audio_streams = meta.get("audio_streams") or []
|
|
126
|
+
if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
|
|
127
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
128
|
+
if args.audio_stream and not audio_streams:
|
|
129
|
+
die("--audio-stream needs an input with audio streams")
|
|
67
130
|
|
|
68
131
|
if args.strip_dovi:
|
|
69
132
|
output = args.output or default_output(args.input, "nodv")
|
|
@@ -76,7 +139,7 @@ def main() -> int:
|
|
|
76
139
|
cmd += ["-movflags", "+faststart"]
|
|
77
140
|
cmd.append(output)
|
|
78
141
|
run(cmd)
|
|
79
|
-
r = probe(output)
|
|
142
|
+
r = probe(output, role="output")
|
|
80
143
|
info(f"wrote {output} (dolby_vision={r['video'].get('dolby_vision')})")
|
|
81
144
|
emit(output)
|
|
82
145
|
return 0
|
|
@@ -96,22 +159,46 @@ def main() -> int:
|
|
|
96
159
|
cmd += ["-movflags", "+faststart"]
|
|
97
160
|
cmd.append(output)
|
|
98
161
|
proc = run(cmd, check=False)
|
|
162
|
+
dropped_streams = False
|
|
99
163
|
if proc.returncode != 0:
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
164
|
+
# Some codecs cannot carry retagged colour info without a bitstream filter, so the
|
|
165
|
+
# stream copy above fails and we fall back to re-encoding video+audio. The copy path
|
|
166
|
+
# (-map 0 -c copy) keeps every stream -- extra audio tracks, subtitles, chapters,
|
|
167
|
+
# attached pictures -- byte-for-byte; -c:s/-c:d copy here keeps that same guarantee
|
|
168
|
+
# for subtitle/data streams even though video/audio must be re-encoded. Only if THAT
|
|
169
|
+
# also fails (e.g. a subtitle codec genuinely incompatible with the target container)
|
|
170
|
+
# do we drop to video+selected-audio-only, and even then we say so explicitly rather
|
|
171
|
+
# than silently reporting "completed" with streams missing.
|
|
172
|
+
info("stream copy could not rewrite tags, re-encoding video/audio (subtitles/data streams kept)")
|
|
173
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
|
|
174
|
+
"-map", "0:s?", "-map", "0:d?"] + x264_args(args.crf, args.preset, keep_bt709=False)
|
|
175
|
+
cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]]
|
|
176
|
+
cmd += (aac_args() if has_audio else []) + ["-c:s", "copy", "-c:d", "copy"] + [output]
|
|
177
|
+
proc2 = run(cmd, check=False)
|
|
178
|
+
if proc2.returncode != 0:
|
|
179
|
+
info("re-encode with subtitles/data streams kept also failed; dropping them")
|
|
180
|
+
dropped_streams = True
|
|
181
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"] + x264_args(args.crf, args.preset, keep_bt709=False)
|
|
182
|
+
cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
|
|
183
|
+
run(cmd)
|
|
105
184
|
info(f"wrote {output} (tags -> {args.retag})")
|
|
106
|
-
emit(output)
|
|
185
|
+
emit(output, reencoded=proc.returncode != 0, dropped_non_av_streams=dropped_streams)
|
|
107
186
|
return 0
|
|
108
187
|
|
|
188
|
+
measurements = None
|
|
109
189
|
if args.to_sdr:
|
|
110
190
|
if not v.get("hdr") and not args.force:
|
|
111
191
|
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
192
|
vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
|
|
113
193
|
output = args.output or default_output(args.input, "sdr")
|
|
114
194
|
tag = "sdr"
|
|
195
|
+
elif args.correct:
|
|
196
|
+
vf = correction_chain(args)
|
|
197
|
+
output = args.output or default_output(args.input, "correct")
|
|
198
|
+
tag = "correct"
|
|
199
|
+
# OBSERVED technical measurements (signalstats: luma / saturation distribution), never a
|
|
200
|
+
# "looks better" judgement -- the same primitive probe.py --analyze uses for Log detection.
|
|
201
|
+
measurements = {"input": analyze_levels(args.input)}
|
|
115
202
|
else:
|
|
116
203
|
if not os.path.exists(args.lut):
|
|
117
204
|
die(f"LUT not found: {args.lut}")
|
|
@@ -124,13 +211,17 @@ def main() -> int:
|
|
|
124
211
|
output = args.output or default_output(args.input, "lut")
|
|
125
212
|
tag = "lut"
|
|
126
213
|
|
|
127
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", "0:a:
|
|
214
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
128
215
|
cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else []) + [output]
|
|
129
216
|
run(cmd)
|
|
130
|
-
r = probe(output)
|
|
217
|
+
r = probe(output, role="output")
|
|
131
218
|
info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
|
|
132
219
|
f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
|
|
133
|
-
|
|
220
|
+
if measurements is not None:
|
|
221
|
+
measurements["output"] = analyze_levels(output)
|
|
222
|
+
emit(output, measurements=measurements)
|
|
223
|
+
else:
|
|
224
|
+
emit(output)
|
|
134
225
|
return 0
|
|
135
226
|
|
|
136
227
|
|
package/scripts/crop.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Crop a video to an exact pixel rectangle.
|
|
3
|
+
|
|
4
|
+
{x, y, width, height} are literal pixel offsets and dimensions in the SOURCE
|
|
5
|
+
frame, not aspect-ratio-relative -- for cropping to a target aspect ratio
|
|
6
|
+
(e.g. 16:9 -> 9:16) use fit.py --fit crop instead, which computes the
|
|
7
|
+
rectangle for you and lets you steer it with --crop-x/--crop-y. This tool is
|
|
8
|
+
for when the caller already knows the exact rectangle (a face-detection box,
|
|
9
|
+
a saved crop from a previous edit, a hand-picked region).
|
|
10
|
+
|
|
11
|
+
The rectangle must lie entirely inside the source frame after accounting for
|
|
12
|
+
any display rotation, and --width/--height must be even (required for 4:2:0
|
|
13
|
+
chroma subsampling, the pixel format every encoder here uses) -- given values
|
|
14
|
+
are validated and refused, never silently rounded, since a caller-specified
|
|
15
|
+
rectangle should do exactly what was asked or fail loudly.
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
python3 crop.py input.mp4 --x 100 --y 0 --width 1080 --height 1920
|
|
19
|
+
python3 crop.py input.mp4 --x 0 --y 140 --width 1920 --height 800 -o cropped.mp4
|
|
20
|
+
"""
|
|
21
|
+
import argparse
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main() -> int:
|
|
28
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
29
|
+
ap.add_argument("input")
|
|
30
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_crop.<ext>)")
|
|
31
|
+
ap.add_argument("--x", type=int, required=True, help="left edge of the crop rectangle, in source pixels")
|
|
32
|
+
ap.add_argument("--y", type=int, required=True, help="top edge of the crop rectangle, in source pixels")
|
|
33
|
+
ap.add_argument("--width", type=int, required=True, help="crop width in px (must be even)")
|
|
34
|
+
ap.add_argument("--height", type=int, required=True, help="crop height in px (must be even)")
|
|
35
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
36
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
37
|
+
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
38
|
+
add_common(ap)
|
|
39
|
+
args = ap.parse_args()
|
|
40
|
+
apply_common(args)
|
|
41
|
+
|
|
42
|
+
if args.x < 0 or args.y < 0:
|
|
43
|
+
die(f"--x/--y must be >= 0, got x={args.x} y={args.y}")
|
|
44
|
+
if args.width <= 0 or args.height <= 0:
|
|
45
|
+
die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
|
|
46
|
+
if args.width % 2 or args.height % 2:
|
|
47
|
+
die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
|
|
48
|
+
|
|
49
|
+
meta = probe(args.input)
|
|
50
|
+
if not meta.get("video"):
|
|
51
|
+
die("input has no video stream")
|
|
52
|
+
sw, sh = meta["video"]["width"], meta["video"]["height"]
|
|
53
|
+
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
54
|
+
sw, sh = sh, sw
|
|
55
|
+
if args.x + args.width > sw or args.y + args.height > sh:
|
|
56
|
+
die(f"crop rectangle ({args.x},{args.y},{args.width}x{args.height}) exceeds the source frame ({sw}x{sh})")
|
|
57
|
+
has_audio = bool(meta.get("audio"))
|
|
58
|
+
|
|
59
|
+
output = args.output or default_output(args.input, "crop")
|
|
60
|
+
vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}", "setsar=1"]
|
|
61
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", ",".join(vf)]
|
|
62
|
+
cmd += video_args(meta, args.crf, args.preset)
|
|
63
|
+
cmd += cfr_args(meta, args.fps)
|
|
64
|
+
if has_audio:
|
|
65
|
+
cmd += aac_args()
|
|
66
|
+
else:
|
|
67
|
+
cmd += ["-an"]
|
|
68
|
+
cmd.append(output)
|
|
69
|
+
run(cmd)
|
|
70
|
+
|
|
71
|
+
result = probe(output, role="output")
|
|
72
|
+
v = result["video"]
|
|
73
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']})")
|
|
74
|
+
emit(output)
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
sys.exit(main())
|
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,21 +51,68 @@ 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:
|
|
52
113
|
info("stream copy failed, falling back to re-encode")
|
|
53
114
|
return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
|
|
54
|
-
die(f"ffmpeg failed:\n{proc.stderr.strip()}")
|
|
115
|
+
die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
|
|
55
116
|
if not reencode and tolerance >= 0 and not STATE["dry_run"]:
|
|
56
117
|
got = probe(dst).get("duration") or 0.0
|
|
57
118
|
if abs(got - dur) > tolerance:
|
|
@@ -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
|
-
result = probe(output)
|
|
193
|
+
result = probe(output, role="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"]:
|
|
@@ -109,7 +118,7 @@ def main() -> int:
|
|
|
109
118
|
cmd += ["-t", f"{p['max']:.3f}"]
|
|
110
119
|
cmd.append(output)
|
|
111
120
|
run(cmd)
|
|
112
|
-
result = probe(output)
|
|
121
|
+
result = probe(output, role="output")
|
|
113
122
|
v = result["video"]
|
|
114
123
|
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['codec']})")
|
|
115
124
|
emit(output)
|