forked from jsl/video_editing_poc
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:
parent
4d2cea15a7
commit
9fb24e8f19
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue