forked from jsl/video_editing_poc
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.
|
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.
|
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.
|
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
|
## Milestone Details
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,12 +52,19 @@ public class EditPlanValidator {
|
||||||
}
|
}
|
||||||
if (decision.timelineStartSeconds() < 0
|
if (decision.timelineStartSeconds() < 0
|
||||||
|| decision.timelineEndSeconds() <= decision.timelineStartSeconds()
|
|| decision.timelineEndSeconds() <= decision.timelineStartSeconds()
|
||||||
|| decision.timelineStartSeconds() + EPSILON < previousEnd) {
|
|| Math.abs(decision.timelineStartSeconds() - previousEnd) > EPSILON) {
|
||||||
reject("Invalid or overlapping timeline range for clipId: " + decision.clipId());
|
reject("Invalid or overlapping timeline range for clipId: " + decision.clipId());
|
||||||
}
|
}
|
||||||
if (decision.playbackSpeed() < 0.25 || decision.playbackSpeed() > 4.0) {
|
if (decision.playbackSpeed() < 0.25 || decision.playbackSpeed() > 4.0) {
|
||||||
reject("Playback speed must be between 0.25 and 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.transitionIn());
|
||||||
validateTransition(decision.transitionOut());
|
validateTransition(decision.transitionOut());
|
||||||
previousEnd = decision.timelineEndSeconds();
|
previousEnd = decision.timelineEndSeconds();
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ public class FfmpegEditRenderer implements EditRenderer {
|
||||||
"-ss", Double.toString(decision.sourceStartSeconds()),
|
"-ss", Double.toString(decision.sourceStartSeconds()),
|
||||||
"-to", Double.toString(decision.sourceEndSeconds()),
|
"-to", Double.toString(decision.sourceEndSeconds()),
|
||||||
"-i", source, "-vf", filter.toString(),
|
"-i", source, "-vf", filter.toString(),
|
||||||
"-af", "atempo=" + decision.playbackSpeed(),
|
"-af", audioTempoFilter(decision.playbackSpeed()),
|
||||||
"-r", Integer.toString(properties.getOutputFrameRate()),
|
"-r", Integer.toString(properties.getOutputFrameRate()),
|
||||||
"-map", "0:v:0", "-map", "0:a?",
|
"-map", "0:v:0", "-map", "0:a?",
|
||||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
|
"-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
|
||||||
|
|
@ -178,6 +178,13 @@ public class FfmpegEditRenderer implements EditRenderer {
|
||||||
return List.copyOf(command);
|
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) {
|
List<String> concatCommand(Path concatFile, Path output) {
|
||||||
return List.of(properties.getFfmpegBinary(), "-hide_banner", "-y", "-f", "concat", "-safe", "0",
|
return List.of(properties.getFfmpegBinary(), "-hide_banner", "-y", "-f", "concat", "-safe", "0",
|
||||||
"-i", concatFile.toString(), "-c", "copy", output.toString());
|
"-i", concatFile.toString(), "-c", "copy", output.toString());
|
||||||
|
|
|
||||||
|
|
@ -93,11 +93,11 @@ public class StoryboardPromptGenerator {
|
||||||
}],
|
}],
|
||||||
"audioCues": [{
|
"audioCues": [{
|
||||||
"type": "music|sfx",
|
"type": "music|sfx",
|
||||||
"asset": "descriptive asset name",
|
"assetKey": "descriptive asset name",
|
||||||
"timelineStartSeconds": 0.0,
|
"timelineStartSeconds": 0.0,
|
||||||
"timelineEndSeconds": 2.5,
|
"timelineEndSeconds": 2.5,
|
||||||
"gainDb": -12.0,
|
"gainDb": -12.0,
|
||||||
"reason": "story purpose"
|
"notes": "story purpose"
|
||||||
}],
|
}],
|
||||||
"voiceover": [{
|
"voiceover": [{
|
||||||
"text": "narration",
|
"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")));
|
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 rejectsUnsupportedTransition() { rejects(plan(decision("clip-1", 0, 4, 0, 4, "wipe"))); }
|
||||||
|
|
||||||
@Test void rejectsInvalidPlaybackSpeed() {
|
@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];"
|
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]");
|
+ "[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("Style: `cinematic-porsche-promo`")
|
||||||
.contains("\"decisions\"")
|
.contains("\"decisions\"")
|
||||||
.contains("\"audioCues\"")
|
.contains("\"audioCues\"")
|
||||||
|
.contains("\"assetKey\"")
|
||||||
|
.contains("\"notes\"")
|
||||||
.contains("\"voiceover\"")
|
.contains("\"voiceover\"")
|
||||||
.contains("Return strict JSON only")
|
.contains("Return strict JSON only")
|
||||||
.contains("thumbnails/clip_00001_0001.jpg")
|
.contains("thumbnails/clip_00001_0001.jpg")
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ class FolderFfmpegIntegrationTest {
|
||||||
VideoClippingProperties properties = new VideoClippingProperties();
|
VideoClippingProperties properties = new VideoClippingProperties();
|
||||||
Path outputRoot = Files.createDirectory(tempDir.resolve("output"));
|
Path outputRoot = Files.createDirectory(tempDir.resolve("output"));
|
||||||
properties.getFolderScheduler().setOutputDirectory(outputRoot.toString());
|
properties.getFolderScheduler().setOutputDirectory(outputRoot.toString());
|
||||||
|
properties.getFolderScheduler().setPreserveInputQuality(false);
|
||||||
|
|
||||||
assertTrue(new FolderVideoValidator(properties).validate(source).valid());
|
assertTrue(new FolderVideoValidator(properties).validate(source).valid());
|
||||||
FolderFfmpegClipper.ClipResult result = new FolderFfmpegClipper(properties).clip(source);
|
FolderFfmpegClipper.ClipResult result = new FolderFfmpegClipper(properties).clip(source);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue