AVAssetExportSessionにおけるカスタムトランジションを用いた動画合成

`AVAssetExportSession`で動画の合成を行いたい場合、はじめに[AVMutableVideoCompositionLayerInstruction](https://developer.apple.com/documentation/avfoundation/avmutablevideocompositionlayerinstruction)が利用できるかを検討することでしょう。
このiOSが提供するAPIでは、以下を行うことができます。

* 透過率を指定した合成
* `CGAffineTransform`による変形
* 矩形領域を指定した切り抜き

これらのパラメータを時間により変化させることで、クロスフェードやスワイプのようなトランジションを表現することができます。

ただ、これだけではiMovieやFCPにあるような高度なトランジションを作ることは難しいでしょう。

## カスタムトランジションを作るには？

幸いにも[AVFoundation](https://developer.apple.com/documentation/avfoundation)は、カスタムトランジションを実装できるよう設計されています。動画のエンコード・デコードはシステムに任せ、トランジションの実装に集中することができます。

* [AVVideoCompositing](https://developer.apple.com/documentation/avfoundation/avvideocompositing)に準拠したクラスに、動画フレームの合成手順を実装する
* [AVVideoCompositionInstructionProtocol](https://developer.apple.com/documentation/avfoundation/avvideocompositioninstructionprotocol)に準拠したクラスに、合成する動画のパラメータを実装する
* 実装したクラスを[AVAssetExportSession](https://developer.apple.com/documentation/avfoundation/avassetexportsession)に渡し、レンダリングする

> 拙作のアプリ[ふぉとむぐ](https://itunes.apple.com/jp/app/ふぉとむぐ/id1251727875?mt=8)では、この記事で紹介する方法を利用し、カスタムトランジションを用いた動画合成を実現しています。

### 動画フレームの合成の仕方

詳細は後述しますが、各動画フレームは`CVPixelBuffer`で得られます。ここでは導入が比較的容易な[Core Image](https://developer.apple.com/documentation/coreimage)を利用する例を示します。

### `AVVideoCompositionInstructionProtocol`の実装クラス

`AVVideoCompositionInstructionProtocol`でのパラメータ指定方法は、以下の2パターンが存在します。

* パススルー: 1つの動画を無加工でそのまま出力
* 複数ソースの合成: 複数の動画を加工・合成したものを出力

> 「パススルー」方式は、トランジションをかけない区間の動画に対して適用することでレンダリングの高速化が期待できますが、変形やクロッピングも行うことができません。うまく動画出力がされないことも多いため、期待した結果を得られない場合はこの後説明する「複数ソースの合成」の方法を試してみるのも良いでしょう。

`AVMutableVideoCompositionLayerInstruction`のように、各入力動画にかける変形やクロッピングを指定するモデルを定義します。

```CoreImageVideoCompositionLayer.swift
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
    }
}
```

トランジション以外の、入力動画をシンプルに出力するインストラクションを実装します。

```CoreImageVideoCompositionInstruction.swift
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
    }
}
```

トランジションをかける区間のインストラクションを実装します。

```CoreImageTransitionVideoCompositionInstruction.swift
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`は以下のようなプロトコルとして定義し、これに準拠することで様々なトランジションを実装することができます。

```CoreImageTransitionFilter.swift
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](https://developer.apple.com/library/archive/samplecode/AVCustomEdit/Introduction/Intro.html) を参考に実装していくといいでしょう。

```CoreImageVideoCompositor.swift
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の属性を指定します。

```CoreImageVideoCompositor.swift
    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)`

与えられた新しいレンダリングコンテキストを握っておきます。

```CoreImageVideoCompositor.swift
    func renderContextChanged(_ newRenderContext: AVVideoCompositionRenderContext) {
        self.renderContextQueue.async {
            self.renderContext = newRenderContext
        }
    }
```

#### `cancelAllPendingVideoCompositionRequests()`

このメソッドがコールされた場合、キャンセルされたリクエストを完了するまで、後述する`startRequest(_:)`でリクエスト処理をブロックする必要があります。

```CoreImageVideoCompositor.swift
    func cancelAllPendingVideoCompositionRequests() {
        self.shouldCancelAllRequests = true
        self.renderingQueue.async(flags: .barrier) {
            self.shouldCancelAllRequests = false
        }
    }
```

#### `startRequest(_ request: AVAsynchronousVideoCompositionRequest)`

ここに、動画合成ロジックを実装していきます。
まず`cancelAllPendingVideoCompositionRequests()`によるキャンセルリクエストがあればこれを完了させます。無い場合に合成されたPixel Bufferを生成し、これを返します。

```CoreImageVideoCompositor.swift
    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:)`は以下のような感じになると思います。

```CoreImageVideoCompositor.swift
    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点を押さえておけば従来と同じです。

1. プロパティ`customVideoCompositorClass`に、`AVVideoCompositing`準拠の型を指定する
(この例では`CoreImageVideoCompositor `)
2. プロパティ`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で多数の動画を合成するときにハマッたお話](https://qiita.com/imk2o/items/9ee3a095c46951183830)に書いた問題に加え、Core Imageはy座標の方向が逆になるため、これらを考慮した `transform` を求めないと、動画フレームが視界に入ってきてくれません。