ffmpeg-skill 0.12.0 → 0.16.12

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.
@@ -60,7 +60,12 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[fl
60
60
  except MissingFpsError as e:
61
61
  die(f"cue '{line}': {e} -- pass --fps, or --input's own fps is used automatically when given")
62
62
  except ValueError:
63
- start, end, text = cursor, cursor + auto_seconds, line.strip()
63
+ # TIME_RE matched (so m.group("text") is the real cue text, not the broken
64
+ # timestamp), but one of the two timestamps itself failed to parse (e.g. a
65
+ # malformed "00:00:03.15.999") -- falling back to `line.strip()` here used to
66
+ # burn the whole raw line, broken timestamp included, into the caption instead
67
+ # of just the text after it.
68
+ start, end, text = cursor, cursor + auto_seconds, m.group("text").strip()
64
69
  else:
65
70
  text = m.group("text").strip()
66
71
  else:
@@ -165,6 +170,13 @@ def parse_srt(path: str) -> List[Tuple[float, float, str]]:
165
170
  def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
166
171
  with open(path, "w", encoding="utf-8") as fh:
167
172
  for i, (s, e, t) in enumerate(cues, 1):
173
+ # A blank line is SRT's own block separator (index/timecode/text, blank, next block).
174
+ # Cue text can contain one -- parse_text_cues() turns a bare "|" into "\n", so a source
175
+ # line with two adjacent pipes ("a||b") becomes "a\n\nb" -- and writing that blank line
176
+ # raw would split one cue into two malformed half-blocks (the second missing its own
177
+ # index/timecode). Collapse any run of blank lines within the cue text to a single
178
+ # newline so the cue's own text can never fake the format's block boundary.
179
+ t = re.sub(r"\n{2,}", "\n", t).strip("\n")
168
180
  fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
169
181
 
170
182
 
@@ -247,12 +259,20 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
247
259
  "[Script Info]", "ScriptType: v4.00+", f"PlayResX: {play_w}", f"PlayResY: {play_h}", "WrapStyle: 0", "ScaledBorderAndShadow: yes", "",
248
260
  "[V4+ Styles]",
249
261
  "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
250
- f"Style: Default,{args.font},{size},{primary},{secondary},{outline},{back},{-1 if args.bold else 0},0,0,0,100,100,0,0,{3 if args.box else 1},{args.outline * scale:.1f},{args.shadow * scale:.1f},{ALIGN[args.position]},{margin},{margin},{margin},1",
262
+ f"Style: Default,{ass_font_name(args.font)},{size},{primary},{secondary},{outline},{back},{-1 if args.bold else 0},0,0,0,100,100,0,0,{3 if args.box else 1},{args.outline * scale:.1f},{args.shadow * scale:.1f},{ALIGN[args.position]},{margin},{margin},{margin},1",
251
263
  "", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
252
264
  ]
253
265
  lines = []
254
266
  for start, end, text in cues:
255
267
  text = text.replace("\n", "\\N")
268
+ # ASS Dialogue text treats a literal `{...}` as an override block -- real style/animation
269
+ # commands, not literal characters. Cue text (from --text, an SRT, or ASR transcription --
270
+ # all effectively user-controlled) that happens to contain braces would otherwise be
271
+ # interpreted as those commands (\pos, \t, \fscx, ...), letting caption content reposition,
272
+ # rescale, or recolor itself or later text instead of just being read out. No caption needs
273
+ # a literal curly brace, so they're dropped outright, matching the "unneeded delimiter
274
+ # character -> drop it" call already made for font names (see ass_font_name()).
275
+ text = text.replace("{", "").replace("}", "")
256
276
  fx = ""
257
277
  if args.animate == "fade":
258
278
  fx = "{\\fad(200,200)}"
@@ -302,6 +322,20 @@ def ass_color(hex_rgb: str, alpha: int = 0) -> str:
302
322
  return f"&H{alpha:02X}{b}{g}{r}".upper()
303
323
 
304
324
 
325
+ def ass_font_name(name: str) -> str:
326
+ """Sanitise a font name for embedding in an ASS [V4+ Styles] Style line (comma-delimited
327
+ fields, no quoting mechanism) and in a `force_style='...'` option list (comma-separated
328
+ Key=Value pairs, colon-separated from the rest of the -vf filter). No real font name uses
329
+ `, : \\ '`, so rather than chase a per-context escape (a Style line and a force_style list
330
+ have different delimiter rules), those characters -- and control characters, which are never
331
+ meaningful in a font name either -- are dropped outright, the same "no escape proven safe
332
+ everywhere it's used" call this codebase already makes for escape_drawtext()'s `'`/`%`."""
333
+ name = re.sub(r"[\x00-\x1f\x7f]", "", name)
334
+ for ch in ",:\\'":
335
+ name = name.replace(ch, "")
336
+ return name
337
+
338
+
305
339
  def main() -> int:
306
340
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
307
341
  ap.add_argument("input", nargs="?", help="video to burn captions into (omit with --write-srt to only generate)")
@@ -427,14 +461,23 @@ def main() -> int:
427
461
  if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
428
462
  die(f"SRT file not found: {srt_path}")
429
463
  codec = mux_subtitle_codec(output)
464
+ # Keep any subtitle track(s) the input already has (e.g. chaining --mode mux once per
465
+ # language to build a multi-language set) -- copied byte-identical, distinct from the
466
+ # newly-added SRT's own codec below.
467
+ existing_subs = meta.get("subtitle_streams") or 0
430
468
  maps = ["-map", "0:v:0"]
431
469
  cmd = ffmpeg_base() + ["-i", args.input, "-i", srt_path]
432
470
  if meta.get("audio"):
433
471
  maps += ["-map", f"0:a:{args.audio_stream}"]
472
+ if existing_subs:
473
+ maps += ["-map", "0:s?"]
434
474
  maps += ["-map", "1:0"]
435
- cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else []) + ["-c:s", codec]
475
+ cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else [])
476
+ for i in range(existing_subs):
477
+ cmd += [f"-c:s:{i}", "copy"]
478
+ cmd += [f"-c:s:{existing_subs}", codec]
436
479
  if args.language:
437
- cmd += ["-metadata:s:s:0", f"language={args.language}"]
480
+ cmd += [f"-metadata:s:s:{existing_subs}", f"language={args.language}"]
438
481
  cmd += [output]
439
482
  run(cmd)
440
483
  result = probe(output, role="output")
@@ -462,7 +505,7 @@ def main() -> int:
462
505
  if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
463
506
  die(f"SRT file not found: {srt_path}")
464
507
  style = [
465
- f"FontName={args.font}",
508
+ f"FontName={ass_font_name(args.font)}",
466
509
  f"FontSize={args.size}",
467
510
  f"PrimaryColour={ass_color(args.color)}",
468
511
  f"OutlineColour={ass_color(args.outline_color)}",
package/scripts/check.py CHANGED
@@ -162,6 +162,14 @@ def main() -> int:
162
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
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")
164
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}")
165
+ else:
166
+ # ffmpeg's loudnorm JSON didn't parse out of stderr (unexpected/garbled output).
167
+ # Dropping the loudness/true-peak rows silently here would report an overall PASS
168
+ # for a platform that has a loudness requirement, without ever having checked it --
169
+ # a false PASS is worse than noise. WARN so the gap is visible in the report.
170
+ row("loudness", "WARN", "could not measure", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", "re-run check.py, or verify loudness manually",
171
+ reason="ffmpeg's loudness measurement didn't produce a readable result -- this was not actually checked")
172
+ row("true peak", "WARN", "could not measure", f"<= {spec['tp']:g} dBTP", "re-run check.py, or verify true peak manually")
165
173
  elif args.platform in ("podcast",):
166
174
  row("audio", "FAIL", "none", "audio stream", "audio.py --replace")
167
175
  else:
package/scripts/color.py CHANGED
@@ -2,7 +2,8 @@
2
2
  """Colour management: convert HDR (HDR10/PQ, HLG, BT.2020) to SDR BT.709 with
3
3
  real tone mapping, apply a .cube LUT (Log footage, creative grades), fix wrong
4
4
  colour tags without re-encoding, or apply typed primary colour correction
5
- (exposure, contrast, saturation, white balance).
5
+ (exposure, contrast, saturation, gamma, white balance, three-way
6
+ lift/gain, levels, curves).
6
7
 
7
8
  Examples:
8
9
  python3 color.py iphone_hdr.mov --to-sdr # PQ/HLG -> BT.709 SDR, hable tonemap
@@ -13,21 +14,26 @@ Examples:
13
14
  python3 color.py iphone_dv.mov --strip-dovi # drop Dolby Vision RPU, keep HLG base layer
14
15
  python3 color.py iphone_dv.mov --to-sdr # DV 8.4 = HLG base layer -> tone-mapped SDR
15
16
  python3 color.py flat.mp4 --correct --exposure 0.3 --contrast 1.1 --saturation 1.05 --temperature 5600 --tint -0.05
17
+ python3 color.py flat.mp4 --correct --gamma 1.2 --lift 0.04 --gain -0.03 # three-way shadows/gamma/highlights
18
+ python3 color.py flat.mp4 --correct --levels-in-black 16 --levels-in-white 235 --curves medium_contrast
16
19
  """
