ffmpeg-skill 0.10.0 → 0.12.5

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 (56) hide show
  1. package/README.md +70 -10
  2. package/SKILL.md +89 -12
  3. package/bin/install.js +15 -1
  4. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  5. package/mcp/server.py +2 -0
  6. package/package.json +2 -2
  7. package/references/ci-platform-pitfalls.md +111 -0
  8. package/references/process-pitfalls.md +85 -0
  9. package/references/scripts.md +139 -12
  10. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  11. package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
  12. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  13. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  14. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  15. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  16. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  17. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  18. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  19. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  20. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  21. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  22. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  23. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  24. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  25. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  26. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  27. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  28. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  29. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  30. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  31. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  32. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
  33. package/scripts/_common.py +255 -14
  34. package/scripts/_contract.py +276 -13
  35. package/scripts/audio.py +1 -1
  36. package/scripts/background.py +73 -0
  37. package/scripts/caption.py +106 -24
  38. package/scripts/color.py +129 -30
  39. package/scripts/crop.py +79 -0
  40. package/scripts/cut.py +2 -2
  41. package/scripts/export.py +1 -1
  42. package/scripts/fit.py +73 -14
  43. package/scripts/graphics.py +18 -7
  44. package/scripts/insert.py +128 -0
  45. package/scripts/join.py +14 -5
  46. package/scripts/look.py +13 -8
  47. package/scripts/loudness.py +3 -3
  48. package/scripts/multicam.py +1 -1
  49. package/scripts/overlay.py +78 -12
  50. package/scripts/proxy.py +82 -0
  51. package/scripts/reverse.py +56 -0
  52. package/scripts/scenes.py +8 -2
  53. package/scripts/sequence.py +124 -0
  54. package/scripts/silence.py +2 -2
  55. package/scripts/stabilize.py +101 -0
  56. package/scripts/sync.py +1 -1
@@ -1,18 +1,25 @@
1
1
  #!/usr/bin/env python3
2
- """Burn SRT/ASS subtitles into a video, or generate an SRT from plain text.
2
+ """Burn SRT/ASS subtitles into a video, or mux one in as a soft (toggleable)
3
+ subtitle stream, or generate an SRT from plain text.
3
4
 
4
5
  Styling (font, size, colour, outline, position) applies to SRT input via
5
6
  libass force_style. ASS files carry their own styles and are rendered as-is.
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.
7
+ Styling and animation only apply to --mode burn (the default): they render
8
+ pixels, so they have no meaning for a soft subtitle stream.
9
+
10
+ --mode mux copies the video and audio streams untouched (see contract --json:
11
+ reencodes_video/reencodes_audio are "never" for this mode) and adds the SRT
12
+ as a separate subtitle stream a player can toggle -- the source is never
13
+ touched. It takes only a plain SRT (from --srt, --text or --transcribe), not
14
+ --ass: ASS styling has no equivalent soft-subtitle representation across
15
+ containers, so --mode mux --ass is refused with a pointer to --mode burn.
16
+ The subtitle codec is picked from the output container: mov_text for
17
+ .mp4/.m4v/.mov, srt for .mkv, webvtt for .webm.
12
18
 
13
19
  Text-to-SRT input format (one cue per line, blank lines ignored):
14
20
  0:00-0:03 Hello and welcome
15
21
  00:00:03.500 --> 00:00:06 Second line | with a manual line break
22
+ 00:00:03:15 --> 00:00:06:00 SMPTE non-drop-frame timecode (hh:mm:ss:ff, needs --fps)
16
23
  Text without a time is auto-timed after the previous cue (--auto-seconds)
17
24
 
18
25
  Examples:
@@ -29,7 +36,7 @@ import sys
29
36
  from pathlib import Path
30
37
  from typing import List, Optional, Tuple
31
38
 
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
39
+ from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args
33
40
 
34
41
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
35
42
 
@@ -38,7 +45,7 @@ TIME_RE = re.compile(
38
45
  )
39
46
 
40
47
 
41
- def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[float, float, str]]:
48
+ def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[float] = None) -> List[Tuple[float, float, str]]:
42
49
  cues: List[Tuple[float, float, str]] = []
43
50
  cursor = 0.0
44
51
  with open(path, encoding="utf-8") as fh:
