fix(camera): harden capture pipeline
This commit is contained in:
@@ -66,6 +66,7 @@ actor CameraController {
|
|||||||
|
|
||||||
session.startRunning()
|
session.startRunning()
|
||||||
defer { session.stopRunning() }
|
defer { session.stopRunning() }
|
||||||
|
await Self.warmUpCaptureSession()
|
||||||
|
|
||||||
let settings: AVCapturePhotoSettings = {
|
let settings: AVCapturePhotoSettings = {
|
||||||
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
||||||
@@ -144,6 +145,7 @@ actor CameraController {
|
|||||||
|
|
||||||
session.startRunning()
|
session.startRunning()
|
||||||
defer { session.stopRunning() }
|
defer { session.stopRunning() }
|
||||||
|
await Self.warmUpCaptureSession()
|
||||||
|
|
||||||
let movURL = FileManager.default.temporaryDirectory
|
let movURL = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov")
|
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov")
|
||||||
@@ -217,7 +219,7 @@ actor CameraController {
|
|||||||
|
|
||||||
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
|
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
|
||||||
let asset = AVURLAsset(url: inputURL)
|
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")
|
throw CameraError.exportFailed("Failed to create export session")
|
||||||
}
|
}
|
||||||
exporter.shouldOptimizeForNetworkUse = true
|
exporter.shouldOptimizeForNetworkUse = true
|
||||||
@@ -233,22 +235,29 @@ actor CameraController {
|
|||||||
exporter.outputURL = outputURL
|
exporter.outputURL = outputURL
|
||||||
exporter.outputFileType = .mp4
|
exporter.outputFileType = .mp4
|
||||||
|
|
||||||
try await withCheckedThrowingContinuation(isolation: nil) { cont in
|
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
||||||
exporter.exportAsynchronously {
|
exporter.exportAsynchronously {
|
||||||
switch exporter.status {
|
cont.resume(returning: ())
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||||
@@ -278,6 +287,13 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
|||||||
]))
|
]))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if data.isEmpty {
|
||||||
|
self.continuation.resume(
|
||||||
|
throwing: NSError(domain: "Camera", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "photo data empty",
|
||||||
|
]))
|
||||||
|
return
|
||||||
|
}
|
||||||
self.continuation.resume(returning: data)
|
self.continuation.resume(returning: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,6 +327,13 @@ private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDel
|
|||||||
self.didResume = true
|
self.didResume = true
|
||||||
|
|
||||||
if let error {
|
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)
|
self.continuation.resume(throwing: error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ actor CameraCaptureService {
|
|||||||
|
|
||||||
session.startRunning()
|
session.startRunning()
|
||||||
defer { session.stopRunning() }
|
defer { session.stopRunning() }
|
||||||
|
await Self.warmUpCaptureSession()
|
||||||
|
|
||||||
let settings: AVCapturePhotoSettings = {
|
let settings: AVCapturePhotoSettings = {
|
||||||
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
||||||
@@ -128,6 +129,7 @@ actor CameraCaptureService {
|
|||||||
|
|
||||||
session.startRunning()
|
session.startRunning()
|
||||||
defer { session.stopRunning() }
|
defer { session.stopRunning() }
|
||||||
|
await Self.warmUpCaptureSession()
|
||||||
|
|
||||||
let tmpMovURL = FileManager.default.temporaryDirectory
|
let tmpMovURL = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov")
|
.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 final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||||
private var cont: CheckedContinuation<Data, Error>?
|
private var cont: CheckedContinuation<Data, Error>?
|
||||||
|
private var didResume = false
|
||||||
|
|
||||||
init(_ cont: CheckedContinuation<Data, Error>) {
|
init(_ cont: CheckedContinuation<Data, Error>) {
|
||||||
self.cont = cont
|
self.cont = cont
|
||||||
@@ -257,7 +265,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
|||||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||||
error: Error?)
|
error: Error?)
|
||||||
{
|
{
|
||||||
guard let cont else { return }
|
guard !self.didResume, let cont else { return }
|
||||||
|
self.didResume = true
|
||||||
self.cont = nil
|
self.cont = nil
|
||||||
if let error {
|
if let error {
|
||||||
cont.resume(throwing: error)
|
cont.resume(throwing: error)
|
||||||
@@ -267,8 +276,24 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
|||||||
cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("No photo data"))
|
cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("No photo data"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if data.isEmpty {
|
||||||
|
cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("Photo data empty"))
|
||||||
|
return
|
||||||
|
}
|
||||||
cont.resume(returning: data)
|
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 {
|
private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
|
||||||
|
|||||||
Reference in New Issue
Block a user