ffmpeg-skill 3.6.6 → 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.
@@ -606,12 +606,26 @@ def kept_deliverables() -> List[str]:
606
606
  return [p for p in STATE.delivered if os.path.isfile(p)]
607
607
 
608
608
 
609
+ def written_on_disk() -> List[str]:
610
+ """Files this process wrote itself (run()'s outputs, STATE.written) that are still on disk, the
611
+ deliverables mark_delivered() named excepted. A stopped command's partial output is removed
612
+ before a stop is reported, so it is not among them."""
613
+ delivered = {os.path.realpath(p) for p in STATE.delivered}
614
+ return sorted(p for p in STATE.written if p not in delivered and os.path.isfile(p))
615
+
616
+
609
617
  def kept_clause() -> str:
610
- """How a stopped run's message ends: "nothing was written", or which deliverable it had already
611
- written and keeps (not the finished result: the step that was stopped did not run on it)."""
618
+ """How a stopped run's message ends: "nothing was written" only when no file this run wrote is
619
+ on disk (A182: every tool but export and render said it after writing its output), else which
620
+ deliverable it had already written and keeps (not the finished result: the step that was
621
+ stopped did not run on it), or which files it had written before the stop."""
612
622
  kept = kept_deliverables()
613
623
  if not kept:
614
- return "nothing was written"
624
+ written = written_on_disk()
625
+ if not written:
626
+ return "nothing was written"
627
+ return (f"{', '.join(written)} had already been written by this run before the stop and "
628
+ f"{'is' if len(written) == 1 else 'are'} still on disk")
615
629
  return (f"{', '.join(kept)} had already been written by this run and {'is' if len(kept) == 1 else 'are'} kept, "
616
630
  "without the step that was stopped (pass --overwrite to make it again)")
617
631
 
@@ -990,6 +1004,76 @@ def _check_output_path(cmd: Sequence[str]) -> None:
990
1004
  refuse("input", "output_dir_not_writable", f"output directory {parent!r} is not writable", output=output, directory=parent)
991
1005
 
992
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
+
993
1077
  EVEN_SCALE = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
994
1078
 
995
1079
 
@@ -1783,6 +1867,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
1783
1867
  _refuse_url_media(cmd)
1784
1868
  _check_no_overwrite_input(cmd)
1785
1869
  _check_output_path(cmd)
1870
+ _check_container_codecs(cmd)
1786
1871
  _check_existing_output(cmd)
1787
1872
  ctx.commands.append(_cmdline(cmd))
1788
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)"],
@@ -241,12 +242,16 @@ DRY_RUN_ANALYSIS = {
241
242
  "sync": "audio is decoded to find the offset; the aligned output is not written",
242
243
  "multicam": "audio is decoded to align the cameras; the switched output is not written",
243
244
  "scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
244
- "report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
245
+ "report": "probe and loudness measurements run (and check.py, with --platform); contact sheets are not drawn under --dry-run (a report_section_dry_run skip); the HTML is not written",
245
246
  "cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
246
247
  "silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written. --filler --transcribe also runs the speech engine (its word timings are what the filler spans are planned on), which may download a model on first use; so does --transcribe with --sentence-min-silence or --sentence-margin-tail",
247
248
  "loudness": "the loudnorm measurement pass runs so input_i and the planned pass-2 command are real; the normalised output is not written",
248
249
  "check": "read-only tool; the loudness measurement (or, when it is skipped, the audio row's volumedetect) and the --content decode run under --dry-run too, so every row is present",
249
250
  "stabilize": "vidstabdetect (pass 1, into a temp file) runs; the stabilised output (pass 2) is not written",
251
+ "metadata": "only with --auto-chapters: its silence and scene measurement decodes the input so the proposed chapters are real; otherwise no ffmpeg runs, and the tagged output is never written",
252
+ "cut": "only with --snap beats (and no --snap-source): the audio is decoded to find the beat grid the in/out points snap to; otherwise no ffmpeg runs, and the cut is never written",
253
+ "render": "each stage runs as its own --dry-run, so a stage whose input exists measures it as that tool does under --dry-run (a clip's --snap beats cut, a loudness or silence pass on a source); a stage whose input an earlier stage would write is not measured (measurement_input_pending); nothing is written",
254
+ "batch": "each recipe step runs as its own --dry-run on the item's source, so a step that measures (loudness, silence, check, a --snap beats cut, a render project's stages) measures it; nothing is written",
250
255
  "overlay": "only for a multi-line --text on FFmpeg 8.0, 8.0.1 or 8.1.0: one drawtext measures each line on a generated one-frame picture (the input is not read), so the planned per-line positions are the ones a run uses; otherwise no ffmpeg runs and the composited output is never written",
251
256
  }
252
257
  DRY_RUN_NOTES = {
@@ -434,6 +439,7 @@ ERROR_REASONS = {
434
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}",
435
440
  "output_dir_missing": "the output's directory does not exist; details: {output, directory}",
436
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}",
437
443
  "timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
438
444
  "output_locked": "another run holds the output's lock; details: {lock, output, pid, pid_alive, deadline, why}",
439
445
  "plan_inputs_changed": "render.py PLAN: a fingerprinted input is missing or changed; details: {plan, plan_sha256, checked_at, "
@@ -449,6 +455,7 @@ ERROR_REASONS = {
449
455
  "encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or ffmpeg itself reported \"Unknown encoder\" for the encoder the command named (kind ffmpeg; details: {name}; the hint points at doctor)",
450
456
  "asr_engine_missing": "kind missing_tool: --transcribe (caption.py; silence.py with --filler or a sentence flag) found no local speech-to-text engine; the message lists the install lines; details: {}",
451
457
  "encoder_list_unreadable": "--codec av1/prores: the build's encoder list could not be read, so whether it has the encoder is unknown (kind ffmpeg); details: {codec, encoders, error}",
458
+ "audio_too_short": "kind input: loudness.py: the audio has a true peak but is shorter than one 400 ms gating block, so it has no integrated loudness to normalise from (not silence); details: {input_tp}",
452
459
  "no_speech": "kind input: --transcribe (caption.py; silence.py with --filler or a sentence flag) ran a local speech engine that heard no speech in the input; details: {engine}; also top-level reason and engine",
453
460
  "engine_failed": "kind input: --transcribe: an installed speech engine ran and failed (a named --engine, or every engine --engine auto found); details: {engine, detail | engines: [{engine, reason, detail}]}; also top-level reason, engine and detail/engines",
454
461
  "english_only": "kind input: --transcribe under --engine auto found only Parakeet engines, which it runs for English speech only, and the language is not English; details: {engine, engines}; also top-level reason, engine and engines",
@@ -500,7 +507,8 @@ DIAGNOSTIC_CODES = {
500
507
  "cues_outside_video": ("warnings", "caption.py: cues wholly outside the video are not drawn; {count, duration}"),
501
508
  "asr_language_assumed": ("warnings", "--transcribe: the language was assumed (English) and the transcript is wrong if it is not"),
502
509
  # analysis
503
- "drift_correction_skipped": ("skipped", "sync.py --fix-drift not applied for a source (drift_skipped_reason); {path, reason: too_short | low_confidence, ...}"),
510
+ "speed_length_placeholder": ("skipped", "render.py --dry-run: a clip's length could not be read before its cut exists, so its speed stage was planned on a stand-in 10 s; {stage, clip, path, placeholder_seconds}"),
511
+ "drift_correction_skipped": ("skipped", "sync.py / multicam.py --fix-drift not applied for a source (drift_skipped_reason); {path, reason: too_short | low_confidence, ...}"),
504
512
  "sync_low_confidence": ("warnings", "sync.py / multicam.py: a source's offset has a correlation confidence below 0.1; {path, confidence}"),
505
513
  "cropdetect_windows_failed": ("warnings", "cropdetect.py: sampled windows ffmpeg could not decode (windows_failed); {failed, windows, starts}"),
506
514
  "filler_words_untimed": ("warnings", "silence.py --filler: listed words in the transcript with no usable timing were not removed (filler.matched_untimed); {count}"),
@@ -521,7 +529,7 @@ DIAGNOSTIC_CODES = {
521
529
  "normalize_on_by_default": ("warnings", "render.py: a platform export preset with no loudness stage got export.py --normalize by default; {preset}"),
522
530
  # degraded or partial results (part 2)
523
531
  "colour_not_converted": ("warnings", "export.py: an HDR or wide-gamut source written by a preset that tags SDR BT.709 without converting it; {preset, hdr_format}"),
524
- "output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS), so no gain meets the platform's loudness; {platform}"),
532
+ "output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS and no true peak above the silence floor), so no gain meets the platform's loudness; {platform}"),
525
533
  "flash_pass_failed": ("warnings", "check.py --content: the flash pass failed, so the flashes row is not measured (WARN, could not measure); the other rows stand"),
526
534
  "repair_incomplete": ("warnings", "export.py --repair: decode errors remain after the remux (a damaged frame needs a re-encode); {timestamp_warnings, decode_errors}"),
527
535
  "legalize_residual": ("warnings", "color.py --legalize: samples of the encoded file are still outside the legal range after the clamp (the encoder's error around clamped areas); the shares are in legalize.output"),
@@ -529,7 +537,7 @@ DIAGNOSTIC_CODES = {
529
537
  "loudness_outside_spec": ("warnings", "export.py: the written file's loudness is outside the platform's spec (normalized: after --normalize too); {platform, lufs, tp, normalized}"),
530
538
  "smart_fallback": ("fallbacks", "cut.py --smart could not make or verify the smart cut, so the whole cut was re-encoded as --accurate would (smart.reason says why); {reason_code}"),
531
539
  "camera_not_started": ("fallbacks", "multicam.py: a range asked for a camera that had not started; camera 0 played it ({start, end, requested_camera, camera, starts_at}); `cuts` names the camera rendered"),
532
- "loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
540
+ "loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, or the audio is shorter than one 400 ms gating block (loudness.too_short), so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
533
541
  "loudness_no_audio": ("skipped", "export.py: a platform preset's loudness check, on a source with no audio"),
534
542
  "report_section_missing": ("warnings", "report.py: a loudness, compliance or contact-sheet section could not be built (also a failing verification step naming the section)"),
535
543
  "report_section_dry_run": ("skipped", "report.py --dry-run draws no contact sheet"),
@@ -608,11 +616,11 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
608
616
  "video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]},
609
617
  "timecode": {"type": "object", "description": "3.0.0: the source's start timecode {value, drop_frame, source}: the first HH:MM:SS[:;.]FF timecode tag from the video stream, then a tmcd data stream, then the container; all three null when none carries one"},
610
618
  "chapters": {"type": "array", "description": "the container's chapter markers [{index, start, end, title}]"},
611
- "subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title}]"},
619
+ "subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title, default (the stream's default disposition)}]"},
612
620
  "keyframes": {"type": "array", "description": "--keyframes-near only: one entry per time [{at, window, keyframes (the video keyframes within window seconds of it), before (the nearest of those at or before it, or null when none is within the window), after (at or after it, likewise)}], seconds from the file's start; a failed keyframe read fails the run (kind ffmpeg)"}},
