ffmpeg-skill 3.8.12 → 3.8.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/SKILL.md +1 -1
- package/docs/contract.md +11 -5
- package/mcp/server.py +0 -1
- package/package.json +5 -2
- package/references/flags.md +2 -2
- package/references/scripts.md +72 -27
- package/scripts/_common/asr.py +1 -1
- package/scripts/_common/color.py +7 -2
- package/scripts/_common/decision.py +40 -8
- package/scripts/_common/emit.py +38 -10
- package/scripts/_common/probe.py +34 -6
- package/scripts/_common/runner.py +76 -7
- package/scripts/_contract.py +24 -10
- package/scripts/_platforms.py +0 -5
- package/scripts/_smartcut.py +23 -5
- package/scripts/audio.py +86 -28
- package/scripts/background.py +1 -1
- package/scripts/caption.py +2 -3
- package/scripts/check.py +3 -3
- package/scripts/color.py +7 -1
- package/scripts/crop.py +3 -2
- package/scripts/cropdetect.py +1 -1
- package/scripts/cut.py +50 -17
- package/scripts/export.py +39 -6
- package/scripts/fit.py +4 -3
- package/scripts/freeze.py +1 -1
- package/scripts/graphics.py +2 -3
- package/scripts/insert.py +1 -1
- package/scripts/look.py +9 -4
- package/scripts/loop.py +3 -3
- package/scripts/loudness.py +20 -12
- package/scripts/multicam.py +56 -15
- package/scripts/overlay.py +1 -1
- package/scripts/probe.py +13 -2
- package/scripts/proxy.py +11 -2
- package/scripts/redact.py +5 -1
- package/scripts/scenes.py +82 -21
- package/scripts/silence.py +62 -16
- package/scripts/sphere.py +23 -3
- package/scripts/stabilize.py +5 -2
- package/scripts/sync.py +11 -6
- package/scripts/verify.py +82 -20
- package/scripts/waveform.py +12 -2
package/scripts/_common/probe.py
CHANGED
|
@@ -10,6 +10,8 @@ import math
|
|
|
10
10
|
import os
|
|
11
11
|
import re
|
|
12
12
|
import statistics
|
|
13
|
+
import sys
|
|
14
|
+
from array import array
|
|
13
15
|
from fractions import Fraction
|
|
14
16
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
15
17
|
from _common.emit import die, warn
|
|
@@ -43,11 +45,16 @@ def fingerprint(path: str) -> Dict[str, Any]:
|
|
|
43
45
|
|
|
44
46
|
|
|
45
47
|
def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None, start: float = 0.0,
|
|
46
|
-
*, check: bool = True) ->
|
|
48
|
+
*, check: bool = True) -> "Sequence[float]":
|
|
47
49
|
"""Decode (part of) a file's audio to mono float samples in [-1, 1) at `sample_rate` via a
|
|
48
50
|
single ffmpeg pass under --timeout. Shared by scenes.py (audio envelope for cut scoring) and
|
|
49
51
|
sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else [].
|
|
50
|
-
The samples are on the file's clock: a sound that starts late is padded at its head.
|
|
52
|
+
The samples are on the file's clock: a sound that starts late is padded at its head.
|
|
53
|
+
|
|
54
|
+
The samples are an array('f'), 4 bytes each: a list of Python floats took about 32 (24 for the
|
|
55
|
+
float, 8 for the slot), so scenes.py held ~2.3 GB per hour of audio at 8 kHz + 22.05 kHz and
|
|
56
|
+
multicam --switch energy one such list per camera (A140). An s16 sample divided by 32768 is
|
|
57
|
+
exact in a float32, so every value read back is the double the list held."""
|
|
51
58
|
ffmpeg = require_tool("ffmpeg")
|
|
52
59
|
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
|
|
53
60
|
if start:
|
|
@@ -65,9 +72,13 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
|
|
|
65
72
|
if check:
|
|
66
73
|
die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr.decode(errors="replace")))
|
|
67
74
|
return []
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
data, proc = proc.stdout, None
|
|
76
|
+
pcm = array("h")
|
|
77
|
+
pcm.frombytes(memoryview(data)[: len(data) // 2 * 2])
|
|
78
|
+
del data
|
|
79
|
+
if sys.byteorder == "big":
|
|
80
|
+
pcm.byteswap() # s16le on the pipe
|
|
81
|
+
return array("f", (v / 32768.0 for v in pcm))
|
|
71
82
|
|
|
72
83
|
|
|
73
84
|
def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool = False, remove_mean: bool = False) -> List[float]:
|
|
@@ -203,6 +214,23 @@ def shorter_than_a_loudness_block(length: "Optional[float]") -> bool:
|
|
|
203
214
|
return length is not None and 0 < length < LOUDNESS_BLOCK_SECONDS
|
|
204
215
|
|
|
205
216
|
|
|
217
|
+
def riff_wave_args(path: str) -> List[str]:
|
|
218
|
+
"""`-f wav` for a file that opens with a RIFF/WAVE header. FFmpeg 9.0.2 scores its MPEG-TS
|
|
219
|
+
probe above the WAV one when the PCM happens to hold 0x47 at a transport packet's spacing (a
|
|
220
|
+
steady tone can): ffprobe then reads a file this skill has just written as a broken transport
|
|
221
|
+
stream and fails with "End of file". The header says what the file is, so the probe is told.
|
|
222
|
+
Measured on 9.0.2 (CI): the same file fails unforced and reads with `-f wav`; 6.1 to 8.0 read
|
|
223
|
+
it either way."""
|
|
224
|
+
if not os.path.isfile(path): # a pipe or a device is never opened here: the open would block
|
|
225
|
+
return []
|
|
226
|
+
try:
|
|
227
|
+
with open(path, "rb") as fh:
|
|
228
|
+
head = fh.read(12)
|
|
229
|
+
except OSError:
|
|
230
|
+
return []
|
|
231
|
+
return ["-f", "wav"] if head[:4] == b"RIFF" and head[8:12] == b"WAVE" else []
|
|
232
|
+
|
|
233
|
+
|
|
206
234
|
def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
207
235
|
"""Return a compact, script-friendly description of a media file.
|
|
208
236
|
|
|
@@ -229,7 +257,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
229
257
|
die(f"input not found: {path}")
|
|
230
258
|
ffprobe = require_tool("ffprobe")
|
|
231
259
|
proc = run(
|
|
232
|
-
[ffprobe, "-v", "error", *still_input_args(path),
|
|
260
|
+
[ffprobe, "-v", "error", *still_input_args(path), *riff_wave_args(path),
|
|
233
261
|
"-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path],
|
|
234
262
|
quiet=True,
|
|
235
263
|
check=False,
|
|
@@ -511,6 +511,11 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
511
511
|
# --json-brief is a shorter --json, not a second output mode: it implies it, so a caller that
|
|
512
512
|
# passes only --json-brief still gets a JSON document (and --json --json-brief is the brief one).
|
|
513
513
|
STATE.json = bool(getattr(args, "json", False)) or STATE.json_brief
|
|
514
|
+
if hasattr(args, "json"):
|
|
515
|
+
# and a tool that branches on args.json reads the same mode: through 3.8.13, probe --field,
|
|
516
|
+
# check, cropdetect, silence --list, report and verify printed text (or a bare document,
|
|
517
|
+
# or a document and then a path) under --json-brief alone (review of #521)
|
|
518
|
+
args.json = STATE.json
|
|
514
519
|
STATE.progress = bool(getattr(args, "progress", False))
|
|
515
520
|
STATE.fast = bool(getattr(args, "fast", False))
|
|
516
521
|
STATE.overwrite = bool(getattr(args, "overwrite", False))
|
|
@@ -991,7 +996,7 @@ def _refuse_output_is_input(output: str, inp: str, real: str, why: str) -> None:
|
|
|
991
996
|
**details)
|
|
992
997
|
|
|
993
998
|
|
|
994
|
-
def refuse_output_is_input(output: str, *inputs: str) -> None:
|
|
999
|
+
def refuse_output_is_input(output: str, *inputs: str, why: str = "the result would replace the source") -> None:
|
|
995
1000
|
"""Tool-level twin of the run() guard, for tools whose final ffmpeg command does not name
|
|
996
1001
|
the user's input at all. `cut.py --segments` cuts each part into a temp dir and then concats
|
|
997
1002
|
a list file: the last command's only `-i` is that list, so `-o` equal to the input sailed
|
|
@@ -1009,7 +1014,7 @@ def refuse_output_is_input(output: str, *inputs: str) -> None:
|
|
|
1009
1014
|
except OSError:
|
|
1010
1015
|
continue
|
|
1011
1016
|
if same:
|
|
1012
|
-
_refuse_output_is_input(output, inp, out_real,
|
|
1017
|
+
_refuse_output_is_input(output, inp, out_real, why)
|
|
1013
1018
|
|
|
1014
1019
|
|
|
1015
1020
|
def _check_output_path(cmd: Sequence[str]) -> None:
|
|
@@ -1040,6 +1045,24 @@ def _check_output_path(cmd: Sequence[str]) -> None:
|
|
|
1040
1045
|
refuse("input", "output_dir_not_writable", f"output directory {parent!r} is not writable", output=output, directory=parent)
|
|
1041
1046
|
|
|
1042
1047
|
|
|
1048
|
+
def _check_output_extension(cmd: Sequence[str]) -> None:
|
|
1049
|
+
"""An ffmpeg output with no file extension and no `-f` after its last input leaves the muxer
|
|
1050
|
+
nothing to choose a container by: refused as `kind: input` before anything runs, dry runs
|
|
1051
|
+
included. Through 3.8.8 every tool that writes media handed it on and failed as `kind: ffmpeg`
|
|
1052
|
+
("Unable to choose an output format", audit A159's sweep). Only the ffmpeg commands run()
|
|
1053
|
+
executes are checked: a sidecar (a report, an SRT) is a plain file and needs no extension,
|
|
1054
|
+
and look.py takes an -o without an image extension as a stem (docs/design-decisions.md)."""
|
|
1055
|
+
output = cmd[-1]
|
|
1056
|
+
if output == "-" or output.startswith("pipe:") or len(os.path.splitext(output)[1]) > 1:
|
|
1057
|
+
return
|
|
1058
|
+
last_input = max((i for i, tok in enumerate(cmd[:-1]) if tok == "-i"), default=-1)
|
|
1059
|
+
if "-f" in cmd[last_input + 2:-1]:
|
|
1060
|
+
return
|
|
1061
|
+
refuse("input", "output_extension_missing",
|
|
1062
|
+
f"output {output!r} has no file extension, so ffmpeg cannot tell which container to write; "
|
|
1063
|
+
f"name it with one (for example {output + '.mp4'!r})", output=output)
|
|
1064
|
+
|
|
1065
|
+
|
|
1043
1066
|
# What the WebM muxer takes (6.1: "Only VP8 or VP9 or AV1 video and Vorbis or Opus audio and
|
|
1044
1067
|
# WebVTT subtitles are supported for WebM"), by the encoder a command names (#488).
|
|
1045
1068
|
_WEBM_VIDEO_ENCODER_RE = re.compile(r"^(?:libvpx(?:-vp9)?|vp8|vp9|libaom-av1|libsvtav1|librav1e|av1|vp8_\w+|vp9_\w+|av1_\w+)$")
|
|
@@ -2017,6 +2040,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
|
|
|
2017
2040
|
_refuse_url_media(cmd)
|
|
2018
2041
|
_check_no_overwrite_input(cmd)
|
|
2019
2042
|
_check_output_path(cmd)
|
|
2043
|
+
_check_output_extension(cmd)
|
|
2020
2044
|
_check_container_codecs(cmd)
|
|
2021
2045
|
_check_build_components(cmd)
|
|
2022
2046
|
_check_existing_output(cmd)
|
|
@@ -2109,11 +2133,56 @@ def _set_refusal_note(ctx: "Context", reason: str, outcome: str) -> None:
|
|
|
2109
2133
|
ctx.hw_notes.append(reason + outcome)
|
|
2110
2134
|
|
|
2111
2135
|
|
|
2136
|
+
# A filter graph longer than this is handed to ffmpeg in a file (A138). Linux refuses any single
|
|
2137
|
+
# argument over 128 KiB (MAX_ARG_STRLEN) and Windows a whole command line over 32767 characters, and
|
|
2138
|
+
# a graph grows with the items it is built from: scenes.py --sheet's select expression is ~56
|
|
2139
|
+
# characters a scene, so ~2,340 scenes raised OSError E2BIG before ffmpeg ran, with a traceback and
|
|
2140
|
+
# no JSON. A graph under the limit is passed as it always was.
|
|
2141
|
+
LONG_FILTER_CHARS = 8000 if os.name == "nt" else 100_000
|
|
2142
|
+
_FILTER_OPTION_RE = re.compile(r"^-(?:filter_complex|lavfi|vf|af|filter(?::[^\s]+)?)$")
|
|
2143
|
+
|
|
2144
|
+
|
|
2145
|
+
def _filter_file_option(option: str) -> str:
|
|
2146
|
+
"""The option that reads `option`'s value from a file: `-/OPTION` from FFmpeg 7.0, which
|
|
2147
|
+
deprecated the `_script` spellings, and `-filter_complex_script` / `-filter_script[:spec]`
|
|
2148
|
+
before it (6.x, the oldest CI runs)."""
|
|
2149
|
+
complex_graph = option in ("-filter_complex", "-lavfi")
|
|
2150
|
+
spec = {"-vf": ":v", "-af": ":a"}.get(option, option[len("-filter"):] if option.startswith("-filter:") else "")
|
|
2151
|
+
if ffmpeg_version() >= (7, 0):
|
|
2152
|
+
return "-/filter_complex" if complex_graph else "-/filter" + spec
|
|
2153
|
+
return "-filter_complex_script" if complex_graph else "-filter_script" + spec
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
@contextlib.contextmanager
|
|
2157
|
+
def long_filters_in_files(cmd: Sequence[str]) -> "Any":
|
|
2158
|
+
"""`cmd` with every filter graph longer than LONG_FILTER_CHARS moved into a temp file read by
|
|
2159
|
+
ffmpeg's file form of the same option; the files are removed when the block ends. The command a
|
|
2160
|
+
run records and prints is the caller's own, with the graph inline."""
|
|
2161
|
+
import tempfile
|
|
2162
|
+
out = list(cmd)
|
|
2163
|
+
made: List[str] = []
|
|
2164
|
+
try:
|
|
2165
|
+
if _is_ffmpeg(out):
|
|
2166
|
+
for i in range(1, len(out) - 1):
|
|
2167
|
+
if _FILTER_OPTION_RE.match(out[i]) and len(out[i + 1]) > LONG_FILTER_CHARS:
|
|
2168
|
+
fd, path = tempfile.mkstemp(prefix="ffmpeg-skill-graph-", suffix=".txt")
|
|
2169
|
+
made.append(path)
|
|
2170
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
2171
|
+
fh.write(out[i + 1])
|
|
2172
|
+
out[i], out[i + 1] = _filter_file_option(out[i]), path
|
|
2173
|
+
yield out
|
|
2174
|
+
finally:
|
|
2175
|
+
for path in made:
|
|
2176
|
+
with contextlib.suppress(OSError):
|
|
2177
|
+
os.remove(path)
|
|
2178
|
+
|
|
2179
|
+
|
|
2112
2180
|
def _execute(exec_cmd: List[str]) -> subprocess.CompletedProcess:
|
|
2113
2181
|
"""One attempt, never exiting on failure (run() decides after its retries)."""
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2182
|
+
with long_filters_in_files(exec_cmd) as cmd:
|
|
2183
|
+
if STATE.progress and _is_ffmpeg(cmd) and cmd[-1] != "-":
|
|
2184
|
+
return _run_with_progress(cmd, False)
|
|
2185
|
+
return _run_captured(cmd, False)
|
|
2117
2186
|
|
|
2118
2187
|
|
|
2119
2188
|
def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, record: bool = False) -> subprocess.CompletedProcess:
|
|
@@ -2134,7 +2203,8 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, r
|
|
|
2134
2203
|
# input filename on stderr, and loudness/check parse the loudnorm JSON out of it. The
|
|
2135
2204
|
# encoding kwargs are rejected with text=False, so they are only passed for text mode.
|
|
2136
2205
|
text_kw = {"encoding": "utf-8", "errors": "replace"} if text else {}
|
|
2137
|
-
|
|
2206
|
+
with long_filters_in_files(cmd) as exec_cmd:
|
|
2207
|
+
proc = subprocess.run(exec_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, **text_kw, timeout=limit)
|
|
2138
2208
|
except subprocess.TimeoutExpired:
|
|
2139
2209
|
_timed_out(cmd, limit or 0)
|
|
2140
2210
|
if check and proc.returncode != 0:
|
|
@@ -2569,7 +2639,6 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
|
|
|
2569
2639
|
with os.replace() under the lock instead of copied -- a checked `cut.py --segments` join was
|
|
2570
2640
|
copied whole, about 2 s for 240 MB. A rename that fails (another filesystem) falls back to
|
|
2571
2641
|
the copy."""
|
|
2572
|
-
import shutil
|
|
2573
2642
|
cmd = ["ffmpeg", dst]
|
|
2574
2643
|
_check_output_path(cmd)
|
|
2575
2644
|
_check_existing_output(cmd)
|
package/scripts/_contract.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
python3 scripts/_contract.py --json # the contract, with detected capabilities
|
|
5
5
|
python3 scripts/_contract.py --json --static # same, without environment detection
|
|
6
|
+
python3 scripts/_contract.py --json --tool cut # only cut under `tools` (repeatable)
|
|
6
7
|
python3 scripts/_contract.py doctor [--json] # which required capabilities this machine has
|
|
7
8
|
ffmpeg-skill contract --json # the same through the npm entry point
|
|
8
9
|
|
|
@@ -21,7 +22,6 @@ import argparse
|
|
|
21
22
|
import functools
|
|
22
23
|
import importlib.util
|
|
23
24
|
import json
|
|
24
|
-
import os
|
|
25
25
|
import platform
|
|
26
26
|
import re
|
|
27
27
|
import shutil
|
|
@@ -170,7 +170,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
170
170
|
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
171
171
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
172
172
|
"audio": dict(role="execution", inputs=["video or audio asset", "music bed (--music) or replacement track (--replace)", "effects/atmos track (--effects)"], outputs=["artifact with the processed audio (video stream-copied, or dropped when -o has an audio extension)"],
|
|
173
|
-
required=FF, optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:
|
|
173
|
+
required=FF, optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaingate", "when": "--duck"}, {"capability": "filter:dcshift", "when": "--duck"},
|
|
174
174
|
{"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit / --voice strong"}, {"capability": "filter:agate", "when": "--gate"},
|
|
175
175
|
{"capability": "filter:deesser", "when": "--voice medium (the default) / --voice strong"},
|
|
176
176
|
{"capability": "filter:extrastereo", "when": "--stereo-widen"},
|
|
@@ -442,6 +442,7 @@ ERROR_REASONS = {
|
|
|
442
442
|
"output_looks_like_option": "the output starts with '-' (other than '-' for stdout), which ffmpeg would read as an option; ./name writes a file of that name; details: {output}",
|
|
443
443
|
"media_path_is_url": "an input or output has a protocol prefix (tcp:, http://, file:, ...) that ffmpeg would open as a URL; the skill reads and writes local files only; details: {path, scheme}",
|
|
444
444
|
"output_dir_missing": "the output's directory does not exist; details: {output, directory}",
|
|
445
|
+
"output_extension_missing": "a media output with no file extension (and no -f naming its format), so ffmpeg could not choose a container; refused before ffmpeg runs, dry runs included; details: {output}",
|
|
445
446
|
"output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
|
|
446
447
|
"output_container_codecs": "a .webm output whose command encodes video or sound WebM cannot hold (H.264/HEVC, AAC; WebM takes VP8/VP9/AV1 and Opus/Vorbis), refused before ffmpeg runs; details: {output, container, encoders}",
|
|
447
448
|
"timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
|
|
@@ -775,7 +776,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
775
776
|
elif name == "verify":
|
|
776
777
|
extra = {"report": {"type": ["string", "null"]},
|
|
777
778
|
"files": {"type": "array", "description": "one entry per file: {file, probe, steps, skipped}. A step is {step, ok, seconds, error (one line, for the table)}, plus the tool's verified when it completed, and on a failure its own kind, reason, message, details and exit_code (reason step_timeout or step_output_unparsed when the tool printed no failure document); skipped lists the steps that do not apply as {step, reason: quick|no_video|no_audio|not_hdr|not_multichannel|input_unreadable, message}"},
|
|
778
|
-
"failed": {"type": "integer"}, "total": {"type": "integer"}
|
|
779
|
+
"failed": {"type": "integer"}, "total": {"type": "integer"},
|
|
780
|
+
"out_dir": {"type": ["string", "null"], "description": "the folder the steps' outputs are in when they are kept: --out, or with --keep alone a new folder that outlives the run; null when they were written to a temporary folder and deleted"}}
|
|
779
781
|
elif name == "batch":
|
|
780
782
|
extra = {"results": {"type": "array", "description": "one row per input, every input (an interrupted batch included): ok (every step completed), verified (every step also verified what it wrote; false under --dry-run) and unverified ([{step, tool, failed}] naming the steps that did not); a failed row's error {step, tool, kind, message, code, reason, details, hint, exit_code} from the failing step's own document; diagnostics from its steps (tagged with step); cache_miss {reason: output_missing|output_changed|entry_unverifiable, ...} when a cache entry was not served; skipped \"timeout\" or \"interrupted\" for an item that never started and interrupted true for one stopped while running; under --watch, skipped \"settling\" (ok false, not cached) for a source that changed since the previous poll, or, first seen, was modified within one poll interval: it is processed on a later pass. A row whose recipe ran cut.py carries cut_reencoded (each cut.py call's reencoded) and cut_reencode_reasons (each reason those calls named, once per call)"},
|
|
781
783
|
"processed": {"type": "integer"}, "total": {"type": "integer"},
|
|
@@ -872,7 +874,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
872
874
|
"silent": {"type": "array", "description": "--on-silent warn (default), only when a track was silent: [{flag, path, peak_db}] for each --music/--replace/--effects file, or under --duck the voice (flag \"input\"), whose whole-file peak is at or below --silence-threshold (default -50 dBFS); it was mixed anyway. --on-silent fail refuses instead, reason \"silent (peak X dBFS)\" in problems"},
|
|
873
875
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "present with `silent`: which tracks were silent and how to refuse them"},
|
|
874
876
|
"timing": {"type": "object", "description": "#486: {tempo, atempo, length_samples, sample_rate}: the --tempo factor (null without it), the atempo stages (each 0.5..2.0, [] at tempo 1) whose product it is, --length-samples (null without it) and the sample rate that count is in (null when not measured yet); with --length-samples a length_samples verification step counts the written track's decoded samples (exact for PCM/FLAC/ALAC, within tolerance_samples, one codec frame, for a lossy codec)"},
|
|
875
|
-
"audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, channel (--channel N, the 0-based channel kept, else null), sample_rate (--sample-rate, else null; both are read back from the output as the channels / sample_rate verification steps), effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db
|
|
877
|
+
"audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, channel (--channel N, the 0-based channel kept, else null), sample_rate (--sample-rate, else null; both are read back from the output as the channels / sample_rate verification steps), effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, the sidechain gate's ratio, attack_ms, release_ms, and amount_db, the depth in dB the bed is lowered under speech)"}}
|
|
876
878
|
for key, prop in EMITTED_KEYS.get(name, {}).items():
|
|
877
879
|
extra.setdefault(key, prop)
|
|
878
880
|
if name in ("overlay", "graphics", "caption", "grid"):
|
|
@@ -1039,11 +1041,6 @@ def _ff_listing(binary: str, flag: str) -> Dict[str, Any]:
|
|
|
1039
1041
|
return {"names": names, "status": "parsed", "detail": f"{len(names)} entries"}
|
|
1040
1042
|
|
|
1041
1043
|
|
|
1042
|
-
def _ff_list(binary: str, flag: str) -> List[str]:
|
|
1043
|
-
"""Names from `ffmpeg -encoders` / `-filters` / `-bsfs` (empty list when ffmpeg is missing or unparsed)."""
|
|
1044
|
-
return _ff_listing(binary, flag)["names"]
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
1044
|
# GPU-backed encoders are named `<codec>_<backend>` by every ffmpeg build (h264_nvenc,
|
|
1048
1045
|
# hevc_videotoolbox, av1_qsv, h264_vaapi, hevc_amf, ...); recognised by the backend suffix so a
|
|
1049
1046
|
# new codec in a future ffmpeg build needs no change here.
|
|
@@ -2179,7 +2176,19 @@ def main() -> int:
|
|
|
2179
2176
|
ap.add_argument("command", nargs="?", choices=["contract", "doctor"], default="contract")
|
|
2180
2177
|
ap.add_argument("--json", action="store_true", help="JSON on stdout (the contract is always JSON)")
|
|
2181
2178
|
ap.add_argument("--static", action="store_true", help="omit environment detection (available / missing capabilities)")
|
|
2179
|
+
ap.add_argument("--tool", action="append", metavar="NAME",
|
|
2180
|
+
help="only this tool's entry under `tools` (repeatable; `cut` or `cut.py`); every other key is "
|
|
2181
|
+
"unchanged. The whole contract is about 950 KB; with one tool, 55-85 KB")
|
|
2182
2182
|
args = ap.parse_args()
|
|
2183
|
+
wanted: List[str] = []
|
|
2184
|
+
for name in args.tool or []:
|
|
2185
|
+
name = name[:-3] if name.endswith(".py") else name
|
|
2186
|
+
if name not in public_tools():
|
|
2187
|
+
ap.error(f"--tool {name}: no such tool (one of: {', '.join(public_tools())})")
|
|
2188
|
+
if name not in wanted:
|
|
2189
|
+
wanted.append(name)
|
|
2190
|
+
if wanted and args.command == "doctor":
|
|
2191
|
+
ap.error("--tool selects from the contract; doctor --json lists every tool under `tools`")
|
|
2183
2192
|
if args.command == "doctor":
|
|
2184
2193
|
d = doctor()
|
|
2185
2194
|
# On stderr in both modes, so `doctor --json`'s stdout stays one JSON document: an
|
|
@@ -2225,7 +2234,12 @@ def main() -> int:
|
|
|
2225
2234
|
# 1: something required is missing; 2: nothing proven missing but a required capability is unknown
|
|
2226
2235
|
# an unsupported Python/FFmpeg is a known failure, like a missing capability (not "unknown")
|
|
2227
2236
|
return 1 if d["missing"] or d.get("unsupported") else 2
|
|
2228
|
-
|
|
2237
|
+
doc = build(detect=not args.static)
|
|
2238
|
+
if wanted:
|
|
2239
|
+
# in the order asked; `provides` and `capability_map` stay whole (a few KB): they map ids
|
|
2240
|
+
# to tools across the skill, which one tool's entry does not change (#467 A220)
|
|
2241
|
+
doc["tools"] = sorted((t for t in doc["tools"] if t["name"] in wanted), key=lambda t: wanted.index(t["name"]))
|
|
2242
|
+
print(json.dumps(doc, indent=2, sort_keys=True, ensure_ascii=False))
|
|
2229
2243
|
return 0
|
|
2230
2244
|
|
|
2231
2245
|
|
package/scripts/_platforms.py
CHANGED
|
@@ -230,11 +230,6 @@ ALIASES: Dict[str, str] = {"youtube-shorts": "shorts", "yt-shorts": "shorts", "y
|
|
|
230
230
|
PLATFORM_CHOICES: List[str] = sorted({n for n in PLATFORMS if PLATFORMS[n]["check"] == n} | set(ALIASES))
|
|
231
231
|
|
|
232
232
|
|
|
233
|
-
def has_frame(name: str) -> bool:
|
|
234
|
-
"""True when this destination has a frame, and therefore a safe zone to place text inside."""
|
|
235
|
-
return bool(PLATFORMS.get(resolve(name) or "", {}).get("frame"))
|
|
236
|
-
|
|
237
|
-
|
|
238
233
|
def spec_of(name: str) -> Dict[str, Any]:
|
|
239
234
|
"""check.py's compliance row values for a destination."""
|
|
240
235
|
return dict(PLATFORMS[name]["spec"])
|
package/scripts/_smartcut.py
CHANGED
|
@@ -110,6 +110,21 @@ def ineligible(params: Optional[Dict[str, Any]], meta: Dict[str, Any], ext: str)
|
|
|
110
110
|
return None
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
# Matroska stores every timestamp in milliseconds (its default TimestampScale), whatever the source's
|
|
114
|
+
# time base: an .mkv output's stream reads 1/1000 however it is written
|
|
115
|
+
MATROSKA_TIME_BASE = "1/1000"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def expected_time_base(params: Dict[str, Any], ext: str) -> Optional[str]:
|
|
119
|
+
"""The time base the joined output's video should read: the source's for MP4/MOV, whose time
|
|
120
|
+
scale execute() sets from it (-video_track_timescale), and Matroska's own for .mkv. Comparing a
|
|
121
|
+
.mkv against an MP4 source's 1/15360 failed every such cut, and only after the whole smart cut had
|
|
122
|
+
been built and checked (#462 A45)."""
|
|
123
|
+
if ext.lower() == ".mkv":
|
|
124
|
+
return MATROSKA_TIME_BASE
|
|
125
|
+
return params.get("time_base")
|
|
126
|
+
|
|
127
|
+
|
|
113
128
|
def _rate(params: Dict[str, Any]) -> Optional[Fraction]:
|
|
114
129
|
try:
|
|
115
130
|
q = Fraction(str(params.get("r_frame_rate") or ""))
|
|
@@ -165,8 +180,10 @@ def plan_segment(packets: List[Tuple[Any, Any, bool]], s: float, e: float, vend:
|
|
|
165
180
|
raise SmartFallback(f"the keyframe at {packets[k1][0]:.3f}s opens its GOP (frames decoded after it are shown "
|
|
166
181
|
"before it), so a copy cannot start there", "open_gop")
|
|
167
182
|
k1_pts = packets[k1][0]
|
|
168
|
-
# where the copy stops: the last keyframe presented at or before the end bound, decoded after K1
|
|
169
|
-
|
|
183
|
+
# where the copy stops: the last keyframe presented at or before the end bound, decoded after K1.
|
|
184
|
+
# A segment that runs to the end of the video has no end bound to stop before: its copy runs
|
|
185
|
+
# to the end, and the last GOP is whole, so it is copied, not re-encoded as a tail (#462 A44)
|
|
186
|
+
stops = [i for i in keyidx if not to_end and packets[i][1] > packets[k1][1] + 1e-9
|
|
170
187
|
and (end_bound is None or packets[i][0] <= end_bound + 1e-6)]
|
|
171
188
|
k2 = stops[-1] if stops else None
|
|
172
189
|
if k2 is not None and gop_is_open(packets, k2):
|
|
@@ -422,9 +439,10 @@ def verify(src: str, dst: str, parts: List[Dict[str, Any]], params: Dict[str, An
|
|
|
422
439
|
decoded += g
|
|
423
440
|
offset += n
|
|
424
441
|
out_params = stream_params(dst) or {}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
442
|
+
want_tb = expected_time_base(params, os.path.splitext(dst)[1])
|
|
443
|
+
if out_params.get("time_base") != want_tb:
|
|
444
|
+
raise SmartFallback(f"the output's time base is {out_params.get('time_base')}, where "
|
|
445
|
+
f"{want_tb} was expected (the source's is {params.get('time_base')})", "time_base")
|
|
428
446
|
diff = mismatches(params, out_params)
|
|
429
447
|
if diff:
|
|
430
448
|
raise SmartFallback("the output's stream does not match the source: " + ", ".join(diff), "params_mismatch")
|
package/scripts/audio.py
CHANGED
|
@@ -29,8 +29,8 @@ from typing import Any, Dict, List
|
|
|
29
29
|
|
|
30
30
|
from _common.probe import SILENT_PEAK_DBFS
|
|
31
31
|
from _common import STATE, add_common, dry_run_input_pending, require_tool, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, measured_level_dbfs, probe, run, run_keeping_subtitles, fmt_secs
|
|
32
|
-
from _common.emit import warn
|
|
33
|
-
from _common.decision import conform_filter, conform_to_length
|
|
32
|
+
from _common.emit import file_origin, stream_end, warn
|
|
33
|
+
from _common.decision import LOSSLESS_AUDIO_EXT, conform_filter, conform_to_length
|
|
34
34
|
|
|
35
35
|
# --downmix: ITU-R BS.775 fold-down, renormalised by pan's '<' (see main()).
|
|
36
36
|
DOWNMIX_PAN = "pan=stereo|FL<FL+0.707*FC+0.707*BL+0.707*SL|FR<FR+0.707*FC+0.707*BR+0.707*SR"
|
|
@@ -43,10 +43,11 @@ VOICE_CHAIN = f"highpass=f=80,deesser=i=0.4,{VOICE_DENOISE},acompressor=threshol
|
|
|
43
43
|
# which is what a good room recording needs; "strong" is for phone/laptop audio: a harder
|
|
44
44
|
# de-esser, a second compression stage and a soft limiter at -1 dBFS so the peaks stop there
|
|
45
45
|
# instead of at whatever the make-up gain produced.
|
|
46
|
+
VOICE_LIMITER = "alimiter=limit=0.891251:level=disabled:latency=1" # --voice strong's -1 dBFS ceiling
|
|
46
47
|
VOICE_LEVELS = {
|
|
47
48
|
"light": "highpass=f=80,acompressor=threshold=-18dB:ratio=2:attack=5:release=80:makeup=1",
|
|
48
49
|
"medium": VOICE_CHAIN,
|
|
49
|
-
"strong": VOICE_CHAIN + ",deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,
|
|
50
|
+
"strong": VOICE_CHAIN + ",deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3," + VOICE_LIMITER,
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
|
|
@@ -66,11 +67,40 @@ def voice_chain(level: str, denoise_strength: "float | None") -> str:
|
|
|
66
67
|
return chain.replace("highpass=f=80,", f"highpass=f=80,{stage},", 1)
|
|
67
68
|
|
|
68
69
|
|
|
69
|
-
# The sidechain threshold the music bed has used since 1.4 is the linear 0.05
|
|
70
|
-
#
|
|
71
|
-
# default command line is unchanged to the byte while the value is now sayable.
|
|
70
|
+
# The sidechain threshold the music bed has used since 1.4 is the linear 0.05 the filter takes;
|
|
71
|
+
# -26.0206 dBFS is that same number in the unit the flag speaks.
|
|
72
72
|
DUCK_THRESHOLD_DB = -26.0206
|
|
73
73
|
|
|
74
|
+
# the knee the filters take is a linear 1..8 (default 2.82843): in the dB the flag speaks that is
|
|
75
|
+
# 0..18.06 dB, default 9.03 dB. The flag used to accept 1..8 *dB* (linear 1.12..2.51), so no knee
|
|
76
|
+
# from 2.51 up to ffmpeg's own default could be asked for, and the help gave the default as 2.83 dB
|
|
77
|
+
KNEE_MAX_DB = 18.06 # 20*log10(8) = 18.0618, rounded down so the linear value stays inside 1..8
|
|
78
|
+
|
|
79
|
+
# --duck (A134): the bed is lowered by exactly --duck-amount dB while the speech is above the
|
|
80
|
+
# threshold. A sidechain gate keyed by the speech passes the bed where there is speech and nothing
|
|
81
|
+
# elsewhere; that part, scaled by -(1 - 10^(-amount/20)), is added back to the bed, which leaves
|
|
82
|
+
# 10^(-amount/20) of it under speech and all of it in the pauses. The sidechain compressor used
|
|
83
|
+
# through 3.8.8 took the amount as ratio = max(2, amount/3), and a compressor's depth is how far
|
|
84
|
+
# the speech rises over its threshold: on the test's speech 0 and 3 ducked the same 9.7 dB, 60 ducked 18.
|
|
85
|
+
# The key gets a DC offset far below any threshold (-120 dBFS) because agate's detector leaves the
|
|
86
|
+
# gate open on a key that is exact zeros from its first sample. The knee is hard (knee=1): agate's
|
|
87
|
+
# default soft knee (2.82843) opened the gate only about 2 dB over the threshold, so a key just
|
|
88
|
+
# over it left the bed at its level (measured: a sine 1.5 dB over -7.5 dBFS ducked 0 dB of 12).
|
|
89
|
+
DUCK_GATE_RATIO = 9000.0 # agate's largest: closed below the threshold
|
|
90
|
+
DUCK_KEY_DC = "dcshift=shift=0.000001"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def duck_graph(threshold_db: float, amount_db: float, attack: float, release: float) -> List[str]:
|
|
94
|
+
"""The filter-graph lines that turn `[music]` and the speech key `[sc]` into `[ducked]`."""
|
|
95
|
+
cut = 1.0 - 10 ** (-amount_db / 20.0)
|
|
96
|
+
return ["[music]asplit=2[mdry][mwet]",
|
|
97
|
+
f"[sc]{DUCK_KEY_DC}[key]",
|
|
98
|
+
f"[mwet][key]sidechaingate=threshold={db_to_linear(threshold_db):.6g}:ratio={DUCK_GATE_RATIO:g}:range=0:knee=1"
|
|
99
|
+
f":attack={attack:g}:release={release:g}[mspeech]",
|
|
100
|
+
f"[mspeech]volume={-cut:.6g}[mcut]",
|
|
101
|
+
"[mdry][mcut]amix=inputs=2:normalize=0[ducked]"]
|
|
102
|
+
|
|
103
|
+
|
|
74
104
|
# Typed dynamics: every flag maps to one real option of one ffmpeg filter, validated against the
|
|
75
105
|
# range that filter documents (ffmpeg -h filter=acompressor / alimiter / agate). dB flags are
|
|
76
106
|
# converted to the linear value the filter takes, so no string reaches the graph unchecked.
|
|
@@ -81,7 +111,7 @@ DYNAMICS = {
|
|
|
81
111
|
"comp_attack": ("attack", "ms", 0.01, 2000.0),
|
|
82
112
|
"comp_release": ("release", "ms", 0.01, 9000.0),
|
|
83
113
|
"comp_makeup": ("makeup", "dB", 0.0, 36.0), # 1..64 linear
|
|
84
|
-
"comp_knee": ("knee", "dB",
|
|
114
|
+
"comp_knee": ("knee", "dB", 0.0, KNEE_MAX_DB), # 1..8 linear (A135: was 1..8 dB, linear 1.12..2.51)
|
|
85
115
|
},
|
|
86
116
|
"alimiter": {
|
|
87
117
|
"limit_ceiling": ("limit", "dB", -24.0, 0.0), # 0.0625..1 linear
|
|
@@ -94,7 +124,7 @@ DYNAMICS = {
|
|
|
94
124
|
"gate_attack": ("attack", "ms", 0.01, 9000.0),
|
|
95
125
|
"gate_release": ("release", "ms", 0.01, 9000.0),
|
|
96
126
|
"gate_range": ("range", "dB", -90.0, 0.0), # 0..1 linear: how far the gate closes
|
|
97
|
-
"gate_knee": ("knee", "dB",
|
|
127
|
+
"gate_knee": ("knee", "dB", 0.0, KNEE_MAX_DB), # 1..8 linear
|
|
98
128
|
},
|
|
99
129
|
}
|
|
100
130
|
|
|
@@ -289,8 +319,8 @@ def main() -> int:
|
|
|
289
319
|
music = ap.add_argument_group("music")
|
|
290
320
|
music.add_argument("--music", help="music file to mix underneath")
|
|
291
321
|
music.add_argument("--music-volume", type=float, default=-14.0, help="music level in dB relative to full scale (default -14)")
|
|
292
|
-
music.add_argument("--duck", action="store_true", help="auto-duck the music
|
|
293
|
-
music.add_argument("--duck-amount", type=float, default=12.0, help="how many dB
|
|
322
|
+
music.add_argument("--duck", action="store_true", help="auto-duck the music by --duck-amount dB while the main track has speech (a sidechain gate keyed by it)")
|
|
323
|
+
music.add_argument("--duck-amount", type=float, default=12.0, help="how many dB the music is lowered under speech, 0..60 (default 12)")
|
|
294
324
|
music.add_argument("--duck-threshold", type=float, default=DUCK_THRESHOLD_DB,
|
|
295
325
|
help="sidechain threshold in dBFS: the main track is heard as speech above this (default -26.02, the 0.05 linear used since 1.4)")
|
|
296
326
|
music.add_argument("--duck-attack", type=float, default=20.0, help="ms the bed takes to duck once speech starts (default 20)")
|
|
@@ -326,25 +356,27 @@ def main() -> int:
|
|
|
326
356
|
"(an audio output only: beside a picture the sound is conformed to the picture)")
|
|
327
357
|
dyn = ap.add_argument_group("dynamics (typed; each flag is one option of ffmpeg's acompressor / alimiter / agate)")
|
|
328
358
|
dyn.add_argument("--compress", action="store_true", help="compressor (acompressor); order: gate -> compressor -> limiter")
|
|
329
|
-
dyn.add_argument("--comp-threshold", type=float, help="dBFS above which gain is reduced, -60..0 (ffmpeg default -
|
|
359
|
+
dyn.add_argument("--comp-threshold", type=float, help="dBFS above which gain is reduced, -60..0 (ffmpeg default -18.06, its linear 0.125)")
|
|
330
360
|
dyn.add_argument("--comp-ratio", type=float, help="ratio 1..20 (default 2)")
|
|
331
361
|
dyn.add_argument("--comp-attack", type=float, help="ms 0.01..2000 (default 20)")
|
|
332
362
|
dyn.add_argument("--comp-release", type=float, help="ms 0.01..9000 (default 250)")
|
|
333
363
|
dyn.add_argument("--comp-makeup", type=float, help="make-up gain dB 0..36 (default 0)")
|
|
334
|
-
dyn.add_argument("--comp-knee", type=float, help="knee dB 1..8
|
|
364
|
+
dyn.add_argument("--comp-knee", type=float, help="knee width dB 0..18.06 (ffmpeg's linear 1..8; default 9.03, its 2.82843)")
|
|
335
365
|
dyn.add_argument("--limit", action="store_true", help="look-ahead limiter (alimiter), level left as is")
|
|
336
366
|
dyn.add_argument("--limit-ceiling", type=float, help="ceiling dBFS -24..0 (default 0)")
|
|
337
367
|
dyn.add_argument("--limit-attack", type=float, help="ms 0.1..80 (default 5)")
|
|
338
368
|
dyn.add_argument("--limit-release", type=float, help="ms 1..8000 (default 50)")
|
|
339
369
|
dyn.add_argument("--gate", action="store_true", help="noise gate (agate)")
|
|
340
|
-
dyn.add_argument("--gate-threshold", type=float, help="dBFS below which the gate closes, -60..0 (default -18.
|
|
370
|
+
dyn.add_argument("--gate-threshold", type=float, help="dBFS below which the gate closes, -60..0 (default -18.06, ffmpeg's linear 0.125)")
|
|
341
371
|
dyn.add_argument("--gate-ratio", type=float, help="ratio 1..9000 (default 2)")
|
|
342
372
|
dyn.add_argument("--gate-attack", type=float, help="ms 0.01..9000 (default 20)")
|
|
343
373
|
dyn.add_argument("--gate-release", type=float, help="ms 0.01..9000 (default 250)")
|
|
344
|
-
dyn.add_argument("--gate-range", type=float, help="attenuation when closed, dB -90..0 (default -
|
|
345
|
-
dyn.add_argument("--gate-knee", type=float, help="knee dB 1..8
|
|
374
|
+
dyn.add_argument("--gate-range", type=float, help="attenuation when closed, dB -90..0 (default -24.26, ffmpeg's linear 0.06125)")
|
|
375
|
+
dyn.add_argument("--gate-knee", type=float, help="knee width dB 0..18.06 (ffmpeg's linear 1..8; default 9.03, its 2.82843)")
|
|
346
376
|
ap.add_argument("--audio-stream", type=int, default=0, help="which audio stream of the input to process, 0-based in file order (probe lists them under audio_streams)")
|
|
347
|
-
ap.add_argument("--bitrate", default=
|
|
377
|
+
ap.add_argument("--bitrate", default=None,
|
|
378
|
+
help="audio bitrate: AAC's beside a picture (default 192k), and the codec's of a lossy audio output "
|
|
379
|
+
"(.m4a/.aac default 256k, .mp3 -q:a 0, .ogg -q:a 6, .opus 128k); refused for .wav/.flac")
|
|
348
380
|
ap.add_argument("--on-silent", choices=["warn", "fail"], default="warn",
|
|
349
381
|
help="a --music/--replace/--effects file (or, under --duck, the voice) whose peak is at or below "
|
|
350
382
|
"--silence-threshold: warn (default: mix it, report it under `silent`) or fail (refuse before ffmpeg runs)")
|
|
@@ -391,7 +423,7 @@ def main() -> int:
|
|
|
391
423
|
("--duck-attack", args.duck_attack, 0.01, 2000.0),
|
|
392
424
|
("--duck-release", args.duck_release, 0.01, 9000.0)):
|
|
393
425
|
if not (lo <= value <= hi):
|
|
394
|
-
die(f"{flag} {value:g} is outside {lo:g}..{hi:g}
|
|
426
|
+
die(f"{flag} {value:g} is outside {lo:g}..{hi:g}")
|
|
395
427
|
if args.stereo_widen is not None and not (0.0 <= args.stereo_widen <= 1.0):
|
|
396
428
|
die(f"--stereo-widen must be 0..1 (0 = untouched, 1 = maximum), got {args.stereo_widen:g}")
|
|
397
429
|
|
|
@@ -421,6 +453,9 @@ def main() -> int:
|
|
|
421
453
|
die("input has no audio stream (use --replace to add one)")
|
|
422
454
|
output = args.output or default_output(args.input, "audio")
|
|
423
455
|
audio_out = is_audio_output(output)
|
|
456
|
+
if args.bitrate is not None and Path(output).suffix.lower() in LOSSLESS_AUDIO_EXT:
|
|
457
|
+
# A143: the rate used to be dropped without a word for every audio extension
|
|
458
|
+
die(f"--bitrate has no effect on a lossless {Path(output).suffix.lower()} output; drop it, or write a lossy one (.m4a, .mp3, ...)")
|
|
424
459
|
streams = meta.get("audio_streams") or []
|
|
425
460
|
if streams and not (0 <= args.audio_stream < len(streams)) and not STATE.dry_run:
|
|
426
461
|
die(f"--audio-stream {args.audio_stream}: input has {len(streams)} audio stream(s), 0..{len(streams) - 1}")
|
|
@@ -486,8 +521,17 @@ def main() -> int:
|
|
|
486
521
|
# 7.1), so in-phase channels at full scale cannot clip; '=' summed 2.7x and clipped a loud
|
|
487
522
|
# 5.1 while the run said verified.
|
|
488
523
|
fx.append(DOWNMIX_PAN)
|
|
524
|
+
# A129: a limiter is the ceiling of what is delivered, so with a bed mixed in it runs after the
|
|
525
|
+
# mix: on the voice alone, the music and effects added on top of it peaked over the ceiling
|
|
526
|
+
# (--limit-ceiling -6 delivered 0 dBFS, --voice strong's -1 dBFS -0.2)
|
|
527
|
+
limit_after_mix = bool(args.music or args.effects)
|
|
528
|
+
post_limit: List[str] = []
|
|
489
529
|
if args.voice:
|
|
490
|
-
|
|
530
|
+
chain = voice_chain(args.voice, args.denoise_strength if args.denoise else None)
|
|
531
|
+
if limit_after_mix and chain.endswith("," + VOICE_LIMITER):
|
|
532
|
+
chain = chain[: -len("," + VOICE_LIMITER)]
|
|
533
|
+
post_limit.append(VOICE_LIMITER)
|
|
534
|
+
fx.append(chain)
|
|
491
535
|
elif args.denoise:
|
|
492
536
|
fx.append(f"afftdn=nf=-{args.denoise_strength:g}:tn=1")
|
|
493
537
|
if args.gain:
|
|
@@ -497,7 +541,7 @@ def main() -> int:
|
|
|
497
541
|
if args.compress:
|
|
498
542
|
fx.append(dynamics_filter("acompressor", args))
|
|
499
543
|
if args.limit:
|
|
500
|
-
fx.append(dynamics_filter("alimiter", args))
|
|
544
|
+
(post_limit if limit_after_mix else fx).append(dynamics_filter("alimiter", args))
|
|
501
545
|
if args.mono:
|
|
502
546
|
# after --downmix the track is the stereo fold-down, whatever the source had
|
|
503
547
|
in_ch = 2 if args.downmix else (in_channels or 2)
|
|
@@ -542,15 +586,28 @@ def main() -> int:
|
|
|
542
586
|
m = f"{idx}:a:0"
|
|
543
587
|
idx += 1
|
|
544
588
|
mfx = [f"volume={args.music_volume:g}dB", f"atrim=0:{main_dur:.6f}" if main_dur else "anull"]
|
|
545
|
-
|
|
546
|
-
|
|
589
|
+
# A142: a bed shorter than the output (and not looped) stops at its own end, so its fade
|
|
590
|
+
# ends there: timed on the output's end it started after the bed had stopped at full level
|
|
591
|
+
# ... and at the main track's end when the output runs past it (--length-samples): the
|
|
592
|
+
# atrim above has cut the bed there. All three are timestamps, which is what afade reads:
|
|
593
|
+
# a bed whose sound starts late in its file (2 to 5 s) keeps those, so its fade ends at 5,
|
|
594
|
+
# not at its 3 s length (that fade ran over the bed's first second)
|
|
595
|
+
ends = [t for t in (end_dur, main_dur) if t]
|
|
596
|
+
if args.music_fade_out and not args.music_loop and not dry_run_input_pending(args.music):
|
|
597
|
+
bed_meta = probe(args.music)
|
|
598
|
+
bed_stop = stream_end(bed_meta, "audio")
|
|
599
|
+
if bed_stop is not None and bed_stop - file_origin(bed_meta) > 0:
|
|
600
|
+
ends.append(bed_stop - file_origin(bed_meta))
|
|
601
|
+
bed_end = min(ends) if ends else None
|
|
602
|
+
if args.music_fade_out and bed_end:
|
|
603
|
+
# a fade longer than the bed is cut to it, so the bed still ends silent (a 5 s fade on
|
|
604
|
+
# a 2 s bed started at 0 and stopped at 60% gain)
|
|
605
|
+
m_fade = min(args.music_fade_out, bed_end)
|
|
606
|
+
mfx.append(f"afade=t=out:st={bed_end - m_fade:.3f}:d={m_fade:g}")
|
|
547
607
|
graph.append(f"[{m}]{','.join(mfx)}[music]")
|
|
548
608
|
if args.duck:
|
|
549
609
|
graph.append("[main]asplit=2[mainA][sc]")
|
|
550
|
-
graph.
|
|
551
|
-
f"[music][sc]sidechaincompress=threshold={db_to_linear(args.duck_threshold):.6g}"
|
|
552
|
-
f":ratio={max(2.0, args.duck_amount / 3):.1f}:attack={args.duck_attack:g}:release={args.duck_release:g}:makeup=1[ducked]"
|
|
553
|
-
)
|
|
610
|
+
graph += duck_graph(args.duck_threshold, args.duck_amount, args.duck_attack, args.duck_release)
|
|
554
611
|
graph.append("[mainA][ducked]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mix]")
|
|
555
612
|
else:
|
|
556
613
|
graph.append("[main][music]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mix]")
|
|
@@ -567,11 +624,12 @@ def main() -> int:
|
|
|
567
624
|
graph.append(f"[{last}][effects]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mixfx]")
|
|
568
625
|
last = "mixfx"
|
|
569
626
|
|
|
570
|
-
post: List[str] =
|
|
627
|
+
post: List[str] = list(post_limit)
|
|
571
628
|
if args.fade_in:
|
|
572
629
|
post.append(f"afade=t=in:st=0:d={args.fade_in:g}")
|
|
573
630
|
if args.fade_out and end_dur:
|
|
574
|
-
|
|
631
|
+
fade_out = min(args.fade_out, end_dur) # cut to the output, so it still ends silent
|
|
632
|
+
post.append(f"afade=t=out:st={end_dur - fade_out:.3f}:d={fade_out:g}")
|
|
575
633
|
# The audio track is conformed to the source duration whenever it is known: padded with
|
|
576
634
|
# silence if the graph came out short, trimmed if long. A mixed track can come out a few
|
|
577
635
|
# hundredths short (amix's dropout_transition, a looped bed's atrim boundary), and with
|
|
@@ -628,7 +686,7 @@ def main() -> int:
|
|
|
628
686
|
audio_block["music_volume"] = args.music_volume
|
|
629
687
|
audio_block["duck"] = ({"amount_db": args.duck_amount, "threshold_db": round(args.duck_threshold, 4),
|
|
630
688
|
"threshold_linear": float(f"{db_to_linear(args.duck_threshold):.6g}"),
|
|
631
|
-
"ratio":
|
|
689
|
+
"ratio": DUCK_GATE_RATIO,
|
|
632
690
|
"attack_ms": args.duck_attack, "release_ms": args.duck_release}
|
|
633
691
|
if args.duck else None)
|
|
634
692
|
verification: List[Dict[str, Any]] = []
|
package/scripts/background.py
CHANGED
|
@@ -19,7 +19,7 @@ import sys
|
|
|
19
19
|
|
|
20
20
|
from _common.alpha import add_alpha_args, check_alpha_args, finish_chain, report as alpha_report, video_line
|
|
21
21
|
from _common.emit import note
|
|
22
|
-
from _common import STATE, add_common, apply_common, die, draws_chroma, emit, ffmpeg_base, info,
|
|
22
|
+
from _common import STATE, add_common, apply_common, die, draws_chroma, emit, ffmpeg_base, info, probe, rgb_chain_to_bt709, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def gradient_source(w: int, h: int, fps: float, c0: str, c1: str, angle: float) -> str:
|