LoginSignup
3

【 Vonage 】Video API と ARKit でバーチャル配信アプリっぽいの作ろうとしてみる話

Last updated at Posted at 2023-12-23

AdventCalndarではWebRTC系の記事しか書いていませんが、またもやそれです。

今回作ろうとするもの

Group 19.png
こんな感じのありがちなやつを想定。
UIは一般的なライブ風だけど、背景は好きな画像で顔はアバターでいきます。
(※だけどめんどくさいのでチャット機能は無し。実質変な顔が動くのを配信するだけです)

  1. 配信をするアプリ
  2. 配信を見るアプリ
    これどちらも必要ですね。

そんで〜今回使うのは〜デデンッ

Vonage Video API

です。
例の如く中身はWebRTCですね。

とりあえずチュートリアルをやってみて、どういうものか把握していきましょうね。
チュートリアル通りにやれば一旦動くものは作れます。

まず準備

ここからサインアップしてプロジェクト作成。

スクリーンショット 2023-12-23 17.02.51.png

スクリーンショット 2023-12-23 17.02.41.png

以下の3つを取得しておきましょうね〜
・ApiKey: Projectごとに割り振られるキー
・SessionID: 多分トークルームにつき割り振られるID
・Token: ユーザごとのトークン。RoleはいくつかあるけどまぁPublisherを選んでおけば良いでしょう。

実装

やはりWebRTCなので
① サーバーに接続してsessionを確立
② 映像や音声をpublish
③ 他の人の音声や映像をsubscribe
この基本的な流れは変わらないようです。

ほんでチュートリアルやるとわかると思うけど、良くも悪くもすごーく簡単に作れるようになっているので、なんか勝手にカメラで撮影した映像がpublishされてなんか勝手に音声も送られて
映像や音声をカスタマイズするのが逆にむずいっていうあるあるパターンですね。

こんな時はサンプルコードをみましょうね。
Vonageはサンプルをたくさん用意してくれているので助かり

今回作る
配信する方のアプリは
サンプルの中のCustom-Video-Driverをベースに改造。

配信を見るアプリは
サンプルの中のBasic-Video-ChatのApiKeyとかを変えるだけで良いです。
視聴者はpublishする意味がないので、publishに関する処理を消した方がベターですが。

ということでやっていきましょう。最短距離で真っ直ぐに

ViewController.swift
class ViewController: UIViewController {
    // ARKitのSceneView。今回はこれをpublishして垂れ流す 
    @IBOutlet weak var sceneView: ARSCNView!

    // SceneView上に背景や顔のアバターを乗っけるためのNode達
    private let backgroundNode = SCNNode()
    private var virtualFaceNode = SCNNode()
    private var faceNode: SCNNode?

    // さっき作ったKey達
    let kApiKey = "XXXX"
    let kSessionId = "YYYY"
    let kToken = "ZZZZ"

    // ここからVonage関連
    lazy var session: OTSession = {
        delegate.delegate = self
        return OTSession(apiKey: kApiKey, sessionId: kSessionId, delegate: delegate)!
    }()

    // publisher。subscriberは今回はいらないので削除
    var publisher: OTPublisher?

    // こいつが今回一番重要。
    // 後述するCapture関連のもの。勝手にインスタンスが代入される
    var videoCaptureConsumer: OTVideoCaptureConsumer?
    // あんまり必要そうに見えないけどProtocolにより強制される
    var videoContentHint: OTVideoContentHint = .none

    // 今回とある理由で作らざるを得なくなったProtocolのWrapperクラス
    let delegate = SessionDelegate()

    override func viewDidLoad() {
        super.viewDidLoad()

        // こっからしばらくARKitの設定
        let config = ARFaceTrackingConfiguration()
        // 空間から光の情報を取得し画面上のライトの情報に適応
        config.isLightEstimationEnabled = true
        sceneView.delegate = self
        sceneView.session.delegate = self
        sceneView.session.run(config, options: [])

        // バーチャル背景のNode
        if let image = UIImage(named: "background") {
            let geometry = SCNPlane(width: image.size.width * image.scale / image.size.height,
                                    height: image.scale)
            geometry.firstMaterial?.diffuse.contents = image
            backgroundNode.geometry = geometry
            sceneView.scene.rootNode.addChildNode(backgroundNode)
        }
        // 顔に被せるマスクのNode
        if let device = sceneView.device,
            let maskGeometry = ARSCNFaceGeometry(device: device) {
            maskGeometry.firstMaterial?.diffuse.contents = UIImage(named: "mask")
            maskGeometry.firstMaterial?.lightingModel = .physicallyBased
            virtualFaceNode.geometry = maskGeometry
            sceneView.scene.rootNode.addChildNode(virtualFaceNode)
        }
        connect()
    }

    // ここら辺はサンプルコードの通り
    fileprivate func connect() {
        var error: OTError?
        defer {
            processError(error)
        }
        session.connect(withToken: kToken, error: &error)
    }
}

次にVonage系の処理

ViewController.swift
protocol SessionDelegateDelegate: AnyObject {
    func sessionDidConnect(_ session: OTSession)
    func session(_ session: OTSession, streamCreated stream: OTStream)
    func session(_ session: OTSession, streamDestroyed stream: OTStream)
}
// こいつは前述したWrapperクラス。OTSessionDelegateの中身を厳選
class SessionDelegate: NSObject, OTSessionDelegate {
    weak var delegate: SessionDelegateDelegate?
    func sessionDidConnect(_ session: OTSession) {
        print("Session connected")
        delegate?.sessionDidConnect(session)
    }
    
    func sessionDidDisconnect(_ session: OTSession) {
        print("Session disconnected")
    }
    
    func session(_ session: OTSession, streamCreated stream: OTStream) {
        print("Session streamCreated: \(stream.streamId)")
        delegate?.session(session, streamCreated: stream)
    }
    
    func session(_ session: OTSession, streamDestroyed stream: OTStream) {
        print("Session streamDestroyed: \(stream.streamId)")
        delegate?.session(session, streamCreated: stream)
    }

    func session(_ session: OTSession, didFailWithError error: OTError) {
        print("Session didFailWithError: \(error.localizedDescription)")
    }
}

extension ViewController: SessionDelegateDelegate {
    func sessionDidConnect(_ session: OTSession) {
        doPublish()
    }
    
    func session(_ session: OTSession, streamCreated stream: OTStream) {
        // 今回はsubscriveしないのでここら辺では何もしない
    }
    
    func session(_ session: OTSession, streamDestroyed stream: OTStream) {
        // 今回はsubscriveしないのでここら辺では何もしない
    }
}

extension ViewController {
    fileprivate func doPublish() {
        var error: OTError? = nil
        defer {
            processError(error)
        }
        let settings = OTPublisherSettings()
        settings.name = UIDevice.current.name
        
        publisher = OTPublisher(delegate: self, settings: settings)
        if let pub = publisher {
            pub.videoCapture = self
            session.publish(pub, error: &error)
        }
    }
    
    fileprivate func processError(_ error: OTError?) {
        if let err = error {
            showAlert(errorStr: err.localizedDescription)
        }
    }
    
    fileprivate func showAlert(errorStr err: String) {
        DispatchQueue.main.async {
            let controller = UIAlertController(title: "Error", message: err, preferredStyle: .alert)
            controller.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
            self.present(controller, animated: true, completion: nil)
        }
    }
}

extension ViewController: OTPublisherDelegate {
    func publisher(_ publisher: OTPublisherKit, streamCreated stream: OTStream) {
        print("Publishing")
    }
    
    func publisher(_ publisher: OTPublisherKit, streamDestroyed stream: OTStream) {
        if let subStream = subscriber?.stream, subStream.streamId == stream.streamId {
            
        }
    }
    
    func publisher(_ publisher: OTPublisherKit, didFailWithError error: OTError) {
        print("Publisher failed: \(error.localizedDescription)")
    }
    
}

extension ViewController: OTVideoCapture {
    func initCapture() {
        
    }
    
    func releaseCapture() {
        
    }
    
    func start() -> Int32 {
        0
    }
    
    func stop() -> Int32 {
        0
    }
    
    func isCaptureStarted() -> Bool {
        true
    }
    
    func captureSettings(_ videoFormat: OTVideoFormat) -> Int32 {
        videoFormat.pixelFormat = .NV12
        videoFormat.imageWidth = 1920
        videoFormat.imageHeight = 1080
        return 0
    }
}

最後にマスクに描画。バーチャル背景 + マスクが動くだけのSceneViewを作ってpublish

ViewController.swift
extension ViewController: ARSCNViewDelegate {
    func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
        guard let camera = sceneView.pointOfView else {
            return
        }
        // Cameraがいつどこでどの方向に向いていようと、背景Nodeと顔のNodeをカメラの中心に捉えるように移動させる
        let position = SCNVector3(0, 0, -0.3)
        backgroundNode.position = camera.convertPosition(position, to: nil)
        backgroundNode.eulerAngles = camera.eulerAngles
        virtualFaceNode.position = camera.convertPosition(position, to: nil)
        if let faceNode {
            virtualFaceNode.eulerAngles = faceNode.eulerAngles
        }
    }
    
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        // 顔を検知したらそのNodeを確保
        faceNode = node
    }
    
    func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
        guard let faceAnchor = anchor as? ARFaceAnchor,
              let geometry = virtualFaceNode.geometry as? ARSCNFaceGeometry else { return }
        // 顔のNodeが変化したら、それを反映
        geometry.update(from: faceAnchor.geometry)
    }
}

extension ViewController: ARSessionDelegate {
    // 撮影しているフレームがupdateされたら
    // sceneViewのsnapshotを撮ってimageBufferに変換してvideoCaptureConsumerで垂れ流す
    func session(_ session: ARSession, didUpdate frame: ARFrame) {
        guard let videoCaptureConsumer = videoCaptureConsumer,
              let cgImage = sceneView.snapshot().cgImage,
              let imageBuffer = pixelBuffer(forImage: cgImage)
            else {
                return
        }
        
        CVPixelBufferLockBaseAddress(imageBuffer, CVPixelBufferLockFlags(rawValue: CVOptionFlags(0)))
        videoCaptureConsumer.consumeImageBuffer(imageBuffer,
                                                orientation: .up,
                                                timestamp: CMTime(),
                                                metadata: OTVideoFrame(format: OTVideoFormat(nv12WithWidth: 1920, height: 1080)).metadata)
        
        CVPixelBufferUnlockBaseAddress(imageBuffer, CVPixelBufferLockFlags(rawValue: CVOptionFlags(0)))
    }
    
    func pixelBuffer (forImage image:CGImage) -> CVPixelBuffer? {
            
            
            let frameSize = CGSize(width: image.width, height: image.height)
            
            var pixelBuffer:CVPixelBuffer? = nil
            let status = CVPixelBufferCreate(kCFAllocatorDefault, Int(frameSize.width), Int(frameSize.height), kCVPixelFormatType_32BGRA , nil, &pixelBuffer)
            
            if status != kCVReturnSuccess {
                return nil
                
            }
            
            CVPixelBufferLockBaseAddress(pixelBuffer!, CVPixelBufferLockFlags.init(rawValue: 0))
            let data = CVPixelBufferGetBaseAddress(pixelBuffer!)
            let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
            let bitmapInfo = CGBitmapInfo(rawValue: CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue)
            let context = CGContext(data: data, width: Int(frameSize.width), height: Int(frameSize.height), bitsPerComponent: 8, bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer!), space: rgbColorSpace, bitmapInfo: bitmapInfo.rawValue)
            
            
            context?.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
            
            CVPixelBufferUnlockBaseAddress(pixelBuffer!, CVPixelBufferLockFlags(rawValue: 0))
            
            return pixelBuffer
            
        }
}

以上です。

hoge.gif

ポイント

Publisherを作成時に、OTVideoCaptureを指定してあげること。
そのOTVideoCaptureのvideoCaptureConsumerに任意のタイミングで配信したい画像をconsumeImageBufferしてあげるだけです。

終わりに

チュートリアルやったところで何もカスタマイズできないのはどのサービスも一緒ですが、
上記の通りサンプルがいくつもあったのでそこは素晴らしいと思いました。
(この手のサービスはドキュメントが英語なことが多く、Document読み漁るの結構辛いので)
あとは背景ぼかしのエフェクトが用意されていたりした点が良かったですね。

Protocolの関数がSwif ARKitの関数名と被っていてかなり面倒だったので、そこら辺解消してもらえると嬉しいです。
今回は映像のみをカスタマイズしましたが、声の編集機能だったりもちろんチャット送信系の機能も備わっているので、興味がある人は使ってみてください。

おしまい。

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
3