AVAssetExportSession
で動画の合成を行いたい場合、はじめにAVMutableVideoCompositionLayerInstructionが利用できるかを検討することでしょう。
このiOSが提供するAPIでは、以下を行うことができます。
- 透過率を指定した合成
-
CGAffineTransform
による変形 - 矩形領域を指定した切り抜き
これらのパラメータを時間により変化させることで、クロスフェードやスワイプのようなトランジションを表現することができます。
ただ、これだけではiMovieやFCPにあるような高度なトランジションを作ることは難しいでしょう。
カスタムトランジションを作るには?
幸いにもAVFoundationは、カスタムトランジションを実装できるよう設計されています。動画のエンコード・デコードはシステムに任せ、トランジションの実装に集中することができます。
- AVVideoCompositingに準拠したクラスに、動画フレームの合成手順を実装する
- AVVideoCompositionInstructionProtocolに準拠したクラスに、合成する動画のパラメータを実装する
- 実装したクラスをAVAssetExportSessionに渡し、レンダリングする
拙作のアプリふぉとむぐでは、この記事で紹介する方法を利用し、カスタムトランジションを用いた動画合成を実現しています。
動画フレームの合成の仕方
詳細は後述しますが、各動画フレームはCVPixelBuffer
で得られます。ここでは導入が比較的容易なCore Imageを利用する例を示します。
AVVideoCompositionInstructionProtocol
の実装クラス
AVVideoCompositionInstructionProtocol
でのパラメータ指定方法は、以下の2パターンが存在します。
- パススルー: 1つの動画を無加工でそのまま出力
- 複数ソースの合成: 複数の動画を加工・合成したものを出力
「パススルー」方式は、トランジションをかけない区間の動画に対して適用することでレンダリングの高速化が期待できますが、変形やクロッピングも行うことができません。うまく動画出力がされないことも多いため、期待した結果を得られない場合はこの後説明する「複数ソースの合成」の方法を試してみるのも良いでしょう。
AVMutableVideoCompositionLayerInstruction
のように、各入力動画にかける変形やクロッピングを指定するモデルを定義します。
struct CoreImageVideoCompositionLayer {
let trackID: CMPersistentTrackID
var transform: CGAffineTransform?
var cropRect: CGRect?
init(
trackID: CMPersistentTrackID,
transform: CGAffineTransform? = nil,
cropRect: CGRect? = nil
) {
self.trackID = trackID
self.transform = transform
self.cropRect = cropRect
}
}
トランジション以外の、入力動画をシンプルに出力するインストラクションを実装します。
class CoreImageVideoCompositionInstruction: NSObject, AVVideoCompositionInstructionProtocol {
let passthroughTrackID: CMPersistentTrackID = kCMPersistentTrackID_Invalid // 上記理由により使用しない
var requiredSourceTrackIDs: [NSValue]? {
return [self.sourceLayer.trackID] as [NSValue]
}
let containsTweening: Bool = true // transform, croppingをかけるためtrueを指定
let timeRange: CMTimeRange
let enablePostProcessing: Bool = false
/// レンダリング元の動画レイヤ。
let sourceLayer: CoreImageVideoCompositionLayer
init(sourceLayer: CoreImageVideoCompositionLayer, for timeRange: CMTimeRange) {
self.sourceLayer = sourceLayer
self.timeRange = timeRange
}
}
トランジションをかける区間のインストラクションを実装します。
class CoreImageTransitionVideoCompositionInstruction: NSObject, AVVideoCompositionInstructionProtocol {
let passthroughTrackID: CMPersistentTrackID = kCMPersistentTrackID_Invalid
var requiredSourceTrackIDs: [NSValue]? {
return [self.sourceLayer.trackID, self.destinationLayer.trackID] as [NSValue]
}
let containsTweening: Bool = true
let timeRange: CMTimeRange
let enablePostProcessing: Bool = false
/// 遷移元の動画レイヤ。
let sourceLayer: CoreImageVideoCompositionLayer
/// 遷移先の動画レイヤ。
let destinationLayer: CoreImageVideoCompositionLayer
/// トランジションフィルタ。
let transitionFilter: CoreImageTransitionFilter
init(
sourceLayer: CoreImageVideoCompositionLayer,
destinationLayer: CoreImageVideoCompositionLayer,
transitionFilter: CoreImageTransitionFilter,
for timeRange: CMTimeRange
) {
self.sourceLayer = sourceLayer
self.destinationLayer = destinationLayer
self.transitionFilter = transitionFilter
self.timeRange = timeRange
}
}
上記に登場するCoreImageTransitionFilter
は以下のようなプロトコルとして定義し、これに準拠することで様々なトランジションを実装することができます。
protocol CoreImageTransitionFilter: class {
/// トランジションフィルタを適用したCIImageを求める。
///
/// - Parameters:
/// - sourceImage: 遷移元の画像
/// - destinationImage: 遷移先の画像
/// - progress: 進行度(0〜1)
/// - request: ビデオコンポジションリクエスト
/// - Returns: フィルタ適用後の画像を返す
/// - Throws: パラメータ不備など、フィルタ適用できない場合にエラーを投げる
func transitionImage(
sourceImage: CIImage,
destinationImage: CIImage,
progress: CGFloat,
request: AVAsynchronousVideoCompositionRequest
) throws -> CIImage
}
/// クロスディゾルブ。
class CrossDissolveTransitionFilter: CoreImageTransitionFilter {
func transitionImage(sourceImage: CIImage, destinationImage: CIImage, progress: CGFloat, request: AVAsynchronousVideoCompositionRequest) throws -> CIImage {
let filter = CIFilter(
name: "CIDissolveTransition",
withInputParameters: [
kCIInputImageKey: sourceImage,
kCIInputTargetImageKey: destinationImage,
kCIInputTimeKey: progress
]
)!
return filter.outputImage!
}
}
AVVideoCompositing
の実装クラス
AVVideoCompositing
の実装クラスには、動画レンダリングの際にシステムが要求する様々なロジックを実装していきます。この部分に関しては、Apple公式サンプル AVCustomEdit を参考に実装していくといいでしょう。
class CoreImageVideoCompositor: NSObject, AVVideoCompositing {
private var context: CIContext = .init()
private var renderContext: AVVideoCompositionRenderContext!
private let renderingQueue: DispatchQueue
private let renderContextQueue: DispatchQueue
private var shouldCancelAllRequests: Bool = false
override init() {
self.renderingQueue = DispatchQueue(label: "com.example.app.renderingqueue")
self.renderContextQueue = DispatchQueue(label: "com.example.app.rendercontextqueue")
super.init()
}
...続く...
}
sourcePixelBufferAttributes
とrequiredPixelBufferAttributesForRenderContext
この2つのプロパティでは、入力動画フレームに対応するPixel Bufferと、合成フレームに用いるPixel Bufferの属性を指定します。
var sourcePixelBufferAttributes: [String: Any]? {
return [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
//kCVPixelBufferOpenGLESCompatibilityKey as String: true
kCVPixelBufferMetalCompatibilityKey as String: true
]
}
var requiredPixelBufferAttributesForRenderContext: [String: Any]? {
// 特に理由がなければ、sourcePixelBufferAttributesと同じでよい
}
renderContextChanged(_ newRenderContext: AVVideoCompositionRenderContext)
与えられた新しいレンダリングコンテキストを握っておきます。
func renderContextChanged(_ newRenderContext: AVVideoCompositionRenderContext) {
self.renderContextQueue.async {
self.renderContext = newRenderContext
}
}
cancelAllPendingVideoCompositionRequests()
このメソッドがコールされた場合、キャンセルされたリクエストを完了するまで、後述するstartRequest(_:)
でリクエスト処理をブロックする必要があります。
func cancelAllPendingVideoCompositionRequests() {
self.shouldCancelAllRequests = true
self.renderingQueue.async(flags: .barrier) {
self.shouldCancelAllRequests = false
}
}
startRequest(_ request: AVAsynchronousVideoCompositionRequest)
ここに、動画合成ロジックを実装していきます。
まずcancelAllPendingVideoCompositionRequests()
によるキャンセルリクエストがあればこれを完了させます。無い場合に合成されたPixel Bufferを生成し、これを返します。
func startRequest(_ request: AVAsynchronousVideoCompositionRequest) {
self.renderingQueue.async {
if self.shouldCancelAllRequests {
request.finishCancelledRequest()
} else {
// newRenderedPixelBuffer()で生成されたCVPixelBufferは、そのままだと全フレームのレンダリングが終わるまで解放されないため、
// 都度解放されるようautoreleasepoolで囲む
autoreleasepool {
let output = self.newRenderedPixelBuffer(for: request)
request.finish(withComposedVideoFrame: output)
}
}
}
}
実際の合成処理を行うself.newRenderedPixelBuffer(for:)
は以下のような感じになると思います。
private func newRenderedPixelBuffer(for request: AVAsynchronousVideoCompositionRequest) -> CVPixelBuffer {
let outputImage: CIImage = {
switch request.videoCompositionInstruction {
case let instruction as CoreImageVideoCompositionInstruction:
// トランジションなし
guard let sourceImage = instruction.sourceLayer.normalizedImage(for: request) else {
fatalError()
}
return sourceImage
case let instruction as CoreImageTransitionVideoCompositionInstruction:
// トランジションあり
guard
let sourceImage = instruction.sourceLayer.normalizedImage(for: request),
let destinationImage = instruction.destinationLayer.normalizedImage(for: request)
else {
fatalError()
}
guard let outputImage = try? instruction.transitionFilter.transitionImage(
sourceImage: sourceImage,
destinationImage: destinationImage,
progress: CGFloat(request.progressInComposition), // 0 ~ 1の値
request: request
) else {
fatalError()
}
return outputImage
default:
fatalError()
}
}()
// Render to output buffer
guard let outputPixelBuffer = self.renderContext.newPixelBuffer() else {
fatalError()
}
self.context.render(outputImage, to: outputPixelBuffer)
return outputPixelBuffer
}
private extension CoreImageVideoCompositionLayer {
/// cropping, transformを適用した画像を求める。
func normalizedImage(for request: AVAsynchronousVideoCompositionRequest) -> CIImage? {
guard let pixelBuffer = request.sourceFrame(byTrackID: self.trackID) else {
return nil
}
var image = CIImage(cvPixelBuffer: pixelBuffer)
if let cropRect = self.cropRect {
image = image.cropped(to: cropRect)
}
if let transform = self.transform {
image = image.transformed(by: transform)
}
return image
}
}
レンダリング
レンダリングは、AVMutableVideoComposition
において以下の2点を押さえておけば従来と同じです。
- プロパティ
customVideoCompositorClass
に、AVVideoCompositing
準拠の型を指定する
(この例ではCoreImageVideoCompositor
) - プロパティ
instructions
には、AVVideoCompositionInstructionProtocol
準拠の型から生成したインストラクションを入れる
(この例ではCoreImageTransitionVideoCompositionInstruction
)
let mainComposition = AVMutableVideoComposition()
mainComposition.customVideoCompositorClass = CoreImageVideoCompositor.self // (1)
mainComposition.instructions = instructions // (2)
mainComposition.frameDuration = CMTime(value: 1, timescale: 30) // 30fps
mainComposition.renderSize = CGSize(width: 1280, height: 720)
let audioMix = AVMutableAudioMix()
audioMix.inputParameters = audioParameters
guard let exportSession = AVAssetExportSession(
asset: mixerComposition,
presetName: AVAssetExportPresetHighestQuality
) else {
fatalError()
}
exportSession.outputURL = URL(fileURLWithPath: "/path/to/file")
exportSession.outputFileType = .mov
exportSession.videoComposition = mainComposition
exportSession.audioMix = audioMix
exportSession.shouldOptimizeForNetworkUse = true
exportSession.exportAsynchronously {
DispatchQueue.main.async {
switch exportSession.status {
case .completed:
// 成功
case .cancelled:
// キャンセルされた
case .failed:
// 失敗(exportSession.errorが返る)
default:
// 不明なエラー
}
}
}
注意点
AVAsset#preferredTransform
はそのまま利用できない
AVAssetExportSessionで多数の動画を合成するときにハマッたお話に書いた問題に加え、Core Imageはy座標の方向が逆になるため、これらを考慮した transform
を求めないと、動画フレームが視界に入ってきてくれません。