ffmpeg-skill 0.9.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +315 -122
  2. package/SKILL.md +115 -18
  3. package/bin/install.js +16 -2
  4. package/mcp/server.py +2 -0
  5. package/package.json +15 -3
  6. package/references/ci-platform-pitfalls.md +111 -0
  7. package/references/process-pitfalls.md +85 -0
  8. package/references/scripts.md +122 -11
  9. package/scripts/_common.py +247 -15
  10. package/scripts/_contract.py +420 -48
  11. package/scripts/audio.py +101 -8
  12. package/scripts/background.py +73 -0
  13. package/scripts/caption.py +97 -18
  14. package/scripts/check.py +21 -7
  15. package/scripts/color.py +104 -13
  16. package/scripts/crop.py +79 -0
  17. package/scripts/cut.py +85 -11
  18. package/scripts/export.py +16 -7
  19. package/scripts/fit.py +76 -12
  20. package/scripts/graphics.py +12 -3
  21. package/scripts/insert.py +128 -0
  22. package/scripts/join.py +88 -8
  23. package/scripts/loudness.py +3 -3
  24. package/scripts/multicam.py +11 -1
  25. package/scripts/overlay.py +64 -5
  26. package/scripts/proxy.py +82 -0
  27. package/scripts/render.py +13 -2
  28. package/scripts/reverse.py +56 -0
  29. package/scripts/scenes.py +15 -3
  30. package/scripts/sequence.py +124 -0
  31. package/scripts/silence.py +2 -2
  32. package/scripts/stabilize.py +83 -0
  33. package/scripts/sync.py +9 -1
  34. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  35. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  36. package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
  37. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  38. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  39. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  40. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  41. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  42. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  43. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  44. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  45. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  46. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  47. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  48. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  49. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  50. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  51. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  52. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  53. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  54. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  55. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  56. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  57. 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
- r = probe(output)
212
+ r = probe(output, role="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
 
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ """Generate a solid-colour or two-colour gradient background clip.
3
+
4
+ No input file: a silent, fixed-duration, exact-size clip generated entirely
5
+ by ffmpeg's own source filters (`color` for solid, `gradients` for a
6
+ two-colour gradient) -- for a title card background, a placeholder behind a
7
+ logo, or a base layer for overlay.py to composite onto.
8
+
9
+ Examples:
10
+ python3 background.py --duration 3 --width 1920 --height 1080 --color 0x101010
11
+ python3 background.py --duration 5 --width 1080 --height 1920 --gradient 0xff6a00:0x0057ff --angle 45
12
+ """
13
+ import argparse
14
+ import math
15
+ import sys
16
+
17
+ from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
18
+
19
+
20
+ def main() -> int:
21
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
22
+ ap.add_argument("-o", "--output", required=True, help="output file")
23
+ ap.add_argument("--duration", required=True, help="clip duration (seconds or mm:ss)")
24
+ ap.add_argument("--width", type=int, required=True, help="output width in px (must be even)")
25
+ ap.add_argument("--height", type=int, required=True, help="output height in px (must be even)")
26
+ ap.add_argument("--fps", type=float, default=30.0, help="output frame rate (default 30)")
27
+ src = ap.add_mutually_exclusive_group()
28
+ src.add_argument("--color", default="black", help="solid background colour, e.g. black, 0x101010 (default black)")
29
+ src.add_argument("--gradient", help="two colours as C1:C2 for a linear gradient, e.g. 0xff6a00:0x0057ff")
30
+ ap.add_argument("--angle", type=float, default=0.0, help="gradient angle in degrees (with --gradient, default 0 = left to right)")
31
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
32
+ ap.add_argument("--preset", default="medium", help="x264 preset")
33
+ add_common(ap)
34
+ args = ap.parse_args()
35
+ apply_common(args)
36
+
37
+ target = parse_time(args.duration)
38
+ if target <= 0:
39
+ die("--duration must be > 0")
40
+ if args.fps <= 0:
41
+ die("--fps must be > 0")
42
+ if args.width <= 0 or args.height <= 0:
43
+ die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
44
+ if args.width % 2 or args.height % 2:
45
+ die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
46
+
47
+ if args.gradient:
48
+ try:
49
+ c0, c1 = args.gradient.split(":")
50
+ except ValueError:
51
+ die(f"--gradient needs two colours as C1:C2, got '{args.gradient}'")
52
+ rad = math.radians(args.angle)
53
+ x1 = round(args.width * math.cos(rad))
54
+ y1 = round(args.width * math.sin(rad))
55
+ src_filter = f"gradients=size={args.width}x{args.height}:rate={args.fps:g}:c0={c0}:c1={c1}:x0=0:y0=0:x1={x1}:y1={y1}"
56
+ else:
57
+ src_filter = f"color=c={args.color}:size={args.width}x{args.height}:rate={args.fps:g}"
58
+
59
+ output = args.output
60
+ cmd = ffmpeg_base() + ["-f", "lavfi", "-i", src_filter, "-t", f"{target:.3f}"]
61
+ cmd += video_args(None, args.crf, args.preset)
62
+ cmd += ["-an", output]
63
+ run(cmd)
64
+
65
+ result = probe(output, role="output")
66
+ v = result["video"]
67
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['fps']:g}fps)")
68
+ emit(output)
69
+ return 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ sys.exit(main())
@@ -1,12 +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.
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.
6
18
 
