Per Apple's macOS AVCaptureSession.h docs (line 1106), setting `videoRotationAngle` on `AVCapturePhotoOutput`'s connection "does not necessarily result in physical rotation of video buffers … In the AVCapturePhotoOutput, orientation is handled using Exif tags." So our connection-rotation tweaks only affect the EXIF Orientation tag the JPEG carries — pixel data is sensor-native. Yet the user keeps seeing a rotated JPEG even after `stripJpegApp1` removes APP1 (EXIF). So either the pixel buffer IS rotated despite the docs, or EXIF is in a non-APP1 marker, or Flutter's decoder auto-rotates somehow. Log the actual captured JPEG's dimensions + EXIF Orientation to banlu.jsonl via the existing per-handle diagnostic stream: `CGImageSourceCopyPropertiesAtIndex` reads `kCGImagePropertyPixelWidth/Height/Orientation` from the JPEG bytes that `AVCapturePhoto.fileDataRepresentation()` produces. Format: `photo: WxH landscape|portrait exifOrientation=N`. Once we see what AVCapturePhotoOutput is actually producing on the user's Mac we'll know which side of the pipeline to fix.
152 lines
6.4 KiB
Swift
152 lines
6.4 KiB
Swift
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<String, NSError>) -> 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<String, NSError>) -> Void
|
|
|
|
init(
|
|
diag: @escaping (String) -> Void,
|
|
completion: @escaping (Result<String, NSError>) -> 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))
|
|
}
|
|
}
|
|
}
|