camera: per-platform capture-orientation extension + macOS sensor=0

macOS preview was stretching (aspect wrong) and macOS photo capture
was rotating the landscape sensor 90° because the shared
PhotoOutput / CameraInstance code was setting
`AVCaptureConnection.videoOrientation` from the orientation snapshot
unconditionally. iOS needs that to rotate sample buffers to portrait;
macOS desktop cams are physically landscape and any rotation just
skews the result.

Moved the rotation call behind a per-platform extension on
`AVCaptureConnection`:
  - `ios/Classes/Camera/AVCaptureConnection+iOS.swift` applies the
    snapshot orientation (current behavior).
  - `macos/Classes/Camera/AVCaptureConnection+macOS.swift` is a
    no-op. macOS-flavoured photos / preview frames now flow at
    native landscape orientation.

`CaptureDevice` reports sensorOrientation=0 on macOS (was hardcoded
90 for iOS); on macOS the page's `normalizeCameraCapture` math then
collapses to identity and the saved JPEG stays the landscape the
sensor produced. iOS keeps sensorOrientation=90 (matches
camera_avfoundation's reported value and the existing capture-
transform math).

Photo and video paths now both produce upright content on macOS
(video already worked because VideoRecorder's transform table maps
the always-portraitUp macOS snapshot to `.identity`).
This commit is contained in:
agra
2026-05-13 19:07:29 +03:00
parent a6d2539722
commit 8ab672c12a
5 changed files with 62 additions and 26 deletions

View File

@@ -425,10 +425,9 @@ final class CameraInstance {
// front camera raw sensor feed at capture, mirror as a // front camera raw sensor feed at capture, mirror as a
// playback decision. // playback decision.
if let videoConn = videoDataOutput?.connection(with: .video) { if let videoConn = videoDataOutput?.connection(with: .video) {
if videoConn.isVideoOrientationSupported { videoConn.applyUxCaptureOrientation(
videoConn.videoOrientation = lockedOrientation?.avVideoOrientation lockedOrientation ?? orientation.current
?? orientation.current.avVideoOrientation )
}
if videoConn.isVideoMirroringSupported { if videoConn.isVideoMirroringSupported {
videoConn.automaticallyAdjustsVideoMirroring = false videoConn.automaticallyAdjustsVideoMirroring = false
videoConn.isVideoMirrored = false videoConn.isVideoMirrored = false
@@ -457,11 +456,8 @@ final class CameraInstance {
} }
private func applyVideoOrientationOnPreview(_ next: DeviceOrientationFlutter) { private func applyVideoOrientationOnPreview(_ next: DeviceOrientationFlutter) {
guard let conn = videoDataOutput?.connection(with: .video), videoDataOutput?.connection(with: .video)?
conn.isVideoOrientationSupported else { .applyUxCaptureOrientation(next)
return
}
conn.videoOrientation = next.avVideoOrientation
} }
private func emit(_ extras: [String: Any]) { private func emit(_ extras: [String: Any]) {

View File

@@ -28,18 +28,22 @@ enum CaptureDevice {
DiscoveredCamera( DiscoveredCamera(
device: device, device: device,
lens: lensName(for: device.position), lens: lensName(for: device.position),
// iOS doesn't expose sensor orientation directly; sensorOrientation: defaultSensorOrientation
// 90° matches what `camera_avfoundation` reports
// and what banlu's `normalizeCameraCapture` math
// assumes for iOS sensors. macOS desktop cameras
// are already landscape 0 is the right answer
// there but the value is unused on macOS (no
// recording rotation, no preview rotation).
sensorOrientation: 90
) )
} }
} }
/// Hardcoded per platform: 90° on iOS (matches the value
/// `camera_avfoundation` reports and that banlu's
/// `normalizeCameraCapture` math assumes), 0° on macOS (desktop
/// cams are already physically landscape any non-zero value
/// would make `normalizeCameraCapture` rotate the saved JPEG).
#if os(iOS)
private static let defaultSensorOrientation = 90
#else
private static let defaultSensorOrientation = 0
#endif
/// Per-platform set of device types the discovery session asks /// Per-platform set of device types the discovery session asks
/// for. iOS only has the built-in cameras; macOS additionally /// for. iOS only has the built-in cameras; macOS additionally
/// surfaces externals + Continuity Camera (iPhone-as-webcam). /// surfaces externals + Continuity Camera (iPhone-as-webcam).

View File

@@ -34,9 +34,12 @@ final class PhotoOutput {
))) )))
return return
} }
if connection.isVideoOrientationSupported { // Rotation handled per-platform: iOS applies the snapshot
connection.videoOrientation = orientation.avVideoOrientation // 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 // The recorded photo carries no mirror; mirroring is a
// preview-only concern. // preview-only concern.
if connection.isVideoMirroringSupported { if connection.isVideoMirroringSupported {
@@ -57,12 +60,12 @@ final class PhotoOutput {
} }
let delegate = PhotoCaptureDelegate { [weak self] result in let delegate = PhotoCaptureDelegate { [weak self] result in
// Reset orientation on the photo connection so a future // Reset orientation back to portraitUp on the photo
// capture without a snapshot defaults to portrait. // connection so a follow-up shot without an explicit
if let conn = self?.avOutput.connection(with: .video), // snapshot defaults cleanly. No-op on macOS (the
conn.isVideoOrientationSupported { // extension method is empty there).
conn.videoOrientation = .portrait self?.avOutput.connection(with: .video)?
} .applyUxCaptureOrientation(.portraitUp)
self?.inFlight = nil self?.inFlight = nil
DispatchQueue.main.async { completion(result) } DispatchQueue.main.async { completion(result) }
} }

View File

@@ -0,0 +1,16 @@
import AVFoundation
/// Per-platform shim for applying capture-time rotation to an
/// `AVCaptureConnection`. On iOS the connection's `videoOrientation`
/// genuinely rotates the sample buffers / captured photo; on macOS
/// it appears to rotate stills but not preview/data-output, AND
/// desktop cameras are physically landscape so any rotation skews
/// the result. The macOS counterpart in
/// `macos/Classes/Camera/AVCaptureConnection+macOS.swift` is a no-op.
extension AVCaptureConnection {
func applyUxCaptureOrientation(_ orientation: DeviceOrientationFlutter) {
if isVideoOrientationSupported {
videoOrientation = orientation.avVideoOrientation
}
}
}

View File

@@ -0,0 +1,17 @@
import AVFoundation
/// macOS counterpart of `AVCaptureConnection+iOS.swift`.
///
/// macOS desktop cameras are physically fixed landscape applying any
/// `AVCaptureVideoOrientation` would skew the captured photo (and on
/// some macOS versions the preview's data-output buffers) by 90°.
/// The orientation snapshot from Flutter (always `portraitUp` on
/// macOS desktops don't rotate) is therefore ignored at the
/// connection layer; the recorded video's track transform is still
/// `.identity` from VideoRecorder's existing mapping, so video stays
/// landscape too.
extension AVCaptureConnection {
func applyUxCaptureOrientation(_ orientation: DeviceOrientationFlutter) {
// Intentionally empty.
}
}