[iOS] AVFoundation(AVCaptureVideoDataOutput/AVCaptureAudioDataOutput)でVine風の継ぎ足し撮影アプリを作ってみた

ios

1 はじめに

Vine (ヴァイン)とは Twitter に最大6秒の短い動画を撮影・投稿できる Twitter 公式アプリです。 (1月17日でサービス終了のアナウンスがあります)

このアプリでは、動画を細切れに撮影し、最終的に連結して1つの動画を作成できます。細切れで撮影できるがゆえ、非常にユニークな動画が多数公開され、人気となっていました。

本記事は、AVFoundationを使用して、Vineのように「細切れで撮影した映像を1つの動画ファイルとして保存」する方法を紹介するものです。

AVFoundationを使用する場合の共通的な処理については、下記に纏めましたので、是非御覧ください。
[iOS] AVFundationを使用して、「ビデオ録画」や「連写カメラ」や「QRコードリーダー」や「バーコードリーダー」を作ってみた

2 入出力

「細切れで撮影した映像を1つの動画ファイルとして保存」する場合、AVCaptudeSessionの入力として、カメラ(今回は背面)とマイクを設定し、出力に、動画データであるAVCaptureVideoDataOutputと音声データであるAVCaptureAudioDataOutputを繋ぎます。 そして、この2つの出力をAVAssetWriterで動画ファイルとして保存します。

004

下記のコードは、AVCaptureSessionを生成して、上記のとおり入出力をセットしている例です。

// セッションのインスタンス生成
let captureSession = AVCaptureSession()

// 入力(背面カメラ)
let videoDevice = AVCaptureDevice.defaultDevice(withMediaType: AVMediaTypeVideo)
let videoInput = try! AVCaptureDeviceInput.init(device: videoDevice)
captureSession.addInput(videoInput)

// 入力(マイク)
let audioDevice = AVCaptureDevice.defaultDevice(withMediaType: AVMediaTypeAudio)
let audioInput = try! AVCaptureDeviceInput.init(device: audioDevice)
captureSession.addInput(audioInput);

// 出力(映像)
let videoDataOutput = AVCaptureVideoDataOutput()
captureSession.addOutput(videoDataOutput)

// 出力(音声)
let audioDataOutput = AVCaptureAudioDataOutput()
captureSession.addOutput(audioDataOutput)

3 AVCaptureVideoDataOutput/AVCaptureAudioDataOutputのデータ

AVCaptureVideoDataOutputでは、AVCaptureVideoDataOutputSampleBufferDelegateプロトコルを実装したクラスのcaptureOutput(_:didOutputMetadataObjects:from:)でフレーム毎のデータを受け取ることが出来ます。

また、AVCaptureAudioDataOutputでは、AVCaptureAudioDataOutputSampleBufferDelegateプロトコルを実装したクラスのcaptureOutput(_:didOutputMetadataObjects:from:)で音声のデータを逐次受け取れます。

両方共、データを受け取るメソッドが同じなので、デリゲートを同じクラスすると、両方のデータが一緒に入ってくることになります。

試験的に、両方のデータを同じ所で受け取るようにして、内容を確認してみます。

下記のコードでは、個々のデータのCMSampleTimingInfoを取り出し、PTS(presentationTimeStamp)から前回のデータとの時間差を表示しています。 また、Videoのデータに関しては、フレーム数のカウントも表示しています。

var lastVideo: Int64 = 0 // 一つ前の時間情報を保存する(Video用)
var lastAudio: Int64 = 0 // 一つ前の時間情報を保存する(Audio用)
var frameCounter = 0 // フレームのカウント

// 両方のデータを、このメソッドで受け取ることが出来る
func captureOutput(_ captureOutput: AVCaptureOutput!, didOutputSampleBuffer sampleBuffer: CMSampleBuffer!, from connection: AVCaptureConnection!) {
    // VideoとAudioのどちらのデータかを判断する
    let isVideo = captureOutput is AVCaptureVideoDataOutput

    // CMSampleTimingInfoを読み取る
    var count: CMItemCount = 1
    var info = CMSampleTimingInfo()
    CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, count, &info, &count);

    if isVideo {
        // 1つ前との時間差
        let offset = CGFloat(info.presentationTimeStamp.value - lastVideo)/CGFloat(info.presentationTimeStamp.timescale)
        print(String(format: "video offset:%.3fsec frame:%d", offset,frameCounter))
        // 今回の時間を保存
        lastVideo = info.presentationTimeStamp.value
        // フレーム数のカウント
        frameCounter += 1
    } else {
        // 1つ前との時間差
        let offset = CGFloat(info.presentationTimeStamp.value - lastAudio)/CGFloat(info.presentationTimeStamp.timescale)
        print(String(format: "audio offset:%.3fsec", offset))
        // 今回の時間を保存
        lastAudio = info.presentationTimeStamp.value
    }
}

結果は下記のとおりです。

audio offset:0.023sec // 開始直後はAudioデータのみが入ってくる
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
video offset:626023.019sec frame:0 // lastVideoが初期化されていないため、差分が計算できていない
audio offset:0.023sec
video offset:0.033sec frame:1 
audio offset:0.023sec
audio offset:0.023sec 
video offset:0.033sec frame:2 // 
audio offset:0.023sec         // VideoとAudioのデータは1対1ではない
audio offset:0.023sec         // 
video offset:0.033sec frame:3
audio offset:0.023sec
video offset:0.033sec frame:4
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
audio offset:0.023sec
video offset:0.200sec frame:5 // 正確に1/30毎にフレームが来ているわけでは無いようだ
audio offset:0.023sec
video offset:0.033sec frame:6
audio offset:0.023sec
video offset:0.033sec frame:7 
audio offset:0.023sec
audio offset:0.023sec
video offset:0.033sec frame:8
audio offset:0.023sec
video offset:0.033sec frame:9
audio offset:0.023sec
video offset:0.033sec frame:10
audio offset:0.023sec
audio offset:0.023sec

試験結果から次のような状況が読み取れると思います。

  • Videoのデータは、概ね0.033秒毎に来ている(フレームレートを1/30秒に設定しているため)
  • Videoのデータは、完全に等間隔とは言い切れない
  • Audioデータは、Videoデータより多くのデータが来ている
  • VideoデータとAudioデータの比は、一定ではない
  • 開始直後は、Audioデータのみが多数来ている(Videoデータの開始は遅れる)

この辺を踏まえて、このデータを扱う必要がありそうです。

4 AVAssetWriter

AVAssetWriterを使用すると、先のcaptureOutput(_:didOutputMetadataObjects:from:)で受け取れるデータであるCMSampleBufferから、各種のオーディオコンテナタイプ(QuickTimeやMPEG4など)のファイルを作成する事ができます。

下記のコードは、AVAssetWriterのインスタンスを生成し、Video用とAudio用の2つのAVAssetWriterInputを追加しているものです。

 // AVAssetWriter生成
let writer = try? AVAssetWriter(outputURL: URL(fileURLWithPath: filePath!), fileType: AVFileTypeQuickTimeMovie)

// Video入力
let videoOutputSettings: Dictionary<String, AnyObject> = [
    AVVideoCodecKey : AVVideoCodecH264 as AnyObject,
    AVVideoWidthKey : width as AnyObject,
    AVVideoHeightKey : height as AnyObject
];
let videoInput = AVAssetWriterInput(mediaType: AVMediaTypeVideo, outputSettings: videoOutputSettings)
videoInput.expectsMediaDataInRealTime = true
writer.add(videoInput)

// Audio入力
let audioOutputSettings: Dictionary<String, AnyObject> = [
    AVFormatIDKey : kAudioFormatMPEG4AAC as AnyObject,
    AVNumberOfChannelsKey : channels as AnyObject,
    AVSampleRateKey : samples as AnyObject,
    AVEncoderBitRateKey : 128000 as AnyObject
]
let audioInput = AVAssetWriterInput(mediaType: AVMediaTypeAudio, outputSettings: audioOutputSettings)
audioInput.expectsMediaDataInRealTime = true
writer.add(audioInput)

AVAssetWriterは、データ追加を始める前に、startWriting()で、ステータスを変更し、開始時間を設定します。

writer?.startWriting()
writer?.startSession(atSourceTime: kCMTimeZero) // 注:ここでは、開始時間を0で初期化しています

そして、該当するAVAssetWriterInputに対して、CMSampleBufferを追加するだけです。

if isVideo {
    if (videoInput?.isReadyForMoreMediaData)! {
        videoInput?.append(sampleBuffer!)
    }
}else{
    if (audioInput?.isReadyForMoreMediaData)! {
        audioInput?.append(sampleBuffer!)
    }
}

終了は、finishWriting(completionHandler:)をコールします。

writer.finishWriting(completionHandler: {
    // writer.outputURLが出来上がった動画ファイルです
})

なお、開始時間を設定する、startSession(atSourceTime:)は、必須ですが、終了時間をセットするendSession(atSourceTime:)は省略可能です。

AVAssetWriterの利用に関しては、詳しくは、サンプルの VideoWriter.swiftをご参照ください。

5 継ぎ足し

単純にデータを追加するだけであれば、startSession(atSourceTime:)に最初のデータの時間を設定して、後は、CMSampleBufferをどんどん追加するだけで、特に問題はありません。

001

しかし、今回のように、継ぎ足しで動画を撮影する場合、データに空白が生じます。 そして、一部のPTSが長く空いたりすると、その動画ファイルは再生が出来なくなってしまいます。

そこで、今回は、一時停止していた時間の分だけ、その後のPTSを修正し、連続した時間になるようにしています。 002

003

修正する時間は、オフセット時間として、一時停止するたびに増加させ、その分を引いて保存しています。

var copyBuffer : CMSampleBuffer?
// 取得したデータのCMSampleTimingInfoを取り出す
var count: CMItemCount = 1
var info = CMSampleTimingInfo()
CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, count, &info, &count) 
// PTSの調整(offSetTimeだけマイナスする)
info.presentationTimeStamp = CMTimeSubtract(info.presentationTimeStamp, offsetTime)
// PTSを調整したCMSampleTimingInfoで、新たにCMSampleBufferを生成する
CMSampleBufferCreateCopyWithNewTiming(kCFAllocatorDefault,sampleBuffer,1,&info,&copyBuffer)

また、計算しやすいように、開始時間も0にしてしまっています。

offsetTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) // 開始時間を0とするために、開始時間をoffSetに保存する
writer?.startWriting()
writer?.startSession(atSourceTime: kCMTimeZero) // 開始時間を0で初期化する

6 動作確認

サンプルのアプリを実行している様子です。

画面下部の録画ボタンを押している間だけ撮影が進行し、合計で6秒の撮影が完了したら、データを保存するかどうかのアラートが表示されます。

アラートで「はい」を選択すると、動画データはライブラリに保存されます。

7 最後に

今回は、AVCaptureVideoDataOutput及びAVCaptureAudioDataOutputのデータから動画ファイルを生成してみました。 この方法であれば、データにエフェクトを掛けたり、PTSを修正する事などが自由に行なえます。

色々、面白いものが作れそうな予感がします。

コードは下記に置いています。気になるところが有りましたら、ぜひ教えてやってください。
github [GitHub] https://github.com/furuya02/AVCaptureVideoDataOutputSample_Concatenation

8 参考資料


API Reference AVAssetWriter
API Reference AVCaptureVideoDataOutput
API Reference AVCaptureAudioDataOutput
[iOS] AVFundationを使用して、「ビデオ録画」や「連写カメラ」や「QRコードリーダー」や「バーコードリーダー」を作ってみた

AWS Cloud Roadshow 2017 福岡