Add edit audio mixing

This commit is contained in:
JSLMPR 2026-07-10 16:15:09 +02:00
parent 46a4982ef9
commit a3ca4826f5
3 changed files with 50 additions and 2 deletions

View File

@ -799,7 +799,7 @@ It writes the script only and requires an imported audio file.
16. [x] Add `PUT /v1/edit-projects/{projectId}/plan`.
17. [x] Add simple segment rendering without transitions.
18. [x] Add concat-based final timeline rendering.
19. [ ] Add music and voiceover audio mixing.
19. [x] Add music and voiceover audio mixing.
20. [ ] Add render manifest with command metadata and output details.
21. [ ] Add basic transition support: cut, crossfade, fade in, fade out.
22. [ ] Add SFX cue support.

View File

@ -64,7 +64,15 @@ public class FfmpegEditRenderer implements EditRenderer {
Path timeline = work.resolve("timeline-video.mp4");
run(concatCommand(concatFile, timeline));
Path output = store.projectDirectory(projectId).resolve("final.mp4");
copy(timeline, output);
Path audioDirectory = store.projectDirectory(projectId).resolve("audio");
Path music = audioDirectory.resolve("music.wav");
Path voiceover = audioDirectory.resolve("voiceover.wav");
if (Files.isRegularFile(music) || Files.isRegularFile(voiceover)) {
run(audioMixCommand(timeline, Files.isRegularFile(music) ? music : null,
Files.isRegularFile(voiceover) ? voiceover : null, output));
} else {
copy(timeline, output);
}
projectService.updateProject(projectId, EditProjectStatus.RENDERED, Path.of(project.inputDirectory()), null);
}
@ -91,6 +99,31 @@ public class FfmpegEditRenderer implements EditRenderer {
"-i", concatFile.toString(), "-c", "copy", output.toString());
}
List<String> audioMixCommand(Path timeline, Path music, Path voiceover, Path output) {
List<String> command = new ArrayList<>(List.of(properties.getFfmpegBinary(), "-hide_banner", "-y",
"-i", timeline.toString()));
List<String> labels = new ArrayList<>(List.of("[0:a]"));
int input = 1;
StringBuilder filters = new StringBuilder();
if (music != null) {
command.addAll(List.of("-i", music.toString()));
filters.append("[").append(input).append(":a]volume=0.25[music];");
labels.add("[music]");
input++;
}
if (voiceover != null) {
command.addAll(List.of("-i", voiceover.toString()));
filters.append("[").append(input).append(":a]volume=1.0[voice];");
labels.add("[voice]");
}
filters.append(String.join("", labels)).append("amix=inputs=").append(labels.size())
.append(":duration=first:dropout_transition=2[a]");
command.addAll(List.of("-filter_complex", filters.toString(), "-map", "0:v:0", "-map", "[a]",
"-c:v", "copy", "-c:a", "aac", "-b:a", properties.getAudioBitrate(),
"-ar", Integer.toString(properties.getAudioSampleRate()), output.toString()));
return List.copyOf(command);
}
private void writeConcatFile(Path file, List<Path> segments) {
String content = segments.stream()
.map(path -> "file '" + path.toAbsolutePath().toString().replace("'", "'\\''") + "'")

View File

@ -38,4 +38,19 @@ class FfmpegEditRendererTest {
assertThat(command).containsSubsequence("-f", "concat", "-safe", "0", "-i", "concat.txt");
assertThat(command).containsSubsequence("-c", "copy", "timeline-video.mp4");
}
@Test
void buildsMusicAndVoiceoverMixCommand() {
VideoClippingProperties properties = new VideoClippingProperties();
FfmpegEditRenderer renderer = new FfmpegEditRenderer(properties, mock(EditProjectStore.class),
mock(EditProjectService.class), mock(EditPlanValidator.class), command -> null);
var command = renderer.audioMixCommand(Path.of("timeline.mp4"), Path.of("music.wav"),
Path.of("voiceover.wav"), Path.of("final.mp4"));
assertThat(command).containsSubsequence("-i", "timeline.mp4", "-i", "music.wav", "-i", "voiceover.wav");
assertThat(command).contains("[1:a]volume=0.25[music];[2:a]volume=1.0[voice];"
+ "[0:a][music][voice]amix=inputs=3:duration=first:dropout_transition=2[a]");
assertThat(command).containsSubsequence("-c:v", "copy", "-c:a", "aac");
}
}