diff --git a/core/ffmpeg.py b/core/ffmpeg.py index 24ca487..3be88b4 100644 --- a/core/ffmpeg.py +++ b/core/ffmpeg.py @@ -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]: """Probe ffmpeg for available H.264 hardware encoders. diff --git a/tests/test_utils.py b/tests/test_utils.py index a1b30da..fc9946f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,6 @@ 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 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 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" +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 --- def test_db_add_and_get_markers():