Fix cursor positioning with cropped video

This commit is contained in:
webadderall
2026-03-20 00:17:53 +11:00
parent 37c8a6ff80
commit c64c088748
6 changed files with 148 additions and 13 deletions
+12 -1
View File
@@ -225,7 +225,18 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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<Graphics | null>(null);
const isPlayingRef = useRef(isPlaying);
@@ -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;
@@ -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,
});
});
});
@@ -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,
};
}
@@ -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 },
};
}
+1
View File
@@ -772,6 +772,7 @@ export class FrameRenderer {
y: centerOffsetY,
width: croppedDisplayWidth,
height: croppedDisplayHeight,
sourceCrop: cropRegion,
},
};
}