iPhoneのカメラロールから動画を引っ張り出すのにハマったお話
こんにちは!アルダグラムでエンジニアをしている渡辺です
今日は 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 を定義してあります
-
assets: [PHAsset]
選択した動画を View に反映させたいので@Binding
を利用する -
selectionLimit: Int
選択できる制限の数値を指定する -
filter: PHPickerFilter
カメラロールで選択できるタイプを指定するall
メソッドを使って複数指定もできます。詳しくはドキュメントをご覧ください
-
-
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 に
selectionLimit
に10
を指定しているので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です。 世界中のノンデスクワーク業界における現場の生産性アップを実現する現場DXサービス「KANNA」を開発しています。 採用情報はこちら herp.careers/v1/aldagram0508/
Discussion