WP-3b: ScreenCaptureKit capturer + region picker overlay per SPEC-A2a
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user