17
20
  import argparse
18
21
  import os
19
22
  import sys
20
23
  from typing import List
21
24
 
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
25
+ 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, run_keeping_subtitles, x264_args
23
26
 
24
27
  TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
25
28
 
26
29
  # Typed primary correction: each flag is one option of one real, always-available libavfilter filter
27
30
  # (never a caller-supplied filter string). Range is this script's own safe subset of what the filter
28
31
  # 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.
32
+ # own documented no-op value, so every stage in this dict is always emitted and the chain never depends
33
+ # on which flags were actually given (exposure/temperature/tint/gamma/lift/gain). `colorlevels` and
34
+ # `curves` (see LEVELS and CURVES_PRESETS below) are the exception: they only add a term to the chain
35
+ # when the caller actually asks for them, because "levels 0..255 in, 0..255 out" and "no curve" are
36
+ # already the identity operation without emitting a no-op filter term for it.
31
37
  CORRECTION = {
32
38
  # flag default lo hi unit
33
39
  "exposure": (0.0, -3.0, 3.0, "stops"), # exposure filter's own full range (linear-domain stops)
@@ -35,8 +41,27 @@ CORRECTION = {
35
41
  "saturation": (1.0, 0.0, 2.0, "x"), # eq filter; 0=grayscale, 1=unchanged, 2=double saturation
36
42
  "temperature": (6500.0, 2000.0, 12000.0, "K"), # colortemperature filter; 6500=unchanged (its own default)
37
43
  "tint": (0.0, -1.0, 1.0, "x"), # mapped to colorbalance midtones, see correction_chain()
44
+ "gamma": (1.0, 0.1, 10.0, "x"), # eq filter's own gamma option; 1=unchanged (its own default)
45
+ "lift": (0.0, -1.0, 1.0, "x"), # colorbalance shadows (rs=gs=bs); 0=unchanged
46
+ "gain": (0.0, -1.0, 1.0, "x"), # colorbalance highlights (rh=gh=bh); 0=unchanged
38
47
  }
39
48
 
49
+ # colorlevels takes fractional 0.0..1.0 input/output black/white points; this tool exposes the
50
+ # familiar 8-bit 0..255 unit instead and divides by 255.0 when building the filter (same convention
51
+ # as the rest of CORRECTION: a human-friendly CLI unit formatted into the filter's own native unit).
52
+ LEVELS = {
53
+ # flag default lo hi
54
+ "levels_in_black": (0, 0, 255),
55
+ "levels_in_white": (255, 0, 255),
56
+ "levels_out_black": (0, 0, 255),
57
+ "levels_out_white": (255, 0, 255),
58
+ }
59
+
60
+ # curves filter's real built-in presets (`ffmpeg -h filter=curves`), excluding its own "none" (0):
61
+ # omitting --curves already gets that identity result without adding a filter term for it.
62
+ CURVES_PRESETS = ["color_negative", "cross_process", "darker", "increase_contrast", "lighter",
63
+ "linear_contrast", "medium_contrast", "negative", "strong_contrast", "vintage"]
64
+
40
65
 
41
66
  def _checked(args: argparse.Namespace, flag: str) -> float:
42
67
  _, lo, hi, unit = CORRECTION[flag]
@@ -46,26 +71,65 @@ def _checked(args: argparse.Namespace, flag: str) -> float:
46
71
  return value
47
72
 
48
73
 
74
+ def _checked_levels(args: argparse.Namespace, flag: str) -> int:
75
+ _, lo, hi = LEVELS[flag]
76
+ value = getattr(args, flag)
77
+ if not (lo <= value <= hi):
78
+ die(f"--{flag.replace('_', '-')} {value} is outside {lo}..{hi} (8-bit units, scaled to colorlevels' own 0..1 range)")
79
+ return value
80
+
81
+
49
82
  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."""
83
+ """Always-present filter stages, in a fixed order chosen so each stage sees a picture already
84
+ corrected by the previous one: exposure (linear light level) -> white balance (temperature/tint/
85
+ lift/gain, so contrast/saturation act on colour-balanced footage) -> contrast/saturation/gamma
86
+ (the most creative-adjacent stage of the always-on chain). `tint` (-1 green .. +1 magenta) is not
87
+ a single ffmpeg option: it is expressed as colorbalance's three midtone channels (gm=-tint,
88
+ rm=bm=tint/2) so a positive tint shifts midtones toward magenta and a negative one toward green
89
+ without changing overall midtone lightness, the same balanced-axis convention colour tools use
90
+ for a one-dial tint control. `lift` and `gain` extend the same colorbalance call to the shadow
91
+ (rs=gs=bs=lift) and highlight (rh=gh=bh=gain) channels, giving a classic three-way shadows/
92
+ midtones/highlights correction in one filter invocation. `gamma` is folded into the same `eq`
93
+ term contrast/saturation already use, as `eq`'s own `gamma` option. Two further stages are
94
+ appended only when asked for, since their own identity value would otherwise add a no-op filter
95
+ term to the chain: `colorlevels` (--levels-*, 8-bit units scaled to the filter's 0..1 range) and
96
+ `curves` (--curves, one of the filter's own named presets)."""
57
97
  exposure = _checked(args, "exposure")
58
98
  contrast = _checked(args, "contrast")
59
99
  saturation = _checked(args, "saturation")
60
100
  temperature = _checked(args, "temperature")
61
101
  tint = _checked(args, "tint")
102
+ gamma = _checked(args, "gamma")
103
+ lift = _checked(args, "lift")
104
+ gain = _checked(args, "gain")
105
+ in_black = _checked_levels(args, "levels_in_black")
106
+ in_white = _checked_levels(args, "levels_in_white")
107
+ out_black = _checked_levels(args, "levels_out_black")
108
+ out_white = _checked_levels(args, "levels_out_white")
109
+ if in_black >= in_white:
110
+ die(f"--levels-in-black {in_black} must be less than --levels-in-white {in_white}")
111
+ if out_black >= out_white:
112
+ die(f"--levels-out-black {out_black} must be less than --levels-out-white {out_white}")
113
+
62
114
  gm, rm, bm = -tint, tint / 2.0, tint / 2.0
63
- return ",".join([
115
+ terms = [
64
116
  f"exposure=exposure={exposure:g}",
65
117
  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
- ])
118
+ f"colorbalance=rs={lift:g}:gs={lift:g}:bs={lift:g}:rm={rm:g}:gm={gm:g}:bm={bm:g}:rh={gain:g}:gh={gain:g}:bh={gain:g}",
119
+ f"eq=contrast={contrast:g}:saturation={saturation:g}:gamma={gamma:g}",
120
+ ]
121
+ if (in_black, in_white, out_black, out_white) != (0, 255, 0, 255):
122
+ rimin, rimax = in_black / 255.0, in_white / 255.0
123
+ romin, romax = out_black / 255.0, out_white / 255.0
124
+ terms.append(
125
+ f"colorlevels=rimin={rimin:g}:gimin={rimin:g}:bimin={rimin:g}:"
126
+ f"rimax={rimax:g}:gimax={rimax:g}:bimax={rimax:g}:"
127
+ f"romin={romin:g}:gomin={romin:g}:bomin={romin:g}:"
128
+ f"romax={romax:g}:gomax={romax:g}:bomax={romax:g}"
129
+ )
130
+ if args.curves:
131
+ terms.append(f"curves=preset={args.curves}")
132
+ return ",".join(terms)
69
133
 
70
134
 
71
135
  def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
@@ -94,7 +158,7 @@ def main() -> int:
94
158
  mode.add_argument("--lut", help=".cube LUT to apply (3D)")
95
159
  mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
96
160
  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")
161
+ mode.add_argument("--correct", action="store_true", help="typed primary colour correction: --exposure/--contrast/--saturation/--temperature/--tint/--gamma/--lift/--gain/--levels-*/--curves")
98
162
  ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
99
163
  ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
100
164
  ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
@@ -105,6 +169,14 @@ def main() -> int:
105
169
  ap.add_argument("--saturation", type=float, default=CORRECTION["saturation"][0], help="--correct: saturation, 0..2, 1=unchanged (default 1)")
106
170
  ap.add_argument("--temperature", type=float, default=CORRECTION["temperature"][0], help="--correct: white-balance temperature in Kelvin, 2000..12000, 6500=unchanged (default 6500)")
107
171
  ap.add_argument("--tint", type=float, default=CORRECTION["tint"][0], help="--correct: green(-1)/magenta(+1) tint, 0=unchanged (default 0)")
172
+ ap.add_argument("--gamma", type=float, default=CORRECTION["gamma"][0], help="--correct: master gamma (eq filter's own gamma), 0.1..10, 1=unchanged (default 1)")
173
+ ap.add_argument("--lift", type=float, default=CORRECTION["lift"][0], help="--correct: shadows lift (colorbalance rs/gs/bs), -1..1, 0=unchanged (default 0)")
174
+ ap.add_argument("--gain", type=float, default=CORRECTION["gain"][0], help="--correct: highlights gain (colorbalance rh/gh/bh), -1..1, 0=unchanged (default 0)")
175
+ ap.add_argument("--levels-in-black", type=int, default=LEVELS["levels_in_black"][0], help="--correct: colorlevels input black point, 0..255 (default 0, unchanged)")
176
+ ap.add_argument("--levels-in-white", type=int, default=LEVELS["levels_in_white"][0], help="--correct: colorlevels input white point, 0..255 (default 255, unchanged)")
177
+ ap.add_argument("--levels-out-black", type=int, default=LEVELS["levels_out_black"][0], help="--correct: colorlevels output black point, 0..255 (default 0, unchanged)")
178
+ ap.add_argument("--levels-out-white", type=int, default=LEVELS["levels_out_white"][0], help="--correct: colorlevels output white point, 0..255 (default 255, unchanged)")
179
+ ap.add_argument("--curves", choices=CURVES_PRESETS, default=None, help="--correct: curves filter built-in preset (default: none, no curves term added)")
108
180
  ap.add_argument("--audio-stream", type=int, default=0,
109
181
  help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
110
182
  "audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
@@ -202,8 +274,16 @@ def main() -> int:
202
274
  else:
203
275
  if not os.path.exists(args.lut):
204
276
  die(f"LUT not found: {args.lut}")
277
+ if not (0.0 <= args.lut_strength <= 1.0):
278
+ die(f"--lut-strength {args.lut_strength:g} is outside 0..1")
205
279
  lut = f"lut3d=file={escape_filter_path(args.lut)}:interp=tetrahedral"
206
- if 0 < args.lut_strength < 1:
280
+ if args.lut_strength == 0.0:
281
+ # 0 means "no LUT at all" -- without this branch, 0 (falling outside the open interval
282
+ # below) landed in the same "apply the LUT at full strength" fallback as an out-of-range
283
+ # value did before the guard above existed: the one strength value documented to mean
284
+ # "don't grade it" instead silently graded it at 100%, the opposite of what was asked.
285
+ vf = "format=yuv420p"
286
+ elif args.lut_strength < 1.0:
207
287
  # blend graded and original
208
288
  vf = f"split[o][g];[g]{lut}[g2];[o][g2]blend=all_mode=normal:all_opacity={args.lut_strength:g},format=yuv420p"
209
289
  else:
@@ -212,16 +292,16 @@ def main() -> int:
212
292
  tag = "lut"
213
293
 
214
294
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
215
- cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else []) + [output]
216
- run(cmd)
295
+ cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else [])
296
+ dropped_streams = run_keeping_subtitles(cmd, output)
217
297
  r = probe(output, role="output")
218
298
  info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
219
299
  f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
300
+ extra = {"dropped_non_av_streams": dropped_streams}
220
301
  if measurements is not None:
221
302
  measurements["output"] = analyze_levels(output)
222
- emit(output, measurements=measurements)
223
- else:
224
- emit(output)
303
+ extra["measurements"] = measurements
304
+ emit(output, **extra)
225
305
  return 0
226
306
 
227
307
 
package/scripts/crop.py CHANGED
@@ -38,6 +38,8 @@ def main() -> int:
38
38
  add_common(ap)
39
39
  args = ap.parse_args()
40
40
  apply_common(args)
41
+ if args.fps is not None and args.fps <= 0:
42
+ die(f"--fps must be positive, got {args.fps:g}")
41
43
 
42
44
  if args.x < 0 or args.y < 0:
43
45
  die(f"--x/--y must be >= 0, got x={args.x} y={args.y}")
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env python3
2
+ """Measure black letterbox/pillarbox bars and report the crop rectangle that removes them.
3
+
4
+ Wraps FFmpeg's cropdetect filter: it samples frames, finds the largest
5
+ non-black rectangle common to (recent) frames, and reports it. This is a
6
+ measurement only -- it writes no file. Feed the reported {x, y, width,
7
+ height} to crop.py to actually remove the bars:
8
+
9
+ python3 cropdetect.py input.mp4
10
+ python3 crop.py input.mp4 --x 0 --y 140 --width 1920 --height 800
11
+
12
+ Distinct from fit.py --fit crop, which crops to a *target aspect ratio* it
13
+ computes itself (no black-bar detection involved) -- this tool instead
14
+ measures bars that are already baked into the source picture and tells you
15
+ where they are; it does not decide whether removing them is wanted (a
16
+ source with genuine letterboxed content, e.g. a scope-ratio film in a 16:9
17
+ frame, will "detect" that letterboxing as bars to strip, which is correct
18
+ for restoring the original frame but wrong if the letterboxing is part of
19
+ the intended presentation -- look at the frame before cropping it away).
20
+
21
+ --seconds controls how much of the file is sampled (default 10s, spread
22
+ across the file by --samples windows so a single black scene near the start
23
+ doesn't skew the result). Detected values fluctuate slightly frame to frame
24
+ even on a static border; the reported rectangle is the most common one seen.
25
+
26
+ Examples:
27
+ python3 cropdetect.py input.mp4
28
+ python3 cropdetect.py input.mp4 --seconds 30 --limit 0.15
29
+ """
30
+ import argparse
31
+ import re
32
+ import subprocess
33
+ import sys
34
+ from collections import Counter
35
+ from typing import Dict, List, Tuple
36
+
37
+ from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool
38
+
39
+ CROP_RE = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
40
+
41
+
42
+ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int, duration: float) -> List[Tuple[int, int, int, int]]:
43
+ ffmpeg = require_tool("ffmpeg")
44
+ per_window = max(0.5, seconds / max(1, samples))
45
+ rects: List[Tuple[int, int, int, int]] = []
46
+ for i in range(samples):
47
+ start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
48
+ start = max(0.0, start)
49
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
50
+ "-vf", f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
51
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
52
+ for m in CROP_RE.finditer(proc.stderr):
53
+ rects.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))))
54
+ return rects
55
+
56
+
57
+ def main() -> int:
58
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
59
+ ap.add_argument("input")
60
+ ap.add_argument("--seconds", type=float, default=10.0, help="total seconds of footage to sample across the file (default 10)")
61
+ ap.add_argument("--samples", type=int, default=5, help="number of windows spread across the file (default 5)")
62
+ ap.add_argument("--limit", type=float, default=0.0941176, help="black-pixel threshold, 0..1 (default ~0.094, cropdetect's own default)")
63
+ ap.add_argument("--round", type=int, default=16, dest="round_to", help="the reported width/height are rounded to a multiple of this (default 16)")
64
+ add_common(ap)
65
+ args = ap.parse_args()
66
+ apply_common(args)
67
+
68
+ if args.seconds <= 0:
69
+ die(f"--seconds must be > 0, got {args.seconds:g}")
70
+ if args.samples <= 0:
71
+ die(f"--samples must be > 0, got {args.samples}")
72
+ if not 0 <= args.limit <= 1:
73
+ die(f"--limit must be 0..1, got {args.limit:g}")
74
+ if args.round_to <= 0:
75
+ die(f"--round must be > 0, got {args.round_to}")
76
+
77
+ meta = probe(args.input)
78
+ if not meta.get("video"):
79
+ die("input has no video stream")
80
+ sw, sh = meta["video"]["width"], meta["video"]["height"]
81
+ duration = meta.get("duration") or 0.0
82
+
83
+ rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration)
84
+ result: Dict = {"file": args.input, "source_width": sw, "source_height": sh}
85
+ if not rects:
86
+ result["crop"] = None
87
+ info("no crop bars detected (cropdetect produced no readings -- try --limit higher, or the source may already be full-frame)")
88
+ else:
89
+ w, h, x, y = Counter(rects).most_common(1)[0][0]
90
+ result["crop"] = {"width": w, "height": h, "x": x, "y": y}
91
+ result["confidence"] = round(Counter(rects).most_common(1)[0][1] / len(rects), 3)
92
+ if (w, h) == (sw, sh):
93
+ info(f"no bars detected: full {sw}x{sh} frame is already content")
94
+ else:
95
+ info(f"detected crop={w}:{h}:{x}:{y} (source {sw}x{sh}, confidence {result['confidence']:.0%}) -- "
96
+ f"crop.py {args.input} --x {x} --y {y} --width {w} --height {h}")
97
+
98
+ if args.json:
99
+ emit(None, **result)
100
+ else:
101
+ print_json(result)
102
+ return 0
103
+
104
+
105
+ if __name__ == "__main__":
106
+ sys.exit(main())
package/scripts/cut.py CHANGED
@@ -149,10 +149,14 @@ def main() -> int:
149
149
  segments = parse_segments(args.segments)
