Files
ux/darwin/Camera/CaptureDevice.swift
agra 8ab672c12a 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`).
2026-05-13 19:07:29 +03:00

127 lines
4.6 KiB
Swift

import AVFoundation
/// Static helpers for camera-device discovery and per-device init.
///
/// Mirrors what telegram-ios's `CameraDevice` does for the
/// session-priority + format-negotiation cases we actually care
/// about front / back wide-angle only. Telegram's preference for
/// `TripleCamera` / `DualCamera` is for multi-cam zoom UX we don't
/// build today; if we ever need it, this is where it goes.
enum CaptureDevice {
/// Enumerate the cameras the system exposes. On iOS that's the
/// front + back wide-angle cameras (in that order, back first).
/// On macOS it's the built-in FaceTime HD plus any external /
/// Continuity cameras the OS reports.
static func discover() -> [DiscoveredCamera] {
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: deviceTypes,
mediaType: .video,
position: .unspecified
)
// Sort so back devices come first; the chat composer opens
// the back camera by default elsewhere, so this matches the
// common "first available" pick. On macOS positions are all
// `.unspecified`, so the sort is a no-op there.
return session.devices
.sorted { positionRank($0.position) < positionRank($1.position) }
.map { device in
DiscoveredCamera(
device: device,
lens: lensName(for: device.position),
sensorOrientation: defaultSensorOrientation
)
}
}
/// 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
/// for. iOS only has the built-in cameras; macOS additionally
/// surfaces externals + Continuity Camera (iPhone-as-webcam).
/// `.external` / `.externalUnknown` / `.continuityCamera` are
/// macOS-only enum cases referencing them on iOS would not
/// compile, hence the `#if os(macOS)` block.
private static var deviceTypes: [AVCaptureDevice.DeviceType] {
var types: [AVCaptureDevice.DeviceType] = [.builtInWideAngleCamera]
#if os(macOS)
if #available(macOS 14.0, *) {
types.append(.external)
types.append(.continuityCamera)
} else {
types.append(.externalUnknown)
}
#endif
return types
}
/// Apply our default per-device config: continuous autofocus,
/// continuous auto-exposure, torch off. Idempotent; safe to call
/// repeatedly. The block is wrapped in
/// `lockForConfiguration` / `unlockForConfiguration`.
static func applyDefaults(_ device: AVCaptureDevice) {
do {
try device.lockForConfiguration()
defer { device.unlockForConfiguration() }
if device.isFocusModeSupported(.continuousAutoFocus) {
device.focusMode = .continuousAutoFocus
}
if device.isExposureModeSupported(.continuousAutoExposure) {
device.exposureMode = .continuousAutoExposure
}
if device.hasTorch && device.isTorchModeSupported(.off) {
device.torchMode = .off
}
} catch {
// Best-effort a device that refuses lockForConfiguration
// will still capture frames; we just can't tweak focus.
}
}
// MARK: - private
private static func positionRank(_ position: AVCaptureDevice.Position) -> Int {
switch position {
case .back: return 0
case .front: return 1
default: return 2
}
}
private static func lensName(for position: AVCaptureDevice.Position) -> String {
switch position {
case .front: return "front"
case .back: return "back"
default: return "back"
}
}
}
/// One row of the discovery result. `lens` and `sensorOrientation`
/// match the wire shape expected by Dart's
/// `MethodChannelUxCameraBackend.availableCameras()`.
struct DiscoveredCamera {
let device: AVCaptureDevice
let lens: String
let sensorOrientation: Int
var uniqueID: String { device.uniqueID }
func toWire() -> [String: Any] {
return [
"id": uniqueID,
"lens": lens,
"sensorOrientation": sensorOrientation,
]
}
}