diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index ca4cf639..ee29c934 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -225,7 +225,18 @@ const VideoPlayback = forwardRef( const videoSizeRef = useRef({ width: 0, height: 0 }); const baseScaleRef = useRef(1); const baseOffsetRef = useRef({ x: 0, y: 0 }); - const baseMaskRef = useRef({ x: 0, y: 0, width: 0, height: 0 }); + const baseMaskRef = useRef<{ + x: number; + y: number; + width: number; + height: number; + sourceCrop?: { + x: number; + y: number; + width: number; + height: number; + }; + }>({ x: 0, y: 0, width: 0, height: 0 }); const cropBoundsRef = useRef({ startX: 0, endX: 0, startY: 0, endY: 0 }); const maskGraphicsRef = useRef(null); const isPlayingRef = useRef(isPlaying); diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts index 3fe53279..6be84150 100644 --- a/src/components/video-editor/videoPlayback/cursorRenderer.ts +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -8,6 +8,10 @@ import { } from "pixi.js"; import { MotionBlurFilter } from "pixi-filters/motion-blur"; import { DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, type CursorTelemetryPoint } from "../types"; +import { + projectCursorPositionToViewport, + type CursorViewportRect, +} from "./cursorViewport"; import { createSpringState, getCursorSpringConfig, @@ -30,13 +34,6 @@ type LoadedCursorAsset = { anchorY: number; }; -export interface CursorViewportRect { - x: number; - y: number; - width: number; - height: number; -} - /** * Configuration for cursor rendering. */ @@ -697,6 +694,20 @@ export class PixiCursorOverlay { return; } + const projectedTarget = projectCursorPositionToViewport( + target, + viewport.sourceCrop, + ); + if (!projectedTarget.visible) { + this.container.visible = false; + this.lastRenderedPoint = null; + this.lastRenderedTimeMs = null; + this.swayRotation = 0; + resetSpringState(this.swaySpring, 0); + this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; + return; + } + const sameFrameTime = this.lastRenderedTimeMs !== null && Math.abs(this.lastRenderedTimeMs - timeMs) < 0.0001; @@ -707,10 +718,10 @@ export class PixiCursorOverlay { if (shouldFreezeCursorMotion) { if (!sameFrameTime || !this.lastRenderedPoint) { - this.state.snapTo(target.cx, target.cy, timeMs); + this.state.snapTo(projectedTarget.cx, projectedTarget.cy, timeMs); } } else { - this.state.update(target.cx, target.cy, timeMs); + this.state.update(projectedTarget.cx, projectedTarget.cy, timeMs); } this.container.visible = true; @@ -895,7 +906,13 @@ export function drawCursorOnCanvas( const target = interpolateCursorPosition(samples, timeMs); if (!target) return; - smoothedState.update(target.cx, target.cy, timeMs); + const projectedTarget = projectCursorPositionToViewport( + target, + viewport.sourceCrop, + ); + if (!projectedTarget.visible) return; + + smoothedState.update(projectedTarget.cx, projectedTarget.cy, timeMs); const px = viewport.x + smoothedState.x * viewport.width; const py = viewport.y + smoothedState.y * viewport.height; diff --git a/src/components/video-editor/videoPlayback/cursorViewport.test.ts b/src/components/video-editor/videoPlayback/cursorViewport.test.ts new file mode 100644 index 00000000..a473a26e --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorViewport.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { projectCursorPositionToViewport } from "./cursorViewport"; + +describe("projectCursorPositionToViewport", () => { + it("leaves coordinates unchanged when no crop is active", () => { + expect( + projectCursorPositionToViewport({ cx: 0.25, cy: 0.75 }), + ).toEqual({ + cx: 0.25, + cy: 0.75, + visible: true, + }); + }); + + it("remaps source-space coordinates into crop-relative viewport space", () => { + const projected = projectCursorPositionToViewport( + { cx: 0.3, cy: 0.55 }, + { x: 0.2, y: 0.4, width: 0.5, height: 0.4 }, + ); + + expect(projected.visible).toBe(true); + expect(projected.cx).toBeCloseTo(0.2, 6); + expect(projected.cy).toBeCloseTo(0.375, 6); + }); + + it("marks cursor invisible when it falls outside the cropped source region", () => { + expect( + projectCursorPositionToViewport( + { cx: 0.1, cy: 0.5 }, + { x: 0.2, y: 0.25, width: 0.5, height: 0.5 }, + ), + ).toEqual({ + cx: -0.2, + cy: 0.5, + visible: false, + }); + }); +}); \ No newline at end of file diff --git a/src/components/video-editor/videoPlayback/cursorViewport.ts b/src/components/video-editor/videoPlayback/cursorViewport.ts new file mode 100644 index 00000000..8a3def64 --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorViewport.ts @@ -0,0 +1,55 @@ +import type { CropRegion } from "../types"; + +const CURSOR_VIEWPORT_EPSILON = 0.000001; + +export interface CursorViewportRect { + x: number; + y: number; + width: number; + height: number; + sourceCrop?: CropRegion; +} + +export interface ProjectedCursorPosition { + cx: number; + cy: number; + visible: boolean; +} + +export function projectCursorPositionToViewport( + position: { cx: number; cy: number }, + sourceCrop?: CropRegion, +): ProjectedCursorPosition { + if (!sourceCrop) { + return { + cx: position.cx, + cy: position.cy, + visible: true, + }; + } + + const cropWidth = sourceCrop.width; + const cropHeight = sourceCrop.height; + + if (cropWidth <= 0 || cropHeight <= 0) { + return { + cx: position.cx, + cy: position.cy, + visible: false, + }; + } + + const projectedX = (position.cx - sourceCrop.x) / cropWidth; + const projectedY = (position.cy - sourceCrop.y) / cropHeight; + const visible = + projectedX >= -CURSOR_VIEWPORT_EPSILON && + projectedX <= 1 + CURSOR_VIEWPORT_EPSILON && + projectedY >= -CURSOR_VIEWPORT_EPSILON && + projectedY <= 1 + CURSOR_VIEWPORT_EPSILON; + + return { + cx: projectedX, + cy: projectedY, + visible, + }; +} \ No newline at end of file diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index 7d157f9c..b4acfb5a 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -18,7 +18,13 @@ interface LayoutResult { videoSize: { width: number; height: number }; baseScale: number; baseOffset: { x: number; y: number }; - maskRect: { x: number; y: number; width: number; height: number }; + maskRect: { + x: number; + y: number; + width: number; + height: number; + sourceCrop?: CropRegion; + }; cropBounds: { startX: number; endX: number; startY: number; endY: number }; } @@ -105,7 +111,13 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { videoSize: { width: croppedVideoWidth, height: croppedVideoHeight }, baseScale: scale, baseOffset: { x: spriteX, y: spriteY }, - maskRect: { x: maskX, y: maskY, width: croppedDisplayWidth, height: croppedDisplayHeight }, + maskRect: { + x: maskX, + y: maskY, + width: croppedDisplayWidth, + height: croppedDisplayHeight, + sourceCrop: crop, + }, cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY }, }; } diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 27437c21..1e9e9deb 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -772,6 +772,7 @@ export class FrameRenderer { y: centerOffsetY, width: croppedDisplayWidth, height: croppedDisplayHeight, + sourceCrop: cropRegion, }, }; }