ffmpeg-skill 3.8.11 → 3.8.13

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.
@@ -170,7 +170,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
170
170
  required=FF + [X264, AAC], optional=[HDR_X265],
171
171
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
172
172
  "audio": dict(role="execution", inputs=["video or audio asset", "music bed (--music) or replacement track (--replace)", "effects/atmos track (--effects)"], outputs=["artifact with the processed audio (video stream-copied, or dropped when -o has an audio extension)"],
173
- required=FF, optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaincompress", "when": "--duck"},
173
+ required=FF, optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaingate", "when": "--duck"}, {"capability": "filter:dcshift", "when": "--duck"},
174
174
  {"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit / --voice strong"}, {"capability": "filter:agate", "when": "--gate"},
175
175
  {"capability": "filter:deesser", "when": "--voice medium (the default) / --voice strong"},
176
176
  {"capability": "filter:extrastereo", "when": "--stereo-widen"},
@@ -872,7 +872,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
872
872
  "silent": {"type": "array", "description": "--on-silent warn (default), only when a track was silent: [{flag, path, peak_db}] for each --music/--replace/--effects file, or under --duck the voice (flag \"input\"), whose whole-file peak is at or below --silence-threshold (default -50 dBFS); it was mixed anyway. --on-silent fail refuses instead, reason \"silent (peak X dBFS)\" in problems"},
873
873
  "notes": {"type": "array", "items": {"type": "string"}, "description": "present with `silent`: which tracks were silent and how to refuse them"},
874
874
  "timing": {"type": "object", "description": "#486: {tempo, atempo, length_samples, sample_rate}: the --tempo factor (null without it), the atempo stages (each 0.5..2.0, [] at tempo 1) whose product it is, --length-samples (null without it) and the sample rate that count is in (null when not measured yet); with --length-samples a length_samples verification step counts the written track's decoded samples (exact for PCM/FLAC/ALAC, within tolerance_samples, one codec frame, for a lossy codec)"},
875
- "audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, channel (--channel N, the 0-based channel kept, else null), sample_rate (--sample-rate, else null; both are read back from the output as the channels / sample_rate verification steps), effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db actually used)"}}
875
+ "audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, channel (--channel N, the 0-based channel kept, else null), sample_rate (--sample-rate, else null; both are read back from the output as the channels / sample_rate verification steps), effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, the sidechain gate's ratio, attack_ms, release_ms, and amount_db, the depth in dB the bed is lowered under speech)"}}
876
876
  for key, prop in EMITTED_KEYS.get(name, {}).items():
877
877
  extra.setdefault(key, prop)
878
878
  if name in ("overlay", "graphics", "caption", "grid"):
@@ -110,6 +110,21 @@ def ineligible(params: Optional[Dict[str, Any]], meta: Dict[str, Any], ext: str)
110
110
  return None
111
111
 
112
112
 
113
+ # Matroska stores every timestamp in milliseconds (its default TimestampScale), whatever the source's
114
+ # time base: an .mkv output's stream reads 1/1000 however it is written
115
+ MATROSKA_TIME_BASE = "1/1000"
116
+
117
+
118
+ def expected_time_base(params: Dict[str, Any], ext: str) -> Optional[str]:
119
+ """The time base the joined output's video should read: the source's for MP4/MOV, whose time
120
+ scale execute() sets from it (-video_track_timescale), and Matroska's own for .mkv. Comparing a
121
+ .mkv against an MP4 source's 1/15360 failed every such cut, and only after the whole smart cut had
122
+ been built and checked (#462 A45)."""
123
+ if ext.lower() == ".mkv":
124
+ return MATROSKA_TIME_BASE
125
+ return params.get("time_base")
126
+
127
+
113
128
  def _rate(params: Dict[str, Any]) -> Optional[Fraction]:
114
129
  try:
115
130
  q = Fraction(str(params.get("r_frame_rate") or ""))
