Files
Luma/Sources/Shared/Services/VideoNoteRecorder.swift
T
wt dee1eab897
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 warnings
2026-08-10 17:18:01 +07:00

1003 lines
37 KiB
Swift

@preconcurrency import AVFoundation
import Combine
import Foundation
#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 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 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,
mode: .videoRecording,
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
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
startedAt = nil
elapsed = 0
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()
}
}
func switchCamera() async throws {
#if os(iOS)
guard isPrepared,
lifecycle == .prepared,
let currentInput = cameraInput
else {
throw VideoNoteRecorderError.notPrepared
}
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
configureVideoConnection()
#else
throw VideoNoteRecorderError.alternateCameraUnavailable
#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
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,
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?) {
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
let fallbackDuration = measuredWallClockDuration
let shouldDiscard = discardCurrentRecording
let wasStopRequested = stopRequested
if shouldDiscard {
try? FileManager.default.removeItem(at: url)
finishAndReset(nil)
return
}
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: inspection.duration,
maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
),
VideoNoteStopPolicy.isValidFinalDuration(inspection.duration)
{
self.finishAndReset(
.success(
Recording(
url: url,
duration: inspection.duration
)))
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()
}
}
}
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
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
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)
if #available(iOS 17.0, *) {
let angle = videoRotationAngle
if connection.isVideoRotationAngleSupported(angle) {
connection.videoRotationAngle = angle
}
} else {
if connection.isVideoOrientationSupported {
connection.videoOrientation = Self.currentVideoOrientation
}
}
#endif
}
#if os(iOS)
private var videoRotationAngle: CGFloat {
switch UIDevice.current.orientation {
case .portraitUpsideDown: return 180
case .landscapeLeft: return 90
case .landscapeRight: return 270
default: return 0
}
}
@available(iOS, deprecated: 17.0)
private static var currentVideoOrientation: AVCaptureVideoOrientation {
switch UIDevice.current.orientation {
case .portraitUpsideDown:
return .portraitUpsideDown
case .landscapeLeft:
return .landscapeRight
case .landscapeRight:
return .landscapeLeft
default:
return .portrait
}
}
#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 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)
guard let microphone = AVCaptureDevice.default(for: .audio) 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
}
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 "Камера создала пустой или нечитаемый видеофайл. Повторите запись."
}
}
}