Add cinematic editing integration test

This commit is contained in:
JSLMPR 2026-07-10 19:00:40 +02:00
parent 8fc85320e9
commit 051f4e56d0
2 changed files with 129 additions and 1 deletions

View File

@ -805,7 +805,7 @@ It writes the script only and requires an imported audio file.
22. [x] Add SFX cue support. 22. [x] Add SFX cue support.
23. [x] Add optional generated voiceover adapter interface. 23. [x] Add optional generated voiceover adapter interface.
24. [x] Add structured logs and metrics for analysis, planning, and rendering durations. 24. [x] Add structured logs and metrics for analysis, planning, and rendering durations.
25. [ ] Add integration test with generated fixture clips and a short edit plan. 25. [x] Add integration test with generated fixture clips and a short edit plan.
26. [ ] Add documentation for running a local cinematic edit with Codex or Claude as director. 26. [ ] Add documentation for running a local cinematic edit with Codex or Claude as director.
27. [ ] Run `mvn verify` and update this checklist. 27. [ ] Run `mvn verify` and update this checklist.

View File

@ -0,0 +1,128 @@
package org.example.videoclips.editing;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.videoclips.config.VideoClippingProperties;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIf;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@EnabledIf("toolsAvailable")
class CinematicEditingIntegrationTest {
@TempDir Path tempDir;
@Test
void runsLocalDirectorPlanPickupAndRenderWithGeneratedClips() throws Exception {
VideoClippingProperties properties = properties();
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
FileSystemEditProjectStore store = new FileSystemEditProjectStore(properties, mapper);
new EditProjectDirectoryInitializer(properties).initialize();
Path source = Files.createDirectory(tempDir.resolve("source/porsche-integration"));
createClip(source.resolve("clip_01.mp4"), "red", 440);
createClip(source.resolve("clip_02.mp4"), "blue", 660);
createClip(source.resolve("clip_03.mp4"), "black", 880);
EditProjectService projects = new EditProjectService(store, Clock.systemUTC());
EditProjectAnalyzer analyzer = new EditProjectAnalyzer(store, new EditClipDiscovery(),
new FfmpegClipInspector(properties, mapper), new ThumbnailExtractor(properties),
new ContactSheetGenerator(properties), new ProxyGenerator(properties), Clock.systemUTC());
StoryboardPromptGenerator storyboard = new StoryboardPromptGenerator(store);
AiDirectorPromptGenerator director = new AiDirectorPromptGenerator(store, storyboard, properties);
LocalDirectorScheduler scheduler = new LocalDirectorScheduler(
properties, projects, analyzer, director, store);
scheduler.scan();
String projectId = "porsche-integration";
Path projectDirectory = tempDir.resolve("projects").resolve(projectId);
assertThat(projectDirectory.resolve("analysis.json")).exists();
assertThat(projectDirectory.resolve("ai-director-prompt.md")).exists();
EditProjectAnalysis analysis = store.readJson(projectId, "analysis.json", EditProjectAnalysis.class);
assertThat(analysis.clips()).hasSize(3);
EditPlan plan = plan(projectId, analysis);
mapper.writeValue(store.inboxDirectory(projectId).resolve("edit-plan.json").toFile(), plan);
EditPlanValidator validator = new EditPlanValidator(store);
FfmpegEditRenderer renderer = new FfmpegEditRenderer(properties, store, projects, validator);
new EditPlanInboxScanner(properties, mapper, store, projects, validator, renderer).scan();
assertThat(projects.getProject(projectId).status()).isEqualTo(EditProjectStatus.PLANNED);
renderer.render(projectId);
Path output = projectDirectory.resolve("final.mp4");
assertThat(output).isRegularFile();
assertThat(probeDuration(output)).isGreaterThan(0);
assertThat(projectDirectory.resolve("render-manifest.json")).exists();
assertThat(projects.getProject(projectId).status()).isEqualTo(EditProjectStatus.RENDERED);
}
private VideoClippingProperties properties() {
VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setProjectDirectory(tempDir.resolve("projects").toString());
properties.getEditing().setThumbnailCountPerClip(1);
properties.getEditing().setContactSheetColumns(1);
properties.getEditing().setProxyEnabled(false);
properties.getEditing().setOutputWidth(320);
properties.getEditing().setOutputHeight(240);
properties.getEditing().setOutputFrameRate(24);
var local = properties.getEditing().getLocalDirector();
local.setSourceDirectory(tempDir.resolve("source").toString());
local.setWorkingDirectory(tempDir.resolve("working").toString());
local.setProcessedDirectory(tempDir.resolve("processed").toString());
local.setRejectedDirectory(tempDir.resolve("rejected").toString());
return properties;
}
private EditPlan plan(String projectId, EditProjectAnalysis analysis) {
List<EditDecision> decisions = java.util.stream.IntStream.range(0, analysis.clips().size())
.mapToObj(index -> new EditDecision(analysis.clips().get(index).clipId(), 0, 2,
index * 2.0, (index + 1) * 2.0, "cut", "cut", 1,
"cinematic grade", "integration sequence"))
.toList();
return new EditPlan(projectId, "cinematic-porsche-promo", 60, decisions, List.of(), List.of(),
"mp4-h264-aac-320p", "Generated integration edit");
}
private void createClip(Path output, String color, int frequency) throws Exception {
run(List.of("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", "color=c=%s:s=320x240:r=24".formatted(color),
"-f", "lavfi", "-i", "sine=frequency=%d:sample_rate=48000".formatted(frequency),
"-t", "3", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest",
output.toString()));
}
private double probeDuration(Path video) throws Exception {
return Double.parseDouble(run(List.of("ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", video.toString())).trim());
}
private String run(List<String> command) throws Exception {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes());
assertThat(process.waitFor()).as(output).isZero();
return output;
}
static boolean toolsAvailable() {
return commandAvailable("ffmpeg") && commandAvailable("ffprobe");
}
private static boolean commandAvailable(String command) {
try {
return new ProcessBuilder(command, "-version").start().waitFor() == 0;
} catch (IOException ex) {
return false;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return false;
}
}
}