diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 8bb6fbe0c..6550b378b 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -58,6 +58,7 @@ import { type BlurData, type CursorTelemetryPoint, computeRotation3DContainScale, + DEFAULT_CROP_REGION, DEFAULT_ROTATION_3D, getZoomScale, isRotation3DIdentity, @@ -1562,6 +1563,7 @@ const VideoPlayback = forwardRef( baseMaskRef.current, showCursorRef.current && !hasNativeCursorRecordingRef.current, !isPlayingRef.current || isSeekingRef.current, + cropRegionRef.current, ); } @@ -1600,7 +1602,7 @@ const VideoPlayback = forwardRef( : frame.sample; const cameraContainer = cameraContainerRef.current; const videoContainer = videoContainerRef.current; - const cropRegionValue = cropRegionRef.current ?? { x: 0, y: 0, width: 1, height: 1 }; + const cropRegionValue = cropRegionRef.current ?? DEFAULT_CROP_REGION; const projectedLocalPoint = projectNativeCursorToLocal({ cropRegion: cropRegionValue, maskRect: baseMaskRef.current, @@ -1637,7 +1639,7 @@ const VideoPlayback = forwardRef( getNativeCursorClickBounceScale(cursorClickBounceRef.current, bounceProgress); // Normalize cursor size to the displayed video width so the cursor // appears at the same fraction of the video in both preview and export. - const crop = cropRegionRef.current ?? { x: 0, y: 0, width: 1, height: 1 }; + const crop = cropRegionRef.current ?? DEFAULT_CROP_REGION; const croppedVideoWidth = (videoRef.current?.videoWidth ?? 0) * crop.width; const sizeNorm = croppedVideoWidth > 0 ? baseMaskRef.current.width / croppedVideoWidth : 1; diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.test.ts b/src/components/video-editor/videoPlayback/cursorRenderer.test.ts new file mode 100644 index 000000000..e604510dd --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorRenderer.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { CropRegion } from "../types"; +import { type CursorViewportRect, mapCursorToCroppedViewport } from "./cursorRenderer"; + +const FULL_CROP: CropRegion = { x: 0, y: 0, width: 1, height: 1 }; +const VIEWPORT: CursorViewportRect = { x: 100, y: 50, width: 800, height: 400 }; + +describe("mapCursorToCroppedViewport", () => { + it("maps positions directly onto the viewport when there is no crop", () => { + const center = mapCursorToCroppedViewport(0.5, 0.5, VIEWPORT, FULL_CROP); + expect(center).toEqual({ px: 500, py: 250 }); + + const topLeft = mapCursorToCroppedViewport(0, 0, VIEWPORT, FULL_CROP); + expect(topLeft).toEqual({ px: 100, py: 50 }); + + const bottomRight = mapCursorToCroppedViewport(1, 1, VIEWPORT, FULL_CROP); + expect(bottomRight).toEqual({ px: 900, py: 450 }); + }); + + it("re-normalizes a full-frame position into the cropped viewport", () => { + // Crop the right-bottom half of the frame. A telemetry point at the frame + // center (0.5, 0.5) sits at the top-left corner of this crop. + const crop: CropRegion = { x: 0.5, y: 0.5, width: 0.5, height: 0.5 }; + + const atCropOrigin = mapCursorToCroppedViewport(0.5, 0.5, VIEWPORT, crop); + expect(atCropOrigin).toEqual({ px: 100, py: 50 }); + + // The frame center of the crop (0.75, 0.75) maps to the viewport center. + const atCropCenter = mapCursorToCroppedViewport(0.75, 0.75, VIEWPORT, crop); + expect(atCropCenter).toEqual({ px: 500, py: 250 }); + }); + + it("does not drift: a point on the visible cropped content keeps its relative offset", () => { + // Regression test for issue #64. Before the fix the cursor was projected with + // the full-frame normalized coordinate directly onto the cropped viewport, + // which shifted (drifted) it away from the underlying pixel after cropping. + const crop: CropRegion = { x: 0.2, y: 0.1, width: 0.6, height: 0.6 }; + const normX = 0.6; // inside the crop, but offset from the crop's own center + const normY = 0.4; + + const mapped = mapCursorToCroppedViewport(normX, normY, VIEWPORT, crop); + + // Expected: re-normalize against the crop, then project onto the viewport. + const expectedPx = VIEWPORT.x + ((normX - crop.x) / crop.width) * VIEWPORT.width; + const expectedPy = VIEWPORT.y + ((normY - crop.y) / crop.height) * VIEWPORT.height; + expect(mapped).toEqual({ px: expectedPx, py: expectedPy }); + + // The naive (buggy) projection would have produced a different point. + const buggyPx = VIEWPORT.x + normX * VIEWPORT.width; + expect(mapped!.px).not.toBeCloseTo(buggyPx, 5); + }); + + it("returns null when the position falls outside the visible crop", () => { + const crop: CropRegion = { x: 0.5, y: 0.5, width: 0.5, height: 0.5 }; + // (0.1, 0.1) is in the top-left of the frame, outside the bottom-right crop. + expect(mapCursorToCroppedViewport(0.1, 0.1, VIEWPORT, crop)).toBeNull(); + }); + + it("returns null for a degenerate crop", () => { + expect( + mapCursorToCroppedViewport(0.5, 0.5, VIEWPORT, { x: 0, y: 0, width: 0, height: 0 }), + ).toBeNull(); + }); +}); diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts index 2edcca97d..4b2ba3a8a 100644 --- a/src/components/video-editor/videoPlayback/cursorRenderer.ts +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -1,6 +1,6 @@ import { Assets, BlurFilter, Container, Graphics, Sprite, Texture } from "pixi.js"; import { MotionBlurFilter } from "pixi-filters/motion-blur"; -import type { CursorTelemetryPoint } from "../types"; +import { type CropRegion, type CursorTelemetryPoint, DEFAULT_CROP_REGION } from "../types"; import { createSpringState, getCursorSpringConfig, @@ -389,6 +389,40 @@ function getCursorViewportScale(viewport: CursorViewportRect) { return Math.max(MIN_CURSOR_VIEWPORT_SCALE, viewport.width / REFERENCE_WIDTH); } +/** + * Maps a cursor position normalized to the full (uncropped) video into pixel + * coordinates inside the cropped viewport. Cursor telemetry is always stored + * relative to the full frame, so after a crop the normalized position must be + * re-normalized against the crop rect before it is projected onto the viewport + * (which already represents just the cropped area). Returns `null` when the + * position falls outside the visible crop, so callers can hide the cursor. + * + * Mirrors getCroppedCursorPosition / projectNativeCursorToLocal in the native + * cursor path (src/lib/cursor/nativeCursor.ts). + */ +export function mapCursorToCroppedViewport( + normX: number, + normY: number, + viewport: CursorViewportRect, + cropRegion: CropRegion, +): { px: number; py: number } | null { + if (cropRegion.width <= 0 || cropRegion.height <= 0) { + return null; + } + + const croppedX = (normX - cropRegion.x) / cropRegion.width; + const croppedY = (normY - cropRegion.y) / cropRegion.height; + + if (croppedX < 0 || croppedX > 1 || croppedY < 0 || croppedY > 1) { + return null; + } + + return { + px: viewport.x + croppedX * viewport.width, + py: viewport.y + croppedY * viewport.height, + }; +} + function getCursorVisualState(samples: CursorTelemetryPoint[], timeMs: number) { const latestClick = findLatestInteractionSample(samples, timeMs); const interactionType = latestClick?.interactionType; @@ -583,6 +617,7 @@ export class PixiCursorOverlay { viewport: CursorViewportRect, visible: boolean, freeze = false, + cropRegion?: CropRegion, ): void { if (!visible || samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) { this.container.visible = false; @@ -592,6 +627,8 @@ export class PixiCursorOverlay { return; } + const crop = cropRegion ?? DEFAULT_CROP_REGION; + const target = interpolateCursorPosition(samples, timeMs); if (!target) { this.container.visible = false; @@ -611,10 +648,21 @@ export class PixiCursorOverlay { } else { this.state.update(target.cx, target.cy, timeMs); } + // Telemetry positions are normalized to the full (uncropped) video. Remap them + // into the cropped viewport so the cursor stays aligned after a crop, and hide + // the cursor when it moves outside the visible crop (matching the native path). + const mapped = mapCursorToCroppedViewport(this.state.x, this.state.y, viewport, crop); + if (!mapped) { + this.container.visible = false; + this.lastRenderedPoint = null; + this.lastRenderedTimeMs = null; + this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; + return; + } + this.container.visible = true; - const px = viewport.x + this.state.x * viewport.width; - const py = viewport.y + this.state.y * viewport.height; + const { px, py } = mapped; const h = this.config.dotRadius * getCursorViewportScale(viewport); const { cursorType, clickBounceProgress, clickProgress } = getCursorVisualState( samples,