forked from jsl/video_editing_poc
Add concat edit timeline rendering
This commit is contained in:
parent
21e5cef45d
commit
46a4982ef9
|
|
@ -798,7 +798,7 @@ It writes the script only and requires an imported audio file.
|
||||||
15. [x] Add strict JSON edit plan schema and validation.
|
15. [x] Add strict JSON edit plan schema and validation.
|
||||||
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. [ ] Add concat-based final timeline rendering.
|
18. [x] Add concat-based final timeline rendering.
|
||||||
19. [ ] Add music and voiceover audio mixing.
|
19. [ ] 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.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
@ -51,11 +52,20 @@ public class FfmpegEditRenderer implements EditRenderer {
|
||||||
createDirectory(work);
|
createDirectory(work);
|
||||||
Map<String, ClipAnalysis> clips = analysis.clips().stream()
|
Map<String, ClipAnalysis> clips = analysis.clips().stream()
|
||||||
.collect(Collectors.toMap(ClipAnalysis::clipId, Function.identity()));
|
.collect(Collectors.toMap(ClipAnalysis::clipId, Function.identity()));
|
||||||
|
List<Path> segments = new ArrayList<>();
|
||||||
for (int index = 0; index < plan.decisions().size(); index++) {
|
for (int index = 0; index < plan.decisions().size(); index++) {
|
||||||
EditDecision decision = plan.decisions().get(index);
|
EditDecision decision = plan.decisions().get(index);
|
||||||
Path output = work.resolve("segment_%04d.mp4".formatted(index + 1));
|
Path output = work.resolve("segment_%04d.mp4".formatted(index + 1));
|
||||||
run(segmentCommand(clips.get(decision.clipId()).sourcePath(), decision, output));
|
run(segmentCommand(clips.get(decision.clipId()).sourcePath(), decision, output));
|
||||||
|
segments.add(output);
|
||||||
}
|
}
|
||||||
|
Path concatFile = work.resolve("concat.txt");
|
||||||
|
writeConcatFile(concatFile, segments);
|
||||||
|
Path timeline = work.resolve("timeline-video.mp4");
|
||||||
|
run(concatCommand(concatFile, timeline));
|
||||||
|
Path output = store.projectDirectory(projectId).resolve("final.mp4");
|
||||||
|
copy(timeline, output);
|
||||||
|
projectService.updateProject(projectId, EditProjectStatus.RENDERED, Path.of(project.inputDirectory()), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> segmentCommand(String source, EditDecision decision, Path output) {
|
List<String> segmentCommand(String source, EditDecision decision, Path output) {
|
||||||
|
|
@ -76,6 +86,30 @@ public class FfmpegEditRenderer implements EditRenderer {
|
||||||
return List.copyOf(command);
|
return List.copyOf(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> concatCommand(Path concatFile, Path output) {
|
||||||
|
return List.of(properties.getFfmpegBinary(), "-hide_banner", "-y", "-f", "concat", "-safe", "0",
|
||||||
|
"-i", concatFile.toString(), "-c", "copy", output.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeConcatFile(Path file, List<Path> segments) {
|
||||||
|
String content = segments.stream()
|
||||||
|
.map(path -> "file '" + path.toAbsolutePath().toString().replace("'", "'\\''") + "'")
|
||||||
|
.collect(Collectors.joining(System.lineSeparator(), "", System.lineSeparator()));
|
||||||
|
try {
|
||||||
|
Files.writeString(file, content);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new IllegalStateException("Unable to write FFmpeg concat file", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void copy(Path source, Path target) {
|
||||||
|
try {
|
||||||
|
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new IllegalStateException("Unable to publish final edit", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void createDirectory(Path directory) {
|
private void createDirectory(Path directory) {
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(directory);
|
Files.createDirectories(directory);
|
||||||
|
|
|
||||||
|
|
@ -26,4 +26,16 @@ class FfmpegEditRendererTest {
|
||||||
assertThat(command).containsSubsequence("-c:v", "libx264", "-c:a", "aac");
|
assertThat(command).containsSubsequence("-c:v", "libx264", "-c:a", "aac");
|
||||||
assertThat(command).endsWith("segment.mp4");
|
assertThat(command).endsWith("segment.mp4");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildsConcatCommandWithoutReencoding() {
|
||||||
|
VideoClippingProperties properties = new VideoClippingProperties();
|
||||||
|
FfmpegEditRenderer renderer = new FfmpegEditRenderer(properties, mock(EditProjectStore.class),
|
||||||
|
mock(EditProjectService.class), mock(EditPlanValidator.class), command -> null);
|
||||||
|
|
||||||
|
var command = renderer.concatCommand(Path.of("concat.txt"), Path.of("timeline-video.mp4"));
|
||||||
|
|
||||||
|
assertThat(command).containsSubsequence("-f", "concat", "-safe", "0", "-i", "concat.txt");
|
||||||
|
assertThat(command).containsSubsequence("-c", "copy", "timeline-video.mp4");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue