macOSで画面を収録する

しむどん 2024-10-25

なぜ取り組むのか

ゲームの画面を配信する時には、OBSというソフトウェアを使用する事が多いだろう。OBSは画面をキャプチャし、合成し、配信するといった機能を持つ、とても良くできたソフトウェアだ。しかしスペックの低いPCでは、ゲームをしながらOBSを起動させると、処理落ちし画面がカクつくという状況になる事がある。これをてっとりばやく改善する方法としては、PCを買い替えるとか、良いGPUを搭載するとかいろいろとあるだろう。しかし、これらはお金を使う必要がある。そして筆者は貧乏だ。y

OBSの設定を見直すという方法もある。設定は当然見直すべきだろうけれど、スペックの低すぎるPCでは効果を得られない事もある。他にもデータを別のマシンに転送し、それらをエンコードして配信するといった事もできるように思えるが、OBSは統合されたソフトウェアであって、機能単体を使い、他の仕組みと協調して動作させるようにはできていない。つまりUNIX哲学における「一つの事をうまくやる」という考えには沿っていない。

しかし、今回必要な機能は画面のキャプチャだ。実際に必要な機能はよりシンプルで、小さなものであるはずだ。その本質的な部分を把握しておきたいと考えた。

UNIX哲学における「一つの事をうまくやる」という考え方は、今なお重要であり、それを実践することでより良いソフトウェアを構築できると信じている。本質的な部分を把握する事で、周辺環境の変化に迅速に対応できる柔軟性も持ち合わせることができる。

筆者はソフトウェアを書く時、しばしば「必要のないものを持つな」という事の大切さを考える。必要のないものを持つという事は、不必要な複雑性を増し、不必要に保守性を低下させ、不必要に協調をしにくくし、不必要にセキュリティ的に脆弱になるという事だ。しかし「必要」という点は、人や要件によって変化する。だからこそ、何が必要なのかを、しっかり捉える必要がある。今回で言えば、画面をキャプチャする事だろう。

加えて副次的にではあるけれど、macOSやSwiftやObjective-Cの良い学びの機会にもなる。学ぶことで、自身のスキルセットを拡充でき、仕事の幅を広げる事もできる。これは、筆者の人生のテーマの1つ「できる事を増やす」にも通じる。

そこで今回は、これに取り組む事にした。

調査

まずは何としても動くものを作る

windowを表示する

プログラミングでは、まずはHello worldから始めるのが鉄則だ。これは一気に複雑な事柄に飛び込んで混乱するのではなく、シンプルな事から始め、一歩ずつ足場を固める事で、許容できる複雑さを広げるという点で意味がある。

筆者は、先日ちょうどswiftでコンソール上でのHello world!はやって、まだ記憶に残っている。そのためそこは省略し、"Hello world!"というウィンドウを表示するところから始める。

import SwiftUI

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView: View {
    var body: some View {
        Text("Hello, World!").padding()
    }
}

ビルドする。

swiftc -o window -parse-as-library  \
		-Xlinker -framework -Xlinker SwiftUI -Xlinker -framework -Xlinker Foundation \
		window.swift

指定する各オプションは、以下のような意味を持つ。

1. **`-o window`**:
   - 出力ファイルの名前を指定します。ここでは、コンパイル後の実行可能ファイルを `window` と名付けています。

2. **`-parse-as-library`**:
   - 指定されたソースファイルをライブラリとしてコンパイルすることを指示しています。このオプションは、エントリーポイント(例えば `main` 関数)を含まないファイルに対して使用します。主にモジュールとして使用される目的で、ライブラリのビルドを行います。

3. **`-Xlinker`**:
   - これはリンク時オプションを指定するためのプレフィックスです。次の引数はリンク時に `linker` に渡されます。このオプションは、Swift コードをコンパイルした後、実行可能ファイルを生成する過程で必要なリンクに関する情報を提供します。

4. **`-framework`**:
   - このオプションは、指定されたフレームワークをリンクすることを指示します。この場合、`SwiftUI` と `Foundation` のフレームワークがリンクされます。

5. **`SwiftUI`**:
   - Apple のユーザーインターフェースを構築するためのフレームワークであり、モダンなアプリケーションの UI を宣言的に構築するために使用されます。

6. **`Foundation`**:
   - Apple の基本的なデータ構造や機能を提供するフレームワークで、日付、配列、辞書、文字列、エラー処理などのクラスや機能を支えています。

7. **`window.swift`**:
   - コンパイルおよびリンクを行う対象の Swift のソースファイル名です。このファイルには、`window` という実行可能ファイルを生成するための Swift コードが含まれています。
ChatGPTによる解説。

実行する。

./window

./window-2024-10-25-12-19-03.png

画面をキャプチャする

ScreenCaptureKit を使って画面をキャプチャしてみよう。結果を表示するためのウィンドウを作成し、そこに表示する事にする。このコードは https://qiita.com/fuziki/items/ad7d21d4aa01bb0357d3 のコードを、ほぼそのまま使っている。

import SwiftUI
import ScreenCaptureKit

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView: View {
    @ObservedObject var viewModel = ContentViewModel()
    var stream: SCStream!

    var body: some View {
        if let image = viewModel.image {
            Image(nsImage: image)
                .resizable()
                .scaledToFit()
        } else {
            Text("Hello, world!")
                .padding(120)
        }
    }
}


class ContentViewModel: NSObject, ObservableObject {
    @Published var image: NSImage?
    var stream: SCStream!
    override init () {
        super.init()
        Task {
            do {
                try await setup()
            } catch let error {
                print("error: \(error)")
            }
        }
    }

    @MainActor func setup () async throws {
        let availableContent = try await SCShareableContent.excludingDesktopWindows(
          false, onScreenWindowsOnly: false)

        guard let window = availableContent.windows.first(
                where: {$0.owningApplication?.applicationName == "Emacs"})
        else {
            print("No window")
            return
        }
        let filter = SCContentFilter(desktopIndependentWindow: window)

        let configuration = SCStreamConfiguration()
        configuration.width = Int(window.frame.width)
        configuration.height = Int(window.frame.height)
        configuration.showsCursor = true

        stream = SCStream(filter: filter, configuration: configuration, delegate: nil)
        try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: DispatchQueue.main)
        print("Start capture")
        try await stream.startCapture()
    }
}

extension ContentViewModel: SCStreamOutput {
    func stream(_ stream: SCStream,
                didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
                of type: SCStreamOutputType
    ) {
        guard let pixelBuffer = sampleBuffer.imageBuffer else { return }
        let size = CGSize(width: CVPixelBufferGetWidth(pixelBuffer),
                          height: CVPixelBufferGetHeight(pixelBuffer))
        let ci = CIImage(cvPixelBuffer: pixelBuffer)
        let cg = CIContext().createCGImage(ci, from: .init(origin: .zero, size: size))!
        self.image = NSImage(cgImage: cg, size: size)
    }
}

このコードの中で、どのウインドウを対象とするかを選択している所がある。便宜上 Emacs としているけれど、ここは対象としたいアプリケーションのウィンドウとなるように適宜変更する必要がある。

guard let window = availableContent.windows.first(
        where: {$0.owningApplication?.applicationName == "Emacs"})
else {
    print("No window")
    return
}

windowSCWindow のインスタンスであり、ドキュメントに属性について記述されているため、それを使い対象をうまく指定すると良い。

それではswitfcを使ってビルドしよう。

swiftc -o capture -parse-as-library capture.swift

実行する。

./capture

参考