@@ -49,7 +56,9 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[fl
49
56
  m = TIME_RE.match(line)
50
57
  if m:
51
58
  try:
52
- start, end = parse_time(m.group("a")), parse_time(m.group("b"))
59
+ start, end = parse_time(m.group("a"), fps), parse_time(m.group("b"), fps)
60
+ except MissingFpsError as e:
61
+ die(f"cue '{line}': {e} -- pass --fps, or --input's own fps is used automatically when given")
53
62
  except ValueError:
54
63
  start, end, text = cursor, cursor + auto_seconds, line.strip()
55
64
  else:
@@ -66,7 +75,7 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[fl
66
75
  return cues
67
76
 
68
77
 
69
- def transcribe(video: str, out_srt: str, language: Optional[str], model: str) -> List[Tuple[float, float, str]]:
78
+ def transcribe(video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int = 0) -> List[Tuple[float, float, str]]:
70
79
  """Optional local ASR bridge. Tries, in order: whisper-cli / main (whisper.cpp), faster-whisper (python),
71
80
  whisper (openai-whisper CLI). Produces an SRT with word timings where the engine supports it.
72
81
  No engine installed -> clear error with install hints; the skill never depends on one."""
@@ -77,7 +86,8 @@ def transcribe(video: str, out_srt: str, language: Optional[str], model: str) ->
77
86
  ffmpeg = require_tool("ffmpeg")
78
87
  tmpdir = tempfile.mkdtemp(prefix="ffskill_asr_")
79
88
  wav = os.path.join(tmpdir, "audio.wav")
80
- subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav], check=True)
89
+ subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
90
+ "-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav], check=True)
81
91
  # 1. whisper.cpp
82
92
  cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp") or shutil.which("main")
83
93
  if cli and (shutil.which("whisper-cli") or shutil.which("whisper-cpp")):
@@ -158,7 +168,7 @@ def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
158
168
  fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
159
169
 
160
170
 
