From 4d2cea15a7bd56c2801b8bdfd95206eea40bcb5e Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Fri, 24 Jul 2026 11:52:43 +0200 Subject: [PATCH] Fix: music/audio cut off early with crossfades (separate video+audio passes) R6 ran the video xfade and audio acrossfade in one filtergraph on the same inputs, which starved the audio path and truncated it (final audio 4.2s vs 9.7s video) -- so the music appeared to end early. Split into two passes (xfadeVideoCommand video only, acrossfadeAudioCommand audio only) and mux them. Verified: final audio now 9.8s matching the 9.67s video, music present through the end. Isolation and unit tests confirm the two chains each produce the full compressed length. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MPuJXQyAeWpFcTtcnxo1UN --- .../editing/HighlightFfmpegRenderer.java | 68 ++++++++++++------- .../editing/HighlightFfmpegRendererTest.java | 15 ++-- 2 files changed, 54 insertions(+), 29 deletions(-) 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");