From 99d6107b4bf25bfc79658dfac02a0690716aace7 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 6 Jul 2026 20:47:51 +0200 Subject: [PATCH 1/4] fix(export): project native cursor onto cropped rect, not mask rect After cropping, the exported cursor drifted because the recorded sample was projected onto the screen mask rect instead of the rectangle the cropped video is actually painted on. In cover layouts where the cropped video letterboxes inside screenRect (coverOffset != 0), the cursor offset was proportional to the letterbox margin. Track the painted croppedRect in FrameRenderer layoutCache and pass it to projectNativeCursorToLocal so the cursor overlays the cropped video pixels exactly. Adds regression tests for projectNativeCursorToLocal. Fixes #64 --- src/lib/cursor/nativeCursor.test.ts | 66 +++++++++++++++++++++++++++++ src/lib/exporter/frameRenderer.ts | 9 +++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/lib/cursor/nativeCursor.test.ts b/src/lib/cursor/nativeCursor.test.ts index eb1ef5305..472ee6f39 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-letterboxed) 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..30fd38575 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) { @@ -761,6 +762,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, }; From fe4562e25b6fe80c6881e5e372d6efc2dd9bb5a9 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 7 Jul 2026 13:29:37 +0200 Subject: [PATCH 2/4] fix(export): address review feedback - Rename cover-letterboxed test to cover-overflowing (fixture is cover, not letterbox) - Update sizeNorm comment to acknowledge the export/preview asymmetry introduced by the croppedRect field: export now uses croppedRect.width, preview still uses screenRect.width. They agree in cover mode but differ in fit-to-height letterbox layouts. --- src/lib/cursor/nativeCursor.test.ts | 2 +- src/lib/exporter/frameRenderer.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/cursor/nativeCursor.test.ts b/src/lib/cursor/nativeCursor.test.ts index 472ee6f39..3bc49a0a2 100644 --- a/src/lib/cursor/nativeCursor.test.ts +++ b/src/lib/cursor/nativeCursor.test.ts @@ -244,7 +244,7 @@ describe("projectNativeCursorToLocal", () => { expect(point?.y).toBeCloseTo(0 + (0.5 / 1.0) * 1080); }); - it("projects onto the cropped (cover-letterboxed) painted rect, not the mask rect", () => { + 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 }; diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 30fd38575..36e86a065 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -602,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 From e6a57cd677bc86c7cfb104e6158e316f1671a7f5 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 7 Jul 2026 18:00:46 +0200 Subject: [PATCH 3/4] fix(cursor): normalize sampler position against physical bounds The Windows cursor-sampler (Win32 GetCursorInfo) reports raw x/y in physical screen pixels. For display captures, normalizeSample was dividing by bounds from Electron's \screen\ API, which are in logical pixels (DIP). On a 100% DPI display these coincide and the bug is invisible, but on any high-DPI display (125%, 150%, 200%) the normalized cursor position is wrong by the scale factor. The error then compounds through projectNativeCursorToLocal in the export, producing a visible cursor offset proportional to the DPI ratio. Fix by converting the logical bounds to physical via the display's scaleFactor before normalizing. payload.bounds (from the sampler's GetWindowRect, used for window captures) is already physical and is left as-is. Pre-existing since 1.5.0; never caught because CI is Linux-only and manual Windows smoke tests ran on 100% DPI displays. --- .../recording/windowsNativeRecordingSession.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts index ea037b3fa..4396d1a2c 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts @@ -226,10 +226,20 @@ 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 logical pixels (DIP) — convert to + // physical via the display's scaleFactor so the normalized position matches + // the WGC video, which is captured in physical pixels. + const isPhysicalBounds = payload.bounds != null; + const scaleFactor = isPhysicalBounds ? 1 : (screen.getDisplayMatching(bounds).scaleFactor ?? 1); + const physicalX = bounds.x * scaleFactor; + const physicalY = bounds.y * scaleFactor; + const width = Math.max(1, bounds.width * scaleFactor); + const height = Math.max(1, bounds.height * scaleFactor); + 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; From a56a59997cc84276cc54768e32bedd4ed09258d0 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 7 Jul 2026 18:11:42 +0200 Subject: [PATCH 4/4] fix(cursor): use screen.dipToScreenRect for multi-monitor origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix multiplied bounds.x/y/width/height by a single scaleFactor to convert from DIPs to physical pixels. That works for the primary display (origin at 0,0 in the virtual screen) but misplaces the origin on non-primary or mixed-DPI displays: a secondary 200% DPI monitor to the right of a 100% primary has DIP bounds {x:1920, y:0, w:1920, h:1080} but physical bounds {x:1920, y:0, w:3840, h:2160} — multiplying the origin by 2 would push it to 3840. Use Electron's \screen.dipToScreenRect(null, bounds)\ instead, which picks the correct display from the rect's center and handles the virtual-screen origin correctly across multi-monitor and mixed-DPI setups. payload.bounds (from the sampler's GetWindowRect) is already physical and is left as-is. --- .../windowsNativeRecordingSession.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts index 4396d1a2c..c7a057fea 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts @@ -229,15 +229,16 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { // 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 logical pixels (DIP) — convert to - // physical via the display's scaleFactor so the normalized position matches - // the WGC video, which is captured in physical pixels. - const isPhysicalBounds = payload.bounds != null; - const scaleFactor = isPhysicalBounds ? 1 : (screen.getDisplayMatching(bounds).scaleFactor ?? 1); - const physicalX = bounds.x * scaleFactor; - const physicalY = bounds.y * scaleFactor; - const width = Math.max(1, bounds.width * scaleFactor); - const height = Math.max(1, bounds.height * scaleFactor); + // 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 =