From 13b7dc4cdf4370735f04ea0c4360029d38da4d57 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Fri, 10 Jul 2026 19:10:34 +0200 Subject: [PATCH] Complete cinematic editing implementation plan --- docs/cinematic-video-editing-service-plan.md | 2 +- .../example/videoclips/editing/EditPlanValidator.java | 9 ++++++++- .../example/videoclips/editing/FfmpegEditRenderer.java | 9 ++++++++- .../videoclips/editing/StoryboardPromptGenerator.java | 4 ++-- .../videoclips/editing/EditPlanValidatorTest.java | 8 ++++++++ .../videoclips/editing/FfmpegEditRendererTest.java | 10 ++++++++++ .../editing/StoryboardPromptGeneratorTest.java | 2 ++ .../videoclips/folder/FolderFfmpegIntegrationTest.java | 1 + 8 files changed, 40 insertions(+), 5 deletions(-) diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index e1f7769..124f489 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -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 diff --git a/src/main/java/org/example/videoclips/editing/EditPlanValidator.java b/src/main/java/org/example/videoclips/editing/EditPlanValidator.java index 8c07a04..6380d67 100644 --- a/src/main/java/org/example/videoclips/editing/EditPlanValidator.java +++ b/src/main/java/org/example/videoclips/editing/EditPlanValidator.java @@ -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(); diff --git a/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java index 0b5380a..f05d6b3 100644 --- a/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java +++ b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java @@ -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 concatCommand(Path concatFile, Path output) { return List.of(properties.getFfmpegBinary(), "-hide_banner", "-y", "-f", "concat", "-safe", "0", "-i", concatFile.toString(), "-c", "copy", output.toString()); diff --git a/src/main/java/org/example/videoclips/editing/StoryboardPromptGenerator.java b/src/main/java/org/example/videoclips/editing/StoryboardPromptGenerator.java index 9a4710b..e5c1e17 100644 --- a/src/main/java/org/example/videoclips/editing/StoryboardPromptGenerator.java +++ b/src/main/java/org/example/videoclips/editing/StoryboardPromptGenerator.java @@ -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", diff --git a/src/test/java/org/example/videoclips/editing/EditPlanValidatorTest.java b/src/test/java/org/example/videoclips/editing/EditPlanValidatorTest.java index e39704d..0712d13 100644 --- a/src/test/java/org/example/videoclips/editing/EditPlanValidatorTest.java +++ b/src/test/java/org/example/videoclips/editing/EditPlanValidatorTest.java @@ -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() { diff --git a/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java b/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java index 5b1628a..0d400fa 100644 --- a/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java +++ b/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java @@ -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"); + } } diff --git a/src/test/java/org/example/videoclips/editing/StoryboardPromptGeneratorTest.java b/src/test/java/org/example/videoclips/editing/StoryboardPromptGeneratorTest.java index 0f41fb6..cde11c0 100644 --- a/src/test/java/org/example/videoclips/editing/StoryboardPromptGeneratorTest.java +++ b/src/test/java/org/example/videoclips/editing/StoryboardPromptGeneratorTest.java @@ -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") diff --git a/src/test/java/org/example/videoclips/folder/FolderFfmpegIntegrationTest.java b/src/test/java/org/example/videoclips/folder/FolderFfmpegIntegrationTest.java index 52b0917..a540f27 100644 --- a/src/test/java/org/example/videoclips/folder/FolderFfmpegIntegrationTest.java +++ b/src/test/java/org/example/videoclips/folder/FolderFfmpegIntegrationTest.java @@ -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);