feat: build_crossfade_merge_command — chained acrossfade / concat

This commit is contained in:
2026-07-02 15:45:33 +02:00
parent 7573277a03
commit 52417ea1ee
2 changed files with 69 additions and 1 deletions
+29
View File
@@ -244,6 +244,35 @@ 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."""
if not clips:
raise ValueError("no clips to merge")
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:
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)
else:
inputs = "".join(f"[{i}]" for i in range(len(clips)))
fc = f"{inputs}concat=n={len(clips)}:v=0:a=1[out]"
return cmd + ["-filter_complex", fc, "-map", "[out]", *codec, out_path]
def detect_hw_encoders() -> list[str]: def detect_hw_encoders() -> list[str]:
"""Probe ffmpeg for available H.264 hardware encoders. """Probe ffmpeg for available H.264 hardware encoders.
+40 -1
View File
@@ -1,6 +1,6 @@
import tempfile, os, json import tempfile, os, json
from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, resolve_keyframe, apply_keyframes_to_jobs from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, resolve_keyframe, apply_keyframes_to_jobs
from core.ffmpeg import build_audio_clip_command from core.ffmpeg import build_audio_clip_command, build_crossfade_merge_command
from core.annotations import build_annotation_json_path, upsert_clip_annotation from core.annotations import build_annotation_json_path, upsert_clip_annotation
from main import ProcessedDB from main import ProcessedDB
@@ -101,6 +101,45 @@ def test_audio_clip_command_appends_filter_chain():
assert i < len(cmd) - 1 and cmd[-1] == "/o/a.wav" assert i < len(cmd) - 1 and cmd[-1] == "/o/a.wav"
def test_merge_single_clip_reencodes():
cmd = build_crossfade_merge_command(["/a.wav"], 0.5, "/o/out.mp3")
assert cmd[0] == "ffmpeg"
assert cmd.count("-i") == 1
assert "libmp3lame" in cmd # codec by out ext
assert "acrossfade" not in " ".join(cmd)
assert "-map" not in cmd
assert "-filter_complex" not in cmd
assert cmd[-1] == "/o/out.mp3"
def test_merge_two_clips_acrossfade():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.5, "/o/out.wav")
assert cmd.count("-i") == 2
fc = cmd[cmd.index("-filter_complex") + 1]
assert "[0][1]acrossfade=d=0.5" in fc
assert "[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
assert "pcm_s16le" in cmd # codec by out ext (multi-clip path)
def test_merge_three_clips_chains():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], 1.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert fc.count("acrossfade=d=1.0") == 2 # two joins
assert "[0][1]acrossfade=d=1.0[a1]" in fc
assert "[a1][2]acrossfade=d=1.0[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
def test_merge_zero_crossfade_uses_concat():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "concat=n=2:v=0:a=1" in fc
assert "acrossfade" not in fc
def test_merge_empty_raises():
import pytest
with pytest.raises(ValueError):
build_crossfade_merge_command([], 0.5, "/o/o.wav")
# --- ProcessedDB --- # --- ProcessedDB ---
def test_db_add_and_get_markers(): def test_db_add_and_get_markers():