2021-05-06 06:11:55 -06:00
|
|
|
|
//
|
|
|
|
|
// RecordingSession.swift
|
|
|
|
|
// VisionCamera
|
|
|
|
|
//
|
|
|
|
|
// Created by Marc Rousavy on 01.05.21.
|
2021-06-01 05:07:57 -06:00
|
|
|
|
// Copyright © 2021 mrousavy. All rights reserved.
|
2021-05-06 06:11:55 -06:00
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
import AVFoundation
|
|
|
|
|
import Foundation
|
|
|
|
|
|
|
|
|
|
// MARK: - BufferType
|
|
|
|
|
|
2021-06-07 05:08:40 -06:00
|
|
|
|
enum BufferType {
|
2021-05-06 06:11:55 -06:00
|
|
|
|
case audio
|
|
|
|
|
case video
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - RecordingSession
|
|
|
|
|
|
2023-11-23 10:17:15 -07:00
|
|
|
|
/**
|
|
|
|
|
A [RecordingSession] class that can record video and audio [CMSampleBuffers] from [AVCaptureVideoDataOutput] and
|
|
|
|
|
[AVCaptureAudioDataOutput] into a .mov/.mp4 file using [AVAssetWriter].
|
|
|
|
|
|
|
|
|
|
It also synchronizes buffers to the CMTime by the CaptureSession so that late frames are removed from the beginning and added
|
|
|
|
|
towards the end (useful e.g. for videoStabilization).
|
|
|
|
|
*/
|
2021-05-06 06:11:55 -06:00
|
|
|
|
class RecordingSession {
|
|
|
|
|
private let assetWriter: AVAssetWriter
|
2021-06-03 06:16:02 -06:00
|
|
|
|
private var audioWriter: AVAssetWriterInput?
|
2023-11-22 09:53:10 -07:00
|
|
|
|
private var videoWriter: AVAssetWriterInput?
|
2024-07-12 09:51:09 -06:00
|
|
|
|
private let recorder: ChunkedRecorder
|
2022-04-15 01:48:32 -06:00
|
|
|
|
private let completionHandler: (RecordingSession, AVAssetWriter.Status, Error?) -> Void
|
2021-05-06 06:11:55 -06:00
|
|
|
|
|
2023-11-23 10:17:15 -07:00
|
|
|
|
private var startTimestamp: CMTime?
|
|
|
|
|
private var stopTimestamp: CMTime?
|
|
|
|
|
|
|
|
|
|
private var lastWrittenTimestamp: CMTime?
|
|
|
|
|
|
2022-05-23 06:27:25 -06:00
|
|
|
|
private var isFinishing = false
|
2023-11-23 10:17:15 -07:00
|
|
|
|
private var hasWrittenLastVideoFrame = false
|
|
|
|
|
private var hasWrittenLastAudioFrame = false
|
|
|
|
|
|
|
|
|
|
private let lock = DispatchSemaphore(value: 1)
|
|
|
|
|
|
|
|
|
|
// If we are waiting for late frames and none actually arrive, we force stop the session after the given timeout.
|
|
|
|
|
private let automaticallyStopTimeoutSeconds = 4.0
|
2021-05-06 06:11:55 -06:00
|
|
|
|
|
2023-10-18 10:04:58 -06:00
|
|
|
|
/**
|
|
|
|
|
Gets the file URL of the recorded video.
|
|
|
|
|
*/
|
2024-07-16 02:50:21 -06:00
|
|
|
|
var outputDiretory: URL {
|
2024-07-12 09:51:09 -06:00
|
|
|
|
return recorder.outputURL
|
2021-05-06 06:11:55 -06:00
|
|
|
|
}
|
|
|
|
|
|
2023-12-12 08:43:57 -07:00
|
|
|
|
/**
|
|
|
|
|
Gets the size of the recorded video, in pixels.
|
|
|
|
|
*/
|
|
|
|
|
var size: CGSize? {
|
|
|
|
|
return videoWriter?.naturalSize
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-18 10:04:58 -06:00
|
|
|
|
/**
|
|
|
|
|
Get the duration (in seconds) of the recorded video.
|
|
|
|
|
*/
|
2021-05-06 06:11:55 -06:00
|
|
|
|
var duration: Double {
|
2023-11-23 10:17:15 -07:00
|
|
|
|
guard let lastWrittenTimestamp = lastWrittenTimestamp,
|
|
|
|
|
let startTimestamp = startTimestamp else {
|
2021-05-06 06:11:55 -06:00
|
|
|
|
return 0.0
|
|
|
|
|
}
|
2023-11-23 10:17:15 -07:00
|
|
|
|
return (lastWrittenTimestamp - startTimestamp).seconds
|
2021-05-06 06:11:55 -06:00
|
|
|
|
}
|
|
|
|
|
|
2024-07-16 02:50:21 -06:00
|
|
|
|
init(outputDiretory: String,
|
2021-05-06 06:11:55 -06:00
|
|
|
|
fileType: AVFileType,
|
2024-07-15 02:50:39 -06:00
|
|
|
|
onChunkReady: @escaping ((ChunkedRecorder.Chunk) -> Void),
|
2022-04-15 01:48:32 -06:00
|
|
|
|
completion: @escaping (RecordingSession, AVAssetWriter.Status, Error?) -> Void) throws {
|
2021-06-03 06:16:02 -06:00
|
|
|
|
completionHandler = completion
|
|
|
|
|
|
2021-05-06 06:11:55 -06:00
|
|
|
|
do {
|
2024-07-16 02:50:21 -06:00
|
|
|
|
let outputURL = URL(fileURLWithPath: outputDiretory)
|
|
|
|
|
recorder = try ChunkedRecorder(outputURL: outputURL, onChunkReady: onChunkReady)
|
2024-07-12 09:51:09 -06:00
|
|
|
|
assetWriter = AVAssetWriter(contentType: UTType(fileType.rawValue)!)
|
2023-11-22 09:53:10 -07:00
|
|
|
|
assetWriter.shouldOptimizeForNetworkUse = false
|
2024-07-12 09:51:09 -06:00
|
|
|
|
assetWriter.outputFileTypeProfile = .mpeg4AppleHLS
|
|
|
|
|
assetWriter.preferredOutputSegmentInterval = CMTime(seconds: 6, preferredTimescale: 1)
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
Apple HLS fMP4 does not have an Edit List Box ('elst') in an initialization segment to remove
|
|
|
|
|
audio priming duration which advanced audio formats like AAC have, since the sample tables
|
|
|
|
|
are empty. As a result, if the output PTS of the first non-fully trimmed audio sample buffer is
|
|
|
|
|
kCMTimeZero, the audio samples’ presentation time in segment files may be pushed forward by the
|
|
|
|
|
audio priming duration. This may cause audio and video to be out of sync. You should add a time
|
|
|
|
|
offset to all samples to avoid this situation.
|
|
|
|
|
*/
|
|
|
|
|
let startTimeOffset = CMTime(value: 10, timescale: 1)
|
|
|
|
|
assetWriter.initialSegmentStartTime = startTimeOffset
|
|
|
|
|
|
|
|
|
|
assetWriter.delegate = recorder
|
2021-05-06 06:11:55 -06:00
|
|
|
|
} catch let error as NSError {
|
|
|
|
|
throw CameraError.capture(.createRecorderError(message: error.description))
|
|
|
|
|
}
|
2021-06-03 06:16:02 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deinit {
|
|
|
|
|
if assetWriter.status == .writing {
|
2021-09-06 08:27:16 -06:00
|
|
|
|
ReactLogger.log(level: .info, message: "Cancelling AssetWriter...")
|
2021-06-03 06:16:02 -06:00
|
|
|
|
assetWriter.cancelWriting()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-06-09 06:56:56 -06:00
|
|
|
|
/**
|
|
|
|
|
Initializes an AssetWriter for video frames (CMSampleBuffers).
|
|
|
|
|
*/
|
2023-11-22 09:53:10 -07:00
|
|
|
|
func initializeVideoWriter(withSettings settings: [String: Any]) {
|
2021-06-03 06:16:02 -06:00
|
|
|
|
guard !settings.isEmpty else {
|
2021-09-06 08:27:16 -06:00
|
|
|
|
ReactLogger.log(level: .error, message: "Tried to initialize Video Writer with empty settings!")
|
2021-06-03 06:16:02 -06:00
|
|
|
|
return
|
|
|
|
|
}
|
2023-11-22 09:53:10 -07:00
|
|
|
|
guard videoWriter == nil else {
|
2021-09-06 08:27:16 -06:00
|
|
|
|
ReactLogger.log(level: .error, message: "Tried to add Video Writer twice!")
|
2021-06-03 06:16:02 -06:00
|
|
|
|
return
|
|
|
|
|
}
|
2021-05-06 06:11:55 -06:00
|
|
|
|
|
2023-11-22 09:53:10 -07:00
|
|
|
|
ReactLogger.log(level: .info, message: "Initializing Video AssetWriter with settings: \(settings.description)")
|
|
|
|
|
videoWriter = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
|
|
|
|
|
videoWriter!.expectsMediaDataInRealTime = true
|
|
|
|
|
assetWriter.add(videoWriter!)
|
2021-06-03 06:16:02 -06:00
|
|
|
|
ReactLogger.log(level: .info, message: "Initialized Video AssetWriter.")
|
2021-05-06 06:11:55 -06:00
|
|
|
|
}
|
|
|
|
|
|
2021-06-09 06:56:56 -06:00
|
|
|
|
/**
|
|
|
|
|
Initializes an AssetWriter for audio frames (CMSampleBuffers).
|
|
|
|
|
*/
|
2023-11-22 09:53:10 -07:00
|
|
|
|
func initializeAudioWriter(withSettings settings: [String: Any]?, format: CMFormatDescription) {
|
2021-06-03 06:16:02 -06:00
|
|
|
|
guard audioWriter == nil else {
|
2021-09-06 08:27:16 -06:00
|
|
|
|
ReactLogger.log(level: .error, message: "Tried to add Audio Writer twice!")
|
2021-06-03 06:16:02 -06:00
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2023-11-22 09:53:10 -07:00
|
|
|
|
if let settings = settings {
|
|
|
|
|
ReactLogger.log(level: .info, message: "Initializing Audio AssetWriter with settings: \(settings.description)")
|
|
|
|
|
} else {
|
|
|
|
|
ReactLogger.log(level: .info, message: "Initializing Audio AssetWriter default settings...")
|
|
|
|
|
}
|
|
|
|
|
audioWriter = AVAssetWriterInput(mediaType: .audio, outputSettings: settings, sourceFormatHint: format)
|
2021-06-03 06:16:02 -06:00
|
|
|
|
audioWriter!.expectsMediaDataInRealTime = true
|
|
|
|
|
assetWriter.add(audioWriter!)
|
|
|
|
|
ReactLogger.log(level: .info, message: "Initialized Audio AssetWriter.")
|
|
|
|
|
}
|
|
|
|
|
|
2021-06-09 06:56:56 -06:00
|
|
|
|
/**
|
2023-11-23 10:17:15 -07:00
|
|
|
|
Start the RecordingSession using the current time of the provided synchronization clock.
|
|
|
|
|
All buffers passed to [append] must be synchronized to this Clock.
|
2021-06-09 06:56:56 -06:00
|
|
|
|
*/
|
2023-11-23 10:17:15 -07:00
|
|
|
|
func start(clock: CMClock) throws {
|
|
|
|
|
lock.wait()
|
|
|
|
|
defer {
|
|
|
|
|
lock.signal()
|
|
|
|
|
}
|
|
|
|
|
|
2021-06-09 06:56:56 -06:00
|
|
|
|
ReactLogger.log(level: .info, message: "Starting Asset Writer(s)...")
|
|
|
|
|
|
|
|
|
|
let success = assetWriter.startWriting()
|
2023-11-23 10:17:15 -07:00
|
|
|
|
guard success else {
|
2021-06-09 06:56:56 -06:00
|
|
|
|
ReactLogger.log(level: .error, message: "Failed to start Asset Writer(s)!")
|
2023-11-22 09:53:10 -07:00
|
|
|
|
throw CameraError.capture(.createRecorderError(message: "Failed to start Asset Writer(s)!"))
|
2021-06-09 06:56:56 -06:00
|
|
|
|
}
|
2023-11-23 10:17:15 -07:00
|
|
|
|
|
|
|
|
|
ReactLogger.log(level: .info, message: "Asset Writer(s) started!")
|
|
|
|
|
|
|
|
|
|
// Get the current time of the AVCaptureSession.
|
|
|
|
|
// Note: The current time might be more advanced than this buffer's timestamp, for example if the video
|
|
|
|
|
// pipeline had some additional delay in processing the buffer (aka it is late) - eg because of Video Stabilization (~1s delay).
|
|
|
|
|
let currentTime = CMClockGetTime(clock)
|
|
|
|
|
|
|
|
|
|
// Start the sesssion at the given time. Frames with earlier timestamps (e.g. late frames) will be dropped.
|
|
|
|
|
assetWriter.startSession(atSourceTime: currentTime)
|
|
|
|
|
startTimestamp = currentTime
|
|
|
|
|
ReactLogger.log(level: .info, message: "Started RecordingSession at time: \(currentTime.seconds)")
|
2023-11-27 06:43:48 -07:00
|
|
|
|
|
|
|
|
|
if audioWriter == nil {
|
2023-11-28 12:23:28 -07:00
|
|
|
|
// Audio was disabled, mark the Audio track as finished so we won't wait for it.
|
2023-11-27 06:43:48 -07:00
|
|
|
|
hasWrittenLastAudioFrame = true
|
|
|
|
|
}
|
2021-05-06 06:11:55 -06:00
|
|
|
|
}
|
|
|
|
|
|
2021-06-09 06:56:56 -06:00
|
|
|
|
/**
|
2023-11-23 10:17:15 -07:00
|
|
|
|
Requests the RecordingSession to stop writing frames at the current time of the provided synchronization clock.
|
|
|
|
|
The RecordingSession will continue to write video frames and audio frames for a little longer if there was a delay
|
|
|
|
|
in the video pipeline (e.g. caused by video stabilization) to avoid the video cutting off late frames.
|
|
|
|
|
Once all late frames have been captured (or an artificial abort timeout has been triggered), the [completionHandler] will be called.
|
2021-06-09 06:56:56 -06:00
|
|
|
|
*/
|
2023-11-23 10:17:15 -07:00
|
|
|
|
func stop(clock: CMClock) {
|
|
|
|
|
lock.wait()
|
|
|
|
|
defer {
|
|
|
|
|
lock.signal()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Current time of the synchronization clock (e.g. from [AVCaptureSession]) - this marks the end of the video.
|
|
|
|
|
let currentTime = CMClockGetTime(clock)
|
|
|
|
|
|
|
|
|
|
// Request a stop at the given time. Frames with later timestamps (e.g. early frames, while we are waiting for late frames) will be dropped.
|
|
|
|
|
stopTimestamp = currentTime
|
|
|
|
|
ReactLogger.log(level: .info,
|
|
|
|
|
message: "Requesting stop at \(currentTime.seconds) seconds for AssetWriter with status \"\(assetWriter.status.descriptor)\"...")
|
|
|
|
|
|
|
|
|
|
// Start a timeout that will force-stop the session if none of the late frames actually arrive
|
|
|
|
|
CameraQueues.cameraQueue.asyncAfter(deadline: .now() + automaticallyStopTimeoutSeconds) {
|
|
|
|
|
if !self.isFinishing {
|
|
|
|
|
ReactLogger.log(level: .error, message: "Waited \(self.automaticallyStopTimeoutSeconds) seconds but no late Frames came in, aborting capture...")
|
|
|
|
|
self.finish()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
Appends a new CMSampleBuffer to the Asset Writer.
|
|
|
|
|
- Use clock to specify the CMClock instance this CMSampleBuffer uses for relative time
|
|
|
|
|
- Use bufferType to specify if this is a video or audio frame.
|
|
|
|
|
*/
|
|
|
|
|
func appendBuffer(_ buffer: CMSampleBuffer, clock _: CMClock, type bufferType: BufferType) {
|
2023-11-28 04:10:21 -07:00
|
|
|
|
// 1. Prepare the data
|
2023-11-23 10:17:15 -07:00
|
|
|
|
guard !isFinishing else {
|
|
|
|
|
// Session is already finishing, can't write anything more
|
|
|
|
|
return
|
|
|
|
|
}
|
2021-06-09 06:56:56 -06:00
|
|
|
|
guard assetWriter.status == .writing else {
|
|
|
|
|
ReactLogger.log(level: .error, message: "Frame arrived, but AssetWriter status is \(assetWriter.status.descriptor)!")
|
|
|
|
|
return
|
|
|
|
|
}
|
2021-05-06 06:11:55 -06:00
|
|
|
|
if !CMSampleBufferDataIsReady(buffer) {
|
2021-06-09 06:56:56 -06:00
|
|
|
|
ReactLogger.log(level: .error, message: "Frame arrived, but sample buffer is not ready!")
|
2021-05-06 06:11:55 -06:00
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2023-11-28 04:10:21 -07:00
|
|
|
|
// 2. Check the timing of the buffer and make sure it's not after we requested a session stop
|
2023-11-23 10:17:15 -07:00
|
|
|
|
let timestamp = CMSampleBufferGetPresentationTimeStamp(buffer)
|
|
|
|
|
if let stopTimestamp = stopTimestamp,
|
|
|
|
|
timestamp >= stopTimestamp {
|
|
|
|
|
// This Frame is exactly at, or after the point in time when RecordingSession.stop() has been called.
|
|
|
|
|
// Consider this the last Frame we write
|
|
|
|
|
switch bufferType {
|
|
|
|
|
case .video:
|
|
|
|
|
if hasWrittenLastVideoFrame {
|
|
|
|
|
// already wrote last Video Frame before, so skip this one.
|
|
|
|
|
return
|
|
|
|
|
}
|
2023-11-28 04:10:21 -07:00
|
|
|
|
hasWrittenLastVideoFrame = true // flip to true, then fallthrough & write it
|
2023-11-23 10:17:15 -07:00
|
|
|
|
case .audio:
|
|
|
|
|
if hasWrittenLastAudioFrame {
|
|
|
|
|
// already wrote last Audio Frame before, so skip this one.
|
|
|
|
|
return
|
|
|
|
|
}
|
2023-11-28 04:10:21 -07:00
|
|
|
|
hasWrittenLastAudioFrame = true // flip to true, then fallthrough & write it
|
2021-05-06 06:11:55 -06:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-11-23 10:17:15 -07:00
|
|
|
|
// 3. Actually write the Buffer to the AssetWriter
|
|
|
|
|
let writer = getAssetWriter(forType: bufferType)
|
|
|
|
|
guard writer.isReadyForMoreMediaData else {
|
|
|
|
|
ReactLogger.log(level: .warning, message: "\(bufferType) AssetWriter is not ready for more data, dropping this Frame...")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writer.append(buffer)
|
|
|
|
|
lastWrittenTimestamp = timestamp
|
|
|
|
|
|
|
|
|
|
// 4. If we failed to write the frames, stop the Recording
|
2021-05-06 06:11:55 -06:00
|
|
|
|
if assetWriter.status == .failed {
|
2021-06-03 06:16:02 -06:00
|
|
|
|
ReactLogger.log(level: .error,
|
2021-09-06 08:27:16 -06:00
|
|
|
|
message: "AssetWriter failed to write buffer! Error: \(assetWriter.error?.localizedDescription ?? "none")")
|
2021-06-09 06:56:56 -06:00
|
|
|
|
finish()
|
2021-05-06 06:11:55 -06:00
|
|
|
|
}
|
2023-11-23 10:17:15 -07:00
|
|
|
|
|
|
|
|
|
// 5. If we finished writing both the last video and audio buffers, finish the recording
|
|
|
|
|
if hasWrittenLastAudioFrame && hasWrittenLastVideoFrame {
|
|
|
|
|
ReactLogger.log(level: .info, message: "Successfully appended last \(bufferType) Buffer (at \(timestamp.seconds) seconds), finishing RecordingSession...")
|
|
|
|
|
finish()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func getAssetWriter(forType type: BufferType) -> AVAssetWriterInput {
|
|
|
|
|
switch type {
|
|
|
|
|
case .video:
|
|
|
|
|
guard let videoWriter = videoWriter else {
|
|
|
|
|
fatalError("Tried to append to a Video Buffer, which was nil!")
|
|
|
|
|
}
|
|
|
|
|
return videoWriter
|
|
|
|
|
case .audio:
|
|
|
|
|
guard let audioWriter = audioWriter else {
|
|
|
|
|
fatalError("Tried to append to a Audio Buffer, which was nil!")
|
|
|
|
|
}
|
|
|
|
|
return audioWriter
|
|
|
|
|
}
|
2021-05-06 06:11:55 -06:00
|
|
|
|
}
|
|
|
|
|
|
2021-06-09 06:56:56 -06:00
|
|
|
|
/**
|
2023-11-23 10:17:15 -07:00
|
|
|
|
Stops the AssetWriters and calls the completion callback.
|
2021-06-09 06:56:56 -06:00
|
|
|
|
*/
|
2023-11-23 10:17:15 -07:00
|
|
|
|
private func finish() {
|
|
|
|
|
lock.wait()
|
|
|
|
|
defer {
|
|
|
|
|
lock.signal()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ReactLogger.log(level: .info, message: "Stopping AssetWriter with status \"\(assetWriter.status.descriptor)\"...")
|
2021-06-09 06:56:56 -06:00
|
|
|
|
|
2023-11-23 10:17:15 -07:00
|
|
|
|
guard !isFinishing else {
|
2022-05-23 06:27:25 -06:00
|
|
|
|
ReactLogger.log(level: .warning, message: "Tried calling finish() twice while AssetWriter is still writing!")
|
|
|
|
|
return
|
|
|
|
|
}
|
2023-11-23 10:17:15 -07:00
|
|
|
|
guard assetWriter.status == .writing else {
|
2022-04-15 01:48:32 -06:00
|
|
|
|
completionHandler(self, assetWriter.status, assetWriter.error)
|
2023-11-23 10:17:15 -07:00
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isFinishing = true
|
|
|
|
|
videoWriter?.markAsFinished()
|
|
|
|
|
audioWriter?.markAsFinished()
|
|
|
|
|
assetWriter.finishWriting {
|
|
|
|
|
self.completionHandler(self, self.assetWriter.status, self.assetWriter.error)
|
2021-05-06 06:11:55 -06:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|