MILLEN BOX

音楽好きの組み込みソフトエンジニアによるプログラミング(主にiOSアプリ開発)の勉強の記録

AVPlayerItemVideoOutputを使ってムービーファイルから取得したPixelBufferからムービーの再生させてみた

iOSでの動画の再生方法には以下の3つがありますよね。

  1. UIWebViewを使う
  2. AVPlayerViewControllerを使う
  3. AVPlayerを使う

番号が大きくなるほど自由度は高くなりますが、難易度は増していきます。
しかしながら今回は上記の方法の何れでもない方法で再生します。AVPlayerを普通に使用するよりも、もう少し込み入った方法でムービーファイルを再生したくなったのです。
具体的には「再生する際に使用されるPixelBufferを実装者の意思で一回取り出して、そのPixelBufferを使って動画を再生する」というものです。
その際に少しAVPlayerの助けはお借りしますが。

以下のレポジトリのソースを参考にしまくってますので合わせてご覧ください。
(今回、ほぼほぼ以下のコピーなんですがXcode 9 + swift4で動かさねばならなかったので…)

github.com

どうやって動画ファイルからPixelBufferを取り出すか

今回ある理由(後日更新予定)で、どうしても動画ファイルからPixelBufferを取り出したかったんですが、そうするにはどうすればいいんだ?というのがはじめのハードルでした。どうやって調べればそこにたどり着くのかすら、初めはわかりませんでした(苦笑)
色々調べた結果、AVPlayerItemVideoOutput + AVPlayerItemOutputPullDelegate を使用すれば取り出せそうだなーということが分かりました。

##今回の動画再生の流れ
今回の動作再生の流れの理解は以下のような感じです(多分…)。

  1. (準備)動画再生時の画像を表示するクラス(親: UIView)をまず作成。これをSampleBufferDisplayLayerViewとする。
  2. (準備)SampleBufferDisplayLayerViewのプロパティにplayer(AVPlayer型)を追加する。
  3. (準備)SampleBufferDisplayLayerViewのプロパティにplayerItemVideoOutput(AVPlayerItemVideoOutput型)を追加する。
  4. (準備)SampleBufferDisplayLayerViewのlayerをvideoLayer(AVSampleBufferDisplayLayer型)に置き換える。
  5. ViewControllerで動画を含めたライブラリを呼び出してムービーを選択。→ファイルのURLを取得。
  6. 5で取得したURLからAssetを取得。そしてそのAssetからitem(AVPlayerItem型)を取得。
  7. SampleBufferDisplayLayerView(1参照)のplayerプロパティ(2参照)に5で取得したitemを追加(というか置き換えって言った方が正しい??)。
  8. SampleBufferDisplayLayerViewがitemが追加されたことを(予めaddしているObserverによって)検知したら、追加されたitemを取得しplayerItemVideoOutput(3参照)に追加。
  9. playerItemVideoOutputのPixelBufferの変化を検知するためにCADisplayLinkとかrequestNotificationOfMediaDataChangeとかsetDelegateとかをごにょごにょ設定を前もってしておく。そのためにはAVPlayerItemOutputPullDelegateをSampleBufferDisplayLayerViewに追加しとく必要がある。
  10. 9で設定したもの達が判断した適切なタイミングでvideoLayerにPixelBufferを格納する。→Viewで表示されている内容が更新される。

SampleBufferDisplayLayerViewクラスのコード

今回の動画再生の心臓部、SampleBufferDisplayLayerViewのコードは以下です。

import UIKit
import AVFoundation

class SampleBufferDisplayLayerView: UIView, AVPlayerItemOutputPullDelegate {
    
    override public class var layerClass: Swift.AnyClass {
        get {
            return AVSampleBufferDisplayLayer.self
        }
    }
    
    let player = AVPlayer()
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        player.addObserver(self, forKeyPath: "currentItem", options: [.new], context: nil)
        playerItemVideoOutput.setDelegate(self, queue: queue)
        playerItemVideoOutput.requestNotificationOfMediaDataChange(withAdvanceInterval: advancedInterval)
        displayLink = CADisplayLink(target: self, selector: #selector(displayLinkCallback(_:)))
        displayLink.preferredFramesPerSecond = 1 / 30
        displayLink.isPaused = true
        displayLink.add(to: RunLoop.current, forMode: RunLoopMode.defaultRunLoopMode) //タイマーを開始
        
    }
    
