ReplayKitで撮影した動画をごにょごにょしたい #iosadventcalendar #replaykit

iOS Advent Calendar 2017 3日目のかっくん a.k.a. fromkkです。
iOS 9で導入されたReplay Kitですが、当時は画面のライブ配信、簡易的な録画からのシェアが目的でした。
iOS 11からReplay Kitを利用してアプリの画面録画をハンドリングする事が出来る様になりました。

一番シンプルな画面録画

ちなみに一番シンプルな画面録画は以下の様に記述出来ます。

import ReplayKit

func startRecording() {
    // 既に録画中だと何もしない
    guard !RPScreenRecorder.shared().isRecording else { return }
    // 録画開始
    RPScreenRecorder.shared().startRecording(withMicrophoneEnabled: true, handler: { (error) in
        if let error = error {
            debugPrint(#function, "recording something failed", error)
        }
    })
}

func endRecording() {
    // 録画中じゃないと終了しない
    guard RPScreenRecorder.shared().isRecording else { return }
    // 録画終了
    RPScreenRecorder.shared().stopRecording(handler: { (previewViewController, error) in
        guard let previewViewController = previewViewController else { return }
        previewViewController.previewControllerDelegate = self //delegateを実装しないとdismissされない

        // プレビューの表示
        self.present(previewViewController, animated: true, completion: nil)
    })
}

func previewControllerDidFinish(_ previewController: RPPreviewViewController) {
    DispatchQueue.main.async {
        self.previewController.dismiss(animated: true, completion: nil)
    }
}

正常に録画が終了すると RPPreviewViewController というクラスのインスタンスが取得出来るんですが動画のURLぐらい返してくれても良さそうなのにドキュメントを見ても何も保持して無いんですね。


画面録画をハンドリングする

iOS 11からReplayKitの RPScreenRecorder クラスに下記のメソッドが追加されました。

func startCapture(handler captureHandler: ((CMSampleBuffer, RPSampleBufferType, Error?) -> Void)?, 
completionHandler: ((Error?) -> Void)? = nil)

func stopCapture(handler: ((Error?) -> Void)? = nil)

こちらのメソッドを利用する事で CMSampleBuffer が取得出来ます。

簡単にいえばこのサンプルバッファー結合して動画にしてしまえば良いのです。

という事で出来上がったコードがこちらです。

@available(iOS 11.0, *)
public class ScreenRecorder: NSObject {

    let screenRecorder = RPScreenRecorder.shared()

    public typealias Completion = (URL?, Error?) -> ()
    let completion: Completion
    let configuration: Configuration
    public init(configuration: Configuration, completion: @escaping Completion) {
        self.configuration = configuration
        self.completion = completion
        super.init()
    }

    /// 画面のキャプチャを開始する
    ///
    /// - Throws: ScreenRecorderError
    public func start() throws {
        guard screenRecorder.isAvailable else {
            throw ScreenRecorderError.notAvailable
        }

        guard !screenRecorder.isRecording else {
            throw ScreenRecorderError.alreadyRunning
        }

        try setUp()

        assetWriter?.startWriting()
        assetWriter?.startSession(atSourceTime: kCMTimeZero)

        screenRecorder.startCapture(handler: { [weak self] (cmSampleBuffer, rpSampleBufferType, error) in
            if let error = error {
                debugPrint(#function, "something happened", error)
                return
            }

            if RPSampleBufferType.video == rpSampleBufferType {
                self?.appendVideo(sampleBuffer: cmSampleBuffer)
            }
        }) { [weak self] (error) in
            if let error = error {
                self?.completion(nil, error)
            }
        }
    }

    /// 画面のキャプチャを終了する
    ///
    /// - Throws: ScreenRecorderError
    public func end() throws {
        guard screenRecorder.isRecording else {
            throw ScreenRecorderError.notRunning
        }

        screenRecorder.stopCapture { [weak self] (error) in
            if let error = error {
                self?.completion(nil, error)
            }

            self?.videoAssetWriterInput?.markAsFinished()
            self?.assetWriter?.finishWriting {
                DispatchQueue.main.async {
                    self?.completion(self?.cacheFileURL, nil)
                }
            }
        }
    }

    /// 録画中かどうか
    public var isRecording: Bool {
        return screenRecorder.isRecording
    }

    private var assetWriter: AVAssetWriter?

    private var videoAssetWriterInput: AVAssetWriterInput?

    private var writerInputPixelBufferAdaptor: AVAssetWriterInputPixelBufferAdaptor?

    /// 事前準備
    ///
    /// - Throws: Error
    private func setUp() throws {
        try createCacheDirectoryIfNeeded()
        try removeOldCachedFile()

        guard let cacheURL = cacheFileURL else {
            throw ScreenRecorderError.invalidURL
        }

        let assetWriter = try AVAssetWriter(url: cacheURL, fileType: configuration.fileType)

        let videoSetting: [String: Any] = [
            AVVideoCodecKey: configuration.codec,
            AVVideoWidthKey: UInt(configuration.videoSize.width),
            AVVideoHeightKey: UInt(configuration.videoSize.height),
            ]
        let videoAssetWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSetting)
        videoAssetWriterInput.expectsMediaDataInRealTime = true

        if assetWriter.canAdd(videoAssetWriterInput) {
            assetWriter.add(videoAssetWriterInput)
        }

        self.assetWriter = assetWriter
        self.videoAssetWriterInput = videoAssetWriterInput
        self.writerInputPixelBufferAdaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: videoAssetWriterInput, sourcePixelBufferAttributes: [
            kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32ARGB)
            ])
    }

    private var startTime: CMTime?

    private func appendVideo(sampleBuffer: CMSampleBuffer) {
        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }

        let firstTime: CMTime
        if let startTime = self.startTime {
            firstTime = startTime
        } else {
            firstTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)

            startTime = firstTime
        }

        let currentTime: CMTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
        let diffTime: CMTime = CMTimeSubtract(currentTime, firstTime)

        if writerInputPixelBufferAdaptor?.assetWriterInput.isReadyForMoreMediaData ?? false {
            writerInputPixelBufferAdaptor?.append(pixelBuffer, withPresentationTime: diffTime)
        }
    }

    /// キャッシュディレクトリーのURL
    private var cacheDirectoryURL: URL? = {
        guard let path = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first else {
            return nil
        }

        return URL(fileURLWithPath: path).appendingPathComponent("ScreenRecorder")
    }()

    /// キャッシュファイルのURL
    private var cacheFileURL: URL? {
        guard let cacheDirectoryURL = cacheDirectoryURL else { return nil }

        return cacheDirectoryURL.appendingPathComponent("screenrecord.mp4")
    }

    /// キャッシュディレクトリが無ければ作成する
    ///
    /// - Throws: Error
    private func createCacheDirectoryIfNeeded() throws {
        guard let cacheDirectoryURL = cacheDirectoryURL else { return }

        let fileManager = FileManager.default

        guard !fileManager.fileExists(atPath: cacheDirectoryURL.path) else {
            return
        }

        try fileManager.createDirectory(at: cacheDirectoryURL, withIntermediateDirectories: true, attributes: nil)
    }

    private func removeOldCachedFile() throws {
        guard let cacheURL = cacheFileURL else { return }

        let fileManager = FileManager.default
        guard fileManager.fileExists(atPath: cacheURL.path) else { return }
        try fileManager.removeItem(at: cacheURL)
    }
}

