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();