Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/components/video-editor/VideoPlayback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
type BlurData,
type CursorTelemetryPoint,
computeRotation3DContainScale,
DEFAULT_CROP_REGION,
DEFAULT_ROTATION_3D,
getZoomScale,
isRotation3DIdentity,
Expand Down Expand Up @@ -1562,6 +1563,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
baseMaskRef.current,
showCursorRef.current && !hasNativeCursorRecordingRef.current,
!isPlayingRef.current || isSeekingRef.current,
cropRegionRef.current,
);
}

Expand Down Expand Up @@ -1600,7 +1602,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
: 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,
Expand Down Expand Up @@ -1637,7 +1639,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
54 changes: 51 additions & 3 deletions src/components/video-editor/videoPlayback/cursorRenderer.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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,
Expand Down
Loading