Corredor

ウェブ、プログラミングの勉強メモ。

撮影した動画ファイルを iOS アプリ内に保存し、任意のタイミングでフォトライブラリに書き出す Swift コード

以前、スーパースロー動画を撮るための Swift コードを紹介した。

neos21.hatenablog.com

この時は AVCaptureSession#startRunning() までで、実際の動画の撮影については触れていなかった。そこで今回は、このコードを利用した動画撮影のコードを掲載しておく。

検証環境

  • macOS Mojave
  • Xcode v10.1
  • Swift v4.2.1
  • iOS v12.0.1

まずはコード全量

まずは ViewController.swift のコード全量を載せる。

import UIKit
import AVFoundation
import Photos

class ViewController: UIViewController, AVCaptureFileOutputRecordingDelegate {
  /// セッション
  var session: AVCaptureSession!
  /// ビデオデバイス
  var videoDevice: AVCaptureDevice!
  /// オーディオデバイス
  var audioDevice: AVCaptureDevice!
  /// ファイル出力
  var fileOutput: AVCaptureMovieFileOutput!
  
  /// 初期表示時の処理 : セッションの用意
  override func viewDidLoad() {
    super.viewDidLoad()
    // セッション生成
    session = AVCaptureSession()
    // 入力 : 背面カメラ
    videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back)
    let videoInput = try! AVCaptureDeviceInput.init(device: videoDevice)
    session.addInput(videoInput)
    // フォーマット指定
    switchFormat(desiredFps: 60.0)
    // 入力 : マイク
    audioDevice = AVCaptureDevice.default(for: .audio)
    let audioInput = try! AVCaptureDeviceInput.init(device: audioDevice)
    session.addInput(audioInput)
    // 出力
    fileOutput = AVCaptureMovieFileOutput()
    session.addOutput(fileOutput)
    // セッション開始
    session.startRunning()
  }
  
  /// 録画完了時の処理 : オーバーライドしておくだけでココでは何もしない
  ///
  /// - parameter output: AVCaptureFileOutput (アンダースコアは外部引数名を省略するもの・呼び出し元でも外部引数名を書かなくて呼び出せるようになる)
  /// - parameter outputFileURL: URL (Option キーで参照できるドキュメントコメントを見ると内部引数名でコメントを書くっぽいので内部引数名を採用)
  /// - parameter connections: AVCaptureConnection
  /// - parameter error: Error
  func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
    print("録画完了")
    // XXX : もし録画完了時にフォトライブラリに書き出したければココで処理する
  }
  
  /// 指定の FPS のフォーマットに切り替える (その FPS で最大解像度のフォーマットを選ぶ)
  ///
  /// - parameter desiredFps: 切り替えたい FPS (AVFrameRateRange.maxFrameRate が Double なので合わせる)
  private func switchFormat(desiredFps: Double) {
    let isRunning = session.isRunning
    if isRunning { session.stopRunning() }  // セッションが始動中なら一時的に停止しておく
    
    // 取得したフォーマットを格納する変数
    var selectedFormat: AVCaptureDevice.Format! = nil
    // そのフレームレートの中で一番大きい解像度を取得する
    var currentMaxWidth: Int32 = 0
    
    // フォーマットを探る
    for format in videoDevice.formats {
      // フォーマット内の情報を抜き出す (for in と書いているが1つの format につき1つの range しかない)
      for range: AVFrameRateRange in format.videoSupportedFrameRateRanges {
        let description = format.formatDescription as CMFormatDescription  // フォーマットの説明
        let dimensions = CMVideoFormatDescriptionGetDimensions(description)  // 幅・高さ情報を抜き出す
        let width = dimensions.width  // 幅
        
        // 指定のフレームレートで一番大きな解像度を得る (1920px 以上は選ばない)
        if desiredFps == range.maxFrameRate && currentMaxWidth <= width && width <= 1920 {
          selectedFormat = format
          currentMaxWidth = width
        }
      }
    }
    
    // フォーマットが取得できていれば設定する
    if selectedFormat != nil {
      do {
        try videoDevice.lockForConfiguration()  // ロックできなければ例外を投げる
        videoDevice.activeFormat = selectedFormat
        videoDevice.activeVideoMinFrameDuration = CMTimeMake(value: 1, timescale: Int32(desiredFps))  // Swift 4.2.1 になって
        videoDevice.activeVideoMaxFrameDuration = CMTimeMake(value: 1, timescale: Int32(desiredFps))  // value と timescale の引数名を書かないといけなくなった
        videoDevice.unlockForConfiguration()
        if isRunning { session.startRunning() }  // セッションが始動中だった場合は一時停止していたものを再開する
      }
      catch {
        print("フォーマット・フレームレートが指定できなかった : \(desiredFps) fps")
      }
    }
    else {
      print("フォーマットが取得できなかった : \(desiredFps) fps")
    }
  }
  
  /// 録画を開始する : ボタンからこの関数を呼び出してあげる
  private func startRecording() {
    // Documents ディレクトリ直下にファイルを生成する
    let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
    let documentsDirectory = paths[0] as String
    
    // 現在時刻をファイル名に付与することでファイル重複を防ぐ : "myvideo-20190101125900999.mp4" な形式になる
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyyMMddHHmmssSSS"
    let filePath: String? = "\(documentsDirectory)/myvideo-\(formatter.string(from: Date())).mp4"
    let fileURL = NSURL(fileURLWithPath: filePath!)
    
    print("録画開始 : \(filePath!)")
    fileOutput?.startRecording(to: fileURL as URL, recordingDelegate: self)
    
    // XXX : あとココでプレビュー表示とか…
  }
  
  /// 録画を停止する : ボタンからこの関数を呼び出してあげる
  private func stopRecording() {
    print("録画停止")
    fileOutput?.stopRecording()
    
    // XXX : あとココでプレビュー表示の取り消しとか…
  }
  
  /// アプリ内に保存した mp4 ファイルをフォトライブラリに書き出す : ボタンからこの関数を呼び出してあげる
  private func outputVideos() {
    // Documents ディレクトリの URL
    let documentDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
    do {
      // Documents ディレクトリ配下のファイル一覧を取得する
      let contentUrls = try FileManager.default.contentsOfDirectory(at: documentDirectoryURL, includingPropertiesForKeys: nil)
      for contentUrl in contentUrls {
        // 拡張子で判定する
        if contentUrl.pathExtension == "mp4" {
          // mp4 ファイルならフォトライブラリに書き出す
          PHPhotoLibrary.shared().performChanges({
            PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: contentUrl)
          }) { (isCompleted, error) in
            if isCompleted {
              // フォトライブラリに書き出し成功
              do {
                try FileManager.default.removeItem(atPath: contentUrl.path)
                print("フォトライブラリ書き出し・ファイル削除成功 : \(contentUrl.lastPathComponent)")
              }
              catch {
                print("フォトライブラリ書き出し後のファイル削除失敗 : \(contentUrl.lastPathComponent)")
              }
            }
            else {
              print("フォトライブラリ書き出し失敗 : \(contentUrl.lastPathComponent)")
            }
          }
        }
      }
    }
    catch {
      print("ファイル一覧取得エラー")
    }
  }
}

