Add edit render manifest

This commit is contained in:
JSLMPR 2026-07-10 16:16:13 +02:00
parent a3ca4826f5
commit c3c6c7a790
4 changed files with 56 additions and 5 deletions

View File

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

View File

@ -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<String, ClipAnalysis> clips = analysis.clips().stream()
.collect(Collectors.toMap(ClipAnalysis::clipId, Function.identity()));
List<List<String>> commands = new ArrayList<>();
List<Path> 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<String> command, List<List<String>> commands) {
run(command);
commands.add(command);
}
private static ProcessResult execute(List<String> command) throws IOException, InterruptedException {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);

View File

@ -0,0 +1,14 @@
package org.example.videoclips.editing;
import java.time.Instant;
import java.util.List;
public record RenderManifest(
String projectId,
List<String> clipIds,
String outputPath,
double durationSeconds,
List<List<String>> commands,
Instant completedAt
) {
}

View File

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