cleanup follow effect

This commit is contained in:
Siddharth
2026-06-04 21:06:48 -07:00
parent e49cc02d75
commit 02b4e1b45b
4 changed files with 202 additions and 28 deletions
+27 -14
View File
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(
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<number | null>(null);
const blurFilterRef = useRef<BlurFilter | null>(null);
const motionBlurFilterRef = useRef<MotionBlurFilter | null>(null);
const isDraggingFocusRef = useRef(false);
@@ -1485,18 +1487,29 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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),
@@ -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);
});
});
@@ -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<typeof getZoomSpringConfig>,
): 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),
};
}
+24 -14
View File
@@ -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;