mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 23:35:43 +00:00
feat(zoom): add cursor follow camera with edge snap focus
Cursor follow camera tracks cursor position for auto zoom regions with edge snapping and zoom-out freeze. Edge snap focus remaps cursor to clamp near screen boundaries.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import type { CursorTelemetryPoint, ZoomFocus } from "../types";
|
||||
import { interpolateCursorPosition } from "./cursorRenderer";
|
||||
import { edgeSnapFocus } from "./focusUtils";
|
||||
|
||||
/**
|
||||
* Cursor-follow camera.
|
||||
*
|
||||
* Computes a TARGET focus point each frame (cursor → edge snap → focus),
|
||||
* then the zoom transition animation layer smoothly interpolates toward it.
|
||||
*
|
||||
* Edge snap: With snapToEdgesRatio = 0.25, cursor positions within 25% of
|
||||
* each edge pin the camera to that edge. The middle 50% maps linearly.
|
||||
* This prevents the camera from panning beyond the viewport bounds.
|
||||
*/
|
||||
|
||||
/** Default snap ratio for manual zoom regions */
|
||||
export const SNAP_TO_EDGES_RATIO_MANUAL = 0.25;
|
||||
/** Snap ratio for system/auto zoom regions */
|
||||
export const SNAP_TO_EDGES_RATIO_AUTO = 0.25;
|
||||
|
||||
export interface CursorFollowCameraState {
|
||||
/** Whether the state has been initialized with a starting position */
|
||||
initialized: boolean;
|
||||
/** Time of last update in ms (video time, not wall clock) */
|
||||
lastTimeMs: number;
|
||||
/** Whether the camera was active (zoomed) on the previous frame */
|
||||
wasZoomed: boolean;
|
||||
/** Whether the zoom reached full strength (≈1) — used to detect zoom-out */
|
||||
reachedFullZoom: boolean;
|
||||
/** Frozen focus when zooming out (camera holds position) */
|
||||
frozenFocusX: number;
|
||||
frozenFocusY: number;
|
||||
}
|
||||
|
||||
export interface CursorFollowConfig {
|
||||
/**
|
||||
* snapToEdgesRatio — how much of the screen edge pins the camera.
|
||||
* 0.25 for manual zooms, 0.5 for auto/system zooms.
|
||||
*/
|
||||
snapToEdgesRatio: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CURSOR_FOLLOW_CONFIG: CursorFollowConfig = {
|
||||
snapToEdgesRatio: SNAP_TO_EDGES_RATIO_AUTO,
|
||||
};
|
||||
|
||||
export function createCursorFollowCameraState(): CursorFollowCameraState {
|
||||
return {
|
||||
initialized: false,
|
||||
lastTimeMs: 0,
|
||||
wasZoomed: false,
|
||||
reachedFullZoom: false,
|
||||
frozenFocusX: 0.5,
|
||||
frozenFocusY: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetCursorFollowCamera(state: CursorFollowCameraState): void {
|
||||
state.initialized = false;
|
||||
state.lastTimeMs = 0;
|
||||
state.wasZoomed = false;
|
||||
state.reachedFullZoom = false;
|
||||
state.frozenFocusX = 0.5;
|
||||
state.frozenFocusY = 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor follow: target focus computation.
|
||||
*
|
||||
* Computes the desired camera focus point based on cursor position and
|
||||
* edge snap. The zoom transition layer handles smooth interpolation.
|
||||
*
|
||||
* Pipeline: cursor → edgeSnap(snapToEdgesRatio) → focus
|
||||
*
|
||||
* @returns The target focus point for this frame (normalized 0-1).
|
||||
*/
|
||||
export function computeCursorFollowFocus(
|
||||
state: CursorFollowCameraState,
|
||||
cursorSamples: CursorTelemetryPoint[],
|
||||
timeMs: number,
|
||||
zoomScale: number,
|
||||
zoomStrength: number,
|
||||
regionFocus: ZoomFocus,
|
||||
config: CursorFollowConfig = DEFAULT_CURSOR_FOLLOW_CONFIG,
|
||||
): ZoomFocus {
|
||||
// If no cursor data available, fall back to static region focus
|
||||
const cursorPos = interpolateCursorPosition(cursorSamples, timeMs);
|
||||
if (!cursorPos) {
|
||||
return regionFocus;
|
||||
}
|
||||
|
||||
// If not zoomed (strength ≈ 0), reset state and return region focus
|
||||
if (zoomStrength < 0.01) {
|
||||
if (state.wasZoomed) {
|
||||
state.wasZoomed = false;
|
||||
state.initialized = false;
|
||||
state.reachedFullZoom = false;
|
||||
}
|
||||
return regionFocus;
|
||||
}
|
||||
|
||||
// Track when zoom reaches full strength
|
||||
if (zoomStrength >= 0.99) {
|
||||
state.reachedFullZoom = true;
|
||||
}
|
||||
|
||||
// Zooming out: was fully zoomed but strength is now dropping — freeze camera
|
||||
if (state.reachedFullZoom && zoomStrength < 0.99) {
|
||||
return { cx: state.frozenFocusX, cy: state.frozenFocusY };
|
||||
}
|
||||
|
||||
// First frame of a zoom: mark initialized
|
||||
if (!state.initialized || !state.wasZoomed) {
|
||||
state.lastTimeMs = timeMs;
|
||||
state.initialized = true;
|
||||
state.wasZoomed = true;
|
||||
}
|
||||
|
||||
state.lastTimeMs = timeMs;
|
||||
|
||||
// Edge snap: maps cursor through clamped linear remap.
|
||||
// Camera pins to edge when cursor is within snapToEdgesRatio of boundary.
|
||||
const targetFocus = edgeSnapFocus(
|
||||
{ cx: cursorPos.cx, cy: cursorPos.cy },
|
||||
zoomScale,
|
||||
config.snapToEdgesRatio,
|
||||
);
|
||||
|
||||
// Save for zoom-out freeze
|
||||
state.frozenFocusX = targetFocus.cx;
|
||||
state.frozenFocusY = targetFocus.cy;
|
||||
|
||||
return targetFocus;
|
||||
}
|
||||
@@ -101,6 +101,48 @@ export function softenFocusToScale(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge snap algorithm for cursor-follow camera.
|
||||
*
|
||||
* Maps cursor position through a clamped linear remap so the camera
|
||||
* pins to the edge when the cursor is within `snapToEdgesRatio` of
|
||||
* the viewport boundary.
|
||||
*
|
||||
* With snapToEdgesRatio = 0.25 (default for manual zooms):
|
||||
* cursor ∈ [0, 0.25] → output 0 (camera pinned to left/top)
|
||||
* cursor ∈ [0.25, 0.75] → linearly maps 0→1 (camera follows)
|
||||
* cursor ∈ [0.75, 1.0] → output 1 (camera pinned to right/bottom)
|
||||
*
|
||||
* The result is then mapped back to focus-space bounds for the given
|
||||
* zoom scale, so the camera stays within the valid viewport.
|
||||
*
|
||||
* @param snapToEdgesRatio 0.25 for manual zooms, 0.5 for system/auto zooms
|
||||
*/
|
||||
export function edgeSnapFocus(
|
||||
cursorFocus: ZoomFocus,
|
||||
zoomScale: number,
|
||||
snapToEdgesRatio: number,
|
||||
): ZoomFocus {
|
||||
const bounds = getFocusBoundsForScale(zoomScale);
|
||||
|
||||
const snappedX = clampedInterpolate(cursorFocus.cx, snapToEdgesRatio, 1 - snapToEdgesRatio + 0.0001);
|
||||
const snappedY = clampedInterpolate(cursorFocus.cy, snapToEdgesRatio, 1 - snapToEdgesRatio + 0.0001);
|
||||
|
||||
return {
|
||||
cx: bounds.minX + snappedX * (bounds.maxX - bounds.minX),
|
||||
cy: bounds.minY + snappedY * (bounds.maxY - bounds.minY),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamped linear interpolation: maps `value` from [inMin, inMax] → [0, 1].
|
||||
*/
|
||||
function clampedInterpolate(value: number, inMin: number, inMax: number): number {
|
||||
if (inMax <= inMin) return 0;
|
||||
const t = (value - inMin) / (inMax - inMin);
|
||||
return clamp(t, 0, 1);
|
||||
}
|
||||
|
||||
export function stageFocusToVideoSpace(
|
||||
focus: ZoomFocus,
|
||||
stageSize: StageSize,
|
||||
|
||||
Reference in New Issue
Block a user