Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions BetterCapture/Model/DisplayGeometry.swift
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 17 additions & 2 deletions BetterCapture/Service/CaptureEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions BetterCapture/Service/CaptureSizeCalculator.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
46 changes: 38 additions & 8 deletions BetterCapture/ViewModel/RecorderViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -416,21 +416,21 @@
// 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
)
}

Expand All @@ -444,6 +444,36 @@

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
Expand Down Expand Up @@ -533,4 +563,4 @@
captureEngine.clearSelection()
captureEngine.deactivatePicker()
}
}

Check warning on line 566 in BetterCapture/ViewModel/RecorderViewModel.swift

View workflow job for this annotation

GitHub Actions / Lint

File should contain 500 lines or less: currently contains 566 (file_length)
157 changes: 157 additions & 0 deletions BetterCaptureTests/CaptureSizeCalculatorTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading