diff --git a/submodules/Camera/Sources/Camera.swift b/submodules/Camera/Sources/Camera.swift index b2fccc4bf1..b2e4598b8f 100644 --- a/submodules/Camera/Sources/Camera.swift +++ b/submodules/Camera/Sources/Camera.swift @@ -60,14 +60,14 @@ final class CameraDeviceContext { self.output = CameraOutput(exclusive: exclusive, ciContext: ciContext, colorSpace: colorSpace, use32BGRA: isRoundVideo) } - func configure(position: Camera.Position, previewView: CameraSimplePreviewView?, audio: Bool, photo: Bool, metadata: Bool, preferWide: Bool = false, preferLowerFramerate: Bool = false, switchAudio: Bool = true) { + func configure(position: Camera.Position, previewView: CameraSimplePreviewView?, audio: Bool, photo: Bool, metadata: Bool, preferWide: Bool = false, preferLowerFramerate: Bool = false, switchAudio: Bool = true, preferVirtualBackCamera: Bool = false) { // MARK: Swiftgram guard let session = self.session else { return } self.previewView = previewView - self.device.configure(for: session, position: position, dual: !self.exclusive || self.additional, switchAudio: switchAudio) + self.device.configure(for: session, position: position, dual: !self.exclusive || self.additional, switchAudio: switchAudio, preferVirtualBackCamera: preferVirtualBackCamera) // MARK: Swiftgram self.device.configureDeviceFormat(maxDimensions: self.maxDimensions(additional: self.additional, preferWide: preferWide), maxFramerate: self.preferredMaxFrameRate(useLower: preferLowerFramerate)) self.input.configure(for: session, device: self.device, audio: audio && switchAudio) self.output.configure(for: session, device: self.device, input: self.input, previewView: previewView, audio: audio && switchAudio, photo: photo, metadata: metadata) @@ -193,6 +193,9 @@ private final class CameraContext { } private var isSessionRunning = false + // MARK: Swiftgram + private var roundVideoTransientDisplayZoom: CGFloat = 1.0 + func startCapture() { guard !self.session.session.isRunning else { return @@ -260,6 +263,9 @@ private final class CameraContext { } self.positionValue = targetPosition self._positionPromise.set(targetPosition) + // MARK: Swiftgram + self.roundVideoTransientDisplayZoom = 1.0 + self.mainDeviceContext?.device.resetZoom() mainDeviceContext.output.markPositionChange(position: targetPosition) } else { @@ -276,12 +282,13 @@ private final class CameraContext { } self.positionValue = targetPosition self._positionPromise.set(targetPosition) + self.roundVideoTransientDisplayZoom = 1.0 // MARK: Swiftgram self.modeChange = .position let preferWide = self.initialConfiguration.preferWide || isRoundVideo let preferLowerFramerate = self.initialConfiguration.preferLowerFramerate || isRoundVideo - mainDeviceContext.configure(position: targetPosition, previewView: self.simplePreviewView, audio: self.initialConfiguration.audio, photo: self.initialConfiguration.photo, metadata: self.initialConfiguration.metadata, preferWide: preferWide, preferLowerFramerate: preferLowerFramerate, switchAudio: !isRoundVideo) + mainDeviceContext.configure(position: targetPosition, previewView: self.simplePreviewView, audio: self.initialConfiguration.audio, photo: self.initialConfiguration.photo, metadata: self.initialConfiguration.metadata, preferWide: preferWide, preferLowerFramerate: preferLowerFramerate, switchAudio: !isRoundVideo, preferVirtualBackCamera: isRoundVideo) // MARK: Swiftgram if isRoundVideo { mainDeviceContext.output.markPositionChange(position: targetPosition) } @@ -300,12 +307,13 @@ private final class CameraContext { self._positionPromise.set(position) self.positionValue = position + self.roundVideoTransientDisplayZoom = 1.0 // MARK: Swiftgram self.modeChange = .position let preferWide = self.initialConfiguration.preferWide || (self.positionValue == .front && self.initialConfiguration.isRoundVideo) let preferLowerFramerate = self.initialConfiguration.preferLowerFramerate || self.initialConfiguration.isRoundVideo - self.mainDeviceContext?.configure(position: position, previewView: self.simplePreviewView, audio: self.initialConfiguration.audio, photo: self.initialConfiguration.photo, metadata: self.initialConfiguration.metadata, preferWide: preferWide, preferLowerFramerate: preferLowerFramerate) + self.mainDeviceContext?.configure(position: position, previewView: self.simplePreviewView, audio: self.initialConfiguration.audio, photo: self.initialConfiguration.photo, metadata: self.initialConfiguration.metadata, preferWide: preferWide, preferLowerFramerate: preferLowerFramerate, preferVirtualBackCamera: self.initialConfiguration.isRoundVideo) // MARK: Swiftgram self.queue.after(0.5) { self.modeChange = .none @@ -332,7 +340,7 @@ private final class CameraContext { self.configure { self.mainDeviceContext?.invalidate() self.mainDeviceContext = CameraDeviceContext(session: self.session, exclusive: false, additional: false, ciContext: self.ciContext, colorSpace: self.colorSpace, isRoundVideo: self.initialConfiguration.isRoundVideo) - self.mainDeviceContext?.configure(position: .back, previewView: self.simplePreviewView, audio: self.initialConfiguration.audio, photo: self.initialConfiguration.photo, metadata: self.initialConfiguration.metadata) + self.mainDeviceContext?.configure(position: .back, previewView: self.simplePreviewView, audio: self.initialConfiguration.audio, photo: self.initialConfiguration.photo, metadata: self.initialConfiguration.metadata, preferVirtualBackCamera: self.initialConfiguration.isRoundVideo) // MARK: Swiftgram self.additionalDeviceContext = CameraDeviceContext(session: self.session, exclusive: false, additional: true, ciContext: self.ciContext, colorSpace: self.colorSpace, isRoundVideo: self.initialConfiguration.isRoundVideo) self.additionalDeviceContext?.configure(position: .front, previewView: self.secondaryPreviewView, audio: false, photo: true, metadata: false) @@ -383,7 +391,7 @@ private final class CameraContext { let preferLowerFramerate = self.initialConfiguration.preferLowerFramerate || self.initialConfiguration.isRoundVideo self.mainDeviceContext = CameraDeviceContext(session: self.session, exclusive: true, additional: false, ciContext: self.ciContext, colorSpace: self.colorSpace, isRoundVideo: self.initialConfiguration.isRoundVideo) - self.mainDeviceContext?.configure(position: self.positionValue, previewView: self.simplePreviewView, audio: self.initialConfiguration.audio, photo: self.initialConfiguration.photo, metadata: self.initialConfiguration.metadata, preferWide: preferWide, preferLowerFramerate: preferLowerFramerate) + self.mainDeviceContext?.configure(position: self.positionValue, previewView: self.simplePreviewView, audio: self.initialConfiguration.audio, photo: self.initialConfiguration.photo, metadata: self.initialConfiguration.metadata, preferWide: preferWide, preferLowerFramerate: preferLowerFramerate, preferVirtualBackCamera: self.initialConfiguration.isRoundVideo) // MARK: Swiftgram } self.mainDeviceContext?.output.processSampleBuffer = { [weak self] sampleBuffer, pixelBuffer, connection in guard let self, let mainDeviceContext = self.mainDeviceContext else { @@ -536,6 +544,24 @@ private final class CameraContext { } } + // MARK: Swiftgram + func setRoundVideoTransientZoomDelta(_ zoomDelta: CGFloat) { + if self.initialConfiguration.isRoundVideo { + if self.positionValue == .front { + if let additionalDeviceContext = self.additionalDeviceContext { + additionalDeviceContext.device.setZoomDelta(zoomDelta) + } else { + self.mainDeviceContext?.device.setZoomDelta(zoomDelta) + } + } else { + self.roundVideoTransientDisplayZoom = max(0.5, min(10.0, self.roundVideoTransientDisplayZoom * zoomDelta)) + self.mainDeviceContext?.device.setRoundVideoTransientDisplayZoom(self.roundVideoTransientDisplayZoom) + } + } else { + self.mainDeviceContext?.device.setZoomDelta(zoomDelta) + } + } + func rampZoom(_ zoomLevel: CGFloat, rate: CGFloat) { if self.initialConfiguration.isRoundVideo { if self.positionValue == .front { @@ -548,6 +574,29 @@ private final class CameraContext { } } + // MARK: Swiftgram + func rampZoomToNeutral(rate: CGFloat) { + if self.initialConfiguration.isRoundVideo { + if self.positionValue == .front { + if let additionalDeviceContext = self.additionalDeviceContext { + additionalDeviceContext.device.rampZoomToNeutral(rate: rate) + } else { + self.mainDeviceContext?.device.rampZoomToNeutral(rate: rate) + } + } else { + self.mainDeviceContext?.device.rampZoomToNeutral(rate: rate) + } + } else { + self.mainDeviceContext?.device.rampZoomToNeutral(rate: rate) + } + } + + // MARK: Swiftgram + func resetRoundVideoTransientZoom(rate: CGFloat) { + self.roundVideoTransientDisplayZoom = 1.0 + self.rampZoomToNeutral(rate: rate) + } + func takePhoto() -> Signal { guard let mainDeviceContext = self.mainDeviceContext else { return .complete() @@ -973,6 +1022,15 @@ public final class Camera { } } + // MARK: Swiftgram + public func setRoundVideoTransientZoomDelta(_ zoomDelta: CGFloat) { + self.queue.async { + if let context = self.contextRef?.takeUnretainedValue() { + context.setRoundVideoTransientZoomDelta(zoomDelta) + } + } + } + public func rampZoom(_ zoomLevel: CGFloat, rate: CGFloat) { self.queue.async { if let context = self.contextRef?.takeUnretainedValue() { @@ -981,6 +1039,15 @@ public final class Camera { } } + // MARK: Swiftgram + public func resetRoundVideoTransientZoom(rate: CGFloat) { + self.queue.async { + if let context = self.contextRef?.takeUnretainedValue() { + context.resetRoundVideoTransientZoom(rate: rate) + } + } + } + public func setTorchActive(_ active: Bool) { self.queue.async { if let context = self.contextRef?.takeUnretainedValue() { diff --git a/submodules/Camera/Sources/CameraDevice.swift b/submodules/Camera/Sources/CameraDevice.swift index c1d3eefbc6..b30ad56475 100644 --- a/submodules/Camera/Sources/CameraDevice.swift +++ b/submodules/Camera/Sources/CameraDevice.swift @@ -4,6 +4,10 @@ import SwiftSignalKit import TelegramCore private let defaultFPS: Double = 30.0 +// MARK: Swiftgram +private let roundVideoWideDisplayZoom: CGFloat = 1.0 +private let roundVideoMinimumUltraWideDisplayZoom: CGFloat = 0.5 +private let roundVideoNeutralRampRate: CGFloat = 8.0 final class CameraDevice { var position: Camera.Position = .back @@ -29,11 +33,15 @@ final class CameraDevice { public private(set) var audioDevice: AVCaptureDevice? = nil - func configure(for session: CameraSession, position: Camera.Position, dual: Bool, switchAudio: Bool) { + func configure(for session: CameraSession, position: Camera.Position, dual: Bool, switchAudio: Bool, preferVirtualBackCamera: Bool = false) { // MARK: Swiftgram self.position = position var selectedDevice: AVCaptureDevice? - if #available(iOS 13.0, *), position != .front && !dual { + // MARK: Swiftgram + if #available(iOS 13.0, *), position != .front && preferVirtualBackCamera { + selectedDevice = dual ? CameraDevice.roundVideoVirtualBackCameraForMultiCam() : CameraDevice.roundVideoVirtualBackCamera() + } + if #available(iOS 13.0, *), selectedDevice == nil && position != .front && !dual { // MARK: Swiftgram if let device = AVCaptureDevice.default(.builtInTripleCamera, for: .video, position: position) { selectedDevice = device } else if let device = AVCaptureDevice.default(.builtInDualCamera, for: .video, position: position) { @@ -62,6 +70,89 @@ final class CameraDevice { } } + // MARK: Swiftgram + @available(iOS 13.0, *) + private static func roundVideoVirtualBackCameraForMultiCam() -> AVCaptureDevice? { + guard let frontDevice = self.frontMultiCamCamera() else { + return nil + } + return self.roundVideoVirtualBackCamera { backDevice in + return self.supportsMultiCamSession(backDevice: backDevice, frontDevice: frontDevice) + } + } + + @available(iOS 13.0, *) + private static func roundVideoVirtualBackCamera() -> AVCaptureDevice? { + return self.roundVideoVirtualBackCamera(isSupported: { _ in true }) + } + + @available(iOS 13.0, *) + private static func roundVideoVirtualBackCamera(isSupported: (AVCaptureDevice) -> Bool) -> AVCaptureDevice? { + let candidates: [AVCaptureDevice.DeviceType] = [ + .builtInTripleCamera, + .builtInDualWideCamera + ] + for deviceType in candidates { + guard let backDevice = AVCaptureDevice.default(deviceType, for: .video, position: .back) else { + continue + } + if self.isUltraWideVirtualBackCamera(backDevice) && self.supportsMinimumRoundVideoFormat(backDevice) && isSupported(backDevice) { + return backDevice + } + } + return nil + } + + @available(iOS 13.0, *) + private static func frontMultiCamCamera() -> AVCaptureDevice? { + return AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera, .builtInTrueDepthCamera], + mediaType: .video, + position: .front + ).devices.first + } + + @available(iOS 13.0, *) + private static func supportsMultiCamSession(backDevice: AVCaptureDevice, frontDevice: AVCaptureDevice) -> Bool { + guard AVCaptureMultiCamSession.isMultiCamSupported, let backInput = try? AVCaptureDeviceInput(device: backDevice), let frontInput = try? AVCaptureDeviceInput(device: frontDevice) else { + return false + } + let session = AVCaptureMultiCamSession() + session.sessionPreset = .inputPriority + guard session.canAddInput(backInput) else { + return false + } + session.addInputWithNoConnections(backInput) + return session.canAddInput(frontInput) + } + + private static func supportsMinimumRoundVideoFormat(_ device: AVCaptureDevice) -> Bool { + for format in device.formats { + if format.mediaType != .video || format.value(forKey: "isPhotoFormat") as? Bool == true { + continue + } + let dimensions = CMVideoFormatDescriptionGetDimensions(format.formatDescription) + if dimensions.width < 1920 || dimensions.height < 1080 { + continue + } + let subtype = CMFormatDescriptionGetMediaSubType(format.formatDescription) + if subtype != kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange { + continue + } + if format.videoSupportedFrameRateRanges.contains(where: { $0.minFrameRate <= 30.0 && $0.maxFrameRate >= 30.0 }) { + return true + } + } + return false + } + + private static func isUltraWideVirtualBackCamera(_ device: AVCaptureDevice) -> Bool { + if #available(iOS 13.0, *) { + return device.position == .back && device.isVirtualDevice && !device.virtualDeviceSwitchOverVideoZoomFactors.isEmpty && device.constituentDevices.contains(where: { $0.deviceType == .builtInUltraWideCamera }) + } + return false + } + func configureDeviceFormat(maxDimensions: CMVideoDimensions, maxFramerate: Double) { guard let device = self.videoDevice else { return @@ -152,6 +243,11 @@ final class CameraDevice { device.activeVideoMinFrameDuration = targetFPS.duration device.activeVideoMaxFrameDuration = targetFPS.duration } + + // MARK: Swiftgram + if #available(iOS 15.0, *), CameraDevice.isUltraWideVirtualBackCamera(device), device.activePrimaryConstituentDeviceSwitchingBehavior != .unsupported { + device.setPrimaryConstituentDeviceSwitchingBehavior(.auto, restrictedSwitchingBehaviorConditions: []) + } if device.isLowLightBoostSupported { device.automaticallyEnablesLowLightBoostWhenAvailable = true @@ -320,6 +416,32 @@ final class CameraDevice { } } + // MARK: Swiftgram + func setRoundVideoTransientDisplayZoom(_ displayZoom: CGFloat) { + guard let device = self.videoDevice else { + return + } + self.transaction(device) { device in + let targetDisplayZoom: CGFloat + if displayZoom < roundVideoWideDisplayZoom { + if self.canUseUltraWideDisplayZoom(for: device) { + targetDisplayZoom = max(self.minimumDisplayZoom(for: device), displayZoom) + } else { + targetDisplayZoom = 1.0 + } + } else { + targetDisplayZoom = max(1.0, displayZoom) + } + let target = self.videoZoomFactor(forDisplayZoom: targetDisplayZoom, device: device) + let clampedTarget = self.clampedZoomFactor(target, for: device) + if targetDisplayZoom == 1.0 && device.videoZoomFactor < device.neutralZoomFactor { + device.ramp(toVideoZoomFactor: clampedTarget, withRate: Float(roundVideoNeutralRampRate)) + } else { + device.videoZoomFactor = clampedTarget + } + } + } + func rampZoom(_ zoomLevel: CGFloat, rate: CGFloat) { guard let device = self.videoDevice else { return @@ -330,6 +452,17 @@ final class CameraDevice { } } + // MARK: Swiftgram + func rampZoomToNeutral(rate: CGFloat) { + guard let device = self.videoDevice else { + return + } + self.transaction(device) { device in + let target = self.clampedZoomFactor(device.neutralZoomFactor, for: device) + device.ramp(toVideoZoomFactor: target, withRate: Float(rate)) + } + } + func resetZoom(neutral: Bool = true) { guard let device = self.videoDevice else { return @@ -345,4 +478,37 @@ final class CameraDevice { let maximum = max(minimum, device.maxAvailableVideoZoomFactor) return min(maximum, max(minimum, value)) } + + // MARK: Swiftgram + private func minimumDisplayZoom(for device: AVCaptureDevice) -> CGFloat { + return max(roundVideoMinimumUltraWideDisplayZoom, self.displayZoomFactor(forVideoZoomFactor: self.clampedZoomFactor(device.minAvailableVideoZoomFactor, for: device), device: device)) + } + + private func canUseUltraWideDisplayZoom(for device: AVCaptureDevice) -> Bool { + guard CameraDevice.isUltraWideVirtualBackCamera(device) else { + return false + } + return self.clampedZoomFactor(self.videoZoomFactor(forDisplayZoom: roundVideoMinimumUltraWideDisplayZoom, device: device), for: device) < device.neutralZoomFactor + } + + private func displayZoomFactor(forVideoZoomFactor videoZoomFactor: CGFloat, device: AVCaptureDevice) -> CGFloat { + if #available(iOS 18.0, *) { + let multiplier = device.displayVideoZoomFactorMultiplier + if multiplier > 0.0 { + return videoZoomFactor * multiplier + } + } + let neutralZoomFactor = max(device.neutralZoomFactor, 1.0) + return videoZoomFactor / neutralZoomFactor + } + + private func videoZoomFactor(forDisplayZoom displayZoom: CGFloat, device: AVCaptureDevice) -> CGFloat { + if #available(iOS 18.0, *) { + let multiplier = device.displayVideoZoomFactorMultiplier + if multiplier > 0.0 { + return displayZoom / multiplier + } + } + return displayZoom * max(device.neutralZoomFactor, 1.0) + } } diff --git a/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift b/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift index 6d3c3d6e43..d652ed1bfd 100644 --- a/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift +++ b/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift @@ -1119,10 +1119,10 @@ public class VideoMessageCameraScreen: ViewController { switch gestureRecognizer.state { case .changed: let scale = gestureRecognizer.scale - camera.setZoomDelta(scale) + camera.setRoundVideoTransientZoomDelta(scale) // MARK: Swiftgram gestureRecognizer.scale = 1.0 case .ended, .cancelled: - camera.rampZoom(1.0, rate: 8.0) + camera.resetRoundVideoTransientZoom(rate: 8.0) // MARK: Swiftgram default: break }