mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 15:25:44 +00:00
Fix cursor positioning with cropped video
This commit is contained in:
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -772,6 +772,7 @@ export class FrameRenderer {
|
||||
y: centerOffsetY,
|
||||
width: croppedDisplayWidth,
|
||||
height: croppedDisplayHeight,
|
||||
sourceCrop: cropRegion,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user