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
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,21 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
): NormalizedSample {
const bounds =
payload.bounds ?? this.options.getDisplayBounds() ?? screen.getPrimaryDisplay().bounds;
const width = Math.max(1, bounds.width);
const height = Math.max(1, bounds.height);
const normalizedX = (payload.x - bounds.x) / width;
const normalizedY = (payload.y - bounds.y) / height;
// The cursor-sampler reports raw x/y in physical screen pixels (Win32
// GetCursorInfo). `payload.bounds` from the sampler's GetWindowRect is also
// physical, so use it as-is. Bounds from Electron's `screen` API (or the
// fallback for display captures) are in DIPs — convert to physical screen
// coordinates via `dipToScreenRect`, which correctly handles the virtual-screen
// origin across multi-monitor and mixed-DPI setups (a naive
// `bounds.x * scaleFactor` would misplace the origin on non-primary
// displays).
const physicalBounds = payload.bounds != null ? bounds : screen.dipToScreenRect(null, bounds);
const physicalX = physicalBounds.x;
const physicalY = physicalBounds.y;
const width = Math.max(1, physicalBounds.width);
const height = Math.max(1, physicalBounds.height);
const normalizedX = (payload.x - physicalX) / width;
const normalizedY = (payload.y - physicalY) / height;
const withinBounds =
normalizedX >= 0 && normalizedX <= 1 && normalizedY >= 0 && normalizedY <= 1;
const leftButtonDown = payload.leftButtonDown === true;
Expand Down
66 changes: 66 additions & 0 deletions src/lib/cursor/nativeCursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getNativeCursorClickBounceProgress,
getNativeCursorClickBounceScale,
hasNativeCursorRecordingData,
projectNativeCursorToLocal,
resolveInterpolatedNativeCursorFrame,
resolveNativeCursorRenderAsset,
} from "./nativeCursor";
Expand Down Expand Up @@ -217,3 +218,68 @@ describe("custom cursor themes", () => {
expect(rendered.id).toBe("pretty:text");
});
});

describe("projectNativeCursorToLocal", () => {
const identityCrop = { x: 0, y: 0, width: 1, height: 1 };

it("maps a sample onto the supplied painted rectangle 1:1 with no crop", () => {
const point = projectNativeCursorToLocal({
cropRegion: identityCrop,
maskRect: { x: 100, y: 200, width: 1280, height: 720 },
sample: { timeMs: 0, cx: 0.25, cy: 0.5, visible: true },
});

expect(point?.x).toBeCloseTo(100 + 0.25 * 1280);
expect(point?.y).toBeCloseTo(200 + 0.5 * 720);
});

it("maps a sample into the cropped region of the painted rectangle", () => {
const point = projectNativeCursorToLocal({
cropRegion: { x: 0.25, y: 0.0, width: 0.5, height: 1.0 },
maskRect: { x: 0, y: 0, width: 1920, height: 1080 },
sample: { timeMs: 0, cx: 0.5, cy: 0.5, visible: true },
});

expect(point?.x).toBeCloseTo(0 + ((0.5 - 0.25) / 0.5) * 1920);
expect(point?.y).toBeCloseTo(0 + (0.5 / 1.0) * 1080);
});

it("projects onto the cropped (cover-overflowing) painted rect, not the mask rect", () => {
const screenRect = { x: 0, y: 0, width: 1920, height: 1080 };
const croppedRect = { x: 0, y: -540, width: 1920, height: 2160 };

const point = projectNativeCursorToLocal({
cropRegion: { x: 0.0, y: 0.0, width: 0.5, height: 1.0 },
maskRect: croppedRect,
sample: { timeMs: 0, cx: 0.25, cy: 0.25, visible: true },
});

const wrong = screenRect.y + 0.25 * screenRect.height;
expect(wrong).toBe(270);

expect(point?.x).toBeCloseTo(croppedRect.x + ((0.25 - 0) / 0.5) * croppedRect.width);
expect(point?.y).toBeCloseTo(croppedRect.y + (0.25 / 1.0) * croppedRect.height);
expect(point?.y).toBe(0);
expect(point?.y).not.toBe(wrong);
});

it("returns null for a sample outside the cropped region", () => {
const point = projectNativeCursorToLocal({
cropRegion: { x: 0.25, y: 0.25, width: 0.5, height: 0.5 },
maskRect: { x: 0, y: 0, width: 1920, height: 1080 },
sample: { timeMs: 0, cx: 0.1, cy: 0.5, visible: true },
});

expect(point).toBeNull();
});

it("returns null for a degenerate (zero-size) crop region", () => {
const point = projectNativeCursorToLocal({
cropRegion: { x: 0, y: 0, width: 0, height: 1 },
maskRect: { x: 0, y: 0, width: 1920, height: 1080 },
sample: { timeMs: 0, cx: 0.5, cy: 0.5, visible: true },
});

expect(point).toBeNull();
});
});
15 changes: 12 additions & 3 deletions src/lib/exporter/frameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ interface LayoutCache {
baseScale: number;
baseOffset: { x: number; y: number };
maskRect: { x: number; y: number; width: number; height: number };
croppedRect: { x: number; y: number; width: number; height: number };
Comment thread
EtienneLescot marked this conversation as resolved.
maskBorderRadius: number;
webcamRect: StyledRenderRect | null;
}
Expand Down Expand Up @@ -573,7 +574,7 @@ export class FrameRenderer {

const projectedPoint = projectNativeCursorToLocal({
cropRegion: this.config.cropRegion,
maskRect: this.layoutCache.maskRect,
maskRect: this.layoutCache.croppedRect,
sample: displaySample,
});
if (!projectedPoint) {
Expand Down Expand Up @@ -601,8 +602,10 @@ export class FrameRenderer {
getNativeCursorClickBounceProgress(this.config.cursorRecordingData, timeMs),
);
const appliedScale = this.animationState.appliedScale;
// Normalize cursor size to the same fraction of video width as the preview;
// both paths use maskRect.width / croppedVideoWidth.
// Normalize cursor size to croppedRect.width (the painted video width).
// The preview path still uses screenRect.width; they agree in cover mode but
// differ in fit-to-height letterbox — known asymmetry pending the preview-path
// follow-up to project the cursor onto the cropped sub-rect as well.
const sizeNorm =
this.layoutCache.videoSize.width > 0
? this.layoutCache.maskRect.width / this.layoutCache.videoSize.width
Expand Down Expand Up @@ -761,6 +764,12 @@ export class FrameRenderer {
y: compositeLayout.screenRect.y + coverOffsetY - cropPixelY,
},
maskRect: compositeLayout.screenRect,
croppedRect: {
x: compositeLayout.screenRect.x + coverOffsetX,
y: compositeLayout.screenRect.y + coverOffsetY,
width: croppedDisplayWidth,
height: croppedDisplayHeight,
},
maskBorderRadius: scaledBorderRadius,
webcamRect: compositeLayout.webcamRect,
};
Expand Down
Loading