5
4

More than 1 year has passed since last update.

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

Posted at

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の映像をリアルタイムに流し込む仮想カメラを作成してみました

実装したコード

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のテンプレートのアプリターゲットを作成します
  2. Signing&Capabilitiesを開き、TeamとBundle Identifierを適当に設定します
  3. +ボタンからSystem ExtensionApp Groupsを追加します
  4. App Groupsには$(TeamIdentifierPrefix)com.example.CamExtAppを設定します
    $(TeamIdentifierPrefix)は自動で反映されます
    スクリーンショット 2023-05-09 0.38.30.png

Camera Extensionの追加

  1. File → New → TargetからCamera Extensionを選択し、CamExtCameraExtensionとして追加します
    image.png
  2. Signing&Capabilitiesを開き、TeamとBundle Identifierを適当に設定します
  3. App Groupsにはアプリターゲットと同じものを設定します
    スクリーンショット 2023-05-09 0.36.22.png

アプリの実装

  1. 適当に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()
    }
}

テンプレートの動作確認

  1. 適当にアプリターゲットをビルドします
  2. System Extensionが動作するために、appが/Applications下にある必要があるので、~/Library/Developer/Xcode/DerivedData/あたりからビルド済みのCamExtApp.appを/Applicationsにコピペします
  3. CamExtApp.appを起動します
  4. Activateを押下すると、アラートが表示されるので、システム設定を開き、許可します
    スクリーンショット 2023-05-09 0.59.28.png
  5. 適当なカメラを起動し、SampleCapture (Swift)を選択します
  6. 白い線が上下に移動していれば成功です
    スクリーンショット 2023-05-09 1.02.54.png

外部のアプリから映像を入れる

白い線が上下に移動する映像は、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を作成します

アプリターゲットの作成

  1. File → New → TargetのGameを使ってGameAppを作成します
    Game TechnologyはSceneKitを選びます
  2. Signing&Capabilitiesを開き、AppSandboxのCameraにチェックを入れます
    スクリーンショット 2023-05-09 1.41.49.png
  3. 適当に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の入力

  1. 作成した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())

動作確認

  1. 適当にアプリターゲットをビルドします
  2. System Extensionが動作するために、/Applications にいる必要があるので、~/Library/Developer/Xcode/DerivedData/あたりからビルド済みのCamExtApp.appを/Applicationsにコピペします
  3. CamExtApp.appを起動します
  4. Activateを押下すると、アラートが表示されるので、システム設定を開き、許可します
  5. 適当なカメラを起動し、SampleCapture (Swift)を選択します
  6. XcodeからGameAppを起動します
  7. カメラにGameAppの映像が映っていれば成功です
    スクリーンショット 2023-05-09 1.59.56.png

まとめ

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

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4