Core Media I/Oを使ったCamera ExtensionでmacOSの仮想カメラを作る
古くからあるmacOSの仮想カメラを作る手法として、DAL(Device Abstraction Layer) Pluginがあります
しかし、DAL Pluginにはマルウェアを仕込まれるリスクや、開発が難しいなどの問題がありした
詳しくは、WWDC2022のCreate camera extensions with Core Media IOで説明されています (1:30あたり)
macOS 12.3以降では、System ExtensionにCamera Extensionが追加され、Core Media I/Oを使ってシンプルで安全にmacOSの仮想カメラを実装できるようになりました
試しに、SceneKitの映像をリアルタイムに流し込む仮想カメラを作成してみました
camera extensionで任意の映像の仮想カメラできた pic.twitter.com/mJXUya0uaB
— ふじき (@fzkqi) May 7, 2023
実装したコード
Appleのテンプレートを動かす
ホストアプリのCamExtAppと、Camera ExtensionのCamExtCameraExtensionを作成します
AppleがCamera Extensionが動作するテンプレートを作ってくれるので、まずはそれを動かしてみます
アプリのBundle Identifierをcom.example.CamExtApp、ExtensionのBundle Identifierをcom.example.CamExtApp.CameraExtensionとしているのて適宜読み替えてください
Camera Extensionを利用するためには課金アカウントが必要のようです
アプリターゲットの作成
- CamExtAppとしてSwiftUIのテンプレートのアプリターゲットを作成します
- Signing&Capabilitiesを開き、TeamとBundle Identifierを適当に設定します
- +ボタンからSystem ExtensionとApp Groupsを追加します
- App Groupsには$(TeamIdentifierPrefix)com.example.CamExtAppを設定します
 $(TeamIdentifierPrefix)は自動で反映されます
   
Camera Extensionの追加
- File → New → TargetからCamera Extensionを選択し、CamExtCameraExtensionとして追加します
   
- Signing&Capabilitiesを開き、TeamとBundle Identifierを適当に設定します
- App Groupsにはアプリターゲットと同じものを設定します
   
アプリの実装
- 適当にActivate、Deactivateのボタンを設置し、Activate、Deactivateをそれぞれ実装します
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()
    }
}
テンプレートの動作確認
- 適当にアプリターゲットをビルドします
- System Extensionが動作するために、appが/Applications下にある必要があるので、~/Library/Developer/Xcode/DerivedData/あたりからビルド済みのCamExtApp.appを/Applicationsにコピペします
- CamExtApp.appを起動します
- Activateを押下すると、アラートが表示されるので、システム設定を開き、許可します
   
- 適当なカメラを起動し、SampleCapture (Swift)を選択します
- 白い線が上下に移動していれば成功です
   
外部のアプリから映像を入れる
白い線が上下に移動する映像は、Camera Extension内で作成しており、このままでは好きな映像を映すことができません
そこで、外部のアプリから映像を渡すために、directionがsinkのCMIOExtensionStreamを作成します
System Extensionの拡張
- CamExtCameraExtensionStreamSourceを参考にdirectionがsinkのCMIOExtensionStreamのSinkStreamSourceを作成します
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を追加します
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に入れます
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を入れないようにします
class CamExtCameraExtensionDeviceSource {
    // 略
    func startStreaming() {
        // 略
		_timer!.setEventHandler {
            if self.sinking { return } // 追加
            var err: OSStatus = 0
			let now = CMClockGetTime(CMClockGetHostTimeClock())
            // 略
        }
        // 略
    }
    // 略
}
CMSampleBufferを送る
CMSampleBufferを送るGameAppを作成します
アプリターゲットの作成
- File → New → TargetのGameを使ってGameAppを作成します
 Game TechnologyはSceneKitを選びます
    
- Signing&Capabilitiesを開き、AppSandboxのCameraにチェックを入れます
   
- 適当にmethod swizzlingして最新のMTLTextureを取得し、CMSampleBufferに変換します
 ※ CMSampleBufferがあればよいので、必ずしもこの方法である必要はないです
 method swizzlingのコードはこちら
 CMSampleBufferに変換するコードはこちら
CMSimpleQueueの取得
CMSampleBufferを渡すために、CamExtCameraExtensionのCMSimpleQueueを取得します
ここからはC API、、、
- AVCaptureDeviceの一覧から名前がSampleCapture (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の一覧を取得します
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と合致しているものを探します
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を取得します
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します
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の入力
- 作成したCMSampleBufferをenqueueします
        let time = CMClockGetTime(CMClockGetHostTimeClock())
        guard let buffer: CMSampleBuffer = sbf.make(mtlTexture: texture, time: time) else { return }
        
        CMSimpleQueueEnqueue(streamQueue, element: Unmanaged.passRetained(buffer).toOpaque())
動作確認
- 適当にアプリターゲットをビルドします
- System Extensionが動作するために、/Applications にいる必要があるので、~/Library/Developer/Xcode/DerivedData/あたりからビルド済みのCamExtApp.appを/Applicationsにコピペします
- CamExtApp.appを起動します
- Activateを押下すると、アラートが表示されるので、システム設定を開き、許可します
- 適当なカメラを起動し、SampleCapture (Swift)を選択します
- XcodeからGameAppを起動します
- カメラにGameAppの映像が映っていれば成功です
   
まとめ
WWDCでもsinkの方はあまり触れられておらず、諸々かなり難しいなぁという感想でした
DAL PluginのサポートはmacOS 13までらしいので、Appleは移行を推し進めたい気持ちが強いのかなと感じました
なのであれば、もう少しApple公式のドキュメントがあっても悪くないのかななどと思いました