Files
Luma/Sources/Shared/Services/VideoNoteRecorder.swift
T
wt 772d4505ea
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
fix
2026-09-07 20:08:57 +07:00

1572 lines
60 KiB
Swift

@preconcurrency import AVFoundation
import Combine
import CoreImage
import Foundation
import os
private let videoNoteFinalizeLogger = Logger(
subsystem: "Luma",
category: "video-note-finalize"
)
#if os(iOS)
import UIKit
#endif
@MainActor
final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRecordingDelegate {
struct Recording {
let url: URL
let duration: TimeInterval
}
@Published private(set) var isPrepared = false
@Published private(set) var isRecording = false
@Published private(set) var elapsed: TimeInterval = 0
@Published private(set) var isUsingFrontCamera = true
@Published private(set) var hasAlternateCamera = false
@Published private(set) var isMicrophoneMuted = false
let session = AVCaptureSession()
var onFinished: ((Result<Recording, Error>) -> Void)?
var hasRequiredAuthorization: Bool {
AVCaptureDevice.authorizationStatus(for: .video) == .authorized
&& AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
}
private let movieOutput = AVCaptureMovieFileOutput()
private let sessionQueue = DispatchQueue(
label: "app.luma.video-note.capture-session",
qos: .userInitiated
)
private let fileInspector = VideoNoteFileInspector()
private var cameraInput: AVCaptureDeviceInput?
private var microphoneInput: AVCaptureDeviceInput?
private var timer: Timer?
private var startedAt: Date?
private var discardCurrentRecording = false
private var stopRequested = false
private var requestedMinimumDuration: TimeInterval = 0
private var sessionGeneration = UUID()
private var lifecycle: VideoNoteRecordingLifecycle = .idle
private var activeRecordingURL: URL?
private var stopRequestTask: Task<Void, Never>?
private var startupTimeoutTask: Task<Void, Never>?
private var finalizationTimeoutTask: Task<Void, Never>?
private var fileValidationTask: Task<Void, Never>?
private var cameraSwitchContinuation: CheckedContinuation<Void, Error>?
private var cameraFlipTimeoutTask: Task<Void, Never>?
private var restoreAudioRouteTask: Task<Void, Never>?
private var cameraFlipRequested = false
private var restartingSegmentForCameraFlip = false
private var recordedSegments: [URL] = []
private var segmentsTotalDuration: TimeInterval = 0
/// The iOS front camera routes the quiet earpiece microphone, so the
/// finalized audio gets a gain boost when any segment used it.
private var usedFrontCamera = false
private struct CaptureGraph {
let cameraInput: AVCaptureDeviceInput
let microphoneInput: AVCaptureDeviceInput
let usesFrontCamera: Bool
}
private struct MovieOutputSnapshot: Sendable {
let isRecording: Bool
let recordedDuration: TimeInterval
}
func prepare() async throws {
try await waitForPreviousRecordingToFinish()
if lifecycle == .prepared, isPrepared { return }
guard lifecycle == .idle, !isPrepared else {
throw VideoNoteRecorderError.recordingBusy
}
let generation = UUID()
sessionGeneration = generation
await waitForPendingSessionOperations()
guard !Task.isCancelled, sessionGeneration == generation else {
throw CancellationError()
}
guard await Self.requestAccess(for: .video) else {
throw VideoNoteRecorderError.cameraPermissionDenied
}
guard !Task.isCancelled, sessionGeneration == generation else {
throw CancellationError()
}
guard await Self.requestAccess(for: .audio) else {
throw VideoNoteRecorderError.microphonePermissionDenied
}
guard !Task.isCancelled, sessionGeneration == generation else {
throw CancellationError()
}
var didPrepare = false
defer {
if !didPrepare {
performShutdown()
}
}
#if os(iOS)
try AVAudioSession.sharedInstance().setCategory(
.playAndRecord,
// .default keeps voice processing and automatic gain control:
// a talking circle recorded at arm's length (front camera)
// stays loud and consistent, unlike .videoRecording which
// disables AGC and records the raw microphone level.
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothHFP]
)
try AVAudioSession.sharedInstance().setActive(true)
#endif
let graph = try await configureCaptureGraph()
guard !Task.isCancelled, sessionGeneration == generation else {
throw CancellationError()
}
cameraInput = graph.cameraInput
microphoneInput = graph.microphoneInput
isUsingFrontCamera = graph.usesFrontCamera
hasAlternateCamera =
hasCamera(at: graph.usesFrontCamera ? .back : .front)
configureVideoConnection()
try await startCaptureSession(generation: generation)
isPrepared = true
lifecycle = .prepared
didPrepare = true
}
func start() throws {
guard lifecycle == .prepared,
isPrepared,
session.isRunning
else { throw VideoNoteRecorderError.notPrepared }
guard !movieOutput.isRecording,
activeRecordingURL == nil
else {
throw VideoNoteRecorderError.recordingBusy
}
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("LumaRecordings", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("video-note-\(UUID().uuidString).mov")
discardCurrentRecording = false
stopRequested = false
requestedMinimumDuration = 0
activeRecordingURL = url
lifecycle = .starting
if !restartingSegmentForCameraFlip {
startedAt = nil
elapsed = 0
}
restartingSegmentForCameraFlip = false
if isUsingFrontCamera {
usedFrontCamera = true
}
isRecording = true
let output = movieOutput
let delegate = self
sessionQueue.async {
output.startRecording(to: url, recordingDelegate: delegate)
}
scheduleStartupTimeout(for: url)
}
func stop() {
requestStop(afterMinimumDuration: VideoNoteStopPolicy.minimumCaptureDuration)
}
func stop(afterMinimumDuration minimumDuration: TimeInterval) async {
requestStop(afterMinimumDuration: minimumDuration)
}
func cancel() {
discardCurrentRecording = true
stopRequested = true
requestedMinimumDuration = 0
switch lifecycle {
case .starting, .recording:
lifecycle = .stopping
stopRequestTask?.cancel()
stopRequestTask = Task { @MainActor [weak self] in
guard let self else { return }
await self.stopMovieOutput()
guard !Task.isCancelled,
self.lifecycle == .stopping,
let url = self.activeRecordingURL
else { return }
self.scheduleFinalizationTimeout(for: url)
}
case .stopping:
break
case .idle, .prepared:
performShutdown()
}
}
/// Flips between the front and the back camera. While recording,
/// AVFoundation tears the active movie down when its input is removed, so
/// the current segment is closed first, the camera is swapped, and a new
/// segment starts; the segments are merged back together on finalization.
func switchCamera() async throws {
#if os(iOS)
guard isPrepared,
hasAlternateCamera,
let currentInput = cameraInput
else {
throw VideoNoteRecorderError.notPrepared
}
switch lifecycle {
case .prepared:
try await swapCameraInput(replacing: currentInput)
case .starting, .recording:
try await stopSegmentForCameraFlip()
guard let input = cameraInput else {
throw VideoNoteRecorderError.notPrepared
}
try await swapCameraInput(replacing: input)
restartingSegmentForCameraFlip = true
try start()
case .stopping, .idle:
throw VideoNoteRecorderError.notPrepared
}
#else
throw VideoNoteRecorderError.alternateCameraUnavailable
#endif
}
/// Closes the in-flight segment without finishing the whole recording:
/// the file is kept (when non-empty), per-segment state resets, and the
/// pending switchCamera call resumes.
private func stopSegmentForCameraFlip() async throws {
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) in
cameraSwitchContinuation = continuation
cameraFlipRequested = true
requestStop(afterMinimumDuration: 0)
scheduleCameraFlipTimeout()
}
}
private func scheduleCameraFlipTimeout() {
cameraFlipTimeoutTask?.cancel()
cameraFlipTimeoutTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 8_000_000_000)
guard !Task.isCancelled,
let self,
let continuation = self.cameraSwitchContinuation
else { return }
self.cameraSwitchContinuation = nil
self.cameraFlipRequested = false
continuation.resume(
throwing: VideoNoteRecorderError.finalizationTimedOut
)
}
}
private func handleCameraFlipSegment(url: URL) {
cameraFlipTimeoutTask?.cancel()
cameraFlipTimeoutTask = nil
let continuation = cameraSwitchContinuation
cameraSwitchContinuation = nil
fileValidationTask?.cancel()
fileValidationTask = Task { @MainActor [weak self] in
guard let self else { return }
let inspection = await self.fileInspector.inspect(
url: url,
fallbackDuration: self.measuredWallClockDuration
)
guard !Task.isCancelled,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
)
else { return }
self.fileValidationTask = nil
if let inspection, inspection.duration > 0.05 {
self.recordedSegments.append(url)
self.segmentsTotalDuration += inspection.duration
} else {
try? FileManager.default.removeItem(at: url)
}
self.resetForNextSegment()
continuation?.resume(returning: ())
}
}
private func resetForNextSegment() {
lifecycle = .prepared
activeRecordingURL = nil
discardCurrentRecording = false
stopRequested = false
requestedMinimumDuration = 0
isRecording = false
timer?.invalidate()
timer = nil
}
private func swapCameraInput(
replacing currentInput: AVCaptureDeviceInput
) async throws {
let nextPosition: AVCaptureDevice.Position =
isUsingFrontCamera ? .back : .front
guard
let camera = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: nextPosition
)
else {
throw VideoNoteRecorderError.alternateCameraUnavailable
}
let nextInput = try AVCaptureDeviceInput(device: camera)
let session = session
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) in
sessionQueue.async {
session.beginConfiguration()
session.removeInput(currentInput)
guard session.canAddInput(nextInput) else {
if session.canAddInput(currentInput) {
session.addInput(currentInput)
}
session.commitConfiguration()
continuation.resume(
throwing: VideoNoteRecorderError.configurationFailed
)
return
}
session.addInput(nextInput)
session.commitConfiguration()
continuation.resume()
}
}
cameraInput = nextInput
isUsingFrontCamera = nextPosition == .front
hasAlternateCamera =
hasCamera(at: nextPosition == .front ? .back : .front)
configureVideoConnection()
#if os(iOS)
// Flipping to the front camera makes iOS re-route the audio to
// the earpiece and switch the voice processing into the quiet
// receiver mode — but the system performs that re-routing
// asynchronously AFTER the swap commits. Restore the speaker
// route with a short delay so it wins the race.
restoreAudioRouteTask?.cancel()
restoreAudioRouteTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 800_000_000)
guard !Task.isCancelled, let self, self.isPrepared else {
return
}
let audioSession = AVAudioSession.sharedInstance()
try? audioSession.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothHFP]
)
try? audioSession.overrideOutputAudioPort(.speaker)
}
#endif
}
func toggleMicrophoneMuted() {
guard isPrepared, let connection = movieOutput.connection(with: .audio) else { return }
isMicrophoneMuted.toggle()
connection.isEnabled = !isMicrophoneMuted
}
func shutdown() {
if lifecycle.isBusy || movieOutput.isRecording {
cancel()
return
}
performShutdown()
}
nonisolated func fileOutput(
_ output: AVCaptureFileOutput,
didStartRecordingTo fileURL: URL,
from connections: [AVCaptureConnection]
) {
Task { @MainActor [weak self] in
self?.recordingDidStart(url: fileURL)
}
}
nonisolated func fileOutput(
_ output: AVCaptureFileOutput,
didFinishRecordingTo outputFileURL: URL,
from connections: [AVCaptureConnection],
error: Error?
) {
Task { @MainActor [weak self] in
await self?.recordingDidFinish(url: outputFileURL, error: error)
}
}
private func requestStop(afterMinimumDuration minimumDuration: TimeInterval) {
guard isRecording,
lifecycle == .starting || lifecycle == .recording
else { return }
stopRequested = true
requestedMinimumDuration = max(
requestedMinimumDuration,
max(0, minimumDuration)
)
scheduleStopAfterActualStartIfNeeded()
}
private func recordingDidStart(url: URL) {
guard
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: activeRecordingURL,
outputURL: url
), isRecording,
lifecycle == .starting || lifecycle == .stopping
else { return }
startupTimeoutTask?.cancel()
startupTimeoutTask = nil
if startedAt == nil {
startedAt = Date()
}
if lifecycle == .stopping {
// Cancellation can be requested while startRecording is still
// crossing the capture queue. If the first stop observed
// isRecording == false, stop again after the real didStart rather
// than leaving an orphaned capture running until the watchdog.
stopRequestTask?.cancel()
stopRequestTask = Task { @MainActor [weak self] in
guard let self else { return }
await self.stopMovieOutput()
guard !Task.isCancelled,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
)
else { return }
self.scheduleFinalizationTimeout(for: url)
}
return
}
lifecycle = .recording
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self, let startedAt = self.startedAt else { return }
self.elapsed = min(
VideoNoteStopPolicy.maximumCaptureDuration,
Date().timeIntervalSince(startedAt)
)
}
}
scheduleStopAfterActualStartIfNeeded()
}
private func scheduleStopAfterActualStartIfNeeded() {
guard stopRequested,
lifecycle == .recording,
startedAt != nil,
let url = activeRecordingURL
else { return }
stopRequestTask?.cancel()
let minimumDuration = requestedMinimumDuration
let mediaDeadline = Date().addingTimeInterval(
VideoNoteStopPolicy.recordedDurationWaitTimeout
)
stopRequestTask = Task { @MainActor [weak self] in
while true {
guard !Task.isCancelled,
let self,
self.lifecycle == .recording,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
)
else { return }
let snapshot = await self.movieOutputSnapshot()
guard !Task.isCancelled,
self.lifecycle == .recording,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
)
else { return }
if !snapshot.isRecording {
// AVFoundation stopped without the user's stop reaching
// the output. Wait for didFinish, but never let this state
// remain without a finalization watchdog.
self.lifecycle = .stopping
self.scheduleFinalizationTimeout(for: url)
return
}
let remaining = VideoNoteStopPolicy.remainingRecordedDuration(
recordedDuration: snapshot.recordedDuration
+ self.segmentsTotalDuration,
minimumDuration: minimumDuration
)
if remaining <= 0 || Date() >= mediaDeadline { break }
do {
try await Task.sleep(
nanoseconds: min(
VideoNoteStopPolicy.recordedDurationPollNanoseconds,
UInt64((remaining * 1_000_000_000).rounded(.up))
)
)
} catch {
return
}
}
guard !Task.isCancelled,
let self,
self.lifecycle == .recording,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
)
else { return }
self.lifecycle = .stopping
await self.stopMovieOutput()
guard !Task.isCancelled,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
)
else { return }
self.scheduleFinalizationTimeout(for: url)
}
}
private func recordingDidFinish(url: URL, error: Error?) async {
guard
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: activeRecordingURL,
outputURL: url
)
else {
// A delayed callback from an earlier attempt must not tear down a
// newer capture graph. Do not delete it here: macOS can spell the
// same temporary URL through a filesystem alias, and preserving a
// doubtful callback is safer than deleting the active movie.
return
}
stopRequestTask?.cancel()
stopRequestTask = nil
startupTimeoutTask?.cancel()
startupTimeoutTask = nil
finalizationTimeoutTask?.cancel()
finalizationTimeoutTask = nil
timer?.invalidate()
timer = nil
isRecording = false
lifecycle = .stopping
if cameraFlipRequested {
// A flip during recording first closes the current segment: keep
// it and hand control back to the pending switchCamera call.
cameraFlipRequested = false
handleCameraFlipSegment(url: url)
return
}
let fallbackDuration = measuredWallClockDuration
let shouldDiscard = discardCurrentRecording
let wasStopRequested = stopRequested
if shouldDiscard {
try? FileManager.default.removeItem(at: url)
finishAndReset(nil)
return
}
// Release the camera BEFORE the finalize re-encode runs. While the
// capture session is still live, the writer's encoder contends with
// it for the media hardware and its input can block forever in
// isReadyForMoreMediaData — the «Завершение…» hang.
await flushCaptureSessionForFinalization()
fileValidationTask?.cancel()
fileValidationTask = Task { @MainActor [weak self] in
guard let self else { return }
let inspection = await self.fileInspector.inspect(
url: url,
fallbackDuration: fallbackDuration
)
guard !Task.isCancelled,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
)
else { return }
self.fileValidationTask = nil
if self.discardCurrentRecording {
try? FileManager.default.removeItem(at: url)
self.finishAndReset(nil)
return
}
if let inspection,
VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
stopRequested: wasStopRequested,
error: error,
recordedDuration: self.segmentsTotalDuration
+ inspection.duration,
maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
),
VideoNoteStopPolicy.isValidFinalDuration(
self.segmentsTotalDuration + inspection.duration
)
{
let totalDuration =
self.segmentsTotalDuration + inspection.duration
// Merge the flip segments back together (passthrough, no
// re-encode). The circle bubble crops the picture visually.
let finalization = await self.fileInspector.finalizeVideoNote(
segments: self.recordedSegments + [url],
frontCameraUsed: self.usedFrontCamera
)
guard !Task.isCancelled,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
)
else { return }
if let finalization {
if finalization.url != url {
try? FileManager.default.removeItem(at: url)
}
self.finishAndReset(
.success(
Recording(
url: finalization.url,
duration: finalization.duration
)))
} else {
// The transcode fell through; the raw file was already
// validated, so send it uncropped instead of losing the
// recording entirely.
self.finishAndReset(
.success(
Recording(url: url, duration: totalDuration)
))
}
return
}
try? FileManager.default.removeItem(at: url)
let failure: Error
if !wasStopRequested,
!VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
stopRequested: false,
error: error,
recordedDuration: inspection?.duration ?? fallbackDuration,
maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
)
{
failure = VideoNoteRecorderError.recordingInterrupted
} else if !VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: error), let error {
failure = error
} else {
failure = VideoNoteRecorderError.emptyRecording
}
self.finishAndReset(.failure(failure))
}
}
private var measuredWallClockDuration: TimeInterval {
guard let startedAt else { return max(0, elapsed) }
return max(elapsed, Date().timeIntervalSince(startedAt))
}
private func stopMovieOutput() async {
let output = movieOutput
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
sessionQueue.async {
if output.isRecording {
output.stopRecording()
}
continuation.resume()
}
}
}
private func movieOutputSnapshot() async -> MovieOutputSnapshot {
let output = movieOutput
return await withCheckedContinuation { continuation in
sessionQueue.async {
let seconds = CMTimeGetSeconds(output.recordedDuration)
continuation.resume(
returning: MovieOutputSnapshot(
isRecording: output.isRecording,
recordedDuration: seconds.isFinite && seconds > 0 ? seconds : 0
))
}
}
}
private func flushCaptureSessionForFinalization() async {
let output = movieOutput
let session = session
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
sessionQueue.async {
if output.isRecording {
output.stopRecording()
}
if session.isRunning {
session.stopRunning()
}
continuation.resume()
}
}
// stopRunning is asynchronous: wait until the camera pipeline really
// released its media resources. The finalize re-encode must not start
// while the capture session still holds the encoder/muxer, otherwise
// the writer's inputs never become ready.
let stopDeadline = Date().addingTimeInterval(10)
while session.isRunning, Date() < stopDeadline {
try? await Task.sleep(nanoseconds: 100_000_000)
}
}
private func scheduleStartupTimeout(for url: URL) {
startupTimeoutTask?.cancel()
startupTimeoutTask = Task { @MainActor [weak self] in
try? await Task.sleep(
nanoseconds: VideoNoteStopPolicy.startupTimeoutNanoseconds
)
guard !Task.isCancelled,
let self,
self.lifecycle == .starting,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
)
else { return }
self.lifecycle = .stopping
await self.stopMovieOutput()
guard !Task.isCancelled,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
)
else { return }
self.scheduleFinalizationTimeout(for: url)
}
}
private func scheduleFinalizationTimeout(for url: URL) {
finalizationTimeoutTask?.cancel()
finalizationTimeoutTask = Task { @MainActor [weak self] in
try? await Task.sleep(
nanoseconds: VideoNoteStopPolicy.finalizationTimeoutNanoseconds
)
guard !Task.isCancelled,
let self,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
)
else { return }
// A few macOS camera drivers stop the file output but delay its
// delegate callback until the capture session itself is flushed.
// This is recovery-only; the normal path still waits for
// didFinishRecording before touching the session.
await self.flushCaptureSessionForFinalization()
try? await Task.sleep(
nanoseconds: VideoNoteStopPolicy.finalizationRecoveryGraceNanoseconds
)
guard !Task.isCancelled,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
)
else { return }
let fallbackDuration = self.measuredWallClockDuration
let inspection = await self.fileInspector.inspect(
url: url,
fallbackDuration: fallbackDuration
)
guard !Task.isCancelled,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
)
else { return }
self.finalizationTimeoutTask = nil
if self.discardCurrentRecording {
try? FileManager.default.removeItem(at: url)
self.finishAndReset(nil)
} else if let inspection,
VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
stopRequested: self.stopRequested,
error: nil,
recordedDuration: inspection.duration,
maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
),
VideoNoteStopPolicy.isValidFinalDuration(inspection.duration)
{
// Some macOS camera drivers finalize the .mov but omit the
// delegate callback. A playable file is more authoritative
// than the missing callback and remains safe to send.
self.finishAndReset(
.success(
Recording(
url: url,
duration: inspection.duration
)))
} else if !self.stopRequested {
try? FileManager.default.removeItem(at: url)
self.finishAndReset(.failure(VideoNoteRecorderError.recordingInterrupted))
} else {
try? FileManager.default.removeItem(at: url)
self.finishAndReset(.failure(VideoNoteRecorderError.finalizationTimedOut))
}
}
}
private func finishAndReset(_ result: Result<Recording, Error>?) {
fileValidationTask = nil
let completion = onFinished
performShutdown()
if let result {
completion?(result)
}
}
private func performShutdown() {
sessionGeneration = UUID()
stopRequestTask?.cancel()
stopRequestTask = nil
startupTimeoutTask?.cancel()
startupTimeoutTask = nil
finalizationTimeoutTask?.cancel()
finalizationTimeoutTask = nil
fileValidationTask?.cancel()
fileValidationTask = nil
cameraSwitchContinuation = nil
cameraFlipTimeoutTask?.cancel()
cameraFlipTimeoutTask = nil
cameraFlipRequested = false
restartingSegmentForCameraFlip = false
usedFrontCamera = false
let segments = recordedSegments
recordedSegments = []
segmentsTotalDuration = 0
for segment in segments {
try? FileManager.default.removeItem(at: segment)
}
timer?.invalidate()
timer = nil
resetCaptureGraph()
lifecycle = .idle
activeRecordingURL = nil
discardCurrentRecording = false
stopRequested = false
requestedMinimumDuration = 0
startedAt = nil
elapsed = 0
isPrepared = false
isRecording = false
isUsingFrontCamera = true
hasAlternateCamera = false
isMicrophoneMuted = false
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(
false, options: .notifyOthersOnDeactivation)
#endif
}
private func waitForPreviousRecordingToFinish() async throws {
guard lifecycle != .starting, lifecycle != .recording else {
throw VideoNoteRecorderError.recordingBusy
}
let deadline = Date().addingTimeInterval(10)
while lifecycle == .stopping, Date() < deadline {
try await Task.sleep(nanoseconds: 25_000_000)
}
guard lifecycle != .stopping else {
throw VideoNoteRecorderError.finalizationTimedOut
}
}
private func configureVideoConnection() {
guard let connection = movieOutput.connection(with: .video) else { return }
#if os(iOS)
configureInteroperableVideoCodec(for: connection)
#endif
if connection.isVideoMirroringSupported {
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = isUsingFrontCamera
}
#if os(iOS)
// iOS records upright portrait at capture time; macOS keeps the
// camera's native landscape orientation (the circle bubble crops
// it visually).
let rotationAngle = videoRotationAngle
if connection.isVideoRotationAngleSupported(rotationAngle) {
connection.videoRotationAngle = rotationAngle
}
#endif
}
#if os(iOS)
private var videoRotationAngle: CGFloat {
// The interface orientation is authoritative: it is what the user
// actually sees. The device sensor can claim landscape (or
// face-up) while the app stays portrait, and recording with the
// sensor value rotated portrait circles by 90 degrees.
VideoNoteRotationPolicy.angle(
for: VideoNoteRotationPolicy.currentInterfaceOrientation
)
}
#endif
#if os(iOS)
private func configureInteroperableVideoCodec(for connection: AVCaptureConnection) {
guard movieOutput.availableVideoCodecTypes.contains(.h264) else { return }
movieOutput.setOutputSettings(
[AVVideoCodecKey: AVVideoCodecType.h264],
for: connection
)
}
#endif
private func resetCaptureGraph() {
let session = session
sessionQueue.async {
if session.isRunning {
session.stopRunning()
}
guard !session.inputs.isEmpty || !session.outputs.isEmpty else { return }
session.beginConfiguration()
session.inputs.forEach { session.removeInput($0) }
session.outputs.forEach { session.removeOutput($0) }
session.commitConfiguration()
}
cameraInput = nil
microphoneInput = nil
}
private func waitForPendingSessionOperations() async {
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
sessionQueue.async {
continuation.resume()
}
}
}
private func hasCamera(at position: AVCaptureDevice.Position) -> Bool {
AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: position
) != nil
}
private func configureCaptureGraph() async throws -> CaptureGraph {
let session = session
let movieOutput = movieOutput
return try await withCheckedThrowingContinuation { continuation in
sessionQueue.async {
session.beginConfiguration()
do {
session.sessionPreset =
session.canSetSessionPreset(.vga640x480)
? .vga640x480
: .medium
#if os(iOS)
let camera =
AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: .front
) ?? AVCaptureDevice.default(for: .video)
#else
let camera = AVCaptureDevice.default(for: .video)
#endif
guard let camera else {
throw VideoNoteRecorderError.cameraUnavailable
}
let cameraInput = try AVCaptureDeviceInput(device: camera)
guard session.canAddInput(cameraInput) else {
throw VideoNoteRecorderError.configurationFailed
}
session.addInput(cameraInput)
#if os(iOS)
// iOS routes the microphone by the active camera:
// the front camera gets the quiet earpiece mic and the
// back camera the loud bottom mic. Pin the bottom
// microphone explicitly so the voice level stays
// consistent no matter which camera is recording.
let microphone =
AVCaptureDevice.default(
.builtInMicrophone,
for: .audio,
position: .back
)
?? AVCaptureDevice.default(for: .audio)
#else
let microphone = AVCaptureDevice.default(for: .audio)
#endif
guard let microphone else {
throw VideoNoteRecorderError.microphoneUnavailable
}
let microphoneInput = try AVCaptureDeviceInput(device: microphone)
guard session.canAddInput(microphoneInput) else {
throw VideoNoteRecorderError.configurationFailed
}
session.addInput(microphoneInput)
guard session.canAddOutput(movieOutput) else {
throw VideoNoteRecorderError.configurationFailed
}
session.addOutput(movieOutput)
movieOutput.maxRecordedDuration = CMTime(
seconds: VideoNoteStopPolicy.maximumCaptureDuration,
preferredTimescale: 600
)
session.commitConfiguration()
continuation.resume(
returning: CaptureGraph(
cameraInput: cameraInput,
microphoneInput: microphoneInput,
usesFrontCamera: camera.position == .front
))
} catch {
session.commitConfiguration()
continuation.resume(throwing: error)
}
}
}
}
private func startCaptureSession(generation: UUID) async throws {
let session = session
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
sessionQueue.async {
if !session.isRunning {
session.startRunning()
}
continuation.resume()
}
}
guard !Task.isCancelled,
sessionGeneration == generation,
session.isRunning
else {
throw CancellationError()
}
}
private static func requestAccess(for mediaType: AVMediaType) async -> Bool {
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
case .authorized:
return true
case .denied, .restricted:
return false
case .notDetermined:
return await AVCaptureDevice.requestAccess(for: mediaType)
@unknown default:
return false
}
}
}
private actor VideoNoteFileInspector {
struct Inspection: Sendable {
let duration: TimeInterval
}
struct Finalization: Sendable {
let url: URL
let duration: TimeInterval
}
private struct Composition {
let asset: AVAsset
let videoTrack: AVMutableCompositionTrack
let transform: CGAffineTransform
let duration: TimeInterval
}
/// Hands the finished recording back for sending. The capture is
/// already rotated to portrait at record time, so no re-encode is needed:
/// the raw file is sent as-is and camera flips are merged with a
/// passthrough export (audio and video stay bit-exact). On iOS, when the
/// quiet front camera was used, the audio gets a deterministic volume
/// boost (see boostFrontCameraAudio).
func finalizeVideoNote(
segments: [URL],
frontCameraUsed: Bool
) async -> Finalization? {
videoNoteFinalizeLogger.info(
"finalize start segments=\(segments.count) front=\(frontCameraUsed)"
)
#if os(iOS)
if frontCameraUsed, segments.count == 1 {
if let boosted = await Self.boostFrontCameraAudio(
segment: segments[0]
) {
videoNoteFinalizeLogger.info("finalize boosted OK")
return boosted
}
videoNoteFinalizeLogger.info("finalize boost failed, raw")
}
#endif
if segments.count == 1 {
let duration =
(try? await AVURLAsset(url: segments[0]).load(.duration)
.seconds) ?? 0
videoNoteFinalizeLogger.info("finalize raw")
return Finalization(url: segments[0], duration: max(0, duration))
}
videoNoteFinalizeLogger.info("finalize passthrough")
return await Self.passthroughMerge(segments: segments)
}
/// Re-encodes a single segment with a fixed audio gain. The iOS front
/// camera routes the quiet earpiece microphone, and the system offers no
/// app-level way to override that routing — so the finished recording
/// gets a deterministic volume boost instead. The video frames are
/// copied verbatim (same dimensions and rotation transform); the audio
/// goes through an AVAudioMix that applies the gain without manual
/// sample math.
private static func boostFrontCameraAudio(
segment: URL
) async -> Finalization? {
let asset = AVURLAsset(url: segment)
guard
let videoTrack = try? await asset.loadTracks(withMediaType: .video)
.first,
let audioTrack = try? await asset.loadTracks(withMediaType: .audio)
.first,
let duration = try? await asset.load(.duration)
else { return nil }
guard let outputURL = finalizationOutputURL() else { return nil }
let writer: AVAssetWriter
do {
writer = try AVAssetWriter(outputURL: outputURL, fileType: .mov)
} catch {
return nil
}
let size = try? await videoTrack.load(.naturalSize)
let width = max(1, Int(size?.width ?? 640))
let height = max(1, Int(size?.height ?? 480))
let videoInput = AVAssetWriterInput(
mediaType: .video,
outputSettings: [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
]
)
videoInput.expectsMediaDataInRealTime = false
videoInput.transform = videoTrack.preferredTransform
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: videoInput,
sourcePixelBufferAttributes: [
kCVPixelBufferPixelFormatTypeKey as String:
kCVPixelFormatType_32BGRA,
kCVPixelBufferWidthKey as String: width,
kCVPixelBufferHeightKey as String: height,
]
)
guard writer.canAdd(videoInput) else { return nil }
writer.add(videoInput)
let audioInput = AVAssetWriterInput(
mediaType: .audio,
outputSettings: [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44_100,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 96_000,
]
)
audioInput.expectsMediaDataInRealTime = false
guard writer.canAdd(audioInput) else { return nil }
writer.add(audioInput)
let reader: AVAssetReader
do {
reader = try AVAssetReader(asset: asset)
} catch {
return nil
}
let videoOutput = AVAssetReaderTrackOutput(
track: videoTrack,
outputSettings: [
kCVPixelBufferPixelFormatTypeKey as String:
kCVPixelFormatType_32BGRA
]
)
guard reader.canAdd(videoOutput) else { return nil }
reader.add(videoOutput)
// The reader applies the gain via the audio mix.
let mix = AVMutableAudioMix()
let parameters = AVMutableAudioMixInputParameters(track: audioTrack)
parameters.setVolume(3.5, at: .zero)
mix.inputParameters = [parameters]
let audioOutput = AVAssetReaderAudioMixOutput(
audioTracks: [audioTrack],
audioSettings: [
AVFormatIDKey: kAudioFormatLinearPCM
]
)
audioOutput.audioMix = mix
guard reader.canAdd(audioOutput) else { return nil }
reader.add(audioOutput)
guard reader.startReading() else { return nil }
guard writer.startWriting() else {
try? FileManager.default.removeItem(at: outputURL)
return nil
}
guard let pool = adaptor.pixelBufferPool else {
writer.cancelWriting()
try? FileManager.default.removeItem(at: outputURL)
return nil
}
var sessionStarted = false
var videoSample = videoOutput.copyNextSampleBuffer()
var audioSample = audioOutput.copyNextSampleBuffer()
while videoSample != nil || audioSample != nil {
let videoPTS = videoSample.map {
CMSampleBufferGetPresentationTimeStamp($0)
}
let audioPTS = audioSample.map {
CMSampleBufferGetPresentationTimeStamp($0)
}
let takeVideo: Bool
switch (videoPTS, audioPTS) {
case let (video?, audio?): takeVideo = video <= audio
case (_?, nil): takeVideo = true
default: takeVideo = false
}
if takeVideo, let sample = videoSample {
videoSample = videoOutput.copyNextSampleBuffer()
guard let source = CMSampleBufferGetImageBuffer(sample)
else { continue }
let pts = CMSampleBufferGetPresentationTimeStamp(sample)
if !sessionStarted {
writer.startSession(atSourceTime: pts)
sessionStarted = true
}
while !videoInput.isReadyForMoreMediaData {
try? await Task.sleep(nanoseconds: 2_000_000)
}
var buffer: CVPixelBuffer?
CVPixelBufferPoolCreatePixelBuffer(
kCFAllocatorDefault, pool, &buffer
)
guard let buffer else { continue }
CVPixelBufferLockBaseAddress(source, .readOnly)
CVPixelBufferLockBaseAddress(buffer, [])
let srcBase = CVPixelBufferGetBaseAddress(source)!
let dstBase = CVPixelBufferGetBaseAddress(buffer)!
let srcBytes = CVPixelBufferGetBytesPerRow(source)
let dstBytes = CVPixelBufferGetBytesPerRow(buffer)
let copyHeight = min(
CVPixelBufferGetHeight(source),
CVPixelBufferGetHeight(buffer)
)
let copyBytes = min(srcBytes, dstBytes)
for row in 0..<copyHeight {
memcpy(
dstBase.advanced(by: row * dstBytes),
srcBase.advanced(by: row * srcBytes),
copyBytes
)
}
CVPixelBufferUnlockBaseAddress(buffer, [])
CVPixelBufferUnlockBaseAddress(source, .readOnly)
guard adaptor.append(buffer, withPresentationTime: pts)
else {
writer.cancelWriting()
try? FileManager.default.removeItem(at: outputURL)
return nil
}
} else if let sample = audioSample {
audioSample = audioOutput.copyNextSampleBuffer()
let pts = CMSampleBufferGetPresentationTimeStamp(sample)
guard pts.isValid else { continue }
while !audioInput.isReadyForMoreMediaData {
try? await Task.sleep(nanoseconds: 2_000_000)
}
guard audioInput.append(sample) else {
writer.cancelWriting()
try? FileManager.default.removeItem(at: outputURL)
return nil
}
}
}
videoInput.markAsFinished()
audioInput.markAsFinished()
let finishTask = Task { await writer.finishWriting() }
let watchdog = Task {
try? await Task.sleep(nanoseconds: 30_000_000_000)
if !Task.isCancelled {
writer.cancelWriting()
}
}
await finishTask.value
watchdog.cancel()
guard writer.status == .completed else {
writer.cancelWriting()
try? FileManager.default.removeItem(at: outputURL)
return nil
}
return Finalization(url: outputURL, duration: duration.seconds)
}
private static func composition(from urls: [URL]) async -> Composition? {
let composition = AVMutableComposition()
guard
let videoTrack = composition.addMutableTrack(
withMediaType: .video,
preferredTrackID: kCMPersistentTrackID_Invalid
)
else { return nil }
// The audio track is created lazily and only when a segment really
// has audio. An empty audio track breaks the macOS exporter.
var compositionAudioTrack: AVMutableCompositionTrack?
var cursor = CMTime.zero
var transform = CGAffineTransform.identity
var hasVideo = false
for url in urls {
let asset = AVURLAsset(url: url)
guard
let duration = try? await asset.load(.duration),
let source = try? await asset.loadTracks(withMediaType: .video)
.first
else { return nil }
let range = CMTimeRange(start: .zero, duration: duration)
do {
try videoTrack.insertTimeRange(range, of: source, at: cursor)
if let audio = try? await asset.loadTracks(
withMediaType: .audio
).first {
if compositionAudioTrack == nil {
compositionAudioTrack = composition.addMutableTrack(
withMediaType: .audio,
preferredTrackID: kCMPersistentTrackID_Invalid
)
}
if let compositionAudioTrack {
try? compositionAudioTrack.insertTimeRange(
range,
of: audio,
at: cursor
)
}
}
} catch {
return nil
}
if !hasVideo {
transform = source.preferredTransform
hasVideo = true
}
cursor = cursor + duration
}
videoTrack.preferredTransform = transform
return Composition(
asset: composition,
videoTrack: videoTrack,
transform: transform,
duration: cursor.seconds
)
}
private static func passthroughMerge(
segments: [URL]
) async -> Finalization? {
guard let composition = await composition(from: segments) else {
return nil
}
guard let outputURL = finalizationOutputURL() else { return nil }
guard let exporter = AVAssetExportSession(
asset: composition.asset,
presetName: AVAssetExportPresetPassthrough
) else { return nil }
exporter.outputURL = outputURL
exporter.outputFileType = .mov
exporter.shouldOptimizeForNetworkUse = true
do {
try await exporter.export(to: outputURL, as: .mov)
} catch {
try? FileManager.default.removeItem(at: outputURL)
return nil
}
guard exporter.status == .completed else {
try? FileManager.default.removeItem(at: outputURL)
return nil
}
return Finalization(url: outputURL, duration: composition.duration)
}
private static func finalizationOutputURL() -> URL? {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("LumaRecordings", isDirectory: true)
do {
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
return directory.appendingPathComponent(
"video-note-final-\(UUID().uuidString).mov"
)
} catch {
return nil
}
}
func inspect(url: URL, fallbackDuration: TimeInterval) async -> Inspection? {
let retryDelays: [UInt64] = [0, 120_000_000, 300_000_000]
for delay in retryDelays {
if delay > 0 {
try? await Task.sleep(nanoseconds: delay)
}
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]),
values.isRegularFile == true,
let size = values.fileSize,
size > 1_024
else { continue }
let asset = AVURLAsset(url: url)
guard let duration = try? await asset.load(.duration),
duration.seconds.isFinite,
duration.seconds > 0.04,
let tracks = try? await asset.loadTracks(withMediaType: .video),
!tracks.isEmpty
else { continue }
return Inspection(duration: duration.seconds)
}
// Keep a non-empty file only when AVFoundation reported enough wall
// clock time to make a useful fallback. This branch covers a small set
// of macOS camera drivers that delay movie metadata longer than the
// delegate callback itself.
guard fallbackDuration > 0.25,
let values = try? url.resourceValues(forKeys: [.fileSizeKey]),
let size = values.fileSize,
size > 16_384
else { return nil }
return Inspection(duration: fallbackDuration)
}
}
enum VideoNoteStopPolicy {
static let minimumCaptureDuration: TimeInterval = 1.2
static let minimumValidFileDuration: TimeInterval = 0.8
static let maximumCaptureDuration: TimeInterval = 60
static let recordedDurationWaitTimeout: TimeInterval = 5
static let recordedDurationPollNanoseconds: UInt64 = 80_000_000
static let startupTimeoutNanoseconds: UInt64 = 6_000_000_000
static let finalizationTimeoutNanoseconds: UInt64 = 12_000_000_000
static let finalizationRecoveryGraceNanoseconds: UInt64 = 1_000_000_000
static func remainingRecordedDuration(
recordedDuration: TimeInterval,
minimumDuration: TimeInterval
) -> TimeInterval {
max(0, max(0, minimumDuration) - max(0, recordedDuration))
}
static func isValidFinalDuration(_ duration: TimeInterval) -> Bool {
duration.isFinite && duration >= minimumValidFileDuration
}
}
// #if os(iOS)
// extension VideoNoteRecorder {
// fileprivate static var currentVideoOrientation: AVCaptureVideoOrientation {
// switch UIDevice.current.orientation {
// case .portraitUpsideDown:
// return .portraitUpsideDown
// case .landscapeLeft:
// return .landscapeRight
// case .landscapeRight:
// return .landscapeLeft
// default:
// return .portrait
// }
// }
// }
// #endif
private enum VideoNoteRecorderError: LocalizedError {
case cameraPermissionDenied
case microphonePermissionDenied
case cameraUnavailable
case alternateCameraUnavailable
case microphoneUnavailable
case configurationFailed
case notPrepared
case recordingBusy
case recordingInterrupted
case finalizationTimedOut
case emptyRecording
var errorDescription: String? {
switch self {
case .cameraPermissionDenied:
return "Разрешите Luma доступ к камере в системных настройках."
case .microphonePermissionDenied:
return "Разрешите Luma доступ к микрофону в системных настройках."
case .cameraUnavailable:
return "Камера недоступна."
case .alternateCameraUnavailable:
return "Вторая камера недоступна."
case .microphoneUnavailable:
return "Микрофон недоступен."
case .configurationFailed:
return "Не удалось настроить запись видеосообщения."
case .notPrepared:
return "Камера ещё не готова."
case .recordingBusy:
return "Предыдущая запись ещё завершается. Подождите секунду и повторите."
case .recordingInterrupted:
return
"Камера прервала запись раньше времени. Видеосообщение не отправлено. Повторите запись."
case .finalizationTimedOut:
return "Камера не смогла завершить видеофайл. Повторите запись."
case .emptyRecording:
return "Камера создала пустой или нечитаемый видеофайл. Повторите запись."
}
}
}