macOSのScreenCaptureKitを使い、特定ウィンドウのスクリーンショットを撮影する
macOSアプリでウィンドウのスクリーンショットを撮影する際、CGWindowListCreateImageというAPIが使われてきました。このAPIはmacOS 14.0でDeprecated扱いとなり、14.0をDeployment TargetにするとScreenCaptureKitを使ってスクリーンショットを撮影せよと警告がつきます。
ScreenCaptureKitで検索するとSCStreamを使った動画キャプチャの話題が多く見つかりましたがスクリーンショット撮影の情報がなかったので調べました。
ScreenCaptureKitについて
ScreenCaptureKitはmacOS 12.3で導入されたフレームワークで、ハイパフォーマンスなスクリーンキャプチャを提供します。
スクリーンの一部を動画キャプチャし録画するほか、スクリーンショットを作成したり、その範囲を決めるピッカーなども提供されています。
全体の概要をつかむにはWWDCの動画がわかりやすいと思います。
- Session 10156: Meet ScreenCaptureKit (WWDC22)
- Session 10155: Take ScreenCaptureKit to the next level(WWDC22)
- Session 10136: What's new in ScreenCaptureKit(WWDC23)
導入段階ではスクリーンキャプチャに主眼が置かれていた印象で、スクリーンショットの撮影をするためだけに使うには不相応な複雑な手順が必要でしたが、14.0のタイミングでSCScreenshotManagerが追加され、スクリーンショットも手軽に取得できるようになりました。
前提
ScreenCaptureKitを使ってスクリーンキャプチャを得るには、アプリが画面収録のパーミッションを取得しておく必要があります。
事前に以下の関数を呼び出し、ユーザーに権限の付与を要求しておきます。
-
CGPreflightScreenCaptureAccess()
- 実行時点で権限付与されているかを返す。 -
CGRequestScreenCaptureAccess()
- 権限が付与されていなければ画面収録の許可依頼のアラートダイアログを表示する。関数の結果自体はすぐに返り、実行時点で権限付与されているかどうかが返る。
また、今回使用するSCScreenshotManagerはmacOS 14.0以降対応となりますので、これ以下のバージョンでは引き続きCGWindowListCreateImageが必要になりそうです。
コード例
早速ですが指定されたウィンドウIDを持つウィンドウのスクリーンショットを得る関数を紹介します。
import ScreenCaptureKit
func captureImageAsync(_ windowID: Int) async throws -> NSImage? {
let shareableContent = try await SCShareableContent.current
guard let captureWindow = shareableContent.windows.first(where: { $0.windowID == windowID }),
let display = shareableContent.displays.first(where: { $0.frame.contains(captureWindow.frame) })
else {
return nil
}
let filter = SCContentFilter(display: display, including: [captureWindow])
let config = SCStreamConfiguration()
config.sourceRect = CGRect(
origin: CGPoint(
x: captureWindow.frame.origin.x - display.frame.origin.x,
y: captureWindow.frame.origin.y - display.frame.origin.y
),
size: captureWindow.frame.size
)
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)
return NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height))
}
おおまかな流れとしては以下の処理を行っています。
- SCShareableContent で、共有対象となるコンテンツを取得する
- SCContentFilter で、共有対象となるコンテンツを指定する
- SCStreamConfiguration で、出力の設定をする
- SCScreenshotManager で画像を取得する
共有対象となるコンテンツを取得する
let shareableContent = try await SCShareableContent.current
guard let captureWindow = shareableContent.windows.first(where: { $0.windowID == windowID }),
let display = shareableContent.displays.first(where: { $0.frame.contains(captureWindow.frame) })
else {
return nil
}
SCShareableContent では、現在画面上に存在しているウィンドウやスクリーンといった、収録対象となるコンテンツを列挙することができます。
ここではawaitを使ってSCShareableContentのインスタンスを取得していますが、getWithCompletionHandler(_:)を使ってコールバックとしてインスタンスを得ることもできます。
今回は別の処理で WindowID がわかっていたので、WindowID で絞り込みました。また、表示されているディスプレイのインスタンスも必要だったため、ウィンドウが表示領域内にあるディスプレイのインスタンスも取得しておきます。
コンテンツの指定と出力の設定
let filter = SCContentFilter(display: display, including: [captureWindow])
let config = SCStreamConfiguration()
config.sourceRect = CGRect(
origin: CGPoint(
x: captureWindow.frame.origin.x - display.frame.origin.x,
y: captureWindow.frame.origin.y - display.frame.origin.y
),
size: captureWindow.frame.size
)
ScreenCaptureKitを使った収録では、SCContentFilterとSCStreamConfigurationの2つの要素が必要となります。
SCContentFilterは収録対象のコンテンツを指定します。ここでは先ほど取得したウィンドウとディスプレイを指定します。
ウィンドウを複数指定することもできます。
SCStreamConfigurationでは、収録するコンテンツの範囲やサイズ、品質などを指定することができます。
ここでは、ウィンドウのみが収録対象になるよう、sourceRectを指定しています。
特に注意が必要なのが、マルチディスプレイ環境においては、SCWindowのframeは複数のディスプレイが1つの描画領域に繋がった形で座標が決まっているものの、sourceRectに指定する座標はSCDisplayのRectからの相対位置になるということです。
例えば、1920x1080のディスプレイが横並びの場合に、2枚目のディスプレイにあるウィンドウを考えたとき、2枚目のディスプレイにあるSCWindowのx座標は、1枚目からの通算で2000の位置にあります。
sourceRectの指定においてはSCDisplay内の相対位置となるため、x座標は80を指定する必要があります。
スクリーンショットの取得
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)
return NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height))
最後に、得られたSCContentFilterとSCStreamConfigurationを使い、SCScreenshotManagerでスクリーンショットを作成します。
CGImageが取得できるcaptureImageと、CMSampleBufferが得られるcaptureSampleBufferの2種類があるので、目的に応じて指定します。今回はcaptureImageを使ってCGImageを取得し、それをNSImageにしています。最終的にはNSImageのインスタンスをSwiftUIに流し込みました。
なお、こちらもawaitしていますが、completionHandlerを使うこともできます。
Discussion
この情報が知りたかったんです。ありがとうございます!!