mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 06:14:14 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
886 lines
32 KiB
Swift
886 lines
32 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AVFoundation
|
|
import CoreMedia
|
|
import CoreVideo
|
|
import VideoToolbox
|
|
import Camera
|
|
// GHOST: round video bitrate / codec / watermark from the Ghostgram settings
|
|
import GGCameraPipeline
|
|
|
|
private extension CMSampleBuffer {
|
|
var presentationTime: CMTime {
|
|
return CMSampleBufferGetPresentationTimeStamp(self)
|
|
}
|
|
|
|
var endTime: CMTime {
|
|
let duration = CMSampleBufferGetDuration(self)
|
|
if duration.isValid && duration.isNumeric {
|
|
return self.presentationTime + duration
|
|
} else {
|
|
return self.presentationTime
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PendingVideoFrame {
|
|
let source: VideoFrameSource
|
|
let rawPresentationTime: CMTime
|
|
let presentationTime: CMTime
|
|
}
|
|
|
|
private struct PendingAudioSample {
|
|
let sampleBuffer: CMSampleBuffer
|
|
let timelinePresentationTime: CMTime
|
|
let timelineEndTime: CMTime
|
|
}
|
|
|
|
private struct VideoTransition {
|
|
let from: Camera.Position
|
|
let to: Camera.Position
|
|
var startTime: CMTime?
|
|
}
|
|
|
|
private struct SingleCameraSwitchRetiming {
|
|
let generation: Int
|
|
let to: Camera.Position
|
|
let canMeasureAudioGap: Bool
|
|
var isCommitted = false
|
|
var isResolved = false
|
|
var accumulatedAudioGap: CMTime = .zero
|
|
var heldFrame: PendingVideoFrame?
|
|
}
|
|
|
|
final class VideoRecorder {
|
|
enum Result {
|
|
case success(
|
|
url: URL,
|
|
thumbnail: UIImage?,
|
|
duration: Double,
|
|
positionChangeTimestamps: [(Camera.Position, Double)]
|
|
)
|
|
case failed
|
|
}
|
|
|
|
private enum State: Equatable {
|
|
case arming
|
|
case writing
|
|
case stopping
|
|
case finishing
|
|
case finished
|
|
case failed
|
|
}
|
|
|
|
private static let minimumDuration: Double = 1.5
|
|
private static let maximumPendingAudioDuration: Double = 1.0
|
|
private static let transitionDuration: Double = 0.2
|
|
private static let singleCameraRetimingTimeout: Double = 0.5
|
|
|
|
private let mediaQueue: DispatchQueue
|
|
private let processor: RoundVideoFrameProcessor
|
|
private let url: URL
|
|
private let hasAudio: Bool
|
|
private let durationUpdated: (Double) -> Void
|
|
private let transitionImageUpdated: (UIImage?) -> Void
|
|
private let runtimeFailure: (CameraRecordingError) -> Void
|
|
private let completion: (Result) -> Void
|
|
// GHOST: round video settings of this recording and its prepared watermark
|
|
private let ggRecording: GGRoundVideoRecordingConfig
|
|
private let ggWatermark: GGRoundVideoWatermark?
|
|
|
|
private var state: State = .arming
|
|
private var assetWriter: AVAssetWriter?
|
|
private var videoInput: AVAssetWriterInput?
|
|
private var videoAdaptor: AVAssetWriterInputPixelBufferAdaptor?
|
|
private var audioInput: AVAssetWriterInput?
|
|
private var audioFormatDescription: CMFormatDescription?
|
|
|
|
private var pendingFirstVideoFrame: PendingVideoFrame?
|
|
private var pendingAudioBuffers: [PendingAudioSample] = []
|
|
|
|
private var recordingStartTime: CMTime?
|
|
private var lastVideoTime: CMTime?
|
|
private var videoTimelineOffset: CMTime = .zero
|
|
private var lastReceivedAudioTime: CMTime?
|
|
private var lastReceivedAudioEndTime: CMTime?
|
|
private var lastAudioEndTime: CMTime?
|
|
private var lastAudioTimelineEndTime: CMTime?
|
|
private var stopTargetTime: CMTime?
|
|
private var stopRequested = false
|
|
private var videoReachedStopTarget = false
|
|
private var audioReachedStopTarget: Bool
|
|
|
|
private var activePosition: Camera.Position
|
|
private var transition: VideoTransition?
|
|
private var latestBackFrame: VideoFrameSource?
|
|
private var latestFrontFrame: VideoFrameSource?
|
|
private var positionChangeTimestamps: [(Camera.Position, Double)] = []
|
|
private var singleCameraSwitch: SingleCameraSwitchRetiming?
|
|
|
|
private var thumbnail: UIImage?
|
|
private var hasGeneratedThumbnail = false
|
|
|
|
init(
|
|
mediaQueue: DispatchQueue,
|
|
processor: RoundVideoFrameProcessor,
|
|
url: URL,
|
|
hasAudio: Bool,
|
|
initialPosition: Camera.Position,
|
|
ggRecording: GGRoundVideoRecordingConfig = .standard, // GHOST: round video settings and watermark of this recording
|
|
ggWatermark: GGRoundVideoWatermark? = nil,
|
|
durationUpdated: @escaping (Double) -> Void,
|
|
transitionImageUpdated: @escaping (UIImage?) -> Void,
|
|
runtimeFailure: @escaping (CameraRecordingError) -> Void,
|
|
completion: @escaping (Result) -> Void
|
|
) {
|
|
self.mediaQueue = mediaQueue
|
|
self.processor = processor
|
|
self.url = url
|
|
self.hasAudio = hasAudio
|
|
self.activePosition = initialPosition
|
|
self.ggRecording = ggRecording // GHOST: see `ggRecording`
|
|
self.ggWatermark = ggWatermark
|
|
self.durationUpdated = durationUpdated
|
|
self.transitionImageUpdated = transitionImageUpdated
|
|
self.runtimeFailure = runtimeFailure
|
|
self.completion = completion
|
|
self.audioReachedStopTarget = !hasAudio
|
|
}
|
|
|
|
func beginSingleCameraSwitch(generation: Int, from: Camera.Position, to: Camera.Position) {
|
|
dispatchPrecondition(condition: .onQueue(self.mediaQueue))
|
|
guard !self.isTerminal, from != to else {
|
|
return
|
|
}
|
|
|
|
var carriedAudioGap: CMTime = .zero
|
|
if let current = self.singleCameraSwitch {
|
|
if !current.isResolved {
|
|
carriedAudioGap = current.accumulatedAudioGap
|
|
}
|
|
}
|
|
|
|
self.singleCameraSwitch = SingleCameraSwitchRetiming(
|
|
generation: generation,
|
|
to: to,
|
|
canMeasureAudioGap: self.hasAudio && self.lastReceivedAudioEndTime != nil && self.lastVideoTime != nil,
|
|
accumulatedAudioGap: carriedAudioGap
|
|
)
|
|
}
|
|
|
|
func noteSingleCameraSwitchCommitted(generation: Int) {
|
|
dispatchPrecondition(condition: .onQueue(self.mediaQueue))
|
|
guard var retiming = self.singleCameraSwitch,
|
|
retiming.generation == generation,
|
|
!retiming.isCommitted else {
|
|
return
|
|
}
|
|
retiming.isCommitted = true
|
|
self.singleCameraSwitch = retiming
|
|
|
|
if !retiming.canMeasureAudioGap {
|
|
self.resolveSingleCameraSwitch(generation: generation, applyAccumulatedAudioGap: true, checkFinish: true)
|
|
return
|
|
}
|
|
|
|
self.mediaQueue.asyncAfter(deadline: .now() + Self.singleCameraRetimingTimeout) { [weak self] in
|
|
self?.resolveSingleCameraSwitch(
|
|
generation: generation,
|
|
applyAccumulatedAudioGap: false,
|
|
checkFinish: true
|
|
)
|
|
}
|
|
}
|
|
|
|
private func resolveSingleCameraSwitch(generation: Int, applyAccumulatedAudioGap: Bool, checkFinish: Bool = false) {
|
|
dispatchPrecondition(condition: .onQueue(self.mediaQueue))
|
|
guard var retiming = self.singleCameraSwitch,
|
|
retiming.generation == generation,
|
|
retiming.isCommitted,
|
|
!retiming.isResolved else {
|
|
return
|
|
}
|
|
|
|
let appliedAudioGap = applyAccumulatedAudioGap ? retiming.accumulatedAudioGap : .zero
|
|
if appliedAudioGap.isValid && appliedAudioGap.isNumeric && appliedAudioGap > .zero {
|
|
self.videoTimelineOffset = self.videoTimelineOffset + appliedAudioGap
|
|
}
|
|
retiming.isResolved = true
|
|
|
|
let heldFrame = retiming.heldFrame
|
|
retiming.heldFrame = nil
|
|
self.singleCameraSwitch = retiming
|
|
|
|
if let heldFrame {
|
|
self.submitVideoFrame(self.retimedVideoFrame(heldFrame), allowPastStopBoundary: true)
|
|
self.singleCameraSwitch = nil
|
|
}
|
|
if checkFinish && heldFrame == nil {
|
|
self.maybeFinish()
|
|
}
|
|
}
|
|
|
|
private func retimedVideoFrame(_ frame: PendingVideoFrame) -> PendingVideoFrame {
|
|
return PendingVideoFrame(
|
|
source: frame.source,
|
|
rawPresentationTime: frame.rawPresentationTime,
|
|
presentationTime: frame.rawPresentationTime - self.videoTimelineOffset
|
|
)
|
|
}
|
|
|
|
private func consumeFirstSingleCameraSwitchVideoFrameIfNeeded(_ frame: PendingVideoFrame) -> Bool {
|
|
guard let retiming = self.singleCameraSwitch,
|
|
retiming.isResolved,
|
|
retiming.to == frame.source.position else {
|
|
return false
|
|
}
|
|
self.singleCameraSwitch = nil
|
|
return true
|
|
}
|
|
|
|
private func discardSingleCameraSwitch() {
|
|
self.singleCameraSwitch = nil
|
|
}
|
|
|
|
func beginPositionChange(to position: Camera.Position) {
|
|
dispatchPrecondition(condition: .onQueue(self.mediaQueue))
|
|
guard position != self.activePosition else {
|
|
return
|
|
}
|
|
self.transition = VideoTransition(from: self.activePosition, to: position, startTime: nil)
|
|
self.activePosition = position
|
|
}
|
|
|
|
func appendVideoSampleBuffer(
|
|
_ sampleBuffer: CMSampleBuffer,
|
|
position: Camera.Position,
|
|
orientation: AVCaptureVideoOrientation
|
|
) {
|
|
dispatchPrecondition(condition: .onQueue(self.mediaQueue))
|
|
guard !self.isTerminal, self.state != .finishing else {
|
|
return
|
|
}
|
|
|
|
guard CMSampleBufferDataIsReady(sampleBuffer),
|
|
let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer),
|
|
let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer) else {
|
|
return
|
|
}
|
|
let rawPresentationTime = sampleBuffer.presentationTime
|
|
guard rawPresentationTime.isValid && rawPresentationTime.isNumeric else {
|
|
return
|
|
}
|
|
|
|
let source = VideoFrameSource(
|
|
pixelBuffer: pixelBuffer,
|
|
formatDescription: formatDescription,
|
|
position: position,
|
|
orientation: orientation
|
|
)
|
|
self.setLatestFrame(source, for: position)
|
|
|
|
guard position == self.activePosition else {
|
|
return
|
|
}
|
|
|
|
let frame = PendingVideoFrame(
|
|
source: source,
|
|
rawPresentationTime: rawPresentationTime,
|
|
presentationTime: rawPresentationTime - self.videoTimelineOffset
|
|
)
|
|
if var retiming = self.singleCameraSwitch,
|
|
retiming.isCommitted,
|
|
!retiming.isResolved,
|
|
retiming.to == position {
|
|
if retiming.heldFrame == nil {
|
|
retiming.heldFrame = frame
|
|
}
|
|
self.singleCameraSwitch = retiming
|
|
return
|
|
}
|
|
|
|
let isFirstSwitchFrame = self.consumeFirstSingleCameraSwitchVideoFrameIfNeeded(frame)
|
|
self.submitVideoFrame(frame, allowPastStopBoundary: isFirstSwitchFrame)
|
|
}
|
|
|
|
private func submitVideoFrame(_ frame: PendingVideoFrame, allowPastStopBoundary: Bool = false) {
|
|
if let lastVideoTime = self.lastVideoTime, frame.presentationTime <= lastVideoTime {
|
|
return
|
|
}
|
|
if self.stopRequested && self.videoReachedStopTarget && !allowPastStopBoundary {
|
|
return
|
|
}
|
|
|
|
if self.assetWriter == nil {
|
|
if self.pendingFirstVideoFrame == nil {
|
|
self.pendingFirstVideoFrame = frame
|
|
}
|
|
self.prepareWriterIfNeeded(firstVideoFrame: frame)
|
|
self.tryStartWriting()
|
|
return
|
|
}
|
|
|
|
guard self.state == .writing || self.state == .stopping else {
|
|
return
|
|
}
|
|
self.appendPreparedVideoFrame(frame)
|
|
}
|
|
|
|
func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer) {
|
|
dispatchPrecondition(condition: .onQueue(self.mediaQueue))
|
|
guard self.hasAudio, !self.isTerminal, self.state != .finishing, CMSampleBufferDataIsReady(sampleBuffer),
|
|
let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer),
|
|
CMFormatDescriptionGetMediaType(formatDescription) == kCMMediaType_Audio else {
|
|
return
|
|
}
|
|
|
|
let rawPresentationTime = sampleBuffer.presentationTime
|
|
let rawEndTime = sampleBuffer.endTime
|
|
guard rawPresentationTime.isValid && rawPresentationTime.isNumeric,
|
|
rawEndTime.isValid && rawEndTime.isNumeric else {
|
|
return
|
|
}
|
|
if let lastReceivedAudioTime = self.lastReceivedAudioTime, rawPresentationTime <= lastReceivedAudioTime {
|
|
return
|
|
}
|
|
let previousAudioEndTime = self.lastReceivedAudioEndTime
|
|
self.lastReceivedAudioTime = rawPresentationTime
|
|
self.lastReceivedAudioEndTime = rawEndTime
|
|
|
|
var shouldResolveSingleCameraSwitch = false
|
|
var resolvingGeneration = 0
|
|
if var retiming = self.singleCameraSwitch, !retiming.isResolved {
|
|
if let previousAudioEndTime {
|
|
let gap = rawPresentationTime - previousAudioEndTime
|
|
if gap.isValid && gap.isNumeric && gap > .zero {
|
|
retiming.accumulatedAudioGap = retiming.accumulatedAudioGap + gap
|
|
}
|
|
}
|
|
if retiming.isCommitted {
|
|
shouldResolveSingleCameraSwitch = true
|
|
resolvingGeneration = retiming.generation
|
|
}
|
|
self.singleCameraSwitch = retiming
|
|
}
|
|
|
|
if shouldResolveSingleCameraSwitch {
|
|
self.resolveSingleCameraSwitch(generation: resolvingGeneration, applyAccumulatedAudioGap: true)
|
|
}
|
|
|
|
let timelinePresentationTime = rawPresentationTime - self.videoTimelineOffset
|
|
let timelineEndTime = rawEndTime - self.videoTimelineOffset
|
|
if self.stopRequested && self.audioReachedStopTarget {
|
|
return
|
|
}
|
|
|
|
if self.audioFormatDescription == nil {
|
|
self.audioFormatDescription = formatDescription
|
|
if self.assetWriter != nil {
|
|
self.addAudioInputIfPossible(formatDescription: formatDescription)
|
|
}
|
|
}
|
|
|
|
self.pendingAudioBuffers.append(PendingAudioSample(
|
|
sampleBuffer: sampleBuffer,
|
|
timelinePresentationTime: timelinePresentationTime,
|
|
timelineEndTime: timelineEndTime
|
|
))
|
|
if self.recordingStartTime == nil {
|
|
self.trimPendingAudioBeforeVideoStart()
|
|
} else if !self.validatePendingAudioDuration() {
|
|
self.fail(.audioInitializationError)
|
|
return
|
|
}
|
|
|
|
self.tryStartWriting()
|
|
if self.state == .writing || self.state == .stopping {
|
|
self.drainPendingAudioBuffers()
|
|
self.updateAudioStopState(with: timelineEndTime)
|
|
self.maybeFinish()
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
dispatchPrecondition(condition: .onQueue(self.mediaQueue))
|
|
guard !self.isTerminal, self.state != .finishing, !self.stopRequested else {
|
|
return
|
|
}
|
|
|
|
self.stopRequested = true
|
|
if self.state == .writing {
|
|
self.state = .stopping
|
|
}
|
|
self.updateStopTargetIfPossible()
|
|
self.updateStopStateFromLastSamples()
|
|
|
|
let currentDuration: Double
|
|
if let startTime = self.recordingStartTime, let lastVideoTime = self.lastVideoTime {
|
|
currentDuration = max(0.0, (lastVideoTime - startTime).seconds)
|
|
} else {
|
|
currentDuration = 0.0
|
|
}
|
|
let deadlineDelay = max(0.0, Self.minimumDuration - currentDuration) + 1.0
|
|
self.mediaQueue.asyncAfter(deadline: .now() + deadlineDelay) { [weak self] in
|
|
self?.finishAtDeadline()
|
|
}
|
|
self.maybeFinish()
|
|
}
|
|
|
|
func cancel() {
|
|
dispatchPrecondition(condition: .onQueue(self.mediaQueue))
|
|
guard !self.isTerminal else {
|
|
return
|
|
}
|
|
self.state = .failed
|
|
self.assetWriter?.cancelWriting()
|
|
self.discardSingleCameraSwitch()
|
|
self.pendingAudioBuffers.removeAll()
|
|
self.pendingFirstVideoFrame = nil
|
|
self.latestBackFrame = nil
|
|
self.latestFrontFrame = nil
|
|
try? FileManager.default.removeItem(at: self.url)
|
|
self.complete(.failed)
|
|
}
|
|
|
|
func failInitialization(_ error: CameraRecordingError) {
|
|
dispatchPrecondition(condition: .onQueue(self.mediaQueue))
|
|
self.fail(error)
|
|
}
|
|
|
|
private var isTerminal: Bool {
|
|
switch self.state {
|
|
case .finished, .failed:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func prepareWriterIfNeeded(firstVideoFrame: PendingVideoFrame) {
|
|
guard self.assetWriter == nil else {
|
|
return
|
|
}
|
|
try? FileManager.default.removeItem(at: self.url)
|
|
|
|
let assetWriter: AVAssetWriter
|
|
do {
|
|
assetWriter = try AVAssetWriter(url: self.url, fileType: .mp4)
|
|
} catch {
|
|
self.fail()
|
|
return
|
|
}
|
|
assetWriter.shouldOptimizeForNetworkUse = false
|
|
|
|
let compressionProperties: [String: Any] = [
|
|
AVVideoAverageBitRateKey: self.ggRecording.bitrate(defaultKbps: 1200), // GHOST: bitrate setting
|
|
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
|
|
AVVideoH264EntropyModeKey: AVVideoH264EntropyModeCABAC
|
|
]
|
|
var videoSettings: [String: Any] = [ // GHOST: `var` — HEVC from the settings may replace it below
|
|
AVVideoCodecKey: AVVideoCodecType.h264,
|
|
AVVideoWidthKey: self.processor.outputSide, // GHOST: round video resolution
|
|
AVVideoHeightKey: self.processor.outputSide,
|
|
AVVideoCompressionPropertiesKey: compressionProperties,
|
|
AVVideoColorPropertiesKey: RoundVideoFrameProcessor.videoColorProperties(from: firstVideoFrame.source)
|
|
]
|
|
// GHOST: HEVC when chosen and supported; H.264 above stays the fallback
|
|
if self.ggRecording.useHEVC {
|
|
var hevcSettings = videoSettings
|
|
hevcSettings[AVVideoCodecKey] = AVVideoCodecType.hevc
|
|
hevcSettings[AVVideoCompressionPropertiesKey] = [AVVideoAverageBitRateKey: self.ggRecording.bitrate(defaultKbps: 1200)]
|
|
if assetWriter.canApply(outputSettings: hevcSettings, forMediaType: .video) {
|
|
videoSettings = hevcSettings
|
|
}
|
|
}
|
|
guard assetWriter.canApply(outputSettings: videoSettings, forMediaType: .video) else {
|
|
self.fail()
|
|
return
|
|
}
|
|
|
|
let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
|
|
videoInput.expectsMediaDataInRealTime = true
|
|
guard assetWriter.canAdd(videoInput) else {
|
|
self.fail()
|
|
return
|
|
}
|
|
assetWriter.add(videoInput)
|
|
|
|
let sourcePixelBufferAttributes: [String: Any] = [
|
|
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
|
kCVPixelBufferWidthKey as String: self.processor.outputSide, // GHOST: round video resolution
|
|
kCVPixelBufferHeightKey as String: self.processor.outputSide,
|
|
kCVPixelBufferMetalCompatibilityKey as String: true,
|
|
kCVPixelBufferIOSurfacePropertiesKey as String: [:] as NSDictionary
|
|
]
|
|
|
|
self.assetWriter = assetWriter
|
|
self.videoInput = videoInput
|
|
self.videoAdaptor = AVAssetWriterInputPixelBufferAdaptor(
|
|
assetWriterInput: videoInput,
|
|
sourcePixelBufferAttributes: sourcePixelBufferAttributes
|
|
)
|
|
|
|
if let audioFormatDescription = self.audioFormatDescription {
|
|
self.addAudioInputIfPossible(formatDescription: audioFormatDescription)
|
|
}
|
|
}
|
|
|
|
private func addAudioInputIfPossible(formatDescription: CMFormatDescription) {
|
|
guard self.hasAudio, self.audioInput == nil, let assetWriter = self.assetWriter, assetWriter.status == .unknown else {
|
|
return
|
|
}
|
|
|
|
var audioSettings: [String: Any] = [
|
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
AVEncoderBitRateKey: 64_000
|
|
]
|
|
if let streamDescription = CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription) {
|
|
audioSettings[AVSampleRateKey] = streamDescription.pointee.mSampleRate
|
|
audioSettings[AVNumberOfChannelsKey] = streamDescription.pointee.mChannelsPerFrame
|
|
}
|
|
|
|
var channelLayoutSize = 0
|
|
if let channelLayout = CMAudioFormatDescriptionGetChannelLayout(formatDescription, sizeOut: &channelLayoutSize), channelLayoutSize > 0 {
|
|
audioSettings[AVChannelLayoutKey] = Data(bytes: channelLayout, count: channelLayoutSize)
|
|
}
|
|
|
|
guard assetWriter.canApply(outputSettings: audioSettings, forMediaType: .audio) else {
|
|
self.fail(.audioInitializationError)
|
|
return
|
|
}
|
|
let audioInput = AVAssetWriterInput(
|
|
mediaType: .audio,
|
|
outputSettings: audioSettings,
|
|
sourceFormatHint: formatDescription
|
|
)
|
|
audioInput.expectsMediaDataInRealTime = true
|
|
guard assetWriter.canAdd(audioInput) else {
|
|
self.fail(.audioInitializationError)
|
|
return
|
|
}
|
|
assetWriter.add(audioInput)
|
|
self.audioInput = audioInput
|
|
}
|
|
|
|
private func tryStartWriting() {
|
|
guard self.state == .arming,
|
|
let assetWriter = self.assetWriter,
|
|
let pendingFirstVideoFrame = self.pendingFirstVideoFrame,
|
|
!self.hasAudio || self.audioInput != nil else {
|
|
return
|
|
}
|
|
|
|
guard assetWriter.startWriting() else {
|
|
self.fail()
|
|
return
|
|
}
|
|
assetWriter.startSession(atSourceTime: pendingFirstVideoFrame.presentationTime)
|
|
self.state = self.stopRequested ? .stopping : .writing
|
|
self.pendingFirstVideoFrame = nil
|
|
|
|
self.appendPreparedVideoFrame(pendingFirstVideoFrame)
|
|
self.drainPendingAudioBuffers()
|
|
}
|
|
|
|
private func appendPreparedVideoFrame(_ frame: PendingVideoFrame) {
|
|
guard self.state == .writing || self.state == .stopping,
|
|
let videoInput = self.videoInput else {
|
|
return
|
|
}
|
|
guard videoInput.isReadyForMoreMediaData else {
|
|
return
|
|
}
|
|
|
|
let renderInputs = self.renderInputs(for: frame)
|
|
let animationTime: Double
|
|
if let recordingStartTime = self.recordingStartTime {
|
|
animationTime = max(0.0, (frame.presentationTime - recordingStartTime).seconds)
|
|
} else {
|
|
animationTime = 0.0
|
|
}
|
|
self.processor.render(
|
|
primary: renderInputs.primary,
|
|
secondary: renderInputs.secondary,
|
|
mixFactor: renderInputs.mixFactor,
|
|
animationTime: animationTime,
|
|
completion: { [weak self] processedFrame in
|
|
guard let self else {
|
|
return
|
|
}
|
|
guard self.state == .writing || self.state == .stopping else {
|
|
return
|
|
}
|
|
guard let processedFrame else {
|
|
return
|
|
}
|
|
self.appendProcessedVideoFrame(
|
|
processedFrame,
|
|
sourceFrame: frame,
|
|
transitionCompleted: renderInputs.transitionCompleted
|
|
)
|
|
}
|
|
)
|
|
}
|
|
|
|
private func appendProcessedVideoFrame(
|
|
_ processedFrame: ProcessedVideoFrame,
|
|
sourceFrame frame: PendingVideoFrame,
|
|
transitionCompleted: Bool
|
|
) {
|
|
guard self.state == .writing || self.state == .stopping,
|
|
let videoInput = self.videoInput,
|
|
let videoAdaptor = self.videoAdaptor else {
|
|
return
|
|
}
|
|
guard videoInput.isReadyForMoreMediaData else {
|
|
return
|
|
}
|
|
|
|
// GHOST: round video watermark over the finished square (the text is rendered once per recording)
|
|
self.ggWatermark?.draw(into: processedFrame.pixelBuffer)
|
|
|
|
guard videoAdaptor.append(processedFrame.pixelBuffer, withPresentationTime: frame.presentationTime) else {
|
|
if self.assetWriter?.error != nil {
|
|
self.fail()
|
|
}
|
|
return
|
|
}
|
|
|
|
if self.recordingStartTime == nil {
|
|
self.recordingStartTime = frame.presentationTime
|
|
self.updateStopTargetIfPossible()
|
|
}
|
|
self.lastVideoTime = frame.presentationTime
|
|
if let recordingStartTime = self.recordingStartTime {
|
|
let duration = max(0.0, (frame.presentationTime - recordingStartTime).seconds)
|
|
self.durationUpdated(duration)
|
|
}
|
|
|
|
if !self.hasGeneratedThumbnail {
|
|
self.hasGeneratedThumbnail = true
|
|
var cgImage: CGImage?
|
|
if VTCreateCGImageFromCVPixelBuffer(processedFrame.pixelBuffer, options: nil, imageOut: &cgImage) == noErr, let cgImage {
|
|
let image = UIImage(cgImage: cgImage)
|
|
self.thumbnail = image
|
|
self.transitionImageUpdated(image)
|
|
}
|
|
}
|
|
|
|
if transitionCompleted {
|
|
self.transition = nil
|
|
}
|
|
self.updateVideoStopState(with: frame.presentationTime)
|
|
self.drainPendingAudioBuffers()
|
|
self.maybeFinish()
|
|
}
|
|
|
|
private func renderInputs(for frame: PendingVideoFrame) -> (
|
|
primary: VideoFrameSource,
|
|
secondary: VideoFrameSource?,
|
|
mixFactor: Float,
|
|
transitionCompleted: Bool
|
|
) {
|
|
guard var transition = self.transition, transition.to == frame.source.position else {
|
|
return (frame.source, nil, 0.0, false)
|
|
}
|
|
|
|
if transition.startTime == nil {
|
|
transition.startTime = frame.presentationTime
|
|
self.transition = transition
|
|
if let recordingStartTime = self.recordingStartTime {
|
|
self.positionChangeTimestamps.append((
|
|
transition.to,
|
|
max(0.0, (frame.presentationTime - recordingStartTime).seconds)
|
|
))
|
|
}
|
|
}
|
|
|
|
guard let startTime = transition.startTime, let previousFrame = self.latestFrame(for: transition.from) else {
|
|
return (frame.source, nil, 0.0, true)
|
|
}
|
|
let progress = max(0.0, min(1.0, (frame.presentationTime - startTime).seconds / Self.transitionDuration))
|
|
return (previousFrame, frame.source, Float(progress), progress >= 1.0)
|
|
}
|
|
|
|
private func drainPendingAudioBuffers() {
|
|
guard self.state == .writing || self.state == .stopping,
|
|
let audioInput = self.audioInput,
|
|
let recordingStartTime = self.recordingStartTime else {
|
|
return
|
|
}
|
|
|
|
while !self.pendingAudioBuffers.isEmpty && audioInput.isReadyForMoreMediaData {
|
|
let sample = self.pendingAudioBuffers[0]
|
|
if sample.timelinePresentationTime < recordingStartTime {
|
|
self.pendingAudioBuffers.removeFirst()
|
|
continue
|
|
}
|
|
if let stopTargetTime = self.stopTargetTime, sample.timelinePresentationTime >= stopTargetTime {
|
|
self.audioReachedStopTarget = true
|
|
self.pendingAudioBuffers.removeFirst()
|
|
continue
|
|
}
|
|
guard audioInput.append(sample.sampleBuffer) else {
|
|
self.fail(.audioInitializationError)
|
|
return
|
|
}
|
|
self.lastAudioEndTime = sample.sampleBuffer.endTime
|
|
self.lastAudioTimelineEndTime = sample.timelineEndTime
|
|
if let stopTargetTime = self.stopTargetTime, sample.timelineEndTime >= stopTargetTime {
|
|
self.audioReachedStopTarget = true
|
|
}
|
|
self.pendingAudioBuffers.removeFirst()
|
|
}
|
|
}
|
|
|
|
private func validatePendingAudioDuration() -> Bool {
|
|
return self.pendingAudioDuration <= Self.maximumPendingAudioDuration
|
|
}
|
|
|
|
private func trimPendingAudioBeforeVideoStart() {
|
|
while self.pendingAudioDuration > Self.maximumPendingAudioDuration && !self.pendingAudioBuffers.isEmpty {
|
|
self.pendingAudioBuffers.removeFirst()
|
|
}
|
|
}
|
|
|
|
private var pendingAudioDuration: Double {
|
|
guard let first = self.pendingAudioBuffers.first, let last = self.pendingAudioBuffers.last else {
|
|
return 0.0
|
|
}
|
|
return max(0.0, (last.sampleBuffer.endTime - first.sampleBuffer.presentationTime).seconds)
|
|
}
|
|
|
|
private func updateStopTargetIfPossible() {
|
|
guard self.stopRequested, self.stopTargetTime == nil, let recordingStartTime = self.recordingStartTime else {
|
|
return
|
|
}
|
|
let minimumStopTime = recordingStartTime + CMTime(seconds: Self.minimumDuration, preferredTimescale: 600)
|
|
let currentStopTime = self.lastVideoTime ?? recordingStartTime
|
|
self.stopTargetTime = currentStopTime > minimumStopTime ? currentStopTime : minimumStopTime
|
|
}
|
|
|
|
private func updateStopStateFromLastSamples() {
|
|
guard let stopTargetTime = self.stopTargetTime else {
|
|
return
|
|
}
|
|
if let lastVideoTime = self.lastVideoTime, lastVideoTime >= stopTargetTime {
|
|
self.videoReachedStopTarget = true
|
|
}
|
|
if !self.hasAudio || (self.lastAudioTimelineEndTime.map { $0 >= stopTargetTime } ?? false) {
|
|
self.audioReachedStopTarget = true
|
|
}
|
|
}
|
|
|
|
private func updateVideoStopState(with presentationTime: CMTime) {
|
|
guard let stopTargetTime = self.stopTargetTime, presentationTime >= stopTargetTime else {
|
|
return
|
|
}
|
|
self.videoReachedStopTarget = true
|
|
}
|
|
|
|
private func updateAudioStopState(with timelineEndTime: CMTime) {
|
|
guard let stopTargetTime = self.stopTargetTime, timelineEndTime >= stopTargetTime else {
|
|
return
|
|
}
|
|
self.audioReachedStopTarget = true
|
|
}
|
|
|
|
private func maybeFinish() {
|
|
guard self.stopRequested,
|
|
self.videoReachedStopTarget,
|
|
self.audioReachedStopTarget,
|
|
self.pendingAudioBuffers.isEmpty,
|
|
!(self.singleCameraSwitch.map { $0.isCommitted && !$0.isResolved } ?? false) else {
|
|
return
|
|
}
|
|
self.finishWriting()
|
|
}
|
|
|
|
private func finishAtDeadline() {
|
|
dispatchPrecondition(condition: .onQueue(self.mediaQueue))
|
|
guard !self.isTerminal, self.state != .finishing else {
|
|
return
|
|
}
|
|
guard self.assetWriter?.status == .writing, self.lastVideoTime != nil else {
|
|
self.fail()
|
|
return
|
|
}
|
|
self.pendingAudioBuffers.removeAll()
|
|
self.finishWriting()
|
|
}
|
|
|
|
private func finishWriting() {
|
|
guard self.state == .writing || self.state == .stopping,
|
|
let assetWriter = self.assetWriter,
|
|
let videoInput = self.videoInput,
|
|
let lastVideoTime = self.lastVideoTime else {
|
|
self.fail()
|
|
return
|
|
}
|
|
|
|
self.state = .finishing
|
|
let lastAudioEndTime = self.lastAudioEndTime ?? lastVideoTime
|
|
let endTime = lastAudioEndTime > lastVideoTime ? lastAudioEndTime : lastVideoTime
|
|
assetWriter.endSession(atSourceTime: endTime)
|
|
videoInput.markAsFinished()
|
|
self.audioInput?.markAsFinished()
|
|
assetWriter.finishWriting { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.mediaQueue.async {
|
|
if assetWriter.status == .completed, let recordingStartTime = self.recordingStartTime {
|
|
self.state = .finished
|
|
let duration = max(0.0, (lastVideoTime - recordingStartTime).seconds)
|
|
let timestamps = self.positionChangeTimestamps.map { ($0.0, $0.1) }
|
|
self.discardSingleCameraSwitch()
|
|
self.complete(.success(
|
|
url: self.url,
|
|
thumbnail: self.thumbnail,
|
|
duration: duration,
|
|
positionChangeTimestamps: timestamps
|
|
))
|
|
} else {
|
|
self.fail()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func fail(_ error: CameraRecordingError = .videoRecorderInitializationError) {
|
|
guard !self.isTerminal else {
|
|
return
|
|
}
|
|
self.state = .failed
|
|
self.assetWriter?.cancelWriting()
|
|
self.discardSingleCameraSwitch()
|
|
self.pendingAudioBuffers.removeAll()
|
|
self.pendingFirstVideoFrame = nil
|
|
try? FileManager.default.removeItem(at: self.url)
|
|
self.runtimeFailure(error)
|
|
self.complete(.failed)
|
|
}
|
|
|
|
private func complete(_ result: Result) {
|
|
self.latestBackFrame = nil
|
|
self.latestFrontFrame = nil
|
|
self.completion(result)
|
|
}
|
|
|
|
private func setLatestFrame(_ frame: VideoFrameSource, for position: Camera.Position) {
|
|
if position == .front {
|
|
self.latestFrontFrame = frame
|
|
} else {
|
|
self.latestBackFrame = frame
|
|
}
|
|
}
|
|
|
|
private func latestFrame(for position: Camera.Position) -> VideoFrameSource? {
|
|
if position == .front {
|
|
return self.latestFrontFrame
|
|
} else {
|
|
return self.latestBackFrame
|
|
}
|
|
}
|
|
}
|