    // KVO
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        switch keyPath {
        case "currentItem"?:
            if let item = change![NSKeyValueChangeKey.newKey] as? AVPlayerItem {
                item.add(playerItemVideoOutput)
                videoLayer.controlTimebase = item.timebase
            }
        default:
            //super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
            break
        }
    }
    
    // MARK: AVPlayerItemOutputPullDelegate
    func outputMediaDataWillChange(_ sender: AVPlayerItemOutput) {
        print("outputMediaDataWillChange")
        displayLink.isPaused = false
    }
    
    func outputSequenceWasFlushed(_ output: AVPlayerItemOutput) {
        videoLayer.controlTimebase = player.currentItem?.timebase
        videoLayer.flush()
    }
    
    //private
    private let playerItemVideoOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: [kCVPixelBufferPixelFormatTypeKey as String : kCVPixelFormatType_32ARGB])  // ピクセルフォーマット(32bit BGRA)
    private let queue = DispatchQueue.main
    private let advancedInterval: TimeInterval = 0.1
    private var displayLink: CADisplayLink!
    private var lastTimestamp: CFTimeInterval = 0
    private var videoInfo: CMVideoFormatDescription?
    
    private var videoLayer: AVSampleBufferDisplayLayer {
        return self.layer as! AVSampleBufferDisplayLayer
    }
    
    /**
   setCADiplayLinkSettingに呼び出されるselector。
   */
    @objc private func displayLinkCallback(_ displayLink: CADisplayLink) {
        
        let nextOutputHostTime = displayLink.timestamp + displayLink.duration * CFTimeInterval(displayLink.preferredFramesPerSecond)
        let nextOutputItemTime = playerItemVideoOutput.itemTime(forHostTime: nextOutputHostTime)
        if playerItemVideoOutput.hasNewPixelBuffer(forItemTime: nextOutputItemTime) {
            lastTimestamp = displayLink.timestamp
            var presentationItemTime = kCMTimeZero
            let pixelBuffer = playerItemVideoOutput.copyPixelBuffer(forItemTime: nextOutputItemTime, itemTimeForDisplay: &presentationItemTime)
            displayPixelBuffer(pixelBuffer: pixelBuffer!, atTime: presentationItemTime)
        } else {
            if displayLink.timestamp - lastTimestamp > 0.5 {
                displayLink.isPaused = true
                playerItemVideoOutput.requestNotificationOfMediaDataChange(withAdvanceInterval: advancedInterval)
            }
        }
    }

    @objc private func displayPixelBuffer(pixelBuffer: CVPixelBuffer, atTime outputTime: CMTime) {
        
        var err: OSStatus = noErr
        
        if videoInfo == nil {
            err = CMVideoFormatDescriptionCreateForImageBuffer(nil, pixelBuffer, &videoInfo)

            if (err != noErr) {
                print("Error at CMVideoFormatDescriptionCreateForImageBuffer \(err)")
            }
            
        }
        
        var sampleTimingInfo = CMSampleTimingInfo(duration: kCMTimeInvalid, presentationTimeStamp: outputTime, decodeTimeStamp: kCMTimeInvalid)
        
        var sampleBuffer: CMSampleBuffer?
        err = CMSampleBufferCreateForImageBuffer(nil, pixelBuffer, true, nil, nil, videoInfo!, &sampleTimingInfo, &sampleBuffer)
        if (err != noErr) {
            NSLog("Error at CMSampleBufferCreateForImageBuffer \(err)")
        }
        
        if videoLayer.isReadyForMoreMediaData {
            videoLayer.enqueue(sampleBuffer!)
        }

        sampleBuffer = nil
    }
}

Github

今回作成したプロジェクトは以下にあげてます。
ViewControllerについても、ファイルの再生が終わったことを認識する部分などにクセがあるので気をつけて見てみてください(今回は説明を割愛致します)。

github.com