diff --git a/docs/cinematic-quality-rules.md b/docs/cinematic-quality-rules.md index 664e83b..d7142ce 100644 --- a/docs/cinematic-quality-rules.md +++ b/docs/cinematic-quality-rules.md @@ -41,15 +41,16 @@ 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 — 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; +## R6 — Transitions: ease, don't jerk ✅ +- **Crossfades:** video xfade + audio acrossfade dissolve montage beats instead of hard-cutting. Run as + SEPARATE passes then muxed (`xfadeVideoCommand` + `acrossfadeAudioCommand` + `muxCommand`) — one combined + filtergraph starves/truncates the audio (that cut the music off early). 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. + stay aligned. Opt-in via `editing.crossfade-seconds` (0 = hard cuts default; localpoc 0.25). +- **Speed-ramp into slow-mo:** `speedRampSetpts` gives a slow-mo shot a log-integrated `setpts` that eases the + playback speed from normal (1.0) down to below the target across the shot, so the payoff decelerates + smoothly instead of snapping. Stays a SINGLE segment (R5 push-in preserved); the `-t` pin keeps the planned + duration. Verified on bowling (payoff segment carries the ramp expression; output valid). ## R7 — Overlays: bold, animated, synced ✅ - **Rule:** captions are large (fontsize 84), thick-outlined + drop-shadowed for legibility on any background, diff --git a/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java b/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java index 55f2eb8..8c410cd 100644 --- a/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java +++ b/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java @@ -283,7 +283,7 @@ public class HighlightFfmpegRenderer { if (exposureFilter != null && !exposureFilter.isBlank()) { filter.append(exposureFilter); // adaptive per-source exposure normalization, before the grade } - filter.append(",setpts=PTS/").append(decision.playbackSpeed()); + filter.append(speedRampSetpts(decision)); filter.append(cinematicVisualFilter(decision.visualTreatment(), style)); if (styled) { filter.append(",noise=alls=6:allf=t"); // subtle film grain @@ -902,6 +902,27 @@ public class HighlightFfmpegRenderer { return AssetLicensePolicy.read(asset).orElse(AssetLicensePolicy.UNTRACKED); } + /** + * The setpts filter for a shot. A slow-motion shot (speed < 0.85) EASES into slow motion: playback speed + * ramps from normal (1.0) down to below the target across the shot via a log-integrated setpts expression, + * so the payoff decelerates smoothly instead of snapping to slow-mo. The shot stays a single segment (the + * R5 push-in is preserved) and the segment's {@code -t} pin keeps the planned output duration. A + * normal-speed shot uses a plain constant setpts. + */ + static String speedRampSetpts(EditDecision decision) { + double speed = decision.playbackSpeed(); + if (speed >= 0.85) { + return ",setpts=PTS/" + speed; + } + double din = Math.max(0.1, decision.sourceEndSeconds() - decision.sourceStartSeconds()); + double s0 = 1.0; + double s1 = Math.max(0.2, speed * 0.6); // end slower than target so the pinned output still fills the beat + double k = s1 - s0; + // output_seconds = din/(s1-s0) * ln((s0 + (s1-s0)/din * in_seconds)/s0); in_seconds = (PTS-STARTPTS)*TB + return ",setpts=(" + fixed(din) + "/(" + fixed(k) + "))*log((" + fixed(s0) + "+(" + fixed(k) + ")/" + + fixed(din) + "*(PTS-STARTPTS)*TB)/" + fixed(s0) + ")/TB"; + } + private String cinematicVisualFilter(String visualTreatment, String style) { if (visualTreatment == null || visualTreatment.isBlank() || "none".equalsIgnoreCase(visualTreatment)) { return ""; diff --git a/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java b/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java index e3bec59..ccbaa0b 100644 --- a/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java +++ b/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java @@ -205,6 +205,18 @@ class HighlightFfmpegRendererTest { assertThat(portrait).contains("scale=1080:1920").doesNotContain("crop=1080:"); // no letterbox } + @Test + void easesIntoSlowMotionOnlyForSlowMoShots() { + EditDecision normal = new EditDecision("c", 0, 4, 0, 4, "cut", "cut", 1.0, "hero", "montage"); + EditDecision slow = new EditDecision("c", 9.2, 11.2, 0, 2.857, "cut", "cut", 0.7, "hero", "montage"); + + assertThat(HighlightFfmpegRenderer.speedRampSetpts(normal)).isEqualTo(",setpts=PTS/1.0"); + + String ramp = HighlightFfmpegRenderer.speedRampSetpts(slow); + assertThat(ramp).contains("setpts=").contains("log(").contains("(PTS-STARTPTS)*TB") + .doesNotContain("PTS/0.7"); // ramped, not a constant-speed setpts + } + @Test void swellsTheMusicTowardThePayoffWhenAPeakIsGiven() { HighlightFfmpegRenderer renderer = renderer();