mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-11-03 22:43:12 +08:00
cleanup follow effect
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user