From 5b07af5083eb7cc389b34c3262743d143516efd8 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:43:27 +1000 Subject: [PATCH] feat(zoom): add spring animation and cursor follow to export pipeline Both legacy (FrameRenderer) and modern (ModernFrameRenderer) exporters now use inlined spring solver and cursor follow camera for auto zooms. Wire zoomSmoothness config through both exporter paths. --- src/lib/exporter/frameRenderer.ts | 63 +++++++++++++++++------ src/lib/exporter/modernFrameRenderer.ts | 68 +++++++++++++++++++------ src/lib/exporter/modernVideoExporter.ts | 2 + src/lib/exporter/videoExporter.ts | 2 + 4 files changed, 104 insertions(+), 31 deletions(-) diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 9263c150..b86fba09 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -23,6 +23,12 @@ import type { import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types"; import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath"; import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; +import { + type CursorFollowCameraState, + createCursorFollowCameraState, + computeCursorFollowFocus, + SNAP_TO_EDGES_RATIO_AUTO, +} from "@/components/video-editor/videoPlayback/cursorFollowCamera"; import { applyZoomTransform, computeFocusFromTransform, @@ -32,9 +38,13 @@ import { } from "@/components/video-editor/videoPlayback/zoomTransform"; import { DEFAULT_FOCUS, - ZOOM_SCALE_DEADZONE, - ZOOM_TRANSLATION_DEADZONE_PX, } from "@/components/video-editor/videoPlayback/constants"; +import { + type SpringState, + createSpringState, + stepSpringValue, + getZoomSpringConfig, +} from "@/components/video-editor/videoPlayback/motionSmoothing"; import { renderAnnotations } from "./annotationRenderer"; import { renderCaptions } from "./captionRenderer"; import { @@ -86,6 +96,7 @@ interface FrameRenderConfig { cursorStyle?: CursorStyle; cursorSize?: number; cursorSmoothing?: number; + zoomSmoothness?: number; cursorMotionBlur?: number; cursorClickBounce?: number; cursorClickBounceDuration?: number; @@ -153,6 +164,11 @@ export class FrameRenderer { private layoutCache: any = null; private currentVideoTime = 0; private lastMotionVector = { x: 0, y: 0 }; + private springScale: SpringState; + private springX: SpringState; + private springY: SpringState; + private cursorFollowCamera: CursorFollowCameraState; + private lastFrameTimeMs: number | null = null; private cursorOverlay: PixiCursorOverlay | null = null; private webcamForwardFrameSource: ForwardFrameSource | null = null; private webcamDecodedFrame: VideoFrame | null = null; @@ -169,6 +185,10 @@ export class FrameRenderer { this.config = config; this.animationState = createAnimationState(); this.motionBlurState = createMotionBlurState(); + this.springScale = createSpringState(1); + this.springX = createSpringState(0); + this.springY = createSpringState(0); + this.cursorFollowCamera = createCursorFollowCameraState(); } async initialize(): Promise { @@ -1034,7 +1054,20 @@ export class FrameRenderer { if (region && strength > 0) { const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; - const regionFocus = region.focus; + + // Cursor follow: use cursor-follow camera for non-manual zoom regions + let regionFocus = region.focus; + if (region.mode !== 'manual' && this.config.cursorTelemetry && this.config.cursorTelemetry.length > 0) { + regionFocus = computeCursorFollowFocus( + this.cursorFollowCamera, + this.config.cursorTelemetry, + timeMs, + zoomScale, + strength, + region.focus, + { snapToEdgesRatio: SNAP_TO_EDGES_RATIO_AUTO }, + ); + } targetScaleFactor = zoomScale; targetFocus = regionFocus; @@ -1102,18 +1135,18 @@ export class FrameRenderer { focusY: state.focusY, }); - state.appliedScale = - Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE - ? projectedTransform.scale - : projectedTransform.scale; - state.x = - Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.x - : projectedTransform.x; - state.y = - Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.y - : projectedTransform.y; + // Spring-driven zoom animation for export + const now = performance.now(); + const deltaMs = this.lastFrameTimeMs !== null + ? now - this.lastFrameTimeMs + : 1000 / 60; + this.lastFrameTimeMs = now; + + const zoomSpringConfig = getZoomSpringConfig(this.config.zoomSmoothness); + + state.appliedScale = stepSpringValue(this.springScale, projectedTransform.scale, deltaMs, zoomSpringConfig); + state.x = stepSpringValue(this.springX, projectedTransform.x, deltaMs, zoomSpringConfig); + state.y = stepSpringValue(this.springY, projectedTransform.y, deltaMs, zoomSpringConfig); this.lastMotionVector = { x: state.x - prevX, diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 3ed26311..3aa30125 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -25,8 +25,6 @@ import type { import { getDefaultCaptionFontFamily, ZOOM_DEPTH_SCALES } from "@/components/video-editor/types"; import { DEFAULT_FOCUS, - ZOOM_SCALE_DEADZONE, - ZOOM_TRANSLATION_DEADZONE_PX, } from "@/components/video-editor/videoPlayback/constants"; import { DEFAULT_CURSOR_CONFIG, @@ -34,6 +32,18 @@ import { preloadCursorAssets, } from "@/components/video-editor/videoPlayback/cursorRenderer"; import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; +import { + type CursorFollowCameraState, + createCursorFollowCameraState, + computeCursorFollowFocus, + SNAP_TO_EDGES_RATIO_AUTO, +} from "@/components/video-editor/videoPlayback/cursorFollowCamera"; +import { + type SpringState, + createSpringState, + stepSpringValue, + getZoomSpringConfig, +} from "@/components/video-editor/videoPlayback/motionSmoothing"; import { applyZoomTransform, computeFocusFromTransform, @@ -105,6 +115,7 @@ interface FrameRenderConfig { cursorClickBounce?: number; cursorClickBounceDuration?: number; cursorSway?: number; + zoomSmoothness?: number; } interface AnimationState { @@ -324,6 +335,11 @@ export class FrameRenderer { private config: FrameRenderConfig; private animationState: AnimationState; private motionBlurState: MotionBlurState; + private springScale: SpringState; + private springX: SpringState; + private springY: SpringState; + private cursorFollowCamera: CursorFollowCameraState; + private lastFrameTimeMs: number | null = null; private layoutCache: LayoutCache | null = null; private currentVideoTime = 0; private lastMotionVector = { x: 0, y: 0 }; @@ -342,6 +358,10 @@ export class FrameRenderer { this.config = config; this.animationState = createAnimationState(); this.motionBlurState = createMotionBlurState(); + this.springScale = createSpringState(1); + this.springX = createSpringState(0); + this.springY = createSpringState(0); + this.cursorFollowCamera = createCursorFollowCameraState(); } private shouldUseZoomMotionBlur(): boolean { @@ -2143,8 +2163,24 @@ export class FrameRenderer { let targetProgress = 0; if (region && strength > 0) { - targetScaleFactor = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; - targetFocus = region.focus; + const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; + + // Cursor follow: use cursor-follow camera for non-manual zoom regions + let regionFocus = region.focus; + if (region.mode !== 'manual' && this.config.cursorTelemetry && this.config.cursorTelemetry.length > 0) { + regionFocus = computeCursorFollowFocus( + this.cursorFollowCamera, + this.config.cursorTelemetry, + timeMs, + zoomScale, + strength, + region.focus, + { snapToEdgesRatio: SNAP_TO_EDGES_RATIO_AUTO }, + ); + } + + targetScaleFactor = zoomScale; + targetFocus = regionFocus; targetProgress = strength; if (transition) { @@ -2204,18 +2240,18 @@ export class FrameRenderer { focusY: state.focusY, }); - state.appliedScale = - Math.abs(projectedTransform.scale - previousScale) < ZOOM_SCALE_DEADZONE - ? projectedTransform.scale - : projectedTransform.scale; - state.x = - Math.abs(projectedTransform.x - previousX) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.x - : projectedTransform.x; - state.y = - Math.abs(projectedTransform.y - previousY) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.y - : projectedTransform.y; + // Spring-driven zoom animation for export + const now = performance.now(); + const deltaMs = this.lastFrameTimeMs !== null + ? now - this.lastFrameTimeMs + : 1000 / 60; + this.lastFrameTimeMs = now; + + const zoomSpringConfig = getZoomSpringConfig(this.config.zoomSmoothness); + + state.appliedScale = stepSpringValue(this.springScale, projectedTransform.scale, deltaMs, zoomSpringConfig); + state.x = stepSpringValue(this.springX, projectedTransform.x, deltaMs, zoomSpringConfig); + state.y = stepSpringValue(this.springY, projectedTransform.y, deltaMs, zoomSpringConfig); this.lastMotionVector = { x: state.x - previousX, diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 7d939944..ba915029 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -79,6 +79,7 @@ interface VideoExporterConfig extends ExportConfig { cursorClickBounce?: number; cursorClickBounceDuration?: number; cursorSway?: number; + zoomSmoothness?: number; audioRegions?: AudioRegion[]; sourceAudioFallbackPaths?: string[]; previewWidth?: number; @@ -337,6 +338,7 @@ export class ModernVideoExporter { cursorClickBounce: this.config.cursorClickBounce, cursorClickBounceDuration: this.config.cursorClickBounceDuration, cursorSway: this.config.cursorSway, + zoomSmoothness: this.config.zoomSmoothness, }); await this.renderer.initialize(); this.rendererInitTimeMs = this.getNowMs() - stageStartedAt; diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 834fb0bd..9c19306a 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -61,6 +61,7 @@ interface VideoExporterConfig extends ExportConfig { cursorClickBounce?: number; cursorClickBounceDuration?: number; cursorSway?: number; + zoomSmoothness?: number; audioRegions?: AudioRegion[]; sourceAudioFallbackPaths?: string[]; previewWidth?: number; @@ -175,6 +176,7 @@ export class VideoExporter { cursorClickBounce: this.config.cursorClickBounce, cursorClickBounceDuration: this.config.cursorClickBounceDuration, cursorSway: this.config.cursorSway, + zoomSmoothness: this.config.zoomSmoothness, }); await this.renderer.initialize();