Timers Tech Blog

グローバルな家族アプリFammを運営するTimers inc (タイマーズ) の公式Tech Blogです。弊社のエンジニアリングを支える記事を随時公開。エンジニア絶賛採用中!→ https://timers-inc.com/engineering

iPhone 12 のHDR動画に対応する

iOSエンジニアの桐山です。 頑張りすぎるMacbook Proがヒーターと化していて、あったかいです。

iPhoneからHDR動画をアップロードしようとした場合に、HDR動画再生未対応機種(iPhone 11以前のiPhoneAndroid端末)でも再生できるようにした話を書きます。

発生していた事象

  • iPhone 12 以降の端末でHDR動画をアップロードすると、動画が水色一色になってしまう f:id:timers-tech:20201203143236p:plain:w300

HDR動画とは

HDRとは、High Dynamic Range(ハイダイナミックレンジ)の略称で、従来のSDR(スタンダードダイナミックレンジ)に比べてより広い明るさの幅(ダイナミックレンジ)を表現できる表示技術です。 一般的なSDR映像では日陰が黒つぶれしたり日向が白飛びしたりしますが、HDR映像では明るい部分と暗い部分どちらの階調も犠牲にすることなく、より自然でリアルな描写が可能になります。

引用元 www.eizo.co.jp

iPhone 12 以降のモデルでは、HDR動画を撮影できるようになりました。

※以降、従来の動画についてはSDR動画と表記します

WWDC2020でのセッション

iPhone 12 以降での、HDR動画の再生、編集、エクスポートについての説明があります。

developer.apple.com

developer.apple.com

1つ目のセッションについてはサンプルコードがあり、こちらからダウンロードが可能です。

対応しなければならないこと

  • エクスポート処理のAVAssetExportSessionを生成する時に、AVAssetではなくAVCompositionを使用するように変更する
  • AVAssetExportSessionAVVideoCompositionを設定する
  • HDR動画をSDR動画としてエクスポートする設定を追加する

HDR動画をSDR動画としてエクスポートするコード

エクスポート処理

let (avComposition, videoComposition) = AssetLoader.loadAsCompositions(asset: urlAsset)
guard
    let assetExportSession = AVAssetExportSession(
        asset: avComposition, presetName: AVAssetExportPreset960x540
    )
else {
    return
}

assetExportSession.videoComposition = videoComposition
assetExportSession.outputFileType = .mp4
assetExportSession.outputURL = outputURL
assetExportSession.exportAsynchronously { [weak self] in
    guard let self = self else {
        return
    }

    switch assetExportSession.status {
    case .completed:
        // 成功時の処理
    case .failed:
        // 失敗時の処理
    case .exporting:
        // エクスポート中の処理
    default:
        break
    }
}

変換処理

// 音声データを設定

までのイメージ図。

f:id:timers-tech:20201203154037p:plain

Adobe After EffectsiMovieなどの動画編集ツールのタイムラインと同じようなイメージ。

final class AssetLoader {
    static func loadAsCompositions(asset: AVAsset) -> (AVComposition, AVVideoComposition) {
        // 動画および音声データを管理するオブジェクト
        let composition = AVMutableComposition()

        let assetVideoTrack = asset.tracks(withMediaType: .video).first!

        // 動画データを設定
        let mutableCompositionVideoTrack = composition.addMutableTrack(
            withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid
        )!
        try? mutableCompositionVideoTrack.insertTimeRange(
            CMTimeRange(start: .zero, duration: asset.duration),
            of: assetVideoTrack,
            at: .zero
        )
        // 音声データを設定
        let audioTracks = asset.tracks(withMediaType: .audio)
        if !audioTracks.isEmpty {
            let mutableCompositionAudioTrack = composition.addMutableTrack(
                withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid
            )!

            for audioTrack in audioTracks {
                try? mutableCompositionAudioTrack.insertTimeRange(
                    audioTrack.timeRange, of: audioTrack, at: audioTrack.timeRange.start
                )
            }
        }

        var assetTransform = assetVideoTrack.preferredTransform

        var videoSize = assetVideoTrack.naturalSize
        if isPortrait(transform: assetTransform) {
            videoSize = CGSize(
                width: assetVideoTrack.naturalSize.height,
                height: assetVideoTrack.naturalSize.width
            )
            assetTransform.tx = videoSize.width
        }

        let compositionVideoTrack = composition.tracks(withMediaType: .video).first!

        let layerInstruction = AVMutableVideoCompositionLayerInstruction(
            assetTrack: compositionVideoTrack
        )
        // AVAssetとレイヤーのアフィン変換を一致させないと、書き出した動画の描画位置がずれる
        layerInstruction.setTransform(assetTransform, at: .zero)

        let compositionInstruction = AVMutableVideoCompositionInstruction()
        compositionInstruction.timeRange = compositionVideoTrack.timeRange
        compositionInstruction.layerInstructions = [layerInstruction]

        let videoComposition = AVMutableVideoComposition()
        videoComposition.instructions = [compositionInstruction]
        // フレームレート設定
        videoComposition.frameDuration = CMTime(value: 1, timescale: 30)
        videoComposition.renderSize = videoSize

        // HDR動画をSDR動画として書き出すための設定
        videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2
        videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2
        videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2

        return (composition, videoComposition)
    }

    private static func isPortrait(transform: CGAffineTransform) -> Bool {
        return (transform.a == 0 && transform.b == 1.0 && transform.c == -1.0 && transform.d == 0)
            || (transform.a == 0 && transform.b == -1.0 && transform.c == 1.0 && transform.d == 0)
    }
}

2021/1/18追記

  • 音声データが存在していない動画でinsertTimeRangeをしてしまうとエクスポート時にエラーになるため、これを修正しました
  • AVMutableVideoCompositionrenderSize同様、AVMutableVideoCompositionLayerInstructiontransformについても縦向き動画の考慮を追加しました

追記ここまで

コードは記載しませんが、同様の方法で以下のような編集処理も可能になります。

f:id:timers-tech:20201203154749p:plain

感想

本事象に対応着手する前は、「Appleに公式のドキュメントがあるし、ほとんどコピペでいけるだろう😀」と思っていましたが、意図しない向きに動画が回転してしまったり😇、動画サイズが期待する描画サイズとずれてしまったり😇、結果的に丸1日かかってしまいました。
もともと、動画周りのコードは触ったことがなかったのですが、今回の対応を通して、AVFoundation周りの知見がかなり増えました💪
同様の事象で悩んでいるエンジニアの時間救済に役立てれば幸いです。

参考リンク

PR

子育て家族アプリFammを運営するTimers inc.では、現在エンジニアを積極採用中!
急成長中のサービスの技術の話を少しでも聞いてみたい方、スタートアップで働きたい方など、是非お気軽にご連絡ください!
採用HP: http://timers-inc.com/engineerings

Timersでは各職種を積極採用中!

急成長スタートアップで、最高のものづくりをしよう。

募集の詳細をみる