diff --git a/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java b/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java index b44d252..55f2eb8 100644 --- a/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java +++ b/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java @@ -111,7 +111,17 @@ public class HighlightFfmpegRenderer { log(projectId, highlight.highlightId(), "highlight_render_concat_started", "segments", Integer.toString(segments.size()), "crossfade_seconds", Double.toString(crossfade)); if (crossfade > 0.0) { - run(xfadeTimelineCommand(segments, segmentDurations, crossfade, sourceAudioPresent, timeline), commands); + // Video and audio crossfades run as SEPARATE passes then mux — one combined filtergraph starves + // the audio path and truncates it (which cut the music off early). + Path xfadeVideo = work.resolve("timeline-xfade-v.mp4"); + run(xfadeVideoCommand(segments, segmentDurations, crossfade, xfadeVideo), commands); + if (sourceAudioPresent) { + Path xfadeAudio = work.resolve("timeline-xfade-a.m4a"); + run(acrossfadeAudioCommand(segments, crossfade, xfadeAudio), commands); + run(muxCommand(xfadeVideo, xfadeAudio, timeline), commands); + } else { + copy(xfadeVideo, timeline); + } } else { Path concatFile = work.resolve("concat.txt"); writeConcatFile(concatFile, segments); @@ -481,12 +491,12 @@ public class HighlightFfmpegRenderer { } /** - * Cross-dissolve the segments into one timeline with an xfade (and acrossfade) chain, so beat boundaries - * blend instead of hard-cutting. Each transition overlaps by {@code xf} seconds; the total shrinks by - * (n-1)*xf. Package-visible for offset-math testing. + * Cross-dissolves the segment VIDEO into one timeline with an xfade chain, so beat boundaries blend + * instead of hard-cutting. Each transition overlaps by {@code xf} seconds; the total shrinks by (n-1)*xf. + * Audio is crossfaded in a SEPARATE pass ({@link #acrossfadeAudioCommand}) and muxed back in: running + * xfade and acrossfade in one filtergraph on the same inputs starves the audio path and truncates it. */ - List xfadeTimelineCommand(List segments, List durations, double xf, - boolean withAudio, Path output) { + List xfadeVideoCommand(List segments, List durations, double xf, Path output) { List command = new ArrayList<>(List.of(properties.getFfmpegBinary(), "-hide_banner", "-y")); for (Path segment : segments) { command.add("-i"); @@ -494,7 +504,6 @@ public class HighlightFfmpegRenderer { } StringBuilder fc = new StringBuilder(); String vPrev = "[0:v]"; - String aPrev = "[0:a]"; double accum = durations.get(0); int last = segments.size() - 1; for (int i = 1; i < segments.size(); i++) { @@ -503,27 +512,40 @@ public class HighlightFfmpegRenderer { fc.append(vPrev).append("[").append(i).append(":v]xfade=transition=fade:duration=") .append(fixed(xf)).append(":offset=").append(fixed(offset)).append(vOut).append(";"); vPrev = vOut; - if (withAudio) { - String aOut = i == last ? "[aout]" : "[a" + i + "]"; - fc.append(aPrev).append("[").append(i).append(":a]acrossfade=d=") - .append(fixed(xf)).append(aOut).append(";"); - aPrev = aOut; - } accum += durations.get(i) - xf; } - command.addAll(List.of("-filter_complex", fc.substring(0, fc.length() - 1), "-map", "[vout]")); - if (withAudio) { - command.addAll(List.of("-map", "[aout]")); - } - command.addAll(List.of("-c:v", "libx264", "-preset", "veryfast", "-crf", "18")); - if (withAudio) { - command.addAll(List.of("-c:a", "aac", "-b:a", properties.getAudioBitrate(), - "-ar", Integer.toString(properties.getAudioSampleRate()))); - } - command.add(output.toString()); + command.addAll(List.of("-filter_complex", fc.substring(0, fc.length() - 1), "-map", "[vout]", "-an", + "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", output.toString())); return List.copyOf(command); } + /** Cross-dissolves the segment AUDIO into one bed with an acrossfade chain (matches the video xfade length). */ + List acrossfadeAudioCommand(List segments, double xf, Path output) { + List command = new ArrayList<>(List.of(properties.getFfmpegBinary(), "-hide_banner", "-y")); + for (Path segment : segments) { + command.add("-i"); + command.add(segment.toString()); + } + StringBuilder fc = new StringBuilder(); + String aPrev = "[0:a]"; + int last = segments.size() - 1; + for (int i = 1; i < segments.size(); i++) { + String aOut = i == last ? "[aout]" : "[a" + i + "]"; + fc.append(aPrev).append("[").append(i).append(":a]acrossfade=d=").append(fixed(xf)).append(aOut) + .append(";"); + aPrev = aOut; + } + command.addAll(List.of("-filter_complex", fc.substring(0, fc.length() - 1), "-map", "[aout]", "-vn", + "-c:a", "aac", "-b:a", properties.getAudioBitrate(), + "-ar", Integer.toString(properties.getAudioSampleRate()), output.toString())); + return List.copyOf(command); + } + + List muxCommand(Path video, Path audio, Path output) { + return List.of(properties.getFfmpegBinary(), "-hide_banner", "-y", "-i", video.toString(), + "-i", audio.toString(), "-c", "copy", "-map", "0:v:0", "-map", "1:a:0", output.toString()); + } + /** Shifts an overlay's timing onto the crossfade-compressed timeline (each earlier transition removes xf). */ static TextOverlay shiftOverlayForCrossfade(TextOverlay overlay, List durations, double xf) { double boundary = 0; diff --git a/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java b/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java index 037e62f..e3bec59 100644 --- a/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java +++ b/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java @@ -224,12 +224,15 @@ class HighlightFfmpegRendererTest { HighlightFfmpegRenderer renderer = renderer(); List durations = List.of(2.0, 2.5, 3.0); - String cmd = String.join(" ", renderer.xfadeTimelineCommand( - List.of(Path.of("s0.mp4"), Path.of("s1.mp4"), Path.of("s2.mp4")), - durations, 0.25, true, Path.of("t.mp4"))); - assertThat(cmd).contains("xfade=transition=fade:duration=0.250:offset=1.750") // dur0 - xf - .contains("offset=4.000") // dur0+dur1 - 2*xf - .contains("acrossfade=d=0.250").contains("[vout]").contains("[aout]"); + var segs = List.of(Path.of("s0.mp4"), Path.of("s1.mp4"), Path.of("s2.mp4")); + // Video and audio crossfade in SEPARATE passes (a combined graph starves/truncates the audio). + String video = String.join(" ", renderer.xfadeVideoCommand(segs, durations, 0.25, Path.of("v.mp4"))); + assertThat(video).contains("xfade=transition=fade:duration=0.250:offset=1.750") // dur0 - xf + .contains("offset=4.000") // dur0+dur1 - 2*xf + .contains("[vout]").contains("-an").doesNotContain("acrossfade"); + String audio = String.join(" ", renderer.acrossfadeAudioCommand(segs, 0.25, Path.of("a.m4a"))); + assertThat(audio).contains("acrossfade=d=0.250").contains("[aout]").contains("-vn") + .doesNotContain("xfade=transition"); // An overlay on the 3rd beat (starts 4.5s uncompressed) shifts back by 2 transitions * xf. TextOverlay overlay = new TextOverlay("STRIKE", 4.5, 6.5, "lower_center_safe", "fade", "montage");