diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts index ea037b3fa..c7a057fea 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts @@ -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; diff --git a/src/lib/cursor/nativeCursor.test.ts b/src/lib/cursor/nativeCursor.test.ts index eb1ef5305..3bc49a0a2 100644 --- a/src/lib/cursor/nativeCursor.test.ts +++ b/src/lib/cursor/nativeCursor.test.ts @@ -4,6 +4,7 @@ import { getNativeCursorClickBounceProgress, getNativeCursorClickBounceScale, hasNativeCursorRecordingData, + projectNativeCursorToLocal, resolveInterpolatedNativeCursorFrame, resolveNativeCursorRenderAsset, } from "./nativeCursor"; @@ -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(); + }); +}); diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index d8f3c4312..36e86a065 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -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 }; maskBorderRadius: number; webcamRect: StyledRenderRect | null; } @@ -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) { @@ -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 @@ -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, };