diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 7490f454..5aebd7ec 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -1084,6 +1084,7 @@ const VideoPlayback = forwardRef( motionBlurFilter.resolution = filterResolution; zoomBlurFilter.resolution = filterResolution; + cursorOverlayRef.current?.setFilterResolution(filterResolution); videoEffectsContainer.filterArea = new Rectangle(0, 0, stageWidth, stageHeight); }, []); @@ -2173,6 +2174,9 @@ const VideoPlayback = forwardRef( sway: cursorSwayRef.current, }); cursorOverlayRef.current = cursorOverlay; + cursorOverlay.setFilterResolution( + app.renderer.resolution || window.devicePixelRatio || 1, + ); cursorContainer.addChild(cursorOverlay.container); } else { cursorOverlayRef.current = null; diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts index c1346ba5..c10b915d 100644 --- a/src/components/video-editor/videoPlayback/cursorRenderer.ts +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -16,6 +16,7 @@ import { DEFAULT_CURSOR_STYLE, normalizeCursorClickEffectColor, } from "../types"; +import { getCursorViewportScale } from "./cursorScale"; import { computeCursorSwayRotation } from "./cursorSway"; import { type CursorViewportRect, projectCursorPositionToViewport } from "./cursorViewport"; import { @@ -110,8 +111,7 @@ export interface CursorRenderConfig { style: CursorStyle; } -const REFERENCE_WIDTH = 1920; -const MIN_CURSOR_VIEWPORT_SCALE = 0.55; +const MIN_CURSOR_VIEWPORT_SCALE = 0; const CURSOR_MOTION_BLUR_BASE_MULTIPLIER = 0.08; const CURSOR_TIME_DISCONTINUITY_MS = 100; const CURSOR_SWAY_SMOOTHING_MULTIPLIER = 0.7; @@ -807,13 +807,6 @@ function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: num return findLatestSample(samples, timeMs)?.cursorType ?? "arrow"; } -function getCursorViewportScale( - viewport: CursorViewportRect, - minViewportScale = MIN_CURSOR_VIEWPORT_SCALE, -) { - return Math.max(minViewportScale, viewport.width / REFERENCE_WIDTH); -} - function getCursorSwaySpringConfig(smoothingFactor: number, springTuning: CursorSpringTuning) { const baseConfig = getCursorSpringConfig( Math.min( @@ -1231,6 +1224,10 @@ export class PixiCursorOverlay { } } + setFilterResolution(resolution: number) { + this.cursorMotionBlurFilter.resolution = Math.max(1, resolution); + } + setClickBounce(clickBounce: number) { this.config.clickBounce = Math.max(0, clickBounce); } @@ -1346,7 +1343,7 @@ export class PixiCursorOverlay { const h = this.config.dotRadius * - getCursorViewportScale(viewport, this.config.minViewportScale); + getCursorViewportScale(viewport.width, this.config.minViewportScale); const { cursorType, clickSample, clickBounceProgress, clickProgress } = getCursorVisualState( samples, @@ -1642,7 +1639,7 @@ export function drawCursorOnCanvas( const px = viewport.x + smoothedState.x * viewport.width; const py = viewport.y + smoothedState.y * viewport.height; - const h = config.dotRadius * getCursorViewportScale(viewport, config.minViewportScale); + const h = config.dotRadius * getCursorViewportScale(viewport.width, config.minViewportScale); const { cursorType, clickSample, clickBounceProgress, clickProgress } = getCursorVisualState( samples, timeMs, diff --git a/src/components/video-editor/videoPlayback/cursorScale.test.ts b/src/components/video-editor/videoPlayback/cursorScale.test.ts new file mode 100644 index 00000000..ccd2c3ad --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorScale.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { getCursorViewportScale } from "./cursorScale"; + +describe("cursor preview/export scale", () => { + it("preserves the same cursor-to-video ratio at preview and export sizes", () => { + const baseCursorHeight = 28 * 2.5; + const previewWidth = 720; + const exportWidth = 2940; + const previewCursorHeight = baseCursorHeight * getCursorViewportScale(previewWidth); + const exportCursorHeight = baseCursorHeight * getCursorViewportScale(exportWidth); + + expect(previewCursorHeight / previewWidth).toBeCloseTo(exportCursorHeight / exportWidth, 8); + }); +}); diff --git a/src/components/video-editor/videoPlayback/cursorScale.ts b/src/components/video-editor/videoPlayback/cursorScale.ts new file mode 100644 index 00000000..878901d3 --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorScale.ts @@ -0,0 +1,5 @@ +export const CURSOR_REFERENCE_VIEWPORT_WIDTH = 1920; + +export function getCursorViewportScale(viewportWidth: number, minimumScale = 0): number { + return Math.max(minimumScale, Math.max(0, viewportWidth) / CURSOR_REFERENCE_VIEWPORT_WIDTH); +} diff --git a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts index f0e57d13..cdc62d12 100644 --- a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts +++ b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts @@ -236,12 +236,12 @@ describe("ModernVideoExporter native static-layout eligibility", () => { ).toBeNull(); }); - it("scales native static-layout cursor size with a minimum visible floor", () => { + it("preserves the cursor-to-video ratio at every native static-layout size", () => { const exporter = createExporter({ cursorSize: 3, cursorStyle: "tahoe" }); expect(exporter.getNativeStaticLayoutCursorSize(1920)).toBeCloseTo(84, 6); - expect(exporter.getNativeStaticLayoutCursorSize(960)).toBeCloseTo(46.2, 6); - expect(exporter.getNativeStaticLayoutCursorSize(480)).toBeCloseTo(46.2, 6); + expect(exporter.getNativeStaticLayoutCursorSize(960)).toBeCloseTo(42, 6); + expect(exporter.getNativeStaticLayoutCursorSize(480)).toBeCloseTo(21, 6); }); it("skips native static-layout when cursor click effects are enabled", () => { diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index c77ba964..a5e4e8fe 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -25,6 +25,7 @@ import { SNAP_TO_EDGES_RATIO_AUTO, } from "@/components/video-editor/videoPlayback/cursorFollowCamera"; import { buildNativeCursorAtlas } from "@/components/video-editor/videoPlayback/cursorRenderer"; +import { getCursorViewportScale } from "@/components/video-editor/videoPlayback/cursorScale"; import { computePaddedLayout, scalePreviewBorderRadius, @@ -2089,7 +2090,7 @@ export class ModernVideoExporter { private getNativeStaticLayoutCursorSize(contentWidth: number) { const cursorStyle = this.config.cursorStyle ?? "tahoe"; - const viewportScale = Math.max(0.55, contentWidth / 1920); + const viewportScale = getCursorViewportScale(contentWidth); return ( 28 * (this.config.cursorSize ?? 3) *