From c1f1735bfd1e107e7bfd6734bc640f5270fde825 Mon Sep 17 00:00:00 2001 From: Mohit Samant Date: Mon, 26 Oct 2026 03:22:51 +0530 Subject: [PATCH 1/3] fix(macos): keep window recordings going when the window leaves the screen --- .../native/ScreenCaptureKitRecorder.swift | 53 ++++++++++++++++++- .../native/ScreenCaptureKitRecorder.test.ts | 20 +++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index 52f9b20ac..f9469b54e 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -248,6 +248,11 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { private var windowCropDisplayId: CGDirectDisplayID? private var excludedProcessIds = Set() private var lastCroppedPixelBuffer: CVPixelBuffer? + private var recentCroppedPixelBuffer: CVPixelBuffer? + private var recentCroppedFrameTime: CMTime = .invalid + private var settledCroppedPixelBuffer: CVPixelBuffer? + private var isTrackedWindowHidden = false + private var trackedWindowShownTime: CMTime = .invalid private let imageContext = CIContext(options: [.cacheIntermediates: false]) private var systemAudioWriter: AVAssetWriter? private var systemAudioInput: AVAssetWriterInput? @@ -703,6 +708,26 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { let pool = adaptor.pixelBufferPool, let source = CMSampleBufferGetImageBuffer(sampleBuffer) else { return false } + // While the window is minimized or on another Space, the display shows other + // content, so hold an earlier frame of the window instead of cropping it. This + // is checked per frame because the 0.5s window poll would let that content in. + // The hold lasts a second past its return to cover the restore animation. + if let trackedWindowId { + if !Self.isWindowOnScreen(trackedWindowId) { + isTrackedWindowHidden = true + trackedWindowShownTime = .invalid + } else if isTrackedWindowHidden { + if !trackedWindowShownTime.isValid { trackedWindowShownTime = presentationTime } + if CMTimeGetSeconds(CMTimeSubtract(presentationTime, trackedWindowShownTime)) >= 1 { + isTrackedWindowHidden = false + } + } + if isTrackedWindowHidden { + guard let frozen = settledCroppedPixelBuffer ?? lastCroppedPixelBuffer else { return false } + return adaptor.append(frozen, withPresentationTime: presentationTime) + } + } + var destination: CVPixelBuffer? guard CVPixelBufferPoolCreatePixelBuffer(nil, pool, &destination) == kCVReturnSuccess, let destination else { return false } @@ -735,7 +760,16 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { ) let appended = adaptor.append(destination, withPresentationTime: presentationTime) - if appended { lastCroppedPixelBuffer = destination } + if appended { + lastCroppedPixelBuffer = destination + // macOS reports the window off screen only after the minimize or Space + // animation ends, so keep a frame from at least a second before that. + if !recentCroppedFrameTime.isValid || CMTimeGetSeconds(CMTimeSubtract(presentationTime, recentCroppedFrameTime)) >= 1 { + settledCroppedPixelBuffer = recentCroppedPixelBuffer + recentCroppedPixelBuffer = destination + recentCroppedFrameTime = presentationTime + } + } return appended } @@ -880,6 +914,11 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { windowCropDisplayId = nil excludedProcessIds.removeAll() lastCroppedPixelBuffer = nil + recentCroppedPixelBuffer = nil + recentCroppedFrameTime = .invalid + settledCroppedPixelBuffer = nil + isTrackedWindowHidden = false + trackedWindowShownTime = .invalid systemAudioWriter = nil systemAudioInput = nil microphoneOnlyWriter = nil @@ -1138,11 +1177,13 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { let availableContent: SCShareableContent do { - availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) } catch { continue } + // Only a closed window ends the recording. A minimized window or one on + // another Space is still listed, just not on screen. guard let window = availableContent.windows.first(where: { $0.windowID == trackedWindowId }) else { print("WINDOW_UNAVAILABLE") fflush(stdout) @@ -1163,6 +1204,9 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { return } + // An off-screen window keeps its last frame, so leave the crop as it is. + guard window.isOnScreen else { continue } + guard let display = Self.captureDisplay(for: window.frame, from: availableContent.displays) else { continue } @@ -1204,6 +1248,11 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } } + private static func isWindowOnScreen(_ windowId: UInt32) -> Bool { + let info = CGWindowListCopyWindowInfo([.optionIncludingWindow], windowId) as? [[String: Any]] + return info?.first?[kCGWindowIsOnscreen as String] as? Bool ?? false + } + private static func captureDisplay(for frame: CGRect, from displays: [SCDisplay]) -> SCDisplay? { let midpoint = CGPoint(x: frame.midX, y: frame.midY) return displays.first(where: { $0.frame.contains(midpoint) }) diff --git a/electron/native/ScreenCaptureKitRecorder.test.ts b/electron/native/ScreenCaptureKitRecorder.test.ts index edaba7ee6..c76ce2c3c 100644 --- a/electron/native/ScreenCaptureKitRecorder.test.ts +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -98,6 +98,26 @@ describe("ScreenCaptureKitRecorder window capture", () => { expect(recorderSource).toContain("try await activeStream.updateContentFilter(filter)"); expect(recorderSource).toContain("self.windowCropRect = cropRect"); }); + + it("stops only when the window closes, not when it is minimized or on another Space", () => { + const validation = recorderSource.slice( + recorderSource.indexOf("private func startWindowValidationIfNeeded"), + ); + expect(validation).toContain( + "SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)", + ); + expect(validation).not.toContain("onScreenWindowsOnly: true"); + expect(validation).toContain("guard window.isOnScreen else { continue }"); + }); + + it("holds an earlier window frame while the window is off screen", () => { + expect(recorderSource).toContain("if !Self.isWindowOnScreen(trackedWindowId) {"); + expect(recorderSource).toContain("[.optionIncludingWindow], windowId"); + expect(recorderSource).toContain( + "guard let frozen = settledCroppedPixelBuffer ?? lastCroppedPixelBuffer", + ); + expect(recorderSource).toContain("settledCroppedPixelBuffer = recentCroppedPixelBuffer"); + }); }); describe("ScreenCaptureKitRecorder first frame timing", () => { From 491dd6f47ac20277b80d1bcca3529d71be52398b Mon Sep 17 00:00:00 2001 From: Mohit Samant Date: Mon, 26 Oct 2026 03:32:41 +0530 Subject: [PATCH 2/3] Use the held frame as the tail frame when stopping off screen --- electron/native/ScreenCaptureKitRecorder.swift | 7 +++++-- electron/native/ScreenCaptureKitRecorder.test.ts | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index f9469b54e..c025e13bf 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -723,8 +723,11 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } } if isTrackedWindowHidden { - guard let frozen = settledCroppedPixelBuffer ?? lastCroppedPixelBuffer else { return false } - return adaptor.append(frozen, withPresentationTime: presentationTime) + guard let frozen = settledCroppedPixelBuffer ?? lastCroppedPixelBuffer, + adaptor.append(frozen, withPresentationTime: presentationTime) else { return false } + // finishCapture repeats lastCroppedPixelBuffer as the tail frame. + lastCroppedPixelBuffer = frozen + return true } } diff --git a/electron/native/ScreenCaptureKitRecorder.test.ts b/electron/native/ScreenCaptureKitRecorder.test.ts index c76ce2c3c..b1a708785 100644 --- a/electron/native/ScreenCaptureKitRecorder.test.ts +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -117,6 +117,7 @@ describe("ScreenCaptureKitRecorder window capture", () => { "guard let frozen = settledCroppedPixelBuffer ?? lastCroppedPixelBuffer", ); expect(recorderSource).toContain("settledCroppedPixelBuffer = recentCroppedPixelBuffer"); + expect(recorderSource).toContain("lastCroppedPixelBuffer = frozen"); }); }); From 8cfb4435ac321001f9dcc250b25d85af699ec823 Mon Sep 17 00:00:00 2001 From: Mohit Samant Date: Tue, 27 Oct 2026 13:54:52 +0530 Subject: [PATCH 3/3] Drop the newest kept frame when the window goes off screen --- electron/native/ScreenCaptureKitRecorder.swift | 3 +++ electron/native/ScreenCaptureKitRecorder.test.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index c025e13bf..66df95c73 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -714,6 +714,9 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { // The hold lasts a second past its return to cover the restore animation. if let trackedWindowId { if !Self.isWindowOnScreen(trackedWindowId) { + // The newest kept frame may be from the hide animation, so don't let it + // become the held frame if the window hides again soon after returning. + if !isTrackedWindowHidden { recentCroppedPixelBuffer = settledCroppedPixelBuffer } isTrackedWindowHidden = true trackedWindowShownTime = .invalid } else if isTrackedWindowHidden { diff --git a/electron/native/ScreenCaptureKitRecorder.test.ts b/electron/native/ScreenCaptureKitRecorder.test.ts index b1a708785..4898136bd 100644 --- a/electron/native/ScreenCaptureKitRecorder.test.ts +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -118,6 +118,9 @@ describe("ScreenCaptureKitRecorder window capture", () => { ); expect(recorderSource).toContain("settledCroppedPixelBuffer = recentCroppedPixelBuffer"); expect(recorderSource).toContain("lastCroppedPixelBuffer = frozen"); + expect(recorderSource).toContain( + "if !isTrackedWindowHidden { recentCroppedPixelBuffer = settledCroppedPixelBuffer }", + ); }); });