ffmpeg-skill 1.11.1 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/SKILL.md +27 -21
- package/docs/contract.md +43 -9
- package/package.json +1 -1
- package/references/gotchas.md +57 -7
- package/references/scripts.md +119 -10
- package/scripts/_common.py +378 -0
- package/scripts/_contract.py +60 -8
- package/scripts/audio.py +99 -5
- package/scripts/caption.py +449 -16
- package/scripts/check.py +15 -0
- package/scripts/graphics.py +18 -3
- package/scripts/loudness.py +3 -0
- package/scripts/overlay.py +9 -1
- package/scripts/render.py +69 -15
package/scripts/_contract.py
CHANGED
|
@@ -157,9 +157,11 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
157
157
|
"multicam": dict(role="execution", inputs=["reference camera", "other cameras / recorders"], outputs=["switched multicam video artifact"],
|
|
158
158
|
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
159
159
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
160
|
-
"audio": dict(role="execution", inputs=["video or audio asset", "music bed (--music) or replacement track (--replace)"], outputs=["artifact with the processed audio (video stream-copied, or dropped when -o has an audio extension)"],
|
|
160
|
+
"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)"],
|
|
161
161
|
required=FF, optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaincompress", "when": "--duck"},
|
|
162
|
-
{"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit"}, {"capability": "filter:agate", "when": "--gate"},
|
|
162
|
+
{"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit / --voice strong"}, {"capability": "filter:agate", "when": "--gate"},
|
|
163
|
+
{"capability": "filter:deesser", "when": "--voice medium (the default) / --voice strong"},
|
|
164
|
+
{"capability": "filter:extrastereo", "when": "--stereo-widen"},
|
|
163
165
|
{"capability": AAC, "when": "output extension isn't .mp3/.opus/.ogg/.flac (audio_codec_for()'s default)"}] + AUDIO_OUT,
|
|
164
166
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
165
167
|
"loudness": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["loudness measurement JSON (--measure-only)", "normalised artifact (video stream-copied)"],
|
|
@@ -411,7 +413,9 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
411
413
|
extra = {"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"},
|
|
412
414
|
"notes": {"type": "array", "items": {"type": "string"}}}
|
|
413
415
|
elif name == "loudness":
|
|
414
|
-
extra = {"measured": {"type": "object", "description": "
|
|
416
|
+
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"},
|
|
417
|
+
"targets": {"type": "object", "description": "the requested lufs / tp / lra"},
|
|
418
|
+
"result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and encodes"}}
|
|
415
419
|
elif name == "cut":
|
|
416
420
|
extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
|
|
417
421
|
"duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
|
|
@@ -426,7 +430,8 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
426
430
|
elif name == "audio":
|
|
427
431
|
extra = {"video": {"type": "boolean", "description": "true when the input's video stream was copied; false for an audio output extension (extraction)"},
|
|
428
432
|
"audio_stream": {"type": "integer", "description": "which input audio stream was processed (--audio-stream)"},
|
|
429
|
-
"dynamics": {"type": "array", "items": {"enum": ["agate", "acompressor", "alimiter"]}, "description": "typed dynamics filters applied, in graph order"}
|
|
433
|
+
"dynamics": {"type": "array", "items": {"enum": ["agate", "acompressor", "alimiter"]}, "description": "typed dynamics filters applied, in graph order"},
|
|
434
|
+
"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)"}}
|
|
430
435
|
props = dict(base)
|
|
431
436
|
props.update(extra)
|
|
432
437
|
required = ["status", "output", "dry_run", "commands"]
|
|
@@ -765,9 +770,53 @@ def doctor() -> Dict[str, Any]:
|
|
|
765
770
|
|
|
766
771
|
|
|
767
772
|
def _fonts_capability() -> Dict[str, Any]:
|
|
773
|
+
"""The default drawtext family (issue #66) plus, since 1.12, one entry per script the tools
|
|
774
|
+
can detect: which languages this machine can actually RENDER, not just which filters exist.
|
|
775
|
+
|
|
776
|
+
Per script: available (a font file covers it, path in `file`), missing (fontconfig knows none),
|
|
777
|
+
unknown (no fontconfig to ask -- the same "unknown is not missing" rule every other capability
|
|
778
|
+
here follows). Informational like the default font and gpu_encoders: a machine with no Thai
|
|
779
|
+
font is not a broken install, it is a machine that must not be asked to burn Thai captions.
|
|
780
|
+
"""
|
|
781
|
+
from _common import SCRIPTS, font_for_script, script_font_status
|
|
782
|
+
|
|
768
783
|
font = _default_font()
|
|
769
784
|
result = _font_available(font)
|
|
770
|
-
|
|
785
|
+
scripts: Dict[str, Any] = {}
|
|
786
|
+
for script in SCRIPTS:
|
|
787
|
+
if script == "latin":
|
|
788
|
+
continue
|
|
789
|
+
# script_font_status() is the one place that tells "fontconfig answered, nothing covers
|
|
790
|
+
# this" (missing) apart from "there is no working fontconfig to ask" (unknown) -- the same
|
|
791
|
+
# distinction the tools refuse or continue on.
|
|
792
|
+
status = script_font_status(script)
|
|
793
|
+
scripts[script] = {"status": status, "file": font_for_script(script) if status == "available" else None}
|
|
794
|
+
return {"default_font": font, "status": result["status"], "detail": result["detail"], "scripts": scripts}
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _fonts_summary_line(fonts: Dict[str, Any]) -> str:
|
|
798
|
+
"""One line for the plain-text doctor: which scripts render here, which do not."""
|
|
799
|
+
scripts = fonts.get("scripts") or {}
|
|
800
|
+
by_state: Dict[str, List[str]] = {"available": [], "missing": [], "unknown": []}
|
|
801
|
+
for name, entry in scripts.items():
|
|
802
|
+
by_state.setdefault(entry["status"], []).append(name)
|
|
803
|
+
# the default font's `detail` (which family fontconfig substituted, or why it is unknown) is
|
|
804
|
+
# the actionable half of a non-available status, and the line has room for it
|
|
805
|
+
head = f"fonts: '{fonts['default_font']}' {fonts['status']}"
|
|
806
|
+
detail = fonts.get("detail") or ""
|
|
807
|
+
# The substituted family is the actionable half of a non-available status, so it goes on the
|
|
808
|
+
# plain line -- but only while it stays short enough to keep doctor's one-line-per-capability
|
|
809
|
+
# shape (the longest other line is ~85 chars). A long explanation is --json only.
|
|
810
|
+
if detail and fonts["status"] != "available" and len(detail) <= 60:
|
|
811
|
+
head += f" ({detail})"
|
|
812
|
+
parts = [head]
|
|
813
|
+
if by_state["available"]:
|
|
814
|
+
parts.append("renders " + " ".join(by_state["available"]))
|
|
815
|
+
if by_state["missing"]:
|
|
816
|
+
parts.append("no font for " + " ".join(by_state["missing"]))
|
|
817
|
+
if by_state["unknown"]:
|
|
818
|
+
parts.append("unknown (no fontconfig) " + " ".join(by_state["unknown"]))
|
|
819
|
+
return "; ".join(parts)
|
|
771
820
|
|
|
772
821
|
|
|
773
822
|
def _capability_fix_hint(cap: str) -> str:
|
|
@@ -795,6 +844,11 @@ def _capability_fix_hint(cap: str) -> str:
|
|
|
795
844
|
return f"this ffmpeg build has no {cap[7:]} filter; {full_hint}"
|
|
796
845
|
if cap.startswith("bsf:"):
|
|
797
846
|
return f"this ffmpeg build has no {cap[4:]} bitstream filter; {full_hint}"
|
|
847
|
+
if cap.startswith("font:"):
|
|
848
|
+
from _common import LANGUAGE_NAMES, FONT_INSTALL_HINT
|
|
849
|
+
script = cap[5:]
|
|
850
|
+
return (f"no installed font covers {LANGUAGE_NAMES.get(script, script)} text on this machine; "
|
|
851
|
+
f"{FONT_INSTALL_HINT} (doctor --json .fonts.scripts lists every script)")
|
|
798
852
|
if cap == "external:whisper":
|
|
799
853
|
return "install a local whisper (whisper-cli, whisper-cpp, faster-whisper or openai-whisper) for --transcribe"
|
|
800
854
|
return f"'{cap}' is not available; see docs/contract.md"
|
|
@@ -1152,9 +1206,7 @@ def main() -> int:
|
|
|
1152
1206
|
gpu = d["gpu_encoders"]
|
|
1153
1207
|
if gpu["status"] == "parsed":
|
|
1154
1208
|
print(f"GPU-backed encoders in this build: {len(gpu['present'])} (no tool here uses one; names in doctor --json)")
|
|
1155
|
-
|
|
1156
|
-
print(f"default drawtext font '{fonts['default_font']}': {fonts['status']}"
|
|
1157
|
-
+ (f" ({fonts['detail']})" if fonts["status"] != "available" else ""))
|
|
1209
|
+
print(_fonts_summary_line(d["fonts"]))
|
|
1158
1210
|
print("full detail: doctor --json (capability lists, per-tool `usable`, fix hints)")
|
|
1159
1211
|
for err in d["errors"]:
|
|
1160
1212
|
print(f"detection error: {err}", file=sys.stderr)
|
package/scripts/audio.py
CHANGED
|
@@ -7,9 +7,12 @@ drops the picture, so `audio.py talk.mp4 -o talk.wav` is an extraction.
|
|
|
7
7
|
Examples:
|
|
8
8
|
python3 audio.py interview.mp4 --denoise # FFT noise reduction
|
|
9
9
|
python3 audio.py interview.mp4 --voice # highpass + de-esser + compressor + denoise
|
|
10
|
+
python3 audio.py interview.mp4 --voice light # highpass + gentle compression only (light|medium|strong)
|
|
10
11
|
python3 audio.py talk.mp4 --music bed.mp3 --duck # music under speech, auto-ducked
|
|
11
12
|
python3 audio.py talk.mp4 --music bed.mp3 --music-volume -18 --music-fade-out 3 # bed fades, voice does not
|
|
12
13
|
python3 audio.py clip.mp4 --fade-in 0.5 --fade-out 1 --stereo
|
|
14
|
+
python3 audio.py talk.mp4 --music bed.mp3 --duck --duck-threshold -30 --duck-release 250 # ducks earlier and recovers faster
|
|
15
|
+
python3 audio.py band.wav --stereo-widen 0.5 -o wide.wav # wider stereo image (a real stereo source; mono is refused)
|
|
13
16
|
python3 audio.py surround.mov --downmix # 5.1 -> stereo with proper centre/LFE weights
|
|
14
17
|
python3 audio.py clip.mp4 --replace narration.wav # swap the audio track entirely
|
|
15
18
|
python3 audio.py interview.mp4 -o interview.wav # extract the audio (no video in the output)
|
|
@@ -18,12 +21,29 @@ Examples:
|
|
|
18
21
|
"""
|
|
19
22
|
import argparse
|
|
20
23
|
import sys
|
|
21
|
-
from typing import List
|
|
24
|
+
from typing import Any, Dict, List
|
|
22
25
|
|
|
23
26
|
from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, run_keeping_subtitles, fmt_secs
|
|
24
27
|
|
|
25
28
|
VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
|
|
26
29
|
|
|
30
|
+
# --voice [light|medium|strong] (1.13). "medium" is the chain --voice has always produced, so a
|
|
31
|
+
# bare --voice (and every existing call and MCP request) is byte-identical to before. "light"
|
|
32
|
+
# leaves the noise floor and the sibilance alone -- it only removes rumble and evens the level,
|
|
33
|
+
# which is what a good room recording needs; "strong" is for phone/laptop audio: a harder
|
|
34
|
+
# de-esser, a second compression stage and a soft limiter at -1 dBFS so the peaks stop there
|
|
35
|
+
# instead of at whatever the make-up gain produced.
|
|
36
|
+
VOICE_LEVELS = {
|
|
37
|
+
"light": "highpass=f=80,acompressor=threshold=-18dB:ratio=2:attack=5:release=80:makeup=1",
|
|
38
|
+
"medium": VOICE_CHAIN,
|
|
39
|
+
"strong": VOICE_CHAIN + ",deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# The sidechain threshold the music bed has used since 1.4 is the linear 0.05 that ffmpeg's
|
|
43
|
+
# sidechaincompress takes; -26.0206 dBFS is that same number in the unit the flag speaks, so the
|
|
44
|
+
# default command line is unchanged to the byte while the value is now sayable.
|
|
45
|
+
DUCK_THRESHOLD_DB = -26.0206
|
|
46
|
+
|
|
27
47
|
# Typed dynamics: every flag maps to one real option of one ffmpeg filter, validated against the
|
|
28
48
|
# range that filter documents (ffmpeg -h filter=acompressor / alimiter / agate). dB flags are
|
|
29
49
|
# converted to the linear value the filter takes, so no string reaches the graph unchecked.
|
|
@@ -79,13 +99,23 @@ def main() -> int:
|
|
|
79
99
|
clean = ap.add_argument_group("clean-up")
|
|
80
100
|
clean.add_argument("--denoise", action="store_true", help="FFT noise reduction (afftdn, adaptive)")
|
|
81
101
|
clean.add_argument("--denoise-strength", type=float, default=25.0, help="noise floor in dB to remove, 10..60 (default 25)")
|
|
82
|
-
clean.add_argument("--voice",
|
|
102
|
+
clean.add_argument("--voice", nargs="?", const="medium", choices=["light", "medium", "strong"], default=None,
|
|
103
|
+
help="speech preset (default medium when the flag is given bare): light = highpass 80 Hz + gentle compression; "
|
|
104
|
+
"medium = highpass, de-esser, denoise, gentle compression; strong = medium plus a harder de-esser, a second "
|
|
105
|
+
"compressor and a soft limiter at -1 dBFS. MCP/JSON callers may still send the 1.12 boolean true, "
|
|
106
|
+
"which is the bare flag and so means medium")
|
|
83
107
|
clean.add_argument("--gain", type=float, help="gain in dB applied to the main track")
|
|
84
108
|
music = ap.add_argument_group("music")
|
|
85
109
|
music.add_argument("--music", help="music file to mix underneath")
|
|
86
110
|
music.add_argument("--music-volume", type=float, default=-14.0, help="music level in dB relative to full scale (default -14)")
|
|
87
111
|
music.add_argument("--duck", action="store_true", help="auto-duck the music when the main track has speech (sidechain compressor)")
|
|
88
112
|
music.add_argument("--duck-amount", type=float, default=12.0, help="how many dB to duck (default 12)")
|
|
113
|
+
music.add_argument("--duck-threshold", type=float, default=DUCK_THRESHOLD_DB,
|
|
114
|
+
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)")
|
|
115
|
+
music.add_argument("--duck-attack", type=float, default=20.0, help="ms the bed takes to duck once speech starts (default 20)")
|
|
116
|
+
music.add_argument("--duck-release", type=float, default=400.0, help="ms the bed takes to come back up after speech (default 400)")
|
|
117
|
+
music.add_argument("--effects", help="a third track (sound effects/atmos) mixed in at --effects-volume; never ducked")
|
|
118
|
+
music.add_argument("--effects-volume", type=float, default=-14.0, help="effects level in dB relative to full scale (default -14)")
|
|
89
119
|
music.add_argument("--music-loop", action="store_true", help="loop the music if shorter than the video")
|
|
90
120
|
fades = ap.add_argument_group("fades / layout")
|
|
91
121
|
fades.add_argument("--fade-in", type=float, default=0.0, help="seconds")
|
|
@@ -94,6 +124,10 @@ def main() -> int:
|
|
|
94
124
|
channels = fades.add_mutually_exclusive_group()
|
|
95
125
|
channels.add_argument("--stereo", action="store_true", help="force 2-channel output (mono is duplicated to both sides)")
|
|
96
126
|
channels.add_argument("--mono", action="store_true", help="force 1-channel output")
|
|
127
|
+
fades.add_argument("--stereo-widen", type=float, default=None, metavar="AMOUNT",
|
|
128
|
+
help="widen the stereo image, 0..1 (0 = untouched, 1 = maximum); needs a real stereo source: a mono input is "
|
|
129
|
+
"refused (duplicating it leaves both channels identical, so there is nothing to widen) and more than two "
|
|
130
|
+
"channels are refused unless --downmix folds them to stereo first")
|
|
97
131
|
fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo using standard weights")
|
|
98
132
|
fades.add_argument("--replace", help="replace the audio with this file (trimmed/padded to the video)")
|
|
99
133
|
dyn = ap.add_argument_group("dynamics (typed; each flag is one option of ffmpeg's acompressor / alimiter / agate)")
|
|
@@ -124,6 +158,26 @@ def main() -> int:
|
|
|
124
158
|
if not getattr(args, switch) and any(getattr(args, f) is not None for f in DYNAMICS[flag_group]):
|
|
125
159
|
die(f"--{switch} is off but one of its parameters was given; add --{switch}")
|
|
126
160
|
|
|
161
|
+
# Same rule as the typed dynamics above, for the ducking knobs: a parameter for a switch that
|
|
162
|
+
# is off does nothing, and a caller who says --duck-release 250 and gets the default 400 ms has
|
|
163
|
+
# no way to notice. --duck itself needs a bed to duck.
|
|
164
|
+
duck_params = [f"--duck-{name}" for name in ("threshold", "attack", "release")
|
|
165
|
+
if getattr(args, f"duck_{name}") != ap.get_default(f"duck_{name}")] + \
|
|
166
|
+
(["--duck-amount"] if args.duck_amount != ap.get_default("duck_amount") else [])
|
|
167
|
+
if not args.duck and duck_params:
|
|
168
|
+
die(f"--duck is off but {duck_params[0]} was given; add --duck")
|
|
169
|
+
if args.duck and not args.music:
|
|
170
|
+
die("--duck ducks the music bed under the main track, but no --music was given; add --music FILE")
|
|
171
|
+
|
|
172
|
+
for flag, value, lo, hi in (("--duck-amount", args.duck_amount, 0.0, 60.0),
|
|
173
|
+
("--duck-threshold", args.duck_threshold, -60.0, 0.0),
|
|
174
|
+
("--duck-attack", args.duck_attack, 0.01, 2000.0),
|
|
175
|
+
("--duck-release", args.duck_release, 0.01, 9000.0)):
|
|
176
|
+
if not (lo <= value <= hi):
|
|
177
|
+
die(f"{flag} {value:g} is outside {lo:g}..{hi:g} (the range ffmpeg's sidechaincompress accepts)")
|
|
178
|
+
if args.stereo_widen is not None and not (0.0 <= args.stereo_widen <= 1.0):
|
|
179
|
+
die(f"--stereo-widen must be 0..1 (0 = untouched, 1 = maximum), got {args.stereo_widen:g}")
|
|
180
|
+
|
|
127
181
|
meta = probe(args.input)
|
|
128
182
|
dur = meta.get("duration") or 0.0
|
|
129
183
|
has_video = bool(meta.get("video"))
|
|
@@ -136,6 +190,19 @@ def main() -> int:
|
|
|
136
190
|
die(f"--audio-stream {args.audio_stream}: input has {len(streams)} audio stream(s), 0..{len(streams) - 1}")
|
|
137
191
|
if args.audio_stream and not streams and not STATE.dry_run:
|
|
138
192
|
die("--audio-stream needs an input with audio streams")
|
|
193
|
+
in_channels = (meta.get("audio") or {}).get("channels") or 0
|
|
194
|
+
if args.stereo_widen is not None:
|
|
195
|
+
if args.mono:
|
|
196
|
+
die("--stereo-widen and --mono contradict each other: there is no stereo image in a 1-channel output")
|
|
197
|
+
# Widening scales the side signal (L-R). Duplicating a mono track to two channels leaves
|
|
198
|
+
# L == R, so the side signal is exactly zero and scaling it changes nothing: --stereo is
|
|
199
|
+
# not a way in, it is a way to a file that measures mono no matter the amount asked for.
|
|
200
|
+
if in_channels == 1:
|
|
201
|
+
die("--stereo-widen needs a real stereo source: mono has no stereo image to widen; keep it mono or "
|
|
202
|
+
"use --stereo to duplicate it, but widening needs a real stereo source")
|
|
203
|
+
if in_channels > 2 and not args.downmix:
|
|
204
|
+
die(f"--stereo-widen needs a stereo track; this input has {in_channels} channels. Add --downmix to fold it "
|
|
205
|
+
"to stereo first (the widening then happens after the downmix), or leave the channels alone.")
|
|
139
206
|
|
|
140
207
|
inputs: List[str] = ["-i", args.input]
|
|
141
208
|
main_src = f"0:a:{args.audio_stream}"
|
|
@@ -150,7 +217,7 @@ def main() -> int:
|
|
|
150
217
|
if args.downmix:
|
|
151
218
|
fx.append("pan=stereo|FL=0.707*FC+FL+0.5*BL+0.5*SL+0.5*LFE|FR=0.707*FC+FR+0.5*BR+0.5*SR+0.5*LFE")
|
|
152
219
|
if args.voice:
|
|
153
|
-
fx.append(
|
|
220
|
+
fx.append(VOICE_LEVELS[args.voice])
|
|
154
221
|
elif args.denoise:
|
|
155
222
|
if not 10 <= args.denoise_strength <= 60:
|
|
156
223
|
die(f"--denoise-strength must be 10..60 (dB of noise floor to remove), got {args.denoise_strength:g}")
|
|
@@ -172,6 +239,11 @@ def main() -> int:
|
|
|
172
239
|
# 1 channel: already mono; the stereo pan used to halve it (-6 dB) because c1 was silence
|
|
173
240
|
elif args.stereo:
|
|
174
241
|
fx.append("aformat=channel_layouts=stereo")
|
|
242
|
+
if args.stereo_widen is not None:
|
|
243
|
+
# extrastereo widens by scaling the side (L-R) signal: m=1 is the input, m=3 is as wide
|
|
244
|
+
# as it goes before the centre collapses. It runs after the channel layout is settled, so
|
|
245
|
+
# a --downmix 5.1 source is widened on the stereo fold-down rather than on six channels.
|
|
246
|
+
fx.append(f"extrastereo=m={1 + 2 * args.stereo_widen:g}")
|
|
175
247
|
|
|
176
248
|
graph: List[str] = []
|
|
177
249
|
graph.append(f"[{main_src}]{','.join(fx) if fx else 'anull'}[main]")
|
|
@@ -192,13 +264,26 @@ def main() -> int:
|
|
|
192
264
|
if args.duck:
|
|
193
265
|
graph.append("[main]asplit=2[mainA][sc]")
|
|
194
266
|
graph.append(
|
|
195
|
-
f"[music][sc]sidechaincompress=threshold=
|
|
267
|
+
f"[music][sc]sidechaincompress=threshold={db_to_linear(args.duck_threshold):.6g}"
|
|
268
|
+
f":ratio={max(2.0, args.duck_amount / 3):.1f}:attack={args.duck_attack:g}:release={args.duck_release:g}:makeup=1[ducked]"
|
|
196
269
|
)
|
|
197
270
|
graph.append("[mainA][ducked]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mix]")
|
|
198
271
|
else:
|
|
199
272
|
graph.append("[main][music]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mix]")
|
|
200
273
|
last = "mix"
|
|
201
274
|
|
|
275
|
+
if args.effects:
|
|
276
|
+
# A third bed, mixed in at its own level and deliberately never ducked: effects are cut
|
|
277
|
+
# to the picture, so dipping them under speech would move them off their own frames.
|
|
278
|
+
probe(args.effects)
|
|
279
|
+
inputs += ["-i", args.effects]
|
|
280
|
+
e = f"{idx}:a:0"
|
|
281
|
+
idx += 1
|
|
282
|
+
efx = [f"volume={args.effects_volume:g}dB", f"atrim=0:{dur:.3f}" if dur else "anull"]
|
|
283
|
+
graph.append(f"[{e}]{','.join(efx)}[effects]")
|
|
284
|
+
graph.append(f"[{last}][effects]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mixfx]")
|
|
285
|
+
last = "mixfx"
|
|
286
|
+
|
|
202
287
|
post: List[str] = []
|
|
203
288
|
if args.fade_in:
|
|
204
289
|
post.append(f"afade=t=in:st=0:d={args.fade_in:g}")
|
|
@@ -237,7 +322,16 @@ def main() -> int:
|
|
|
237
322
|
die(f"{output} unexpectedly contains a video stream")
|
|
238
323
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz"
|
|
239
324
|
+ (", video stream-copied" if has_video and not audio_out else ", video dropped" if has_video else "") + ")")
|
|
240
|
-
|
|
325
|
+
audio_block: Dict[str, Any] = {"voice": args.voice, "stereo_widen": args.stereo_widen,
|
|
326
|
+
"effects": bool(args.effects), "effects_volume": args.effects_volume if args.effects else None}
|
|
327
|
+
if args.music:
|
|
328
|
+
audio_block["music_volume"] = args.music_volume
|
|
329
|
+
audio_block["duck"] = ({"amount_db": args.duck_amount, "threshold_db": round(args.duck_threshold, 4),
|
|
330
|
+
"threshold_linear": float(f"{db_to_linear(args.duck_threshold):.6g}"),
|
|
331
|
+
"ratio": round(max(2.0, args.duck_amount / 3), 1),
|
|
332
|
+
"attack_ms": args.duck_attack, "release_ms": args.duck_release}
|
|
333
|
+
if args.duck else None)
|
|
334
|
+
emit(output, audio=audio_block, video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
|
|
241
335
|
dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f],
|
|
242
336
|
dropped_non_av_streams=dropped_streams)
|
|
243
337
|
return 0
|