WP-3b: ScreenCaptureKit capturer + region picker overlay per SPEC-A2a

This commit is contained in:
2026-08-31 15:08:05 +04:00
parent 7379e4fbd7
commit 15021e9dda
3 changed files with 409 additions and 0 deletions
@@ -0,0 +1,118 @@
import ScreenCaptureKit
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers
import AppKit // NSScreen only, to read backingScaleFactor as a fallback
import Foundation
public struct CapturedImage: Sendable {
public let pngData: Data
public let pixelWidth: Int
public let pixelHeight: Int
public let scale: CGFloat
}
public struct ScreenCapturer: Sendable {
public init() {}
/// Does NOT prompt. Reports the current Screen Recording grant.
public static var isScreenRecordingGranted: Bool { CGPreflightScreenCaptureAccess() }
/// Captures `region` at the display's true backing scale. Throws
/// `.screenRecordingNotGranted` when the grant is absent never requests it.
public func capture(_ region: CaptureRegion) async throws -> CapturedImage {
guard Self.isScreenRecordingGranted else {
throw ShotdeckError.screenRecordingNotGranted
}
let content: SCShareableContent
do {
content = try await SCShareableContent.excludingDesktopWindows(
false,
onScreenWindowsOnly: true
)
} catch {
throw ShotdeckError.captureFailed(underlying: error.localizedDescription)
}
guard let display = content.displays.first(where: { $0.displayID == region.displayID }) else {
throw ShotdeckError.displayNoLongerConnected(displayID: region.displayID)
}
// Bounds/clamp: a resolution change often shrinks the display bounds by a few
// points without the region Ben picked being meaningfully wrong; 80% keeps a real
// recapture usable while a smaller overlap (e.g. a genuinely different display
// swapped in) still throws.
let rect: CGRect
if display.frame.contains(region.rect) {
rect = region.rect
} else {
let overlap = display.frame.intersection(region.rect)
let originalArea = region.rect.width * region.rect.height
let overlapArea = overlap.width * overlap.height
if originalArea > 0 && overlapArea / originalArea >= 0.8 {
rect = overlap
} else {
throw ShotdeckError.displayNoLongerConnected(displayID: region.displayID)
}
}
let localRect = CGRect(
x: rect.minX - display.frame.minX,
y: rect.minY - display.frame.minY,
width: rect.width,
height: rect.height
)
guard let mode = CGDisplayCopyDisplayMode(region.displayID) else {
throw ShotdeckError.captureFailed(
underlying: "no display mode for displayID \(region.displayID)"
)
}
guard mode.width > 0 else {
throw ShotdeckError.captureFailed(
underlying: "display mode width is 0 for displayID \(region.displayID)"
)
}
let scale = CGFloat(mode.pixelWidth) / CGFloat(mode.width)
let filter = SCContentFilter(display: display, excludingWindows: [])
let configuration = SCStreamConfiguration()
configuration.sourceRect = localRect
configuration.width = Int((localRect.width * scale).rounded())
configuration.height = Int((localRect.height * scale).rounded())
configuration.scalesToFit = false
configuration.showsCursor = false
let cgImage: CGImage
do {
cgImage = try await SCScreenshotManager.captureImage(
contentFilter: filter,
configuration: configuration
)
} catch {
throw ShotdeckError.captureFailed(underlying: error.localizedDescription)
}
let data = NSMutableData()
guard let dest = CGImageDestinationCreateWithData(
data,
UTType.png.identifier as CFString,
1,
nil
) else {
throw ShotdeckError.captureFailed(underlying: "could not create PNG image destination")
}
CGImageDestinationAddImage(dest, cgImage, nil)
guard CGImageDestinationFinalize(dest) else {
throw ShotdeckError.captureFailed(underlying: "PNG encoding failed to finalize")
}
return CapturedImage(
pngData: data as Data,
pixelWidth: cgImage.width,
pixelHeight: cgImage.height,
scale: scale
)
}
}