🍁

Swift: あるNSWindowより下層領域のスクリーンショットを取得する

2024/12/21に公開

macOSアプリ開発でニッチなところを攻めようと思うと、しばしば最前面に透明なNSWindowを配置して、それより下層にあるコンテンツ全てを収めたスクリーンショットが撮りたくなることがあります。(カラーピッカーを作ったり、デスクトップのスナップショットを撮ったり)

そこでディスプレイに映っているもの全てを撮影したくなるわけですが、これまでそれを可能としてきたCGWindowListCreateImage()はmacOS 15 Sequoiaからは非推奨となりましたので、代替APIを提供しているScreenCaptureKitを使って同様のことを実現します。

まずはNSWindowからCGWindowIDを取得してください。

extension NSWindow {
    var windowID: CGWindowID {
        .init(windowNumber)
    }
}

let window = NSWindow()
window.windowID

続いてRetina画質かどうかを判断するためにディスプレイの解像度を取得します。
NSWindowbackingScaleFactorとかEnvironmentdisplayScaleとかで1.02.0のどちらかが取得できます。

let window = NSWindow()
window.backingScaleFactor

struct SomeView: View {
    @Environment(\.displayScale) var displayScale
}

そうしたらScreenCaptureKitSCScreenshotManager.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でマージするように描画させる
    • この際、CGImageCGWindowは座標系が異なるので注意する
    • Swift: macOSでの座標系のややこしい話
    • CGContextの設定値はなるべく元のスクリーンショットのものを引き継ぐこと(特にColorSpaceは変更してしまうと色味が変わってしまう)

参考にした記事
https://zenn.dev/iseebi/articles/macos_screencapturekit_capture_image

Discussion