From a3ca4826f570ff54b0820db4df1daf8f553b4eec Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Fri, 10 Jul 2026 16:15:09 +0200 Subject: [PATCH] Add edit audio mixing --- docs/cinematic-video-editing-service-plan.md | 2 +- .../editing/FfmpegEditRenderer.java | 35 ++++++++++++++++++- .../editing/FfmpegEditRendererTest.java | 15 ++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index c8d42ea..08d6eeb 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -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. diff --git a/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java index 400c81e..896a06a 100644 --- a/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java +++ b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java @@ -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 audioMixCommand(Path timeline, Path music, Path voiceover, Path output) { + List command = new ArrayList<>(List.of(properties.getFfmpegBinary(), "-hide_banner", "-y", + "-i", timeline.toString())); + List 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 segments) { String content = segments.stream() .map(path -> "file '" + path.toAbsolutePath().toString().replace("'", "'\\''") + "'") diff --git a/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java b/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java index f9cd229..a96578d 100644 --- a/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java +++ b/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java @@ -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"); + } }