ffmpeg-skill 3.8.12 → 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.
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/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)
package/scripts/export.py CHANGED
@@ -52,7 +52,7 @@ from _common.decision import hw_preset_video, restate_last_swap
52
52
  from _common.emit import note, record_fallback, record_skip, warn
53
53
  from _common.probe import PROBED_AUDIO, PROBED_VIDEO, probed_maps, square_pixels, square_pixels_note
54
54
  from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, run_analysis as _run_analysis, subtitle_codec_for
55
- from _common.emit import file_identity, input_length
55
+ from _common.emit import file_identity, input_length, own_length
56
56
  from check import SPECS as PLATFORMS, measure_loudness
57
57
  from _common.probe import SILENT_PEAK_DBFS, shorter_than_a_loudness_block
58
58
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, UPLOAD_LIMITS_AS_OF, resolve as resolve_platform
@@ -254,7 +254,7 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
254
254
  pair = {k: [fv.get(k), kv.get(k)] for k in ("width", "height", "nb_frames", "r_frame_rate")}
255
255
  # the video streams' lengths: the fill's container also counts its sound, which may run longer
256
256
  # than the picture, and the key has none
257
- pair["duration"] = [round(v.get("duration") or v.get("tagged_duration") or 0, 3) for v in (fv, kv)]
257
+ pair["duration"] = [round(own_length(v) or 0, 3) for v in (fv, kv)]
258
258
  same = all(a == b and a not in (None, 0) for a, b in pair.values())
259
259
  # the two written files against each other: no expected value, so no provenance (#403: it said
260
260
  # "output_measurement", which is not one of the contract's values)
@@ -741,7 +741,7 @@ def main() -> int:
741
741
  m = measure_loudness(output)
742
742
  written_audio = probe(output, role="output").get("audio") or {}
743
743
  if (m and not math.isfinite(m["lufs"]) and math.isfinite(m["tp"]) and m["tp"] > SILENT_PEAK_DBFS
744
- and shorter_than_a_loudness_block(written_audio.get("duration") or written_audio.get("tagged_duration"))):
744
+ and shorter_than_a_loudness_block(own_length(written_audio))):
745
745
  # A257: too short for a gating block is not silent (it has a measured peak); a longer
746
746
  # track under the gate is not "too short" and takes the path it took before
747
747
  _loudness_unmeasured(extra, notes, platform, spec, tp=m["tp"])
package/scripts/loop.py CHANGED
@@ -36,7 +36,7 @@ from _common import STATE, concat_list_line, add_common, aac_args, apply_common,
36
36
  from _common.emit import warn
37
37
  from _common.decision import HELD_FRAMES_WARN_BYTES, av_trim, held_frames_bytes, length_output_t, stream_starts
38
38
  from _common.probe import decode_gray_frames, decode_pcm_mono
39
- from _common.emit import input_length
39
+ from _common.emit import input_length, own_length
40
40
 
41
41
 
42
42
  # The loop filter's size is capped at 32767 frames, so one boomerang cycle (2N-2 frames) must fit.
