import AVFoundation import Foundation import ImageIO /// Wraps `AVCapturePhotoOutput`. One instance per /// [CameraInstance]; gets added to the session at create time and /// stays for the session's lifetime (no swap on camera flip — the /// output is generic, only the connection's video device changes). /// /// `take(orientation:flashMode:completion:)` is the only public entry. /// It sets the photo connection's `videoOrientation` to the /// snapshotted orientation just before firing, hands off to a /// per-capture delegate, and resets the connection back to portrait /// afterward (so a takePicture without an explicit snapshot — should /// the path ever exist — falls back cleanly). final class PhotoOutput { let avOutput = AVCapturePhotoOutput() private var inFlight: PhotoCaptureDelegate? /// Forward post-capture diagnostics (encoded JPEG dimensions + /// EXIF Orientation) through `CameraInstance.emit` so they land /// in `banlu.jsonl`. macOS-only debug aid for "photo is rotated /// 90° CW" cases — Apple's docs say /// `AVCapturePhotoOutput`'s connection rotation only affects EXIF /// tags, not pixel data, but observation contradicts that. Logging /// the actual file metadata helps tell which side is rotated. var onCapturedDiagnostic: ((String) -> Void)? /// Capture a single still. [orientation] applies to the photo /// connection. [flashMode] is applied to the per-shot /// `AVCapturePhotoSettings`. [completion] is invoked on `.main` /// with either the saved file path or an `NSError`. func take( orientation: DeviceOrientationFlutter, flashMode: AVCaptureDevice.FlashMode, completion: @escaping (Result) -> Void ) { guard let connection = avOutput.connection(with: .video) else { completion(.failure(NSError( domain: "ux.camera", code: -1, userInfo: [NSLocalizedDescriptionKey: "Photo connection unavailable"] ))) return } // Rotation handled per-platform: iOS applies the snapshot // orientation to the connection (which rotates the captured // JPEG); macOS is a no-op (desktop cams are physically // landscape, any rotation skews the photo). See // `AVCaptureConnection+iOS.swift` / `…+macOS.swift`. connection.applyUxCaptureOrientation(orientation) // The recorded photo carries no mirror; mirroring is a // preview-only concern. if connection.isVideoMirroringSupported { connection.automaticallyAdjustsVideoMirroring = false connection.isVideoMirrored = false } let settings = AVCapturePhotoSettings() // `supportedFlashModes` arrived in macOS 11; `flashMode` setter // in macOS 13. iOS has both since iOS 10. Gated via Swift // availability so we don't have to bump the macOS deployment // target just to use a flash that almost no Mac has anyway. if #available(macOS 11.0, *), avOutput.supportedFlashModes.contains(flashMode) { if #available(macOS 13.0, *) { settings.flashMode = flashMode } } let delegate = PhotoCaptureDelegate( diag: { [weak self] message in self?.onCapturedDiagnostic?(message) } ) { [weak self] result in // Reset orientation back to portraitUp on the photo // connection so a follow-up shot without an explicit // snapshot defaults cleanly. No-op on macOS (the // extension method is empty there). self?.avOutput.connection(with: .video)? .applyUxCaptureOrientation(.portraitUp) self?.inFlight = nil DispatchQueue.main.async { completion(result) } } // Retain the delegate for the duration of the capture — // AVCapturePhotoOutput holds it weakly. inFlight = delegate avOutput.capturePhoto(with: settings, delegate: delegate) } } /// Per-shot delegate. Receives the photo, writes /// `fileDataRepresentation()` to a unique path under /// `NSTemporaryDirectory()`, invokes the completion. The plugin /// retains it via [PhotoOutput.inFlight] across the async hop. private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate { private let diag: (String) -> Void private let completion: (Result) -> Void init( diag: @escaping (String) -> Void, completion: @escaping (Result) -> Void ) { self.diag = diag self.completion = completion } func photoOutput( _ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error? ) { if let error = error as NSError? { completion(.failure(error)) return } guard let data = photo.fileDataRepresentation() else { completion(.failure(NSError( domain: "ux.camera", code: -2, userInfo: [NSLocalizedDescriptionKey: "No photo data"] ))) return } // Parse the produced JPEG's actual pixel dimensions + EXIF // Orientation so we can see in `banlu.jsonl` whether the // pixel buffer is sensor-native (landscape) or rotated. The // dimensions come from `CGImageSourceCopyProperties` reading // the SOFn marker, EXIF orientation from kCGImagePropertyOrientation. if let src = CGImageSourceCreateWithData(data as CFData, nil), let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any] { let w = (props[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue ?? -1 let h = (props[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue ?? -1 let orient = (props[kCGImagePropertyOrientation] as? NSNumber)?.intValue ?? -1 let aspect = w > h ? "landscape" : (h > w ? "portrait" : "square") diag("photo: \(w)x\(h) \(aspect) exifOrientation=\(orient)") } let url = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("ux_camera_\(UUID().uuidString).jpg") do { try data.write(to: url, options: .atomic) completion(.success(url.path)) } catch let error as NSError { completion(.failure(error)) } } }