FAANSの動画投稿機能開発で直面した問題と解決方法

FAANSの動画投稿機能開発で直面した問題と解決方法

はじめに

こんにちは、ブランドソリューション開発本部FAANS部の加藤です。私の開発しているショップスタッフの販売サポートツールFAANSでは、この度、コーディネート動画の投稿機能が実装されました。動画の投稿機能は、動画のトリミングや音声の編集ができ、投稿された動画はアプリ上で閲覧できます。

この記事では、動画の投稿機能を開発する上で直面した問題と、その解決方法をお伝えします。

目次

動画の投稿機能の流れ

まず、動画の投稿機能の流れを以下の図で説明します。ユーザーは最初に投稿したい動画を選択して、選択した動画に対してトリミング範囲、動画に付与する音楽、音楽の再生範囲を決定します。その後、動画に関する情報(動画内のモデルが使用しているアイテムの情報・動画の説明など)を動画情報の入力画面で入力して、投稿ボタンを押すことで動画のエンコードとアップロードが行われます。動画の投稿後は、一覧画面から投稿された動画とその詳細情報を閲覧できます。

動画投稿の全体図

今回、これらの動画投稿の機能を実装する上で、以下の3つの問題に直面しました。

  • トリミング画面を1から作成する
  • トリミング区間でループ再生する
  • エンコードされた動画が再生できるまでにラグが発生する

トリミング画面を1から作成する

iOSでは動画のトリミング機能を実装する方法の1つとして、UIKitのUIImagePickerControllerを用いた実装方法が挙げられます。UIImagePickerControllerを用いると下記、画像左側のように画面上部のトリミングコントローラーでトリミング範囲を指定できます。一方で、FAANSのトリミング機能は、トリミング後の動画長を1分以下に収める制限があります。そのため、トリミング後の動画長が1分を超える場合には、即時、画面上にアラートを出すことが求められるのですが、UIImagePickerControllerでは実現できません。そこで、複数のViewを組み合わせて、画像右側のようなオリジナルのトリミング画面を作成しました。

UIImagePickerControllerとFAANSにおけるトリミング画面の比較図

FAANSのトリミングコントローラーは、以下の画像のような5種類のViewで作成されています。View1は動画の各時刻におけるサムネイル画像が並べられたViewです。また、View2はView1に重ねられており、トリミング範囲外のView1に影をつけるためのViewです。ユーザーはView1を見ながら、どの部分をトリミング時刻にするかを伸縮可能なView3の両端をドラッグすることで指定します。具体的には、View3の両端に重ねられている透明なView5に触れており、ドラッグで動くView5の位置から動画のトリミング時刻を算出します。View2、3の内側は、View4を用いてくり抜かれており、下側のView1が見える状態になっています。次節では、トリミングコントローラのView1に配置するサムネイル画像を作成する方法と、View5の位置からトリミング後の動画の再生時刻を算出する方法を紹介します。

トリミング画面の構成

サムネイル画像の作成方法

本節では、トリミングコントローラー(View1)内に表示する動画の各時刻のサムネイル画像の作成方法について述べます。以下のようなプログラムでサムネイル画像を生成しました。

// トリミングビューに表示するサムネイルの数を計算する関数
private func thumbnailCount() -> Int {
    let thumbnailWidth: CGFloat = 30 // 各サムネイルの幅を固定値で設定
    return Int(trimmingViewWidth / thumbnailWidth) // トリミングコントローラーの幅に基づいてサムネイル数を計算
}

// 動画のサムネイルを生成し、トリミングコントローラーに追加する関数
private func setupThumbnails() {
    guard let videoURL = videoState?.path else { return }
    let asset = AVAsset(url: videoURL)
    let imageGenerator = AVAssetImageGenerator(asset: asset) // 動画の画像を生成するジェネレーターを設定
    imageGenerator.appliesPreferredTrackTransform = true

    let duration = CMTimeGetSeconds(asset.duration) // 動画の全体時間を取得
    let interval = duration / Double(thumbnailCount()) // サムネイル間の時間間隔を計算

    // サムネイルの数だけループ
    for i in 0..<thumbnailCount() {
        // 各サムネイルの生成時間を設定
        let cmTime = CMTime(seconds: interval * Double(i), preferredTimescale: 600)
        let timeValue = NSValue(time: cmTime)

        // timeValueを参照して、サムネイル画像を生成
        imageGenerator.generateCGImagesAsynchronously(forTimes: [timeValue]) { [weak self] _, cgImage, _, _, _ in
            guard let self = self else { return }
            if let cgImage = cgImage {
                DispatchQueue.main.async {
                    let imageView = UIImageView(image: UIImage(cgImage: cgImage))
                    // i番目のサムネイル画像の表示位置を設定
                    imageView.frame = CGRect(
                        x: CGFloat(i) * (self.trimmingViewWidth / CGFloat(self.thumbnailCount())),
                        y: 0,
                        width: self.trimmingViewWidth / CGFloat(self.thumbnailCount()),
                        height: self.trimmingViewHeight
                    )
                    self.trimmingView.addSubview(imageView)
                    self.trimmingView.sendSubviewToBack(imageView)
                }
            }
        }
    }
}

上記のプログラムでは、thumbnailCountでサムネイル画像の幅を定義して、何枚のサムネイル画像をトリミングコントローラー内に配置できるかを算出します。算出された値で動画内の時刻を等間隔で指定して、指定した時刻のサムネイル画像をAVAssetImageGeneratorのgenerateCGImagesAsynchronouslyで生成します。あとは、生成された画像をtrimmingView(土台となるView)上に配置して完成です。

トリミングコントローラからトリミング時刻の算出

本節では、トリミングコントローラーからトリミング時刻を算出する方法について述べます。画像のように、黄色のView(以下、trimmingRangeView)とサムネイル画像が設置されたView(以下、trimmingView)の境界を基準として、トリミング時刻を算出します。

トリミング範囲の選択例

トリミング時刻算出のプログラムは以下の通りです。

private let handleWidth: CGFloat = 17 // trimmingViewの両端の幅

// トリミング範囲の開始時刻と終了時刻を計算する関数
private func calculateTrimmedTimeRange() -> ClosedRange<Double>? {
    guard let videoURL = videoState?.path else { return nil }
    let asset = AVAsset(url: videoURL)
    let videoDuration = CMTimeGetSeconds(asset.duration)

    // trimmingViewの長さに対する時刻の算出基準位置の割合を算出
    let leftHandlePosition = (trimmingRangeView.frame.minX + handleWidth - trimmingView.frame.minX) / trimmingViewWidth
    let rightHandlePosition = (trimmingRangeView.frame.maxX - handleWidth - trimmingView.frame.minX) / trimmingViewWidth

    // 算出された割合×動画長でトリミング範囲後の時刻を算出する
    let trimmedStartTime = max(0.0, leftHandlePosition * videoDuration)
    let trimmedEndTime = min(rightHandlePosition * videoDuration, videoDuration)
    return trimmedStartTime...trimmedEndTime
}

トリミング時刻を算出するために、AVAssetを用いて動画長(d)を取得します。つぎに、trimmingRangeViewの時刻算出の基準点とtrimmingViewの長さの割合rを算出します。そして、d\times{r}を計算することでトリミング時刻を算出できます。これを左右の基準点で行い、トリミングの開始時刻と終了時刻を算出できます。以下にtrimmingRangeViewの位置に応じて、トリミング時刻を更新しているgifを示します。gifのように算出されたトリミング時刻が1分を超える場合には、アラートを出すようにすることで、トリミング後の動画長を制限するFAANSオリジナルのトリミング画面を作成できました。

trimmingRangeViewに応じたトリミング時刻の更新

以上がトリミング画面を1から実装する方法の紹介です。

動画のループ再生方法

FAANSには2種類の動画再生が存在します。指定したトリミング区間に基づいてループ再生する場合(以下、エンコード前)と、動画の初めから終わりまでをループ再生する場合(以下、エンコード後)です。まず、比較的シンプルなエンコード後の動画の再生方法について述べます。プログラムは以下の通りです。

    // 動画ファイルのURLから、AVPlayerを使用してプレイヤーを初期化
    let player = AVPlayer(playerItem: .init(url: videoURL))
    player.play() // 動画の再生

    // 以下は表示のロジック
    let playerLayer = AVPlayerLayer()
    playerLayer.player = player
    playerLayer.videoGravity = .resizeAspect
    playerLayer.frame = bounds
    layer.addSublayer(playerLayer)

    // 動画の再生終了を監視するオブザーバーを追加
    NotificationCenter.default.addObserver(self, selector: #selector(playerDidFinishPlaying(_:)), name: .AVPlayerItemDidPlayToEndTime, object: player.currentItem)

    // 動画再生終了時に呼び出されるメソッド
    @objc private func playerDidFinishPlaying(_ notification: Notification) {
        guard
            let playerItem = notification.object as? AVPlayerItem,
            playerItem == playerLayer.player?.currentItem
        else {
            return
        }
        // 再生位置を最初に戻す
        playerLayer.player?.seek(to: .zero) { [weak self] _ in
            self?.playerLayer.player?.play()
        }
    }

上記のプログラムでは、オブザーバー(.AVPlayerItemDidPlayToEnd)で動画の終了を監視しており、動画の終了時にplayerDidFinishPlayingが呼び出されます。playerDidFinishPlayingが呼び出されたとき、動画が初期の状態、すなわち0:00に戻されます。このプログラムでエンコード後の動画であれば、ループ再生できます。

一方でエンコード前の動画は、指定されたトリミング区間に基づいて、映像の途中でループする必要があるため、AVPlayerItemDidPlayToEndは利用できません。そこで、エンコード前の動画のループ再生を以下のように実装しました。

    // 動画ファイルのURLから、AVPlayerを使用してプレイヤーを初期化
    let player = AVPlayer(playerItem: .init(url: videoURL))
    player.play() // 動画の再生

    // AVPlayerの動画を表示するためのAVPlayerLayerを設定
    let playerLayer = AVPlayerLayer()
    playerLayer.player = player
    playerLayer.videoGravity = .resizeAspect
    playerLayer.frame = bounds
    layer.addSublayer(playerLayer)

    // 再生中の時刻を定期的に監視するためのオブザーバーを設定
    var timeObserverToken: Any?
    let timeInterval = CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) // 0.01秒間隔で監視
    timeObserverToken = player.addPeriodicTimeObserver(forInterval: timeInterval, queue: .main) { [weak self] _ in
        guard let self = self, let currentItem = playerLayer.player?.currentItem else { return }
        let currentItemTimeSeconds = CMTimeGetSeconds(currentItem.currentTime()) // 再生時間の取得
        // プレイヤーが再生可能な状態(readyToPlay)である場合に処理を続行
        if currentItem.status == .readyToPlay {
            playbackTimeDidChange(currentTime: currentItemTimeSeconds)
        }
    }

    // 再生時刻が変更された時に呼び出される関数
    func playbackTimeDidChange(currentTime: Double) {
        guard let range = videoState.value?.video.timeRange else { return } // トリミング区間が代入されている変数
        let startTime = range.lowerBound // トリミング区間の開始時刻
        let endTime = range.upperBound // トリミング区間の終了時刻

        if currentTime >= endTime {
            // トリミング区間の終了時刻を再生時刻が超えたときにトリミング区間の開始時刻に戻す
            player.seek(to: startTime, toleranceBefore: .zero, toleranceAfter: .zero) { [weak player, weak self] completion in
                guard completion else { return }
                player?.play() // シーク後に再生を再開
            }
        }
    }

