ffmpeg-skill 3.6.7 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -250,7 +250,7 @@ These are the rules the skill file gives the agent and the code enforces.
250
250
 
251
251
  | Tool | What it does |
252
252
  |---|---|
253
- | `audio.py` | Voice clean-up chain at three strengths (`--voice light\|medium\|strong`), FFT denoise, typed compressor / limiter / gate, music bed with sidechain ducking (`--duck-amount/-threshold/-attack/-release`), a never-ducked effects bed (`--effects`), `--stereo-widen`, fades, 5.1 → stereo, track replacement, extraction (`-o out.wav`), `--audio-stream N` |
253
+ | `audio.py` | Voice clean-up chain at three strengths (`--voice light\|medium\|strong`), FFT denoise, typed compressor / limiter / gate, music bed with sidechain ducking (`--duck-amount/-threshold/-attack/-release`), a never-ducked effects bed (`--effects`), `--stereo-widen`, fades, 5.1 → stereo, track replacement, extraction (`-o out.wav`), `--audio-stream N`, a tempo change that keeps the pitch (`--tempo`, atempo stages in 0.5–2.0) and an exact length in samples (`--length-samples`), resampling (`--sample-rate`) and one channel by index (`--channel N`), each read back from the output |
254
254
  | `sync.py` | Offset between two recordings by audio cross-correlation (1 ms, pure Python), clock-drift correction; aligned video or audio out (audio-to-audio only — no lip-sync/face detection); a third or later camera is an additional `more_sources` positional (`sync.py REF A B ...`), the `second` positional itself keeps its name |
255
255
  | `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP or any target (`--lra` for the range), video stream-copied; the written file is measured again and re-encoded until it meets `--tp` (lossy encoders overshoot); `--measure-only`; `--versions streaming,ebu-r128,broadcast-jp` (or `LUFS/TP` pairs) writes one file per target from one master, each measured after and listed in `versions` (`output` is null), `verified` only when every version met its own target |
256
256
 
package/SKILL.md CHANGED
@@ -146,6 +146,8 @@ Variants, caveats and extra flags per row: `references/requests.md`.
146
146
  | "wider stereo" | `audio.py band.wav --stereo-widen 0.5` |
147
147
  | "swap in the narration track" | `audio.py in.mp4 --replace narration.wav` |
148
148
  | "give me the sound as WAV" | `audio.py in.mp4 -o in.wav` |
149
+ | "speed the line up, same pitch", "exactly 96000 samples long" | `audio.py line.wav --tempo 1.2 --length-samples 96000 -o fit.wav` |
150
+ | "just the third mic", "resample to 48 kHz" | `audio.py room.wav --channel 2 --sample-rate 48000 -o mic3.wav` |
149
151
  | "compress the voice", "limit the peaks", "gate the noise" | `audio.py in.mp4 --compress` (or `--limit`, `--gate`) |
150
152
  | "audiogram", "turn this podcast into a video" | `render.py --template audiogram ep.m4a --image cover.png` |
151
153
  | "make this a Reel / TikTok / Short / YouTube" | `render.py --template reels in.mp4 --cues cues.txt` |
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.6.7`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.7.0`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.6.7", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.7.0", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
@@ -675,7 +675,18 @@ Per-tool keys added in 1.13: `audio` (`audio.py`) reports the mix it built — t
675
675
  `--voice` level, `stereo_widen`, whether an `--effects` bed was mixed, and with
676
676
  `--music` the `music_volume` plus a `duck` object naming the threshold (dB and
677
677
  linear), ratio, attack and release actually used, or `null` when `--duck` was not
678
- given. `loudness.py` reports `measured` (the input's loudnorm measurement,
678
+ given. Since #486 `audio.py` also reports `timing`, `{tempo, atempo, length_samples, sample_rate}`:
679
+ the `--tempo` factor and the atempo stages (each 0.5..2.0) it was built from, `--length-samples`,
680
+ and the rate that count is in (`null` before a pending input is measured); with
681
+ `--length-samples` a `length_samples` verification step decodes the written track and counts its
682
+ samples (`expected`, `measured`, `tolerance_samples`: 0 for PCM/FLAC/ALAC, one codec frame for a
683
+ lossy codec; `reason: not_measured` and `ok: false` when the count cannot be read).
684
+ Since #487 `audio.py --sample-rate HZ` resamples the output and `--channel N` keeps one channel by
685
+ index (0-based); the `audio` block reports `channel` and `sample_rate` (`null` when not asked), and
686
+ each is read back from the output by probe as a verification step, `sample_rate` and `channels`
687
+ (`{expected, measured}`; 1 channel, or 2 with `--stereo`; `reason: not_measured` when probe cannot
688
+ read it).
689
+ `loudness.py` reports `measured` (the input's loudnorm measurement,
679
690
  including `input_lra`) and `targets` (the requested lufs / tp / lra).
680
691
  `check.py --platform podcast` adds two informational rows to `checks`,
681
692
  `channels` and `chapters`.
@@ -910,6 +921,7 @@ here claims otherwise until real exit-code/stderr sniffing exists to back that u
910
921
  A refusal also carries `error.reason`, a stable snake_case code for *why*
911
922
  (`contract --json` → `json_output.error_reasons` lists them: `output_exists`, `output_is_input`,
912
923
  `output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
924
+ `output_container_codecs` (a `.webm` output for an H.264/HEVC or AAC encode, which WebM cannot hold),
913
925
  `media_path_is_url` (`subfile,...:` included), `media_path_is_playlist` (an HLS, DASH, IMF or ffconcat playlist given as media, DASH and IMF told by content whatever the file is called), `media_path_has_line_break` (a path with a line break, which a concat list cannot hold),
914
926
  `output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
915
927
  `render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.6.7",
3
+ "version": "3.7.0",
4
4
  "mcpName": "io.github.kajisho5/ffmpeg-skill",
5
5
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
6
6
  "keywords": [
@@ -2029,7 +2029,8 @@ it) or the copy fails, the video is re-encoded with the tags on its frames
2029
2029
  `verification`'s `color_tags` step. Tags that are still wrong after the
2030
2030
  re-encode fail the run with `kind: verification`. The re-encode is H.264
2031
2031
  unless `--codec` says otherwise, so an output container that cannot hold it
2032
- (`.webm`) fails with `kind: ffmpeg` instead of completing with the old tags.
2032
+ (`.webm`) is refused before it runs (`kind: input`, reason `output_container_codecs`) instead of
2033
+ completing with the old tags.
2033
2034
 
2034
2035
  ### audio.py — clean-up, music, ducking, layout
2035
2036
  ```
@@ -2037,7 +2038,8 @@ audio.py INPUT [--voice [light|medium|strong] | --denoise [--denoise-strength 25
2037
2038
  [--music FILE [--music-volume -14] [--duck [--duck-amount 12] [--duck-threshold -26.02]
2038
2039
  [--duck-attack 20] [--duck-release 400]] [--music-loop]]
2039
2040
  [--effects FILE [--effects-volume -14]] [--stereo-widen 0..1]
2040
- [--fade-in S] [--fade-out S] [--stereo | --mono | --downmix] [--replace FILE] [-o OUT]
2041
+ [--fade-in S] [--fade-out S] [--stereo | --mono | --downmix] [--replace FILE]
2042
+ [--tempo FACTOR] [--length-samples N] [--channel N] [--sample-rate HZ] [-o OUT]
2041
2043
  ```
2042
2044
  `--denoise` is `afftdn=nf=-N:tn=1`, where N is `--denoise-strength`, the noise
2043
2045
  floor in dB below full scale (20..60, default 25). It is the starting floor that
@@ -2051,7 +2053,7 @@ produced. The exact filter chains:
2051
2053
  | --- | --- |
2052
2054
  | `light` | `highpass=f=80,acompressor=threshold=-18dB:ratio=2:attack=5:release=80:makeup=1` |
2053
2055
  | `medium` | `highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2` |
2054
- | `strong` | the `medium` chain, then `deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled` |
2056
+ | `strong` | the `medium` chain, then `deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled:latency=1` |
2055
2057
 
2056
2058
  `light` for a good room (rumble and level only, noise floor and sibilance left
2057
2059
  alone), `medium` for a normal talking head, `strong` for phone/laptop audio.
@@ -2088,6 +2090,29 @@ alone comes out 7.7 dB lower than it went in (9.9 dB on 7.1; follow with `loudne
2088
2090
  leaves a 1-channel input untouched and downmixes >2 channels through
2089
2091
  swresample. Video is always stream-copied, and so is a subtitle/data track
2090
2092
  when the container can hold it (`dropped_non_av_streams` says when it could not).
2093
+ `--limit` (and `--voice strong`'s limiter) is `alimiter` with `level=disabled:latency=1`: the
2094
+ look-ahead is compensated, so the limited sound starts on the input's own sample instead of
2095
+ `--limit-attack` (5 ms, 240 samples at 48 kHz) late against the picture (#476).
2096
+ With `--replace`, `--music` or a kept picture the sound is padded with silence or trimmed to the
2097
+ source's length counted in samples at the track's rate (`apad=whole_len=N,atrim=end_sample=N`),
2098
+ so it is exact to the sample, not to the millisecond (#478). Sound that starts after the picture
2099
+ keeps its start and still ends with the picture (a trim on the timestamp follows the count).
2100
+ A fade-out ends where the output ends, which is `--length-samples` when that is given.
2101
+ `--tempo FACTOR` (0.1..10) changes the main track's tempo and keeps its pitch: `atempo` stages of
2102
+ 0.5..2.0 whose product is the factor (3 is `atempo=2,atempo=1.5`, 0.3 is `atempo=0.5,atempo=0.6`).
2103
+ atempo's own output length is close to, not exactly, the input's divided by the factor, so
2104
+ `--length-samples N` pads with silence or trims the output to exactly N samples per channel at the
2105
+ track's rate; the result's `length_samples` verification step decodes the written track and counts
2106
+ them (exact for PCM/FLAC/ALAC, within one codec frame for a lossy codec). Both need an audio output,
2107
+ or for `--tempo` a `--replace` track beside the picture: the input's own sound beside its
2108
+ stream-copied picture would drift from it (`fit.py --method speed` retimes both). `timing` reports
2109
+ `{tempo, atempo, length_samples, sample_rate}`.
2110
+ `--channel N` keeps channel N of the track (0-based, `pan=mono|c0=cN`, first in the chain) as a
2111
+ 1-channel output, or both sides of a stereo one with `--stereo`; an index past the track's channels
2112
+ and `--downmix` are refused. `--sample-rate HZ` (1000..768000) resamples the output (`aresample`,
2113
+ after the mix and the fades; `--length-samples` counts in this rate). Each is read back from the
2114
+ output by probe as a verification step, `channels` and `sample_rate` (`expected`, `measured`), and
2115
+ the `audio` block reports `channel` and `sample_rate`.
2091
2116
  Run `loudness.py` after this for final levels.
2092
2117
 
2093
2118
  ### loudness.py — EBU R128 normalisation
@@ -25,6 +25,7 @@ import threading
25
25
  from typing import Any, Dict, List, Optional
26
26
 
27
27
  from _common import runner as _runner
28
+ from _common.color import carried_tag
28
29
  from _common.runner import STATE, ffmpeg_encoders, require_tool, run
29
30
 
30
31
  # A pixel format that carries an alpha plane (yuva*, rgba, argb, gbrap, ya8/ya16, ...).
@@ -284,10 +285,12 @@ _TAG_KEYS = (("colorspace", "color_space", "-colorspace"), ("color_primaries", "
284
285
  ("color_trc", "color_transfer", "-color_trc"))
285
286
 
286
287
 
287
- def _source_tags(meta: Optional[Dict[str, Any]]) -> List[tuple]:
288
+ def _source_tags(meta: Optional[Dict[str, Any]], dest: str = "option") -> List[tuple]:
289
+ """(setparams name, value, output option) per source tag that is passed on, the value spelled
290
+ for `dest` (carried_tag: an RGB source's `gbr` is left out, #454)."""
288
291
  v = (meta or {}).get("video") or {}
289
- return [(param, v.get(key), flag) for param, key, flag in _TAG_KEYS
290
- if v.get(key) and v.get(key) not in ("unknown", "reserved", "unspecified")]
292
+ tags = [(param, carried_tag(param, v.get(key), dest), flag) for param, key, flag in _TAG_KEYS]
293
+ return [t for t in tags if t[1]]
291
294
 
292
295
 
293
296
  def finish_chain(codec: str, mode: Optional[str], meta: Optional[Dict[str, Any]] = None) -> str:
@@ -295,7 +298,7 @@ def finish_chain(codec: str, mode: Optional[str], meta: Optional[Dict[str, Any]]
295
298
  when asked, the source's colour tags put back on the frames (`setparams`: a grade's RGB legs
296
299
  and an RGB layer's conversion relabel them, BT.601 or unknown), then the line's own pixel
297
300
  format (so no later conversion can drop the plane)."""
298
- tags = _source_tags(meta)
301
+ tags = _source_tags(meta, "setparams")
299
302
  label = ("setparams=" + ":".join(f"{p}={val}" for p, val, _ in tags) + ",") if tags else ""
300
303
  # premultiplied colour is colour x alpha in RGB: multiplied there (a YUV premultiply scales the
301
304
  # limited-range offsets too), after the tags are back so both conversions use the same matrix
@@ -57,6 +57,36 @@ NON_BT709_MATRICES = ("smpte170m", "bt470bg", "fcc", "smpte240m")
57
57
  _RGB_PIX_FMT_RE = re.compile(r"^(?:rgb|bgr|argb|abgr|0rgb|0bgr|gbr|x2rgb|x2bgr|pal8)")
58
58
 
59
59
 
60
+ # A probed colour tag passed back to FFmpeg (#454). ffprobe prints av_color_*_name(), which is
61
+ # also the setparams filter's table; the output options -colorspace/-color_primaries/-color_trc
62
+ # parse an older one that differs on three names (6.1, measured): -colorspace refuses `gbr` (its
63
+ # name is `rgb`) and -color_trc refuses `bt470m` and `bt470bg` (its names: `gamma22`, `gamma28`).
64
+ # `gbr` is not carried over at all: it says the stored samples are RGB, which is the pixel
65
+ # format's to say (the PNG encoder tags it itself), and a tool that converts the picture to YUV on
66
+ # the way (a grade's legs, a 4:4:4 alpha line) would label YUV samples as RGB with it.
67
+ _TAG_KIND = {"-colorspace": "colorspace", "-color_primaries": "color_primaries", "-color_trc": "color_trc"}
68
+ _OPTION_NAMES = {"color_trc": {"bt470m": "gamma22", "bt470bg": "gamma28"}}
69
+
70
+
71
+ def carried_tag(kind: str, name: Optional[str], dest: str = "option") -> Optional[str]:
72
+ """The probed tag `name` of `kind` (colorspace, color_primaries, color_trc) spelled the way
73
+ `dest` ("option": -colorspace & co; "setparams") accepts it, or None when it is not to be
74
+ passed on: unknown, unspecified, reserved, or the RGB `gbr`/`rgb`."""
75
+ if not name or name in ("unknown", "unspecified", "reserved") or (kind == "colorspace" and name in ("gbr", "rgb")):
76
+ return None
77
+ return _OPTION_NAMES.get(kind, {}).get(name, name) if dest == "option" else name
78
+
79
+
80
+ def carried_tag_args(pairs: Sequence[Tuple[str, Optional[str]]]) -> List[str]:
81
+ """[flag, value, ...] for each (flag, probed name) whose tag carried_tag() passes on."""
82
+ out: List[str] = []
83
+ for flag, name in pairs:
84
+ value = carried_tag(_TAG_KIND.get(flag, flag.lstrip("-")), name)
85
+ if value:
86
+ out += [flag, value]
87
+ return out
88
+
89
+
60
90
  def rgb_to_bt709_filter(pix_fmt: str = "yuv444p") -> str:
61
91
  """An RGB picture converted to BT.709 limited-range YUV right here, in `pix_fmt`, so no later
62
92
  conversion (an auto-inserted one, with FFmpeg's BT.601 default) decides the matrix. 4:4:4 by
@@ -1004,6 +1004,76 @@ def _check_output_path(cmd: Sequence[str]) -> None:
1004
1004
  refuse("input", "output_dir_not_writable", f"output directory {parent!r} is not writable", output=output, directory=parent)
1005
1005
 
1006
1006
 
1007
+ # What the WebM muxer takes (6.1: "Only VP8 or VP9 or AV1 video and Vorbis or Opus audio and
1008
+ # WebVTT subtitles are supported for WebM"), by the encoder a command names (#488).
1009
+ _WEBM_VIDEO_ENCODER_RE = re.compile(r"^(?:libvpx(?:-vp9)?|vp8|vp9|libaom-av1|libsvtav1|librav1e|av1|vp8_\w+|vp9_\w+|av1_\w+)$")
1010
+ _WEBM_AUDIO_ENCODER_RE = re.compile(r"^(?:libopus|opus|libvorbis|vorbis)$")
1011
+ _CODEC_OPTION_RE = re.compile(r"^-(?:c|codec)(?::([va])(?::\d+)?)?$|^-(v|a)codec$")
1012
+
1013
+
1014
+ def _an_input_has(cmd: Sequence[str], kind: str) -> bool:
1015
+ """False only when every input of `cmd` is a file that was read and holds no `kind` ("v"/"a")
1016
+ stream, and no filter graph could make one. An encoder option for a stream the output will
1017
+ not have selects nothing (FFmpeg applies -c:a after stream selection), so it is not a reason
1018
+ to refuse: cut.py names -c:a aac for a silent source too. Anything not known counts as present."""
1019
+ if any(tok in ("-filter_complex", "-lavfi", "-filter_complex_script") for tok in cmd):
1020
+ return True
1021
+ paths = [cmd[i + 1] for i, tok in enumerate(cmd[:-1]) if tok == "-i"]
1022
+ if not paths:
1023
+ return True
1024
+ for i, tok in enumerate(cmd[:-1]):
1025
+ if tok == "-f" and cmd[i + 1] in ("lavfi", "concat"):
1026
+ return True
1027
+ for path in paths:
1028
+ if not os.path.isfile(path):
1029
+ return True
1030
+ probe_cmd = [shutil.which("ffprobe") or "ffprobe", "-v", "error", "-select_streams", kind,
1031
+ "-show_entries", "stream=index", "-of", "csv=p=0", path]
1032
+ limit = _probe_limit() # PROBE_TIMEOUT, or --timeout when that is lower; never unlimited (--timeout 0)
1033
+ try:
1034
+ proc = subprocess.run(probe_cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=limit)
1035
+ except subprocess.TimeoutExpired:
1036
+ # a stalled read is reported as what it is, not turned into "the stream is there"
1037
+ die(f"ffprobe did not finish reading {path} within {limit:g} s and was killed; nothing was written",
1038
+ code=124, kind="timeout", output=cmd[-1])
1039
+ except OSError:
1040
+ return True
1041
+ if proc.returncode != 0 or proc.stdout.strip():
1042
+ return True
1043
+ return False
1044
+
1045
+
1046
+ def _check_container_codecs(cmd: Sequence[str]) -> None:
1047
+ """A `.webm` output whose command encodes a picture or a sound WebM cannot hold (the tools'
1048
+ H.264/HEVC and AAC lines) is refused before anything runs, dry runs included: the muxer
1049
+ refused it only after the encode had started, as `kind: ffmpeg` with "Nothing was written
1050
+ into output file", and a dry run said completed (#488). A stream copy is left to the muxer:
1051
+ what it copies is not named in the command."""
1052
+ output = cmd[-1]
1053
+ if os.path.splitext(output)[1].lower() != ".webm":
1054
+ return
1055
+ refused: List[Any] = []
1056
+ for i, tok in enumerate(cmd[:-2]):
1057
+ m = _CODEC_OPTION_RE.match(tok)
1058
+ if not m:
1059
+ continue
1060
+ stream, value = m.group(1) or m.group(2), cmd[i + 1]
1061
+ if value == "copy" or stream is None:
1062
+ continue
1063
+ allowed = _WEBM_VIDEO_ENCODER_RE if stream == "v" else _WEBM_AUDIO_ENCODER_RE
1064
+ if not allowed.match(value):
1065
+ refused.append((stream, value))
1066
+ # only for a stream the output will have: asked last, and only when something would be refused
1067
+ refused = [value for stream, value in dict.fromkeys(refused) if _an_input_has(cmd, stream)]
1068
+ if refused:
1069
+ refuse("input", "output_container_codecs",
1070
+ f"output {output!r} is WebM, which holds only VP8/VP9/AV1 video and Opus/Vorbis sound; this tool "
1071
+ f"encodes {' and '.join(refused)} for it",
1072
+ hint="name a .mp4, .mov or .mkv output (Matroska holds what WebM refuses); "
1073
+ "a transparent VP9 WebM is --keep-alpha --alpha-codec vp9 or export.py --preset vp9-alpha",
1074
+ output=output, container="webm", encoders=refused)
1075
+
1076
+
1007
1077
  EVEN_SCALE = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
1008
1078
 
1009
1079
 
@@ -1797,6 +1867,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
1797
1867
  _refuse_url_media(cmd)
1798
1868
  _check_no_overwrite_input(cmd)
1799
1869
  _check_output_path(cmd)
1870
+ _check_container_codecs(cmd)
1800
1871
  _check_existing_output(cmd)
1801
1872
  ctx.commands.append(_cmdline(cmd))
1802
1873
  if not quiet:
@@ -171,6 +171,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
171
171
  {"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit / --voice strong"}, {"capability": "filter:agate", "when": "--gate"},
172
172
  {"capability": "filter:deesser", "when": "--voice medium (the default) / --voice strong"},
173
173
  {"capability": "filter:extrastereo", "when": "--stereo-widen"},
174
+ {"capability": "filter:atempo", "when": "--tempo"},
174
175
  {"capability": AAC, "when": "output extension isn't .mp3/.opus/.ogg/.flac (audio_codec_for()'s default)"}] + AUDIO_OUT,
175
176
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
176
177
  "loudness": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["loudness measurement JSON (--measure-only)", "normalised artifact (video stream-copied)"],
@@ -438,6 +439,7 @@ ERROR_REASONS = {
438
439
  "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}",
439
440
  "output_dir_missing": "the output's directory does not exist; details: {output, directory}",
440
441
  "output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
442
+ "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}",
441
443
  "timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
442
444
  "output_locked": "another run holds the output's lock; details: {lock, output, pid, pid_alive, deadline, why}",
443
445
  "plan_inputs_changed": "render.py PLAN: a fingerprinted input is missing or changed; details: {plan, plan_sha256, checked_at, "
@@ -797,7 +799,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
797
799
  "dynamics": {"type": "array", "items": {"enum": ["agate", "acompressor", "alimiter"]}, "description": "typed dynamics filters applied, in graph order"},
798
800
  "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"},
799
801
  "notes": {"type": "array", "items": {"type": "string"}, "description": "present with `silent`: which tracks were silent and how to refuse them"},
800
- "audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, 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)"}}
802
+ "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)"},
803
+ "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)"}}
801
804
  if name in ("overlay", "graphics", "caption", "grid"):
802
805
  extra["font_used"] = FONT_USED_SCHEMA
803
806
  if name in ALPHA_LINE_TOOLS:
@@ -34,6 +34,7 @@ from fractions import Fraction
34
34
  from typing import Any, Dict, List, Optional, Tuple
35
35
 
36
36
  from _common import STATE, aac_args, concat_list_line, ffmpeg_base, require_tool, run
37
+ from _common.color import carried_tag_args
37
38
  from _common.runner import fail_if_classified
38
39
 
39
40
  # the stream parameters a re-encoded part must share with the source (ffprobe -show_streams)
@@ -129,11 +130,12 @@ def x264_line(params: Dict[str, Any], crf: int, preset: str) -> List[str]:
129
130
  "-x264-params", f"{b}:open-gop=0"]
130
131
  if level:
131
132
  args += ["-level:v", f"{level // 10}.{level % 10}"]
132
- for flag, field in (("-colorspace", "color_space"), ("-color_primaries", "color_primaries"),
133
- ("-color_trc", "color_transfer"), ("-color_range", "color_range")):
134
- value = params.get(field)
135
- if value and value != "unknown":
136
- args += [flag, str(value)]
133
+ # spelled the way the output options take them (#454: ffprobe's bt470m/bt470bg transfer and
134
+ # gbr space are not their names)
135
+ args += carried_tag_args([("-colorspace", params.get("color_space")), ("-color_primaries", params.get("color_primaries")),
136
+ ("-color_trc", params.get("color_transfer"))])
137
+ if params.get("color_range") and params.get("color_range") != "unknown":
138
+ args += ["-color_range", str(params["color_range"])]
137
139
  return args
138
140
 
139
141
 
package/scripts/audio.py CHANGED
@@ -18,6 +18,8 @@ Examples:
18
18
  python3 audio.py interview.mp4 -o interview.wav # extract the audio (no video in the output)
19
19
  python3 audio.py multi.mkv --audio-stream 1 --voice -o lav.m4a # pick the second audio track, clean it, write M4A
20
20
  python3 audio.py talk.wav --compress --comp-threshold -20 --comp-ratio 4 --limit --limit-ceiling -1 -o talk_dyn.wav
21
+ python3 audio.py line.wav --tempo 1.25 --length-samples 96000 -o line_fit.wav # faster, same pitch, exactly 2 s at 48 kHz
22
+ python3 audio.py room.wav --channel 2 --sample-rate 48000 -o mic3.wav # the third microphone alone, resampled to 48 kHz
21
23
  """
22
24
  import argparse
23
25
  import os
@@ -42,7 +44,7 @@ VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=thresh
42
44
  VOICE_LEVELS = {
43
45
  "light": "highpass=f=80,acompressor=threshold=-18dB:ratio=2:attack=5:release=80:makeup=1",
44
46
  "medium": VOICE_CHAIN,
45
- "strong": VOICE_CHAIN + ",deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled",
47
+ "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",
46
48
  }
47
49
 
48
50
  # The sidechain threshold the music bed has used since 1.4 is the linear 0.05 that ffmpeg's
@@ -95,6 +97,9 @@ def dynamics_filter(name: str, args: argparse.Namespace) -> str:
95
97
  opts.append(f"{opt}={value:g}")
96
98
  if name == "alimiter":
97
99
  opts.append("level=disabled") # keep the level: a limiter must not normalise the whole track upwards
100
+ # latency compensation: without it the output is the input delayed by the look-ahead
101
+ # (--limit-attack, 5 ms by default: 240 samples at 48 kHz) and drifts off the picture (#476)
102
+ opts.append("latency=1")
98
103
  return name + ("=" + ":".join(opts) if opts else "")
99
104
 
100
105
 
@@ -173,6 +178,90 @@ def main_track_channels(args: argparse.Namespace, meta: Dict[str, Any]) -> int:
173
178
  return (meta.get("audio") or {}).get("channels") or 0
174
179
 
175
180
 
181
+ def main_track_rate(args: argparse.Namespace, meta: Dict[str, Any]) -> int:
182
+ """Sample rate of the track audio.py processes (as main_track_channels() picks it); 0 when it
183
+ is not known (a dry run's pending input)."""
184
+ if args.replace:
185
+ if dry_run_input_pending(args.replace) or not os.path.isfile(args.replace):
186
+ return 0
187
+ proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "a:0", "-show_entries",
188
+ "stream=sample_rate", "-of", "csv=p=0", args.replace], quiet=True, check=False)
189
+ first = (proc.stdout or "").strip().splitlines()[:1]
190
+ return int(first[0]) if first and first[0].strip().isdigit() else 0
191
+ streams = meta.get("audio_streams") or []
192
+ track = streams[args.audio_stream] if 0 <= args.audio_stream < len(streams) else (meta.get("audio") or {})
193
+ try:
194
+ return int(track.get("sample_rate") or 0)
195
+ except (TypeError, ValueError):
196
+ return 0
197
+
198
+
199
+ # --tempo: atempo keeps the pitch; one stage is kept within 0.5..2.0 (above 2 it drops whole
200
+ # blocks of samples), so a factor outside that range is a chain of stages whose product is it (#486)
201
+ TEMPO_RANGE = (0.1, 10.0)
202
+ LOSSLESS_AUDIO = ("pcm_", "flac", "alac", "wavpack", "tta")
203
+ # samples per frame of the lossy encoders audio_codec_for() picks: a decoded length can differ from
204
+ # the samples written by the encoder's own padding, by at most one frame
205
+ CODEC_FRAME_SAMPLES = {"aac": 1024, "mp3": 1152, "opus": 960, "vorbis": 2048, "ac3": 1536, "eac3": 1536}
206
+
207
+
208
+ def atempo_steps(factor: float) -> List[float]:
209
+ """atempo stages, each in 0.5..2.0, whose product is `factor`."""
210
+ steps: List[float] = []
211
+ while factor > 2.0:
212
+ steps.append(2.0)
213
+ factor /= 2.0
214
+ while factor < 0.5:
215
+ steps.append(0.5)
216
+ factor /= 0.5
217
+ return steps + [factor]
218
+
219
+
220
+ def decoded_samples(path: str) -> "int | None":
221
+ """Samples per channel of the first audio track, counted by decoding it (astats); None when
222
+ the count could not be read."""
223
+ proc = run([require_tool("ffmpeg"), "-hide_banner", "-nostats", "-i", path, "-map", "0:a:0", "-af",
224
+ "astats=measure_perchannel=none:measure_overall=Number_of_samples", "-f", "null", "-"],
225
+ quiet=True, check=False)
226
+ found = [line.rsplit(":", 1)[-1].strip() for line in (proc.stderr or "").splitlines() if "Number of samples:" in line]
227
+ return int(found[-1]) if proc.returncode == 0 and found and found[-1].isdigit() else None
228
+
229
+
230
+ def length_step(path: str, codec: str, expected: int) -> Dict[str, Any]:
231
+ """The `length_samples` verification step: the written track's decoded length against
232
+ --length-samples, exact for a lossless codec, within one codec frame for a lossy one."""
233
+ measured = decoded_samples(path)
234
+ lossless = codec.startswith(LOSSLESS_AUDIO)
235
+ tolerance = 0 if lossless else CODEC_FRAME_SAMPLES.get(codec, 2048)
236
+ step: Dict[str, Any] = {"step": "length_samples", "expected": expected, "measured": measured,
237
+ "tolerance_samples": tolerance, "codec": codec}
238
+ if measured is None:
239
+ return dict(step, ok=False, reason="not_measured")
240
+ return dict(step, ok=abs(measured - expected) <= tolerance)
241
+
242
+
243
+ SAMPLE_RATE_RANGE = (1000, 768000)
244
+ OPUS_DECODED_RATE = 48000 # what an Opus stream decodes at, whatever rate was encoded (measured: 16 kHz in, 48 kHz out)
245
+
246
+
247
+ def property_step(name: str, expected: int, measured: Any) -> Dict[str, Any]:
248
+ """A verification step comparing one probed property of the written track with the request
249
+ (#487: `sample_rate`, `channels`); `reason: not_measured` when probe could not read it."""
250
+ try:
251
+ value = int(measured)
252
+ except (TypeError, ValueError):
253
+ return {"step": name, "expected": expected, "measured": None, "ok": False, "reason": "not_measured"}
254
+ return {"step": name, "expected": expected, "measured": value, "ok": value == expected}
255
+
256
+
257
+ def conform_filter(samples: int, rate: int) -> str:
258
+ """Pad with silence or trim to exactly `samples` at `rate` (#478): counted in samples, not in
259
+ seconds written to the millisecond, which left a result up to 24 samples (48 kHz) off. The
260
+ aformat pins the rate the count is in, so a resampler FFmpeg inserts sits ahead of the count."""
261
+ head = f"aformat=sample_rates={rate}," if rate else ""
262
+ return f"{head}apad=whole_len={samples},atrim=end_sample={samples}"
263
+
264
+
176
265
  def main() -> int:
177
266
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
178
267
  ap.add_argument("input")
@@ -209,8 +298,21 @@ def main() -> int:
209
298
  help="widen the stereo image, 0..1 (0 = untouched, 1 = maximum); needs a real stereo source: a mono input is "
210
299
  "refused (duplicating it leaves both channels identical, so there is nothing to widen) and more than two "
211
300
  "channels are refused unless --downmix folds them to stereo first")
301
+ fades.add_argument("--channel", type=int, default=None, metavar="N",
302
+ help="keep only channel N of the track (0-based, in the track's own order), as a 1-channel output "
303
+ "(--stereo duplicates it to both sides); checked on the output by probe")
304
+ fades.add_argument("--sample-rate", type=int, default=None, metavar="HZ",
305
+ help="resample the output to HZ (aresample); checked on the output by probe. Default: the track's own rate")
212
306
  fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo with the ITU-R BS.775 weights (centre and surrounds -3 dB, LFE dropped), scaled so the fold-down cannot clip")
213
307
  fades.add_argument("--replace", help="replace the audio with this file (trimmed/padded to the video)")
308
+ timing = ap.add_argument_group("timing (an audio output, or --replace beside a kept picture)")
309
+ timing.add_argument("--tempo", type=float, default=None, metavar="FACTOR",
310
+ help="change the tempo of the main track keeping its pitch (atempo; 2 = twice as fast, half as long), "
311
+ f"{TEMPO_RANGE[0]:g}..{TEMPO_RANGE[1]:g}; outside 0.5..2.0 it is a chain of atempo stages. "
312
+ "Refused on the input's own sound while its picture is stream-copied (use fit.py --method speed)")
313
+ timing.add_argument("--length-samples", type=int, default=None, metavar="N",
314
+ help="pad with silence or trim the output to exactly N samples per channel at the output rate "
315
+ "(an audio output only: beside a picture the sound is conformed to the picture)")
214
316
  dyn = ap.add_argument_group("dynamics (typed; each flag is one option of ffmpeg's acompressor / alimiter / agate)")
215
317
  dyn.add_argument("--compress", action="store_true", help="compressor (acompressor); order: gate -> compressor -> limiter")
216
318
  dyn.add_argument("--comp-threshold", type=float, help="dBFS above which gain is reduced, -60..0 (ffmpeg default -12.4)")
@@ -299,6 +401,31 @@ def main() -> int:
299
401
  # or --replace's first track. meta["audio"] is the input's first track, so a mono a:0 left a
300
402
  # stereo --audio-stream 1 unfolded by --mono, and a stereo a:0 halved a mono one (A128)
301
403
  in_channels = main_track_channels(args, meta)
404
+ keep_video = has_video and not audio_out
405
+ if args.tempo is not None:
406
+ if not (TEMPO_RANGE[0] <= args.tempo <= TEMPO_RANGE[1]):
407
+ die(f"--tempo must be {TEMPO_RANGE[0]:g}..{TEMPO_RANGE[1]:g}, got {args.tempo:g}")
408
+ if keep_video and not args.replace:
409
+ die("--tempo changes the length of the input's own sound, and its picture is stream-copied, so the two "
410
+ "would drift apart; write an audio output (-o NAME.wav), or retime both with fit.py --method speed",
411
+ kind="input")
412
+ if args.length_samples is not None:
413
+ if args.length_samples < 1:
414
+ die(f"--length-samples must be a positive number of samples, got {args.length_samples}")
415
+ if keep_video:
416
+ die("--length-samples needs an audio output: beside a stream-copied picture the sound is conformed to "
417
+ "the picture's length; write -o NAME.wav (or .flac, .m4a, ...)", kind="input")
418
+ if args.sample_rate is not None and not (SAMPLE_RATE_RANGE[0] <= args.sample_rate <= SAMPLE_RATE_RANGE[1]):
419
+ die(f"--sample-rate must be {SAMPLE_RATE_RANGE[0]}..{SAMPLE_RATE_RANGE[1]} Hz, got {args.sample_rate}")
420
+ if args.channel is not None:
421
+ if args.channel < 0:
422
+ die(f"--channel is a 0-based channel index, got {args.channel}")
423
+ if args.downmix:
424
+ die("--channel and --downmix contradict each other: --channel keeps one channel, --downmix folds them all to stereo")
425
+ if in_channels and args.channel >= in_channels:
426
+ die(f"--channel {args.channel}: the track has {in_channels} channel(s), 0..{in_channels - 1}",
427
+ kind="input", channels=in_channels)
428
+ in_channels = 1 # what every later stage sees: the one channel kept
302
429
  if args.stereo_widen is not None:
303
430
  if args.mono:
304
431
  die("--stereo-widen and --mono contradict each other: there is no stereo image in a 1-channel output")
@@ -321,6 +448,9 @@ def main() -> int:
321
448
  idx += 1
322
449
 
323
450
  fx: List[str] = []
451
+ if args.channel is not None:
452
+ # #487: one channel by index, first, so every later stage works on it alone
453
+ fx.append(f"pan=mono|c0=c{args.channel}")
324
454
  if args.downmix:
325
455
  # ITU-R BS.775: centre and surrounds at -3 dB (0.707), LFE dropped. pan's '<' scales each
326
456
  # output so its gains sum to 1 over the channels the input has (1/2.414 on 5.1, 1/3.121 on
@@ -357,6 +487,22 @@ def main() -> int:
357
487
  # as it goes before the centre collapses. It runs after the channel layout is settled, so
358
488
  # a --downmix 5.1 source is widened on the stereo fold-down rather than on six channels.
359
489
  fx.append(f"extrastereo=m={1 + 2 * args.stereo_widen:g}")
490
+ tempo_steps = atempo_steps(args.tempo) if args.tempo is not None and args.tempo != 1.0 else []
491
+ if tempo_steps:
492
+ fx.append(",".join(f"atempo={f:.10g}" for f in tempo_steps))
493
+ # the main track's length in the output: the source's, or the source's / --tempo on an audio
494
+ # output of the input's own sound (beside a picture, or for --replace, it is the picture's)
495
+ main_dur = dur / args.tempo if dur and tempo_steps and not (keep_video or args.replace) else dur
496
+ rate = args.sample_rate or main_track_rate(args, meta)
497
+ # where the output ends, which is where a fade-out has to end: --length-samples when it is
498
+ # given (a fade timed on the source's length sat past the trim and was never heard)
499
+ end_dur = args.length_samples / rate if args.length_samples is not None and rate else main_dur
500
+ if args.length_samples is not None and rate and rate != OPUS_DECODED_RATE and "libopus" in audio_codec_for(output, args.bitrate):
501
+ # An Opus stream decodes at 48 kHz whatever rate went in (a 16 kHz track too), so the
502
+ # decoded track has N * 48000 / rate samples and the count asked for is not the file's
503
+ die(f"--length-samples counts at {rate} Hz, and an Opus output decodes at {OPUS_DECODED_RATE} Hz: "
504
+ f"the file would not hold {args.length_samples} samples",
505
+ kind="input", hint="add --sample-rate 48000 and give the length in 48 kHz samples, or write .wav/.flac")
360
506
 
361
507
  graph: List[str] = []
362
508
  graph.append(f"[{main_src}]{','.join(fx) if fx else 'anull'}[main]")
@@ -369,9 +515,9 @@ def main() -> int:
369
515
  inputs += ["-i", args.music]
370
516
  m = f"{idx}:a:0"
371
517
  idx += 1
372
- mfx = [f"volume={args.music_volume:g}dB", f"atrim=0:{dur:.3f}" if dur else "anull"]
373
- if args.music_fade_out and dur:
374
- mfx.append(f"afade=t=out:st={max(0.0, dur - args.music_fade_out):.3f}:d={args.music_fade_out:g}")
518
+ mfx = [f"volume={args.music_volume:g}dB", f"atrim=0:{main_dur:.6f}" if main_dur else "anull"]
519
+ if args.music_fade_out and end_dur:
520
+ mfx.append(f"afade=t=out:st={max(0.0, end_dur - args.music_fade_out):.3f}:d={args.music_fade_out:g}")
375
521
  graph.append(f"[{m}]{','.join(mfx)}[music]")
376
522
  if args.duck:
377
523
  graph.append("[main]asplit=2[mainA][sc]")
@@ -390,7 +536,7 @@ def main() -> int:
390
536
  inputs += ["-i", args.effects]
391
537
  e = f"{idx}:a:0"
392
538
  idx += 1
393
- efx = [f"volume={args.effects_volume:g}dB", f"atrim=0:{dur:.3f}" if dur else "anull"]
539
+ efx = [f"volume={args.effects_volume:g}dB", f"atrim=0:{main_dur:.6f}" if main_dur else "anull"]
394
540
  graph.append(f"[{e}]{','.join(efx)}[effects]")
395
541
  graph.append(f"[{last}][effects]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mixfx]")
396
542
  last = "mixfx"
@@ -398,17 +544,35 @@ def main() -> int:
398
544
  post: List[str] = []
399
545
  if args.fade_in:
400
546
  post.append(f"afade=t=in:st=0:d={args.fade_in:g}")
401
- if args.fade_out and dur:
402
- post.append(f"afade=t=out:st={max(0.0, dur - args.fade_out):.3f}:d={args.fade_out:g}")
547
+ if args.fade_out and end_dur:
548
+ post.append(f"afade=t=out:st={max(0.0, end_dur - args.fade_out):.3f}:d={args.fade_out:g}")
403
549
  # The audio track is conformed to the source duration whenever it is known: padded with
404
550
  # silence if the graph came out short, trimmed if long. A mixed track can come out a few
405
551
  # hundredths short (amix's dropout_transition, a looped bed's atrim boundary), and with
406
552
  # -shortest below that used to shorten the *video* to match: 12.00 s in, 11.925 s out, four
407
553
  # frames of a stream-copied picture gone (#164). Padding the audio, not cutting the picture,
408
554
  # is the only correct answer for a tool whose contract says the video is never touched.
409
- keep_video = has_video and not audio_out
410
- if dur and (args.replace or args.music or keep_video):
411
- post.append(f"apad,atrim=0:{dur:.3f}")
555
+ if args.sample_rate:
556
+ # #487: resampled after the mix and the fades, ahead of the sample count, which is in this rate
557
+ post.append(f"aresample={args.sample_rate}")
558
+ conform_samples = None
559
+ if args.length_samples is not None:
560
+ # #486: an exact length asked for, counted at the track's rate (unknown only for a dry
561
+ # run's pending input, whose real run pins it)
562
+ conform_samples = args.length_samples
563
+ post.append(conform_filter(conform_samples, rate))
564
+ elif main_dur and (args.replace or args.music or keep_video):
565
+ if rate:
566
+ conform_samples = round(main_dur * rate)
567
+ # The count is of samples, and sound that starts after the picture keeps its start
568
+ # (apad and atrim=end_sample do not move timestamps): N samples from 0.3 s end 0.3 s
569
+ # past the picture. The trim on the timestamp, which is what this was before #478,
570
+ # ends it with the picture; half a sample of slack keeps the Nth sample of a track
571
+ # that starts at 0.
572
+ post.append(conform_filter(conform_samples, rate) + f",atrim=end={main_dur + 0.5 / rate:.6f}")
573
+ else:
574
+ # a dry run's pending input: no rate to count in yet; the real run counts samples
575
+ post.append(f"apad,atrim=0:{main_dur:.6f}")
412
576
  if post:
413
577
  graph.append(f"[{last}]{','.join(post)}[out]")
414
578
  last = "out"
@@ -444,7 +608,18 @@ def main() -> int:
444
608
  "ratio": round(max(2.0, args.duck_amount / 3), 1),
445
609
  "attack_ms": args.duck_attack, "release_ms": args.duck_release}
446
610
  if args.duck else None)
447
- emit(output, audio=audio_block, video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
611
+ verification: List[Dict[str, Any]] = []
612
+ if args.length_samples is not None and not STATE.dry_run:
613
+ verification.append(length_step(output, str(a.get("codec") or ""), args.length_samples))
614
+ if args.sample_rate is not None and not STATE.dry_run:
615
+ verification.append(property_step("sample_rate", args.sample_rate, a.get("sample_rate")))
616
+ if args.channel is not None and not STATE.dry_run:
617
+ verification.append(property_step("channels", 2 if args.stereo else 1, a.get("channels")))
618
+ audio_block["channel"] = args.channel
619
+ audio_block["sample_rate"] = args.sample_rate
620
+ timing_block = {"tempo": args.tempo, "atempo": tempo_steps, "length_samples": args.length_samples,
621
+ "sample_rate": rate or None}
622
+ emit(output, audio=audio_block, timing=timing_block, **({"verification": verification} if verification else {}), video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
448
623
  dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f],
449
624
  dropped_non_av_streams=dropped_streams,
450
625
  **({"silent": silent, "notes": notes} if silent else {}))