diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index 52f9b20ac..c025e13bf 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,29 @@ 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, + adaptor.append(frozen, withPresentationTime: presentationTime) else { return false } + // finishCapture repeats lastCroppedPixelBuffer as the tail frame. + lastCroppedPixelBuffer = frozen + return true + } + } + var destination: CVPixelBuffer? guard CVPixelBufferPoolCreatePixelBuffer(nil, pool, &destination) == kCVReturnSuccess, let destination else { return false } @@ -735,7 +763,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 +917,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 +1180,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 +1207,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 +1251,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..b1a708785 100644 --- a/electron/native/ScreenCaptureKitRecorder.test.ts +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -98,6 +98,27 @@ 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"); + expect(recorderSource).toContain("lastCroppedPixelBuffer = frozen"); + }); }); describe("ScreenCaptureKitRecorder first frame timing", () => {