WEARに動画投稿を実装した際にハマった事とその解決策

OGP

はじめに

こんにちは。WEAR部iOSチームの坂倉です。先日、WEARにコーディネート動画の投稿機能を実装しました。

iOSで動画を扱うにはAVFoundationを使う必要がありますが、原因がわかりにくいエラーを引き起こすことが多々あり、実装になかなか苦労しました。

この記事では、動画投稿の開発中に起きた問題とその解決法をお伝えします。

WEARの動画投稿には以下の機能が存在します。

  • 動画を選択する
  • 動画をプレビューする
  • 動画に付与する音楽を選択する
  • 動画に付与する音楽の範囲を指定してトリミングする
  • 動画に関する情報を付与する
  • 動画と音楽をミックスしてエンコードする
  • 完成した動画を投稿する

WEARのコーディネート動画投稿フロー

これらを実装する中で、2つの問題に直面しました。

  • 特定の動画が原因不明のエラーでエンコードできない
  • 音楽の再生と合わせて一部の波形のみをアニメーション付きで着色する処理の設計

それぞれの解決方法を下記にてお伝えいたします。

特定の動画が原因不明のエラーでエンコードできない

動画といっても色々な種類があり、この動画のエンコードは問題ないが、あの動画はエラーが出てしまうといったことが多々ありました。

ここでは、その時の対処法について解説します。

ちなみに、WEARで使用しているエンコードのコードは以下の通りです(一部省略)。

エンコードコード(クリックで展開)

// 動画と音楽のURLからAVURLAssetを生成
let videoURLAsset: AVURLAsset = .init(url: videoPath)
let audioURLAsset: AVURLAsset = .init(url: audioPath)
guard let videoAssetTrack = videoURLAsset.tracks(withMediaType: .video).first,
      let audioAssetTrack = audioURLAsset.tracks(withMediaType: .audio).first
else {
    return
}
// AVURLAssetから動画+音楽を追加し、AVAssetExportSessionに必要なコンポジションを作成
let composition: AVMutableComposition = .init()

// 動画をAVMutableCompositionに追加
guard let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else {
    return
}
try? videoTrack.insertTimeRange(videoAssetTrack.timeRange, of: videoAssetTrack, at: .zero)

// 範囲を指定し音楽をAVMutableCompositionに追加(ここでは音楽の長さは動画の長さと同じにする)
guard let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else {
    return
}

let audioTimeRange: CMTimeRange = .init(start: .zero, end: videoTrack.timeRange.end)
try? audioTrack.insertTimeRange(audioTimeRange, of: audioAssetTrack, at: .zero)


// 動画を回転させるためにAVMutableVideoCompositionLayerInstructionを生成する
let videoCompositionLayerInstruction: AVMutableVideoCompositionLayerInstruction = .init(assetTrack: videoTrack)
let transform = makeTransform(with: videoTrack)
videoCompositionLayerInstruction.setTransform(transform, at: .zero)


// AVMutableVideoCompositionInstructionに動画の時間と回転情報を渡す
let videoCompositionInstruction: AVMutableVideoCompositionInstruction = .init()
videoCompositionInstruction.timeRange = videoTrack.timeRange
videoCompositionInstruction.layerInstructions = [videoCompositionLayerInstruction]


// 動画の解像度やframeDurationカラー情報をAVAssetExportSessionに渡すためのAVMutableVideoCompositionを生成
let videoComposition: AVMutableVideoComposition = .init()


// iOSの画面収録で撮った動画がiOS 14でエンコードできないで解説します
videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2
videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2
videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2


// 写真アプリでトリミングした動画がエンコード出来ないで解説します
let fps = max(videoAssetTrack.nominalFrameRate, 1.0)
videoComposition.frameDuration = CMTime(value: 1, timescale: CMTimeScale(fps))
videoComposition.renderSize = CGSize(width: 1080.0, height: 1920.0) // 解像度を指定
videoComposition.instructions = [videoCompositionInstruction]


// AVMutableCompositionとAVMutableVideoCompositionで動画+音楽ソース+解像度などの詳細を渡し、動画を生成
guard let assetExportSession: AVAssetExportSession = .init(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
    return
}
guard let documentPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first else {
    return
}
assetExportSession.videoComposition = videoComposition
// AVMutableVideoCompositionInstructionにも指定しているがここでも指定しないとエラーが出る
assetExportSession.timeRange = videoTrack.timeRange
assetExportSession.outputFileType = .mp4
let videoFilePath = "\(documentPath)/tmp.mp4"
assetExportSession.outputURL = URL(fileURLWithPath: videoFilePath)
assetExportSession.shouldOptimizeForNetworkUse = true
assetExportSession.exportAsynchronously {
    switch assetExportSession.status {
    case .completed:
        print(assetExportSession.outputURL!)
    @unknown default:
        return
    }
}

iOSの画面収録で撮った動画がiOS 14でエンコードできない

色々な動画のエンコードを試す中で、どうしてもエンコードできない動画がありました。それは、iOSの画面収録で撮影した動画です。

AVAssetExportSessionはエラーを出力してくれますが、エラーを見ても原因を突き止めるのが困難な内容でした。

Error Domain=AVFoundationErrorDomain Code=-11800 "操作を完了できませんでした" UserInfo={NSLocalizedFailureReason=原因不明のエラーが起きました(-12212), NSLocalizedDescription=操作を完了できませんでした, NSUnderlyingError=xxxx {Error Domain=NSOSStatusErrorDomain Code=-12212 "(null)"}}

そのため、エラーコードに注目しました。-12212 と表示されていたので調べたところkVTColorCorrectionPixelTransferFailedErrというエラーだとわかりました。

stackoverflow.com developer.apple.com

色に問題ありということで、問題の動画をQuickTime Playerのムービーインスペクタで確認しました。

すると、エンコードできる動画と比べ、画面収録で撮った動画はTransfer FunctionがsRGBとなっており、Appleの Setting Color Properties for a Specific Resolution に記述されている設定例に無い値になっていました。

エンコードできる動画とできない動画のムービーインスペクタ

そのため、「この動画はAVAssetExportSessionに対応していない」という仮説を立て、色指定を変更することでエンコード出来るか試してみました。

AVVideoCompositionは、動画の色空間情報を設定するためのプロパティを3つ持っています。

これらを、Appleの Setting Color Properties for a Specific Resolution に記述されている例を元に設定してみました。(10-bit wide gamut HDはAVVideoSettingsのドキュメントコメントに記述されています)。

なんと、OSのバージョンによって違いが出る結果になりました。

OS別エンコード確認表(○は可、×は不可)

設定無 HD SD wide gamut HD 10-bit
wide gamut HD
iOS 15 ○(HD化) ○(HD化) ○(HD化)
iOS 14 × × ×
iOS 13 ×

iOS 15は、どのパターンでもエンコードできました。iOS 13/14は、HD/wide gamut HD以外だとエンコードエラーになりました。

面白いのは、iOS 15の場合、設定なし/SD/10-bit wide gamut HDの場合、色空間がHDと同じになることがわかりました。この結果を見る限り、iOS 15は変換できなかった場合システム側でHDに色を揃えていそうですね。

この結果を見て、HD(Rec.709)が全ての対応OSでもエンコードできて一般的な規格である事から、WEARはHDの設定を使用する事にしました。

// For HD colorimetry, specify
let videoComposition: AVMutableVideoComposition = .init()
videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2
videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2
videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2

ちなみに、動画の色指定に関して丁寧に説明しているAppleのドキュメントがあるので一見の価値ありです。

developer.apple.com

写真アプリでトリミングした動画がエンコードできない

写真アプリでトリミングした時間が短い動画を指定するとエンコードエラーになる事象もありました。デバッグログを見ると以下のようなエラーが出ていました。

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[AVAssetExportSession setVideoComposition:] video composition must have a positive frameDuration'

CMTimeScaleは整数であることが求められますが、あまりに短い動画だとnominalFrameRateで得られるフレームレートは1を下回ることもあります。 なので、最低値を1.0にして対応しました。

let fps = max(videoTrack.nominalFrameRate, 1.0)
videoComposition.frameDuration = CMTime(value: 1, timescale: CMTimeScale(fps))

動画をmp4で出力する場合、mp3を使うと出力できない問題

Appleのドキュメントには記述が見つからなかったのですが、mp3の音楽ファイルを取り込んでmp4の形式で動画を出力する場合エラーになりました。

StackOverflowによると、movとcaf以外の形式ではmp3を使うことはできないようです。

stackoverflow.com

そのため、WEARではm4aのみを使いmp3は使わないようにしています。

AVMutableVideoCompositionLayerInstructionは、AVAssetTrackを使って生成するとエンコード出来ない場合がある

特定の動画で、なぜかエンコードできないことがありました。エラーの内容は以下の通り。

Error Domain=AVFoundationErrorDomain Code=-11841 "操作が停止しました" UserInfo={NSLocalizedFailureReason=ビデオを作成できませんでした。, NSLocalizedDescription=操作が停止しました, NSUnderlyingError=... {Error Domain=NSOSStatusErrorDomain Code=-17390 "(null)"}}

結論から言うと、AVMutableVideoCompositionLayerInstructionを生成するときに渡していたassetTrackに原因がありました。

🙅‍♀️な例

 guard let videoAssetTrack = videoURLAsset.tracks(withMediaType: .video).first else { return }
let videoCompositionLayerInstruction: AVMutableVideoCompositionLayerInstruction = .init(assetTrack: videoAssetTrack)

🙆‍♂️な例

guard let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { return }
let videoCompositionLayerInstruction: AVMutableVideoCompositionLayerInstruction = .init(assetTrack: videoTrack)

AVMutableVideoCompositionLayerInstructionのイニシャライザを見ると、assetTrackの型がAVAssetTrackだったので間違ってしまいましたが、AVMutableCompositionのaddMutableTrackのAVMutableCompositionTrackを指定するのが正でした(ちなみにAVMutableCompositionTrackの親の親はAVAssetTrack)。

気付きづらいのが、全ての動画がエンコードできないというわけではないということです。完全に原因を特定できてはいませんが、写真アプリ以外で編集した動画はこの現象が起こりやすい印象でした。

StackOverflowに対処法があったことで気づくことができましたが、これはなかなかの罠ですね。皆さんもご注意ください。

stackoverflow.com

音楽の再生と合わせて一部の波形のみをアニメーション付きで着色する処理の設計

WEARの動画投稿には、動画に付与する音楽をトリミングできる画面が存在します。

波形で枠内だけを着色する

この画面には、スクロールを止めたタイミングで音楽のループ再生に合わせて枠内(UIScrollViewのスクロール領域のみ)の波形をアニメーション付きで着色する実装が求められました。

開発当初はなかなか上手い実装方法が思い浮かびませんでした。

「音楽のループ再生とアニメーションをどう同期させるか?」「波形画像の一部だけを着色するにはどんな方法でやるのがシンプルか?」「スクロールを邪魔せずどう実装するか?」などを一つ一つ考慮した結果、AVQueuePlayer、AVPlayerLooper、UIGraphicsImageRenderer、UIViewのmask、CAKeyframeAnimationを組み合わせることで実装できました。

踏んだ手順は以下の通りです。

  1. AVQueuePlayer+AVPlayerLooperでループ再生を実装
  2. UIScrollViewに波形の画像を入れる
  3. スクロールが止まったタイミングで音楽を再生しスクロール量を元に枠内の波形を切り出す
  4. 切り出した波形の画像をアニメーション用のViewのmaskに追加
  5. 音楽再生のタイミングでCAKeyframeAnimationを使って波形をアニメーション付きで塗る

