fix(camera): harden capture pipeline

This commit is contained in:
Peter Steinberger
2025-12-14 05:30:34 +00:00
parent f3db02018f
commit 7d4c8ef6b2
2 changed files with 61 additions and 13 deletions

View File

@@ -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<Void, Error>) 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
}

View File

@@ -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<Data, Error>?
private var didResume = false
init(_ cont: CheckedContinuation<Data, Error>) {
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 {