Add concat edit timeline rendering

This commit is contained in:
JSLMPR 2026-07-10 16:13:59 +02:00
parent 21e5cef45d
commit 46a4982ef9
3 changed files with 47 additions and 1 deletions

View File

@ -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.

View File

@ -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);

View File

@ -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");
}
} }