各項目を最小限のコードで説明します。

1. AVQueuePlayer+AVPlayerLooperでループ再生機能を実装する

まず、音楽を繰り返し再生する必要があるためAVQueuePlayer+AVPlayerLooperでループ再生を実装します(AVAudioEngineを使う方法もありますが今回は再生だけで良いのでAVQueuePlayerを使いました)。

また、音楽の再生が完了するたびに着色を初めからやり直したいため、NSKeyValueObservationを使ってAVPlayerLooperのloopCountの状態を監視しています。

final class AudioPlayer {
    private let asset: AVAsset
    private let playerItem: AVPlayerItem
    private let player: AVQueuePlayer
    private var playerLooper: AVPlayerLooper?
    private var playerLooperObservation: NSKeyValueObservation?

    init(withAudioFilePath audioFilePath: URL) {
        asset = .init(url: audioFilePath)
        playerItem = .init(asset: asset)
        player = AVQueuePlayer(items: [playerItem])
    }

    // rangeは音楽の再生範囲(秒)
    func play(range: ClosedRange<Double>, completion: @escaping (()) -> Void) {
        player.removeAllItems() // 再生範囲を変えるにはAVPlayerLooperを作り直す必要があるためリセット。これがないとクラッシュする。
        playerLooper = AVPlayerLooper(
            player: player,
            templateItem: playerItem,
            timeRange: CMTimeRange(range: range, timescale: asset.duration.timescale)
        )
        player.play()
        playerLooperObservation = playerLooper?.observe(\.loopCount, options: [.new]) { playerLooper, _ in
            guard playerLooper.loopCount > 0 else { return }
            completion(())
        }
    }
}

extension CMTimeRange {
    init(range: ClosedRange<Double>, timeScale: CMTimeScale) {
        let start: CMTime = .init(seconds: range.lowerBound, preferredTimescale: timeScale)
        let end: CMTime = .init(seconds: range.upperBound, preferredTimescale: timeScale)
        self = .init(start: start, end: end)
    }
}

2. UIScrollViewに波形の画像を入れる

次に、音楽の波形画像をUIScrollViewに追加します。

UIScrollViewに入れた波形は、UIScrollViewのframeから出ても描画したいので、scrollView.clipsToBounds = false にします。

UIScrollViewの図

final class AudioRangeView: UIView {
    @IBOutlet private var scrollView: UIScrollView!
    
    override func awakeFromNib() {
        super.awakeFromNib()
      
        guard let waveFormImageView: UIImageView = .init(image: waveFormImage) else { return } 
        scrollView.addSubview(waveFormImageView)
        scrollView.contentSize = CGSize(
            width: waveFormImageView.bounds.size.width,
            height: waveFormImageView.bounds.size.height
        )
        scrollView.clipsToBounds = false // スクロールバーのframe以外のcontentViewを描写する
    }
}

3. スクロールが止まったタイミングでスクロール量を元に枠内の波形を切り出す

次は、枠内の波形のみを着色するためUIScrollViewの枠内に入っている波形画像を切り出します。

波形の着色は、スクロールが止まった時に行うため、UIScrollViewDelegateのscrollViewDidEndDeceleratingとscrollViewDidEndDragging上で行います。

スクロールが止まったタイミングで着色する

scrollView.contentOffset.x(スクロール量)を使って枠内の波形のポジションを取得しUIGraphicsImageRendererで切り出します。

final class AudioRangeView: UIView {
    // 以下略
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        let cropImage: UIImage = UIGraphicsImageRenderer(size: scrollView.bounds.size).image { context in
            waveFormImageView.image?.draw(at: CGPoint(x: -scrollView.contentOffset.x, y: .zero))
        }
        animationView.startAnimation(image: image, animationDuration: 10.0)
    }
}

4. 切り出した波形の画像をアニメーション用のViewのmaskに追加

次に、UIScrollViewの上に同サイズのアニメーション用のViewを置きます。

このViewに先ほど切り出した波形画像を渡し、これを着色することで、UIScrollViewの中に収まった波形だけが塗られていくように見せます。

波形画像を着色するにはmaskを利用します。maskに切り出した波形画像を入れます。

final class WaveFormColoringAnimationView: UIView {
    @IBOutlet private var liquidView: UIView!

    func startAnimation(image: UIImage, animationDuration: Double) {
        mask = UIImageView(image: image)
    }
}

そして、着色用のViewをアニメーション用のViewの一番上に貼ります。

アニメーション用のViewのxib

このViewには波形に着色したい色をアニメーションを開始するタイミングでbackgroundColorに指定します。

func startAnimation(image: UIImage, animationDuration: Double) {
    mask = UIImageView(image: image)
    liquidView.backgroundColor = .blue
}

次に、CAKeyframeAnimationを用いて切り出した波形を左から右に着色します。

durationには音楽の再生時間を指定します。こうすることで再生と共にアニメーションが進むようになります。

final class WaveFormColoringAnimationView: UIView {
    private var liquidView: UIView!
    
    func startAnimation(image: UIImage, animationDuration: Double) {
        mask = UIImageView(image: image)
        liquidView.backgroundColor = .blue

        let animation: CAKeyframeAnimation = .init(keyPath: "position.x")
        animation.values = [-(liquidView.bounds.size.width * 0.5), liquidView.bounds.size.width * 0.5]
        animation.duration = CFTimeInterval(animationDuration)
        animation.isRemovedOnCompletion = false
        liquidView.layer.add(animation, forKey: "coloringAnimation")
    }
}

ループ再生が終わったタイミングでアニメーションを止めたいので、そのためのメソッドも用意しておきましょう。

final class WaveFormColoringAnimationView: UIView {
    // 以下略
    func stopWaveFormColoringAnimation() {
        liquidView.layer.removeAllAnimations()
        liquidView.backgroundColor = .clear
        mask = nil
    }
}

5. 再生と同時にアニメーションを実行して枠内の波形を着色する

あとはスクロールが止まったタイミングで、音楽の再生と着色処理を同時に実行すれば、ループ再生中、枠内の波形だけ着色します。

アニメーションの時間指定には、音楽の再生時間と同じ値を入れるのをお忘れなく。

final class AudioRangeView: UIView {
    private let audioPlayer: AudioPlayer
    @IBOutlet private var animationView: WaveFormColoringAnimationView!

    override init(frame: CGRect) {
        super.init(frame: frame)
        audioPlayer = AudioPlayer(url: audioFilePath) // 音楽のローカルパスを指定
    }

    // 以下略

    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        let cropImage: UIImage = UIGraphicsImageRenderer(size: scrollView.bounds.size).image { context in
            waveFormImageView.image?.draw(at: .init(x: -scrollView.contentOffset.x, y: .zero))
        }

        let playRange: ClosedRange<Double> = 0.0 ... 10.0
        let animationDuration = playRange.upperBound - playRange.lowerBound

        animationView.startAnimation(image: cropImage, animationDuration: animationDuration)
        audioPlayer.play(range: playRange, completion: { [weak self] in
            guard let self = self else { return }
            DispatchQueue.main.async {
                self.animationView.stopWaveFormColoringAnimation()
                self.animationView.startAnimation(image: cropImage, animationDuration: 10.0)
            }
        })
    }

    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        guard !decelerate else { return }
        // 同様の処理
    }
}

さいごに

AVFoudationは扱いづらい事もありますが、段々慣れてくると非常に楽しくなってきますね。この記事が、誰かの動画開発の一助になれば幸いです。

ぜひ、お気に入りのコーデ動画を投稿してくれると嬉しいです。よろしくお願いします。

WEARでは、今後もどんどん動画の開発を進めていきます。ご興味のある方は以下のリンクからぜひご応募してください!

hrmos.co

カテゴリー