From 52b5e78e75da837fd14572be4ea51ffec9fdffc5 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sun, 8 Nov 2026 17:24:25 -0800 Subject: [PATCH] Portrait mode: a third capture mode with the background blurred from depth The director shows PHOTO / PORTRAIT / VIDEO when every camera in the rig reports supports_portrait; one camera that can't (older build, single-lens phone, Mac, front wide camera) hides the tab and drops the rig to Photo. Wire: RecordingModeEnum.Portrait = 4 and CameraState.supports_portrait. No new commands; SetCameraMode carries the mode. Camera: Portrait is the photo format plus depth and person-matte delivery, capped at 12MP. The blur is baked by CIDepthBlurEffect at f/4.5 and the still is cropped and encoded like any other. Shutter paths keep a still mode instead of forcing Photo. Co-Authored-By: Claude Opus 5.5 --- RemoteCam/CameraProgressOverlayView.swift | 1 + RemoteCam/CameraScreenView.swift | 1 + RemoteCam/CameraViewModel.swift | 2 +- RemoteCam/CaptureEngine.swift | 127 +++++++++++++++--- RemoteCam/FlatBufferSchemas.fbs | 8 +- RemoteCam/FlatBufferSchemas_generated.swift | 14 +- RemoteCam/MultiCamChrome.swift | 2 +- RemoteCam/MulticamController.swift | 14 +- RemoteCam/MulticamView.swift | 42 +++--- RemoteCam/MulticamViewController.swift | 10 +- RemoteCam/MulticamViewModel.swift | 16 ++- RemoteCam/RemoteCmdFlatBuffers.swift | 6 +- RemoteCam/RemoteCmds.swift | 6 + RemoteCam/RigQualityMenu.swift | 4 +- RemoteCam/SessionCoordinator.swift | 18 ++- RemoteCam/UICmds.swift | 9 ++ RemoteCam/WatchRemoteCameraController.swift | 2 +- RemoteCam/da.lproj/Localizable.strings | 1 + RemoteCam/de-DE.lproj/Localizable.strings | 1 + RemoteCam/en.lproj/Localizable.strings | 1 + RemoteCam/es-MX.lproj/Localizable.strings | 1 + RemoteCam/fr-FR.lproj/Localizable.strings | 1 + RemoteCam/hi.lproj/Localizable.strings | 1 + RemoteCam/it.lproj/Localizable.strings | 1 + RemoteCam/ja.lproj/Localizable.strings | 1 + RemoteCam/ko.lproj/Localizable.strings | 1 + RemoteCam/ms.lproj/Localizable.strings | 1 + RemoteCam/pt-BR.lproj/Localizable.strings | 1 + RemoteCam/ru.lproj/Localizable.strings | 1 + RemoteCam/tr.lproj/Localizable.strings | 1 + RemoteCam/vi.lproj/Localizable.strings | 1 + RemoteCam/zh-Hans.lproj/Localizable.strings | 1 + RemoteCamTests/CaptureIntegrationTests.swift | 55 ++++++++ RemoteCamTests/CropRectTests.swift | 7 + RemoteCamTests/MultiCamChromeTests.swift | 6 + RemoteCamTests/MulticamControllerTests.swift | 34 +++++ RemoteCamTests/MulticamViewModelTests.swift | 23 +++- RemoteCamTests/RemoteCamSessionTests.swift | 26 ++++ .../RemoteCmdSerializationTests.swift | 26 ++++ 39 files changed, 416 insertions(+), 58 deletions(-) diff --git a/RemoteCam/CameraProgressOverlayView.swift b/RemoteCam/CameraProgressOverlayView.swift index 8aa5c7f6..6086e204 100644 --- a/RemoteCam/CameraProgressOverlayView.swift +++ b/RemoteCam/CameraProgressOverlayView.swift @@ -86,6 +86,7 @@ struct CameraProgressOverlayView: View { case .Photo: return "Photo" case .Video: return "Video" case .Shorts: return "Shorts" + case .Portrait: return "Portrait" } } } diff --git a/RemoteCam/CameraScreenView.swift b/RemoteCam/CameraScreenView.swift index 740d56d9..001463ee 100644 --- a/RemoteCam/CameraScreenView.swift +++ b/RemoteCam/CameraScreenView.swift @@ -331,6 +331,7 @@ struct CameraStandbyView: View { case .Photo: return NSLocalizedString("Photo", comment: "capture mode") case .Video: return NSLocalizedString("Video", comment: "capture mode") case .Shorts: return NSLocalizedString("Shorts", comment: "capture mode") + case .Portrait: return NSLocalizedString("Portrait", comment: "capture mode") } } } diff --git a/RemoteCam/CameraViewModel.swift b/RemoteCam/CameraViewModel.swift index 1d30439d..6e4ca42d 100644 --- a/RemoteCam/CameraViewModel.swift +++ b/RemoteCam/CameraViewModel.swift @@ -88,7 +88,7 @@ class CameraViewModel: ObservableObject { switch mode { case .Video, .Shorts: self.qualityInfo = "\(resolution.displayName) \(frameRate.displayName)fps" - case .Photo: + case .Photo, .Portrait: var info = photoFormat.displayName if hdrMode == .on { info += " HDR" } self.qualityInfo = info diff --git a/RemoteCam/CaptureEngine.swift b/RemoteCam/CaptureEngine.swift index 46c112cb..b369ae25 100644 --- a/RemoteCam/CaptureEngine.swift +++ b/RemoteCam/CaptureEngine.swift @@ -8,6 +8,7 @@ import Atomics import UIKit import AVFoundation +import CoreImage /// Classifies capture-session runtime errors by recency. One error is /// transient (media services reset — resume and move on); a repeat inside the @@ -196,7 +197,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { } self.captureSession.beginConfiguration() self.captureSession.sessionPreset = - (self.captureMode == .Photo && Self.photoPresetSupported + (self.captureMode.isStill && Self.photoPresetSupported && self.captureSession.canSetSessionPreset(.photo)) ? .photo : .high @@ -234,6 +235,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { self.configSessionOutput() self.captureSession.commitConfiguration() self.reconcilePreviewOutputLocked() + self.applyDepthDeliveryLocked() self.updateMaxPhotoDimensionsLocked() self.currentPhotoFormat = Self.defaultPhotoFormat( hevcAvailable: self.photoOutput.availablePhotoCodecTypes.contains(.hevc)) @@ -409,6 +411,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { rotateOutputsLocked(orientation: orientation) let newFlashMode: AVCaptureDevice.FlashMode? = newDevice.hasFlash ? cameraSettings.flashMode : nil captureSession.commitConfiguration() + applyDepthDeliveryLocked() // the new device may or may not deliver depth reconcilePreviewOutputLocked() updateMaxPhotoDimensionsLocked() applyDesiredTorchLocked() // restore torch onto the new camera (no-op if it has none) @@ -1105,6 +1108,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { aspectRatio: currentAspectRatio, exposure: exposureStateLocked(currentDevice), cameraMode: captureMode, + supportsPortrait: Self.supportsPortrait(currentDevice), error: nil ) @@ -1779,30 +1783,64 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { /// Follows the rig's `currentCameraMode`. Stores the mode even before the /// session is set up (setup reads it); once live, reconfigures the session: - /// Photo gets the full-sensor `.photo` preset, Video/Shorts get the selected - /// video format back. Dedupes, so repeated sets are free. + /// Photo and Portrait get the full-sensor `.photo` preset (Portrait adds + /// depth delivery), Video/Shorts get the selected video format back. + /// Dedupes, so repeated sets are free. func setCaptureMode(_ mode: RecordingMode) { sessionQueue.async { guard mode != self.captureMode else { return } + let previous = self.captureMode self.captureMode = mode guard self.videoDeviceInput?.device != nil else { return } - self.applyCaptureModeLocked() + self.applyCaptureModeLocked(from: previous) } } /// Reconfigures the session for the stored mode. A real reconfiguration /// changes the frame dimensions (16:9 video ↔ 4:3 full sensor) — a scene - /// cut for the preview encoder, announced like a device swap. - private func applyCaptureModeLocked() { + /// cut for the preview encoder, announced like a device swap. Photo ↔ + /// Portrait keeps the photo format and only toggles depth delivery. + private func applyCaptureModeLocked(from previous: RecordingMode) { dispatchPrecondition(condition: .onQueue(sessionQueue)) - let changed = captureMode == .Photo - ? applyPhotoFormatLocked() - : setVideoQualityLocked(resolution: currentVideoResolution, - frameRate: currentVideoFrameRate, - isRecording: false) != nil + let changed: Bool + if captureMode.isStill && previous.isStill { + changed = false + } else if captureMode.isStill { + changed = applyPhotoFormatLocked() + } else { + changed = setVideoQualityLocked(resolution: currentVideoResolution, + frameRate: currentVideoFrameRate, + isRecording: false) != nil + } + applyDepthDeliveryLocked() if changed { onDeviceSwapped?() } } + /// Whether `device` can take Portrait stills: it has formats that deliver + /// depth (dual/triple back cameras, TrueDepth front) and the platform runs + /// the photo preset (Mac cameras have no depth). + static func supportsPortrait(_ device: AVCaptureDevice) -> Bool { + photoPresetSupported && device.formats.contains { !$0.supportedDepthDataFormats.isEmpty } + } + + /// Depth (and the person matte, where offered) is delivered only in + /// Portrait. Turning it on can narrow a virtual device's zoom range and + /// photo sizes, so the format tail re-clamps zoom and the photo ceiling. + private func applyDepthDeliveryLocked() { + dispatchPrecondition(condition: .onQueue(sessionQueue)) + let depth = captureMode == .Portrait && photoOutput.isDepthDataDeliverySupported + let matte = depth && photoOutput.isPortraitEffectsMatteDeliverySupported + guard photoOutput.isDepthDataDeliveryEnabled != depth + || photoOutput.isPortraitEffectsMatteDeliveryEnabled != matte else { return } + captureSession.beginConfiguration() + if !matte { photoOutput.isPortraitEffectsMatteDeliveryEnabled = false } + photoOutput.isDepthDataDeliveryEnabled = depth + if matte { photoOutput.isPortraitEffectsMatteDeliveryEnabled = true } + captureSession.commitConfiguration() + restoreAfterFormatChangeLocked() + onStatusChanged?() + } + /// Puts the session on the full-sensor `.photo` preset, keeping the /// device's frame rate (`resolveFrameRate` clamps it into the photo /// format's own ranges). Where the preset is unavailable the session @@ -1987,6 +2025,10 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { hevcAvailable ? .heif : .jpeg } + /// Portrait stills cap at 12MP. Larger sizes with depth on are untested, + /// and an unsupported photo size raises an uncatchable exception. + static let portraitMaxPhotoPixels: Int64 = 12_600_000 + /// Single point that keeps the photo output asking for everything the /// ACTIVE format can produce (12/24/48MP on modern iPhones). Must run /// after the configuration commit — `activeFormat` settles there. @@ -1994,9 +2036,11 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { dispatchPrecondition(condition: .onQueue(sessionQueue)) guard let device = videoDeviceInput?.device else { return } if #available(iOS 16.0, macCatalyst 16.0, *) { - guard let dims = device.activeFormat.supportedMaxPhotoDimensions - .max(by: { Int64($0.width) * Int64($0.height) < Int64($1.width) * Int64($1.height) }) - else { return } + let pixels = { (d: CMVideoDimensions) in Int64(d.width) * Int64(d.height) } + let candidates = device.activeFormat.supportedMaxPhotoDimensions.filter { + !photoOutput.isDepthDataDeliveryEnabled || pixels($0) <= Self.portraitMaxPhotoPixels + } + guard let dims = candidates.max(by: { pixels($0) < pixels($1) }) else { return } photoOutput.maxPhotoDimensions = dims } } @@ -2013,7 +2057,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { guard !isRecording else { return nil } guard let device = videoDeviceInput?.device else { return nil } - guard captureMode != .Photo else { + guard !captureMode.isStill else { currentVideoResolution = resolution currentVideoFrameRate = frameRate fpsSetting.store(frameRate.value, ordering: .relaxed) @@ -2124,15 +2168,54 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { return nil } + return encode(croppedCG, like: source, type: type) + } + + /// Simulated aperture for Portrait stills; Apple's Camera opens at f/4.5. + static let portraitAperture: Float = 4.5 + private static let portraitContext = CIContext() + + /// A Portrait still: Core Image's portrait filter blurs the background + /// from the photo's own depth (and person matte, when delivered), then the + /// result is cropped to the aspect ratio and encoded like `cropPhotoData` + /// (sensor space, EXIF orientation and metadata carried). The blur is baked + /// in; the depth is not kept. Nil when the data carries no depth, so the + /// caller falls back to a plain crop. + static func portraitPhotoData(_ data: Data, to aspectRatio: AspectRatio) -> Data? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let type = CGImageSourceGetType(source), + let filter = portraitContext.depthBlurEffectFilter(forImageData: data, options: nil) else { + debugLog("portraitPhotoData: no depth in photo data (\(data.count) bytes)") + return nil + } + filter.setValue(portraitAperture, forKey: "inputAperture") + guard let blurred = filter.outputImage else { return nil } + let extent = blurred.extent + let rect = cropRect(sourceWidth: extent.width, sourceHeight: extent.height, + aspectRatio: aspectRatio)? + .offsetBy(dx: extent.minX, dy: extent.minY) ?? extent + let colorSpace = CGImageSourceCreateImageAtIndex(source, 0, nil)?.colorSpace + ?? CGColorSpace(name: CGColorSpace.sRGB)! + guard let cgImage = portraitContext.createCGImage( + blurred, from: rect, format: .RGBA8, colorSpace: colorSpace) else { + debugLog("portraitPhotoData: render failed for rect \(rect)") + return nil + } + return encode(cgImage, like: source, type: type) + } + + /// Encodes `image` into `type` with `source`'s metadata, EXIF pixel + /// dimensions rewritten to the image's size. + private static func encode(_ image: CGImage, like source: CGImageSource, type: CFString) -> Data? { let properties = destinationProperties( - of: source, croppedWidth: croppedCG.width, croppedHeight: croppedCG.height) + of: source, croppedWidth: image.width, croppedHeight: image.height) let output = NSMutableData() guard let dest = CGImageDestinationCreateWithData(output, type, 1, nil) else { return nil } - CGImageDestinationAddImage(dest, croppedCG, properties as CFDictionary) + CGImageDestinationAddImage(dest, image, properties as CFDictionary) guard CGImageDestinationFinalize(dest) else { - debugLog("cropPhotoData: CGImageDestinationFinalize failed") + debugLog("encode: CGImageDestinationFinalize failed") return nil } return output as Data @@ -2187,6 +2270,10 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { newSettings.isHighResolutionPhotoEnabled = settings.isHighResolutionPhotoEnabled } newSettings.photoQualityPrioritization = .quality + if photoOutput.isDepthDataDeliveryEnabled { + newSettings.isDepthDataDeliveryEnabled = true + newSettings.isPortraitEffectsMatteDeliveryEnabled = photoOutput.isPortraitEffectsMatteDeliveryEnabled + } return newSettings } @@ -2213,6 +2300,10 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { // through the session queue. Any ratio other than the still's own is // a crop of the full capture; a failed crop delivers the full frame. let aspectRatio = currentAspectRatioValue() + if photo.depthData != nil, let portrait = Self.portraitPhotoData(photoData, to: aspectRatio) { + onPicture?(portrait, nil) + return + } onPicture?(Self.cropPhotoData(photoData, to: aspectRatio) ?? photoData, nil) } } diff --git a/RemoteCam/FlatBufferSchemas.fbs b/RemoteCam/FlatBufferSchemas.fbs index 2d29644e..b74ee7f1 100644 --- a/RemoteCam/FlatBufferSchemas.fbs +++ b/RemoteCam/FlatBufferSchemas.fbs @@ -189,7 +189,9 @@ enum RecordingModeEnum : byte { Unknown = 0, Photo = 1, Video = 2, - Shorts = 3 + Shorts = 3, + Portrait = 4 // a still with the background blurred from depth. Only + // sent to a camera whose CameraState sets supports_portrait. } enum AspectRatioEnum : byte { @@ -391,6 +393,10 @@ table CameraState { // stills, video = the selected video format). Absent/Unknown = peer // predates mode pre-warming; a director must not send SetCameraMode. camera_mode: RecordingModeEnum; + // The active camera can take Portrait stills (it delivers depth). Moves + // with every device swap. False/absent = a director must not send + // Portrait; it offers the mode only when every camera in the rig sets it. + supports_portrait: bool; } table CameraCapabilities { diff --git a/RemoteCam/FlatBufferSchemas_generated.swift b/RemoteCam/FlatBufferSchemas_generated.swift index 24709c23..4a2279fc 100644 --- a/RemoteCam/FlatBufferSchemas_generated.swift +++ b/RemoteCam/FlatBufferSchemas_generated.swift @@ -227,8 +227,9 @@ public enum RemoteShutter_RecordingModeEnum: Int8, Enum, Verifiable { case photo = 1 case video = 2 case shorts = 3 + case portrait = 4 - public static var max: RemoteShutter_RecordingModeEnum { return .shorts } + public static var max: RemoteShutter_RecordingModeEnum { return .portrait } public static var min: RemoteShutter_RecordingModeEnum { return .unknown } } @@ -1160,6 +1161,7 @@ public struct RemoteShutter_CameraState: FlatBufferObject, Verifiable { case previewMode = 26 case exposure = 28 case cameraMode = 30 + case supportsPortrait = 32 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -1179,7 +1181,8 @@ public struct RemoteShutter_CameraState: FlatBufferObject, Verifiable { public var previewMode: RemoteShutter_CameraPreviewModeEnum { let o = _accessor.offset(VTOFFSET.previewMode.v); return o == 0 ? .unknown : RemoteShutter_CameraPreviewModeEnum(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .unknown } public var exposure: RemoteShutter_ExposureState? { let o = _accessor.offset(VTOFFSET.exposure.v); return o == 0 ? nil : RemoteShutter_ExposureState(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } public var cameraMode: RemoteShutter_RecordingModeEnum { let o = _accessor.offset(VTOFFSET.cameraMode.v); return o == 0 ? .unknown : RemoteShutter_RecordingModeEnum(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .unknown } - public static func startCameraState(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 14) } + public var supportsPortrait: Bool { let o = _accessor.offset(VTOFFSET.supportsPortrait.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } + public static func startCameraState(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 15) } public static func add(currentCamera: RemoteShutter_CameraPosition, _ fbb: inout FlatBufferBuilder) { fbb.add(element: currentCamera.rawValue, def: 0, at: VTOFFSET.currentCamera.p) } public static func add(currentLens: RemoteShutter_CameraLensType, _ fbb: inout FlatBufferBuilder) { fbb.add(element: currentLens.rawValue, def: 0, at: VTOFFSET.currentLens.p) } public static func add(zoomFactor: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: zoomFactor, def: 0.0, at: VTOFFSET.zoomFactor.p) } @@ -1194,6 +1197,8 @@ public struct RemoteShutter_CameraState: FlatBufferObject, Verifiable { public static func add(previewMode: RemoteShutter_CameraPreviewModeEnum, _ fbb: inout FlatBufferBuilder) { fbb.add(element: previewMode.rawValue, def: 0, at: VTOFFSET.previewMode.p) } public static func add(exposure: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: exposure, at: VTOFFSET.exposure.p) } public static func add(cameraMode: RemoteShutter_RecordingModeEnum, _ fbb: inout FlatBufferBuilder) { fbb.add(element: cameraMode.rawValue, def: 0, at: VTOFFSET.cameraMode.p) } + public static func add(supportsPortrait: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: supportsPortrait, def: false, + at: VTOFFSET.supportsPortrait.p) } public static func endCameraState(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCameraState( _ fbb: inout FlatBufferBuilder, @@ -1210,7 +1215,8 @@ public struct RemoteShutter_CameraState: FlatBufferObject, Verifiable { activeDeviceIdOffset activeDeviceId: Offset = Offset(), previewMode: RemoteShutter_CameraPreviewModeEnum = .unknown, exposureOffset exposure: Offset = Offset(), - cameraMode: RemoteShutter_RecordingModeEnum = .unknown + cameraMode: RemoteShutter_RecordingModeEnum = .unknown, + supportsPortrait: Bool = false ) -> Offset { let __start = RemoteShutter_CameraState.startCameraState(&fbb) RemoteShutter_CameraState.add(currentCamera: currentCamera, &fbb) @@ -1227,6 +1233,7 @@ public struct RemoteShutter_CameraState: FlatBufferObject, Verifiable { RemoteShutter_CameraState.add(previewMode: previewMode, &fbb) RemoteShutter_CameraState.add(exposure: exposure, &fbb) RemoteShutter_CameraState.add(cameraMode: cameraMode, &fbb) + RemoteShutter_CameraState.add(supportsPortrait: supportsPortrait, &fbb) return RemoteShutter_CameraState.endCameraState(&fbb, start: __start) } @@ -1246,6 +1253,7 @@ public struct RemoteShutter_CameraState: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.previewMode.p, fieldName: "previewMode", required: false, type: RemoteShutter_CameraPreviewModeEnum.self) try _v.visit(field: VTOFFSET.exposure.p, fieldName: "exposure", required: false, type: ForwardOffset.self) try _v.visit(field: VTOFFSET.cameraMode.p, fieldName: "cameraMode", required: false, type: RemoteShutter_RecordingModeEnum.self) + try _v.visit(field: VTOFFSET.supportsPortrait.p, fieldName: "supportsPortrait", required: false, type: Bool.self) _v.finish() } } diff --git a/RemoteCam/MultiCamChrome.swift b/RemoteCam/MultiCamChrome.swift index e13feac3..0eb645b0 100644 --- a/RemoteCam/MultiCamChrome.swift +++ b/RemoteCam/MultiCamChrome.swift @@ -80,7 +80,7 @@ enum RigTray { switch mode { case .video: items.append(.resolution) - case .photo: + case .photo, .portrait: items.append(contentsOf: [.format, .hdr]) } diff --git a/RemoteCam/MulticamController.swift b/RemoteCam/MulticamController.swift index f1ba9c4e..3692c459 100644 --- a/RemoteCam/MulticamController.swift +++ b/RemoteCam/MulticamController.swift @@ -675,6 +675,10 @@ public actor MulticamController { if caps.aspectRatio != activeAspectRatio { sendControl(.setaspectratio, RemoteCmd.SetAspectRatio(aspectRatio: activeAspectRatio), to: peer) } + // A camera that can't take Portrait drops the whole rig to Photo. + if rigCameraMode == .Portrait && !rigSupportsPortrait() { + handleSetCameraMode(.Photo) + } // Mode pre-warm rides the same gate: reporting a mode is the // capability, and only a differing camera is commanded. if let reported = caps.cameraMode, reported != rigCameraMode { @@ -1442,6 +1446,13 @@ public actor MulticamController { return order.filter { armed.contains($0) } } + /// Portrait is offered only when every live camera reports it; one camera + /// that can't (an older build, a single-lens phone, a Mac) hides it. + private func rigSupportsPortrait() -> Bool { + let live = order.compactMap { links[$0] }.filter { $0.status != .failed } + return !live.isEmpty && live.allSatisfy { $0.capabilities?.supportsPortrait == true } + } + /// The tray's snapshot, derived on demand from the live lane set — never /// stored, so it cannot go stale when a camera joins or drops. private func rigSettingsSnapshot() -> RigSettingsSnapshot { @@ -1466,7 +1477,8 @@ public actor MulticamController { }, standbyOn: rigPreviewMode == .standby, exposureAvailable: focusedPeer.flatMap { links[$0]?.capabilities?.exposure } != nil, - exposureControlsOn: exposureControlsOn) + exposureControlsOn: exposureControlsOn, + portraitAvailable: rigSupportsPortrait()) } // MARK: Ack aggregation (shared across photo / start / stop) diff --git a/RemoteCam/MulticamView.swift b/RemoteCam/MulticamView.swift index 731a8d73..451206c3 100644 --- a/RemoteCam/MulticamView.swift +++ b/RemoteCam/MulticamView.swift @@ -19,8 +19,8 @@ struct MulticamView: View { let onFocusLane: (CameraLane) -> Void /// The synced shutter — photo, or record start/stop depending on mode. let onShutter: () -> Void - /// Toggle the shutter between photo and video mode. - let onToggleMode: () -> Void + /// A mode segment tapped — the host decides whether the rig may switch. + let onSelectMode: (MonitorMode) -> Void /// "Add camera" tapped — the host decides paywall vs. the sheet. let onAddCamera: () -> Void /// Invite a discovered camera into the rig. @@ -369,7 +369,7 @@ struct MulticamView: View { private static let actionColumnWidth: CGFloat = 136 private var controlCluster: some View { - ControlCluster(showsFlash: viewModel.mode == .photo, + ControlCluster(showsFlash: viewModel.mode != .video, showsTorch: viewModel.showsTorchButton, isFlashEnabled: viewModel.focusedFlashOn, isFlashButtonEnabled: viewModel.focusedFlashEnabled, @@ -440,21 +440,19 @@ struct MulticamView: View { } } - /// PHOTO / VIDEO segmented capsule — the shared `CaptureModeSelector`. - /// Tapping the inactive segment flips the rig mode; disabled while - /// recording. + /// PHOTO / PORTRAIT / VIDEO segmented capsule — the shared + /// `CaptureModeSelector`. PORTRAIT shows only when every camera in the rig + /// can take it. Tapping an inactive segment selects that rig mode; + /// disabled while recording. private var modeSelector: some View { CaptureModeSelector( - segments: [ - CaptureModeSelector.Segment( - id: 0, title: NSLocalizedString("PHOTO", comment: "capture mode"), - isActive: viewModel.mode != .video, - action: { if viewModel.mode == .video { onToggleMode() } }), - CaptureModeSelector.Segment( - id: 1, title: NSLocalizedString("VIDEO", comment: "capture mode"), - isActive: viewModel.mode == .video, - action: { if viewModel.mode != .video { onToggleMode() } }), - ], + segments: MulticamViewModel.modes(portraitAvailable: viewModel.rigSettings.portraitAvailable) + .enumerated().map { index, mode in + CaptureModeSelector.Segment( + id: index, title: mode.selectorTitle, + isActive: viewModel.mode == mode, + action: { if viewModel.mode != mode { onSelectMode(mode) } }) + }, isEnabled: !viewModel.isRecording) } @@ -994,7 +992,7 @@ private extension View { struct RigTrayPanel: View { let settings: RigSettingsSnapshot - /// Photo vs video — the tray lists only the tiles that matter to the mode. + /// Still vs video — the tray lists only the tiles that matter to the mode. let mode: MonitorMode /// Mid-recording the capture settings dim (standby and help stay live). let isRecording: Bool @@ -1095,3 +1093,13 @@ struct RigTrayPanel: View { } } } + +extension MonitorMode { + var selectorTitle: String { + switch self { + case .photo: return NSLocalizedString("PHOTO", comment: "capture mode") + case .portrait: return NSLocalizedString("PORTRAIT", comment: "capture mode") + case .video: return NSLocalizedString("VIDEO", comment: "capture mode") + } + } +} diff --git a/RemoteCam/MulticamViewController.swift b/RemoteCam/MulticamViewController.swift index 299818aa..91c9f0e9 100644 --- a/RemoteCam/MulticamViewController.swift +++ b/RemoteCam/MulticamViewController.swift @@ -56,14 +56,14 @@ public final class MulticamViewController: UIViewController { viewModel: viewModel, onFocusLane: { [weak self] lane in self?.controller.setFocusedPeer(lane.peerID) }, onShutter: { [weak self] in self?.triggerShutter() }, - onToggleMode: { [weak self] in + onSelectMode: { [weak self] mode in // The mode is frozen while a shot is in play — recording, // collecting acks, or counting down. What you armed is what fires. guard let self, !self.viewModel.isRecording, !self.viewModel.isCapturing, self.viewModel.rigSettings.countdown == nil else { return } - self.viewModel.mode = self.viewModel.mode == .photo ? .video : .photo - logInfo("director: mode → \(self.viewModel.mode)") - self.controller.setCameraMode(self.viewModel.mode == .video ? .Video : .Photo) + self.viewModel.mode = mode + logInfo("director: mode → \(mode)") + self.controller.setCameraMode(mode.recordingMode) }, onAddCamera: { [weak self] in self?.handleAddCameraTapped() }, onInviteCamera: { [weak self] peer in @@ -178,7 +178,7 @@ public final class MulticamViewController: UIViewController { /// Route the shutter: a photo, or record start/stop, per the current mode. private func triggerShutter() { switch (viewModel.mode, viewModel.isRecording) { - case (.photo, _): controller.capturePhoto() + case (.photo, _), (.portrait, _): controller.capturePhoto() case (.video, false): controller.startRecording() case (.video, true): controller.stopRecording() } diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift index 4a7b43f0..11089141 100644 --- a/RemoteCam/MulticamViewModel.swift +++ b/RemoteCam/MulticamViewModel.swift @@ -81,8 +81,13 @@ final class MulticamViewModel: ObservableObject { @Published var isRecording: Bool = false /// When the rig actually started rolling — drives the classic /// `RecordingTimer` in the top bar. Nil unless recording. - /// Photo vs video shutter mode. + /// Photo, portrait or video shutter mode. @Published var mode: MonitorMode = .photo + /// The selector's segments, left to right. Portrait sits next to Photo, + /// as in Apple's Camera, and only when the whole rig can take it. + static func modes(portraitAvailable: Bool) -> [MonitorMode] { + portraitAvailable ? [.photo, .portrait, .video] : [.photo, .video] + } /// Focus (viewfinder + strip) vs grid (monitor wall) layout. @Published var displayMode: MulticamDisplayMode = .focus /// Cameras discovered but not yet in the rig — the add-camera sheet's list. @@ -90,7 +95,12 @@ final class MulticamViewModel: ObservableObject { /// Whether the add-camera sheet is showing. @Published var showingAddCamera: Bool = false /// Rig-wide settings (timer + quality intersection) for the tray. - @Published var rigSettings = RigSettingsSnapshot() + @Published var rigSettings = RigSettingsSnapshot() { + // A camera that can't do Portrait joined or switched lenses: the + // tab is gone, so the selection falls back to Photo (the controller + // makes the same call for the cameras). + didSet { if mode == .portrait && !rigSettings.portraitAvailable { mode = .photo } } + } /// Whether the rig settings tray is showing. @Published var showingRigTray: Bool = false /// What a tray tile asked for once the tray is gone (Settings, Help). @@ -141,7 +151,7 @@ final class MulticamViewModel: ObservableObject { /// nothing while recording). One source of truth for both paths. var focusedControlState: FocusedCameraControlState { FocusedCameraControlState(isLinked: focusedLane?.status == .linked, - isPhotoMode: mode == .photo, + isPhotoMode: mode != .video, isRecording: isRecording) } /// Controls with a command in flight to the focused camera: the button diff --git a/RemoteCam/RemoteCmdFlatBuffers.swift b/RemoteCam/RemoteCmdFlatBuffers.swift index 9d0eddae..4fc62db9 100644 --- a/RemoteCam/RemoteCmdFlatBuffers.swift +++ b/RemoteCam/RemoteCmdFlatBuffers.swift @@ -437,7 +437,8 @@ private func encodeCapabilitiesEnvelope( activeDeviceIdOffset: activeIDOffset, previewMode: toFBPreviewMode(c.previewMode), exposureOffset: exposureOffset, - cameraMode: c.cameraMode.map { toFBRecordingMode($0) } ?? .unknown) + cameraMode: c.cameraMode.map { toFBRecordingMode($0) } ?? .unknown, + supportsPortrait: c.supportsPortrait) return (capsOffset, stateOffset) } @@ -935,6 +936,7 @@ func toFBRecordingMode(_ mode: RecordingMode) -> RemoteShutter_RecordingModeEnum case .Photo: return .photo case .Video: return .video case .Shorts: return .shorts + case .Portrait: return .portrait } } @@ -944,6 +946,7 @@ func fromFBRecordingMode(_ mode: RemoteShutter_RecordingModeEnum) -> RecordingMo case .photo: return .Photo case .video: return .Video case .shorts: return .Shorts + case .portrait: return .Portrait case .unknown: return nil } } @@ -1343,6 +1346,7 @@ extension RemoteCmd { aspectRatio: state.map { fromFBAspectRatio($0.aspectRatio) } ?? .sixteenNine, exposure: state?.exposure.map { decodeExposureState($0) }, cameraMode: state.flatMap { fromFBRecordingMode($0.cameraMode) }, + supportsPortrait: state?.supportsPortrait ?? false, inReplyTo: inReplyTo, error: error ) diff --git a/RemoteCam/RemoteCmds.swift b/RemoteCam/RemoteCmds.swift index 904afed1..675a52c3 100644 --- a/RemoteCam/RemoteCmds.swift +++ b/RemoteCam/RemoteCmds.swift @@ -625,6 +625,10 @@ public class RemoteCmd: Message, @unchecked Sendable { /// mode pre-warming; the director never sends `SetCameraMode` to it /// (presence is the capability, as with `exposure`). public let cameraMode: RecordingMode? + /// The ACTIVE camera can take Portrait stills (it delivers depth). + /// False for old peers; the director offers Portrait only when every + /// camera in the rig reports it. + public let supportsPortrait: Bool /// The control command this answers; `.requestcapabilities` for an /// unsolicited push. Set by the camera coordinator before sending. public var inReplyTo: RemoteShutter_CommandAction @@ -649,6 +653,7 @@ public class RemoteCmd: Message, @unchecked Sendable { aspectRatio: AspectRatio = .sixteenNine, exposure: ExposureState? = nil, cameraMode: RecordingMode? = nil, + supportsPortrait: Bool = false, inReplyTo: RemoteShutter_CommandAction = .requestcapabilities, error: Error?) { self.frontCamera = frontCamera @@ -671,6 +676,7 @@ public class RemoteCmd: Message, @unchecked Sendable { self.aspectRatio = aspectRatio self.exposure = exposure self.cameraMode = cameraMode + self.supportsPortrait = supportsPortrait self.inReplyTo = inReplyTo self.error = error super.init(sender: nil) diff --git a/RemoteCam/RigQualityMenu.swift b/RemoteCam/RigQualityMenu.swift index 2f60cade..d8dde2b1 100644 --- a/RemoteCam/RigQualityMenu.swift +++ b/RemoteCam/RigQualityMenu.swift @@ -189,6 +189,8 @@ struct RigSettingsSnapshot: Equatable { var exposureAvailable: Bool = false /// The director's remembered choice to show the exposure controls. var exposureControlsOn: Bool = false + /// Every live camera in the rig can take Portrait stills. + var portraitAvailable: Bool = false /// The glass quality tile cycles in place: Automatic → each enabled option /// in order → back to Automatic. `nil` is Automatic. Disabled options @@ -222,7 +224,7 @@ struct RigSettingsSnapshot: Equatable { let who = opt.blockedBy.first else { return nil } return String(format: NSLocalizedString("%@ can’t do %@", comment: "camera blocks a quality"), who, opt.label) - case .photo: + case .photo, .portrait: if !heifAvailable, let who = heifBlockedBy.first { return String(format: NSLocalizedString("%@ can’t do %@", comment: "camera blocks a quality"), who, PhotoFormat.heif.displayName) diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index 0334f495..e9c02fdf 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -22,7 +22,17 @@ import Photos /// Which capture mode the director is in; shared with the tray and rig menu. enum MonitorMode: Equatable { case photo + case portrait case video + + /// The session configuration the cameras run for this mode. + var recordingMode: RecordingMode { + switch self { + case .photo: return .Photo + case .portrait: return .Portrait + case .video: return .Video + } + } } /// The session's complete state space — every `become` state of the old @@ -1031,7 +1041,7 @@ public actor SessionCoordinator { await sendOrGoToScanning(RemoteCmd.StopRecordingVideoResp(error: unableToProcessError(msg)), mode: .reliable) case let pic as RemoteCmd.TakePic: - ctrl.currentCameraMode = .Photo + ctrl.currentCameraMode = ctrl.currentCameraMode.stillMode ctrl.updateCameraStatus() ctrl.takePicture(pic.sendMediaToPeer) let generation = scheduleTimeout(.cameraTakingPic) @@ -1057,7 +1067,7 @@ public actor SessionCoordinator { // auto-collects every angle — unless the director's "Send Media // to Remote" setting is off, in which case the still stays here. pendingSyncMetadata = fire.metadata - ctrl.currentCameraMode = .Photo + ctrl.currentCameraMode = ctrl.currentCameraMode.stillMode ctrl.updateCameraStatus() ctrl.takePicture(fire.sendMediaToPeer) let generation = scheduleTimeout(.cameraTakingPic) @@ -1359,7 +1369,7 @@ public actor SessionCoordinator { // Photo configuration is armed at arrival: the session reconfigures // during the countdown, never between the cameras' fire instants. - ctrl?.currentCameraMode = .Photo + if let ctrl { ctrl.currentCameraMode = ctrl.currentCameraMode.stillMode } ctrl?.updateCameraStatus() let metadata = CaptureSyncMetadata( @@ -2088,7 +2098,7 @@ public actor SessionCoordinator { switch msg { case is RemoteCmd.TakePic: - ctrl.currentCameraMode = .Photo + ctrl.currentCameraMode = ctrl.currentCameraMode.stillMode ctrl.takePicture(false) let generation = scheduleTimeout(.watchRemoteCameraTakingPic) await transition(to: .watchCameraTakingPic(generation: generation)) diff --git a/RemoteCam/UICmds.swift b/RemoteCam/UICmds.swift index 5a1509db..d6dae0e4 100644 --- a/RemoteCam/UICmds.swift +++ b/RemoteCam/UICmds.swift @@ -16,6 +16,15 @@ public enum RecordingMode: Int { case Photo case Video case Shorts + /// A still with the background blurred from the camera's depth data. + case Portrait + + /// Photo and Portrait both shoot stills on the full-sensor photo format. + public var isStill: Bool { self == .Photo || self == .Portrait } + + /// The mode a shutter press shoots in: a still mode stays, a video mode + /// falls back to Photo. + public var stillMode: RecordingMode { isStill ? self : .Photo } } /// Commands that travel within one device: a screen to the session coordinator, diff --git a/RemoteCam/WatchRemoteCameraController.swift b/RemoteCam/WatchRemoteCameraController.swift index 044c21a9..6c029a0c 100644 --- a/RemoteCam/WatchRemoteCameraController.swift +++ b/RemoteCam/WatchRemoteCameraController.swift @@ -200,7 +200,7 @@ public class WatchRemoteCameraController: UIViewController { switch mode { case .photo: return .Photo case .video: return .Video - case .shorts, .unknown: return nil + case .shorts, .portrait, .unknown: return nil } }() if let newMode { diff --git a/RemoteCam/da.lproj/Localizable.strings b/RemoteCam/da.lproj/Localizable.strings index a0bb4bdc..19244b40 100644 --- a/RemoteCam/da.lproj/Localizable.strings +++ b/RemoteCam/da.lproj/Localizable.strings @@ -258,6 +258,7 @@ "Video Quality" = "Videokvalitet"; "Automatic" = "Automatisk"; "Photo" = "Foto"; +"Portrait" = "Portræt"; "HEIF" = "HEIF"; "HDR" = "HDR"; "Rig Settings" = "Rig-indstillinger"; diff --git a/RemoteCam/de-DE.lproj/Localizable.strings b/RemoteCam/de-DE.lproj/Localizable.strings index c00a8dc1..a357b80e 100644 --- a/RemoteCam/de-DE.lproj/Localizable.strings +++ b/RemoteCam/de-DE.lproj/Localizable.strings @@ -379,6 +379,7 @@ "Video Quality" = "Videoqualität"; "Automatic" = "Automatisch"; "Photo" = "Foto"; +"Portrait" = "Porträt"; "HEIF" = "HEIF"; "HDR" = "HDR"; "Rig Settings" = "Rig-Einstellungen"; diff --git a/RemoteCam/en.lproj/Localizable.strings b/RemoteCam/en.lproj/Localizable.strings index 73f5030f..63908542 100644 --- a/RemoteCam/en.lproj/Localizable.strings +++ b/RemoteCam/en.lproj/Localizable.strings @@ -381,6 +381,7 @@ "Video Quality" = "Video Quality"; "Automatic" = "Automatic"; "Photo" = "Photo"; +"Portrait" = "Portrait"; "HEIF" = "HEIF"; "HDR" = "HDR"; "Rig Settings" = "Rig Settings"; diff --git a/RemoteCam/es-MX.lproj/Localizable.strings b/RemoteCam/es-MX.lproj/Localizable.strings index 426c8a18..25425c84 100644 --- a/RemoteCam/es-MX.lproj/Localizable.strings +++ b/RemoteCam/es-MX.lproj/Localizable.strings @@ -273,6 +273,7 @@ "Video Quality" = "Calidad de video"; "Automatic" = "Automático"; "Photo" = "Foto"; +"Portrait" = "Retrato"; "HEIF" = "HEIF"; "HDR" = "HDR"; "Rig Settings" = "Ajustes del equipo"; diff --git a/RemoteCam/fr-FR.lproj/Localizable.strings b/RemoteCam/fr-FR.lproj/Localizable.strings index 464d3c66..e4903fe9 100644 --- a/RemoteCam/fr-FR.lproj/Localizable.strings +++ b/RemoteCam/fr-FR.lproj/Localizable.strings @@ -273,6 +273,7 @@ "Video Quality" = "Qualité vidéo"; "Automatic" = "Automatique"; "Photo" = "Photo"; +"Portrait" = "Portrait"; "HEIF" = "HEIF"; "HDR" = "HDR"; "Rig Settings" = "Réglages du rig"; diff --git a/RemoteCam/hi.lproj/Localizable.strings b/RemoteCam/hi.lproj/Localizable.strings index 6d46f0ff..7014bbe6 100644 --- a/RemoteCam/hi.lproj/Localizable.strings +++ b/RemoteCam/hi.lproj/Localizable.strings @@ -379,6 +379,7 @@ "Video Quality" = "वीडियो गुणवत्ता"; "Automatic" = "स्वचालित"; "Photo" = "फ़ोटो"; +"Portrait" = "पोर्ट्रेट"; "HEIF" = "HEIF"; "HDR" = "HDR"; "Rig Settings" = "रिग सेटिंग्स"; diff --git a/RemoteCam/it.lproj/Localizable.strings b/RemoteCam/it.lproj/Localizable.strings index e3eb9c03..2e87b3ff 100644 --- a/RemoteCam/it.lproj/Localizable.strings +++ b/RemoteCam/it.lproj/Localizable.strings @@ -258,6 +258,7 @@ "Video Quality" = "Qualità video"; "Automatic" = "Automatico"; "Photo" = "Foto"; +"Portrait" = "Ritratto"; "HEIF" = "HEIF"; "HDR" = "HDR"; "Rig Settings" = "Impostazioni rig"; diff --git a/RemoteCam/ja.lproj/Localizable.strings b/RemoteCam/ja.lproj/Localizable.strings index 6cec107c..6bff9a91 100644 --- a/RemoteCam/ja.lproj/Localizable.strings +++ b/RemoteCam/ja.lproj/Localizable.strings @@ -379,6 +379,7 @@ "Video Quality" = "ビデオ画質"; "Automatic" = "自動"; "Photo" = "写真"; +"Portrait" = "ポートレート"; "HEIF" = "HEIF"; "HDR" = "HDR"; "Rig Settings" = "リグ設定"; diff --git a/RemoteCam/ko.lproj/Localizable.strings b/RemoteCam/ko.lproj/Localizable.strings index de9ac394..4352da3e 100644 --- a/RemoteCam/ko.lproj/Localizable.strings +++ b/RemoteCam/ko.lproj/Localizable.strings @@ -379,6 +379,7 @@ "Video Quality" = "비디오 화질"; "Automatic" = "자동"; "Photo" = "사진"; +"Portrait" = "인물 사진"; "HEIF" = "HEIF"; "HDR" = "HDR"; "Rig Settings" = "리그 설정"; diff --git a/RemoteCam/ms.lproj/Localizable.strings b/RemoteCam/ms.lproj/Localizable.strings index 3321dd39..4f03f652 100644 --- a/RemoteCam/ms.lproj/Localizable.strings +++ b/RemoteCam/ms.lproj/Localizable.strings @@ -379,6 +379,7 @@ "Video Quality" = "Kualiti video"; "Automatic" = "Automatik"; "Photo" = "Foto"; +"Portrait" = "Potret"; "HEIF" = "HEIF"; "HDR" = "HDR"; "Rig Settings" = "Tetapan rig"; diff --git a/RemoteCam/pt-BR.lproj/Localizable.strings b/RemoteCam/pt-BR.lproj/Localizable.strings index d4f16dd5..4565e410 100644 --- a/RemoteCam/pt-BR.lproj/Localizable.strings +++ b/RemoteCam/pt-BR.lproj/Localizable.strings @@ -379,6 +379,7 @@ "Video Quality" = "Qualidade de vídeo"; "Automatic" = "Automático"; "Photo" = "Foto"; +"Portrait" = "Retrato"; "HEIF" = "HEIF"; "HDR" = "HDR"; "Rig Settings" = "Ajustes do rig"; diff --git a/RemoteCam/ru.lproj/Localizable.strings b/RemoteCam/ru.lproj/Localizable.strings index 6a055c23..3ebcecb7 100644 --- a/RemoteCam/ru.lproj/Localizable.strings +++ b/RemoteCam/ru.lproj/Localizable.strings @@ -379,6 +379,7 @@ "Video Quality" = "Качество видео"; "Automatic" = "Автоматически"; "Photo" = "Фото"; +"Portrait" = "Портрет"; "HEIF" = "HEIF"; "HDR" = "HDR"; "Rig Settings" = "Настройки рига"; diff --git a/RemoteCam/tr.lproj/Localizable.strings b/RemoteCam/tr.lproj/Localizable.strings index d642fe2e..7f12df2a 100644 --- a/RemoteCam/tr.lproj/Localizable.strings +++ b/RemoteCam/tr.lproj/Localizable.strings @@ -379,6 +379,7 @@ "Video Quality" = "Video kalitesi"; "Automatic" = "Otomatik"; "Photo" = "Fotoğraf"; +"Portrait" = "Portre"; "HEIF" = "HEIF"; "HDR" = "HDR"; "Rig Settings" = "Rig ayarları"; diff --git a/RemoteCam/vi.lproj/Localizable.strings b/RemoteCam/vi.lproj/Localizable.strings index fac75a07..4658eb13 100644 --- a/RemoteCam/vi.lproj/Localizable.strings +++ b/RemoteCam/vi.lproj/Localizable.strings @@ -379,6 +379,7 @@ "Video Quality" = "Chất lượng video"; "Automatic" = "Tự động"; "Photo" = "Ảnh"; +"Portrait" = "Chân dung"; "HEIF" = "HEIF"; "HDR" = "HDR"; "Rig Settings" = "Cài đặt rig"; diff --git a/RemoteCam/zh-Hans.lproj/Localizable.strings b/RemoteCam/zh-Hans.lproj/Localizable.strings index 8b9072bc..1c118495 100644 --- a/RemoteCam/zh-Hans.lproj/Localizable.strings +++ b/RemoteCam/zh-Hans.lproj/Localizable.strings @@ -379,6 +379,7 @@ "Video Quality" = "视频质量"; "Automatic" = "自动"; "Photo" = "照片"; +"Portrait" = "人像"; "HEIF" = "HEIF"; "HDR" = "HDR"; "Rig Settings" = "多机位设置"; diff --git a/RemoteCamTests/CaptureIntegrationTests.swift b/RemoteCamTests/CaptureIntegrationTests.swift index ec6138cf..876e0a4f 100644 --- a/RemoteCamTests/CaptureIntegrationTests.swift +++ b/RemoteCamTests/CaptureIntegrationTests.swift @@ -589,6 +589,61 @@ final class CaptureIntegrationTests: XCTestCase { XCTAssertFalse(data.isEmpty, "a still must capture after round-tripping the modes") } + /// Portrait on real hardware: a camera that advertises it turns depth + /// on, the still comes back with the blur baked in (re-encoded, no depth + /// kept) at 12MP or less, and frames keep flowing both ways. Both stills + /// are attached to the result so the blur can be eyeballed. + func testPortraitStillIsBlurredWithin12MPAndFramesKeepFlowing() async throws { + try await startRealRig() + guard await waitForFrames(since: 0) != nil else { + throw XCTSkip("camera delivers no frames here — \(await diagnostics())") + } + let device = try XCTUnwrap(rig.engine.currentDevice()) + guard CaptureEngine.supportsPortrait(device) else { + throw XCTSkip("\(device.localizedName) delivers no depth") + } + _ = await rig.engine.setAspectRatio(.fourThree) + let photo = try await captureStill() + attach(photo, named: "photo-mode") + let zoomBefore = (device.minAvailableVideoZoomFactor, device.maxAvailableVideoZoomFactor) + + var mark = Date().timeIntervalSinceReferenceDate + rig.currentCameraMode = .Portrait + let output = rig.engine.photoOutput + await eventually { output.isDepthDataDeliveryEnabled } + XCTAssertTrue(output.isDepthDataDeliveryEnabled, "Portrait turns depth on") + guard await waitForFrames(since: mark) != nil else { + return XCTFail("no frames after →portrait — \(await diagnostics())") + } + print("📸 portrait: \(device.localizedName) matte=\(output.isPortraitEffectsMatteDeliveryEnabled) " + + "zoom \(zoomBefore) → (\(device.minAvailableVideoZoomFactor), \(device.maxAvailableVideoZoomFactor))") + + let portrait = try await captureStill() + attach(portrait, named: "portrait-mode") + let dims = try stillDimensions(portrait) + print("📸 portrait: still \(dims.width)x\(dims.height), \(portrait.count) bytes") + XCTAssertLessThanOrEqual(Int64(dims.width) * Int64(dims.height), CaptureEngine.portraitMaxPhotoPixels) + let source = try XCTUnwrap(CGImageSourceCreateWithData(portrait as CFData, nil)) + XCTAssertNil(CGImageSourceCopyAuxiliaryDataInfoAtIndex(source, 0, kCGImageAuxiliaryDataTypeDisparity), + "the native-ratio still was re-rendered by the blur, not passed through") + XCTAssertNil(CGImageSourceCopyAuxiliaryDataInfoAtIndex(source, 0, kCGImageAuxiliaryDataTypeDepth)) + + mark = Date().timeIntervalSinceReferenceDate + rig.currentCameraMode = .Photo + await eventually { !output.isDepthDataDeliveryEnabled } + XCTAssertFalse(output.isDepthDataDeliveryEnabled, "Photo turns depth off") + guard await waitForFrames(since: mark) != nil else { + return XCTFail("no frames after →photo — \(await diagnostics())") + } + } + + private func attach(_ data: Data, named name: String) { + let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.heic") + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + /// A fresh session starts on the best format the hardware encodes. func testDefaultPhotoFormatMatchesTheHardware() async throws { try await startRealRig() diff --git a/RemoteCamTests/CropRectTests.swift b/RemoteCamTests/CropRectTests.swift index a10cd17a..212100d9 100644 --- a/RemoteCamTests/CropRectTests.swift +++ b/RemoteCamTests/CropRectTests.swift @@ -216,6 +216,13 @@ final class CropRectTests: XCTestCase { "the EXIF dims must be rewritten to the cropped size") } + /// A still without depth has nothing to blur: Portrait returns nil and + /// the capture path falls back to the plain crop. + func testPortraitPhotoDataWithoutDepthIsNil() { + let jpeg = makeJPEG(width: 400, height: 300, userComment: "no-depth") + XCTAssertNil(CR.portraitPhotoData(jpeg, to: .fourThree)) + } + func testCropPhotoDataReturnsOriginalBytesWhenRatioMatches() { let jpeg = makeJPEG(width: 1600, height: 900, userComment: "unused") XCTAssertEqual(CR.cropPhotoData(jpeg, to: .sixteenNine), jpeg, diff --git a/RemoteCamTests/MultiCamChromeTests.swift b/RemoteCamTests/MultiCamChromeTests.swift index 12aba49a..9a635b05 100644 --- a/RemoteCamTests/MultiCamChromeTests.swift +++ b/RemoteCamTests/MultiCamChromeTests.swift @@ -55,6 +55,12 @@ final class MultiCamChromeTests: XCTestCase { [.timer, .aspect, .resolution, .cameraStandby, .settings, .help]) } + /// Portrait is a still mode: the same tiles as Photo. + func testRigTrayPortraitModeListsPhotoTiles() { + XCTAssertEqual(RigTray.items(mode: .portrait, standbyAvailable: true), + RigTray.items(mode: .photo, standbyAvailable: true)) + } + /// A rig with no standby-capable camera omits the tile (not dims it). func testRigTrayOmitsStandbyWhenUnavailable() { XCTAssertFalse(RigTray.items(mode: .photo, standbyAvailable: false).contains(.cameraStandby)) diff --git a/RemoteCamTests/MulticamControllerTests.swift b/RemoteCamTests/MulticamControllerTests.swift index 174284cc..1fc72e8c 100644 --- a/RemoteCamTests/MulticamControllerTests.swift +++ b/RemoteCamTests/MulticamControllerTests.swift @@ -2327,6 +2327,40 @@ final class MulticamControllerTests: XCTestCase { XCTAssertEqual(modes, [.Video], "the rig's mode wins on join") } + /// Portrait is offered only when every camera reports it, and a camera + /// that can't take it drops a Portrait rig back to Photo. + func testPortraitNeedsEveryCameraAndFallsBackToPhoto() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + func caps(portrait: Bool) -> RemoteCmd.CameraCapabilitiesResp { + RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, currentCamera: .back, currentLens: .wideAngle, currentZoom: 1, + supportsMulticam: true, cameraMode: .Photo, supportsPortrait: portrait, error: nil) + } + controller.didReceiveMessage(caps(portrait: true), from: camA) + await controller.waitForIdle() + var settings = await controller.rigSettingsSnapshotForTesting() + XCTAssertFalse(settings.portraitAvailable, "camB hasn't said it can") + + controller.didReceiveMessage(caps(portrait: true), from: camB) + await controller.waitForIdle() + settings = await controller.rigSettingsSnapshotForTesting() + XCTAssertTrue(settings.portraitAvailable, "every camera can") + + controller.setCameraMode(.Portrait) + await controller.waitForIdle() + let before = sent(transport, RemoteCmd.SetCameraMode.self).count + + // camB flips to a lens without depth. + controller.didReceiveMessage(caps(portrait: false), from: camB) + await controller.waitForIdle() + settings = await controller.rigSettingsSnapshotForTesting() + XCTAssertFalse(settings.portraitAvailable) + let modes = sent(transport, RemoteCmd.SetCameraMode.self).dropFirst(before) + .compactMap { ($0.msg as? RemoteCmd.SetCameraMode)?.mode } + XCTAssertFalse(modes.isEmpty, "the rig is told to leave Portrait") + XCTAssertTrue(modes.allSatisfy { $0 == .Photo }, "and drops to Photo, never Portrait") + } + /// Exposure is sent only to a camera whose state carries the block, and /// Manual only when the block says the device accepts it. func testExposureIsGatedOnTheLanesExposureBlock() async { diff --git a/RemoteCamTests/MulticamViewModelTests.swift b/RemoteCamTests/MulticamViewModelTests.swift index 100776d0..1965075a 100644 --- a/RemoteCamTests/MulticamViewModelTests.swift +++ b/RemoteCamTests/MulticamViewModelTests.swift @@ -263,6 +263,27 @@ final class MulticamViewModelTests: XCTestCase { XCTAssertEqual(vm.focusedSwitchControl, .flipButton) XCTAssertEqual(vm.focusedActiveDeviceID, "back") } + + // MARK: - Portrait + + func testPortraitSegmentShowsOnlyWhenTheRigCanTakeIt() { + XCTAssertEqual(MulticamViewModel.modes(portraitAvailable: false), [.photo, .video]) + XCTAssertEqual(MulticamViewModel.modes(portraitAvailable: true), [.photo, .portrait, .video]) + } + + /// The tab disappearing under a Portrait selection falls back to Photo. + func testLosingPortraitFallsBackToPhoto() { + let vm = MulticamViewModel() + var settings = RigSettingsSnapshot() + settings.portraitAvailable = true + vm.rigSettings = settings + vm.mode = .portrait + + settings.portraitAvailable = false + vm.rigSettings = settings + + XCTAssertEqual(vm.mode, .photo) + } } /// The shared enablement rules both camera-control paths derive from. @@ -513,7 +534,7 @@ final class MulticamGridSnapshotTests: SnapshotTestCase { private func directorView(_ vm: MulticamViewModel) -> MulticamView { MulticamView(viewModel: vm, - onFocusLane: { _ in }, onShutter: {}, onToggleMode: {}, + onFocusLane: { _ in }, onShutter: {}, onSelectMode: { _ in }, onAddCamera: {}, onInviteCamera: { _ in }, onSetTimer: { _ in }, onSelectVideoQuality: { _, _ in }, onAutomaticVideoQuality: {}, onSetPhotoFormat: { _ in }, onSetHDR: { _ in }, diff --git a/RemoteCamTests/RemoteCamSessionTests.swift b/RemoteCamTests/RemoteCamSessionTests.swift index 230380a7..b1922992 100644 --- a/RemoteCamTests/RemoteCamSessionTests.swift +++ b/RemoteCamTests/RemoteCamSessionTests.swift @@ -343,6 +343,32 @@ class SessionCoordinatorTests: XCTestCase { XCTAssertEqual(camera.takePictureCalls, [true]) } + /// A shutter press keeps a still mode: Portrait shoots Portrait. + func testTakePicKeepsPortrait() async { + await enterCamera() + harness.fakeMP.sendResult = true + camera.currentCameraMode = .Portrait + + await harness.deliver(RemoteCmd.TakePic(sender: nil, sendMediaToPeer: false)) + + XCTAssertEqual(camera.currentCameraMode, .Portrait) + XCTAssertEqual(camera.takePictureCalls, [false]) + } + + /// A scheduled (synced) shot armed in Portrait stays Portrait. + func testScheduledCaptureKeepsPortrait() async { + await enterCamera() + harness.fakeMP.sendResult = true + camera.currentCameraMode = .Portrait + + await harness.deliver(RemoteCmd.ScheduledCapture( + fireAtCameraClockMillis: SyncClock.nowMillis() + 800, + anchorMillis: SyncClock.nowMillis(), captureId: "CAP-PORTRAIT", + sessionId: "S", cameraIndex: 1)) + + XCTAssertEqual(camera.currentCameraMode, .Portrait) + } + // MARK: - Recording truth derivation (v10) private var reportSeq: UInt64 = 0 diff --git a/RemoteCamTests/RemoteCmdSerializationTests.swift b/RemoteCamTests/RemoteCmdSerializationTests.swift index 018b7219..25ee9d7e 100644 --- a/RemoteCamTests/RemoteCmdSerializationTests.swift +++ b/RemoteCamTests/RemoteCmdSerializationTests.swift @@ -962,6 +962,32 @@ final class RemoteCmdSerializationTests: XCTestCase { XCTAssertEqual(decoded.mode, .Photo) } + func testSetCameraMode_portrait_roundTrip() { + let original = RemoteCmd.SetCameraMode(mode: .Portrait) + let decoded: RemoteCmd.SetCameraMode = roundTrip(original) + XCTAssertEqual(decoded.mode, .Portrait) + } + + func testCapabilitiesCarryPortraitSupportAndMode() { + let caps = RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, + currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + cameraMode: .Portrait, supportsPortrait: true, error: nil) + let result = roundTrip(caps) + XCTAssertTrue(result.supportsPortrait) + XCTAssertEqual(result.cameraMode, .Portrait) + } + + /// A peer that predates Portrait sends no flag: it decodes as unsupported, + /// so the director never offers the mode to it. + func testCapabilitiesWithoutPortraitFlagDecodeAsUnsupported() { + let caps = RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, + currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + cameraMode: .Photo, error: nil) + XCTAssertFalse(roundTrip(caps).supportsPortrait) + } + // MARK: - SetAspectRatio Round-Trip func testSetAspectRatio_fourThree_roundTrip() {