From c3c6c7a79026c49e463cd082867478eba8eaef66 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Fri, 10 Jul 2026 16:16:13 +0200 Subject: [PATCH] Add edit render manifest --- docs/cinematic-video-editing-service-plan.md | 2 +- .../editing/FfmpegEditRenderer.java | 20 ++++++++++++--- .../videoclips/editing/RenderManifest.java | 14 +++++++++++ .../editing/RenderManifestTest.java | 25 +++++++++++++++++++ 4 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 src/main/java/org/example/videoclips/editing/RenderManifest.java create mode 100644 src/test/java/org/example/videoclips/editing/RenderManifestTest.java diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index 08d6eeb..a533369 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -800,7 +800,7 @@ It writes the script only and requires an imported audio file. 17. [x] Add simple segment rendering without transitions. 18. [x] Add concat-based final timeline rendering. 19. [x] Add music and voiceover audio mixing. -20. [ ] Add render manifest with command metadata and output details. +20. [x] 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. 23. [ ] Add optional generated voiceover adapter interface. diff --git a/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java index 896a06a..9b227bc 100644 --- a/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java +++ b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java @@ -14,6 +14,7 @@ import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.time.Instant; import java.util.function.Function; import java.util.stream.Collectors; @@ -52,27 +53,33 @@ public class FfmpegEditRenderer implements EditRenderer { createDirectory(work); Map clips = analysis.clips().stream() .collect(Collectors.toMap(ClipAnalysis::clipId, Function.identity())); + List> commands = new ArrayList<>(); 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)); + runAndRecord(segmentCommand(clips.get(decision.clipId()).sourcePath(), decision, output), commands); segments.add(output); } Path concatFile = work.resolve("concat.txt"); writeConcatFile(concatFile, segments); Path timeline = work.resolve("timeline-video.mp4"); - run(concatCommand(concatFile, timeline)); + runAndRecord(concatCommand(concatFile, timeline), commands); Path output = store.projectDirectory(projectId).resolve("final.mp4"); 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)); + runAndRecord(audioMixCommand(timeline, Files.isRegularFile(music) ? music : null, + Files.isRegularFile(voiceover) ? voiceover : null, output), commands); } else { copy(timeline, output); } + double duration = plan.decisions().getLast().timelineEndSeconds(); + RenderManifest manifest = new RenderManifest(projectId, + plan.decisions().stream().map(EditDecision::clipId).toList(), output.toString(), duration, + List.copyOf(commands), Instant.now()); + store.writeJson(projectId, "render-manifest.json", manifest); projectService.updateProject(projectId, EditProjectStatus.RENDERED, Path.of(project.inputDirectory()), null); } @@ -165,6 +172,11 @@ public class FfmpegEditRenderer implements EditRenderer { } } + private void runAndRecord(List command, List> commands) { + run(command); + commands.add(command); + } + private static ProcessResult execute(List command) throws IOException, InterruptedException { Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); diff --git a/src/main/java/org/example/videoclips/editing/RenderManifest.java b/src/main/java/org/example/videoclips/editing/RenderManifest.java new file mode 100644 index 0000000..ac82a32 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/RenderManifest.java @@ -0,0 +1,14 @@ +package org.example.videoclips.editing; + +import java.time.Instant; +import java.util.List; + +public record RenderManifest( + String projectId, + List clipIds, + String outputPath, + double durationSeconds, + List> commands, + Instant completedAt +) { +} diff --git a/src/test/java/org/example/videoclips/editing/RenderManifestTest.java b/src/test/java/org/example/videoclips/editing/RenderManifestTest.java new file mode 100644 index 0000000..78f067c --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/RenderManifestTest.java @@ -0,0 +1,25 @@ +package org.example.videoclips.editing; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class RenderManifestTest { + + @Test + void roundTripsRenderProvenanceAsJson() throws Exception { + ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + RenderManifest manifest = new RenderManifest("project", List.of("clip-1", "clip-2"), "final.mp4", 8.0, + List.of(List.of("ffmpeg", "-i", "clip.mp4", "final.mp4")), + Instant.parse("2026-07-10T10:00:00Z")); + + String json = mapper.writeValueAsString(manifest); + + assertThat(json).contains("\"clipIds\"").contains("\"commands\"").contains("final.mp4"); + assertThat(mapper.readValue(json, RenderManifest.class)).isEqualTo(manifest); + } +}