From 46a4982ef90e19d3722865d70fe67722dfed04d3 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Fri, 10 Jul 2026 16:13:59 +0200 Subject: [PATCH] Add concat edit timeline rendering --- docs/cinematic-video-editing-service-plan.md | 2 +- .../editing/FfmpegEditRenderer.java | 34 +++++++++++++++++++ .../editing/FfmpegEditRendererTest.java | 12 +++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index 409cda6..c8d42ea 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -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. 16. [x] Add `PUT /v1/edit-projects/{projectId}/plan`. 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. 20. [ ] Add render manifest with command metadata and output details. 21. [ ] Add basic transition support: cut, crossfade, fade in, fade out. diff --git a/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java index 064b62b..400c81e 100644 --- a/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java +++ b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java @@ -10,6 +10,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -51,11 +52,20 @@ public class FfmpegEditRenderer implements EditRenderer { createDirectory(work); Map clips = analysis.clips().stream() .collect(Collectors.toMap(ClipAnalysis::clipId, Function.identity())); + List segments = new ArrayList<>(); for (int index = 0; index < plan.decisions().size(); index++) { EditDecision decision = plan.decisions().get(index); Path output = work.resolve("segment_%04d.mp4".formatted(index + 1)); 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 segmentCommand(String source, EditDecision decision, Path output) { @@ -76,6 +86,30 @@ public class FfmpegEditRenderer implements EditRenderer { return List.copyOf(command); } + List 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 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) { try { Files.createDirectories(directory); diff --git a/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java b/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java index 995c454..f9cd229 100644 --- a/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java +++ b/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java @@ -26,4 +26,16 @@ class FfmpegEditRendererTest { assertThat(command).containsSubsequence("-c:v", "libx264", "-c:a", "aac"); 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"); + } }