diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index 55db9e0..2ae5495 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -805,7 +805,7 @@ It writes the script only and requires an imported audio file. 22. [x] Add SFX cue support. 23. [x] Add optional generated voiceover adapter interface. 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. 27. [ ] Run `mvn verify` and update this checklist. diff --git a/src/test/java/org/example/videoclips/editing/CinematicEditingIntegrationTest.java b/src/test/java/org/example/videoclips/editing/CinematicEditingIntegrationTest.java new file mode 100644 index 0000000..0a38519 --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/CinematicEditingIntegrationTest.java @@ -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 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 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; + } + } +}