613
621
  "additionalProperties": True}
614
622
  base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
615
- "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
623
+ "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "the ffmpeg command lines this run planned or ran to write its output (render.py and waveform.py: their stages' too); a measurement pass is listed only where the tool records it (e.g. loudness.py pass 1, check.py, stabilize.py pass 1), and batch.py lists none of its steps' (each results row's step documents carry their own)"},
616
624
  "probe": {"type": "object", "description": "probe of the output when a file was written"},
617
625
  "output_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file this run wrote, absent under --dry-run; pass it to check.py --expect-identity SIZE:MTIME_NS so the check grades this file and not whatever later sits at the path"},
618
626
  "encoder": {"type": "string", "description": "the video encoder the last ffmpeg command used (after any GPU->CPU fallback; under --dry-run the planned one; render.py and waveform.py: their stages' commands included), `copy` for a stream copy; absent when no command encoded video"},
@@ -630,7 +638,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
630
638
  "levels": {"type": "object", "description": "--levels only: {standard, reference, bit_depth, legal {y, c}: inclusive legal code ranges at the file's depth, window_seconds, frames, out_of_range_pct {y, u, v, chroma}: share of samples outside them (chroma: Cb and Cr samples together), brng_pct (signalstats BRNG: pixels with any component outside the fixed BT.601/709 nominal range), worst_frame {time, y_pct, chroma_pct}, min {y, u, v}, max {y, u, v}, windows: the same per --levels-window seconds ({start, end, ...})}"},
631
639
  "change_scope": {"type": "object", "description": "--expect-changed only, when the two files could be compared: {declared [{start, end, rect}], observed [{start, end, frames, rect [x, y, w, h], peak}]: every span with a tile above the floor, undeclared: the same for the tiles no declaration covers, declared_unchanged: declared spans in which nothing changed, floor, tile [columns, rows], frames, quiet_peak: the largest tile at or under the floor}. Times are this file's presentation times in seconds, rectangles are displayed pixels"},
632
640
  "notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named (youtube was assumed and judgement rows are WARN), when --compare paired a different number of frames than the reference has, or when --expect-changed compared two files that are not encoded alike"},
633
- "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-changed adds change_scope; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
641
+ "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-changed adds change_scope; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS). A value that could not be read is \"could not measure\", never a PASS: duration FAILs (WARN without a length limit) when the file states no length, and black, frozen and silence are then WARN rows; loudness is WARN for audio shorter than one 400 ms block; --tp without --lufs (custom) measures the true peak row"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
634
642
  elif name == "caption":
635
643
  extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
636
644
  "caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column. Burn mode: cues_burned -- non-blank cues that overlap [0, duration] and are drawn -- and cues_outside -- non-blank cues wholly outside it (2.2.6); a burn with no visible cue is refused, kind input. --ass: ass_malformed -- Dialogue lines that could not be read and are not counted -- and with --offset ass_shifted / ass_unshifted -- lines moved, and lines whose timestamps could not be read, copied as they were (each count above 0 is also a diagnostics.warnings entry)"},
@@ -705,7 +713,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
705
713
  extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"], "description": "check.py's document for --platform; null when it was not asked for or could not run (a failing compliance verification step then says why)"}}
706
714
  elif name == "export":
707
715
  extra = {"repair": {"type": "object", "description": "--repair (2.24): {before, after}, each {timestamp_warnings, decode_errors} counted over one full decode at -v warning"},
708
- "loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read"},
716
+ "loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read, and with too_short true (lufs and ok null, tp measured) when the audio is shorter than one 400 ms gating block, which is not silence"},
709
717
  "alpha_mode": {"enum": ["straight", "premultiplied"], "description": "alpha presets only (prores4444, prores4444xq, qtrle, vp9-alpha, hap-alpha): how the colour was written, from --alpha-mode (default straight). The `alpha` verification step compares the written file's alpha with the source's frame by frame, tolerance 2 of 255 per frame (comparison: {ok, paired, worst_delta, worst_frame}; with a different frame count the mean and the most transparent frame, paired false; frames: {expected, measured}, each {mean, min, frames}); `codec` compares the written codec and profile with the preset's"},
710
718
  "key": {"type": "object", "description": "--preset key-fill only: the key file {output (<out stem>_key.mov), identity, sha256, timecode (the source's start timecode written on both, or null)}. Verification adds key_fill_pair (width, height, frame count, rate and the video streams' lengths equal on both files) and key_level (the key's level against the source's alpha frame by frame, tolerance 2 of 255); the source's subtitle/data streams are not written (non_av_streams.dropped, reason key_fill)"},
711
719
  "fill_sha256": {"type": "string", "description": "--preset key-fill only: the fill file's SHA-256 (the key's is key.sha256)"},
@@ -714,9 +722,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
714
722
  "dropped_non_av_streams": {"type": "boolean", "description": "true when non_av_streams.dropped is not empty; a notes line says so too"},
715
723
  "notes": {"type": "array", "items": {"type": "string"}}}
716
724
  elif name == "loudness":
717
- extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result"},
725
+ extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result. silent true (input_i \"-inf\") when the audio has no sound; too_short true (input_i null, input_tp measured) when it is shorter than one 400 ms gating block: not silence, and a normalisation is refused with error.reason audio_too_short"},
718
726
  "targets": {"type": "object", "description": "the requested lufs / tp / lra"},
719
- "result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and encodes"},
727
+ "result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used, encodes and loudnorm_mode (linear, or dynamic when loudnorm could not apply one linear gain: the input's true peak plus the gain passes the ceiling, or its range is 0 or above --lra); note says why the loudness missed the target, naming dynamic mode when that is the cause. A loudness step on an output with no integrated loudness is ok false with reason not_measured"},
720
728
  "versions": {"type": "array", "description": "--versions only (output is then null): one row per target, {target, output, targets, result}; every row's loudness and duration steps are in verification with a `version` key, and verified is true only when every version met its own target"},
721
729
  "outputs": {"type": "array", "items": {"type": "string"}, "description": "--versions only: the files written, in the order asked"}}
722
730
  elif name == "color":
@@ -768,7 +776,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
768
776
  "segment_end_snap_seconds": {"type": ["array", "null"], "items": {"type": ["number", "null"]}, "description": "--segments stream-copy join: per segment, the signed seconds its end moved to the keyframe the copied part stops at (the first keyframe at or after the requested end, never an earlier one; null for a part that runs to the end of the video, a part of a source with no B-frames, which is cut to its requested end as in every 2.x release, or a Matroska/MPEG-TS part, which is not snapped). null as a whole unless the output is those copied parts: a single segment, audio, --dry-run, or any re-encode or fallback"}}
769
777
  elif name == "multicam":
770
778
  extra = {"cuts": {"type": "array", "description": "[[start, end, camera], ...] on the reference timeline: the camera actually rendered. A range whose camera had not started yet plays camera 0, and diagnostics.fallbacks names the camera asked for (code camera_not_started)"},
771
- "offsets_seconds": {"type": "array"}, "confidence": {"type": "array"}}
779
+ "offsets_seconds": {"type": "array"}, "confidence": {"type": "array"},
780
+ "drift_ppm": {"type": "array", "items": {"type": ["number", "null"]}, "description": "--fix-drift: per input, the drift measured against the reference (the reference itself 0, the clock); null for a camera whose drift was not measured, with the reason in drift_skipped_reason (also a diagnostics.skipped entry, drift_correction_skipped)"},
781
+ "drift_skipped_reason": {"type": "array", "items": {"enum": ["too_short", "low_confidence", None]}, "description": "--fix-drift: per input, why its drift was not measured (too_short: no tail window far enough from the head; low_confidence: the tail did not correlate), null when it was and for the reference"}}
772
782
  elif name == "join":
773
783
  extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
774
784
  "expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
@@ -789,7 +799,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
789
799
  "dynamics": {"type": "array", "items": {"enum": ["agate", "acompressor", "alimiter"]}, "description": "typed dynamics filters applied, in graph order"},
790
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"},
791
801
  "notes": {"type": "array", "items": {"type": "string"}, "description": "present with `silent`: which tracks were silent and how to refuse them"},
792
- "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)"}}
793
804
  if name in ("overlay", "graphics", "caption", "grid"):
794
805
  extra["font_used"] = FONT_USED_SCHEMA
795
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 {}))