diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index a533369..9a0b9ff 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -801,7 +801,7 @@ It writes the script only and requires an imported audio file. 18. [x] Add concat-based final timeline rendering. 19. [x] Add music and voiceover audio mixing. 20. [x] Add render manifest with command metadata and output details. -21. [ ] Add basic transition support: cut, crossfade, fade in, fade out. +21. [x] Add basic transition support: cut, crossfade, fade in, fade out. 22. [ ] Add SFX cue support. 23. [ ] Add optional generated voiceover adapter interface. 24. [ ] Add structured logs and metrics for analysis, planning, and rendering durations. diff --git a/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java index 9b227bc..d404faa 100644 --- a/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java +++ b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java @@ -84,15 +84,26 @@ public class FfmpegEditRenderer implements EditRenderer { } List segmentCommand(String source, EditDecision decision, Path output) { - String filter = "scale=%d:%d:force_original_aspect_ratio=decrease,".formatted( + double outputDuration = (decision.sourceEndSeconds() - decision.sourceStartSeconds()) / decision.playbackSpeed(); + StringBuilder filter = new StringBuilder("scale=%d:%d:force_original_aspect_ratio=decrease,".formatted( properties.getOutputWidth(), properties.getOutputHeight()) + "pad=%d:%d:(ow-iw)/2:(oh-ih)/2,format=yuv420p".formatted( - properties.getOutputWidth(), properties.getOutputHeight()); + properties.getOutputWidth(), properties.getOutputHeight())); + filter.append(",setpts=PTS/").append(decision.playbackSpeed()); + double fadeDuration = Math.min(0.5, outputDuration / 2); + if ("fade-in".equals(decision.transitionIn()) || "crossfade".equals(decision.transitionIn())) { + filter.append(",fade=t=in:st=0:d=").append(fadeDuration); + } + if ("fade-out".equals(decision.transitionOut()) || "crossfade".equals(decision.transitionOut())) { + filter.append(",fade=t=out:st=").append(Math.max(0, outputDuration - fadeDuration)) + .append(":d=").append(fadeDuration); + } List command = new ArrayList<>(List.of( properties.getFfmpegBinary(), "-hide_banner", "-y", "-ss", Double.toString(decision.sourceStartSeconds()), "-to", Double.toString(decision.sourceEndSeconds()), - "-i", source, "-vf", filter, + "-i", source, "-vf", filter.toString(), + "-af", "atempo=" + decision.playbackSpeed(), "-r", Integer.toString(properties.getOutputFrameRate()), "-map", "0:v:0", "-map", "0:a?", "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", diff --git a/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java b/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java index a96578d..48e9b16 100644 --- a/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java +++ b/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java @@ -21,8 +21,9 @@ class FfmpegEditRendererTest { var command = renderer.segmentCommand("source.mp4", decision, Path.of("segment.mp4")); assertThat(command).containsSubsequence("-ss", "1.25", "-to", "5.5", "-i", "source.mp4"); - assertThat(command).contains("scale=1920:1080:force_original_aspect_ratio=decrease," - + "pad=1920:1080:(ow-iw)/2:(oh-ih)/2,format=yuv420p"); + assertThat(command).anyMatch(argument -> argument.startsWith( + "scale=1920:1080:force_original_aspect_ratio=decrease," + + "pad=1920:1080:(ow-iw)/2:(oh-ih)/2,format=yuv420p")); assertThat(command).containsSubsequence("-c:v", "libx264", "-c:a", "aac"); assertThat(command).endsWith("segment.mp4"); } @@ -53,4 +54,20 @@ class FfmpegEditRendererTest { + "[0:a][music][voice]amix=inputs=3:duration=first:dropout_transition=2[a]"); assertThat(command).containsSubsequence("-c:v", "copy", "-c:a", "aac"); } + + @Test + void appliesFadeAndPlaybackTreatmentsToSegments() { + VideoClippingProperties properties = new VideoClippingProperties(); + FfmpegEditRenderer renderer = new FfmpegEditRenderer(properties, mock(EditProjectStore.class), + mock(EditProjectService.class), mock(EditPlanValidator.class), command -> null); + EditDecision decision = new EditDecision("clip-1", 0, 4, 0, 2, + "fade-in", "crossfade", 2, "grade", "reason"); + + var command = renderer.segmentCommand("source.mp4", decision, Path.of("segment.mp4")); + + assertThat(command).contains("scale=1920:1080:force_original_aspect_ratio=decrease," + + "pad=1920:1080:(ow-iw)/2:(oh-ih)/2,format=yuv420p,setpts=PTS/2.0," + + "fade=t=in:st=0:d=0.5,fade=t=out:st=1.5:d=0.5"); + assertThat(command).containsSubsequence("-af", "atempo=2.0"); + } }