ffmpeg-skill 3.8.12 → 3.8.14

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 (44) hide show
  1. package/README.md +2 -1
  2. package/SKILL.md +1 -1
  3. package/docs/contract.md +11 -5
  4. package/mcp/server.py +0 -1
  5. package/package.json +5 -2
  6. package/references/flags.md +2 -2
  7. package/references/scripts.md +72 -27
  8. package/scripts/_common/asr.py +1 -1
  9. package/scripts/_common/color.py +7 -2
  10. package/scripts/_common/decision.py +40 -8
  11. package/scripts/_common/emit.py +38 -10
  12. package/scripts/_common/probe.py +34 -6
  13. package/scripts/_common/runner.py +76 -7
  14. package/scripts/_contract.py +24 -10
  15. package/scripts/_platforms.py +0 -5
  16. package/scripts/_smartcut.py +23 -5
  17. package/scripts/audio.py +86 -28
  18. package/scripts/background.py +1 -1
  19. package/scripts/caption.py +2 -3
  20. package/scripts/check.py +3 -3
  21. package/scripts/color.py +7 -1
  22. package/scripts/crop.py +3 -2
  23. package/scripts/cropdetect.py +1 -1
  24. package/scripts/cut.py +50 -17
  25. package/scripts/export.py +39 -6
  26. package/scripts/fit.py +4 -3
  27. package/scripts/freeze.py +1 -1
  28. package/scripts/graphics.py +2 -3
  29. package/scripts/insert.py +1 -1
  30. package/scripts/look.py +9 -4
  31. package/scripts/loop.py +3 -3
  32. package/scripts/loudness.py +20 -12
  33. package/scripts/multicam.py +56 -15
  34. package/scripts/overlay.py +1 -1
  35. package/scripts/probe.py +13 -2
  36. package/scripts/proxy.py +11 -2
  37. package/scripts/redact.py +5 -1
  38. package/scripts/scenes.py +82 -21
  39. package/scripts/silence.py +62 -16
  40. package/scripts/sphere.py +23 -3
  41. package/scripts/stabilize.py +5 -2
  42. package/scripts/sync.py +11 -6
  43. package/scripts/verify.py +82 -20
  44. package/scripts/waveform.py +12 -2
@@ -10,6 +10,8 @@ import math
10
10
  import os
11
11
  import re
12
12
  import statistics
13
+ import sys
14
+ from array import array
13
15
  from fractions import Fraction
14
16
  from typing import Any, Dict, List, Optional, Sequence, Tuple
15
17
  from _common.emit import die, warn
@@ -43,11 +45,16 @@ def fingerprint(path: str) -> Dict[str, Any]:
43
45
 
44
46
 
45
47
  def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None, start: float = 0.0,