@available(iOS 11.0, *)
extension ScreenRecorder {
    public struct Configuration {
        public var codec: AVVideoCodecType = .h264
        public var fileType: AVFileType = .mp4
        public var videoSize: CGSize = CGSize(width: UIScreen.main.bounds.size.width * UIScreen.main.scale, height: UIScreen.main.bounds.size.height * UIScreen.main.scale)
        public var audioQuality: AVAudioQuality = AVAudioQuality.medium
        public var audioFormatID: AudioFormatID = kAudioFormatMPEG4AAC
        public var numberOfChannels: UInt = 2
        public var sampleRate: Double = 44100.0
        public var bitrate: UInt = 16

        public init() {}
    }

    public enum ScreenRecorderError: Error {
        case notAvailable
        case alreadyRunning
        case notRunning
        case invalidURL
    }
}

使い方はこちらです

private lazy var recorder: ScreenRecorder = ScreenRecorder(configuration: ScreenRecorder.Configuration(), completion: { (url, error) in
    guard let url = url else {
        fatalError("\(#function) record failed \(error)")
    }

    debugPrint(#function, "success", url)
})

func recordStart() {
    guard !recorder.isRecording else  { return }

    do {
        try recorder.start()
    } catch {
        fatalError("start recording failed \(error)")
    }
}

func recordStop() {
    guard recorder.isRecording else { return }

    do {
        try recorder.end()
    } catch {
        fatalError("start recording failed \(error)")
    }
}

RPSampleBufferType の値をハンドリングすれば端末が発している音声も取得出来るみたいですが、僕は必要無かったので今回は対応しませんでした。
興味のある方は是非試してみて頂ければと思います。

まとめ

実はこれを作ったのは ARKit のを利用した画面を録画したかったからなんですが、ReplayKit を使うと録画開始時にネイティブのアラートダイアログが表示されてしまうんですね。
ユーザーからすると録画するという意思表示をした後に再度アラートが表示されてしまうという体験になってしまいよろしくないので ReplayKit を使う事は諦めましたが、せっかくここまで作ったので供養の意味も込めて公開させていただきました。
(ARKitの画面、音声の録画の方法はどこかで公開出来ればと思います)
もし ReplayKit で録画した動画のURLを取得したくて困っている方の助けになれば幸いです!