✨ feat: enhance adaptive smoothing for auto-follow zoom in video playback
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user