Complete cinematic editing implementation plan
This commit is contained in:
parent
e5a86257d1
commit
13b7dc4cdf
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue