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公式のドキュメントがあっても悪くないのかななどと思いました