長くなったが以上である。

コードの説明

今回のコードの構成は、以下のようになっている。

class ViewController: UIViewController, AVCaptureFileOutputRecordingDelegate {
  // 初期表示時の処理
  override func viewDidLoad() { }
  // 録画完了時に自動的に呼ばれる・AVCaptureFileOutputRecordingDelegate が実装を必須にしているモノ
  func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { }
  
  // 以下プライベート関数
  
  // フォーマットの指定 : 今回のコードでは 60fps 固定にしたが、必要に応じて 120fps・240fps なども設定可能
  private func switchFormat(desiredFps: Double) { }
  // 録画開始用の処理
  private func startRecording() { }
  // 録画停止用の処理
  private func stopRecording() { }
  // 動画ファイルをフォトライブラリに書き出してアプリ内からは削除する処理
  private func outputVideos() { }
}

これらのコードは一旦そのままにし、あとは画面 (Main.storyboard) にボタンなどを配置して、ボタンタップ時にプライベート関数を呼び出すようにすれば良い。最低限必要になるのは、

  1. 録画開始ボタン
  2. 録画停止ボタン
  3. 動画ファイルの書き出しボタン

ぐらいだろうか。

録画開始時にファイル名と保存場所を指定している

今回のポイントは、動画の録画開始時にファイル名と保存場所を指定していること。startRecording() を再掲する。

/// 録画を開始する : ボタンからこの関数を呼び出してあげる
private func startRecording() {
  // Documents ディレクトリ直下にファイルを生成する
  let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
  let documentsDirectory = paths[0] as String
  
  // 現在時刻をファイル名に付与することでファイル重複を防ぐ : "myvideo-20190101125900999.mp4" な形式になる
  let formatter = DateFormatter()
  formatter.dateFormat = "yyyyMMddHHmmssSSS"
  let filePath: String? = "\(documentsDirectory)/myvideo-\(formatter.string(from: Date())).mp4"
  let fileURL = NSURL(fileURLWithPath: filePath!)
  
  print("録画開始 : \(filePath!)")
  fileOutput?.startRecording(to: fileURL as URL, recordingDelegate: self)
  
  // XXX : あとココでプレビュー表示とか…
}

このように、/Documents/ ディレクトリ配下に myvideo-20190101125900999.mp4 といった形式のファイル名で保存するように設定している。/Library/Caches//tmp/ はシステムによる自動削除の危険があるので、/Documents/ ディレクトリを使用している。もしココで指定した名前のファイルが既に存在する場合は自動的に上書きされるので、ファイル名を固定にしてあえて重複させるようにしておけば、動画ファイルは常に最新の1つのみ保持するような作りにもできる。

この辺のコードは AVFoundation で動画撮影する系の記事から借用しただけ。

また、// XXX コメントで示しているとおり、このままだと動画の撮影状況を表示するためのプレビューがないままなので、そこは別途実装してやること。

録画停止用の関数は単に stopRecording() を呼ぶだけ

録画停止は、fileOutput.stopRecording() を呼ぶだけ。コレを呼ぶと、次に紹介する fileOutput() メソッドが自動的に実行される、という関係。

/// 録画を停止する : ボタンからこの関数を呼び出してあげる
private func stopRecording() {
  print("録画停止")
  fileOutput?.stopRecording()
  
  // XXX : あとココでプレビュー表示の取り消しとか…
}

前述のとおり、プレビュー表示に関するコードはないので、プレビュー表示用のレイヤーを非表示にするなどの処理はココでやると良いかと。

録画完了時に自動実行される fileOutput() では何もしない

次に、AVCaptureFileOutputRecordingDelegate を実装する際に必須となる fileOutput() メソッドだが、この中では特に何もしない。

/// 録画完了時の処理 : オーバーライドしておくだけでココでは何もしない
///
/// - parameter output: AVCaptureFileOutput (アンダースコアは外部引数名を省略するもの・呼び出し元でも外部引数名を書かなくて呼び出せるようになる)
/// - parameter outputFileURL: URL (Option キーで参照できるドキュメントコメントを見ると内部引数名でコメントを書くっぽいので内部引数名を採用)
/// - parameter connections: AVCaptureConnection
/// - parameter error: Error
func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
  print("録画完了")
  // XXX : もし録画完了時にフォトライブラリに書き出したければココで処理する
}

以下の文献では capture() メソッドが必須、と紹介されているが、多分バージョン違いによるもの。

引数 outputFileURL で、録画を終了させた動画ファイル名が分かるので、この場でフォトライブラリに書き出して、/Documents/ 配下からはファイルを削除するよう実装しても良い。そのためのコードは後述する書き出し処理が参考になるだろう。

さて、録画開始時に /Documents/ 配下に動画ファイルを保存するよう指定し、録画停止時には何もしない、とすることで、アプリ内に動画ファイルが溜まっていく作りになるのだ。このままではフォトライブラリにも書き出されない。

アプリ内に保存された動画ファイルをフォトライブラリに書き出す

さて、アプリ内に動画ファイルを溜め込めるようになったのは良いが、このままではアプリ内に溜まりっぱなしで取り出せない (Xcode から「Download Container」などして引き抜く、といったことはできるが…)。

そこで、アプリ内に保存されている mp4 ファイルをまとめてフォトライブラリ (= カメラロール) に書き出し、アプリ内からは動画ファイルを削除する処理を作る。それが以下の outputVideos() だ。

/// アプリ内に保存した mp4 ファイルをフォトライブラリに書き出す : ボタンからこの関数を呼び出してあげる
private func outputVideos() {
  // Documents ディレクトリの URL
  let documentDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  do {
    // Documents ディレクトリ配下のファイル一覧を取得する
    let contentUrls = try FileManager.default.contentsOfDirectory(at: documentDirectoryURL, includingPropertiesForKeys: nil)
    for contentUrl in contentUrls {
      // 拡張子で判定する
      if contentUrl.pathExtension == "mp4" {
        // mp4 ファイルならフォトライブラリに書き出す
        PHPhotoLibrary.shared().performChanges({
          PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: contentUrl)
        }) { (isCompleted, error) in
          if isCompleted {
            // フォトライブラリに書き出し成功
            do {
              try FileManager.default.removeItem(atPath: contentUrl.path)
              print("フォトライブラリ書き出し・ファイル削除成功 : \(contentUrl.lastPathComponent)")
            }
            catch {
              print("フォトライブラリ書き出し後のファイル削除失敗 : \(contentUrl.lastPathComponent)")
            }
          }
          else {
            print("フォトライブラリ書き出し失敗 : \(contentUrl.lastPathComponent)")
          }
        }
      }
    }
  }
  catch {
    print("ファイル一覧取得エラー")
  }
}

FileManager.default.contentsOfDirectory() を使って、/Documents/ ディレクトリ配下からファイル一覧を取得する。そしてコレをループし、NSURL#pathExtension で拡張子を参照する。録画開始時に指定したように、mp4 なファイルだったら書き出し対象として扱う。

フォトライブラリへの書き出しは PHAssetChangeRequest.creationRequestForAssetFromVideo() とかいう関数を使う。書き出しに成功したら、FileManager.default.removeItem() を使って、アプリ内に保存されているファイルを削除する。

この辺、try とか do-catch とかよく分かっていなくて、コードのネストが深くなってる。for で回すのもイマイチな気がするのだが、フォトライブラリへの書き出しが非同期に行われてタイミングがおかしくなるので、書き出しの成功を isCompleted で確認してからファイルを消すようにしている。もっと Swift 勉強しないとキレイなコードにならんち…。

しかしひとまずはコレで、アプリ内の動画ファイルをフォトライブラリに書き出した上で削除できるようになった。

コレについても、もう少し UI 面を加工していけば、アプリ内の動画ファイル一覧をカメラロールちっくに画面に表示し、選択した動画ファイルのみエクスポートする、みたいにも作れるだろう。

以上

動画ファイルの扱い方が全く分からなくて色々調べたが、随分すんなりと実装できた。

Swift らしいエレガントな書き方がまだ分かっておらず、コードの行数もかさむし、ネストが深くなりがち。!? の使い分けとかも定石を知らないので勉強しないと。

参考文献

今回実装するにあたって参照した文献。

絶対に挫折しない iPhoneアプリ開発「超」入門 第7版 【Xcode 10 & iOS 12】 完全対応

絶対に挫折しない iPhoneアプリ開発「超」入門 第7版 【Xcode 10 & iOS 12】 完全対応