From 54df597160a130e82cbf9c43e4bc988c551fa5ba Mon Sep 17 00:00:00 2001 From: xKeCo Date: Fri, 3 Apr 2026 12:26:07 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20enhance=20adaptive=20smooth?= =?UTF-8?q?ing=20for=20auto-follow=20zoom=20in=20video=20playback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/video-editor/VideoPlayback.tsx | 46 +++++++++++------ .../video-editor/videoPlayback/constants.ts | 5 +- .../videoPlayback/cursorFollowUtils.ts | 19 +++++++ .../video-editor/videoPlayback/focusUtils.ts | 29 ++++++++--- .../videoPlayback/zoomRegionUtils.ts | 31 +++++++++--- src/lib/exporter/frameRenderer.ts | 50 +++++++++++++------ 6 files changed, 133 insertions(+), 47 deletions(-) diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 8c2d120..694c137 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -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( }; 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( 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( } 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; } diff --git a/src/components/video-editor/videoPlayback/constants.ts b/src/components/video-editor/videoPlayback/constants.ts index 1c41cb7..b5b4bd1 100644 --- a/src/components/video-editor/videoPlayback/constants.ts +++ b/src/components/video-editor/videoPlayback/constants.ts @@ -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; diff --git a/src/components/video-editor/videoPlayback/cursorFollowUtils.ts b/src/components/video-editor/videoPlayback/cursorFollowUtils.ts index 9c92c25..14dad24 100644 --- a/src/components/video-editor/videoPlayback/cursorFollowUtils.ts +++ b/src/components/video-editor/videoPlayback/cursorFollowUtils.ts @@ -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; +} diff --git a/src/components/video-editor/videoPlayback/focusUtils.ts b/src/components/video-editor/videoPlayback/focusUtils.ts index 5a56f0e..f893935 100644 --- a/src/components/video-editor/videoPlayback/focusUtils.ts +++ b/src/components/video-editor/videoPlayback/focusUtils.ts @@ -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); diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index 8543907..e5c16e1 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -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 }; diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index d08705d..17986ec 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -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;