Complete cinematic editing implementation plan

This commit is contained in:
JSLMPR 2026-07-10 19:10:34 +02:00
parent e5a86257d1
commit 13b7dc4cdf
8 changed files with 40 additions and 5 deletions

View File

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

View File

@ -52,12 +52,19 @@ public class EditPlanValidator {
}
if (decision.timelineStartSeconds() < 0
|| decision.timelineEndSeconds() <= decision.timelineStartSeconds()
|| decision.timelineStartSeconds() + EPSILON < previousEnd) {
|| Math.abs(decision.timelineStartSeconds() - previousEnd) > EPSILON) {
reject("Invalid or overlapping timeline range for clipId: " + decision.clipId());
}
if (decision.playbackSpeed() < 0.25 || decision.playbackSpeed() > 4.0) {
reject("Playback speed must be between 0.25 and 4.0");
}
double renderedDuration = (decision.sourceEndSeconds() - decision.sourceStartSeconds())
/ decision.playbackSpeed();
double plannedDuration = decision.timelineEndSeconds() - decision.timelineStartSeconds();
if (Math.abs(renderedDuration - plannedDuration) > 0.05) {
reject("Timeline duration does not match source range and playback speed for clipId: "
+ decision.clipId());
}
validateTransition(decision.transitionIn());
validateTransition(decision.transitionOut());
previousEnd = decision.timelineEndSeconds();

View File

@ -169,7 +169,7 @@ public class FfmpegEditRenderer implements EditRenderer {
"-ss", Double.toString(decision.sourceStartSeconds()),
"-to", Double.toString(decision.sourceEndSeconds()),
"-i", source, "-vf", filter.toString(),
"-af", "atempo=" + decision.playbackSpeed(),
"-af", audioTempoFilter(decision.playbackSpeed()),
"-r", Integer.toString(properties.getOutputFrameRate()),
"-map", "0:v:0", "-map", "0:a?",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
@ -178,6 +178,13 @@ public class FfmpegEditRenderer implements EditRenderer {
return List.copyOf(command);
}
String audioTempoFilter(double speed) {
if (speed < 0.5) {
return "atempo=0.5,atempo=" + (speed / 0.5);
}
return "atempo=" + speed;
}
List<String> concatCommand(Path concatFile, Path output) {
return List.of(properties.getFfmpegBinary(), "-hide_banner", "-y", "-f", "concat", "-safe", "0",
"-i", concatFile.toString(), "-c", "copy", output.toString());

View File

@ -93,11 +93,11 @@ public class StoryboardPromptGenerator {
}],
"audioCues": [{
"type": "music|sfx",
"asset": "descriptive asset name",
"assetKey": "descriptive asset name",
"timelineStartSeconds": 0.0,
"timelineEndSeconds": 2.5,
"gainDb": -12.0,
"reason": "story purpose"
"notes": "story purpose"
}],
"voiceover": [{
"text": "narration",

View File

@ -45,6 +45,14 @@ class EditPlanValidatorTest {
rejects(plan(decision("clip-1", 0, 4, 0, 4, "cut"), decision("clip-1", 4, 8, 3, 7, "cut")));
}
@Test void rejectsTimelineGap() {
rejects(plan(decision("clip-1", 0, 4, 1, 5, "cut")));
}
@Test void rejectsTimelineDurationThatRendererCannotHonor() {
rejects(plan(decision("clip-1", 0, 4, 0, 3, "cut")));
}
@Test void rejectsUnsupportedTransition() { rejects(plan(decision("clip-1", 0, 4, 0, 4, "wipe"))); }
@Test void rejectsInvalidPlaybackSpeed() {

View File

@ -86,4 +86,14 @@ class FfmpegEditRendererTest {
assertThat(command).contains("[1:a]atrim=duration=1.5,volume=-3.0dB,adelay=1500|1500[sfx0];"
+ "[0:a][sfx0]amix=inputs=2:duration=first:dropout_transition=2[a]");
}
@Test
void chainsAudioTempoAtQuarterSpeed() {
VideoClippingProperties properties = new VideoClippingProperties();
FfmpegEditRenderer renderer = new FfmpegEditRenderer(properties, mock(EditProjectStore.class),
mock(EditProjectService.class), mock(EditPlanValidator.class), command -> null);
assertThat(renderer.audioTempoFilter(0.25)).isEqualTo("atempo=0.5,atempo=0.5");
assertThat(renderer.audioTempoFilter(1.5)).isEqualTo("atempo=1.5");
}
}

View File

@ -34,6 +34,8 @@ class StoryboardPromptGeneratorTest {
.contains("Style: `cinematic-porsche-promo`")
.contains("\"decisions\"")
.contains("\"audioCues\"")
.contains("\"assetKey\"")
.contains("\"notes\"")
.contains("\"voiceover\"")
.contains("Return strict JSON only")
.contains("thumbnails/clip_00001_0001.jpg")

View File

@ -34,6 +34,7 @@ class FolderFfmpegIntegrationTest {
VideoClippingProperties properties = new VideoClippingProperties();
Path outputRoot = Files.createDirectory(tempDir.resolve("output"));
properties.getFolderScheduler().setOutputDirectory(outputRoot.toString());
properties.getFolderScheduler().setPreserveInputQuality(false);
assertTrue(new FolderVideoValidator(properties).validate(source).valid());
FolderFfmpegClipper.ClipResult result = new FolderFfmpegClipper(properties).clip(source);