From f03d5472c566caba049a0a3539e6bdbb8c1af335 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Thu, 23 Jul 2026 23:41:12 +0200 Subject: [PATCH] R6: cross-dissolve transitions between montage beats Beats now blend instead of hard-cutting (incl. the cut into the slow-mo payoff) via an xfade + acrossfade chain (xfadeTimelineCommand). The timeline compresses by (n-1)*xf, so shiftOverlayForCrossfade re-times overlays and the reported duration is reduced to keep overlays, loudness mastering, and QA aligned. Opt-in via editing.crossfade-seconds (0 = hard cuts default; localpoc 0.25), clamped to half the shortest beat. Offset math + overlay shift unit-tested; verified on the bowling cut (visible dissolve, STRIKE overlay stayed on the payoff). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MPuJXQyAeWpFcTtcnxo1UN --- docs/cinematic-quality-rules.md | 12 +- .../config/VideoClippingProperties.java | 11 ++ .../editing/HighlightFfmpegRenderer.java | 106 ++++++++++++++++-- src/main/resources/application-localpoc.yml | 3 + .../editing/HighlightFfmpegRendererTest.java | 22 ++++ 5 files changed, 141 insertions(+), 13 deletions(-) diff --git a/docs/cinematic-quality-rules.md b/docs/cinematic-quality-rules.md index 3043609..47d2f3a 100644 --- a/docs/cinematic-quality-rules.md +++ b/docs/cinematic-quality-rules.md @@ -41,9 +41,15 @@ Legend: ✅ implemented · ⏳ planned (see `cinematic-highlight-poc-plan.md` P5 - **Evidence (bowling):** static opening shot measured lowest motion → strongest push (0.117); active celebration → gentlest (0.084). Confirmed visually (opening pushes in ~11% over 2s). -## R6 — Transitions: ease, don't jerk ⏳ (P5.11) -- Ramp speed into a slow-motion beat (don't hard-switch playback speed); crossfade on beat boundaries where - the cut isn't meant to be a hard cut. +## R6 — Transitions: ease, don't jerk — crossfades ✅ / speed-ramp ⏳ +- **Crossfades (DONE):** `xfadeTimelineCommand` dissolves montage beats (video xfade + audio acrossfade) + instead of hard-cutting, incl. the cut into the slow-mo payoff. Timeline compresses by (n-1)*xf; + `shiftOverlayForCrossfade` re-times overlays and the reported duration is adjusted so overlays/loudness/QA + stay aligned. Opt-in via `editing.crossfade-seconds` (0 = hard cuts default; localpoc 0.25), clamped to + half the shortest beat. Verified on bowling (visible dissolve, overlay stayed on the payoff). +- **Speed-ramp into slow-mo (deferred):** easing the playback-speed change itself conflicts with the R5 + per-shot push-in (sub-segment splitting would reset the zoom); needs a time-varying `setpts` or a push-in + that spans sub-segments. The crossfade already softens the *cut* into the payoff. ## R7 — Overlays: bold, animated, synced ⏳ (P5.12) - Title/label text scaled to the frame, with an entrance (scale/fade) timed to the musical/edit accent — not diff --git a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java index 5e9ef28..2497633 100644 --- a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java +++ b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java @@ -523,6 +523,9 @@ public class VideoClippingProperties { @Min(1) private int outputFrameRate = 30; + /** Cross-dissolve duration (seconds) between montage beats. 0 = hard cuts (default). */ + private double crossfadeSeconds = 0.0; + @Min(1) private int audioSampleRate = 48000; @@ -694,6 +697,14 @@ public class VideoClippingProperties { this.outputFrameRate = outputFrameRate; } + public double getCrossfadeSeconds() { + return crossfadeSeconds; + } + + public void setCrossfadeSeconds(double crossfadeSeconds) { + this.crossfadeSeconds = crossfadeSeconds; + } + public int getAudioSampleRate() { return audioSampleRate; } diff --git a/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java b/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java index 9a15f77..a9f7758 100644 --- a/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java +++ b/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java @@ -104,19 +104,30 @@ public class HighlightFfmpegRenderer { "segment", Integer.toString(index + 1)); } List renderedClips = publishRenderedClips(highlightDirectory, segments); - Path concatFile = work.resolve("concat.txt"); - writeConcatFile(concatFile, segments); - log(projectId, highlight.highlightId(), "highlight_render_concat_started", - "segments", Integer.toString(segments.size())); + List segmentDurations = plan.decisions().stream() + .map(d -> d.timelineEndSeconds() - d.timelineStartSeconds()).toList(); + double crossfade = crossfadeDuration(segmentDurations); Path timeline = work.resolve("timeline-video.mp4"); - run(concatCommand(concatFile, timeline), commands); + log(projectId, highlight.highlightId(), "highlight_render_concat_started", + "segments", Integer.toString(segments.size()), "crossfade_seconds", Double.toString(crossfade)); + if (crossfade > 0.0) { + run(xfadeTimelineCommand(segments, segmentDurations, crossfade, sourceAudioPresent, timeline), commands); + } else { + Path concatFile = work.resolve("concat.txt"); + writeConcatFile(concatFile, segments); + run(concatCommand(concatFile, timeline), commands); + } + double crossfadeShrink = crossfade > 0.0 ? crossfade * (segments.size() - 1) : 0.0; + List overlays = crossfade > 0.0 + ? plan.overlays().stream().map(o -> shiftOverlayForCrossfade(o, segmentDurations, crossfade)).toList() + : plan.overlays(); Path postTimeline = timeline; - if (!plan.overlays().isEmpty()) { + if (!overlays.isEmpty()) { postTimeline = work.resolve("timeline-overlays.mp4"); log(projectId, highlight.highlightId(), "highlight_render_effects_started", - "overlays", Integer.toString(plan.overlays().size()), + "overlays", Integer.toString(overlays.size()), "treatment", plan.decisions().isEmpty() ? "none" : plan.decisions().get(0).visualTreatment()); - run(overlayCommand(timeline, plan.overlays(), postTimeline), commands); + run(overlayCommand(timeline, overlays, postTimeline), commands); } Path output = highlightDirectory.resolve("final.mp4"); @@ -144,8 +155,8 @@ public class HighlightFfmpegRenderer { } masterLoudness(output, commands); run(previewCommand(output, preview), commands); - double duration = plan.decisions().isEmpty() ? 0 : plan.decisions().get(plan.decisions().size() - 1) - .timelineEndSeconds(); + double duration = (plan.decisions().isEmpty() ? 0 : plan.decisions().get(plan.decisions().size() - 1) + .timelineEndSeconds()) - crossfadeShrink; RenderManifest manifest = new RenderManifest(projectId, plan.decisions().stream().map(EditDecision::clipId).toList(), renderedClips.stream().map(Path::toString).toList(), output.toString(), duration, List.copyOf(commands), @@ -460,6 +471,81 @@ public class HighlightFfmpegRenderer { "-i", concatFile.toString(), "-c", "copy", output.toString()); } + /** + * Cross-dissolve the segments into one timeline with an xfade (and acrossfade) chain, so beat boundaries + * blend instead of hard-cutting. Each transition overlaps by {@code xf} seconds; the total shrinks by + * (n-1)*xf. Package-visible for offset-math testing. + */ + List xfadeTimelineCommand(List segments, List durations, double xf, + boolean withAudio, Path output) { + List command = new ArrayList<>(List.of(properties.getFfmpegBinary(), "-hide_banner", "-y")); + for (Path segment : segments) { + command.add("-i"); + command.add(segment.toString()); + } + StringBuilder fc = new StringBuilder(); + String vPrev = "[0:v]"; + String aPrev = "[0:a]"; + double accum = durations.get(0); + int last = segments.size() - 1; + for (int i = 1; i < segments.size(); i++) { + double offset = accum - xf; + String vOut = i == last ? "[vout]" : "[v" + i + "]"; + fc.append(vPrev).append("[").append(i).append(":v]xfade=transition=fade:duration=") + .append(fixed(xf)).append(":offset=").append(fixed(offset)).append(vOut).append(";"); + vPrev = vOut; + if (withAudio) { + String aOut = i == last ? "[aout]" : "[a" + i + "]"; + fc.append(aPrev).append("[").append(i).append(":a]acrossfade=d=") + .append(fixed(xf)).append(aOut).append(";"); + aPrev = aOut; + } + accum += durations.get(i) - xf; + } + command.addAll(List.of("-filter_complex", fc.substring(0, fc.length() - 1), "-map", "[vout]")); + if (withAudio) { + command.addAll(List.of("-map", "[aout]")); + } + command.addAll(List.of("-c:v", "libx264", "-preset", "veryfast", "-crf", "18")); + if (withAudio) { + command.addAll(List.of("-c:a", "aac", "-b:a", properties.getAudioBitrate(), + "-ar", Integer.toString(properties.getAudioSampleRate()))); + } + command.add(output.toString()); + return List.copyOf(command); + } + + /** Shifts an overlay's timing onto the crossfade-compressed timeline (each earlier transition removes xf). */ + static TextOverlay shiftOverlayForCrossfade(TextOverlay overlay, List durations, double xf) { + double boundary = 0; + int transitions = 0; + for (int i = 0; i < durations.size() - 1; i++) { + boundary += durations.get(i); + if (boundary <= overlay.timelineStartSeconds() + 0.001) { + transitions++; + } + } + double shift = transitions * xf; + return new TextOverlay(overlay.text(), Math.max(0.0, overlay.timelineStartSeconds() - shift), + Math.max(0.1, overlay.timelineEndSeconds() - shift), overlay.placement(), + overlay.animation(), overlay.reason()); + } + + /** Effective crossfade duration: the configured value clamped so it can't exceed half the shortest beat; 0 when disabled or fewer than two beats. */ + double crossfadeDuration(List segmentDurations) { + double configured = properties.getCrossfadeSeconds(); + if (configured <= 0.0 || segmentDurations.size() < 2) { + return 0.0; + } + double shortest = segmentDurations.stream().mapToDouble(Double::doubleValue).min().orElse(0.0); + double clamped = Math.min(configured, shortest * 0.5); + return clamped >= 0.05 ? clamped : 0.0; + } + + private static String fixed(double value) { + return String.format(Locale.ROOT, "%.3f", value); + } + List previewCommand(Path input, Path output) { return List.of(properties.getFfmpegBinary(), "-hide_banner", "-y", "-i", input.toString(), "-vf", "scale=iw*0.5:ih*0.5", "-c:v", "libx264", "-preset", "veryfast", "-crf", "26", diff --git a/src/main/resources/application-localpoc.yml b/src/main/resources/application-localpoc.yml index 79ba43d..b79d993 100644 --- a/src/main/resources/application-localpoc.yml +++ b/src/main/resources/application-localpoc.yml @@ -19,6 +19,9 @@ video-clipping: # Isolated PoC output tree (base output/ is left untouched). highlight-project-directory: ./output/localpoc/highlight-projects + # Soft cross-dissolves between montage beats (0 = hard cuts). Softens the abrupt cut into the slow-mo payoff. + crossfade-seconds: 0.25 + assets: # Empty/absent asset folders -> the pipeline generates assets with local models instead of copying. music-folder: ./input/localpoc/assets/music diff --git a/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java b/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java index 2c18aeb..c2ad5b2 100644 --- a/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java +++ b/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java @@ -203,6 +203,28 @@ class HighlightFfmpegRendererTest { assertThat(portrait).contains("scale=1080:1920").doesNotContain("crop=1080:"); // no letterbox } + @Test + void crossfadeChainOverlapsSegmentsAndShiftsOverlaysOntoTheCompressedTimeline() { + HighlightFfmpegRenderer renderer = renderer(); + List durations = List.of(2.0, 2.5, 3.0); + + String cmd = String.join(" ", renderer.xfadeTimelineCommand( + List.of(Path.of("s0.mp4"), Path.of("s1.mp4"), Path.of("s2.mp4")), + durations, 0.25, true, Path.of("t.mp4"))); + assertThat(cmd).contains("xfade=transition=fade:duration=0.250:offset=1.750") // dur0 - xf + .contains("offset=4.000") // dur0+dur1 - 2*xf + .contains("acrossfade=d=0.250").contains("[vout]").contains("[aout]"); + + // An overlay on the 3rd beat (starts 4.5s uncompressed) shifts back by 2 transitions * xf. + TextOverlay overlay = new TextOverlay("STRIKE", 4.5, 6.5, "lower_center_safe", "fade", "montage"); + TextOverlay shifted = HighlightFfmpegRenderer.shiftOverlayForCrossfade(overlay, durations, 0.25); + assertThat(shifted.timelineStartSeconds()).isCloseTo(4.0, within(0.001)); + assertThat(shifted.timelineEndSeconds()).isCloseTo(6.0, within(0.001)); + + // Disabled by default (crossfade-seconds defaults to 0). + assertThat(renderer.crossfadeDuration(durations)).isZero(); + } + @Test void loudnessMasteringCorrectsTowardTargetOnlyWhenNeeded() { // Too quiet -> positive boost; too loud -> negative cut; on-target and implausible -> no change.