上記のプログラムでは、AVPlayerの再生時刻をオブザーバー(addPeriodicTimeObserver)を用いて0.01秒間隔で監視しています。AVPlayerの再生時刻がトリミング区間の終了時刻を超えた場合、AVPlayerの再生時刻をトリミング区間の開始時刻にシークすることで、トリミング区間におけるループ再生を実現しています。

エンコードされた動画が再生できるまでにラグが発生する問題への対処法

FAANSでは、エンコードした動画をアップロードした際に、エンコードされた動画のURLが発行されます。発行されたURLを参照して動画を再生しますが、S3に動画がコピーされるまでの間、動画を再生できません。そのため、動画が再生できるようになるまでの間はインジケータを表示して、再生可能になった時点でインジケータを非表示にして動画を表示する必要があります。下記の図は動画が再生できないケースの模式図です。本節では、動画が再生可能かどうかの監視方法について述べます。プログラムは以下の通りです。

動画が再生できないケースの模式図

private func configureVideoPlayer() {
    guard let videoURL = self.videoURL else { return }
    self.videoPlayer = AVPlayer(playerItem: .init(url: videoURL))

    self.cancellable?.cancel() // 前回の監視がある場合はキャンセル(メモリリークを防止)
    // AVPlayerItemのステータスを監視
    self.cancellable = videoPlayer?.currentItem?.publisher(for: \.status)
        .sink { [weak self] status in
            self?.handleStatus(status: status) // ステータスに応じて処理を実行
        }
}

private func handleStatus(status: AVPlayerItem.Status) {
    switch status {
    case .readyToPlay: // 動画が再生可能な状態になった場合
        self.isLoadingVideo.value = false // インジケータを非表示に設定
    case .failed: // 再生に失敗した場合
        // エラーがファイルの未存在である場合にリトライ処理を実行
        guard let error = videoPlayer?.currentItem?.error as NSError?, error.code == NSURLErrorFileDoesNotExist else {
            self.isLoadingVideo.value = false
            return
        }
        // 2秒後に再試行を行う(非同期で再生設定を再実行)
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
            guard let self = self else { return }
            self.configureVideoPlayer()
        }
    default:
        break
    }
}

このプログラムでは、AVPlayerItemのステータスをCombineのpublisherを用いて監視し、ステータスが変化した際に対応します。ステータスがreadyToPlayの場合には再生可能であるため、インジケータを非表示にします。また、ステータスがfailedでエラーコードがNSURLErrorFileDoesNotExistの場合は、動画をS3にコピーしている最中と判断し、インジケータを表示したまま2秒後に再試行します。それ以外のエラーの場合には、S3のコピー以外のエラーとして処理を中断します。この実装で、S3への動画コピーが完了して再生可能になるまでインジケータを表示できます。

まとめ

今回は、FAANSにおける動画投稿に関する機能の実装方法について紹介しました。トリミング機能の実装方法の紹介では、トリミング画面を1から作る方法を解説し、サムネイル画像を作成する方法やトリミングコントローラーからトリミング時刻を算出する方法について述べました。また、動画をループ再生する方法と、エンコードされた動画が再生できるまでにラグが発生する問題の対処法についても紹介しました。この記事が同じような問題に遭遇した方や、これから動画に関する機能を開発しようとしている方の参考になれば幸いです。

さいごに

ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com

カテゴリー