@@ -131,7 +131,7 @@ def _conformed_frames(v, rate: float, src_dur: float) -> int:
131
131
  its length are counted in these, or a repeat is a frame or more off the length planned."""
132
132
  # the video stream's length, else Matroska's tag, else the container's (main() refuses a source
133
133
  # without one): never the stored frame count, which is what fps= does not keep
134
- d = v.get("duration") or v.get("tagged_duration") or src_dur
134
+ d = own_length(v) or src_dur
135
135
  return int(round(float(d) * rate))
136
136
 
137
137
 
@@ -24,8 +24,9 @@ from _platforms import TP_TOLERANCE_DB
24
24
  AUDIO_LENGTH_TOLERANCE = 0.03 # seconds: one AAC frame at 48 kHz is 0.021 s
25
25
  LUFS_TOLERANCE = 1.0 # LU: a normalised file within this of the requested -I is on target
26
26
  from _common.probe import PROBED_AUDIO, probed_maps, shorter_than_a_loudness_block
27
- from _common.emit import refuse
27
+ from _common.emit import own_length, refuse
28
28
  from _common.runner import ffmpeg_failure_extra
29
+ from _common.decision import LOSSLESS_AUDIO_EXT
29
30
  from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
30
31
 
31
32
 
@@ -121,7 +122,12 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
121
122
  sr = args.sample_rate or meta["audio"].get("sample_rate") or 48000
122
123
  ext = os.path.splitext(output)[1].lower()
123
124
  bitrate_pinned = args.audio_bitrate is not None
124
- bitrate = args.audio_bitrate or "192k"
125
+ # an AAC audio extension starts at its own rate (256k for .m4a/.aac), which is what it was
126
+ # written at: a pinned --audio-bitrate and the raised retries below never reached it, so the
127
+ # retries re-encoded at 256k and audio_bitrate_used said 192k, then 256k and 320k (A143)
128
+ codec = AUDIO_CODECS.get(ext, [])
129
+ own_rate = codec[codec.index("-b:a") + 1] if "aac" in codec and "-b:a" in codec else None
130
+ bitrate = args.audio_bitrate or own_rate or "192k"
125
131
 
126
132
  dropped_streams = False
127
133
  # loudnorm emits its last block whole, and through the AAC encoder that came out as 0.1 s more
@@ -142,7 +148,9 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
142
148
  af += f",atrim=duration={float(in_audio):.6f}"
143
149
  cmd = ffmpeg_base() + ["-i", args.input, "-af", af, "-ar", str(sr)]
144
150
  if ext in AUDIO_CODECS or not meta.get("video"):
145
- cmd += ["-map", f"0:{PROBED_AUDIO}", "-vn"] + audio_codec_for(output, bitrate)
151
+ # the rate reaches an audio extension's codec when it was pinned or is AAC's (an
152
+ # .mp3/.ogg/.opus left alone keeps its own setting, as it always has)
153
+ cmd += ["-map", f"0:{PROBED_AUDIO}", "-vn"] + audio_codec_for(output, bitrate if ext not in AUDIO_CODECS or bitrate_pinned or own_rate else None)
146
154
  else:
147
155
  cmd += probed_maps(meta) + ["-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
148
156
  if ext in (".mp4", ".mov", ".m4v"):
@@ -185,7 +193,8 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
185
193
  info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {lufs} LUFS, TP <= {tp_max:g})")
186
194
  result = {k: after[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")}
187
195
  result["tp_ceiling_used"] = round(ceiling, 2)
188
- if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext]:
196
+ if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext] or bitrate_pinned:
197
+ # a pinned rate reaches an .mp3/.ogg/.opus codec too (A143); unpinned, those keep their own
189
198
  result["audio_bitrate_used"] = bitrate
190
199
  result["encodes"] = rounds + 1
191
200
  # the mode of the last encode: its ceiling is the one the file was made with
@@ -277,7 +286,8 @@ def main() -> int:
277
286
  ap.add_argument("--tp", type=float, default=-1.0, help="true peak ceiling in dBTP (default -1)")
278
287
  ap.add_argument("--lra", type=float, default=11.0, help="loudness range target in LU (default 11)")
279
288
  ap.add_argument("--measure-only", action="store_true", help="print the measured stats as JSON and exit")
280
- ap.add_argument("--audio-bitrate", default=None, help="AAC bitrate when the container is video (default 192k; raised to 256k/320k only when the encoder overshoots the true-peak ceiling and you did not pin it)")
289
+ ap.add_argument("--audio-bitrate", default=None, help="audio bitrate: AAC's in a video container (default 192k), and the codec's of a lossy audio output (.m4a/.aac default 256k, .mp3/.ogg/.opus their own setting); "
290
+ "refused for .wav/.flac. Raised to 256k/320k for AAC only when the encoder overshoots the true-peak ceiling and you did not pin it")
281
291
  ap.add_argument("--versions", metavar="LIST",
282
292
  help="write one normalised file per target from this master, each measured after: a comma list of "
283
293
  + ", ".join(f"{n} ({i:g} LUFS / {t:g} dBTP)" for n, (i, t) in VERSION_TARGETS.items())
@@ -303,6 +313,10 @@ def main() -> int:
303
313
  meta = probe(args.input)
304
314
  if not meta.get("audio"):
305
315
  die("input has no audio stream")
316
+ out_ext = os.path.splitext(args.output or default_output(args.input, "loudnorm"))[1].lower()
317
+ if args.audio_bitrate is not None and not args.measure_only and out_ext in LOSSLESS_AUDIO_EXT:
318
+ # A143: the rate was dropped without a word; a lossless codec has none to set
319
+ die(f"--audio-bitrate has no effect on a lossless {out_ext} output; drop it, or write a lossy one (.m4a, .mp3, ...)")
306
320
  if plan is not None and not args.measure_only:
307
321
  # each version measures the master against its own target (loudnorm's offset depends on
308
322
  # it): a pass against -I/--tp first would be one more full decode whose result is unused
@@ -349,8 +363,7 @@ def audio_length(meta: dict) -> "Optional[float]":
349
363
  """The sound's own length: the stream's duration, else Matroska's DURATION tag. Never the