7
19
  Text-to-SRT input format (one cue per line, blank lines ignored):
8
20
  0:00-0:03 Hello and welcome
9
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)
10
23
  Text without a time is auto-timed after the previous cue (--auto-seconds)
11
24
 
12
25
  Examples:
@@ -23,7 +36,7 @@ import sys
23
36
  from pathlib import Path
24
37
  from typing import List, Optional, Tuple
25
38
 
26
- 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
27
40
 
28
41
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
29
42
 
@@ -32,7 +45,7 @@ TIME_RE = re.compile(
32
45
  )
33
46
 
34
47
 
35
- 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]]:
36
49
  cues: List[Tuple[float, float, str]] = []
37
50
  cursor = 0.0
38
51
  with open(path, encoding="utf-8") as fh:
@@ -43,7 +56,9 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[fl
43
56
  m = TIME_RE.match(line)
44
57
  if m:
45
58
  try:
46
- 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")
47
62
  except ValueError:
48
63
  start, end, text = cursor, cursor + auto_seconds, line.strip()
49
64
  else:
@@ -60,7 +75,7 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[fl
60
75
  return cues
61
76
 
62
77
 
63
- 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]]:
64
79
  """Optional local ASR bridge. Tries, in order: whisper-cli / main (whisper.cpp), faster-whisper (python),
65
80
  whisper (openai-whisper CLI). Produces an SRT with word timings where the engine supports it.
66
81
  No engine installed -> clear error with install hints; the skill never depends on one."""
@@ -71,7 +86,8 @@ def transcribe(video: str, out_srt: str, language: Optional[str], model: str) ->
71
86
  ffmpeg = require_tool("ffmpeg")
72
87
  tmpdir = tempfile.mkdtemp(prefix="ffskill_asr_")
73
88
  wav = os.path.join(tmpdir, "audio.wav")
74
- 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)
75
91
  # 1. whisper.cpp
76
92
  cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp") or shutil.which("main")
77
93
  if cli and (shutil.which("whisper-cli") or shutil.which("whisper-cpp")):
@@ -152,7 +168,7 @@ def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
152
168
  fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
153
169
 
154
170
 
155
- 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]:
156
172
  """Split a cue's time across n_words in proportion to speech energy (centiseconds each).
157
173
 
158
174
  Decodes the cue window to 8 kHz mono, builds a 10 ms RMS envelope, removes the noise floor,
@@ -167,7 +183,7 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
167
183
  return [total_cs]
168
184
  ffmpeg = require_tool("ffmpeg")
169
185
  cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", video,
170
- "-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", "-"]
171
187
  proc = sp.run(cmd, stdout=sp.PIPE, stderr=sp.PIPE)
172
188
  n = len(proc.stdout) // 2
173
189
  if proc.returncode != 0 or n < 800:
@@ -251,7 +267,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
251
267
  segments = body.split("\\N")
252
268
  words = [w for seg in segments for w in seg.split(" ") if w]
253
269
  if getattr(args, "karaoke_timing", "even") == "energy" and video:
254
- 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))
255
271
  else:
256
272
  per = max(1, dur_cs // max(1, len(words)))
257
273
  durs = [per] * len(words)
@@ -266,6 +282,18 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
266
282
  fh.write("\n".join(header + lines) + "\n")
267
283
 
268
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
+
269
297
  def ass_color(hex_rgb: str, alpha: int = 0) -> str:
270
298
  h = hex_rgb.lstrip("#")
271
299
  if len(h) != 6:
@@ -278,16 +306,26 @@ def main() -> int:
278
306
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
279
307
  ap.add_argument("input", nargs="?", help="video to burn captions into (omit with --write-srt to only generate)")
280
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")
281
316
  src = ap.add_argument_group("subtitle source")
282
317
  src.add_argument("--srt", help="SRT file to burn")
283
318
  src.add_argument("--ass", help="ASS file to burn (styles inside the file are used)")
284
319
  src.add_argument("--text", help="plain text cue file to convert into SRT (see format above)")
285
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")
286
- 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")
287
322
  src.add_argument("--model", default="base", help="whisper model name/path for --transcribe (default base)")
288
323
  src.add_argument("--write-srt", help="where to save the generated SRT (default: <text>.srt)")
289
324
  src.add_argument("--auto-seconds", type=float, default=3.0, help="duration for cues without timing (default 3)")
290
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")
291
329
  sty = ap.add_argument_group("style (SRT only)")
292
330
  sty.add_argument("--brand", help="brand.json: font, colours, caption size/position/animation defaults")
293
331
  sty.add_argument("--font", default=None, help="font family, e.g. 'Noto Sans CJK JP' for Japanese (default DejaVu Sans or brand font)")
@@ -333,17 +371,37 @@ def main() -> int:
333
371
  args.fonts_dir = str(Path(brand["font_file"]).parent)
334
372
  if not (args.srt or args.ass or args.text or args.transcribe):
335
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")
336
394
 
337
395
  srt_path = args.srt
338
396
  if args.transcribe:
339
397
  if not args.input:
340
398
  die("--transcribe needs the input video")
341
399
  srt_path = args.write_srt or os.path.splitext(args.input)[0] + ".srt"
342
- cues = transcribe(args.input, srt_path, args.language, args.model)
400
+ cues = transcribe(args.input, srt_path, args.language, args.model, args.audio_stream)
343
401
  info(f"wrote {srt_path} ({len(cues)} cues)")
344
402
  args.text = None
345
403
  if args.text:
346
- 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)
347
405
  if args.write_srt:
348
406
  srt_path = args.write_srt
349
407
  elif args.input:
@@ -354,18 +412,36 @@ def main() -> int:
354
412
  srt_path = os.path.splitext(args.text)[0] + ".srt"
355
413
  if not STATE.dry_run:
356
414
  write_srt(cues, srt_path)
357
- 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})")
358
417
  if not args.input:
359
418
  print(srt_path)
360
419
  return 0
361
420
 
362
421
  if not args.input:
363
422
  die("input video is required unless you only use --text/--write-srt")
364
- meta = probe(args.input)
365
- if not meta.get("video"):
366
- die("input has no video stream")
367
423
 
368
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
+ maps = ["-map", "0:v:0"]
431
+ cmd = ffmpeg_base() + ["-i", args.input, "-i", srt_path]
432
+ if meta.get("audio"):
433
+ maps += ["-map", f"0:a:{args.audio_stream}"]
434
+ maps += ["-map", "1:0"]
435
+ cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else []) + ["-c:s", codec]
436
+ if args.language:
437
+ cmd += ["-metadata:s:s:0", f"language={args.language}"]
438
+ cmd += [output]
439
+ run(cmd)
440
+ result = probe(output, role="output")
441
+ info(f"wrote {output} ({result.get('duration'):.3f}s, mux, subtitle codec {codec})")
442
+ emit(output)
443
+ return 0
444
+
369
445
  if (args.animate != "none" or args.karaoke) and not args.ass:
370
446
  cues_for_ass = cues if args.text else parse_srt(srt_path)
371
447
  ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
@@ -403,10 +479,13 @@ def main() -> int:
403
479
  if args.fonts_dir:
404
480
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
405
481
 
406
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
482
+ cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0"]
483
+ if meta.get("audio"):
484
+ cmd += ["-map", f"0:a:{args.audio_stream}"]
485
+ cmd += ["-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
407
486
  cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
408
487
  run(cmd)
409
- result = probe(output)
488
+ result = probe(output, role="output")
410
489
  info(f"wrote {output} ({result.get('duration'):.3f}s)")
411
490
  emit(output)
412
491
  return 0
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)