diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index dc965d9b..d5207a60 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -74,8 +74,6 @@ import { AUTO_FOLLOW_SMOOTHING_FACTOR, AUTO_FOLLOW_SMOOTHING_FACTOR_MAX, DEFAULT_FOCUS, - ZOOM_SCALE_DEADZONE, - ZOOM_TRANSLATION_DEADZONE_PX, } from "./videoPlayback/constants"; import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils"; import { @@ -89,6 +87,7 @@ import { clamp01 } from "./videoPlayback/mathUtils"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; +import { createZoomSpringState, resetZoomSpring, stepZoomSpring } from "./videoPlayback/zoomSpring"; import { applyZoomTransform, computeFocusFromTransform, @@ -322,6 +321,9 @@ const VideoPlayback = forwardRef( y: 0, appliedScale: 1, }); + // Spring that chases the eased zoom target so the camera glides instead of jerking. + const zoomSpringRef = useRef(createZoomSpringState()); + const prevZoomTimeMsRef = useRef(null); const blurFilterRef = useRef(null); const motionBlurFilterRef = useRef(null); const isDraggingFocusRef = useRef(false); @@ -1485,18 +1487,29 @@ const VideoPlayback = forwardRef( focusY: state.focusY, }); - const appliedScale = - Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE - ? projectedTransform.scale - : projectedTransform.scale; - const appliedX = - Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.x - : projectedTransform.x; - const appliedY = - Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.y - : projectedTransform.y; + // Chase the eased target with a spring so the camera glides (no jerk at the steep + // start of the ease, no snap at close-region seams). Step by content time while + // playing; snap to the deterministic target when paused/seeking/scrubbing so those + // frames are crisp and exact. + const nowMs = currentTimeRef.current; + const prevMs = prevZoomTimeMsRef.current; + const animating = isPlayingRef.current && !isSeekingRef.current && !isScrubbingRef.current; + const dtMs = prevMs === null ? 0 : nowMs - prevMs; + let appliedScale: number; + let appliedX: number; + let appliedY: number; + if (!animating || prevMs === null || dtMs <= 0 || dtMs > 80) { + resetZoomSpring(zoomSpringRef.current, projectedTransform); + appliedScale = projectedTransform.scale; + appliedX = projectedTransform.x; + appliedY = projectedTransform.y; + } else { + const sprung = stepZoomSpring(zoomSpringRef.current, projectedTransform, dtMs); + appliedScale = sprung.scale; + appliedX = sprung.x; + appliedY = sprung.y; + } + prevZoomTimeMsRef.current = nowMs; const motionIntensity = Math.max( Math.abs(appliedScale - prevScale), diff --git a/src/components/video-editor/videoPlayback/zoomSpring.test.ts b/src/components/video-editor/videoPlayback/zoomSpring.test.ts new file mode 100644 index 00000000..c2113426 --- /dev/null +++ b/src/components/video-editor/videoPlayback/zoomSpring.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { createZoomSpringState, resetZoomSpring, stepZoomSpring } from "./zoomSpring"; + +const DT = 1000 / 60; + +describe("zoom spring chase", () => { + it("resetZoomSpring snaps every axis exactly to the target", () => { + const state = createZoomSpringState(); + resetZoomSpring(state, { scale: 1.8, x: -120, y: 40 }); + expect(stepZoomSpring(state, { scale: 1.8, x: -120, y: 40 }, DT)).toEqual({ + scale: 1.8, + x: -120, + y: 40, + }); + }); + + it("eases into a jumped target instead of snapping (velocity continuity)", () => { + const state = createZoomSpringState(); + resetZoomSpring(state, { scale: 1, x: 0, y: 0 }); + // Target jumps from 1 to 2; a single step must NOT teleport there. + const first = stepZoomSpring(state, { scale: 2, x: 0, y: 0 }, DT); + expect(first.scale).toBeGreaterThan(1); + expect(first.scale).toBeLessThan(2); + }); + + it("converges to a static target without overshooting it", () => { + const state = createZoomSpringState(); + resetZoomSpring(state, { scale: 1, x: 0, y: 0 }); + const target = { scale: 2.2, x: 0, y: 0 }; + let maxScale = 1; + let last = 1; + for (let i = 0; i < 200; i++) { + last = stepZoomSpring(state, target, DT).scale; + maxScale = Math.max(maxScale, last); + } + expect(last).toBeCloseTo(2.2, 2); // settled onto the target + expect(maxScale).toBeLessThanOrEqual(2.2 + 1e-6); // never overshot past it + }); + + it("does not overshoot when the target reverses mid-motion", () => { + const state = createZoomSpringState(); + resetZoomSpring(state, { scale: 1, x: 0, y: 0 }); + // Build upward momentum chasing a high target... + for (let i = 0; i < 8; i++) stepZoomSpring(state, { scale: 3, x: 0, y: 0 }, DT); + // ...then reverse the target below the current value; momentum must not carry it past. + const reverseTarget = { scale: 1.5, x: 0, y: 0 }; + let min = Number.POSITIVE_INFINITY; + for (let i = 0; i < 200; i++) { + min = Math.min(min, stepZoomSpring(state, reverseTarget, DT).scale); + } + expect(min).toBeGreaterThanOrEqual(1.5 - 1e-6); // never dipped below the reversed target + }); + + it("steps each axis independently", () => { + const state = createZoomSpringState(); + resetZoomSpring(state, { scale: 1, x: 0, y: 0 }); + const out = stepZoomSpring(state, { scale: 1, x: 100, y: 0 }, DT); + expect(out.scale).toBe(1); // already at target → unchanged + expect(out.x).toBeGreaterThan(0); + expect(out.x).toBeLessThan(100); + expect(out.y).toBe(0); + }); +}); diff --git a/src/components/video-editor/videoPlayback/zoomSpring.ts b/src/components/video-editor/videoPlayback/zoomSpring.ts new file mode 100644 index 00000000..6a49a514 --- /dev/null +++ b/src/components/video-editor/videoPlayback/zoomSpring.ts @@ -0,0 +1,88 @@ +import { + createSpringState, + getZoomSpringConfig, + type SpringState, + stepSpringValue, +} from "./motionSmoothing"; + +/** + * Spring-chase for the camera zoom transform. + * + * The zoom envelope (`computeZoomTransform`) is a deterministic, time-driven *target* shaped by an + * ease curve. Applying it straight to the camera reproduces every velocity discontinuity in that + * curve — the steep launch of the ease-in, the seams between close regions — which reads as a jerk. + * Instead we chase the target with a spring per axis: the target supplies the authored timing, the + * spring guarantees the rendered motion is velocity-continuous, so it physically can't jerk. + */ + +export interface ZoomTransform { + scale: number; + x: number; + y: number; +} + +export interface ZoomSpringState { + scale: SpringState; + x: SpringState; + y: SpringState; +} + +export function createZoomSpringState(): ZoomSpringState { + return { + scale: createSpringState(1), + x: createSpringState(0), + y: createSpringState(0), + }; +} + +/** Snap every axis straight to the target (used on seek / pause / first frame). */ +export function resetZoomSpring(state: ZoomSpringState, target: ZoomTransform): void { + for (const [axis, value] of [ + [state.scale, target.scale], + [state.x, target.x], + [state.y, target.y], + ] as const) { + axis.value = value; + axis.velocity = 0; + axis.initialized = true; + } +} + +/** + * Step one axis toward `target`, with a moving-target overshoot clamp: because the target moves every + * frame, a fast (near-critical) spring can carry velocity past it on a reversal and wobble. If the + * step crosses the target, we snap to it and zero the velocity — keeping the spring quick without jelly. + */ +function stepAxis( + axis: SpringState, + target: number, + deltaMs: number, + config: ReturnType, +): number { + const before = axis.initialized ? axis.value : target; + const after = stepSpringValue(axis, target, deltaMs, config); + const crossed = (before <= target && after > target) || (before >= target && after < target); + if (crossed) { + axis.value = target; + axis.velocity = 0; + return target; + } + return after; +} + +/** + * Advance the spring toward `target` by `deltaMs` (content time). Returns the smoothed transform to + * apply to the camera. + */ +export function stepZoomSpring( + state: ZoomSpringState, + target: ZoomTransform, + deltaMs: number, +): ZoomTransform { + const config = getZoomSpringConfig(); + return { + scale: stepAxis(state.scale, target.scale, deltaMs, config), + x: stepAxis(state.x, target.x, deltaMs, config), + y: stepAxis(state.y, target.y, deltaMs, config), + }; +} diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 2e19b35e..c8fc846a 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -28,8 +28,6 @@ import { AUTO_FOLLOW_SMOOTHING_FACTOR, AUTO_FOLLOW_SMOOTHING_FACTOR_MAX, DEFAULT_FOCUS, - ZOOM_SCALE_DEADZONE, - ZOOM_TRANSLATION_DEADZONE_PX, } from "@/components/video-editor/videoPlayback/constants"; import { adaptiveSmoothFactor, @@ -37,6 +35,11 @@ import { } from "@/components/video-editor/videoPlayback/cursorFollowUtils"; import { clampFocusToScale } from "@/components/video-editor/videoPlayback/focusUtils"; import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; +import { + createZoomSpringState, + resetZoomSpring, + stepZoomSpring, +} from "@/components/video-editor/videoPlayback/zoomSpring"; import { applyZoomTransform, computeFocusFromTransform, @@ -163,6 +166,7 @@ export class FrameRenderer { private nativeCursorMotionBlurState = createNativeCursorMotionBlurState(); private smoothedAutoFocus: { cx: number; cy: number } | null = null; private prevAnimationTimeMs: number | null = null; + private zoomSpringState = createZoomSpringState(); private prevTargetProgress = 0; private isLinux = false; @@ -903,18 +907,24 @@ export class FrameRenderer { focusY: state.focusY, }); - const appliedScale = - Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE - ? projectedTransform.scale - : projectedTransform.scale; - const appliedX = - Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.x - : projectedTransform.x; - const appliedY = - Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.y - : projectedTransform.y; + // Spring-chase the eased target (same as preview) so exported motion glides without the + // jerk at the steep start of the ease. Stepped by content time; snapped on the first frame + // or any large time jump. + const dtMs = this.prevAnimationTimeMs != null ? timeMs - this.prevAnimationTimeMs : 0; + let appliedScale: number; + let appliedX: number; + let appliedY: number; + if (this.prevAnimationTimeMs == null || dtMs <= 0 || dtMs > 80) { + resetZoomSpring(this.zoomSpringState, projectedTransform); + appliedScale = projectedTransform.scale; + appliedX = projectedTransform.x; + appliedY = projectedTransform.y; + } else { + const sprung = stepZoomSpring(this.zoomSpringState, projectedTransform, dtMs); + appliedScale = sprung.scale; + appliedX = sprung.x; + appliedY = sprung.y; + } state.x = appliedX; state.y = appliedY;