R6 speed-ramp: ease into slow-motion

A slow-motion shot now decelerates smoothly instead of snapping to slow-mo:
speedRampSetpts builds a log-integrated setpts that ramps playback speed from
normal (1.0) down to below the target across the shot. The shot stays a SINGLE
segment (so the R5 per-shot push-in is preserved) and the existing -t pin keeps
the planned output duration. Normal-speed shots keep a plain constant setpts.
Unit-tested; verified in a real render (payoff carries the ramp, output valid).
Completes R6 (crossfades + speed-ramp).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPuJXQyAeWpFcTtcnxo1UN
This commit is contained in:
JSLMPR 2026-07-24 12:04:32 +02:00
parent 4d2cea15a7
commit 9fb24e8f19
3 changed files with 43 additions and 9 deletions

View File

@ -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 - **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). celebration → gentlest (0.084). Confirmed visually (opening pushes in ~11% over 2s).
## R6 — Transitions: ease, don't jerk — crossfades ✅ / speed-ramp ⏳ ## R6 — Transitions: ease, don't jerk ✅
- **Crossfades (DONE):** `xfadeTimelineCommand` dissolves montage beats (video xfade + audio acrossfade) - **Crossfades:** video xfade + audio acrossfade dissolve montage beats instead of hard-cutting. Run as
instead of hard-cutting, incl. the cut into the slow-mo payoff. Timeline compresses by (n-1)*xf; 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 `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 stay aligned. Opt-in via `editing.crossfade-seconds` (0 = hard cuts default; localpoc 0.25).
half the shortest beat. Verified on bowling (visible dissolve, overlay stayed on the payoff). - **Speed-ramp into slow-mo:** `speedRampSetpts` gives a slow-mo shot a log-integrated `setpts` that eases the
- **Speed-ramp into slow-mo (deferred):** easing the playback-speed change itself conflicts with the R5 playback speed from normal (1.0) down to below the target across the shot, so the payoff decelerates
per-shot push-in (sub-segment splitting would reset the zoom); needs a time-varying `setpts` or a push-in smoothly instead of snapping. Stays a SINGLE segment (R5 push-in preserved); the `-t` pin keeps the planned
that spans sub-segments. The crossfade already softens the *cut* into the payoff. duration. Verified on bowling (payoff segment carries the ramp expression; output valid).
## R7 — Overlays: bold, animated, synced ✅ ## R7 — Overlays: bold, animated, synced ✅
- **Rule:** captions are large (fontsize 84), thick-outlined + drop-shadowed for legibility on any background, - **Rule:** captions are large (fontsize 84), thick-outlined + drop-shadowed for legibility on any background,

View File

@ -283,7 +283,7 @@ public class HighlightFfmpegRenderer {
if (exposureFilter != null && !exposureFilter.isBlank()) { if (exposureFilter != null && !exposureFilter.isBlank()) {
filter.append(exposureFilter); // adaptive per-source exposure normalization, before the grade 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)); filter.append(cinematicVisualFilter(decision.visualTreatment(), style));
if (styled) { if (styled) {
filter.append(",noise=alls=6:allf=t"); // subtle film grain filter.append(",noise=alls=6:allf=t"); // subtle film grain
@ -902,6 +902,27 @@ public class HighlightFfmpegRenderer {
return AssetLicensePolicy.read(asset).orElse(AssetLicensePolicy.UNTRACKED); return AssetLicensePolicy.read(asset).orElse(AssetLicensePolicy.UNTRACKED);
} }
/**
* The setpts filter for a shot. A slow-motion shot (speed &lt; 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) { private String cinematicVisualFilter(String visualTreatment, String style) {
if (visualTreatment == null || visualTreatment.isBlank() || "none".equalsIgnoreCase(visualTreatment)) { if (visualTreatment == null || visualTreatment.isBlank() || "none".equalsIgnoreCase(visualTreatment)) {
return ""; return "";

View File

@ -205,6 +205,18 @@ class HighlightFfmpegRendererTest {
assertThat(portrait).contains("scale=1080:1920").doesNotContain("crop=1080:"); // no letterbox 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 @Test
void swellsTheMusicTowardThePayoffWhenAPeakIsGiven() { void swellsTheMusicTowardThePayoffWhenAPeakIsGiven() {
HighlightFfmpegRenderer renderer = renderer(); HighlightFfmpegRenderer renderer = renderer();