obsとお別れした話【いんちきヘッドレス用キャプチャアプリ自作】


# はじめに
- [前回](https://qiita.com/kei_tnk/items/6d028f4108e7d9690d8c)、いんちきヘッドレスのためのキャプチャソフトとして使ったobsは、動画配信用のアプリとしては高機能だと思うけど、hdmi信号を表示するだけのキャプチャソフトとしては過剰性能
- また、実際に使ってみると、obsを立ち上げた後、usbからのhdmi信号を一発で見つけてくれなくて、前回のソース設定のデバイスをいったん消して、再指定しないといけない。もしかしたら、デバイスのusbケーブルを同じ差し口に刺しっぱなしなら、次の日に立ち上げても、画面に映してくれるのかもしれないけどね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2733720/4f340d69-f0be-75ae-5d8b-d80d4a3dcee3.png)
- とにかく、使い勝手が良くないので、キャプチャ画面表示アプリを自作することにした。久しぶりにswiftUIの出番だ。なお、xcodeは使わず、playgroundsを使った。作ったアプリを簡単にmacにインストールできるし、Catalystを使ってコンパイルしてるから、多分、同じコードのままipadのplaygroundsで動かせるんじゃないかな？


# 接続されているデバイス(カメラ機能)の確認
- playgroundsでこんなプログラムを走らせてみると、「USB3 Video」「OBS Virtual Camera」「FaceTime HDカメラ」の三つが見つかった。
![スクリーンショット 2024-05-19 13.45.36.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2733720/ba67e925-17dc-bb5b-9979-71df4a36a959.jpeg)
- 使いたいのは「USB3 Video」。実装するときは、接続したい機器を選んだ後、データ永続化のためにUserDefaultsにデバイス名を入力させておけば良いかな？でも、swiftUIで書くならシャレオツにAppStorageで書くか。
- デバイス情報として、3つのbool値を見てみたけどよくわからん。
- isConnectedは発見されている以上、3つともtrueで当然だと思うし、isSuspendedとisInUseAnotherApplicationは3つともfalseだけど、playgroundsの画面の後ろに映っているとおり、マイAppアプリで使用中なのよね。他のアプリで『排他的に使用中』の時だけ、InUseAnotherなのかな？

# playgroundsでの動作
- アプリに「カメラ」機能を追加することを忘れずに。
![スクリーンショット 2024-05-21 0.48.06.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2733720/420a4cd8-4f09-2d11-e714-20f584ae5b2a.jpeg)
- 今回作るアプリは、スマホとかでカメラを立ち上げたときのプレビュー機能を利用する。プレビュー機能って、つまり、カメラに写った映像を垂れ流すだけの機能のことだけど、キャプチャしてる画面を写すだけならそれで十分。
- 実際の動作
![スクリーンショット 2024-05-21 0.24.00.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2733720/1947d7b9-7cbe-0163-2745-9680ce56d8a3.jpeg)
- playgroundsで作ったアプリは、簡単にmacにインストールできる。個人の趣味でアプリを作るなら、xcodeなど使わずに、playgroundsで十分。
![スクリーンショット 2024-05-21 1.00.00.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2733720/a6e05354-3754-4baa-aba1-a95528525c47.jpeg)



# 書いたコード（シンプル版）
- 接続するデバイス名を「USB3 Video」という名前で固定した。
- MyAppは何もいじってないので、ContentViewのみ載せる。
```swift

import SwiftUI
import AVFoundation

struct ContentView: View {
    
    //キャプチャセッション
    let cptSsn = AVCaptureSession()
    
    var body: some View {
        VStack{
            //キャプチャ画面本体
            PreviewViewStruct(cptSsn: cptSsn)
            
            //起動時
                .onAppear(perform: {
                startCapHDMISession()
                })
        }
    }//ここまでbody

    //接続されているデバイスを発見する
    func getDevCapHDMI()->(AVCaptureDevice?){
        //デバイスを発見するためのセッション
        let dscvSsn = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera, .external], mediaType: .video, position: .unspecified)
        
        //登録デバイス名（capHDMIName)と一致しているデバイスを探す
        for device in dscvSsn.devices {
            if device.localizedName == "USB3 Video" {
                return (device) 
            }
        }
    
        //登録デバイス名（capHDMIName)と一致してるデバイス名を発見できなかった場合
        return (nil)
    }

    
    
    //デバイスに接続してキャプチャセッションを開始する関数
    func startCapHDMISession() {
        //登録デバイス名（capHDMIName)と一致しているデバイスがない場合をガード
        guard let capDev = getDevCapHDMI() else {
            print("登録デバイスは接続されていません")
            return
        }
        
        //インプットデバイスとしてキャプチャセッションに追加可能かチェック
        guard let vdDvcIpt = try? AVCaptureDeviceInput(device: capDev),
              cptSsn.canAddInput(vdDvcIpt) 
        else {
            print("入力装置接続エラー")    
            return
        }
        
        //キャプチャセッションにインプットデバイス追加
        cptSsn.addInput(vdDvcIpt)
        
        //キャプチャセッション開始
        self.cptSsn.startRunning()

    }
}

//「protocol UIViewRepresentable : View」を使って、
// UIkitのUIView(class)をswiftUIのView(struct)に変換
struct PreviewViewStruct: UIViewRepresentable {
    typealias UIViewControllerType = PreviewViewClass //元となったUIViewの型
    let cptSsn: AVCaptureSession //
    func makeUIView(context: Context) ->  UIViewControllerType { //UIVieｗのインスタンス生成
        let prv = PreviewViewClass()
        prv.vdPrvLyr.session = cptSsn
        return prv
    }
    func updateUIView(_ uiViewController:  UIViewControllerType, context: Context) {
    }
}


//PreviewViewStructの本体となる「UIView」クラス
//キャプチャーのプレビューを表示する「AVCaptureVideoPreviewLayer」を利用
class PreviewViewClass: UIView {
    override class var layerClass: AnyClass {
        return AVCaptureVideoPreviewLayer.self
    }
    var vdPrvLyr: AVCaptureVideoPreviewLayer {
        let lyr = layer as! AVCaptureVideoPreviewLayer
        //セッションのinput設定で、ラズパイ映像を内蔵カメラ扱いして左右反転していたのを元に戻す作業
        lyr.videoGravity = AVLayerVideoGravity.resizeAspect
        lyr.setAffineTransform(CGAffineTransform(scaleX: -1, y: 1))
        return lyr
    }
}

```

# 書いたコード（接続デバイス選択機能付き）
- 他人にお見せするのに、デバイス名固定はいかんだろうと思って、選択機能を書き足したら、意外と分量が増えた。
- サブビューを作るのが面倒なので、「Alert」と「confirmationDialog」を利用した。


```swift
import SwiftUI
import AVFoundation

struct ContentView: View {
    
    //接続デバイス名登録用（Alert画面で登録）
    @AppStorage("capHDMI") var capHDMIName = ""
    @State private var isShowAlert: Bool = false
    @State private var nmInpTxt: String = ""
    
    @State private var isShowDialog: Bool = false   
    @State private var msg: String = ""
    
    //キャプチャセッション
    let cptSsn = AVCaptureSession()
    
    var body: some View {
        VStack{
            //キャプチャ画面本体
            PreviewViewStruct(cptSsn: cptSsn)
            
            //起動時
                .onAppear(perform: {
                    //接続デバイス名登録画面(Alert画面)のデバイス名初期値をAppStrageより取得
                    nmInpTxt = capHDMIName
                    //接続デバイス名登録画面表示
                    isShowAlert = true
                })
            
        }
        //接続デバイス名登録画面(Alert画面)
        .alert("(接続先)\n" + nmInpTxt, isPresented: $isShowAlert) {
            
            Button("接続") {
                //登録デバイス名を上書きしてキャプチャセッション開始
                capHDMIName = nmInpTxt
                if (getDevCapHDMI().2 == nil){
                    msg = "接続出来ませんでした"
                    isShowDialog = true
                }
                startCapHDMISession()
            }
            Button("変更"){
                msg = "接続先を変更します"
                isShowDialog = true                   
            }
            
            Button("アプリ終了") {
                exit(0) //アプリ終了コマンド
            }    
        } message: {
            //macに接続されているデバイス（カメラ扱い）を表示
            Text("現在、接続されているデバイスは、" + getDevCapHDMI().1)
        }
        
        //接続可能なデバイスの中から選択する
        .confirmationDialog(msg, isPresented: $isShowDialog, titleVisibility: .visible) {
            ForEach(getDevCapHDMI().0, id: \.self) { selection in
                Button(selection, action: {
                    nmInpTxt = selection
                    isShowAlert = true
                })
            }
            Button("キャンセル", role: .cancel, action: {
                isShowAlert = true
            })
        } message:{
            Text("下記一覧から選択してください")
        }
    }//ここまでbody
    
    //ディスカバーセッションを使って接続デバイス情報を取得してタプルで返す関数
    //要素0：macと接続している全デバイスの名前配列([String型])
    //要素1：macと接続している全デバイスの名前(String型) 要素0のreduce後
    //要素2：登録デバイス名（capHDMIName)と一致してるデバイス(AVCaptureDevice型)
    func getDevCapHDMI()->([String],String,AVCaptureDevice?){
        let dscvSsn = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera, .external], mediaType: .video, position: .unspecified)
        
        //macと接続している各デバイス名を改行(\n)で連結。
        let dvNms = dscvSsn.devices.map{$0.localizedName}
        let dvNmsRd = dvNms.reduce(""){$0 + $1 + "\n"}
        
        //登録デバイス名（capHDMIName)と一致しているデバイスを探す
        for device in dscvSsn.devices {
            if device.localizedName == capHDMIName {
                return (dvNms,dvNmsRd,device) 
            }
        }
        
        //登録デバイス名（capHDMIName)と一致してるデバイス名を発見できなかった場合
        return (dvNms,dvNmsRd,nil)
    }
    
    
    //デバイスに接続してキャプチャセッションを開始する関数
    func startCapHDMISession() {
        //登録デバイス名（capHDMIName)と一致しているデバイスがない場合をガード
        guard let capDev = getDevCapHDMI().2 else {
            print("登録デバイスは接続されていません")
            return
        }
        
        //インプットデバイスとしてキャプチャセッションに追加可能かチェック
        guard let vdDvcIpt = try? AVCaptureDeviceInput(device: capDev),
              cptSsn.canAddInput(vdDvcIpt) 
        else {
            print("入力装置接続エラー")    
            return
        }
        
        //キャプチャセッションにインプットデバイス追加
        cptSsn.addInput(vdDvcIpt)
        
        //キャプチャセッション開始
        self.cptSsn.startRunning()
    }
}

//「protocol UIViewRepresentable : View」を使って、
// UIkitのUIView(class)をswiftUIのView(struct)に変換
struct PreviewViewStruct: UIViewRepresentable {
    typealias UIViewControllerType = PreviewViewClass //元となったUIViewの型
    let cptSsn: AVCaptureSession //
    func makeUIView(context: Context) ->  UIViewControllerType { //UIVieｗのインスタンス生成
        let prv = PreviewViewClass()
        prv.vdPrvLyr.session = cptSsn
        return prv
    }
    func updateUIView(_ uiViewController:  UIViewControllerType, context: Context) {
    }
}


//PreviewViewStructの本体となる「UIView」クラス
//キャプチャーのプレビューを表示する「AVCaptureVideoPreviewLayer」を利用
class PreviewViewClass: UIView {
    override class var layerClass: AnyClass {
        return AVCaptureVideoPreviewLayer.self
    }
    var vdPrvLyr: AVCaptureVideoPreviewLayer {
        let lyr = layer as! AVCaptureVideoPreviewLayer
        //セッションのinput設定で、ラズパイ映像を内蔵カメラ扱いして左右反転していたのを元に戻す作業
        lyr.videoGravity = AVLayerVideoGravity.resizeAspect
        lyr.setAffineTransform(CGAffineTransform(scaleX: -1, y: 1))
        return lyr
    }
}

```

# 動作
- シンプル版は、デバイスをusb-c接続した後、アプリを起動するだけ。何も操作はない。
- 接続デバイス選択機能付き版は、起動すると、前回接続したデバイスが(接続先)に表示され、それに接続するときは、「接続」ボタンを押す。
(alertで作った画面)
![スクリーンショット 2024-05-21 1.19.06.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2733720/f1c16135-c9e2-83b7-4a06-28731ce4f298.jpeg)
- 上記画面で、「変更」ボタンを押すと、接続先を変更できる。
(confirmationDialogで作った画面)
![スクリーンショット 2024-05-21 1.17.26.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2733720/006fc6e3-e2f2-a321-1382-551c0b46639b.jpeg)

# おわりに
- AVFoundationを使うと、あっさりとキャプチャアプリが作れた。
- 過去に心拍数取得のためのアプリを作った際の、Bluetoothの手続きの煩雑さに比べれば、AVFoundationのセッション手続きは楽な方かな。
- このアプリによりobsよりも画面の動きがなめらかになったので、タッチパッドの違和感もかなり減少したように感じた。
- いんちきヘッドレスも多少は実用的になるのではないだろうか。