🍁
Swift: あるNSWindowより下層領域のスクリーンショットを取得する
macOSアプリ開発でニッチなところを攻めようと思うと、しばしば最前面に透明なNSWindowを配置して、それより下層にあるコンテンツ全てを収めたスクリーンショットが撮りたくなることがあります。(カラーピッカーを作ったり、デスクトップのスナップショットを撮ったり)
そこでディスプレイに映っているもの全てを撮影したくなるわけですが、これまでそれを可能としてきたCGWindowListCreateImage()はmacOS 15 Sequoiaからは非推奨となりましたので、代替APIを提供しているScreenCaptureKit
を使って同様のことを実現します。
まずはNSWindow
からCGWindowID
を取得してください。
extension NSWindow {
var windowID: CGWindowID {
.init(windowNumber)
}
}
let window = NSWindow()
window.windowID
続いてRetina画質かどうかを判断するためにディスプレイの解像度を取得します。
NSWindow
のbackingScaleFactor
とかEnvironment
のdisplayScale
とかで1.0
か2.0
のどちらかが取得できます。
let window = NSWindow()
window.backingScaleFactor
struct SomeView: View {
@Environment(\.displayScale) var displayScale
}
そうしたらScreenCaptureKit
のSCScreenshotManager.captureImage()
APIを使ってスクリーンショットを取得できます。
import CoreGraphics
import ScreenCaptureKit
struct DisplayImage {
var image: CGImage
var frame: CGRect
}
@MainActor class ScreenshotService {
private let windowID: CGWindowID
private let displayScale: CGFloat
init(_ windowID: CGWindowID, _ displayScale: CGFloat) {
self.windowID = windowID
self.displayScale = displayScale
}
func captureImageAsync() async -> CGImage? {
do {
let currentContent = try await SCShareableContent.current
guard let window = currentContent.windows.first(where: { $0.windowID == windowID }) else { return nil }
let targetContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnlyBelow: window)
var images = [DisplayImage]()
for display in targetContent.displays {
let filter = SCContentFilter(display: display, including: targetContent.windows)
let configuration = SCStreamConfiguration()
configuration.captureResolution = .nominal
configuration.width = Int(displayScale * display.frame.width)
configuration.height = Int(displayScale * display.frame.height)
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration)
images.append(.init(image: image, frame: display.frame))
}
return mergeImages(images)
} catch {
print(error.localizedDescription)
return nil
}
}
private func mergeImages(_ images: [DisplayImage]) -> CGImage? {
let rect = images.reduce(into: CGRect.zero) { $0 = $0.union($1.frame) }
guard let first = images.first?.image,
let colorSpace = first.colorSpace,
let cgContext = CGContext(
data: nil,
width: Int(rect.size.width),
height: Int(rect.size.height),
bitsPerComponent: 8,
bytesPerRow: 4 * Int(rect.size.width),
space: colorSpace,
bitmapInfo: first.bitmapInfo.rawValue
) else {
return nil
}
images.forEach {
let destination = CGRect(
x: $0.frame.origin.x - rect.origin.x,
y: (rect.origin.y + rect.height) - ($0.frame.origin.y + $0.frame.height),
width: $0.frame.width,
height: $0.frame.height
)
cgContext.draw($0.image, in: destination)
}
return cgContext.makeImage()
}
}
ポイント
- 現在のディスプレイに表示されているコンテンツは
SCShareableContent.current
で取得できる -
SCShareableContent.excludingDesktopWindows(_:onScreenWindowsOnlyBelow:)
であるウインドウより仮想領域にあるコンテンツを取得できる -
SCContentFilter
でスクリーンショットに含めたいコンテンツの絞り込みがディスプレイごとにできる -
SCStreamConfiguration
でスクリーンショットに関する設定を微調整できる- これをちゃんとやらないとRetinaディスプレイで解像度の低い画像しか取得できない
- ディスプレイごとにスクリーンショットが取得できるので、ディスプレイの配置に従って1枚の画像にしたい場合は相対位置を計算して
CGContext
でマージするように描画させる- この際、
CGImage
とCGWindow
は座標系が異なるので注意する - Swift: macOSでの座標系のややこしい話
-
CGContext
の設定値はなるべく元のスクリーンショットのものを引き継ぐこと(特にColorSpace
は変更してしまうと色味が変わってしまう)
- この際、
参考にした記事
Discussion