@@ -165,8 +180,10 @@ def plan_segment(packets: List[Tuple[Any, Any, bool]], s: float, e: float, vend:
165
180
  raise SmartFallback(f"the keyframe at {packets[k1][0]:.3f}s opens its GOP (frames decoded after it are shown "
166
181
  "before it), so a copy cannot start there", "open_gop")
167
182
  k1_pts = packets[k1][0]
168
- # where the copy stops: the last keyframe presented at or before the end bound, decoded after K1
169
- stops = [i for i in keyidx if packets[i][1] > packets[k1][1] + 1e-9
183
+ # where the copy stops: the last keyframe presented at or before the end bound, decoded after K1.
184
+ # A segment that runs to the end of the video has no end bound to stop before: its copy runs
185
+ # to the end, and the last GOP is whole, so it is copied, not re-encoded as a tail (#462 A44)
186
+ stops = [i for i in keyidx if not to_end and packets[i][1] > packets[k1][1] + 1e-9
170
187
  and (end_bound is None or packets[i][0] <= end_bound + 1e-6)]
171
188
  k2 = stops[-1] if stops else None
172
189
  if k2 is not None and gop_is_open(packets, k2):
@@ -422,9 +439,10 @@ def verify(src: str, dst: str, parts: List[Dict[str, Any]], params: Dict[str, An
422
439
  decoded += g
423
440
  offset += n
424
441
  out_params = stream_params(dst) or {}
425
- if out_params.get("time_base") != params.get("time_base"):
426
- raise SmartFallback(f"the output's time base is {out_params.get('time_base')}, the source's "
427
- f"{params.get('time_base')}", "time_base")
442
+ want_tb = expected_time_base(params, os.path.splitext(dst)[1])
443
+ if out_params.get("time_base") != want_tb:
444
+ raise SmartFallback(f"the output's time base is {out_params.get('time_base')}, where "
445
+ f"{want_tb} was expected (the source's is {params.get('time_base')})", "time_base")
428
446
  diff = mismatches(params, out_params)
429
447
  if diff:
430
448
  raise SmartFallback("the output's stream does not match the source: " + ", ".join(diff), "params_mismatch")
package/scripts/audio.py CHANGED
@@ -29,8 +29,8 @@ from typing import Any, Dict, List
29
29
 
30
30
  from _common.probe import SILENT_PEAK_DBFS
31
31
  from _common import STATE, add_common, dry_run_input_pending, require_tool, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, measured_level_dbfs, probe, run, run_keeping_subtitles, fmt_secs
32
- from _common.emit import warn
33
- from _common.decision import conform_filter, conform_to_length
32
+ from _common.emit import file_origin, stream_end, warn
33
+ from _common.decision import LOSSLESS_AUDIO_EXT, conform_filter, conform_to_length
34
34
 
35
35
  # --downmix: ITU-R BS.775 fold-down, renormalised by pan's '<' (see main()).
36
36
  DOWNMIX_PAN = "pan=stereo|FL<FL+0.707*FC+0.707*BL+0.707*SL|FR<FR+0.707*FC+0.707*BR+0.707*SR"
@@ -43,10 +43,11 @@ VOICE_CHAIN = f"highpass=f=80,deesser=i=0.4,{VOICE_DENOISE},acompressor=threshol
43
43
  # which is what a good room recording needs; "strong" is for phone/laptop audio: a harder
44
44
  # de-esser, a second compression stage and a soft limiter at -1 dBFS so the peaks stop there
45
45
  # instead of at whatever the make-up gain produced.
46
+ VOICE_LIMITER = "alimiter=limit=0.891251:level=disabled:latency=1" # --voice strong's -1 dBFS ceiling
46
47
  VOICE_LEVELS = {
47
48
  "light": "highpass=f=80,acompressor=threshold=-18dB:ratio=2:attack=5:release=80:makeup=1",
48
49
  "medium": VOICE_CHAIN,
49
- "strong": VOICE_CHAIN + ",deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled:latency=1",
50
+ "strong": VOICE_CHAIN + ",deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3," + VOICE_LIMITER,
50
51
  }
51
52
 
52
53
 
@@ -66,11 +67,40 @@ def voice_chain(level: str, denoise_strength: "float | None") -> str:
66
67
  return chain.replace("highpass=f=80,", f"highpass=f=80,{stage},", 1)
67
68
 
68
69
 
69
- # The sidechain threshold the music bed has used since 1.4 is the linear 0.05 that ffmpeg's
70
- # sidechaincompress takes; -26.0206 dBFS is that same number in the unit the flag speaks, so the
71
- # default command line is unchanged to the byte while the value is now sayable.
70
+ # The sidechain threshold the music bed has used since 1.4 is the linear 0.05 the filter takes;
71
+ # -26.0206 dBFS is that same number in the unit the flag speaks.
72
72
  DUCK_THRESHOLD_DB = -26.0206
73
73
 
74
+ # the knee the filters take is a linear 1..8 (default 2.82843): in the dB the flag speaks that is
75
+ # 0..18.06 dB, default 9.03 dB. The flag used to accept 1..8 *dB* (linear 1.12..2.51), so no knee
76
+ # from 2.51 up to ffmpeg's own default could be asked for, and the help gave the default as 2.83 dB
77
+ KNEE_MAX_DB = 18.06 # 20*log10(8) = 18.0618, rounded down so the linear value stays inside 1..8
78
+
79
+ # --duck (A134): the bed is lowered by exactly --duck-amount dB while the speech is above the
80
+ # threshold. A sidechain gate keyed by the speech passes the bed where there is speech and nothing
81
+ # elsewhere; that part, scaled by -(1 - 10^(-amount/20)), is added back to the bed, which leaves
82
+ # 10^(-amount/20) of it under speech and all of it in the pauses. The sidechain compressor used
83
+ # through 3.8.8 took the amount as ratio = max(2, amount/3), and a compressor's depth is how far
84
+ # the speech rises over its threshold: on the test's speech 0 and 3 ducked the same 9.7 dB, 60 ducked 18.
85
+ # The key gets a DC offset far below any threshold (-120 dBFS) because agate's detector leaves the
86
+ # gate open on a key that is exact zeros from its first sample. The knee is hard (knee=1): agate's
87
+ # default soft knee (2.82843) opened the gate only about 2 dB over the threshold, so a key just
88
+ # over it left the bed at its level (measured: a sine 1.5 dB over -7.5 dBFS ducked 0 dB of 12).
89
+ DUCK_GATE_RATIO = 9000.0 # agate's largest: closed below the threshold
90
+ DUCK_KEY_DC = "dcshift=shift=0.000001"
91
+
92
+
93
+ def duck_graph(threshold_db: float, amount_db: float, attack: float, release: float) -> List[str]:
94
+ """The filter-graph lines that turn `[music]` and the speech key `[sc]` into `[ducked]`."""
95
+ cut = 1.0 - 10 ** (-amount_db / 20.0)
96
+ return ["[music]asplit=2[mdry][mwet]",
97
+ f"[sc]{DUCK_KEY_DC}[key]",
98
+ f"[mwet][key]sidechaingate=threshold={db_to_linear(threshold_db):.6g}:ratio={DUCK_GATE_RATIO:g}:range=0:knee=1"
99
+ f":attack={attack:g}:release={release:g}[mspeech]",
100
+ f"[mspeech]volume={-cut:.6g}[mcut]",
101
+ "[mdry][mcut]amix=inputs=2:normalize=0[ducked]"]
102
+
103
+
74
104
  # Typed dynamics: every flag maps to one real option of one ffmpeg filter, validated against the
75
105
  # range that filter documents (ffmpeg -h filter=acompressor / alimiter / agate). dB flags are
76
106
  # converted to the linear value the filter takes, so no string reaches the graph unchecked.
@@ -81,7 +111,7 @@ DYNAMICS = {
81
111
  "comp_attack": ("attack", "ms", 0.01, 2000.0),
82
112
  "comp_release": ("release", "ms", 0.01, 9000.0),
83
113
  "comp_makeup": ("makeup", "dB", 0.0, 36.0), # 1..64 linear
84
- "comp_knee": ("knee", "dB", 1.0, 8.0),
114
+ "comp_knee": ("knee", "dB", 0.0, KNEE_MAX_DB), # 1..8 linear (A135: was 1..8 dB, linear 1.12..2.51)
85
115
  },
86
116
  "alimiter": {
87
117
  "limit_ceiling": ("limit", "dB", -24.0, 0.0), # 0.0625..1 linear
@@ -94,7 +124,7 @@ DYNAMICS = {
94
124
  "gate_attack": ("attack", "ms", 0.01, 9000.0),
95
125
  "gate_release": ("release", "ms", 0.01, 9000.0),
96
126
  "gate_range": ("range", "dB", -90.0, 0.0), # 0..1 linear: how far the gate closes
97
- "gate_knee": ("knee", "dB", 1.0, 8.0),
127
+ "gate_knee": ("knee", "dB", 0.0, KNEE_MAX_DB), # 1..8 linear
98
128
  },
99
129
  }
100
130
 
@@ -289,8 +319,8 @@ def main() -> int:
289
319
  music = ap.add_argument_group("music")
290
320
  music.add_argument("--music", help="music file to mix underneath")
291
321
  music.add_argument("--music-volume", type=float, default=-14.0, help="music level in dB relative to full scale (default -14)")
292
- music.add_argument("--duck", action="store_true", help="auto-duck the music when the main track has speech (sidechain compressor)")
293
- music.add_argument("--duck-amount", type=float, default=12.0, help="how many dB to duck (default 12)")
322
+ music.add_argument("--duck", action="store_true", help="auto-duck the music by --duck-amount dB while the main track has speech (a sidechain gate keyed by it)")
323
+ music.add_argument("--duck-amount", type=float, default=12.0, help="how many dB the music is lowered under speech, 0..60 (default 12)")
294
324
  music.add_argument("--duck-threshold", type=float, default=DUCK_THRESHOLD_DB,
295
325
  help="sidechain threshold in dBFS: the main track is heard as speech above this (default -26.02, the 0.05 linear used since 1.4)")
296
326
  music.add_argument("--duck-attack", type=float, default=20.0, help="ms the bed takes to duck once speech starts (default 20)")
@@ -326,25 +356,27 @@ def main() -> int:
326
356
  "(an audio output only: beside a picture the sound is conformed to the picture)")
327
357
  dyn = ap.add_argument_group("dynamics (typed; each flag is one option of ffmpeg's acompressor / alimiter / agate)")
328
358
  dyn.add_argument("--compress", action="store_true", help="compressor (acompressor); order: gate -> compressor -> limiter")
329
- dyn.add_argument("--comp-threshold", type=float, help="dBFS above which gain is reduced, -60..0 (ffmpeg default -12.4)")
359
+ dyn.add_argument("--comp-threshold", type=float, help="dBFS above which gain is reduced, -60..0 (ffmpeg default -18.06, its linear 0.125)")
330
360
  dyn.add_argument("--comp-ratio", type=float, help="ratio 1..20 (default 2)")
331
361
  dyn.add_argument("--comp-attack", type=float, help="ms 0.01..2000 (default 20)")
332
362
  dyn.add_argument("--comp-release", type=float, help="ms 0.01..9000 (default 250)")
333
363
  dyn.add_argument("--comp-makeup", type=float, help="make-up gain dB 0..36 (default 0)")
334
- dyn.add_argument("--comp-knee", type=float, help="knee dB 1..8 (default 2.83)")
364
+ dyn.add_argument("--comp-knee", type=float, help="knee width dB 0..18.06 (ffmpeg's linear 1..8; default 9.03, its 2.82843)")
335
365
  dyn.add_argument("--limit", action="store_true", help="look-ahead limiter (alimiter), level left as is")
336
366
  dyn.add_argument("--limit-ceiling", type=float, help="ceiling dBFS -24..0 (default 0)")
337
367
  dyn.add_argument("--limit-attack", type=float, help="ms 0.1..80 (default 5)")
338
368
  dyn.add_argument("--limit-release", type=float, help="ms 1..8000 (default 50)")
339
369
  dyn.add_argument("--gate", action="store_true", help="noise gate (agate)")
340
- dyn.add_argument("--gate-threshold", type=float, help="dBFS below which the gate closes, -60..0 (default -18.1)")
370
+ dyn.add_argument("--gate-threshold", type=float, help="dBFS below which the gate closes, -60..0 (default -18.06, ffmpeg's linear 0.125)")
341
371
  dyn.add_argument("--gate-ratio", type=float, help="ratio 1..9000 (default 2)")
342
372
  dyn.add_argument("--gate-attack", type=float, help="ms 0.01..9000 (default 20)")
343
373
  dyn.add_argument("--gate-release", type=float, help="ms 0.01..9000 (default 250)")
344
- dyn.add_argument("--gate-range", type=float, help="attenuation when closed, dB -90..0 (default -6.1)")
345
- dyn.add_argument("--gate-knee", type=float, help="knee dB 1..8 (default 2.83)")
374
+ dyn.add_argument("--gate-range", type=float, help="attenuation when closed, dB -90..0 (default -24.26, ffmpeg's linear 0.06125)")
375
+ dyn.add_argument("--gate-knee", type=float, help="knee width dB 0..18.06 (ffmpeg's linear 1..8; default 9.03, its 2.82843)")
346
376
  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)")
347
- ap.add_argument("--bitrate", default="192k")
377
+ ap.add_argument("--bitrate", default=None,
378
+ help="audio bitrate: AAC's beside a picture (default 192k), and the codec's of a lossy audio output "
379
+ "(.m4a/.aac default 256k, .mp3 -q:a 0, .ogg -q:a 6, .opus 128k); refused for .wav/.flac")
348
380
  ap.add_argument("--on-silent", choices=["warn", "fail"], default="warn",
349
381
  help="a --music/--replace/--effects file (or, under --duck, the voice) whose peak is at or below "
350
382
  "--silence-threshold: warn (default: mix it, report it under `silent`) or fail (refuse before ffmpeg runs)")
@@ -391,7 +423,7 @@ def main() -> int:
391
423
  ("--duck-attack", args.duck_attack, 0.01, 2000.0),
392
424
  ("--duck-release", args.duck_release, 0.01, 9000.0)):
393
425
  if not (lo <= value <= hi):
394
- die(f"{flag} {value:g} is outside {lo:g}..{hi:g} (the range ffmpeg's sidechaincompress accepts)")
426
+ die(f"{flag} {value:g} is outside {lo:g}..{hi:g}")
395
427
  if args.stereo_widen is not None and not (0.0 <= args.stereo_widen <= 1.0):
396
428
  die(f"--stereo-widen must be 0..1 (0 = untouched, 1 = maximum), got {args.stereo_widen:g}")
397
429
 
@@ -421,6 +453,9 @@ def main() -> int:
421
453
  die("input has no audio stream (use --replace to add one)")
422
454
  output = args.output or default_output(args.input, "audio")
423
455
  audio_out = is_audio_output(output)
456
+ if args.bitrate is not None and Path(output).suffix.lower() in LOSSLESS_AUDIO_EXT:
457
+ # A143: the rate used to be dropped without a word for every audio extension
458
+ die(f"--bitrate has no effect on a lossless {Path(output).suffix.lower()} output; drop it, or write a lossy one (.m4a, .mp3, ...)")
424
459
  streams = meta.get("audio_streams") or []
425
460
  if streams and not (0 <= args.audio_stream < len(streams)) and not STATE.dry_run:
426
461
  die(f"--audio-stream {args.audio_stream}: input has {len(streams)} audio stream(s), 0..{len(streams) - 1}")
@@ -486,8 +521,17 @@ def main() -> int:
486
521
  # 7.1), so in-phase channels at full scale cannot clip; '=' summed 2.7x and clipped a loud
487
522
  # 5.1 while the run said verified.
488
523
  fx.append(DOWNMIX_PAN)
524
+ # A129: a limiter is the ceiling of what is delivered, so with a bed mixed in it runs after the
525
+ # mix: on the voice alone, the music and effects added on top of it peaked over the ceiling
526
+ # (--limit-ceiling -6 delivered 0 dBFS, --voice strong's -1 dBFS -0.2)
527
+ limit_after_mix = bool(args.music or args.effects)
528
+ post_limit: List[str] = []
489
529
  if args.voice:
490
- fx.append(voice_chain(args.voice, args.denoise_strength if args.denoise else None))
530
+ chain = voice_chain(args.voice, args.denoise_strength if args.denoise else None)
531
+ if limit_after_mix and chain.endswith("," + VOICE_LIMITER):
532
+ chain = chain[: -len("," + VOICE_LIMITER)]
533
+ post_limit.append(VOICE_LIMITER)
534
+ fx.append(chain)
491
535
  elif args.denoise:
492
536
  fx.append(f"afftdn=nf=-{args.denoise_strength:g}:tn=1")
493
537
  if args.gain:
@@ -497,7 +541,7 @@ def main() -> int:
497
541
  if args.compress:
498
542
  fx.append(dynamics_filter("acompressor", args))
499
543
  if args.limit:
500
- fx.append(dynamics_filter("alimiter", args))
544
+ (post_limit if limit_after_mix else fx).append(dynamics_filter("alimiter", args))
501
545
  if args.mono:
502
546
  # after --downmix the track is the stereo fold-down, whatever the source had
503
547
  in_ch = 2 if args.downmix else (in_channels or 2)
@@ -542,15 +586,28 @@ def main() -> int:
542
586
  m = f"{idx}:a:0"
543
587
  idx += 1
544
588
  mfx = [f"volume={args.music_volume:g}dB", f"atrim=0:{main_dur:.6f}" if main_dur else "anull"]
545
- if args.music_fade_out and end_dur:
546
- mfx.append(f"afade=t=out:st={max(0.0, end_dur - args.music_fade_out):.3f}:d={args.music_fade_out:g}")
589
+ # A142: a bed shorter than the output (and not looped) stops at its own end, so its fade
590
+ # ends there: timed on the output's end it started after the bed had stopped at full level
591
+ # ... and at the main track's end when the output runs past it (--length-samples): the
592
+ # atrim above has cut the bed there. All three are timestamps, which is what afade reads:
593
+ # a bed whose sound starts late in its file (2 to 5 s) keeps those, so its fade ends at 5,
594
+ # not at its 3 s length (that fade ran over the bed's first second)
595
+ ends = [t for t in (end_dur, main_dur) if t]
596
+ if args.music_fade_out and not args.music_loop and not dry_run_input_pending(args.music):
597
+ bed_meta = probe(args.music)
598
+ bed_stop = stream_end(bed_meta, "audio")
599
+ if bed_stop is not None and bed_stop - file_origin(bed_meta) > 0:
600
+ ends.append(bed_stop - file_origin(bed_meta))
601
+ bed_end = min(ends) if ends else None
602
+ if args.music_fade_out and bed_end:
603
+ # a fade longer than the bed is cut to it, so the bed still ends silent (a 5 s fade on
604
+ # a 2 s bed started at 0 and stopped at 60% gain)
605
+ m_fade = min(args.music_fade_out, bed_end)
606
+ mfx.append(f"afade=t=out:st={bed_end - m_fade:.3f}:d={m_fade:g}")
547
607
  graph.append(f"[{m}]{','.join(mfx)}[music]")
548
608
  if args.duck:
549
609
  graph.append("[main]asplit=2[mainA][sc]")
550
- graph.append(
551
- f"[music][sc]sidechaincompress=threshold={db_to_linear(args.duck_threshold):.6g}"
552
- f":ratio={max(2.0, args.duck_amount / 3):.1f}:attack={args.duck_attack:g}:release={args.duck_release:g}:makeup=1[ducked]"
553
- )
610
+ graph += duck_graph(args.duck_threshold, args.duck_amount, args.duck_attack, args.duck_release)
554
611
  graph.append("[mainA][ducked]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mix]")
555
612
  else:
556
613
  graph.append("[main][music]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mix]")
@@ -567,11 +624,12 @@ def main() -> int:
567
624
  graph.append(f"[{last}][effects]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mixfx]")
568
625
  last = "mixfx"
569
626
 
570
- post: List[str] = []
627
+ post: List[str] = list(post_limit)
571
628
  if args.fade_in:
572
629
  post.append(f"afade=t=in:st=0:d={args.fade_in:g}")
573
630
  if args.fade_out and end_dur:
574
- post.append(f"afade=t=out:st={max(0.0, end_dur - args.fade_out):.3f}:d={args.fade_out:g}")
631
+ fade_out = min(args.fade_out, end_dur) # cut to the output, so it still ends silent
632
+ post.append(f"afade=t=out:st={end_dur - fade_out:.3f}:d={fade_out:g}")
575
633
  # The audio track is conformed to the source duration whenever it is known: padded with
576
634
  # silence if the graph came out short, trimmed if long. A mixed track can come out a few
577
635
  # hundredths short (amix's dropout_transition, a looped bed's atrim boundary), and with
@@ -628,7 +686,7 @@ def main() -> int:
628
686
  audio_block["music_volume"] = args.music_volume
629
687
  audio_block["duck"] = ({"amount_db": args.duck_amount, "threshold_db": round(args.duck_threshold, 4),
630
688
  "threshold_linear": float(f"{db_to_linear(args.duck_threshold):.6g}"),
631
- "ratio": round(max(2.0, args.duck_amount / 3), 1),
689
+ "ratio": DUCK_GATE_RATIO,
632
690
  "attack_ms": args.duck_attack, "release_ms": args.duck_release}
633
691
  if args.duck else None)
634
692
  verification: List[Dict[str, Any]] = []
package/scripts/broll.py CHANGED
@@ -18,10 +18,12 @@ Examples:
18
18
  python3 broll.py talk.mp4 --insert demo.mp4 --at 30 --duration 8 --audio mix
19
19
  """
20
20
  import argparse
21
+ import math
21
22
  import sys
22
23
  from typing import Any, Dict, List
23
24
 
24
- from _common.decision import av_trim, conform_output_t, conform_to_length, frame_rate, grid_at_or_after, silence_filter, stream_starts
25
+ from _common.decision import (av_trim, conform_output_t, conform_to_length, frame_durations, frame_rate, frame_window,
26
+ grid_at_or_after, silence_filter, stream_starts, window_expr, windowed_overlay_x)
25
27
  from _common import STATE, add_common, aac_args, apply_common, cfr_args, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, overlay_format, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
26
28
  from _common.emit import note, record_fallback
27
29
  from _common.probe import display_size, square_pixels, square_pixels_note
@@ -77,6 +79,11 @@ def main() -> int:
77
79
  a_chain = (a_chain + "," if a_chain else "") + f"scale={ew}:{eh},setsar=1"
78
80
  w, h = ew, eh
79
81
  fps = meta_a["video"].get("fps") or 30.0
82
+ # the A-roll's frame grid, when it has one (frame_window): its exact rate and where it starts
83
+ grid = frame_window(0.0, None, meta_a) is not None
84
+ rate_a, v0a = frame_rate(meta_a), stream_starts(meta_a)[0]
85
+ fps_filter = frame_durations(meta_a, rate_a) # fps=30000/1001; a whole rate reads as it did (fps=25)
86
+ fps_filter = fps_filter[:-2] if fps_filter.endswith("/1") else fps_filter
80
87
  has_audio_a = bool(meta_a.get("audio"))
81
88
 
82
89
  def per(values: List[str], i: int, default: str) -> str:
@@ -105,7 +112,19 @@ def main() -> int:
105
112
  dur_b = input_length(meta_b)
106
113
  if dur_b and start_b + length > dur_b + 0.01 and not STATE.dry_run:
107
114
  die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
108
- cutaways.append({"path": path, "at": at, "length": length, "from": start_b, "has_audio": bool(meta_b.get("audio")), "meta": meta_b})
115
+ # the cutaway in whole frames: `first` is B's first frame at or after --from, `on` the
116
+ # A-roll's first frame at or after --at, and `span` runs to its first at or after the end
117
+ v0b, rate_b = stream_starts(meta_b)[0], frame_rate(meta_b)
118
+ first = max(v0b, grid_at_or_after(start_b, v0b, rate_b))
119
+ on = span = None
120
+ if grid:
121
+ on = grid_at_or_after(at, v0a, rate_a)
122
+ span = grid_at_or_after(at + length, v0a, rate_a) - on
123
+ if round(span * rate_a) < 1:
124
+ die(f"cutaway {i + 1}: no frame of the A-roll starts between {at:g}s and {at + length:g}s "
125
+ f"(its frames are {1 / rate_a:.4f}s apart): nothing would be shown")
126
+ cutaways.append({"path": path, "at": at, "length": length, "from": start_b, "has_audio": bool(meta_b.get("audio")), "meta": meta_b,
127
+ "first": first, "rate": rate_b, "on": on, "span": span})
109
128
  # overlap is a property of the windows in time, not of the order they were given in: compared
110
129
  # against the one given before it, a later window listed first was called overlapping (A179)
111
130
  order = sorted(range(len(cutaways)), key=lambda k: cutaways[k]["at"])
@@ -123,7 +142,7 @@ def main() -> int:
123
142
  cmd += ["-i", c["path"]]
124
143
 
125
144
  # Picture: each B window is trimmed, normalised to A's frame and fps, shifted to start at its
126
- # --at time, and overlaid on A; before its first frame and after its last (eof_action=pass)
145
+ # --at time, and overlaid on A; outside the window the overlay is placed off the picture and
127
146
  # A shows through unchanged, so A's timeline is never touched.
128
147
  parts: List[str] = []
129
148
  cur = "[0:v]"
@@ -144,9 +163,32 @@ def main() -> int:
144
163
  + drawn_chain(pad + args.pad_color, as_limited(c["meta"]), [args.pad_color], pad + "black", line_meta=meta_a,
145
164
  tag=f"pad{i}", labelled=True))
146
165
  b_sq = (square_pixels(c["meta"]["video"]) + ",") if square_pixels(c["meta"]["video"]) else ""
147
- parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
148
- f"{convert[i + 1]}{b_sq}{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
149
- parts.append(f"{cur}[b{i}]overlay=0:0{overlay_format(meta_a)}:eof_action=pass:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'[v{i}]")
166
+ pix = 'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'
167
+ if c["on"] is None:
168
+ # no grid to count the A-roll's frames on (a dry run's pending A-roll, a variable
169
+ # rate): the window as it was written before, to the millisecond
170
+ parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
171
+ f"{convert[i + 1]}{b_sq}{geo},setsar=1,fps={fps:g},format={pix}[b{i}]")
172
+ window = window_expr(c["at"], c["at"] + c["length"], None)
173
+ else:
174
+ # The window is whole frames of the A-roll: from its first frame at or after --at to
175
+ # the frame before its first at or after the end. B is cut half a frame of its own
176
+ # before its first frame at or after --from, for as long as the window lasts, and
177
+ # that frame is put on the tick of the `fps` grid at or before the window's first
178
+ # frame, at the A-roll's exact rate (30000/1001, not probe()'s 29.97: frame 37
179
+ # counted at 29.97 comes 1.2 microseconds after the A-roll's and was shown a frame on)
180
+ # The cut never ends before B's first frame: a window shorter than half a frame of B
181
+ # (one 60 fps frame of the A-roll under a 24 fps cutaway) cut that frame off too
182
+ half_b = 0.5 / c["rate"] if c["rate"] else 0.0
183
+ parts.append(f"[{i + 1}:v]trim=start={max(0.0, c['first'] - half_b):.6f}:end={max(c['first'] + c['span'], c['first'] + 2 * half_b) - half_b:.6f},"
184
+ f"setpts=PTS-STARTPTS+{math.floor(c['on'] * rate_a + 1e-6) / rate_a:.6f}/TB,"
185
+ f"{convert[i + 1]}{b_sq}{geo},setsar=1,{fps_filter},format={pix}[b{i}]")
186
+ window = window_expr(c["on"], c["on"] + c["span"], meta_a, half_open=True)
187
+ # The window is in the cutaway's position: overlay's enable is evaluated on B's frames
188
+ # too. B's last frame is held when B ends (eof_action=repeat) and the position takes it
189
+ # off the picture: with eof_action=pass the window's last frame depended on where B's
190
+ # end fell against it, and an A-roll whose picture starts 13 ms late lost that frame.
191
+ parts.append(f"{cur}[b{i}]overlay={windowed_overlay_x(0, window)}:0{overlay_format(meta_a)}:eof_action=repeat[v{i}]")
150
192
  cur = f"[v{i}]"
151
193
  vout = cur
152
194
 
@@ -158,24 +200,40 @@ def main() -> int:
158
200
  aout = "0:a:0" if has_audio_a else None
159
201
  else:
160
202
  layers: List[str] = []
203
+ # the cutaway's sound follows its picture: it starts with the window's first frame and
204
+ # lasts as long as the window, counted in samples of the mix (without a frame grid:
205
+ # --at and the length asked)
206
+ for c in cutaways:
207
+ c["s0"] = round((c["at"] if c["on"] is None else c["on"]) * MIX_RATE)
208
+ c["sn"] = round((c["length"] if c["on"] is None else c["span"]) * MIX_RATE)
161
209
  if has_audio_a:
162
- gate = "".join(f",volume=0:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'" for c in cutaways) if args.audio == "b" else ""
210
+ # `b` mutes the A-roll over exactly the samples B's sound takes, on the A-roll's
211
+ # own clock: aeval's `t` is each sample's time, and the bounds sit half a sample
212
+ # outside the first and the last. `volume=0:enable='between(t,...)'` is evaluated
213
+ # once per audio frame and muted 48128-96255 where 48000-95999 was asked
214
+ # (1024-sample frames; FFmpeg 6.1.1)
215
+ gate = ""
216
+ if args.audio == "b":
217
+ gate = ",aeval='val(ch)*not(" + "+".join(
218
+ f"between(t,{(c['s0'] - 0.5) / MIX_RATE:.9f},{(c['s0'] + c['sn'] - 0.5) / MIX_RATE:.9f})" for c in cutaways) + ")':c=same"
163
219
  parts.append(f"[0:a:0]aformat=sample_rates=48000:channel_layouts=stereo{gate}[a0]")
164
220
  layers.append("[a0]")
165
221
  for i, c in enumerate(cutaways):
166
222
  # B's sound is shifted by the same constant as its picture, whose first frame (the
167
- # first at or after --from) lands on --at; rebasing the sound to its own first
168
- # sample moved a track that starts 0.3 s late 0.279 s early (#409)
169
- v0b = stream_starts(c["meta"])[0]
170
- first = max(v0b, grid_at_or_after(c["from"], v0b, frame_rate(c["meta"])))
171
- # the delay is --at to the sample of the mix's rate: `int(at * 1000)` cut 1.001 to
223
+ # first at or after --from) lands on the window's first frame; rebasing the sound
224
+ # to its own first sample moved a track that starts 0.3 s late 0.279 s early (#409)
225
+ first, length = c["first"], c["length"] if c["on"] is None else c["span"]
226
+ # the delay is to the sample of the mix's rate: `int(at * 1000)` cut 1.001 to
172
227
  # 1000 ms and wrote 1.2346 s as 1234 ms, the cutaway's sound 48 and 29 samples
173
228
  # early. A whole millisecond keeps the text it had, rounded (silence_filter)
174
- delay = silence_filter(MIX_RATE, lead=c["at"])
229
+ lead = c["s0"] / MIX_RATE
230
+ delay = silence_filter(MIX_RATE, lead=lead)
175
231
  if "S:" not in delay:
176
- delay = f"adelay={round(c['at'] * 1000)}|{round(c['at'] * 1000)}"
177
- parts.append(f"[{i + 1}:a:0]{av_trim(first, first + c['length'])[1]},"
178
- f"aformat=sample_rates=48000:channel_layouts=stereo,{delay}[ab{i}]")
232
+ delay = f"adelay={round(lead * 1000)}|{round(lead * 1000)}"
233
+ # under `b` the layer is as many samples as the gate mutes, padded or cut
234
+ exact = f"apad=whole_len={c['sn']},atrim=end_sample={c['sn']}," if args.audio == "b" else ""
235
+ parts.append(f"[{i + 1}:a:0]{av_trim(first, first + length)[1]},"
236
+ f"aformat=sample_rates=48000:channel_layouts=stereo,{exact}{delay}[ab{i}]")
179
237
  layers.append(f"[ab{i}]")
180
238
  # the mix ends with the A-roll, to the sample (#493): every layer is 48 kHz, so the count
181
239
  # is in that rate. `atrim=duration={:.3f}` and `-t {:.3f}` cut it to the millisecond. A
package/scripts/check.py CHANGED
@@ -50,7 +50,7 @@ from typing import Any, Dict, List, Optional, Tuple
50
50
  from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, upload_limit_note, resolve as resolve_platform
51
51
  from _common.probe import (CHANGE_FLOOR, CHANGE_GRID, change_mask_tiles, change_tiles, LEGAL_RANGE_CHOICES, measure_signal_range, COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
52
52
  detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
53
- from _common.emit import file_identity
53
+ from _common.emit import file_identity, own_length
54
54
  from _common.probe import PROBED_AUDIO
55
55
  from _common.probe import display_size as shown_size, shorter_than_a_loudness_block
56
56
  from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
@@ -618,7 +618,7 @@ def main() -> int:
618
618
  row("true peak", "WARN", "could not measure", f"<= {spec['tp']:g} dBTP", "re-run check.py, or verify true peak manually",
619
619
  reason="ffmpeg's loudness measurement didn't produce a readable result -- this was not actually checked")
620
620
  elif (lm and lm["lufs"] == float("-inf") and lm["tp"] > SILENT_PEAK_DBFS
621
- and shorter_than_a_loudness_block(a.get("duration") or a.get("tagged_duration") or dur_read)):
621
+ and shorter_than_a_loudness_block(own_length(a) or dur_read)):
622
622
  # A257: shorter than one 400 ms gating block: a true peak but no integrated
623
623
  # loudness. Not silence, and not a -inf LUFS that FAILs the target. Decided on
624
624
  # the sound's length: a longer track whose every block is under the gate reads
package/scripts/cut.py CHANGED
@@ -56,7 +56,7 @@ import os
56
56
  import sys
57
57
  import tempfile
58
58
  import zlib
59
- from typing import List, Tuple
59
+ from typing import List, Optional, Tuple
60
60
 
61
61
  from _common.runner import ffmpeg_failure_extra, JsonTooDeep, fail_if_classified, read_json
62
62
  from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
@@ -81,12 +81,27 @@ def _t(value: str, fps, flag: str = "--segments") -> float:
81
81
  return time_arg(value, flag, fps)
82
82
 
83
83
 
84
- def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float, fps=None) -> List[Tuple[float, float]]:
84
+ def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float, fps=None,
85
+ picture: Optional[Tuple[float, float]] = None,
86
+ dropped: Optional[List[Tuple[float, float]]] = None) -> List[Tuple[float, float]]:
85
87
  """--remove: what is left of [0, total] once `removed` is dropped (merged where they overlap,
86
88
  clamped to the file). A leftover shorter than one frame (10 ms without a frame rate) is not a
87
- piece anyone asked to keep. Refuses a removal that would leave nothing."""
89
+ piece anyone asked to keep. With `picture` (where the video starts and ends, for a video
90
+ output), neither is a leftover that holds less than one frame of it: sound before the first
91
+ frame or after the last (a track that outlasts its picture) has no picture to cut, and was
92
+ kept and then refused as a segment the caller never gave (#462 A51). Such leftovers are added
93
+ to `dropped`. Refuses a removal that would leave nothing."""
88
94
  least = 1.0 / fps if fps else 0.01
89
95
  keep, cursor = [], 0.0
96
+
97
+ def add(a: float, b: float) -> None:
98
+ if b - a < least:
99
+ return
100
+ if picture is not None and min(b, picture[1]) - max(a, picture[0]) < least - 1e-6:
101
+ if dropped is not None:
102
+ dropped.append((a, b))
103
+ return
104
+ keep.append((a, b))
90
105
  outside = [(s, e) for s, e in removed if min(total, e) <= min(total, max(0.0, s))]
91
106
  if outside:
92
107
  die("--remove " + ", ".join(f"{s:g}-{e:g}" for s, e in outside) + f" lies outside the file (0-{total:.3f}s), "
@@ -94,12 +109,14 @@ def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float,
94
109
  for s, e in sorted((min(total, max(0.0, s)), min(total, e)) for s, e in removed):
95
110
  if e <= cursor:
96
111
  continue
97
- if s - cursor >= least:
98
- keep.append((cursor, s))
112
+ add(cursor, s)
99
113
  cursor = max(cursor, e)
100
- if total - cursor >= least:
101
- keep.append((cursor, total))
114
+ add(cursor, total)
102
115
  if not keep:
116
+ if dropped:
117
+ die(f"--remove leaves only sound with no picture ({', '.join(f'{a:.3f}-{b:.3f}s' for a, b in dropped)}, "
118
+ f"outside the video's {picture[0]:.3f}-{picture[1]:.3f}s); nothing with a picture would be left to keep. "
119
+ "Write the sound out with an audio -o (e.g. -o part.wav) instead", kind="input")
103
120
  die(f"--remove drops the whole file ({total:.3f}s); nothing would be left to keep")
104
121
  return keep
105
122
 
@@ -277,11 +294,12 @@ def source_skew(src: str, meta: dict, t: float) -> float:
277
294
 
278
295
  def video_end(src: str, meta: dict):
279
296
  """Where the video stream ends on the cut's clock (seconds from the file's start), or None when
280
- the stream's length is unknown."""
281
- v = meta.get("video") or {}
282
- if not v.get("duration"):
283
- return None
284
- return (v.get("start_time") or 0.0) + v["duration"] - file_origin(src)
297
+ the stream's length is unknown. A Matroska/WebM stream states no duration, only its DURATION
298
+ tag, which is already that end (own_end()); read from `duration` alone, a .mkv had no picture
299
+ end and --remove kept a sound-only leftover past it."""
300
+ from _common.emit import own_end
301
+ end = own_end(meta.get("video"))
302
+ return None if end is None else end - file_origin(src)
285
303
 
286
304
 
287
305
  _SEEK_SHIFTS: dict = {}
@@ -893,9 +911,12 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
893
911
  if is_audio_output(dst) or not meta.get("video"):
894
912
  # -ss before -i seeks, then decoding discards samples up to the exact start
895
913
  # (accurate_seek); atrim bounds the decoded stream to the requested length at sample
896
- # resolution.
914
+ # resolution. The input seek counts from the file's start, so a sound that starts after
915
+ # it (late against its picture) arrives at its offset from `start`; it is padded with
916
+ # silence from 0, as the --segments re-cut pads it (av_trim), not rebased to its own
917
+ # first sample, which moved it earlier and made the cut shorter than asked (#462 A46)
897
918
  cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"] + stream_maps(meta, dst, copy=False)
898
- cmd += ["-af", f"atrim=end={dur:.6f},asetpts=PTS-STARTPTS"]
919
+ cmd += ["-af", f"atrim=end={dur:.6f},aresample=async=1:first_pts=0"]
899
920
  else:
900
921
  # Seek SEEK_MARGIN early, then drop the margin on the output side, which decodes and
901
922
  # discards it from both streams alike. An input seek straight to `start` can land (the
@@ -1515,6 +1536,7 @@ def main() -> int:
1515
1536
  forced.append("codec")
1516
1537
 
1517
1538
  fps = (meta.get("video") or {}).get("fps")
1539
+ remove_notes: List[str] = []
1518
1540
  if args.remove:
1519
1541
  if args.segments or args.end or args.duration or args.start is not None:
1520
1542
  die("--remove names what to drop from the whole file: it does not combine with --segments, --start, --end or --duration")
@@ -1525,7 +1547,18 @@ def main() -> int:
1525
1547
  if not total:
1526
1548
  die(f"--remove needs the input's duration, which {args.input} does not report"
1527
1549
  + (" yet (it is an earlier step's output)" if dry_run_input_pending(args.input) else ""))
1528
- args.segments = ",".join(f"{s:.6f}-{e:.6f}" for s, e in keep_ranges_after_removing(parse_segments(args.remove, fps, clamp=True), total, fps))
1550
+ picture = None
1551
+ if (meta.get("video") or {}).get("fps") and not is_audio_output(args.output or "") and not dry_run_input_pending(args.input):
1552
+ vend = video_end(args.input, meta)
1553
+ if vend is not None:
1554
+ picture = ((meta["video"].get("start_time") or 0.0) - file_origin(args.input), vend)
1555
+ sound_only: List[Tuple[float, float]] = []
1556
+ args.segments = ",".join(f"{s:.6f}-{e:.6f}" for s, e in keep_ranges_after_removing(
1557
+ parse_segments(args.remove, fps, clamp=True), total, fps, picture, sound_only))
1558
+ for a, b in sound_only:
1559
+ remove_notes.append(f"--remove left {a:.3f}-{b:.3f}s, which holds sound but no frame of the picture "
1560
+ f"(the video runs {picture[0]:.3f}-{picture[1]:.3f}s); it is not kept")
1561
+ info(remove_notes[-1])
1529
1562
  elif args.segments and (args.end or args.duration or args.start is not None):
1530
1563
  # A50: the single-range flags were accepted beside --segments and not read, where --remove
1531
1564
  # refuses the same combination
@@ -1588,7 +1621,7 @@ def main() -> int:
1588
1621
  # clean copy). clip_length's rule, as join.py uses it: less than a frame past -> end the
1589
1622
  # segment with its picture; more -> hold the last frame for the sound, which only a re-cut
1590
1623
  # can do. The last segment is left as the source had it: nothing is placed after it.
1591
- join_notes: List[str] = []
1624
+ join_notes: List[str] = list(remove_notes)
1592
1625
  hold_needed = False
1593
1626
  vend = (video_end(args.input, meta) if len(segments) > 1 and video.get("fps") and not is_audio_output(output)
1594
1627
  and not dry_run_input_pending(args.input) else None)