feat: per-join crossfade durations + acrossfade curve in build_crossfade_merge_command

This commit is contained in:
2026-07-02 17:35:26 +02:00
parent 449b3dfa72
commit a298418156
2 changed files with 97 additions and 17 deletions
+45 -17
View File
@@ -244,32 +244,60 @@ def build_audio_clip_command(input_path: str, start: float, duration: float,
]
def build_crossfade_merge_command(clips: list[str], crossfade: float,
out_path: str) -> list[str]:
"""ffmpeg command that concatenates *clips* in order into *out_path*,
crossfading each join by *crossfade* seconds (0 = butt-join via concat).
Re-encoded per the output extension."""
_ACROSSFADE_CURVES = frozenset({
"tri", "qsin", "hsin", "esin", "log", "ipar", "qua", "cub", "squ", "cbr",
"par", "exp", "iqsin", "ihsin", "dese", "desi", "losi", "sinc", "isinc",
"nofade",
})
def build_crossfade_merge_command(clips: list[str], crossfade,
out_path: str, curves=None) -> list[str]:
"""ffmpeg command concatenating *clips* into *out_path*. *crossfade* is a
single duration (applied to every join) or a per-join list of length
len(clips)-1; 0 = butt-join. *curves* is a single acrossfade curve name or a
per-join list (default 'tri', ffmpeg's default, emitted implicitly)."""
if not clips:
raise ValueError("no clips to merge")
n = len(clips)
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
cmd = [_bin("ffmpeg"), "-y"]
for c in clips:
cmd += ["-i", c]
if len(clips) == 1:
if n == 1:
return cmd + ["-vn", *codec, out_path]
if crossfade > 0:
# Chain acrossfade: [0][1]->[a1]; [a1][2]->[a2]; …; last label = [out].
# default acrossfade curve is 'tri' (linear); per-join curves deferred
parts, prev = [], "0"
for i in range(1, len(clips)):
label = "out" if i == len(clips) - 1 else f"a{i}"
parts.append(f"[{prev}][{i}]acrossfade=d={round(crossfade, 3)}[{label}]")
prev = label
fc = ";".join(parts)
# normalize per-join params (n-1 joins)
xfs = [float(crossfade)] * (n - 1) if isinstance(crossfade, (int, float)) \
else [float(x) for x in crossfade]
if curves is None or isinstance(curves, str):
cvs = [curves or "tri"] * (n - 1)
else:
inputs = "".join(f"[{i}]" for i in range(len(clips)))
fc = f"{inputs}concat=n={len(clips)}:v=0:a=1[out]"
cvs = [str(c) for c in curves]
if len(xfs) != n - 1:
raise ValueError(f"crossfade list must have {n - 1} entries, got {len(xfs)}")
if len(cvs) != n - 1:
raise ValueError(f"curves list must have {n - 1} entries, got {len(cvs)}")
for cv in cvs:
if cv not in _ACROSSFADE_CURVES:
raise ValueError(f"unknown acrossfade curve: {cv!r}")
# Chain per join: [0][1]->[a1]; [a1][2]->[a2]; …; last label = [out].
# A join uses acrossfade when its duration>0, else per-pair concat=n=2.
# The curve suffix is emitted only for non-'tri' curves ('tri' is ffmpeg's
# default, so omitting it keeps the default command strings byte-identical).
parts, prev = [], "0"
for i in range(1, n):
j = i - 1
label = "out" if i == n - 1 else f"a{i}"
xf = xfs[j] if j < len(xfs) else 0.0
cv = cvs[j] if j < len(cvs) else "tri"
if xf > 0:
suffix = f":c1={cv}:c2={cv}" if cv != "tri" else ""
parts.append(f"[{prev}][{i}]acrossfade=d={round(xf, 3)}{suffix}[{label}]")
else:
parts.append(f"[{prev}][{i}]concat=n=2:v=0:a=1[{label}]")
prev = label
fc = ";".join(parts)
return cmd + ["-filter_complex", fc, "-map", "[out]", *codec, out_path]