46
- *, check: bool = True) -> List[float]:
48
+ *, check: bool = True) -> "Sequence[float]":
47
49
  """Decode (part of) a file's audio to mono float samples in [-1, 1) at `sample_rate` via a
48
50
  single ffmpeg pass under --timeout. Shared by scenes.py (audio envelope for cut scoring) and
49
51
  sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else [].
50
- The samples are on the file's clock: a sound that starts late is padded at its head."""
52
+ The samples are on the file's clock: a sound that starts late is padded at its head.
53
+
54
+ The samples are an array('f'), 4 bytes each: a list of Python floats took about 32 (24 for the
55
+ float, 8 for the slot), so scenes.py held ~2.3 GB per hour of audio at 8 kHz + 22.05 kHz and
56
+ multicam --switch energy one such list per camera (A140). An s16 sample divided by 32768 is
57
+ exact in a float32, so every value read back is the double the list held."""
51
58
  ffmpeg = require_tool("ffmpeg")
52
59
  cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
53
60
  if start:
@@ -65,9 +72,13 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
65
72
  if check:
66
73
  die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr.decode(errors="replace")))
67
74
  return []
68
- n = len(proc.stdout) // 2
69
- import struct
70
- return [v / 32768.0 for v in struct.unpack(f"<{n}h", proc.stdout[: n * 2])]
75
+ data, proc = proc.stdout, None
76
+ pcm = array("h")
77
+ pcm.frombytes(memoryview(data)[: len(data) // 2 * 2])
78
+ del data
79
+ if sys.byteorder == "big":
80
+ pcm.byteswap() # s16le on the pipe
81
+ return array("f", (v / 32768.0 for v in pcm))
71
82
 
72
83
 
73
84
  def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool = False, remove_mean: bool = False) -> List[float]:
@@ -203,6 +214,23 @@ def shorter_than_a_loudness_block(length: "Optional[float]") -> bool:
203
214
  return length is not None and 0 < length < LOUDNESS_BLOCK_SECONDS
204
215
 
205
216
 
217
+ def riff_wave_args(path: str) -> List[str]:
218
+ """`-f wav` for a file that opens with a RIFF/WAVE header. FFmpeg 9.0.2 scores its MPEG-TS
219
+ probe above the WAV one when the PCM happens to hold 0x47 at a transport packet's spacing (a
220
+ steady tone can): ffprobe then reads a file this skill has just written as a broken transport
221
+ stream and fails with "End of file". The header says what the file is, so the probe is told.
222
+ Measured on 9.0.2 (CI): the same file fails unforced and reads with `-f wav`; 6.1 to 8.0 read
223
+ it either way."""
224
+ if not os.path.isfile(path): # a pipe or a device is never opened here: the open would block
225
+ return []
226
+ try:
227
+ with open(path, "rb") as fh:
228
+ head = fh.read(12)
229
+ except OSError:
230
+ return []
231
+ return ["-f", "wav"] if head[:4] == b"RIFF" and head[8:12] == b"WAVE" else []
232
+
233
+
206
234
  def probe(path: str, role: str = "input") -> Dict[str, Any]:
207
235
  """Return a compact, script-friendly description of a media file.
208
236
 
@@ -229,7 +257,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
229
257
  die(f"input not found: {path}")
230
258
  ffprobe = require_tool("ffprobe")
