diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index e32ef3635..85d154881 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, @@ -424,6 +424,8 @@ let nativeMacCursorRecordingStartMs = 0; let nativeMacPauseStartedAtMs: number | null = null; let nativeMacPauseRanges: Array<{ startMs: number; endMs: number }> = []; let nativeMacIsPaused = false; +// Global frame of the region captured by the SCK helper (see getSelectedSourceBounds). +let activeMacCaptureBounds: Rectangle | null = null; function normalizeCursorSample(sample: unknown): CursorRecordingSample | null { if (!sample || typeof sample !== "object") { @@ -573,6 +575,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) @@ -1039,11 +1049,19 @@ function tryParseNativeHelperEvent(line: string) { } } +function dispatchNativeMacHelperEvent(event: Record) { + const bounds = event.captureBounds as Rectangle | undefined; + if (bounds && bounds.width > 0 && bounds.height > 0) { + 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); } } } @@ -1059,7 +1077,7 @@ function attachNativeMacCaptureOutputDrain(proc: ChildProcessWithoutNullStreams) for (const line of lines) { const event = tryParseNativeHelperEvent(line.trim()); if (event) { - nativeMacCaptureEvents.emit("helper-event", event); + dispatchNativeMacHelperEvent(event); } } }; @@ -1817,6 +1835,7 @@ export function registerIpcHandlers( nativeMacPauseStartedAtMs = null; nativeMacPauseRanges = []; nativeMacIsPaused = false; + activeMacCaptureBounds = null; const cursorStartTimeMs = Date.now(); if (cursorCaptureMode === "editable-overlay") { @@ -2132,6 +2151,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..9525b717b 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,10 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { try setupWriter() self.stream = stream - emit(["event": "ready", "schemaVersion": 1]) + emit([ + "event": "ready", + "schemaVersion": 1, + ]) try await stream.startCapture() } @@ -305,6 +313,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { "timestampMs": Int(Date().timeIntervalSince1970 * 1000), "width": outputWidth, "height": outputHeight, + "captureBounds": captureBoundsPayload(), ]) } } @@ -337,6 +346,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 +369,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 +388,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..e5137c3de 100644 --- a/src/lib/nativeMacRecording.ts +++ b/src/lib/nativeMacRecording.ts @@ -56,6 +56,7 @@ export type NativeMacHelperReadyEvent = { export type NativeMacHelperRecordingStartedEvent = { event: "recording-started"; timestampMs: number; + captureBounds?: Rectangle; }; export type NativeMacHelperRecordingStoppedEvent = {