From 2aa4727f2513ff328e8d0cc3cba11612196ae818 Mon Sep 17 00:00:00 2001 From: Joshua Sattler <34030048+jsattler@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:29:48 +0200 Subject: [PATCH] fix(capture): fix window crop on stacked displays `SCContentFilter.pointPixelScale` reports the main display's scale for a window on a display arranged above the primary one, because such a display has a negative Y origin in the global CoreGraphics space. On a 2x built-in with a 1x external above it that yields 2.0 instead of 1.0, so the stream was configured at twice the size ScreenCaptureKit renders and the recording gained blank padding to the right of and below the content. Resolve a window's scale from the display it actually occupies instead, and set `scalesToFit` for window captures: independent window capture is the one mode where the configured width and height are only a buffer size, not a resize instruction. Closes #190 --- BetterCapture/Model/DisplayGeometry.swift | 21 +++ BetterCapture/Service/CaptureEngine.swift | 19 ++- .../Service/CaptureSizeCalculator.swift | 52 ++++++ .../ViewModel/RecorderViewModel.swift | 46 ++++- .../CaptureSizeCalculatorTests.swift | 157 ++++++++++++++++++ 5 files changed, 285 insertions(+), 10 deletions(-) create mode 100644 BetterCapture/Model/DisplayGeometry.swift create mode 100644 BetterCapture/Service/CaptureSizeCalculator.swift create mode 100644 BetterCaptureTests/CaptureSizeCalculatorTests.swift diff --git a/BetterCapture/Model/DisplayGeometry.swift b/BetterCapture/Model/DisplayGeometry.swift new file mode 100644 index 0000000..29aee24 --- /dev/null +++ b/BetterCapture/Model/DisplayGeometry.swift @@ -0,0 +1,21 @@ +// +// DisplayGeometry.swift +// BetterCapture +// +// Created by Joshua Sattler on 29.08.26. +// + +import CoreGraphics +import Foundation + +/// A display's position and backing scale, expressed in the global CoreGraphics coordinate +/// space that `SCWindow.frame` and `CGDisplayBounds` use: points, top-left origin, Y increasing +/// downwards. Displays arranged above the primary one therefore have a negative Y origin. +struct DisplayGeometry: Equatable, Sendable { + + /// The display's bounds in global CoreGraphics points. + let frame: CGRect + + /// The number of pixels per point on this display. + let scaleFactor: CGFloat +} diff --git a/BetterCapture/Service/CaptureEngine.swift b/BetterCapture/Service/CaptureEngine.swift index e368380..8182ed4 100644 --- a/BetterCapture/Service/CaptureEngine.swift +++ b/BetterCapture/Service/CaptureEngine.swift @@ -137,7 +137,7 @@ final class CaptureEngine: NSObject { let filteredContent = try await contentFilterService.applySettings(to: filter, settings: settings) logger.info("Content filter applied, creating stream...") - let streamConfig = createStreamConfiguration(from: settings, contentSize: videoSize, sourceRect: sourceRect) + let streamConfig = createStreamConfiguration(from: settings, contentSize: videoSize, sourceRect: sourceRect, isWindowCapture: filteredContent.style == .window) stream = SCStream(filter: filteredContent, configuration: streamConfig, delegate: self) @@ -196,7 +196,13 @@ final class CaptureEngine: NSObject { /// - settings: The settings store containing capture configuration /// - contentSize: The output dimensions for the captured video /// - sourceRect: Optional rectangle for area selection (display points, top-left origin) - private func createStreamConfiguration(from settings: SettingsStore, contentSize: CGSize, sourceRect: CGRect? = nil) -> SCStreamConfiguration { + /// - isWindowCapture: Whether the filter captures a single window + private func createStreamConfiguration( + from settings: SettingsStore, + contentSize: CGSize, + sourceRect: CGRect? = nil, + isWindowCapture: Bool = false + ) -> SCStreamConfiguration { let config: SCStreamConfiguration switch settings.hdrPreset { @@ -232,6 +238,15 @@ final class CaptureEngine: NSObject { config.width = Int(contentSize.width) config.height = Int(contentSize.height) + // Independent window capture is the one mode where ScreenCaptureKit does not resize content + // to the configured dimensions on its own: it renders the window at its natural pixel size + // in the top-left corner and leaves the rest of the buffer blank. Scaling to fit keeps the + // content filling the frame even if the dimensions above are ever wrong again, rather than + // silently baking a crop into the recording. + if isWindowCapture { + config.scalesToFit = true + } + // Set source rect for area selection (only works with display captures) if let sourceRect { config.sourceRect = sourceRect diff --git a/BetterCapture/Service/CaptureSizeCalculator.swift b/BetterCapture/Service/CaptureSizeCalculator.swift new file mode 100644 index 0000000..5bcc17a --- /dev/null +++ b/BetterCapture/Service/CaptureSizeCalculator.swift @@ -0,0 +1,52 @@ +// +// CaptureSizeCalculator.swift +// BetterCapture +// +// Created by Joshua Sattler on 29.08.26. +// + +import CoreGraphics +import Foundation + +/// Derives the pixel dimensions a capture should be configured and encoded at. +enum CaptureSizeCalculator { + + /// Resolves the point-to-pixel scale for a window from the display it occupies. + /// + /// `SCContentFilter.pointPixelScale` reports the *main* display's scale for windows living on + /// a display arranged above the primary one, because such a display has a negative Y origin in + /// the global CoreGraphics space and the framework's lookup falls back to the main display. On + /// a 2x built-in with a 1x external above it that yields 2.0 instead of 1.0, so the stream is + /// configured at twice the size ScreenCaptureKit renders and the output gains blank padding to + /// the right of and below the content. + /// + /// - Parameters: + /// - windowFrame: The window's frame in global CoreGraphics points. + /// - displays: The connected displays, in the same coordinate space. + /// - fallback: The scale to use when the window overlaps no known display. + /// - Returns: The scale of the display covering most of the window. + static func windowScale(windowFrame: CGRect, displays: [DisplayGeometry], fallback: CGFloat) -> CGFloat { + // A window can straddle two displays, so pick the one showing most of it rather than the + // one containing some arbitrary corner. + let overlaps = displays.compactMap { display -> (scale: CGFloat, area: CGFloat)? in + let intersection = display.frame.intersection(windowFrame) + guard !intersection.isNull, !intersection.isEmpty else { return nil } + return (display.scaleFactor, intersection.width * intersection.height) + } + + guard let best = overlaps.max(by: { $0.area < $1.area }) else { return fallback } + return best.scale + } + + /// The dimensions to capture and encode at. + /// + /// - Parameters: + /// - contentRect: The content to capture, in points. + /// - scale: The display's point-to-pixel scale. + /// - useNativeResolution: Whether to capture at the display's native pixel density. + /// - Returns: The video dimensions, in pixels when capturing natively and in points otherwise. + static func videoSize(contentRect: CGRect, scale: CGFloat, useNativeResolution: Bool) -> CGSize { + guard useNativeResolution else { return contentRect.size } + return CGSize(width: contentRect.width * scale, height: contentRect.height * scale) + } +} diff --git a/BetterCapture/ViewModel/RecorderViewModel.swift b/BetterCapture/ViewModel/RecorderViewModel.swift index 7a78ebc..07f0b48 100644 --- a/BetterCapture/ViewModel/RecorderViewModel.swift +++ b/BetterCapture/ViewModel/RecorderViewModel.swift @@ -416,21 +416,21 @@ final class RecorderViewModel { // If area selection is active, use the source rect dimensions. // The sourceRect is already snapped to even pixel counts in presentAreaSelection(). if let sourceRect = selectedSourceRect { - let scale = CGFloat(filter.pointPixelScale) - return CGSize( - width: applyScale ? sourceRect.width * scale : sourceRect.width, - height: applyScale ? sourceRect.height * scale : sourceRect.height + return CaptureSizeCalculator.videoSize( + contentRect: sourceRect, + scale: CGFloat(filter.pointPixelScale), + useNativeResolution: applyScale ) } // Get the content rect from the filter let rect = filter.contentRect - let scale = CGFloat(filter.pointPixelScale) if rect.width > 0 && rect.height > 0 { - return CGSize( - width: applyScale ? rect.width * scale : rect.width, - height: applyScale ? rect.height * scale : rect.height + return CaptureSizeCalculator.videoSize( + contentRect: rect, + scale: pointPixelScale(for: filter), + useNativeResolution: applyScale ) } @@ -444,6 +444,36 @@ final class RecorderViewModel { return CGSize(width: 1920, height: 1080) } + + /// The point-to-pixel scale to size the capture with. + /// + /// Window captures cannot trust `SCContentFilter.pointPixelScale`: it reports the main + /// display's scale for a window on a display arranged above the primary one, which sizes the + /// video larger than ScreenCaptureKit renders and pads the output. Resolve the scale from the + /// display the window actually occupies instead. + private func pointPixelScale(for filter: SCContentFilter) -> CGFloat { + let reported = CGFloat(filter.pointPixelScale) + + guard filter.style == .window, let window = filter.includedWindows.first else { + return reported + } + + return CaptureSizeCalculator.windowScale( + windowFrame: window.frame, + displays: Self.connectedDisplays(), + fallback: reported + ) + } + + /// The connected displays in the global CoreGraphics space that `SCWindow.frame` uses. + private static func connectedDisplays() -> [DisplayGeometry] { + NSScreen.screens.compactMap { screen in + guard let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else { + return nil + } + return DisplayGeometry(frame: CGDisplayBounds(displayID), scaleFactor: screen.backingScaleFactor) + } + } } // MARK: - CaptureEngineDelegate diff --git a/BetterCaptureTests/CaptureSizeCalculatorTests.swift b/BetterCaptureTests/CaptureSizeCalculatorTests.swift new file mode 100644 index 0000000..a6b6fe5 --- /dev/null +++ b/BetterCaptureTests/CaptureSizeCalculatorTests.swift @@ -0,0 +1,157 @@ +// +// CaptureSizeCalculatorTests.swift +// BetterCaptureTests +// +// Created by Joshua Sattler on 29.08.26. +// + +import CoreGraphics +import Foundation +import Testing +@testable import BetterCapture + +struct CaptureSizeCalculatorTests { + + // MARK: - Fixtures + + /// The 2x built-in display, at the origin of the global CoreGraphics space. + private let builtIn = DisplayGeometry( + frame: CGRect(x: 0, y: 0, width: 1512, height: 982), + scaleFactor: 2 + ) + + /// A 1x external display arranged *above* the built-in one, so it has a negative Y origin. + /// This is the arrangement that makes `SCContentFilter.pointPixelScale` report 2.0. + private let externalAbove = DisplayGeometry( + frame: CGRect(x: 2099, y: -1440, width: 2560, height: 1440), + scaleFactor: 1 + ) + + /// The same 1x external display arranged to the right of the built-in one. + private let externalBeside = DisplayGeometry( + frame: CGRect(x: 1512, y: 0, width: 2560, height: 1440), + scaleFactor: 1 + ) + + // MARK: - windowScale + + @Test func windowScaleUsesDisplayAboveThePrimaryRatherThanTheReportedScale() { + let window = CGRect(x: 2099, y: -1440, width: 2560, height: 1410) + + let scale = CaptureSizeCalculator.windowScale( + windowFrame: window, + displays: [builtIn, externalAbove], + fallback: 2 + ) + + #expect(scale == 1) + } + + @Test func windowScaleUsesDisplayBesideThePrimary() { + let window = CGRect(x: 1512, y: 0, width: 2560, height: 1410) + + let scale = CaptureSizeCalculator.windowScale( + windowFrame: window, + displays: [builtIn, externalBeside], + fallback: 2 + ) + + #expect(scale == 1) + } + + @Test func windowScaleUsesTheBuiltInDisplayForAWindowOnIt() { + let window = CGRect(x: 0, y: 80, width: 1512, height: 949) + + let scale = CaptureSizeCalculator.windowScale( + windowFrame: window, + displays: [builtIn, externalAbove], + fallback: 2 + ) + + #expect(scale == 2) + } + + @Test func windowScaleFallsBackWhenNoDisplayOverlaps() { + let window = CGRect(x: -5000, y: -5000, width: 400, height: 300) + + let scale = CaptureSizeCalculator.windowScale( + windowFrame: window, + displays: [builtIn, externalAbove], + fallback: 2 + ) + + #expect(scale == 2) + } + + @Test func windowScaleFallsBackWhenNoDisplaysAreKnown() { + let window = CGRect(x: 0, y: 80, width: 1512, height: 949) + + let scale = CaptureSizeCalculator.windowScale( + windowFrame: window, + displays: [], + fallback: 1.5 + ) + + #expect(scale == 1.5) + } + + @Test func windowScalePrefersTheDisplayShowingMostOfAStraddlingWindow() { + // 300pt on the built-in, 700pt on the external beside it. + let window = CGRect(x: 1212, y: 100, width: 1000, height: 600) + + let scale = CaptureSizeCalculator.windowScale( + windowFrame: window, + displays: [builtIn, externalBeside], + fallback: 2 + ) + + #expect(scale == 1) + } + + @Test func windowScaleIgnoresDisplaysTouchingOnlyTheWindowEdge() { + // The window ends exactly where the external display begins. + let window = CGRect(x: 0, y: 80, width: 1512, height: 902) + + let scale = CaptureSizeCalculator.windowScale( + windowFrame: window, + displays: [builtIn, externalBeside], + fallback: 1 + ) + + #expect(scale == 2) + } + + // MARK: - videoSize + + @Test func videoSizeAppliesScaleWhenCapturingNatively() { + let size = CaptureSizeCalculator.videoSize( + contentRect: CGRect(x: 0, y: 80, width: 1512, height: 949), + scale: 2, + useNativeResolution: true + ) + + #expect(size == CGSize(width: 3024, height: 1898)) + } + + @Test func videoSizeMatchesScreenCaptureKitForAOneTimesWindow() { + // The regression from issue #190: this used to be scaled by the reported 2.0 and produce + // a 5120x2820 frame holding 2560x1410 of content. + let size = CaptureSizeCalculator.videoSize( + contentRect: CGRect(x: 0, y: 0, width: 2560, height: 1410), + scale: 1, + useNativeResolution: true + ) + + #expect(size == CGSize(width: 2560, height: 1410)) + } + + @Test func videoSizeIgnoresScaleWhenNotCapturingNatively() { + let size = CaptureSizeCalculator.videoSize( + contentRect: CGRect(x: 0, y: 80, width: 1512, height: 949), + scale: 2, + useNativeResolution: false + ) + + #expect(size == CGSize(width: 1512, height: 949)) + } +}