161
- def word_durations_from_audio(video: str, start: float, end: float, n_words: int) -> List[int]:
171
+ def word_durations_from_audio(video: str, start: float, end: float, n_words: int, audio_stream: int = 0) -> List[int]:
162
172
  """Split a cue's time across n_words in proportion to speech energy (centiseconds each).
163
173
 
164
174
  Decodes the cue window to 8 kHz mono, builds a 10 ms RMS envelope, removes the noise floor,
@@ -173,7 +183,7 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
173
183
  return [total_cs]
174
184
  ffmpeg = require_tool("ffmpeg")
175
185
  cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", video,
176
- "-t", f"{end - start:.3f}", "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
186
+ "-map", f"0:a:{audio_stream}", "-t", f"{end - start:.3f}", "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
177
187
  proc = sp.run(cmd, stdout=sp.PIPE, stderr=sp.PIPE)
178
188
  n = len(proc.stdout) // 2
179
189
  if proc.returncode != 0 or n < 800:
@@ -257,7 +267,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
257
267
  segments = body.split("\\N")
258
268
  words = [w for seg in segments for w in seg.split(" ") if w]
259
269
  if getattr(args, "karaoke_timing", "even") == "energy" and video:
260
- durs = word_durations_from_audio(video, start, end, len(words))
270
+ durs = word_durations_from_audio(video, start, end, len(words), getattr(args, "audio_stream", 0))
261
271
  else:
262
272
  per = max(1, dur_cs // max(1, len(words)))
263
273
  durs = [per] * len(words)
@@ -272,6 +282,18 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
272
282
  fh.write("\n".join(header + lines) + "\n")
273
283
 
274
284
 
285
+ def mux_subtitle_codec(output: str) -> str:
286
+ ext = Path(output).suffix.lower()
287
+ if ext in (".mp4", ".m4v", ".mov"):
288
+ return "mov_text"
289
+ if ext == ".mkv":
290
+ return "srt"
291
+ if ext == ".webm":
292
+ return "webvtt"
293
+ die(f"--mode mux: don't know a soft-subtitle codec for '{ext}' output "
294
+ "(know .mp4/.m4v/.mov, .mkv, .webm) -- use --mode burn, or pick one of those containers with -o")
295
+
296
+
275
297
  def ass_color(hex_rgb: str, alpha: int = 0) -> str:
276
298
  h = hex_rgb.lstrip("#")
277
299
  if len(h) != 6:
@@ -284,16 +306,26 @@ def main() -> int:
284
306
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
285
307
  ap.add_argument("input", nargs="?", help="video to burn captions into (omit with --write-srt to only generate)")
286
308
  ap.add_argument("-o", "--output", help="output video (default: <name>_captioned.<ext>)")
309
+ ap.add_argument("--mode", choices=["burn", "mux"], default="burn",
310
+ help="'burn' renders subtitles into the picture (default); "
311
+ "'mux' copies video/audio untouched and adds the SRT as a soft, toggleable subtitle stream")
312
+ ap.add_argument("--audio-stream", type=int, default=0,
313
+ help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
314
+ "audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
315
+ "the first track, same as leaving it unset always did")
287
316
  src = ap.add_argument_group("subtitle source")
288
317
  src.add_argument("--srt", help="SRT file to burn")
289
318
  src.add_argument("--ass", help="ASS file to burn (styles inside the file are used)")
290
319
  src.add_argument("--text", help="plain text cue file to convert into SRT (see format above)")
291
320
  src.add_argument("--transcribe", action="store_true", help="generate the SRT from the audio with a local speech-to-text engine if one is installed (whisper-cli / whisper / faster-whisper); never required")
292
- src.add_argument("--language", help="language code for --transcribe (e.g. en, ja); default auto")
321
+ src.add_argument("--language", help="language code for --transcribe (e.g. en, ja; default auto), also tagged on the subtitle stream with --mode mux")
293
322
  src.add_argument("--model", default="base", help="whisper model name/path for --transcribe (default base)")
294
323
  src.add_argument("--write-srt", help="where to save the generated SRT (default: <text>.srt)")
295
324
  src.add_argument("--auto-seconds", type=float, default=3.0, help="duration for cues without timing (default 3)")
296
325
  src.add_argument("--gap", type=float, default=0.0, help="gap after auto-timed cues in seconds")
326
+ src.add_argument("--fps", type=float, default=None,
327
+ help="frame rate for interpreting hh:mm:ss:ff SMPTE timecode cues in --text (non-drop-frame); "
328
+ "defaults to the input video's own fps when --input is given, required otherwise")
297
329
  sty = ap.add_argument_group("style (SRT only)")
298
330
  sty.add_argument("--brand", help="brand.json: font, colours, caption size/position/animation defaults")
299
331
  sty.add_argument("--font", default=None, help="font family, e.g. 'Noto Sans CJK JP' for Japanese (default DejaVu Sans or brand font)")
@@ -339,17 +371,37 @@ def main() -> int:
339
371
  args.fonts_dir = str(Path(brand["font_file"]).parent)
340
372
  if not (args.srt or args.ass or args.text or args.transcribe):
341
373
  die("give one of --srt, --ass, --text or --transcribe")
374
+ if args.mode == "mux":
375
+ if args.ass:
376
+ die("--mode mux takes --srt (or --text/--transcribe), not --ass -- "
377
+ "ASS carries burn-only styling with no soft-subtitle equivalent; use --mode burn for an ASS file")
378
+ if args.animate != "none" or args.karaoke:
379
+ die("--animate/--karaoke render pixels into the picture and require --mode burn")
380
+
381
+ meta = None
382
+ if args.input:
383
+ meta = probe(args.input)
384
+ if not meta.get("video"):
385
+ die("input has no video stream")
386
+ audio_streams = meta.get("audio_streams") or []
387
+ if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
388
+ die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
389
+ if args.audio_stream and not audio_streams:
390
+ die("--audio-stream needs an input with audio streams")
391
+ fps_for_tc = args.fps
392
+ if fps_for_tc is None and meta is not None:
393
+ fps_for_tc = meta.get("video", {}).get("fps")
342
394
 
343
395
  srt_path = args.srt
344
396
  if args.transcribe:
345
397
  if not args.input:
346
398
  die("--transcribe needs the input video")
347
399
  srt_path = args.write_srt or os.path.splitext(args.input)[0] + ".srt"
348
- cues = transcribe(args.input, srt_path, args.language, args.model)
400
+ cues = transcribe(args.input, srt_path, args.language, args.model, args.audio_stream)
349
401
  info(f"wrote {srt_path} ({len(cues)} cues)")
350
402
  args.text = None
351
403
  if args.text:
352
- cues = parse_text_cues(args.text, args.auto_seconds, args.gap)
404
+ cues = parse_text_cues(args.text, args.auto_seconds, args.gap, fps_for_tc)
353
405
  if args.write_srt:
354
406
  srt_path = args.write_srt
355
407
  elif args.input:
@@ -360,18 +412,45 @@ def main() -> int:
360
412
  srt_path = os.path.splitext(args.text)[0] + ".srt"
361
413
  if not STATE.dry_run:
362
414
  write_srt(cues, srt_path)
363
- info(f"wrote {srt_path} ({len(cues)} cues)")
415
+ tc_range = f", {fmt_smpte_time(cues[0][0], fps_for_tc)}-{fmt_smpte_time(cues[-1][1], fps_for_tc)} @ {fps_for_tc:g}fps" if fps_for_tc else ""
416
+ info(f"wrote {srt_path} ({len(cues)} cues{tc_range})")
364
417
  if not args.input:
365
418
  print(srt_path)
366
419
  return 0
367
420
 
368
421
  if not args.input:
369
422
  die("input video is required unless you only use --text/--write-srt")
370
- meta = probe(args.input)
371
- if not meta.get("video"):
372
- die("input has no video stream")
373
423
 
374
424
  output = args.output or default_output(args.input, "captioned")
425
+
426
+ if args.mode == "mux":
427
+ if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
428
+ die(f"SRT file not found: {srt_path}")
429
+ codec = mux_subtitle_codec(output)
430
+ # Keep any subtitle track(s) the input already has (e.g. chaining --mode mux once per
431
+ # language to build a multi-language set) -- copied byte-identical, distinct from the
432
+ # newly-added SRT's own codec below.
433
+ existing_subs = meta.get("subtitle_streams") or 0
434
+ maps = ["-map", "0:v:0"]
435
+ cmd = ffmpeg_base() + ["-i", args.input, "-i", srt_path]
436
+ if meta.get("audio"):
437
+ maps += ["-map", f"0:a:{args.audio_stream}"]
438
+ if existing_subs:
439
+ maps += ["-map", "0:s?"]
440
+ maps += ["-map", "1:0"]
441
+ cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else [])
442
+ for i in range(existing_subs):
443
+ cmd += [f"-c:s:{i}", "copy"]
444
+ cmd += [f"-c:s:{existing_subs}", codec]
445
+ if args.language:
446
+ cmd += [f"-metadata:s:s:{existing_subs}", f"language={args.language}"]
447
+ cmd += [output]
448
+ run(cmd)
449
+ result = probe(output, role="output")
450
+ info(f"wrote {output} ({result.get('duration'):.3f}s, mux, subtitle codec {codec})")
451
+ emit(output)
452
+ return 0
453
+
375
454
  if (args.animate != "none" or args.karaoke) and not args.ass:
376
455
  cues_for_ass = cues if args.text else parse_srt(srt_path)
377
456
  ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
@@ -409,10 +488,13 @@ def main() -> int:
409
488
  if args.fonts_dir:
410
489
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
411
490
 
412
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
491
+ cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0"]
492
+ if meta.get("audio"):
493
+ cmd += ["-map", f"0:a:{args.audio_stream}"]
494
+ cmd += ["-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
413
495
  cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
414
496
  run(cmd)
415
- result = probe(output)
497
+ result = probe(output, role="output")
416
498
  info(f"wrote {output} ({result.get('duration'):.3f}s)")
417
499
  emit(output)
418
500
  return 0
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,20 @@ 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)")
180
+ ap.add_argument("--audio-stream", type=int, default=0,
181
+ help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
182
+ "audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
183
+ "the first track, same as leaving it unset always did. Only affects modes that re-encode "
184
+ "audio (--to-sdr, --lut, --correct, and --retag's re-encode fallback) -- --strip-dovi and "
185
+ "a successful --retag stream-copy all streams untouched, so the flag has nothing to select there.")
108
186
  ap.add_argument("--crf", type=int, default=18)
109
187
  ap.add_argument("--preset", default="medium")
110
188
  add_common(ap)
@@ -116,6 +194,11 @@ def main() -> int:
116
194
  die("input has no video stream")
117
195
  v = meta["video"]
118
196
  has_audio = bool(meta.get("audio"))
197
+ audio_streams = meta.get("audio_streams") or []
198
+ if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
199
+ die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
200
+ if args.audio_stream and not audio_streams:
201
+ die("--audio-stream needs an input with audio streams")
119
202
 
120
203
  if args.strip_dovi:
121
204
  output = args.output or default_output(args.input, "nodv")
@@ -128,7 +211,7 @@ def main() -> int:
128
211
  cmd += ["-movflags", "+faststart"]
129
212
  cmd.append(output)
130
213
  run(cmd)
131
- r = probe(output)
214
+ r = probe(output, role="output")
132
215
  info(f"wrote {output} (dolby_vision={r['video'].get('dolby_vision')})")
133
216
  emit(output)
134
217
  return 0
@@ -148,14 +231,30 @@ def main() -> int:
148
231
  cmd += ["-movflags", "+faststart"]
149
232
  cmd.append(output)
150
233
  proc = run(cmd, check=False)
234
+ dropped_streams = False
151
235
  if proc.returncode != 0:
152
- # some codecs cannot carry retagged colour info without a bitstream filter; fall back to re-encode
153
- info("stream copy could not rewrite tags, re-encoding")
154
- cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", "0:a:0?"] + x264_args(args.crf, args.preset, keep_bt709=False)
155
- cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
156
- run(cmd)
236
+ # Some codecs cannot carry retagged colour info without a bitstream filter, so the
237
+ # stream copy above fails and we fall back to re-encoding video+audio. The copy path
238
+ # (-map 0 -c copy) keeps every stream -- extra audio tracks, subtitles, chapters,
239
+ # attached pictures -- byte-for-byte; -c:s/-c:d copy here keeps that same guarantee
240
+ # for subtitle/data streams even though video/audio must be re-encoded. Only if THAT
241
+ # also fails (e.g. a subtitle codec genuinely incompatible with the target container)
242
+ # do we drop to video+selected-audio-only, and even then we say so explicitly rather
243
+ # than silently reporting "completed" with streams missing.
244
+ info("stream copy could not rewrite tags, re-encoding video/audio (subtitles/data streams kept)")
245
+ cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
246
+ "-map", "0:s?", "-map", "0:d?"] + x264_args(args.crf, args.preset, keep_bt709=False)
247
+ cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]]
248
+ cmd += (aac_args() if has_audio else []) + ["-c:s", "copy", "-c:d", "copy"] + [output]
249
+ proc2 = run(cmd, check=False)
250
+ if proc2.returncode != 0:
251
+ info("re-encode with subtitles/data streams kept also failed; dropping them")
252
+ dropped_streams = True
253
+ 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)
254
+ cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
255
+ run(cmd)
157
256
  info(f"wrote {output} (tags -> {args.retag})")
158
- emit(output)
257
+ emit(output, reencoded=proc.returncode != 0, dropped_non_av_streams=dropped_streams)
159
258
  return 0
160
259
 
161
260
  measurements = None
@@ -184,17 +283,17 @@ def main() -> int:
184
283
  output = args.output or default_output(args.input, "lut")
185
284
  tag = "lut"
186
285
 
187
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", "0:a:0?"]
188
- cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else []) + [output]
189
- run(cmd)
190
- r = probe(output)
286
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
287
+ cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else [])
288
+ dropped_streams = run_keeping_subtitles(cmd, output)
289
+ r = probe(output, role="output")
191
290
  info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
192
291
  f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
292
+ extra = {"dropped_non_av_streams": dropped_streams}
193
293
  if measurements is not None:
194
294
  measurements["output"] = analyze_levels(output)
195
- emit(output, measurements=measurements)
196
- else:
197
- emit(output)
295
+ extra["measurements"] = measurements
296
+ emit(output, **extra)
198
297
  return 0
199
298
 
200
299
 
@@ -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
@@ -112,7 +112,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
112
112
  if not reencode:
113
113
  info("stream copy failed, falling back to re-encode")
114
114
  return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
115
- die(f"ffmpeg failed:\n{proc.stderr.strip()}")
115
+ die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
116
116
  if not reencode and tolerance >= 0 and not STATE["dry_run"]:
117
117
  got = probe(dst).get("duration") or 0.0
118
118
  if abs(got - dur) > tolerance:
@@ -190,7 +190,7 @@ def main() -> int:
190
190
  cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile] + encode_args(meta, output, args.crf, args.preset) + [output]
191
191
  run(cmd)
192
192
 
193
- result = probe(output)
193
+ result = probe(output, role="output")
194
194
  expected = sum(e - s for s, e in segments)
195
195
  precision = precision_of(meta, output, reencoded)
196
196
  got = result.get("duration")
package/scripts/export.py CHANGED
@@ -118,7 +118,7 @@ def main() -> int:
118
118
  cmd += ["-t", f"{p['max']:.3f}"]
119
119
  cmd.append(output)
120
120
  run(cmd)
121
- result = probe(output)
121
+ result = probe(output, role="output")
122
122
  v = result["video"]
123
123
  info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['codec']})")
124
124
  emit(output)