Add basic edit transitions

This commit is contained in:
JSLMPR 2026-07-10 16:19:05 +02:00
parent c3c6c7a790
commit e2037a7629
3 changed files with 34 additions and 6 deletions

View File

@ -801,7 +801,7 @@ It writes the script only and requires an imported audio file.
18. [x] Add concat-based final timeline rendering. 18. [x] Add concat-based final timeline rendering.
19. [x] Add music and voiceover audio mixing. 19. [x] Add music and voiceover audio mixing.
20. [x] Add render manifest with command metadata and output details. 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. 22. [ ] Add SFX cue support.
23. [ ] Add optional generated voiceover adapter interface. 23. [ ] Add optional generated voiceover adapter interface.
24. [ ] Add structured logs and metrics for analysis, planning, and rendering durations. 24. [ ] Add structured logs and metrics for analysis, planning, and rendering durations.

View File

@ -84,15 +84,26 @@ public class FfmpegEditRenderer implements EditRenderer {
} }
List<String> segmentCommand(String source, EditDecision decision, Path output) { List<String> 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()) properties.getOutputWidth(), properties.getOutputHeight())
+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2,format=yuv420p".formatted( + "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<String> command = new ArrayList<>(List.of( List<String> command = new ArrayList<>(List.of(
properties.getFfmpegBinary(), "-hide_banner", "-y", properties.getFfmpegBinary(), "-hide_banner", "-y",
"-ss", Double.toString(decision.sourceStartSeconds()), "-ss", Double.toString(decision.sourceStartSeconds()),
"-to", Double.toString(decision.sourceEndSeconds()), "-to", Double.toString(decision.sourceEndSeconds()),
"-i", source, "-vf", filter, "-i", source, "-vf", filter.toString(),
"-af", "atempo=" + 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",

View File

@ -21,8 +21,9 @@ class FfmpegEditRendererTest {
var command = renderer.segmentCommand("source.mp4", decision, Path.of("segment.mp4")); 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).containsSubsequence("-ss", "1.25", "-to", "5.5", "-i", "source.mp4");
assertThat(command).contains("scale=1920:1080:force_original_aspect_ratio=decrease," assertThat(command).anyMatch(argument -> argument.startsWith(
+ "pad=1920:1080:(ow-iw)/2:(oh-ih)/2,format=yuv420p"); "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).containsSubsequence("-c:v", "libx264", "-c:a", "aac");
assertThat(command).endsWith("segment.mp4"); assertThat(command).endsWith("segment.mp4");
} }
@ -53,4 +54,20 @@ class FfmpegEditRendererTest {
+ "[0:a][music][voice]amix=inputs=3:duration=first:dropout_transition=2[a]"); + "[0:a][music][voice]amix=inputs=3:duration=first:dropout_transition=2[a]");
assertThat(command).containsSubsequence("-c:v", "copy", "-c:a", "aac"); 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");
}
} }