📹

iPhoneのカメラロールから動画を引っ張り出すのにハマったお話

2023/11/09に公開

こんにちは!アルダグラムでエンジニアをしている渡辺です

今日は iPhone のカメラロールから動画を サーバーにアップロードするために必要なデータを取得する方法を書いていけたらと思います。

もともと KANNA では React Native のライブラリである Expo を用いて写真や動画をサーバーにアップロードしていました。

しかし、 Expo を利用せずに独自で作ったモジュールを用いてアップロードしたい!という思いがあったので実際に実装しました。そこで得た知識をかなり軽くではありますがサンプル(SwiftUI で書きます)を用いて記事を書いていこうと思います!

ImagePicker を用いて動画を選択できるようにする

// ImagePicker.swift
import Photos
import PhotosUI
import SwiftUI

public struct ImagePicker: UIViewControllerRepresentable {
    public typealias UIViewControllerType = PHPickerViewController

    @Binding var assets: [PHAsset] // 選択した動画を PHAsset を配列で管理する
    let selectionLimit: Int // 選択できる動画の数
    let filter: PHPickerFilter // カメラロールで選択できるタイプ(今回は動画を選択したいので .videos がここには入ります)

		public init(
        assets: Binding<[PHAsset]>,
        selectionLimit: Int,
        filter: PHPickerFilter
    ) {
        _assets = assets
        self.selectionLimit = selectionLimit
        self.filter = filter
    }
    public func makeUIViewController(context: Context) -> PHPickerViewController {
        var configuration = PHPickerConfiguration(photoLibrary: .shared())
        configuration.selectionLimit = self.selectionLimit
        configuration.filter = self.filter
        configuration.preselectedAssetIdentifiers = assets.map { $0.localIdentifier } // ImagePicker を再度開いたときに選択済みにする
        let picker = PHPickerViewController(configuration: configuration)
        picker.delegate = context.coordinator
        return picker
    }
    public func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {
        
    }

    public func makeCoordinator() -> Coordinator {
        return ImagePicker.Coordinator(parent: self)
    }

    public class Coordinator: NSObject, PHPickerViewControllerDelegate, UINavigationControllerDelegate {
        
        var parent: MultiImagePicker
        
        init(parent: MultiImagePicker) {
            self.parent = parent
        }
        
        // 動画を選択終了時に実行される
        public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
            picker.dismiss(animated: true)
            var newAssets: [PHAsset] = []
            let assetIdentifiers = results.compactMap { $0.assetIdentifier}
             // 選択した動画を PHAsset で取得している
            let fetchResults = PHAsset.fetchAssets(withLocalIdentifiers: assetIdentifiers, options: nil)
            fetchResults.enumerateObjects({ asset, _, _ in
                newAssets.append(asset)
            })
            self.parent.assets = newAssets
        }
    }
}
  • ImagePicker を呼び出すときに3つ props を定義してあります

    1. assets: [PHAsset] 選択した動画を View に反映させたいので @Bindingを利用する

    2. selectionLimit: Int 選択できる制限の数値を指定する

    3. filter: PHPickerFilter カメラロールで選択できるタイプを指定する

      all メソッドを使って複数指定もできます。詳しくはドキュメントをご覧ください

      PHPickerFilter | Apple Developer Documentation

  • ImagePicker は動画だけではなくfilterで指定したタイプであれば利用できるようになっています

  • 選択終了後にpickerメソッドが実行されます。ここでは選択した動画から PHAsset を取得して assets に入れています

    これで選択後に View へ反映させることができます

ImagePicker を呼び出すための View を用意する

// VideoUploadView
import SwiftUI
import Photos

struct VideoUploadView: View {
    @State var assets: [PHAsset] = []
    @State var isPresented = false

    var body: some View {
        VStack {
            Button {
                isPresented = true
            } label: {
                Text("動画を選ぶ")
            }
        }
        .sheet(isPresented: $isPresented) {
            ImagePicker(assets: $assets, selectionLimit: 10, filter: .videos)
        }
    }
}
  • ImagePicker を表示するためにボタンを用意してタップされたら isPresented を true にして表示する
  • ImagePicker に selectionLimit10 を指定しているので10個まで動画を選択できます
  • ImagePicker に filter.videos を指定しているので動画のみ表示されます

選択した動画のサムネイル画像を表示する

// VideoUploadView.swift
import SwiftUI
import Photos

struct VideoUploadView: View {
    @State var assets: [PHAsset] = []
    @State var isPresented = false

    var body: some View {
        VStack {
            Button {
                isPresented = true
            } label: {
                Text("動画を選ぶ")
            }
            // 追加内容
            // ここから
            ForEach(assets, id: \.self) { asset in
                if let uiImage = convertToUIImage(asset: asset, imageSize: CGSize(width: 300, height: 300)) {
                    Image(uiImage: uiImage)
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(width: 300, height: 300)
                        .border(.clear, width: 1)
                        .cornerRadius(6)
                        .clipped()
												.overlay(alignment: .topTrailing) {
                            // 選択した動画を選択から取り除く
		                        Button {
		                            assets.removeAll(where: {$0 == asset})
		                        } label: {
		                            Image(systemName: "xmark")
		                        }
				                }
												.overlay(alignment: .bottomLeading) {
                            // 選択した動画の再生時間を表示する
		                        Text(generateDuration(timeInterval: asset.duration))
		                            .foregroundColor(.white)
		                            .padding(.leading, 8)
				                }
                }
            }
             // ここまで
        }
        .sheet(isPresented: $isPresented) {
            ImagePicker(assets: $assets, selectionLimit: 1, filter: .videos)
        }
    }

    // 追加内容
    // ここから
    // サイズを指定して PHAsset から UIImage に変換する
    public func convertToUIImage(asset: PHAsset, imageSize: CGSize) -> UIImage? {
        var uiImage: UIImage?
        PHImageManager().requestImage(
            for: asset,
            targetSize: imageSize,
            contentMode: PHImageContentMode.aspectFill,
            options: requestOptions
        ) { (image, info) in uiImage = image }
        return uiImage
    }

    // 動画の再生時間を TimeInterval から文字列に変換する
		func generateDuration(timeInterval: TimeInterval) -> String {
        let formatter = DateComponentsFormatter()
        formatter.unitsStyle = .positional
        formatter.allowedUnits = [.minute, .second]
        formatter.zeroFormattingBehavior = .pad
        return formatter.string(from: timeInterval) ?? ""
    }
    // ここまで
}
  • 選択された動画(PHAsset)を UIImage に変換してサムネイル画像を生成している(画像サイズはここでは適当に指定してます)
  • 表示しているサムネイル画像の右上に×ボタンを付けて選択解除ができるようにしている
  • 表示しているサムネイル画像の左下に動画の再生時間を表示している

選択した動画を Data 型に変換する

// VideoUploadViewModel.swift
import Photos

enum ViewState {
    case inital
    case isUploading
    case isSuccess
    case isError
}
class VideoUploadViewModel: ObservableObject {
    @Published var viewState = .inital

    func upload(assets: [PHAsset]) async {
        await MainActor.run {
            viewState = .isUploading
        }
        do {
            for asset in assets {
                // アップロードするのにData型が必要なのでPHAssetから動画のDataを取得
                let exportUrl = try await exportVideoPHAsset(asset: asset)
                let videoData = try Data(contentsOf: exportUrl)

                // サーバーへのアップロード処理は割愛します
            }
            await MainActor.run {
		            viewState = .isSuccess
		        }
        } catch {
            // 処理が失敗した場合の処理
						await MainActor.run {
		            viewState = .isError
		        }
        }
    }

    // PHAsset から tmp ディレクトリに動画ファイルをエクスポートする
    private func exportVideoPHAsset(asset: PHAsset) async throws -> URL {
        return try await withCheckedThrowingContinuation({
            (continuation: CheckedContinuation<URL, Error>) in
            let options = PHVideoRequestOptions()
            options.isNetworkAccessAllowed = true
            PHImageManager.default().requestAVAsset(forVideo: asset, options: options) { avAsset, _, _ in
                guard let avAsset = avAsset else { return continuation.resume(throwing: NSError(domain: "export error", code: -1, userInfo: nil)) }
                let exportSession = AVAssetExportSession(asset: avAsset, presetName: AVAssetExportPresetHighestQuality)

                let outputPath = NSTemporaryDirectory() + "\(UUID().uuidString).mov"
                let outputURL = URL(fileURLWithPath: outputPath)

                exportSession?.outputURL = outputURL
                exportSession?.outputFileType = .mov
                exportSession?.exportAsynchronously(completionHandler: {
                    switch exportSession?.status {
                    case .completed:
                        continuation.resume(returning: outputURL)
                    case .failed, .cancelled:
                        continuation.resume(throwing: NSError(domain: "export error", code: -1, userInfo: nil))
                    default:
                        continuation.resume(throwing: NSError(domain: "export error", code: -1, userInfo: nil))
                    }
                })
            }
        })
    }
}
// VideoUploadView.swift
import SwiftUI
import Photos

struct VideoUploadView: View {
    @State var assets: [PHAsset] = []
    @State var isPresented = false
    @StateObject var viewModel = VideoUploadViewModel() // 追加

    var body: some View {
        VStack {
            Button {
                isPresented = true
            } label: {
                Text("動画を選ぶ")
            }
            // 追加内容
            // ここから
            Button {
                Task {
                    await viewModel.upload(assets: assets)
                }
            } label: {
                Text("アップロード")
            }
            // ここまで
            // 以下省略
}
  • 動画をアップロードするために ViewModel を追加する(アップロード処理自体はRepositoryなどに切り出してもいいと思います)
  • アップロードするためには Data 型が必要になってきますので PHAsset から Data 型を作り出すために一度ローカルの tmp ディレクトリにファイルをエクスポートします。そのエクスポートしたファイルかた Data 型にコンバートすることで作り出すことができます
  • サーバーへのアップロードの処理はコード量が多くなってしまうので割愛させていただきます
    • 今回はカメラロールから動画をアップロードために必要な Data を作り出すまでにしてます
  • viewState を用意していますがそれぞれの case に変更されたタイミングで View への変更などを行えるようにしてますがこちらも詳しいコードは割愛させていただきます

以上がアプリからImagePicker を開き、選択した動画を一覧で表示してアップロードするために必要な動画の Data を生成するまでになります。

ハマった点

アップロードする上で必要になってくるのが動画の Data です。しかし、ImagePicker から取得できるのはassetIdentifier でここからは PHAsset を取得する以外の用途が今回はありませんでした。

なので PHAsset からどうやって 動画のデータを生成するかが重要となってきます。

最初は PHAsset から PHImageManager でそのまま取得できるのかなと思ってました。

しかし取得できるのは画像データのみ(動画だとサムネイル画像のデータ)でそれを動画としてアップロードしても画像なのに動画として作成されたファイルがアップロードされてしまいます。

他にもいろいろ試してみましたが全くうまくいかない状態が続きました…

もしかしたら一度ローカルにファイルを作ってそこからデータ作れば成功するのではと思い調べてみると AVAsset からローカルにエクスポートする方法が見つかり試してみるとうまくいったとういう感じでした。

感想

PHAsset というカメラロールの写真や動画を扱う上では切っても切り離せない存在に今回はかなり翻弄されました。

画像はエクスポートをしなくてもデータは取得や View で Image を利用することも簡単にできます。しかし、動画は一度エクスポートしないと動画のデータを取得することができないんだなーって学びました。

今回はアップロードに必要なデータを取得するところまででしたが、今度は選択した動画を再生する機能なんかもチャレンジしてみたいです


もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

アルダグラム Tech Blog

Discussion