Add cinematic editing integration test
This commit is contained in:
parent
8fc85320e9
commit
051f4e56d0
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue