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


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

## 一番シンプルな画面録画

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

```swift
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](https://developer.apple.com/documentation/replaykit/rppreviewviewcontroller) というクラスのインスタンスが取得出来るんですが動画のURLぐらい返してくれても良さそうなのにドキュメントを見ても何も保持して無いんですね。

---

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

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

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

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

こちらのメソッドを利用する事で `CMSampleBuffer` が取得出来ます。  
簡単にいえばこのサンプルバッファー結合して動画にしてしまえば良いのです。

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

```swift
@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
    }
}

```

使い方はこちらです

```swift
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を取得したくて困っている方の助けになれば幸いです！