From b72d223950c95ab6af87ea0af78cccae05b9b703 Mon Sep 17 00:00:00 2001 From: giulio333 Date: Tue, 23 Jun 2026 21:21:39 +0200 Subject: [PATCH 1/2] fix(macos): correct cursor offset in single-window capture When recording a single window on macOS, the cursor in the exported video was offset by a fixed translation: clicks landed in the wrong place. Full-screen capture was correct. Root cause: the cursor sampler normalized the global cursor position against the selected display's bounds, never against the captured window's region. `getSelectedSourceBounds()` resolved a window source to its display (the window origin was never subtracted), so the normalized coordinates carried a constant offset equal to the window origin. Fix: the ScreenCaptureKit helper now reports the captured region's global frame (the window frame for window captures, the display frame for display captures) via a `captureBounds` field on its `ready` and `recording-started` events. The main process stores it and `getSelectedSourceBounds()` returns the window frame for window sources, so the cursor is normalized into the captured window's coordinate space. Display capture is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013iccbhodxNjMBraYPXpX6q --- electron/ipc/handlers.ts | 55 ++++++++++++++++++- .../main.swift | 27 ++++++++- src/lib/nativeMacRecording.ts | 2 + 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index d02c6cdec..ed07236e0 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -5,7 +5,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import type { DesktopCapturerSource } from "electron"; +import type { DesktopCapturerSource, Rectangle } from "electron"; import { app, BrowserWindow, @@ -423,6 +423,33 @@ let nativeMacCursorRecordingStartMs = 0; let nativeMacPauseStartedAtMs: number | null = null; let nativeMacPauseRanges: Array<{ startMs: number; endMs: number }> = []; let nativeMacIsPaused = false; +// Global frame (DIP, top-left origin) of the region captured by the SCK helper. For +// window captures this is the window's bounds, which differ from the display bounds; +// the cursor sampler must normalize against it to avoid a fixed offset in the export. +let activeMacCaptureBounds: Rectangle | null = null; + +function parseCaptureBounds(value: unknown): Rectangle | null { + if (!value || typeof value !== "object") { + return null; + } + const candidate = value as Partial; + if ( + typeof candidate.x === "number" && + typeof candidate.y === "number" && + typeof candidate.width === "number" && + typeof candidate.height === "number" && + candidate.width > 0 && + candidate.height > 0 + ) { + return { + x: candidate.x, + y: candidate.y, + width: candidate.width, + height: candidate.height, + }; + } + return null; +} function normalizeCursorSample(sample: unknown): CursorRecordingSample | null { if (!sample || typeof sample !== "object") { @@ -572,6 +599,14 @@ function resolveAssetBasePath() { } function getSelectedSourceBounds() { + // Single-window capture records only the window's region, not the whole display. + // Normalizing the cursor against display bounds leaves a fixed offset in the export, + // so prefer the helper-reported window frame when capturing a window. + const isWindowSource = selectedSource?.id?.startsWith("window:") === true; + if (isWindowSource && activeMacCaptureBounds) { + return activeMacCaptureBounds; + } + const cursor = screen.getCursorScreenPoint(); const sourceDisplayId = Number(selectedSource?.display_id); const sourceDisplay = Number.isFinite(sourceDisplayId) @@ -1038,11 +1073,21 @@ function tryParseNativeHelperEvent(line: string) { } } +function dispatchNativeMacHelperEvent(event: Record) { + // The helper reports the captured region's global frame; window captures use it to + // normalize cursor positions instead of the full display bounds. + const bounds = parseCaptureBounds(event.captureBounds); + if (bounds) { + activeMacCaptureBounds = bounds; + } + nativeMacCaptureEvents.emit("helper-event", event); +} + function inspectNativeMacCaptureOutput() { for (const line of nativeMacCaptureOutput.split(/\r?\n/)) { const event = tryParseNativeHelperEvent(line.trim()); if (event) { - nativeMacCaptureEvents.emit("helper-event", event); + dispatchNativeMacHelperEvent(event); } } } @@ -1058,7 +1103,7 @@ function attachNativeMacCaptureOutputDrain(proc: ChildProcessWithoutNullStreams) for (const line of lines) { const event = tryParseNativeHelperEvent(line.trim()); if (event) { - nativeMacCaptureEvents.emit("helper-event", event); + dispatchNativeMacHelperEvent(event); } } }; @@ -1816,6 +1861,9 @@ export function registerIpcHandlers( nativeMacPauseStartedAtMs = null; nativeMacPauseRanges = []; nativeMacIsPaused = false; + // Clear any stale frame from a previous recording; the helper reports the exact + // captured window frame in its first event, which the cursor sampler then uses. + activeMacCaptureBounds = null; const cursorStartTimeMs = Date.now(); if (cursorCaptureMode === "editable-overlay") { @@ -2131,6 +2179,7 @@ export function registerIpcHandlers( nativeMacPauseStartedAtMs = null; nativeMacPauseRanges = []; nativeMacIsPaused = false; + activeMacCaptureBounds = null; const source = selectedSource || { name: "Screen" }; if (onRecordingStateChange) { onRecordingStateChange(false, source.name); diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift index 14860b03f..31933ba27 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift @@ -124,6 +124,9 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { let filter: SCContentFilter let width: Int let height: Int + // Global frame (points, top-left origin) of the captured region. Used by the + // renderer to normalize cursor positions into the captured window's space. + let captureFrame: CGRect } private let request: RecordingRequest @@ -143,6 +146,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { private var nativeMicrophoneEnabled = false private var outputWidth = 1920 private var outputHeight = 1080 + private var captureFrame = CGRect.zero private let microphoneOutputTypeRawValue = 2 private let hostClock = CMClockGetHostTimeClock() @@ -160,6 +164,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { let target = try makeCaptureTarget(from: content) outputWidth = target.width outputHeight = target.height + captureFrame = target.captureFrame let configuration = makeStreamConfiguration() let stream = SCStream(filter: target.filter, configuration: configuration, delegate: self) @@ -178,7 +183,11 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { try setupWriter() self.stream = stream - emit(["event": "ready", "schemaVersion": 1]) + emit([ + "event": "ready", + "schemaVersion": 1, + "captureBounds": captureBoundsPayload(), + ]) try await stream.startCapture() } @@ -305,6 +314,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { "timestampMs": Int(Date().timeIntervalSince1970 * 1000), "width": outputWidth, "height": outputHeight, + "captureBounds": captureBoundsPayload(), ]) } } @@ -337,6 +347,15 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } } + private func captureBoundsPayload() -> [String: Double] { + return [ + "x": captureFrame.origin.x, + "y": captureFrame.origin.y, + "width": captureFrame.size.width, + "height": captureFrame.size.height, + ] + } + private func makeCaptureTarget(from content: SCShareableContent) throws -> CaptureTarget { switch request.source.type { case "display": @@ -351,7 +370,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { return CaptureTarget( filter: SCContentFilter(display: display, excludingWindows: []), width: clampCaptureDimension(width, fallback: request.video.width), - height: clampCaptureDimension(height, fallback: request.video.height) + height: clampCaptureDimension(height, fallback: request.video.height), + captureFrame: display.frame ) case "window": guard let windowId = request.source.windowId else { @@ -369,7 +389,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { return CaptureTarget( filter: SCContentFilter(desktopIndependentWindow: window), width: clampCaptureDimension(width, fallback: request.video.width), - height: clampCaptureDimension(height, fallback: request.video.height) + height: clampCaptureDimension(height, fallback: request.video.height), + captureFrame: window.frame ) default: throw HelperError.invalidSourceType(request.source.type) diff --git a/src/lib/nativeMacRecording.ts b/src/lib/nativeMacRecording.ts index 4202132f9..84b2ce738 100644 --- a/src/lib/nativeMacRecording.ts +++ b/src/lib/nativeMacRecording.ts @@ -51,11 +51,13 @@ export type NativeMacRecordingRequest = { export type NativeMacHelperReadyEvent = { event: "ready"; schemaVersion: 1; + captureBounds?: Rectangle; }; export type NativeMacHelperRecordingStartedEvent = { event: "recording-started"; timestampMs: number; + captureBounds?: Rectangle; }; export type NativeMacHelperRecordingStoppedEvent = { From 02665389fcf3676d537b4f8be7b86940823d4ba4 Mon Sep 17 00:00:00 2001 From: giulio333 Date: Sat, 27 Jun 2026 09:14:35 +0200 Subject: [PATCH 2/2] =?UTF-8?q?refactor(macos):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20drop=20captureBounds=20validator=20and=20ready-even?= =?UTF-8?q?t=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inline the captureBounds check in dispatchNativeMacHelperEvent and remove parseCaptureBounds; we control both ends of this IPC - drop captureBounds from the `ready` event (Swift + TS): cursor polling only starts after `recording-started` resolves, so it was read by nothing - trim the duplicated/self-explanatory comments around activeMacCaptureBounds Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01S4UdyNkRoEn2B8JaG5YdLK --- electron/ipc/handlers.ts | 35 ++----------------- .../main.swift | 1 - src/lib/nativeMacRecording.ts | 1 - 3 files changed, 3 insertions(+), 34 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index ed07236e0..a3160085e 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -423,34 +423,9 @@ let nativeMacCursorRecordingStartMs = 0; let nativeMacPauseStartedAtMs: number | null = null; let nativeMacPauseRanges: Array<{ startMs: number; endMs: number }> = []; let nativeMacIsPaused = false; -// Global frame (DIP, top-left origin) of the region captured by the SCK helper. For -// window captures this is the window's bounds, which differ from the display bounds; -// the cursor sampler must normalize against it to avoid a fixed offset in the export. +// Global frame of the region captured by the SCK helper (see getSelectedSourceBounds). let activeMacCaptureBounds: Rectangle | null = null; -function parseCaptureBounds(value: unknown): Rectangle | null { - if (!value || typeof value !== "object") { - return null; - } - const candidate = value as Partial; - if ( - typeof candidate.x === "number" && - typeof candidate.y === "number" && - typeof candidate.width === "number" && - typeof candidate.height === "number" && - candidate.width > 0 && - candidate.height > 0 - ) { - return { - x: candidate.x, - y: candidate.y, - width: candidate.width, - height: candidate.height, - }; - } - return null; -} - function normalizeCursorSample(sample: unknown): CursorRecordingSample | null { if (!sample || typeof sample !== "object") { return null; @@ -1074,10 +1049,8 @@ function tryParseNativeHelperEvent(line: string) { } function dispatchNativeMacHelperEvent(event: Record) { - // The helper reports the captured region's global frame; window captures use it to - // normalize cursor positions instead of the full display bounds. - const bounds = parseCaptureBounds(event.captureBounds); - if (bounds) { + const bounds = event.captureBounds as Rectangle | undefined; + if (bounds && bounds.width > 0 && bounds.height > 0) { activeMacCaptureBounds = bounds; } nativeMacCaptureEvents.emit("helper-event", event); @@ -1861,8 +1834,6 @@ export function registerIpcHandlers( nativeMacPauseStartedAtMs = null; nativeMacPauseRanges = []; nativeMacIsPaused = false; - // Clear any stale frame from a previous recording; the helper reports the exact - // captured window frame in its first event, which the cursor sampler then uses. activeMacCaptureBounds = null; const cursorStartTimeMs = Date.now(); diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift index 31933ba27..9525b717b 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift @@ -186,7 +186,6 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { emit([ "event": "ready", "schemaVersion": 1, - "captureBounds": captureBoundsPayload(), ]) try await stream.startCapture() } diff --git a/src/lib/nativeMacRecording.ts b/src/lib/nativeMacRecording.ts index 84b2ce738..e5137c3de 100644 --- a/src/lib/nativeMacRecording.ts +++ b/src/lib/nativeMacRecording.ts @@ -51,7 +51,6 @@ export type NativeMacRecordingRequest = { export type NativeMacHelperReadyEvent = { event: "ready"; schemaVersion: 1; - captureBounds?: Rectangle; }; export type NativeMacHelperRecordingStartedEvent = {