From ea1633bd0bb08b0b4ecbe29c38a3e21182b536cd Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:50:55 +1000 Subject: [PATCH] fix: eliminate zoom spring overshoot wobble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use analytical velocity (closed-form derivative) instead of frame-to-frame finite differences for spring state propagation. Add overshoot clamp for overdamped springs (ζ ≥ 1) to prevent counter-oscillation when the zoom easing target reverses direction mid-animation. --- .../videoPlayback/motionSmoothing.ts | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/src/components/video-editor/videoPlayback/motionSmoothing.ts b/src/components/video-editor/videoPlayback/motionSmoothing.ts index 7d7d5939..3d78286b 100644 --- a/src/components/video-editor/videoPlayback/motionSmoothing.ts +++ b/src/components/video-editor/videoPlayback/motionSmoothing.ts @@ -136,7 +136,6 @@ export function stepSpringValue( const initialVelocity = -state.velocity; const tSec = msToSec(safeDeltaMs); - const previousValue = state.value; const current = resolveSpringPosition( tSec, target, @@ -146,23 +145,36 @@ export function stepSpringValue( undampedAngularFreq, ); - // Check convergence - let currentVelocity = 0; - if (dampingRatio < 1) { - // Only underdamped springs can overshoot, so we need velocity checks - const epsilon = 0.0001; - const ahead = resolveSpringPosition( - tSec + epsilon, - target, - initialDelta, - initialVelocity, - dampingRatio, - undampedAngularFreq, - ); - currentVelocity = ((ahead - current) / epsilon) * 1000; // convert back to ms + // Overshoot guard for overdamped / critically-damped springs (ζ ≥ 1). + // With a fixed target an overdamped spring never overshoots, but when + // the target moves every frame (zoom easing curve) carried-over velocity + // can push the value past the new target → wobble on reversal. + // Clamping to target keeps the original animation speed while preventing + // the jelly-like counter-oscillation. + if (dampingRatio >= 1) { + const crossed = + (state.value <= target && current > target) || + (state.value >= target && current < target); + if (crossed) { + state.value = target; + state.velocity = 0; + return state.value; + } } - const isBelowVelocityThreshold = Math.abs(currentVelocity) <= restSpeed; + // Analytical velocity via forward-difference on the closed-form solution. + const epsilon = 0.0001; + const ahead = resolveSpringPosition( + tSec + epsilon, + target, + initialDelta, + initialVelocity, + dampingRatio, + undampedAngularFreq, + ); + const analyticalVelocity = (ahead - current) / epsilon; // value per second + + const isBelowVelocityThreshold = Math.abs(analyticalVelocity) <= restSpeed; const isBelowDisplacementThreshold = Math.abs(target - current) <= restDelta; const isDone = isBelowVelocityThreshold && isBelowDisplacementThreshold; @@ -171,7 +183,7 @@ export function stepSpringValue( state.velocity = 0; } else { state.value = current; - state.velocity = ((state.value - previousValue) / safeDeltaMs) * 1000; + state.velocity = analyticalVelocity; } return state.value; @@ -237,7 +249,9 @@ export function getZoomSpringConfig(smoothnessFactor = 0.5): SpringConfig { const scaled = clamped * 2; // Hooke's law spring: F = -kx - cv - // Damping ratio ζ = c / (2√(km)) ≈ 1.05 — always overdamped, no overshoot. + // Damping ratio ζ = c / (2√(km)) ≈ 1.05 — barely overdamped. + // The overshoot clamp in stepSpringValue prevents wobble even at + // this low damping, so animations stay fast and responsive. // Higher scaled → lower stiffness + higher mass → slower, floatier settle. return { stiffness: 100 / scaled,