diff --git a/apps/ios/Sources/Camera/CameraController.swift b/apps/ios/Sources/Camera/CameraController.swift index 58a990481..2092274be 100644 --- a/apps/ios/Sources/Camera/CameraController.swift +++ b/apps/ios/Sources/Camera/CameraController.swift @@ -66,6 +66,7 @@ actor CameraController { session.startRunning() defer { session.stopRunning() } + await Self.warmUpCaptureSession() let settings: AVCapturePhotoSettings = { if output.availablePhotoCodecTypes.contains(.jpeg) { @@ -144,6 +145,7 @@ actor CameraController { session.startRunning() defer { session.stopRunning() } + await Self.warmUpCaptureSession() let movURL = FileManager.default.temporaryDirectory .appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov") @@ -217,7 +219,7 @@ actor CameraController { private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws { let asset = AVURLAsset(url: inputURL) - guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else { + guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else { throw CameraError.exportFailed("Failed to create export session") } exporter.shouldOptimizeForNetworkUse = true @@ -233,22 +235,29 @@ actor CameraController { exporter.outputURL = outputURL exporter.outputFileType = .mp4 - try await withCheckedThrowingContinuation(isolation: nil) { cont in + try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) in exporter.exportAsynchronously { - switch exporter.status { - case .completed: - cont.resume(returning: ()) - case .failed: - cont.resume(throwing: exporter.error ?? CameraError.exportFailed("Export failed")) - case .cancelled: - cont.resume(throwing: CameraError.exportFailed("Export cancelled")) - default: - cont.resume(throwing: CameraError.exportFailed("Export did not complete")) - } + cont.resume(returning: ()) } } + + switch exporter.status { + case .completed: + return + case .failed: + throw CameraError.exportFailed(exporter.error?.localizedDescription ?? "export failed") + case .cancelled: + throw CameraError.exportFailed("export cancelled") + default: + throw CameraError.exportFailed("export did not complete") + } } } + + private nonisolated static func warmUpCaptureSession() async { + // A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices. + try? await Task.sleep(nanoseconds: 150_000_000) // 150ms + } } private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate { @@ -278,6 +287,13 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat ])) return } + if data.isEmpty { + self.continuation.resume( + throwing: NSError(domain: "Camera", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "photo data empty", + ])) + return + } self.continuation.resume(returning: data) } @@ -311,6 +327,13 @@ private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDel self.didResume = true if let error { + let ns = error as NSError + if ns.domain == AVFoundationErrorDomain, + ns.code == AVError.maximumDurationReached.rawValue + { + self.continuation.resume(returning: outputFileURL) + return + } self.continuation.resume(throwing: error) return } diff --git a/apps/macos/Sources/Clawdis/CameraCaptureService.swift b/apps/macos/Sources/Clawdis/CameraCaptureService.swift index 868acd664..a7d25d359 100644 --- a/apps/macos/Sources/Clawdis/CameraCaptureService.swift +++ b/apps/macos/Sources/Clawdis/CameraCaptureService.swift @@ -61,6 +61,7 @@ actor CameraCaptureService { session.startRunning() defer { session.stopRunning() } + await Self.warmUpCaptureSession() let settings: AVCapturePhotoSettings = { if output.availablePhotoCodecTypes.contains(.jpeg) { @@ -128,6 +129,7 @@ actor CameraCaptureService { session.startRunning() defer { session.stopRunning() } + await Self.warmUpCaptureSession() let tmpMovURL = FileManager.default.temporaryDirectory .appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov") @@ -243,10 +245,16 @@ actor CameraCaptureService { } } } + + private nonisolated static func warmUpCaptureSession() async { + // A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices. + try? await Task.sleep(nanoseconds: 150_000_000) // 150ms + } } private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate { private var cont: CheckedContinuation? + private var didResume = false init(_ cont: CheckedContinuation) { self.cont = cont @@ -257,7 +265,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { - guard let cont else { return } + guard !self.didResume, let cont else { return } + self.didResume = true self.cont = nil if let error { cont.resume(throwing: error) @@ -267,8 +276,24 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("No photo data")) return } + if data.isEmpty { + cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("Photo data empty")) + return + } cont.resume(returning: data) } + + func photoOutput( + _ output: AVCapturePhotoOutput, + didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, + error: Error?) + { + guard let error else { return } + guard !self.didResume, let cont else { return } + self.didResume = true + self.cont = nil + cont.resume(throwing: error) + } } private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {