AdventCalndarではWebRTC系の記事しか書いていませんが、またもやそれです。
今回作ろうとするもの
こんな感じのありがちなやつを想定。
UIは一般的なライブ風だけど、背景は好きな画像で顔はアバターでいきます。
(※だけどめんどくさいのでチャット機能は無し。実質変な顔が動くのを配信するだけです)
- 配信をするアプリ
- 配信を見るアプリ
これどちらも必要ですね。
そんで〜今回使うのは〜デデンッ
Vonage Video API
です。
例の如く中身はWebRTCですね。
とりあえずチュートリアルをやってみて、どういうものか把握していきましょうね。
チュートリアル通りにやれば一旦動くものは作れます。
まず準備
ここからサインアップしてプロジェクト作成。
以下の3つを取得しておきましょうね〜
・ApiKey: Projectごとに割り振られるキー
・SessionID: 多分トークルームにつき割り振られるID
・Token: ユーザごとのトークン。RoleはいくつかあるけどまぁPublisherを選んでおけば良いでしょう。
実装
やはりWebRTCなので
① サーバーに接続してsessionを確立
② 映像や音声をpublish
③ 他の人の音声や映像をsubscribe
この基本的な流れは変わらないようです。
ほんでチュートリアルやるとわかると思うけど、良くも悪くもすごーく簡単に作れるようになっているので、なんか勝手にカメラで撮影した映像がpublishされてなんか勝手に音声も送られて
映像や音声をカスタマイズするのが逆にむずいっていうあるあるパターンですね。
こんな時はサンプルコードをみましょうね。
Vonageはサンプルをたくさん用意してくれているので助かり
今回作る
配信する方のアプリは
サンプルの中のCustom-Video-Driverをベースに改造。
配信を見るアプリは
サンプルの中のBasic-Video-ChatのApiKeyとかを変えるだけで良いです。
視聴者はpublishする意味がないので、publishに関する処理を消した方がベターですが。
ということでやっていきましょう。最短距離で真っ直ぐに
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系の処理
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
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
}
}
以上です。
ポイント
Publisherを作成時に、OTVideoCaptureを指定してあげること。
そのOTVideoCaptureのvideoCaptureConsumerに任意のタイミングで配信したい画像をconsumeImageBufferしてあげるだけです。
終わりに
チュートリアルやったところで何もカスタマイズできないのはどのサービスも一緒ですが、
上記の通りサンプルがいくつもあったのでそこは素晴らしいと思いました。
(この手のサービスはドキュメントが英語なことが多く、Document読み漁るの結構辛いので)
あとは背景ぼかしのエフェクトが用意されていたりした点が良かったですね。
Protocolの関数がSwif ARKitの関数名と被っていてかなり面倒だったので、そこら辺解消してもらえると嬉しいです。
今回は映像のみをカスタマイズしましたが、声の編集機能だったりもちろんチャット送信系の機能も備わっているので、興味がある人は使ってみてください。
おしまい。