350
364
  container's, which is the picture's when the picture runs longer (and would fail a correct
351
365
  output against an expected length the sound never had)."""
352
- audio = meta.get("audio") or {}
353
- return audio.get("duration") or audio.get("tagged_duration")
366
+ return own_length(meta.get("audio"))
354
367
 
355
368
 
356
369
  def _kbps(value: str) -> int:
@@ -30,7 +30,7 @@ import json
30
30
  import math
31
31
  import os
32
32
  import sys
33
- from typing import Dict, List, Tuple
33
+ from typing import Dict, List, Optional, Tuple
34
34
 
35
35
  from _common import guard_sidecar, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
36
36
  from _common.emit import note, record_fallback, record_skip, warn
@@ -60,8 +60,19 @@ def parse_switch(spec: str, n: int) -> List[Tuple[float, float, int]]:
60
60
  die(f"camera {c} does not exist (inputs are 0..{n - 1})")
61
61
  if e <= s:
62
62
  die(f"switch entry '{raw}': end must be after start")
63
+ if round(e, 3) <= round(s, 3):
64
+ # the cut list (--edl, `cuts`) is written to the millisecond: this range was its
65
+ # "0.000-0.000", a line cut.py --segments refuses (the scenes.py --edl sibling)
66
+ die(f"switch entry '{raw}': shorter than a millisecond, the precision the cut list is written in",
67
+ kind="input")
63
68
  out.append((s, e, c))
64
69
  out.sort()
70
+ # A132: ranges on one timeline cannot overlap. The fill below kept both, so the picture of the
71
+ # overlap played twice while the sound played once, and every later cut ran behind it
72
+ for (s0, e0, _c0), (s1, e1, _c1) in zip(out, out[1:]):
73
+ if s1 < e0:
74
+ die(f"--switch ranges overlap: {fmt_secs(s0)}-{fmt_secs(e0)} and {fmt_secs(s1)}-{fmt_secs(e1)} "
75
+ "(on the reference timeline each moment has one camera)", kind="input")
65
76
  return out
66
77
 
67
78
 
@@ -158,7 +169,7 @@ def main() -> int:
158
169
  media_out = args.output or default_output(args.inputs[0], "multicam", "mp4")
159
170
  guard_sidecar(args.edl, *args.inputs, siblings=(args.write_project, media_out))
160
171
  guard_sidecar(args.write_project, *args.inputs, siblings=(media_out,))
161
- if args.edl or args.write_project: # they are written before the cut: refuse its output first
172
+ if args.edl or args.write_project: # refused up front with them, before any decode
162
173
  guard_sidecar(media_out, *args.inputs)
163
174
  if args.analyze_seconds > 900:
164
175
  die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
@@ -272,17 +283,26 @@ def main() -> int:
272
283
  cursor = max(cursor, e)
273
284
  if cursor < ref_dur:
274
285
  filled.append((cursor, ref_dur, 0))
286
+ # a gap or a clipped range under a millisecond is nothing at the precision the cut list is
287
+ # written in (--switch "0.0004-3:1" left a leading "0.000-0.000", the line cut.py --segments
288
+ # refuses, and a 0.4 ms trim in the render): its neighbour takes it, so the timeline stays whole
289
+ whole: List[Tuple[float, float, int]] = []
290
+ carry: Optional[float] = None
291
+ for s, e, c in filled:
292
+ if carry is not None:
293
+ s, carry = carry, None
294
+ if round(e, 3) <= round(s, 3):
295
+ if whole:
296
+ whole[-1] = (whole[-1][0], e, whole[-1][2])
297
+ else:
298
+ carry = s
299
+ continue
300
+ whole.append((s, e, c))
301
+ filled = whole
275
302
  for s, e, c in filled:
276
303
  if not metas[c].get("video"):
277
304
  die(f"camera {c} ({args.inputs[c]}) has no video; it can only be used with --audio")
278
305
 
279
- if args.edl:
280
- if not STATE.dry_run:
281
- with open(args.edl, "w", encoding="utf-8") as fh:
282
- for s, e, _c in filled:
283
- fh.write(f"{s:.3f}-{e:.3f}\n")
284
- info(f"wrote {args.edl}")
285
-
286
306
  v0 = metas[0]["video"]
287
307
  # camera 0's displayed frame: rotation applied, non-square pixels squared (#411)
288
308
  w, h = args.width or display_size(v0)[0], args.height or display_size(v0)[1]
@@ -322,9 +342,18 @@ def main() -> int:
322
342
  c, src_s, src_e = 0, s, e
323
343
  rendered.append((s, e, c))
324
344
  sq = (square_pixels(metas[c]["video"]) + ",") if square_pixels(metas[c]["video"]) else ""
325
- parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{convert[c]}{sq}{geo}[v{i}]")
345
+ # A130: a drift-corrected camera's range is (e - s) * ratio of its own time; played at that
346
+ # rate it lasts e - s on the reference, as its sound does (asetrate) -- at its own rate the
347
+ # picture ran (ratio - 1) * (e - s) long and every later cut fell behind the sound
348
+ retime = f"(PTS-STARTPTS)/{ratios[c]:.9f}" if abs(ratios[c] - 1.0) > 1e-7 else "PTS-STARTPTS"
349
+ parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts={retime},{convert[c]}{sq}{geo}[v{i}]")
326
350
  labels.append(f"[v{i}]")
327
- project_clips.append({"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)})
351
+ clip = {"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)}
352
+ if abs(ratios[c] - 1.0) > 1e-7:
353
+ # render.py plays a clip at clips[].speed (its length / speed), as the retime above
354
+ # does: without it a drift-corrected camera's clip ran (ratio - 1) * (e - s) long
355
+ clip["speed"] = round(ratios[c], 9)
356
+ project_clips.append(clip)
328
357
  parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
329
358
  a = args.audio
330
359
  a_start = -offsets[a] if offsets[a] < 0 else 0.0
@@ -358,17 +387,29 @@ def main() -> int:
358
387
  # render.py project instead of the direct render this tool already does -- additive, not
359
388
  # a replacement: the combined output below is still written as usual.
360
389
  project = {"output": default_output(args.inputs[0], "multicam_project", "mp4"),
361
- "clips": project_clips}
390
+ "clips": project_clips,
391
+ # a switch is a cut: render.py's default is a 0.5 s crossfade at every join,
392
+ # which shortens the timeline by 0.5 s a switch and moves every later one
393
+ "transition": {"type": "none"}}
362
394
  # the inputs are named as given, relative to the working directory, and render.py reads a
363
395
  # project's relative paths against the project file's directory: written elsewhere
364
396
  # (--write-project out/cut.json), they named files that are not there
365
397
  project = rebase_project_paths(project, os.getcwd())
398
+
399
+ run(cmd)
400
+ # the EDL and the project record the cut: written once it exists, not before an encode that then
401
+ # failed (#462 A41, the silence.py --edl sibling)
402
+ if args.edl:
403
+ if not STATE.dry_run:
404
+ with open(args.edl, "w", encoding="utf-8") as fh:
405
+ for s, e, _c in filled:
406
+ fh.write(f"{s:.3f}-{e:.3f}\n")
407
+ info(f"wrote {args.edl}")
408
+ if args.write_project:
366
409
  if not STATE.dry_run:
367
410
  with open(args.write_project, "w", encoding="utf-8") as fh:
368
411
  fh.write(json.dumps(project, indent=2) + "\n")
369
412
  info(f"wrote {args.write_project}")
370
-
371
- run(cmd)
372
413
  r = probe(output, role="output")
373
414
  info(f"wrote {output} ({fmt_secs(r['duration'])}, {len(filled)} cuts, audio from input {a})")
374
415
  extra = {"switch_mode": "energy", "min_shot": args.min_shot} if energy_mode else {}