feat: enhance adaptive smoothing for auto-follow zoom in video playback

This commit is contained in:
xKeCo
2026-04-03 12:26:07 -05:00
parent 05a87a8ab1
commit 54df597160
6 changed files with 133 additions and 47 deletions
+30 -16
View File
@@ -41,13 +41,14 @@ import {
type ZoomRegion,
} from "./types";
import {
AUTO_FOLLOW_DEADZONE,
AUTO_FOLLOW_RAMP_DISTANCE,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
DEFAULT_FOCUS,
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "./videoPlayback/constants";
import { smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
@@ -843,10 +844,16 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
};
const ticker = () => {
const bm = baseMaskRef.current;
const ss = stageSizeRef.current;
const viewportRatio =
bm.width > 0 && bm.height > 0
? { widthRatio: ss.width / bm.width, heightRatio: ss.height / bm.height }
: undefined;
const { region, strength, blendedScale, transition } = findDominantRegion(
zoomRegionsRef.current,
currentTimeRef.current,
{ connectZooms: true, cursorTelemetry: cursorTelemetryRef.current },
{ connectZooms: true, cursorTelemetry: cursorTelemetryRef.current, viewportRatio },
);
const defaultFocus = DEFAULT_FOCUS;
@@ -867,24 +874,24 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
targetFocus = regionFocus;
targetProgress = strength;
// Apply deadzone + smoothing for auto-follow mode
// Apply adaptive smoothing for auto-follow mode
if (region.focusMode === "auto" && !transition) {
const raw = targetFocus;
const isZoomingIn =
targetProgress < 0.999 && targetProgress >= prevTargetProgressRef.current;
if (targetProgress >= 0.999) {
// Full zoom: apply deadzone + smoothing for stable follow
// Full zoom: adaptive smoothing — moves faster when far, decelerates when close
const prev = smoothedAutoFocusRef.current ?? raw;
const dx = Math.abs(raw.cx - prev.cx);
const dy = Math.abs(raw.cy - prev.cy);
if (dx > AUTO_FOLLOW_DEADZONE || dy > AUTO_FOLLOW_DEADZONE) {
const smoothed = smoothCursorFocus(raw, prev, AUTO_FOLLOW_SMOOTHING_FACTOR);
smoothedAutoFocusRef.current = smoothed;
targetFocus = smoothed;
} else {
smoothedAutoFocusRef.current = prev;
targetFocus = prev;
}
const factor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const smoothed = smoothCursorFocus(raw, prev, factor);
smoothedAutoFocusRef.current = smoothed;
targetFocus = smoothed;
} else if (isZoomingIn) {
// Zoom-in: track cursor directly so zoom always aims at current cursor
// position; keep ref in sync to avoid snap when full-zoom begins
@@ -892,7 +899,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
} else {
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
const prev = smoothedAutoFocusRef.current ?? raw;
const smoothed = smoothCursorFocus(raw, prev, AUTO_FOLLOW_SMOOTHING_FACTOR);
const factor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const smoothed = smoothCursorFocus(raw, prev, factor);
smoothedAutoFocusRef.current = smoothed;
targetFocus = smoothed;
}
@@ -8,5 +8,6 @@ export const VIEWPORT_SCALE = 0.8;
export const SMOOTHING_FACTOR = 0.12;
export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25;
export const ZOOM_SCALE_DEADZONE = 0.002;
export const AUTO_FOLLOW_SMOOTHING_FACTOR = 0.05;
export const AUTO_FOLLOW_DEADZONE = 0.06;
export const AUTO_FOLLOW_SMOOTHING_FACTOR = 0.1;
export const AUTO_FOLLOW_SMOOTHING_FACTOR_MAX = 0.25;
export const AUTO_FOLLOW_RAMP_DISTANCE = 0.15;
@@ -52,3 +52,22 @@ export function smoothCursorFocus(raw: ZoomFocus, prev: ZoomFocus, factor: numbe
cy: prev.cy + (raw.cy - prev.cy) * factor,
};
}
/**
* Compute an adaptive smoothing factor that scales with distance:
* far from target → faster (maxFactor), close → slower (minFactor).
* This replaces the hard deadzone with a natural deceleration curve.
*/
export function adaptiveSmoothFactor(
raw: ZoomFocus,
prev: ZoomFocus,
minFactor: number,
maxFactor: number,
rampDistance: number,
): number {
const dx = raw.cx - prev.cx;
const dy = raw.cy - prev.cy;
const distance = Math.sqrt(dx * dx + dy * dy);
const t = Math.min(1, distance / rampDistance);
return minFactor + (maxFactor - minFactor) * t;
}
@@ -39,9 +39,16 @@ function getFocusBounds(depth: ZoomDepth) {
return getFocusBoundsForScale(zoomScale);
}
function getFocusBoundsForScale(zoomScale: number) {
const marginX = 1 / (2 * zoomScale);
const marginY = 1 / (2 * zoomScale);
interface ViewportRatio {
widthRatio: number;
heightRatio: number;
}
function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
const wr = viewportRatio?.widthRatio ?? 1;
const hr = viewportRatio?.heightRatio ?? 1;
const marginX = Math.min(0.5, wr / (2 * zoomScale));
const marginY = Math.min(0.5, hr / (2 * zoomScale));
return {
minX: marginX,
@@ -65,12 +72,16 @@ export function clampFocusToStage(
};
}
export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
export function clampFocusToScale(
focus: ZoomFocus,
zoomScale: number,
viewportRatio?: ViewportRatio,
): ZoomFocus {
const baseFocus = {
cx: clamp(focus.cx, 0, 1),
cy: clamp(focus.cy, 0, 1),
};
const bounds = getFocusBoundsForScale(zoomScale);
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
return {
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
@@ -78,12 +89,16 @@ export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocu
};
}
export function softenFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
export function softenFocusToScale(
focus: ZoomFocus,
zoomScale: number,
viewportRatio?: ViewportRatio,
): ZoomFocus {
const baseFocus = {
cx: clamp(focus.cx, 0, 1),
cy: clamp(focus.cy, 0, 1),
};
const bounds = getFocusBoundsForScale(zoomScale);
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
const horizontalRange = bounds.maxX - bounds.minX;
const verticalRange = bounds.maxY - bounds.minY;
const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35);
@@ -12,6 +12,7 @@ const ZOOM_IN_OVERLAP_MS = 500;
type DominantRegionOptions = {
connectZooms?: boolean;
cursorTelemetry?: CursorTelemetryPoint[];
viewportRatio?: ViewportRatio;
};
type ConnectedRegionPair = {
@@ -66,11 +67,17 @@ function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomF
};
}
interface ViewportRatio {
widthRatio: number;
heightRatio: number;
}
function getResolvedFocus(
region: ZoomRegion,
zoomScale: number,
timeMs?: number,
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
): ZoomFocus {
let focus = region.focus;
@@ -86,7 +93,7 @@ function getResolvedFocus(
}
}
return clampFocusToScale(focus, zoomScale);
return clampFocusToScale(focus, zoomScale, viewportRatio);
}
function getConnectedRegionPairs(regions: ZoomRegion[]) {
@@ -118,6 +125,7 @@ function getActiveRegion(
timeMs: number,
connectedPairs: ConnectedRegionPair[],
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
) {
const activeRegions = regions
.map((region) => {
@@ -152,7 +160,7 @@ function getActiveRegion(
return {
region: {
...activeRegion,
focus: getResolvedFocus(activeRegion, activeScale, timeMs, cursorTelemetry),
focus: getResolvedFocus(activeRegion, activeScale, timeMs, cursorTelemetry, viewportRatio),
},
strength: activeRegions[0].strength,
blendedScale: null,
@@ -163,6 +171,7 @@ function getConnectedRegionHold(
timeMs: number,
connectedPairs: ConnectedRegionPair[],
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
) {
for (const pair of connectedPairs) {
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
@@ -170,7 +179,13 @@ function getConnectedRegionHold(
return {
region: {
...pair.nextRegion,
focus: getResolvedFocus(pair.nextRegion, nextScale, timeMs, cursorTelemetry),
focus: getResolvedFocus(
pair.nextRegion,
nextScale,
timeMs,
cursorTelemetry,
viewportRatio,
),
},
strength: 1,
blendedScale: null,
@@ -185,6 +200,7 @@ function getConnectedRegionTransition(
connectedPairs: ConnectedRegionPair[],
timeMs: number,
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
) {
for (const pair of connectedPairs) {
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
@@ -209,10 +225,12 @@ function getConnectedRegionTransition(
? sharedCursorFocus
: currentRegion.focus,
currentScale,
viewportRatio,
);
const nextFocus = clampFocusToScale(
nextRegion.focusMode === "auto" && sharedCursorFocus ? sharedCursorFocus : nextRegion.focus,
nextScale,
viewportRatio,
);
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
@@ -248,20 +266,21 @@ export function findDominantRegion(
} {
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
const telemetry = options.cursorTelemetry;
const vr = options.viewportRatio;
if (options.connectZooms) {
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry);
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
if (connectedTransition) {
return connectedTransition;
}
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry);
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
if (connectedHold) {
return { ...connectedHold, transition: null };
}
}
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry);
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
return activeRegion
? { ...activeRegion, transition: null }
: { region: null, strength: 0, blendedScale: null, transition: null };
+34 -16
View File
@@ -18,13 +18,17 @@ import type {
} from "@/components/video-editor/types";
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
import {
AUTO_FOLLOW_DEADZONE,
AUTO_FOLLOW_RAMP_DISTANCE,
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 { smoothCursorFocus } from "@/components/video-editor/videoPlayback/cursorFollowUtils";
import {
adaptiveSmoothFactor,
smoothCursorFocus,
} from "@/components/video-editor/videoPlayback/cursorFollowUtils";
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import {
@@ -515,10 +519,16 @@ export class FrameRenderer {
private updateAnimationState(timeMs: number): number {
if (!this.cameraContainer || !this.layoutCache) return 0;
const bmEx = this.layoutCache.maskRect;
const ssEx = this.layoutCache.stageSize;
const viewportRatio =
bmEx.width > 0 && bmEx.height > 0
? { widthRatio: ssEx.width / bmEx.width, heightRatio: ssEx.height / bmEx.height }
: undefined;
const { region, strength, blendedScale, transition } = findDominantRegion(
this.config.zoomRegions,
timeMs,
{ connectZooms: true, cursorTelemetry: this.config.cursorTelemetry },
{ connectZooms: true, cursorTelemetry: this.config.cursorTelemetry, viewportRatio },
);
const defaultFocus = DEFAULT_FOCUS;
@@ -534,26 +544,26 @@ export class FrameRenderer {
targetFocus = regionFocus;
targetProgress = strength;
// Apply deadzone + time-based smoothing for auto-follow mode
// Apply adaptive smoothing for auto-follow mode
if (region.focusMode === "auto" && !transition) {
const raw = targetFocus;
const dtMs = this.prevAnimationTimeMs != null ? timeMs - this.prevAnimationTimeMs : 0;
const framesElapsed = dtMs > 0 ? dtMs / (1000 / 60) : 1;
const factor = 1 - Math.pow(1 - AUTO_FOLLOW_SMOOTHING_FACTOR, Math.max(1, framesElapsed));
const isZoomingIn = targetProgress < 0.999 && targetProgress >= this.prevTargetProgress;
if (targetProgress >= 0.999) {
// Full zoom: apply deadzone + smoothing for stable follow
// Full zoom: adaptive smoothing — moves faster when far, decelerates when close
const prev = this.smoothedAutoFocus ?? raw;
const dx = Math.abs(raw.cx - prev.cx);
const dy = Math.abs(raw.cy - prev.cy);
if (dx > AUTO_FOLLOW_DEADZONE || dy > AUTO_FOLLOW_DEADZONE) {
const smoothed = smoothCursorFocus(raw, prev, factor);
this.smoothedAutoFocus = smoothed;
targetFocus = smoothed;
} else {
this.smoothedAutoFocus = prev;
targetFocus = prev;
}
const baseFactor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const factor = 1 - Math.pow(1 - baseFactor, Math.max(1, framesElapsed));
const smoothed = smoothCursorFocus(raw, prev, factor);
this.smoothedAutoFocus = smoothed;
targetFocus = smoothed;
} else if (isZoomingIn) {
// Zoom-in: track cursor directly so zoom always aims at current cursor
// position; keep ref in sync to avoid snap when full-zoom begins
@@ -561,6 +571,14 @@ export class FrameRenderer {
} else {
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
const prev = this.smoothedAutoFocus ?? raw;
const baseFactor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const factor = 1 - Math.pow(1 - baseFactor, Math.max(1, framesElapsed));
const smoothed = smoothCursorFocus(raw, prev, factor);
this.smoothedAutoFocus = smoothed;
targetFocus = smoothed;