CoreMediaIOのCamera ExtensionでmacOSの仮想カメラを作る

 # Core Media I/Oを使ったCamera ExtensionでmacOSの仮想カメラを作る
古くからあるmacOSの仮想カメラを作る手法として、DAL(Device Abstraction Layer) Pluginがあります
しかし、DAL Pluginにはマルウェアを仕込まれるリスクや、開発が難しいなどの問題がありした
詳しくは、[WWDC2022のCreate camera extensions with Core Media IO](https://developer.apple.com/videos/play/wwdc2022/10022/)で説明されています (1:30あたり)
macOS 12.3以降では、System ExtensionにCamera Extensionが追加され、Core Media I/Oを使ってシンプルで安全にmacOSの仮想カメラを実装できるようになりました

https://developer.apple.com/documentation/coremediaio/creating_a_camera_extension_with_core_media_i_o

試しに、SceneKitの映像をリアルタイムに流し込む仮想カメラを作成してみました

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">camera extensionで任意の映像の仮想カメラできた <a href="https://t.co/mJXUya0uaB">pic.twitter.com/mJXUya0uaB</a></p>&mdash; ふじき (@fzkqi) <a href="https://twitter.com/fzkqi/status/1655120893306347521?ref_src=twsrc%5Etfw">May 7, 2023</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# 実装したコード

https://github.com/fuziki/CameraEX

# Appleのテンプレートを動かす
ホストアプリの`CamExtApp`と、Camera Extensionの`CamExtCameraExtension`を作成します
AppleがCamera Extensionが動作するテンプレートを作ってくれるので、まずはそれを動かしてみます
アプリのBundle Identifierを`com.example.CamExtApp`、ExtensionのBundle Identifierを`com.example.CamExtApp.CameraExtension`としているのて適宜読み替えてください
Camera Extensionを利用するためには課金アカウントが必要のようです

## アプリターゲットの作成
1. CamExtAppとしてSwiftUIのテンプレートのアプリターゲットを作成します
1. Signing&Capabilitiesを開き、TeamとBundle Identifierを適当に設定します
1. ＋ボタンから`System Extension`と`App Groups`を追加します
1. App Groupsには`$(TeamIdentifierPrefix)com.example.CamExtApp`を設定します
  $(TeamIdentifierPrefix)は自動で反映されます
  ![スクリーンショット 2023-05-09 0.38.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/99c28af9-2415-7596-0c9b-d71a05c4e4bf.png)

## Camera Extensionの追加
1. File → New → Targetから`Camera Extension`を選択し、`CamExtCameraExtension`として追加します
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/e1f5e457-da57-4c6d-3313-cc7118ea1976.png)
1. Signing&Capabilitiesを開き、TeamとBundle Identifierを適当に設定します
1. App Groupsにはアプリターゲットと同じものを設定します
  ![スクリーンショット 2023-05-09 0.36.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/06a1699f-2249-32f6-080a-b98956eccd6d.png)

## アプリの実装
1. 適当にActivate、Deactivateのボタンを設置し、Activate、Deactivateをそれぞれ実装します

```swift
import SwiftUI
import SystemExtensions

struct ContentView: View {
    let extID: String = "com.example.CameraEXApp.CameraEXCameraExtension"
    var body: some View {
        HStack {
            Button {
                let activationRequest = OSSystemExtensionRequest.activationRequest(forExtensionWithIdentifier: extID, queue: .main)
                OSSystemExtensionManager.shared.submitRequest(activationRequest)
            } label: {
                Text("Activate")
            }
            Button {
                let deactivationRequest = OSSystemExtensionRequest.deactivationRequest(forExtensionWithIdentifier: extID, queue: .main)
                OSSystemExtensionManager.shared.submitRequest(deactivationRequest)
            } label: {
                Text("Deactivate")
            }
        }
        .padding()
    }
}
```