150
150
  else:
151
151
  start = parse_time(args.start)
152
+ if start < 0:
153
+ die(f"--start must not be negative, got {args.start!r}")
152
154
  if args.end and args.duration:
153
155
  die("use --end or --duration, not both")
154
156
  if args.end:
155
157
  end = parse_time(args.end)
158
+ if end < 0:
159
+ die(f"--end must not be negative, got {args.end!r}")
156
160
  elif args.duration:
157
161
  end = start + parse_time(args.duration)
158
162
  else:
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env python3
2
+ """Deinterlace interlaced source footage (old broadcast masters, DV, some camcorders).
3
+
4
+ Wraps FFmpeg's yadif filter. --mode frame (default) keeps the original frame
5
+ rate, blending each pair of fields back into one frame; --mode field instead
6
+ emits one frame per field, doubling the frame rate (smoother motion, the
7
+ usual choice for footage that will be watched at full quality). --parity
8
+ tells yadif which field came first when the container doesn't say so
9
+ correctly; --only-interlaced skips frames the source itself doesn't mark as
10
+ interlaced, leaving already-progressive frames untouched.
11
+
12
+ This tool does not detect whether the source needs deinterlacing at all --
13
+ that's a probe.py/look.py judgement (visible combing on motion in a contact
14
+ sheet). Running yadif on already-progressive footage is a harmless no-op in
15
+ practice but still re-encodes the whole file, so don't run this by default.
16
+
17
+ Examples:
18
+ python3 deinterlace.py old_tape.mov
19
+ python3 deinterlace.py broadcast.mxf --mode field --parity tff
20
+ python3 deinterlace.py mixed.mov --only-interlaced
21
+ """
22
+ import argparse
23
+ import sys
24
+
25
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
26
+
27
+ MODES = {"frame": 0, "field": 1}
28
+ PARITIES = {"auto": -1, "tff": 0, "bff": 1}
29
+
30
+
31
+ def main() -> int:
32
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
33
+ ap.add_argument("input")
34
+ ap.add_argument("-o", "--output", help="output file (default: <name>_deint.<ext>)")
35
+ ap.add_argument("--mode", choices=list(MODES), default="frame",
36
+ help="frame (default): one output frame per input frame; field: one output frame per field, doubling fps")
37
+ ap.add_argument("--parity", choices=list(PARITIES), default="auto",
38
+ help="which field came first (default auto-detect from the stream)")
39
+ ap.add_argument("--only-interlaced", action="store_true",
40
+ help="only deinterlace frames the source marks as interlaced; leave the rest untouched")
41
+ ap.add_argument("--audio-stream", type=int, default=0,
42
+ help="which audio stream of the input to keep, 0-based in file order (default 0)")
43
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
44
+ ap.add_argument("--preset", default="medium", help="x264 preset")
45
+ add_common(ap)
46
+ args = ap.parse_args()
47
+ apply_common(args)
48
+
49
+ meta = probe(args.input)
50
+ if not meta.get("video"):
51
+ die("input has no video stream")
52
+ has_audio = bool(meta.get("audio"))
53
+ audio_streams = meta.get("audio_streams") or []
54
+ if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
55
+ die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
56
+ if args.audio_stream and not audio_streams:
57
+ die("--audio-stream needs an input with audio streams")
58
+ output = args.output or default_output(args.input, "deint")
59
+
60
+ vf = f"yadif=mode={MODES[args.mode]}:parity={PARITIES[args.parity]}:deint={1 if args.only_interlaced else 0}"
61
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
62
+ if has_audio:
63
+ cmd += ["-map", f"0:a:{args.audio_stream}?"]
64
+ cmd += video_args(meta, args.crf, args.preset)
65
+ if args.mode == "field":
66
+ v = meta["video"]
67
+ src_fps = v.get("fps") or 30.0
68
+ cmd += ["-fps_mode", "cfr", "-r", f"{src_fps * 2:g}"]
69
+ else:
70
+ cmd += cfr_args(meta)
71
+ if has_audio:
72
+ cmd += aac_args()
73
+ else:
74
+ cmd += ["-an"]
75
+ dropped_streams = run_keeping_subtitles(cmd, output)
76
+
77
+ result = probe(output, role="output")
78
+ v = result["video"]
79
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['fps']:g}fps, mode={args.mode})")
80
+ emit(output, dropped_non_av_streams=dropped_streams)
81
+ return 0
82
+
83
+
84
+ if __name__ == "__main__":
85
+ sys.exit(main())
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env python3
2
+ """Reduce video noise/grain with FFmpeg's hqdn3d filter.
3
+
4
+ For audio noise reduction use audio.py --denoise instead -- this tool only
5
+ touches the picture. --strength picks a tested preset (low/medium/high,
6
+ scaling hqdn3d's four spatial/temporal luma/chroma parameters together);
7
+ --luma-spatial/--chroma-spatial/--luma-temporal/--chroma-temporal override
8
+ any of the four individually when a preset isn't precise enough. Heavier
9
+ denoising trades away fine detail (skin texture, foliage, film grain) for a
10
+ cleaner-looking but softer image -- there is no setting that removes noise
11
+ "for free"; --strength high is a real quality trade-off, not just "better".
12
+
13
+ Examples:
14
+ python3 denoise.py noisy_lowlight.mp4
15
+ python3 denoise.py grainy_scan.mov --strength high
16
+ python3 denoise.py source.mp4 --luma-spatial 6 --chroma-spatial 4 --luma-temporal 8 --chroma-temporal 6
17
+ """
18
+ import argparse
19
+ import sys
20
+
21
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
22
+
23
+ # hqdn3d's own AVOptions default to 0 (off); these tested presets are the light/medium/heavy
24
+ # starting points its own documentation and common usage recommend (spatial then temporal,
25
+ # luma then chroma -- chroma tends to tolerate more smoothing before looking soft).
26
+ STRENGTH_PRESETS = {
27
+ "low": (2.0, 1.5, 3.0, 2.25),
28
+ "medium": (4.0, 3.0, 6.0, 4.5),
29
+ "high": (8.0, 6.0, 10.0, 7.5),
30
+ }
31
+
32
+
33
+ def main() -> int:
34
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
35
+ ap.add_argument("input")
36
+ ap.add_argument("-o", "--output", help="output file (default: <name>_denoise.<ext>)")
37
+ ap.add_argument("--strength", choices=list(STRENGTH_PRESETS), default="medium",
38
+ help="preset denoising amount (default medium); overridden per-parameter by the flags below")
39
+ ap.add_argument("--luma-spatial", type=float, help="spatial luma denoising strength (default from --strength)")
40
+ ap.add_argument("--chroma-spatial", type=float, help="spatial chroma denoising strength (default from --strength)")
41
+ ap.add_argument("--luma-temporal", type=float, help="temporal luma denoising strength (default from --strength)")
42
+ ap.add_argument("--chroma-temporal", type=float, help="temporal chroma denoising strength (default from --strength)")
43
+ ap.add_argument("--audio-stream", type=int, default=0,
44
+ help="which audio stream of the input to keep, 0-based in file order (default 0)")
45
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
46
+ ap.add_argument("--preset", default="medium", help="x264 preset")
47
+ ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
48
+ add_common(ap)
49
+ args = ap.parse_args()
50
+ apply_common(args)
51
+ if args.fps is not None and args.fps <= 0:
52
+ die(f"--fps must be positive, got {args.fps:g}")
53
+
54
+ ls, cs, lt, ct = STRENGTH_PRESETS[args.strength]
55
+ ls = args.luma_spatial if args.luma_spatial is not None else ls
56
+ cs = args.chroma_spatial if args.chroma_spatial is not None else cs
57
+ lt = args.luma_temporal if args.luma_temporal is not None else lt
58
+ ct = args.chroma_temporal if args.chroma_temporal is not None else ct
59
+ for name, val in (("--luma-spatial", ls), ("--chroma-spatial", cs), ("--luma-temporal", lt), ("--chroma-temporal", ct)):
60
+ if val < 0:
61
+ die(f"{name} must be >= 0, got {val:g}")
62
+
63
+ meta = probe(args.input)
64
+ if not meta.get("video"):
65
+ die("input has no video stream")
66
+ has_audio = bool(meta.get("audio"))
67
+ audio_streams = meta.get("audio_streams") or []
68
+ if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
69
+ die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
70
+ if args.audio_stream and not audio_streams:
71
+ die("--audio-stream needs an input with audio streams")
72
+ output = args.output or default_output(args.input, "denoise")
73
+
74
+ vf = f"hqdn3d={ls:g}:{cs:g}:{lt:g}:{ct:g}"
75
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
76
+ if has_audio:
77
+ cmd += ["-map", f"0:a:{args.audio_stream}?"]
78
+ cmd += video_args(meta, args.crf, args.preset)
79
+ cmd += cfr_args(meta, args.fps)
80
+ if has_audio:
81
+ cmd += aac_args()
82
+ else:
83
+ cmd += ["-an"]
84
+ dropped_streams = run_keeping_subtitles(cmd, output)
85
+
86
+ result = probe(output, role="output")
87
+ v = result["video"]
88
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, strength={args.strength})")
89
+ emit(output, dropped_non_av_streams=dropped_streams)
90
+ return 0
91
+
92
+
93
+ if __name__ == "__main__":
94
+ sys.exit(main())