231
259
  proc = run(
232
- [ffprobe, "-v", "error", *still_input_args(path),
260
+ [ffprobe, "-v", "error", *still_input_args(path), *riff_wave_args(path),
233
261
  "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path],
234
262
  quiet=True,
235
263
  check=False,
@@ -511,6 +511,11 @@ def apply_common(args: "argparse.Namespace") -> None:
511
511
  # --json-brief is a shorter --json, not a second output mode: it implies it, so a caller that
512
512
  # passes only --json-brief still gets a JSON document (and --json --json-brief is the brief one).
513
513
  STATE.json = bool(getattr(args, "json", False)) or STATE.json_brief
514
+ if hasattr(args, "json"):
515
+ # and a tool that branches on args.json reads the same mode: through 3.8.13, probe --field,
516
+ # check, cropdetect, silence --list, report and verify printed text (or a bare document,
517
+ # or a document and then a path) under --json-brief alone (review of #521)
518
+ args.json = STATE.json
514
519
  STATE.progress = bool(getattr(args, "progress", False))
515
520
  STATE.fast = bool(getattr(args, "fast", False))
516
521
  STATE.overwrite = bool(getattr(args, "overwrite", False))
@@ -991,7 +996,7 @@ def _refuse_output_is_input(output: str, inp: str, real: str, why: str) -> None:
991
996
  **details)
992
997
 
993
998
 
994
- def refuse_output_is_input(output: str, *inputs: str) -> None:
999
+ def refuse_output_is_input(output: str, *inputs: str, why: str = "the result would replace the source") -> None:
995
1000
  """Tool-level twin of the run() guard, for tools whose final ffmpeg command does not name
996
1001
  the user's input at all. `cut.py --segments` cuts each part into a temp dir and then concats
997
1002
  a list file: the last command's only `-i` is that list, so `-o` equal to the input sailed
@@ -1009,7 +1014,7 @@ def refuse_output_is_input(output: str, *inputs: str) -> None:
1009
1014
  except OSError:
1010
1015
  continue
1011
1016
  if same:
1012
- _refuse_output_is_input(output, inp, out_real, "the result would replace the source")
1017
+ _refuse_output_is_input(output, inp, out_real, why)
1013
1018
 
1014
1019
 
1015
1020
  def _check_output_path(cmd: Sequence[str]) -> None:
@@ -1040,6 +1045,24 @@ def _check_output_path(cmd: Sequence[str]) -> None:
1040
1045
  refuse("input", "output_dir_not_writable", f"output directory {parent!r} is not writable", output=output, directory=parent)
1041
1046
 
1042
1047
 
1048
+ def _check_output_extension(cmd: Sequence[str]) -> None:
1049
+ """An ffmpeg output with no file extension and no `-f` after its last input leaves the muxer
1050
+ nothing to choose a container by: refused as `kind: input` before anything runs, dry runs
1051
+ included. Through 3.8.8 every tool that writes media handed it on and failed as `kind: ffmpeg`
1052
+ ("Unable to choose an output format", audit A159's sweep). Only the ffmpeg commands run()
1053
+ executes are checked: a sidecar (a report, an SRT) is a plain file and needs no extension,
1054
+ and look.py takes an -o without an image extension as a stem (docs/design-decisions.md)."""
1055
+ output = cmd[-1]
1056
+ if output == "-" or output.startswith("pipe:") or len(os.path.splitext(output)[1]) > 1:
1057
+ return
1058
+ last_input = max((i for i, tok in enumerate(cmd[:-1]) if tok == "-i"), default=-1)
1059
+ if "-f" in cmd[last_input + 2:-1]:
1060
+ return
1061
+ refuse("input", "output_extension_missing",
1062
+ f"output {output!r} has no file extension, so ffmpeg cannot tell which container to write; "
1063
+ f"name it with one (for example {output + '.mp4'!r})", output=output)
1064
+
1065
+
1043
1066
  # What the WebM muxer takes (6.1: "Only VP8 or VP9 or AV1 video and Vorbis or Opus audio and
1044
1067
  # WebVTT subtitles are supported for WebM"), by the encoder a command names (#488).
1045
1068
  _WEBM_VIDEO_ENCODER_RE = re.compile(r"^(?:libvpx(?:-vp9)?|vp8|vp9|libaom-av1|libsvtav1|librav1e|av1|vp8_\w+|vp9_\w+|av1_\w+)$")
@@ -2017,6 +2040,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
2017
2040
  _refuse_url_media(cmd)
2018
2041
  _check_no_overwrite_input(cmd)
2019
2042
  _check_output_path(cmd)
2043
+ _check_output_extension(cmd)
2020
2044
  _check_container_codecs(cmd)
2021
2045
  _check_build_components(cmd)
2022
2046
  _check_existing_output(cmd)
@@ -2109,11 +2133,56 @@ def _set_refusal_note(ctx: "Context", reason: str, outcome: str) -> None:
2109
2133
  ctx.hw_notes.append(reason + outcome)
2110
2134
 
2111
2135
 
2136
+ # A filter graph longer than this is handed to ffmpeg in a file (A138). Linux refuses any single
2137
+ # argument over 128 KiB (MAX_ARG_STRLEN) and Windows a whole command line over 32767 characters, and
2138
+ # a graph grows with the items it is built from: scenes.py --sheet's select expression is ~56
2139
+ # characters a scene, so ~2,340 scenes raised OSError E2BIG before ffmpeg ran, with a traceback and
2140
+ # no JSON. A graph under the limit is passed as it always was.
2141
+ LONG_FILTER_CHARS = 8000 if os.name == "nt" else 100_000
2142
+ _FILTER_OPTION_RE = re.compile(r"^-(?:filter_complex|lavfi|vf|af|filter(?::[^\s]+)?)$")
2143
+
2144
+
2145
+ def _filter_file_option(option: str) -> str:
2146
+ """The option that reads `option`'s value from a file: `-/OPTION` from FFmpeg 7.0, which
2147
+ deprecated the `_script` spellings, and `-filter_complex_script` / `-filter_script[:spec]`
2148
+ before it (6.x, the oldest CI runs)."""
2149
+ complex_graph = option in ("-filter_complex", "-lavfi")
2150
+ spec = {"-vf": ":v", "-af": ":a"}.get(option, option[len("-filter"):] if option.startswith("-filter:") else "")
2151
+ if ffmpeg_version() >= (7, 0):
2152
+ return "-/filter_complex" if complex_graph else "-/filter" + spec
2153
+ return "-filter_complex_script" if complex_graph else "-filter_script" + spec
2154
+
2155
+
2156
+ @contextlib.contextmanager
2157
+ def long_filters_in_files(cmd: Sequence[str]) -> "Any":
2158
+ """`cmd` with every filter graph longer than LONG_FILTER_CHARS moved into a temp file read by
2159
+ ffmpeg's file form of the same option; the files are removed when the block ends. The command a
2160
+ run records and prints is the caller's own, with the graph inline."""
2161
+ import tempfile
2162
+ out = list(cmd)
2163
+ made: List[str] = []
2164
+ try:
2165
+ if _is_ffmpeg(out):
2166
+ for i in range(1, len(out) - 1):
2167
+ if _FILTER_OPTION_RE.match(out[i]) and len(out[i + 1]) > LONG_FILTER_CHARS:
2168
+ fd, path = tempfile.mkstemp(prefix="ffmpeg-skill-graph-", suffix=".txt")
2169
+ made.append(path)
2170
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
2171
+ fh.write(out[i + 1])
2172
+ out[i], out[i + 1] = _filter_file_option(out[i]), path
2173
+ yield out
2174
+ finally:
2175
+ for path in made:
2176
+ with contextlib.suppress(OSError):
2177
+ os.remove(path)
2178
+
2179
+
2112
2180
  def _execute(exec_cmd: List[str]) -> subprocess.CompletedProcess:
2113
2181
  """One attempt, never exiting on failure (run() decides after its retries)."""
2114
- if STATE.progress and _is_ffmpeg(exec_cmd) and exec_cmd[-1] != "-":
2115
- return _run_with_progress(exec_cmd, False)
2116
- return _run_captured(exec_cmd, False)
2182
+ with long_filters_in_files(exec_cmd) as cmd:
2183
+ if STATE.progress and _is_ffmpeg(cmd) and cmd[-1] != "-":
2184
+ return _run_with_progress(cmd, False)
2185
+ return _run_captured(cmd, False)
2117
2186
 
2118
2187
 
2119
2188
  def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, record: bool = False) -> subprocess.CompletedProcess:
@@ -2134,7 +2203,8 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, r
2134
2203
  # input filename on stderr, and loudness/check parse the loudnorm JSON out of it. The
2135
2204
  # encoding kwargs are rejected with text=False, so they are only passed for text mode.
2136
2205
  text_kw = {"encoding": "utf-8", "errors": "replace"} if text else {}
2137
- proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, **text_kw, timeout=limit)
2206
+ with long_filters_in_files(cmd) as exec_cmd:
2207
+ proc = subprocess.run(exec_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, **text_kw, timeout=limit)
2138
2208
  except subprocess.TimeoutExpired:
2139
2209
  _timed_out(cmd, limit or 0)
2140
2210
  if check and proc.returncode != 0:
@@ -2569,7 +2639,6 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
2569
2639
  with os.replace() under the lock instead of copied -- a checked `cut.py --segments` join was
2570
2640
  copied whole, about 2 s for 240 MB. A rename that fails (another filesystem) falls back to
2571
2641
  the copy."""
2572
- import shutil
2573
2642
  cmd = ["ffmpeg", dst]
2574
2643
  _check_output_path(cmd)
2575
2644
  _check_existing_output(cmd)
@@ -3,6 +3,7 @@
3
3
 
4
4
  python3 scripts/_contract.py --json # the contract, with detected capabilities
5
5
  python3 scripts/_contract.py --json --static # same, without environment detection
6
+ python3 scripts/_contract.py --json --tool cut # only cut under `tools` (repeatable)
6
7
  python3 scripts/_contract.py doctor [--json] # which required capabilities this machine has
7
8
  ffmpeg-skill contract --json # the same through the npm entry point
8
9
 
@@ -21,7 +22,6 @@ import argparse
21
22
  import functools
22
23
  import importlib.util
23
24
  import json
24
- import os
25
25
  import platform
26
26
  import re
27
27
  import shutil
@@ -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"},
@@ -442,6 +442,7 @@ ERROR_REASONS = {
442
442
  "output_looks_like_option": "the output starts with '-' (other than '-' for stdout), which ffmpeg would read as an option; ./name writes a file of that name; details: {output}",
443
443
  "media_path_is_url": "an input or output has a protocol prefix (tcp:, http://, file:, ...) that ffmpeg would open as a URL; the skill reads and writes local files only; details: {path, scheme}",
444
444
  "output_dir_missing": "the output's directory does not exist; details: {output, directory}",
445
+ "output_extension_missing": "a media output with no file extension (and no -f naming its format), so ffmpeg could not choose a container; refused before ffmpeg runs, dry runs included; details: {output}",
445
446
  "output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
446
447
  "output_container_codecs": "a .webm output whose command encodes video or sound WebM cannot hold (H.264/HEVC, AAC; WebM takes VP8/VP9/AV1 and Opus/Vorbis), refused before ffmpeg runs; details: {output, container, encoders}",
447
448
  "timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
@@ -775,7 +776,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
775
776
  elif name == "verify":
776
777
  extra = {"report": {"type": ["string", "null"]},
777
778
  "files": {"type": "array", "description": "one entry per file: {file, probe, steps, skipped}. A step is {step, ok, seconds, error (one line, for the table)}, plus the tool's verified when it completed, and on a failure its own kind, reason, message, details and exit_code (reason step_timeout or step_output_unparsed when the tool printed no failure document); skipped lists the steps that do not apply as {step, reason: quick|no_video|no_audio|not_hdr|not_multichannel|input_unreadable, message}"},
778
- "failed": {"type": "integer"}, "total": {"type": "integer"}}
779
+ "failed": {"type": "integer"}, "total": {"type": "integer"},
780
+ "out_dir": {"type": ["string", "null"], "description": "the folder the steps' outputs are in when they are kept: --out, or with --keep alone a new folder that outlives the run; null when they were written to a temporary folder and deleted"}}
779
781
  elif name == "batch":
780
782
  extra = {"results": {"type": "array", "description": "one row per input, every input (an interrupted batch included): ok (every step completed), verified (every step also verified what it wrote; false under --dry-run) and unverified ([{step, tool, failed}] naming the steps that did not); a failed row's error {step, tool, kind, message, code, reason, details, hint, exit_code} from the failing step's own document; diagnostics from its steps (tagged with step); cache_miss {reason: output_missing|output_changed|entry_unverifiable, ...} when a cache entry was not served; skipped \"timeout\" or \"interrupted\" for an item that never started and interrupted true for one stopped while running; under --watch, skipped \"settling\" (ok false, not cached) for a source that changed since the previous poll, or, first seen, was modified within one poll interval: it is processed on a later pass. A row whose recipe ran cut.py carries cut_reencoded (each cut.py call's reencoded) and cut_reencode_reasons (each reason those calls named, once per call)"},
781
783
  "processed": {"type": "integer"}, "total": {"type": "integer"},
@@ -872,7 +874,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
872
874
  "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
875
  "notes": {"type": "array", "items": {"type": "string"}, "description": "present with `silent`: which tracks were silent and how to refuse them"},
874
876
  "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)"}}
877
+ "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
878
  for key, prop in EMITTED_KEYS.get(name, {}).items():
877
879
  extra.setdefault(key, prop)
878
880
  if name in ("overlay", "graphics", "caption", "grid"):
@@ -1039,11 +1041,6 @@ def _ff_listing(binary: str, flag: str) -> Dict[str, Any]:
1039
1041
  return {"names": names, "status": "parsed", "detail": f"{len(names)} entries"}
1040
1042
 
1041
1043
 
1042
- def _ff_list(binary: str, flag: str) -> List[str]:
1043
- """Names from `ffmpeg -encoders` / `-filters` / `-bsfs` (empty list when ffmpeg is missing or unparsed)."""
1044
- return _ff_listing(binary, flag)["names"]
1045
-
1046
-
1047
1044
  # GPU-backed encoders are named `<codec>_<backend>` by every ffmpeg build (h264_nvenc,
1048
1045
  # hevc_videotoolbox, av1_qsv, h264_vaapi, hevc_amf, ...); recognised by the backend suffix so a
1049
1046
  # new codec in a future ffmpeg build needs no change here.
@@ -2179,7 +2176,19 @@ def main() -> int:
2179
2176
  ap.add_argument("command", nargs="?", choices=["contract", "doctor"], default="contract")
2180
2177
  ap.add_argument("--json", action="store_true", help="JSON on stdout (the contract is always JSON)")
2181
2178
  ap.add_argument("--static", action="store_true", help="omit environment detection (available / missing capabilities)")
2179
+ ap.add_argument("--tool", action="append", metavar="NAME",
2180
+ help="only this tool's entry under `tools` (repeatable; `cut` or `cut.py`); every other key is "
2181
+ "unchanged. The whole contract is about 950 KB; with one tool, 55-85 KB")
2182
2182
  args = ap.parse_args()
2183
+ wanted: List[str] = []
2184
+ for name in args.tool or []:
2185
+ name = name[:-3] if name.endswith(".py") else name
2186
+ if name not in public_tools():
2187
+ ap.error(f"--tool {name}: no such tool (one of: {', '.join(public_tools())})")
2188
+ if name not in wanted:
2189
+ wanted.append(name)
2190
+ if wanted and args.command == "doctor":
2191
+ ap.error("--tool selects from the contract; doctor --json lists every tool under `tools`")
2183
2192
  if args.command == "doctor":
2184
2193
  d = doctor()
2185
2194
  # On stderr in both modes, so `doctor --json`'s stdout stays one JSON document: an
@@ -2225,7 +2234,12 @@ def main() -> int:
2225
2234
  # 1: something required is missing; 2: nothing proven missing but a required capability is unknown
2226
2235
  # an unsupported Python/FFmpeg is a known failure, like a missing capability (not "unknown")
2227
2236
  return 1 if d["missing"] or d.get("unsupported") else 2
2228
- print(json.dumps(build(detect=not args.static), indent=2, sort_keys=True, ensure_ascii=False))
2237
+ doc = build(detect=not args.static)
2238
+ if wanted:
2239
+ # in the order asked; `provides` and `capability_map` stay whole (a few KB): they map ids
2240
+ # to tools across the skill, which one tool's entry does not change (#467 A220)
2241
+ doc["tools"] = sorted((t for t in doc["tools"] if t["name"] in wanted), key=lambda t: wanted.index(t["name"]))
2242
+ print(json.dumps(doc, indent=2, sort_keys=True, ensure_ascii=False))
2229
2243
  return 0
2230
2244
 
2231
2245
 
@@ -230,11 +230,6 @@ ALIASES: Dict[str, str] = {"youtube-shorts": "shorts", "yt-shorts": "shorts", "y
230
230
  PLATFORM_CHOICES: List[str] = sorted({n for n in PLATFORMS if PLATFORMS[n]["check"] == n} | set(ALIASES))
231
231
 
232
232
 
233
- def has_frame(name: str) -> bool:
234
- """True when this destination has a frame, and therefore a safe zone to place text inside."""
235
- return bool(PLATFORMS.get(resolve(name) or "", {}).get("frame"))
236
-
237
-
238
233
  def spec_of(name: str) -> Dict[str, Any]:
239
234
  """check.py's compliance row values for a destination."""
240
235
  return dict(PLATFORMS[name]["spec"])
@@ -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]] = []
@@ -19,7 +19,7 @@ import sys
19
19
 
20
20
  from _common.alpha import add_alpha_args, check_alpha_args, finish_chain, report as alpha_report, video_line
21
21
  from _common.emit import note
22
- from _common import STATE, add_common, apply_common, die, draws_chroma, emit, ffmpeg_base, info, parse_time, probe, rgb_chain_to_bt709, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
22
+ from _common import STATE, add_common, apply_common, die, draws_chroma, emit, ffmpeg_base, info, probe, rgb_chain_to_bt709, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
23
23
 
24
24
 
25
25
  def gradient_source(w: int, h: int, fps: float, c0: str, c1: str, angle: float) -> str: