forked from jsl/video_editing_poc
Add edit audio mixing
This commit is contained in:
parent
46a4982ef9
commit
a3ca4826f5
|
|
@ -799,7 +799,7 @@ It writes the script only and requires an imported audio file.
|
||||||
16. [x] Add `PUT /v1/edit-projects/{projectId}/plan`.
|
16. [x] Add `PUT /v1/edit-projects/{projectId}/plan`.
|
||||||
17. [x] Add simple segment rendering without transitions.
|
17. [x] Add simple segment rendering without transitions.
|
||||||
18. [x] Add concat-based final timeline rendering.
|
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.
|
20. [ ] Add render manifest with command metadata and output details.
|
||||||
21. [ ] Add basic transition support: cut, crossfade, fade in, fade out.
|
21. [ ] Add basic transition support: cut, crossfade, fade in, fade out.
|
||||||
22. [ ] Add SFX cue support.
|
22. [ ] Add SFX cue support.
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,15 @@ public class FfmpegEditRenderer implements EditRenderer {
|
||||||
Path timeline = work.resolve("timeline-video.mp4");
|
Path timeline = work.resolve("timeline-video.mp4");
|
||||||
run(concatCommand(concatFile, timeline));
|
run(concatCommand(concatFile, timeline));
|
||||||
Path output = store.projectDirectory(projectId).resolve("final.mp4");
|
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);
|
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());
|
"-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) {
|
private void writeConcatFile(Path file, List<Path> segments) {
|
||||||
String content = segments.stream()
|
String content = segments.stream()
|
||||||
.map(path -> "file '" + path.toAbsolutePath().toString().replace("'", "'\\''") + "'")
|
.map(path -> "file '" + path.toAbsolutePath().toString().replace("'", "'\\''") + "'")
|
||||||
|
|
|
||||||
|
|
@ -38,4 +38,19 @@ class FfmpegEditRendererTest {
|
||||||
assertThat(command).containsSubsequence("-f", "concat", "-safe", "0", "-i", "concat.txt");
|
assertThat(command).containsSubsequence("-f", "concat", "-safe", "0", "-i", "concat.txt");
|
||||||
assertThat(command).containsSubsequence("-c", "copy", "timeline-video.mp4");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue