forked from jsl/video_editing_poc
Add edit render manifest
This commit is contained in:
parent
a3ca4826f5
commit
c3c6c7a790
|
|
@ -800,7 +800,7 @@ It writes the script only and requires an imported audio file.
|
||||||
17. [x] Add simple segment rendering without transitions.
|
17. [x] Add simple segment rendering without transitions.
|
||||||
18. [x] Add concat-based final timeline rendering.
|
18. [x] Add concat-based final timeline rendering.
|
||||||
19. [x] Add music and voiceover audio mixing.
|
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.
|
21. [ ] Add basic transition support: cut, crossfade, fade in, fade out.
|
||||||
22. [ ] Add SFX cue support.
|
22. [ ] Add SFX cue support.
|
||||||
23. [ ] Add optional generated voiceover adapter interface.
|
23. [ ] Add optional generated voiceover adapter interface.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ 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;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
|
@ -52,27 +53,33 @@ 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<List<String>> commands = new ArrayList<>();
|
||||||
List<Path> segments = new ArrayList<>();
|
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));
|
runAndRecord(segmentCommand(clips.get(decision.clipId()).sourcePath(), decision, output), commands);
|
||||||
segments.add(output);
|
segments.add(output);
|
||||||
}
|
}
|
||||||
Path concatFile = work.resolve("concat.txt");
|
Path concatFile = work.resolve("concat.txt");
|
||||||
writeConcatFile(concatFile, segments);
|
writeConcatFile(concatFile, segments);
|
||||||
Path timeline = work.resolve("timeline-video.mp4");
|
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 output = store.projectDirectory(projectId).resolve("final.mp4");
|
||||||
Path audioDirectory = store.projectDirectory(projectId).resolve("audio");
|
Path audioDirectory = store.projectDirectory(projectId).resolve("audio");
|
||||||
Path music = audioDirectory.resolve("music.wav");
|
Path music = audioDirectory.resolve("music.wav");
|
||||||
Path voiceover = audioDirectory.resolve("voiceover.wav");
|
Path voiceover = audioDirectory.resolve("voiceover.wav");
|
||||||
if (Files.isRegularFile(music) || Files.isRegularFile(voiceover)) {
|
if (Files.isRegularFile(music) || Files.isRegularFile(voiceover)) {
|
||||||
run(audioMixCommand(timeline, Files.isRegularFile(music) ? music : null,
|
runAndRecord(audioMixCommand(timeline, Files.isRegularFile(music) ? music : null,
|
||||||
Files.isRegularFile(voiceover) ? voiceover : null, output));
|
Files.isRegularFile(voiceover) ? voiceover : null, output), commands);
|
||||||
} else {
|
} else {
|
||||||
copy(timeline, output);
|
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);
|
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 {
|
private static ProcessResult execute(List<String> command) throws IOException, InterruptedException {
|
||||||
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||||
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue