この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
1 はじめに
Vine (ヴァイン)とは Twitter に最大6秒の短い動画を撮影・投稿できる Twitter 公式アプリです。 (1月17日でサービス終了のアナウンスがあります)
このアプリでは、動画を細切れに撮影し、最終的に連結して1つの動画を作成できます。細切れで撮影できるがゆえ、非常にユニークな動画が多数公開され、人気となっていました。
本記事は、AVFoundationを使用して、Vineのように「細切れで撮影した映像を1つの動画ファイルとして保存」する方法を紹介するものです。
AVFoundationを使用する場合の共通的な処理については、下記に纏めましたので、是非御覧ください。
[iOS] AVFundationを使用して、「ビデオ録画」や「連写カメラ」や「QRコードリーダー」や「バーコードリーダー」を作ってみた
2 入出力
「細切れで撮影した映像を1つの動画ファイルとして保存」する場合、AVCaptudeSessionの入力として、カメラ(今回は背面)とマイクを設定し、出力に、動画データであるAVCaptureVideoDataOutputと音声データであるAVCaptureAudioDataOutputを繋ぎます。 そして、この2つの出力をAVAssetWriterで動画ファイルとして保存します。
下記のコードは、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をどんどん追加するだけで、特に問題はありません。
しかし、今回のように、継ぎ足しで動画を撮影する場合、データに空白が生じます。 そして、一部のPTSが長く空いたりすると、その動画ファイルは再生が出来なくなってしまいます。
そこで、今回は、一時停止していた時間の分だけ、その後のPTSを修正し、連続した時間になるようにしています。
修正する時間は、オフセット時間として、一時停止するたびに増加させ、その分を引いて保存しています。
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,©Buffer)
また、計算しやすいように、開始時間も0にしてしまっています。
offsetTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) // 開始時間を0とするために、開始時間をoffSetに保存する
writer?.startWriting()
writer?.startSession(atSourceTime: kCMTimeZero) // 開始時間を0で初期化する
6 動作確認
サンプルのアプリを実行している様子です。
画面下部の録画ボタンを押している間だけ撮影が進行し、合計で6秒の撮影が完了したら、データを保存するかどうかのアラートが表示されます。
アラートで「はい」を選択すると、動画データはライブラリに保存されます。
7 最後に
今回は、AVCaptureVideoDataOutput及びAVCaptureAudioDataOutputのデータから動画ファイルを生成してみました。 この方法であれば、データにエフェクトを掛けたり、PTSを修正する事などが自由に行なえます。
色々、面白いものが作れそうな予感がします。
コードは下記に置いています。気になるところが有りましたら、ぜひ教えてやってください。
[GitHub] https://github.com/furuya02/AVCaptureVideoDataOutputSample_Concatenation
8 参考資料
API Reference AVAssetWriter
API Reference AVCaptureVideoDataOutput
API Reference AVCaptureAudioDataOutput
[iOS] AVFundationを使用して、「ビデオ録画」や「連写カメラ」や「QRコードリーダー」や「バーコードリーダー」を作ってみた