動画作成の基本

AVfoundation周りでswift4用のコードがなかなか見つからなかったので、整理しておきます。
以下のコードで、自分で用意した動画を再エンコードできます。

このコードが基本になって、

  • 動画と動画をくっつけたり、
  • 音だけを差し替えたり、
  • 文字や画像を動画の上に載せたり、
  • 文字や画像をアニメーションさせたり、

といったことができるようになります。

何もせず再エンコードするだけのコード

Sample.swift
import UIKit
import AVKit
import AVFoundation
import Photos

class Sample: UIViewController {
    var _assetExport: AVAssetExportSession!

    //動画のマージ処理をするメソッド
    func mergeMovie() {                
        //元の動画のURLを取得
        let baseMovieURL = self.getBaseMovieURL()

        //アセットの作成
        //動画のアセットとトラックを作成
        var videoAsset: AVURLAsset
        var videoTrack: AVAssetTrack
        var audioTrack: AVAssetTrack

        videoAsset = AVURLAsset(url: baseMovieURL, options:nil)
        let videoTracks = videoAsset.tracks(withMediaType: AVMediaType.video)
        videoTrack = videoTracks[0]   //トラックの取得
        let audioTracks = videoAsset.tracks(withMediaType: AVMediaType.audio)
        audioTrack = audioTracks[0]   //トラックの取得

        //コンポジション作成
        let mixComposition : AVMutableComposition = AVMutableComposition()
        // ベースとなる動画のコンポジション作成
        let compositionVideoTrack: AVMutableCompositionTrack! = mixComposition.addMutableTrack(withMediaType: AVMediaType.video, preferredTrackID: kCMPersistentTrackID_Invalid)
        // ベースとなる音声のコンポジション作成
        let compositionAudioTrack: AVMutableCompositionTrack! = mixComposition.addMutableTrack(withMediaType: AVMediaType.audio, preferredTrackID: kCMPersistentTrackID_Invalid)

        // コンポジションの設定
        // 動画の長さ設定
        try! compositionVideoTrack.insertTimeRange(CMTimeRangeMake(kCMTimeZero, videoAsset.duration), of: videoTrack, at: kCMTimeZero)        
        // 音声の長さ設定
        try! compositionAudioTrack.insertTimeRange(CMTimeRangeMake(kCMTimeZero, videoAsset.duration), of: audioTrack, at: kCMTimeZero)
        // 回転方向の設定
        compositionVideoTrack.preferredTransform = videoAsset.tracks(withMediaType: AVMediaType.video)[0].preferredTransform

        // 動画のサイズを取得
        let videoSize: CGSize = videoTrack.naturalSize

        // 合成用コンポジション作成
        let videoComp: AVMutableVideoComposition = AVMutableVideoComposition()
        videoComp.renderSize = videoSize
        videoComp.frameDuration = CMTimeMake(1, 30)

        // インストラクションを合成用コンポジションに設定
        let instruction: AVMutableVideoCompositionInstruction = AVMutableVideoCompositionInstruction()
        instruction.timeRange = CMTimeRangeMake(kCMTimeZero, videoAsset.duration)
        let layerInstruction: AVMutableVideoCompositionLayerInstruction = AVMutableVideoCompositionLayerInstruction.init(assetTrack: compositionVideoTrack)
        instruction.layerInstructions = [layerInstruction]
        videoComp.instructions = [instruction]

        // 動画のコンポジションをベースにAVAssetExportを生成
        _assetExport = AVAssetExportSession.init(asset: mixComposition, presetName: AVAssetExportPresetHighestQuality)
        // 合成用コンポジションを設定
        _assetExport?.videoComposition = videoComp

        // エクスポートファイルの設定
        let exportPath: String = NSHomeDirectory() + "/tmp/createdMovie.mov"
        let exportUrl: URL = URL(fileURLWithPath: exportPath)
        _assetExport?.outputFileType = AVFileType.mov
        _assetExport?.outputURL = exportUrl
        _assetExport?.shouldOptimizeForNetworkUse = true

        // ファイルが存在している場合は削除
        if FileManager.default.fileExists(atPath: exportPath) {
            try! FileManager.default.removeItem(atPath: exportPath)
        }

        // エクスポート実行
        _assetExport?.exportAsynchronously(completionHandler: {() -> Void in
            if self._assetExport?.status == AVAssetExportSessionStatus.failed {
                // 失敗した場合
                print("failed:", self._assetExport?.error)
            }
            if self._assetExport?.status == AVAssetExportSessionStatus.completed {
                // 成功した場合
                print("completed")
                // カメラロールに保存
                PHPhotoLibrary.shared().performChanges({
                    PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: exportUrl)
                })
            }
        })
    }

    //元の動画の取得
    func getBaseMovieURL() -> URL {
        // プロジェクト入れたファイルはこれで取得可能
        let baseMovieURL:URL = Bundle.main.bundleURL.appendingPathComponent("basemovie.mov")
        return baseMovieURL
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        mergeMovie()
    }


}

解説

最低限のコードがこれになります。どれも省けません。結構長いです。しかもエラー処理は省いてしまってます。

ハマりポイント

自分がハマったポイントはコレ↓

let layerInstruction: AVMutableVideoCompositionLayerInstruction =
AVMutableVideoCompositionLayerInstruction.init(assetTrack: compositionVideoTrack)

compositionVideoTrackをセットしないとうまくいかないので注意。他の記事などでvideoTrackをセットしているものがあるのですが、それでも上手くいってしまします。ですが、作成された動画を再度エンコードしようとするとfailedとなってしまいます。

画質設定

AVAssetExportPresetHighestQualityを変えれば画質は変えられます。AVAssetExportPresetLowQualityなどにすれば低画質に。

音がないファイルの場合

この場合、audioTrack = audioTracks[0] で落ちてしまいます。なので、

if audioTracks.count > 0 {
    audioTrack = audioTracks[0] 
    // ベースとなる音声のコンポジション作成
    let compositionAudioTrack: AVMutableCompositionTrack! = mixComposition.addMutableTrack(withMediaType: AVMediaType.audio, preferredTrackID: kCMPersistentTrackID_Invalid)
    // 音声の長さ設定
    try! compositionAudioTrack.insertTimeRange(CMTimeRangeMake(kCMTimeZero, videoAsset.duration), of: audioTrack, at: kCMTimeZero)
}

などとやって音声関係のところを上手く処理しましょう。

動画に動画を重ねてみる

動画を30フレームごとの静止画に切り出して、動画の上に重ねてみます。

Sample.swift
        // 動画のサイズを取得
        let videoSize: CGSize = videoTrack.naturalSize
// --- ここから追加 ---
        // キャプチャ画像レイヤの作成
        let movieLayer = self.makeMovieLayer(videoSize)

        // 親レイヤーを作成
        let parentLayer: CALayer = CALayer()
        let videoLayer: CALayer = CALayer()
        parentLayer.frame = CGRect(x: 0, y: 0, width: videoSize.width, height: videoSize.height)
        videoLayer.frame = CGRect(x: 0, y: 0, width: videoSize.width, height: videoSize.height)
        parentLayer.addSublayer(videoLayer)
        parentLayer.addSublayer(movieLayer) //キャプチャ画像レイヤを追加

        // 合成用コンポジション作成
        let videoComp: AVMutableVideoComposition = AVMutableVideoComposition()
        videoComp.renderSize = videoSize
        videoComp.frameDuration = CMTimeMake(1, 30)
        videoComp.animationTool = AVVideoCompositionCoreAnimationTool.init(postProcessingAsVideoLayer: videoLayer, in: parentLayer)
// 最後にanimationToolに設定
Sample.swift
    //動画を静止画で切り出すレイヤーの処理 元の動画が1080x1920前提
    func makeMovieLayer(_ videoSize: CGSize) -> CALayer {
        //親レイヤの作成
        let movieLayer: CALayer = CALayer()
        movieLayer.frame = CGRect(x: 100, y: 0, width: 607, height: 1080)
        movieLayer.opacity = 1.0
        movieLayer.masksToBounds = true

        //静止画のレイヤー作成
        let imageLayer: CALayer = CALayer()
        imageLayer.frame = CGRect(x: 0, y: 0, width: movieLayer.frame.width, height: movieLayer.frame.height)
        imageLayer.contentsGravity = kCAGravityResizeAspectFill

        // 元の動画を取得
        //マージする元動画のURLを取得
        let orgVideoURL = self.getOriginalVideoURL()
        //動画のアセットを作成
        var videoAsset: AVURLAsset
        videoAsset = AVURLAsset(url: orgVideoURL, options:nil)

        //元動画から静止画を抜き出す
        var gene : AVAssetImageGenerator
        gene = AVAssetImageGenerator(asset: videoAsset))
        gene.requestedTimeToleranceAfter = CMTimeMake(1,30)
        gene.requestedTimeToleranceBefore = CMTimeMake(1,30)
        gene[i].maximumSize = videoSize   //オリジナルサイズ

        //静止画アニメーション
        let animImg:CAKeyframeAnimation = CAKeyframeAnimation(keyPath: "contents")
        animImg.beginTime = 1.0
        animImg.duration = 3.0
        animImg.repeatCount = 1
        animImg.autoreverses = false
        animImg.isRemovedOnCompletion = false
        animImg.fillMode = kCAFillModeForwards
        animImg.calculationMode = kCAAnimationDiscrete
        var imgKeyTimes:Array<NSNumber> = []

        let frameCount = 90 // (3.0秒 x 30フレーム)
        for i in 0 ... frameCount {
            imgKeyTimes.append((Double(i)/Double(frameCount)) as NSNumber)
        }
        animImg.keyTimes = imgKeyTimes

        // 始めの3秒を90フレームに分割して静止画を取得
        var imgValue:Array<CGImage> = []
        for i in 0 ... frameCount {
            imgValue.append(try! gene.copyCGImage(at: CMTimeMultiplyByFloat64(CMTimeMake(3 ,1), Double(i)/Double(frameCount)), actualTime: nil))
        }
        animImg.values = imgValue
        imageLayer.add(animImg, forKey: nil)

        //サブレイヤに追加
        movieLayer.addSublayer(imageLayer)

        return movieLayer
    }

解説

キーフレームアニメーションで静止画を用意して、1/30のタイミングを設定してパラパラアニメのように変えています。

重ねる元となる動画も重ねる動画も1080x1920の前提で作っています。左側に重なるように作っています。
90フレームで1080×1920だとメモリをたくさん使うので注意です。パラメータは適宜調整。
※作成した静止画は適宜メモリから解放しましょう。といいつつ、メモリ解放が今のところ上手くいってないので確認中・・・。
getOriginalVideoURLは書いてないですが、getBaseMovieURLと同じようにやればいけます。

ハマりポイント

gene.requestedTimeToleranceAfter = CMTimeMake(1,30)
このあたりのコードを入れないと、30分の1フレームで静止画が撮ってこれないので要注意。
これを設定しないとcopyCGImageでフレームを指定しても指定したフレームで持ってこれません。

メモリの解放

※上手くいってないので、うまくいったら情報更新します。

movieLayer.sublayers.removeAll()
imageLayer.removeAllAnimations()
imgValue.removeAll()
animImg.values = nil

こんな感じでひとつづつ関連を外していって削除すればいいはずですが、_assetExportでエクスポートした後に解放しようとしてもどこかに残ってしまう模様。

その他

音だけ差し替え、文字を載せる、アニメーションさせるなど気が向いたらコード書いておきます。
参考にしたURLも書きたいのですが、またのちほど。