## テンプレートの動作確認
1. 適当にアプリターゲットをビルドします
1. System Extensionが動作するために、appが`/Applications`下にある必要があるので、`~/Library/Developer/Xcode/DerivedData/`あたりからビルド済みのCamExtApp.appを`/Applications`にコピペします
1. CamExtApp.appを起動します
1. Activateを押下すると、アラートが表示されるので、システム設定を開き、許可します
  ![スクリーンショット 2023-05-09 0.59.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/4edd8e9a-c970-043a-7fe7-017c2607475a.png)
1. 適当なカメラを起動し、`SampleCapture (Swift)`を選択します
1. 白い線が上下に移動していれば成功です
  ![スクリーンショット 2023-05-09 1.02.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/61483016-46f2-8385-78bc-a8416b4543e8.png)

# 外部のアプリから映像を入れる
白い線が上下に移動する映像は、Camera Extension内で作成しており、このままでは好きな映像を映すことができません
そこで、外部のアプリから映像を渡すために、directionが[sink](https://developer.apple.com/documentation/coremediaio/cmioextensionstream/direction/sink)のCMIOExtensionStreamを作成します

## System Extensionの拡張

* CamExtCameraExtensionStreamSourceを参考にdirectionがsinkのCMIOExtensionStreamのSinkStreamSourceを作成します

```swift
import CoreMediaIO
import Foundation
import os.log

class SinkStreamSource: NSObject, CMIOExtensionStreamSource {
    var consumeSampleBuffer: ((CMSampleBuffer) -> Void)?

    private(set) var stream: CMIOExtensionStream!
    let device: CMIOExtensionDevice
    private let _streamFormat: CMIOExtensionStreamFormat
    private var client: CMIOExtensionClient?

    init(localizedName: String, streamID: UUID, streamFormat: CMIOExtensionStreamFormat, device: CMIOExtensionDevice) {
        self.device = device
        self._streamFormat = streamFormat
        super.init()
        self.stream = CMIOExtensionStream(localizedName: localizedName,
                                          streamID: streamID,
                                          direction: .sink, // .sinkを設定する
                                          clockType: .hostTime,
                                          source: self)
    }

    var formats: [CMIOExtensionStreamFormat] {
        return [_streamFormat]
    }
    var activeFormatIndex: Int = 0 {
        didSet {
            if activeFormatIndex >= 1 {
                os_log(.error, "Invalid index")
            }
        }
    }
    var availableProperties: Set<CMIOExtensionProperty> {
        [
            .streamActiveFormatIndex,
            .streamFrameDuration,
            .streamSinkBufferQueueSize,
            .streamSinkBuffersRequiredForStartup,
        ]
    }
    func streamProperties(forProperties properties: Set<CMIOExtensionProperty>) throws -> CMIOExtensionStreamProperties {
        let streamProperties = CMIOExtensionStreamProperties(dictionary: [:])
        if properties.contains(.streamActiveFormatIndex) {
            streamProperties.activeFormatIndex = 0
        }
        if properties.contains(.streamFrameDuration) {
            let frameDuration = CMTime(value: 1, timescale: Int32(kFrameRate))
            streamProperties.frameDuration = frameDuration
        }
        if properties.contains(.streamSinkBufferQueueSize) {
            streamProperties.sinkBufferQueueSize = 1
        }
        if properties.contains(.streamSinkBuffersRequiredForStartup) {
            streamProperties.sinkBuffersRequiredForStartup = 1
        }
        return streamProperties
    }

    func setStreamProperties(_ streamProperties: CMIOExtensionStreamProperties) throws {
        if let activeFormatIndex = streamProperties.activeFormatIndex {
            self.activeFormatIndex = activeFormatIndex
        }
    }

    func authorizedToStartStream(for client: CMIOExtensionClient) -> Bool {
        self.client = client
        // An opportunity to inspect the client info and decide if it should be allowed to start the stream.
        return true
    }

    func startStream() throws {
        guard let deviceSource = device.source as? CamExtCameraExtensionDeviceSource else {
            fatalError("Unexpected source type \(String(describing: device.source))")
        }
        deviceSource.startSinkStreaming() // sink用のstart
        subscribe()
    }

    func stopStream() throws {
        guard let deviceSource = device.source as? CamExtCameraExtensionDeviceSource else {
            fatalError("Unexpected source type \(String(describing: device.source))")
        }
        deviceSource.stopSinkStreaming() // sink用のstop
    }

    private func subscribe() {
        guard let client else { return }
        // consumeSampleBufferを使ってCMSampleBufferを受け取る
        stream.consumeSampleBuffer(from: client) { [weak self] (sampleBuffer: CMSampleBuffer?,
                                                                sampleBufferSequenceNumber: UInt64,
                                                                discontinuity: CMIOExtensionStream.DiscontinuityFlags,
                                                                hasMoreSampleBuffers: Bool,
                                                                error: Error?) in
            if let error {
                return
            }
            defer { self?.subscribe() }
            if let sampleBuffer {
                self?.consumeSampleBuffer?(sampleBuffer)
                let presentationNanoSec = UInt64(sampleBuffer.presentationTimeStamp.seconds * Double(NSEC_PER_SEC))
                let output = CMIOExtensionScheduledOutput(sequenceNumber: sampleBufferSequenceNumber,
                                                          hostTimeInNanoseconds: presentationNanoSec)
                self?.stream.notifyScheduledOutputChanged(output)
            }
        }
    }
}
```

* CamExtCameraExtensionDeviceSourceにSinkStreamSourceを追加します

```swift
class CamExtCameraExtensionDeviceSource {
    // 略
    private var _sinkStreamSource: SinkStreamSource!
    private var sinking: Bool = false
    // 略
	init(localizedName: String) {
        // 略
        let sinkStreamID = UUID()
        _sinkStreamSource = SinkStreamSource(localizedName: "SampleCapture.Video.Sink",
                                             streamID: sinkStreamID,
                                             streamFormat: videoStreamFormat,
                                             device: device) // 追加
		do {
			try device.addStream(_streamSource.stream)
            try device.addStream(_sinkStreamSource.stream) // 追加
		} catch let error {
			fatalError("Failed to add stream: \(error.localizedDescription)")
		}
	}
    // 略
}
```

* SinkStreamSourceのstart/stopを受け取ります
* CMSampleBufferを受け取ったら、_streamSource.streamに入れます

```swift
class CamExtCameraExtensionDeviceSource {
    // 略
    func startSinkStreaming() {
        sinking = true
        _sinkStreamSource.consumeSampleBuffer = { [weak self] buffer in
            if self?._streamingCounter == 0 { return }
            var timingInfo = CMSampleTimingInfo()
            timingInfo.presentationTimeStamp = CMClockGetTime(CMClockGetHostTimeClock())
            let nanoSec = UInt64(timingInfo.presentationTimeStamp.seconds * Double(NSEC_PER_SEC))
            self?._streamSource.stream.send(buffer,
                                            discontinuity: [],
                                            hostTimeInNanoseconds: nanoSec)
        }
    }
    func stopSinkStreaming() {
        sinking = false
        _sinkStreamSource.consumeSampleBuffer = nil
    }
}
```

* SinkStreamSourceが動いている間は、_timerを使って_streamSource.streamにCMSampleBufferを入れないようにします

```swift
class CamExtCameraExtensionDeviceSource {
    // 略
    func startStreaming() {
        // 略
		_timer!.setEventHandler {
            if self.sinking { return } // 追加
            var err: OSStatus = 0
			let now = CMClockGetTime(CMClockGetHostTimeClock())
            // 略
        }
        // 略
    }
    // 略
}
```

## CMSampleBufferを送る
CMSampleBufferを送る`GameApp`を作成します

### アプリターゲットの作成
1. File → New → TargetのGameを使ってGameAppを作成します
  **Game TechnologyはSceneKitを選びます**
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/5b1913b2-d14b-ffa8-2cdb-9f98e53e6b06.png" width="256"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/457bd0e0-0705-ce6b-e483-5a36c54fdff3.png" width="256">
1. Signing&Capabilitiesを開き、AppSandboxの`Camera`にチェックを入れます
  ![スクリーンショット 2023-05-09 1.41.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/fe1b48fc-4d67-90b6-86ae-19ba37f1c154.png)
1. 適当にmethod swizzlingして最新のMTLTextureを取得し、CMSampleBufferに変換します
  ※ CMSampleBufferがあればよいので、必ずしもこの方法である必要はないです
  [method swizzlingのコードはこちら](https://github.com/fuziki/CameraEX/blob/cd77ef86c2306f429a12963cd435fa1fa914558f/GameApp/GameViewController.swift#L129-L178)
  [CMSampleBufferに変換するコードはこちら](https://github.com/fuziki/CameraEX/blob/cd77ef86c2306f429a12963cd435fa1fa914558f/CameraEXLib/Sources/GameAppLib/Utils/CoreVideo/SampleBufferVideoFactory.swift#L13)

### CMSimpleQueueの取得
CMSampleBufferを渡すために、CamExtCameraExtensionの`CMSimpleQueue`を取得します
ここからはC API、、、

* AVCaptureDeviceの一覧から名前が`SampleCapture (Swift)`のものを取得します

```swift
func getCaptureDevice(name: String) -> AVCaptureDevice? {
    AVCaptureDevice
        .DiscoverySession(deviceTypes: [.externalUnknown],
                          mediaType: .video,
                          position: .unspecified)
        .devices
        .first { $0.localizedName == name }
}
guard let cd = getCaptureDevice(name: "SampleCapture (Swift)") else {
    print("no capture device")
    return
}
```

* Deviceの一覧を取得します

```swift
func getDeviceIDs() -> [CMIODeviceID] {
    var res: OSStatus
    var opa = CMIOObjectPropertyAddress(
        mSelector: CMIOObjectPropertySelector(kCMIOHardwarePropertyDevices),
        mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal),
        mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain)
    )
    var dataSize: UInt32 = 0
    res = CMIOObjectGetPropertyDataSize(CMIODeviceID(kCMIOObjectSystemObject),
                                        &opa, 0, nil, &dataSize)
    if res != noErr {
        print("failed CMIOObjectGetPropertyDataSize")
        return []
    }
    let count = Int(dataSize) / MemoryLayout<CMIODeviceID>.size
    var dataUsed: UInt32 = 0
    var devices = [CMIODeviceID](repeating: 0, count: count)
    res = CMIOObjectGetPropertyData(CMIOObjectPropertySelector(kCMIOObjectSystemObject),
                                    &opa, 0, nil, dataSize, &dataUsed, &devices)
    if res != noErr {
        print("failed CMIOObjectGetPropertyData")
        return []
    }
    return devices
}
let deviceIDs = getDeviceIDs()
if deviceIDs.isEmpty {
    print("deviceIDs is empty")
    return
}
```

* UIDが`SampleCapture (Swift)`のAVCaptureDeviceと合致しているものを探します

```swift
func getDeviceUID(deviceID: CMIODeviceID) -> String? {
    var res: OSStatus
    var opa = CMIOObjectPropertyAddress(
        mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceUID),
        mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal),
        mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain)
    )
    var dataSize: UInt32 = 0
    res = CMIOObjectGetPropertyDataSize(deviceID, &opa, 0, nil, &dataSize)
    if res != noErr {
        print("failed CMIOObjectGetPropertyDataSize")
        return nil
    }
    var dataUsed: UInt32 = 0
    var deviceUID: NSString = ""
    res = CMIOObjectGetPropertyData(deviceID, &opa, 0, nil, dataSize, &dataUsed, &deviceUID)
    if res != noErr {
        print("failed CMIOObjectGetPropertyData")
        return nil
    }
    return deviceUID as String
}
guard let deviceID = deviceIDs
    .first(where: { getDeviceUID(deviceID: $0) == cd.uniqueID }) else {
    print("no math deviceID")
    return
}
```

* Streamを取得します

```swift
func getStreams(deviceID: CMIODeviceID) -> [CMIOStreamID] {
    var res: OSStatus
    var opa = CMIOObjectPropertyAddress(
        mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyStreams),
        mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal),
        mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain)
    )
    var dataSize: UInt32 = 0
    res = CMIOObjectGetPropertyDataSize(deviceID, &opa, 0, nil, &dataSize)
    if res != noErr {
        print("failed CMIOObjectGetPropertyDataSize")
        return []
    }
    var dataUsed: UInt32 = 0
    let count = Int(dataSize) / MemoryLayout<CMIOStreamID>.size
    var streams = [CMIOStreamID](repeating: 0, count: count)
    res = CMIOObjectGetPropertyData(deviceID, &opa, 0, nil, dataSize, &dataUsed, &streams)
    if res != noErr {
        print("failed CMIOObjectGetPropertyData")
        return []
    }
    return streams
}
let streams = getStreams(deviceID: deviceID)
if streams.count < 2 {
    print("Streams is less than expected")
    return
}
let sinkStreamID = streams[1]
```

* StreamをStartします

```swift
startStream(deviceID: deviceID, streamID: sinkStreamID)

func startStream(deviceID: CMIODeviceID,
                 streamID: CMIOStreamID,
                 proc: CMIODeviceStreamQueueAlteredProc,
                 refCon: UnsafeMutableRawPointer) -> CMSimpleQueue? {
    var res: OSStatus
    var queuePtr: Unmanaged<CMSimpleQueue>? = nil
    res = CMIOStreamCopyBufferQueue(streamID, proc, refCon, &queuePtr)
    if res != noErr {
        print("failed CMIOStreamCopyBufferQueue")
        return nil
    }
    res = CMIODeviceStartStream(deviceID, streamID)
    if res != noErr {
        print("failed CMIOStreamCopyBufferQueue")
        return nil
    }
    return queuePtr?.takeUnretainedValue()
}
func startStream(deviceID: CMIODeviceID, streamID: CMIOStreamID) {
    let proc: CMIODeviceStreamQueueAlteredProc = { (streamID: CMIOStreamID,
                                                    token: UnsafeMutableRawPointer?,
                                                    refCon: UnsafeMutableRawPointer?) in
        guard let refCon else { return }
        let con = Unmanaged<GameViewModel>.fromOpaque(refCon).takeUnretainedValue()
        con.alteredProc()
    }
    let refCon = Unmanaged.passUnretained(self).toOpaque()
    streamQueue = CoreMediaIOUtil.startStream(deviceID: deviceID, streamID: streamID, proc: proc, refCon: refCon)
    isStreaming = true
    shouldEnqueue = true
}
```


### CMSampleBufferの入力
1. 作成したCMSampleBufferをenqueueします

```swift
        let time = CMClockGetTime(CMClockGetHostTimeClock())
        guard let buffer: CMSampleBuffer = sbf.make(mtlTexture: texture, time: time) else { return }
        
        CMSimpleQueueEnqueue(streamQueue, element: Unmanaged.passRetained(buffer).toOpaque())
```

### 動作確認
1. 適当にアプリターゲットをビルドします
1. System Extensionが動作するために、/Applications にいる必要があるので、`~/Library/Developer/Xcode/DerivedData/`あたりからビルド済みのCamExtApp.appを`/Applications`にコピペします
1. CamExtApp.appを起動します
1. Activateを押下すると、アラートが表示されるので、システム設定を開き、許可します
1. 適当なカメラを起動し、`SampleCapture (Swift)`を選択します
1. XcodeからGameAppを起動します
1. カメラにGameAppの映像が映っていれば成功です
  ![スクリーンショット 2023-05-09 1.59.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/4a706e1c-a5b0-e93d-bc9c-789c29d83f1d.png)

# まとめ
WWDCでもsinkの方はあまり触れられておらず、諸々かなり難しいなぁという感想でした
DAL PluginのサポートはmacOS 13までらしいので、Appleは移行を推し進めたい気持ちが強いのかなと感じました
なのであれば、もう少しApple公式のドキュメントがあっても悪くないのかななどと思いました