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.
Files changed (44) hide show
  1. package/README.md +296 -118
  2. package/SKILL.md +39 -7
  3. package/bin/install.js +10 -2
  4. package/mcp/server.py +40 -44
  5. package/package.json +19 -5
  6. package/scripts/_common.py +55 -7
  7. package/scripts/_contract.py +757 -0
  8. package/scripts/audio.py +100 -7
  9. package/scripts/caption.py +10 -3
  10. package/scripts/check.py +21 -7
  11. package/scripts/color.py +68 -4
  12. package/scripts/cut.py +83 -9
  13. package/scripts/export.py +15 -6
  14. package/scripts/fit.py +17 -3
  15. package/scripts/join.py +74 -3
  16. package/scripts/multicam.py +10 -0
  17. package/scripts/overlay.py +5 -0
  18. package/scripts/render.py +13 -2
  19. package/scripts/report.py +6 -3
  20. package/scripts/scenes.py +15 -3
  21. package/scripts/sync.py +8 -0
  22. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  23. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  24. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  25. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  26. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  27. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  28. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  29. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  30. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  31. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  32. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  33. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  34. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  35. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  36. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  37. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  38. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  39. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  40. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  41. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  42. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  43. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  44. 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, background music with auto-ducking,
3
- fades and stereo/mono handling. Video is stream-copied.
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, emit, audio_codec_for, default_output, die, ffmpeg_base, info, probe, run
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:0"
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
- info(f"wrote {output} ({r['duration']:.3f}s, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz)")
126
- emit(output)
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
 
@@ -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
- write_srt(cues, srt_path)
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), or fix
4
- wrong colour tags without re-encoding.
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
- emit(output)
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, x264_args
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
- cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}"]
46
- cmd += video_args(meta, crf, preset) + cfr_args(meta) + aac_args() + ["-avoid_negative_ts", "make_zero", dst]
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}", "-c", "copy", "-avoid_negative_ts", "make_zero", dst]
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] + video_args(meta, args.crf, args.preset) + cfr_args(meta) + aac_args() + [output]
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
- info(f"wrote {output} ({result.get('duration'):.3f}s, expected ~{expected:.3f}s, "
135
- + ("re-encoded" if reencoded else "lossless stream copy") + ")")
136
- emit(output)
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 != "prores":
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 "-r" not in video:
101
- cmd += cfr_args(meta)
102
- if args.preset not in ("prores",):
103
- cmd += BT709
104
- if p["ext"] == "mp4":
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"]: