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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPuJXQyAeWpFcTtcnxo1UN
This commit is contained in:
parent
99ec273020
commit
4d2cea15a7
|
|
@ -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<String> xfadeTimelineCommand(List<Path> segments, List<Double> durations, double xf,
|
||||
boolean withAudio, Path output) {
|
||||
List<String> xfadeVideoCommand(List<Path> segments, List<Double> durations, double xf, Path output) {
|
||||
List<String> 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<String> acrossfadeAudioCommand(List<Path> segments, double xf, Path output) {
|
||||
List<String> 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<String> 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<Double> durations, double xf) {
|
||||
double boundary = 0;
|
||||
|
|
|
|||
|
|
@ -224,12 +224,15 @@ class HighlightFfmpegRendererTest {
|
|||
HighlightFfmpegRenderer renderer = renderer();
|
||||
List<Double> 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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue