camera: macOS port via darwin/ split (no shared-file pragmas)
Reuse the AVFoundation Swift files between iOS and macOS without
sprinkling `#if canImport(UIKit)` through them. The split is:
darwin/Camera/ platform-shared (AVFoundation only)
CameraPlugin channel + instance map
CameraInstance session + outputs + texture
CameraSession AVCaptureSession + runtime-error obs
CaptureDevice front/back discovery
PhotoOutput AVCapturePhotoOutput
PreviewSink CVPixelBuffer → FlutterTexture
VideoRecorder AVAssetWriter
DeviceOrientation wire-string enum
ios/Classes/Camera/ iOS-only impls + extensions
AudioSession AVAudioSession.upgradeForRecording
DeviceOrientationBridge UIDevice.orientation listener
CameraSession+iOS AVCaptureSessionWasInterrupted obs
+ InterruptionReason decode + the
application-audio-session flags
(all iOS-only on AVCaptureSession)
CameraSettings UIApplication.openSettingsURLString
FlutterRegistrar+iOS method-form of textures/messenger
macos/Classes/Camera/ macOS no-op stubs (same surface)
AudioSession no-op (no AVAudioSession on macOS)
DeviceOrientationBridge no-op (desktops don't rotate)
CameraSession+macOS no-op setupPlatform()
CameraSettings NSWorkspace → System Settings'
Privacy_Camera pane
FlutterRegistrar+macOS property-form of textures/messenger
`CameraSession.init` now calls `setupPlatform()` which each platform
provides via an extension — keeps the iOS-only interruption observer
and the `automaticallyConfiguresApplicationAudioSession` /
`usesApplicationAudioSession` flags (both iOS-only on AVCaptureSession)
out of the shared file. Flash-mode in PhotoOutput uses
`if #available(macOS 11/13, *)` rather than `#if`, since those are
plain version gates not platform splits.
The shared files compile into the iOS pod from `ios/Classes/Camera-shared/`
and into the macOS pod from `macos/Classes/Camera-shared/`, each a
mirror populated by a `prepare_command` in the podspec:
rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared
Symlinks and `../` source globs both fail — Pathname.glob bails on
symlinks, and CocoaPods silently drops paths that escape the pod
directory. The mirror destinations are .gitignore'd.
macOS UxPlugin now registers CameraPlugin alongside the others.
This commit is contained in:
@@ -10,6 +10,10 @@ import AVFoundation
|
||||
/// `automaticallyConfiguresApplicationAudioSession = false` +
|
||||
/// `usesApplicationAudioSession = true` (set in [CameraSession.init])
|
||||
/// so AVCaptureSession doesn't yank the category back.
|
||||
///
|
||||
/// iOS-only. The macOS counterpart lives in
|
||||
/// `macos/Classes/Camera/AudioSession.swift` and is a no-op —
|
||||
/// `AVCaptureSession` manages its own audio routing on macOS.
|
||||
enum AudioSession {
|
||||
/// Upgrade the shared category to include `.playAndRecord` (and
|
||||
/// the given options union'd with whatever's already set). No-op
|
||||
|
||||
@@ -1,514 +0,0 @@
|
||||
import AVFoundation
|
||||
import Flutter
|
||||
import Foundation
|
||||
|
||||
/// One per `UxCameraController` on the Dart side. Owns its
|
||||
/// `AVCaptureSession`, the texture-backed preview pipeline, photo
|
||||
/// output, audio + video data outputs, and the
|
||||
/// [VideoRecorder] when a recording is in flight. Multiple instances
|
||||
/// coexist; the `[CameraPlugin]` keys them by `handle`.
|
||||
///
|
||||
/// **Threading**:
|
||||
/// - Session config (add/remove inputs/outputs, start, stop) runs on
|
||||
/// `sessionQueue` (serial).
|
||||
/// - Video sample-buffer delegate fires on `videoBufferQueue` (serial).
|
||||
/// - Audio sample-buffer delegate fires on `audioBufferQueue` (serial).
|
||||
/// - [VideoRecorder] mutates its writer state on `recorderQueue` (serial).
|
||||
/// - All public completions land on `.main`.
|
||||
final class CameraInstance {
|
||||
let handle: Int
|
||||
|
||||
/// Called whenever the instance has an event to forward to Dart.
|
||||
/// The payload is the `{event, handle, …}` map the EventChannel
|
||||
/// emits. Set by the plugin at construction.
|
||||
var onEvent: (([String: Any]) -> Void)?
|
||||
|
||||
private let session = CameraSession()
|
||||
private let sink = PreviewSink()
|
||||
private let photoOutput = PhotoOutput()
|
||||
private let orientation = DeviceOrientationBridge()
|
||||
|
||||
private let sessionQueue: DispatchQueue
|
||||
private let videoBufferQueue: DispatchQueue
|
||||
private let audioBufferQueue: DispatchQueue
|
||||
private let recorderQueue: DispatchQueue
|
||||
|
||||
private var device: AVCaptureDevice?
|
||||
private var deviceInput: AVCaptureDeviceInput?
|
||||
private var videoDataOutput: AVCaptureVideoDataOutput?
|
||||
|
||||
private var audioDevice: AVCaptureDevice?
|
||||
private var audioDeviceInput: AVCaptureDeviceInput?
|
||||
private var audioDataOutput: AVCaptureAudioDataOutput?
|
||||
|
||||
private let fanout: SampleFanout
|
||||
|
||||
private var flashMode: AVCaptureDevice.FlashMode = .off
|
||||
private var lockedOrientation: DeviceOrientationFlutter?
|
||||
private var enableAudio: Bool = false
|
||||
private var disposed = false
|
||||
|
||||
/// Set during [startVideoRecording], cleared after the stop
|
||||
/// completion fires. Lives on `sessionQueue` (set/cleared); the
|
||||
/// [SampleFanout] holds a parallel reference (under its own lock)
|
||||
/// for the videoBufferQueue / audioBufferQueue to access without
|
||||
/// crossing queue boundaries per frame.
|
||||
private var videoRecorder: VideoRecorder?
|
||||
|
||||
/// `uniqueID` of the AVCaptureDevice this instance is currently
|
||||
/// bound to, or `nil` after [dispose]. Used by [CameraPlugin] to
|
||||
/// release its device claim. Read on `.main`.
|
||||
var currentCameraId: String? { device?.uniqueID }
|
||||
|
||||
/// Whether this instance was constructed with `enableAudio: true`
|
||||
/// and therefore owns the app-global audio claim. Read on `.main`.
|
||||
var audioClaimed: Bool { enableAudio }
|
||||
|
||||
/// Hop to this instance's serial session queue. Public entry
|
||||
/// point so the plugin can dispatch session work without
|
||||
/// exposing the queue directly.
|
||||
func sessionQueueAsync(_ block: @escaping () -> Void) {
|
||||
sessionQueue.async(execute: block)
|
||||
}
|
||||
|
||||
/// Active-format dimensions in the camera sensor's natural
|
||||
/// orientation (typically landscape — `1920×1080` etc.). Set
|
||||
/// after `create` / `setDescription` configures the session.
|
||||
private(set) var previewSize: CGSize = .zero
|
||||
|
||||
/// Texture id handed back to Dart. Stable for the lifetime of
|
||||
/// the instance.
|
||||
private(set) var textureId: Int64 = -1
|
||||
|
||||
init(handle: Int) {
|
||||
self.handle = handle
|
||||
sessionQueue = DispatchQueue(label: "ux.camera.session.\(handle)")
|
||||
videoBufferQueue = DispatchQueue(label: "ux.camera.video.\(handle)")
|
||||
audioBufferQueue = DispatchQueue(label: "ux.camera.audio.\(handle)")
|
||||
recorderQueue = DispatchQueue(label: "ux.camera.recorder.\(handle)")
|
||||
fanout = SampleFanout(sink: sink)
|
||||
|
||||
session.onRuntimeError = { [weak self] error in
|
||||
self?.emit([
|
||||
"event": "sessionError",
|
||||
"code": "session_runtime_error",
|
||||
"description": error.localizedDescription,
|
||||
])
|
||||
}
|
||||
session.onInterrupted = { [weak self] reason in
|
||||
self?.emit(["event": "sessionInterrupted", "reason": reason])
|
||||
}
|
||||
session.onResumed = { [weak self] in
|
||||
self?.emit(["event": "sessionResumed"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Synchronously configure the session for [cameraId]. Registers
|
||||
/// the texture, attaches audio if requested, upgrades the audio
|
||||
/// session, and starts the orientation bridge. Must run on
|
||||
/// sessionQueue.
|
||||
func create(
|
||||
cameraId: String,
|
||||
enableAudio: Bool,
|
||||
registry: FlutterTextureRegistry
|
||||
) throws {
|
||||
precondition(!disposed)
|
||||
self.enableAudio = enableAudio
|
||||
|
||||
textureId = sink.register(with: registry)
|
||||
|
||||
if enableAudio {
|
||||
// Widen the shared audio session category before we
|
||||
// attach the mic input — matches `camera_avfoundation`'s
|
||||
// defensive pattern. No-op if already widened.
|
||||
AudioSession.upgradeForRecording()
|
||||
}
|
||||
|
||||
try configureSession(forDeviceUniqueID: cameraId, replacing: false)
|
||||
|
||||
orientation.start { [weak self] next in
|
||||
guard let self = self else { return }
|
||||
self.sessionQueue.async { self.applyOrientationFollowDevice(next) }
|
||||
self.emit([
|
||||
"event": "deviceOrientationChanged",
|
||||
"orientation": next.rawValue,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the session. Must run on sessionQueue.
|
||||
func initialize() {
|
||||
precondition(!disposed)
|
||||
session.start()
|
||||
}
|
||||
|
||||
/// Tear everything down. Idempotent. Hard-cancels any in-flight
|
||||
/// recording (drops queued audio, `cancelWriting`, deletes the
|
||||
/// partial file — telegram-ios's `cancelRecording` path). Must run
|
||||
/// on sessionQueue.
|
||||
func dispose() {
|
||||
if disposed { return }
|
||||
disposed = true
|
||||
|
||||
if let recorder = videoRecorder {
|
||||
recorder.cancel()
|
||||
videoRecorder = nil
|
||||
fanout.recorder = nil
|
||||
}
|
||||
|
||||
session.stop()
|
||||
if let input = deviceInput { session.av.removeInput(input) }
|
||||
if let input = audioDeviceInput { session.av.removeInput(input) }
|
||||
if let output = videoDataOutput { session.av.removeOutput(output) }
|
||||
if let output = audioDataOutput { session.av.removeOutput(output) }
|
||||
session.av.removeOutput(photoOutput.avOutput)
|
||||
|
||||
videoDataOutput = nil
|
||||
deviceInput = nil
|
||||
device = nil
|
||||
audioDeviceInput = nil
|
||||
audioDataOutput = nil
|
||||
audioDevice = nil
|
||||
|
||||
orientation.stop()
|
||||
sink.unregister()
|
||||
onEvent = nil
|
||||
}
|
||||
|
||||
// MARK: - Camera flip
|
||||
|
||||
/// Replace the video input device (audio stays attached). Returns
|
||||
/// the new previewSize. Must run on sessionQueue.
|
||||
func setDescription(cameraId: String) throws -> CGSize {
|
||||
precondition(!disposed)
|
||||
try configureSession(forDeviceUniqueID: cameraId, replacing: true)
|
||||
return previewSize
|
||||
}
|
||||
|
||||
// MARK: - Flash + orientation
|
||||
|
||||
func setFlashMode(_ mode: AVCaptureDevice.FlashMode) {
|
||||
flashMode = mode
|
||||
}
|
||||
|
||||
func lockCaptureOrientation(_ next: DeviceOrientationFlutter) {
|
||||
lockedOrientation = next
|
||||
applyVideoOrientationOnPreview(next)
|
||||
}
|
||||
|
||||
func unlockCaptureOrientation() {
|
||||
lockedOrientation = nil
|
||||
applyOrientationFollowDevice(orientation.current)
|
||||
}
|
||||
|
||||
// MARK: - Photo
|
||||
|
||||
func takePicture(
|
||||
snapshot: DeviceOrientationFlutter,
|
||||
completion: @escaping (Result<String, NSError>) -> Void
|
||||
) {
|
||||
photoOutput.take(orientation: snapshot, flashMode: flashMode, completion: completion)
|
||||
}
|
||||
|
||||
// MARK: - Video recording
|
||||
|
||||
/// Begin a recording. Must run on sessionQueue. Throws on writer
|
||||
/// setup failure (typically a path / file-system issue).
|
||||
/// [snapshot] is the orientation embedded as the file's track
|
||||
/// transform — when the user is holding the device landscape,
|
||||
/// pass landscape here and the file plays back landscape.
|
||||
func startVideoRecording(
|
||||
snapshot: DeviceOrientationFlutter
|
||||
) throws {
|
||||
precondition(!disposed)
|
||||
guard videoRecorder == nil else {
|
||||
throw NSError(
|
||||
domain: "ux.camera",
|
||||
code: -20,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey:
|
||||
"Recording already in flight"
|
||||
]
|
||||
)
|
||||
}
|
||||
guard let videoOutput = videoDataOutput else {
|
||||
throw NSError(
|
||||
domain: "ux.camera",
|
||||
code: -21,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Video output unavailable"]
|
||||
)
|
||||
}
|
||||
|
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("ux_camera_\(UUID().uuidString).mp4")
|
||||
|
||||
// Audio is viable only when both the device input attached AND
|
||||
// the audio output can recommend writer settings. Empty
|
||||
// recommended settings means the audio path can't be muxed —
|
||||
// telegram-ios fails the whole recording in that case
|
||||
// (CameraOutput.swift:397-401); we silent-fall back to
|
||||
// video-only so an audio-permission glitch doesn't break the
|
||||
// page.
|
||||
var audioViable = enableAudio
|
||||
&& audioDeviceInput != nil
|
||||
&& audioDataOutput != nil
|
||||
|
||||
let baseVideoSettings = videoOutput.recommendedVideoSettingsForAssetWriter(
|
||||
writingTo: .mp4
|
||||
) as? [String: Any]
|
||||
|
||||
var baseAudioSettings: [String: Any] = [:]
|
||||
if audioViable, let ao = audioDataOutput {
|
||||
baseAudioSettings = (ao.recommendedAudioSettingsForAssetWriter(
|
||||
writingTo: .mp4
|
||||
) as? [String: Any]) ?? [:]
|
||||
if baseAudioSettings.isEmpty {
|
||||
audioViable = false
|
||||
}
|
||||
}
|
||||
|
||||
let recorder = VideoRecorder(
|
||||
url: url,
|
||||
orientation: snapshot,
|
||||
hasAudio: audioViable,
|
||||
baseVideoSettings: baseVideoSettings,
|
||||
baseAudioSettings: baseAudioSettings,
|
||||
recorderQueue: recorderQueue
|
||||
)
|
||||
recorder.onDiagnostic = { [weak self] msg in
|
||||
self?.emit(["event": "diagnostic", "message": msg])
|
||||
}
|
||||
try recorder.start()
|
||||
|
||||
videoRecorder = recorder
|
||||
// Publish the recorder under the fanout's lock so the buffer
|
||||
// queues see it on their next sample.
|
||||
fanout.recorder = recorder
|
||||
}
|
||||
|
||||
/// Stop the in-flight recording. Completion fires on
|
||||
/// `recorderQueue` (which is `.async`'d here back to `.main` by
|
||||
/// the plugin). Returns the file path or an error.
|
||||
///
|
||||
/// The fanout reference stays attached until `finishWriting`
|
||||
/// completes — the recorder relies on *post-stop* sample buffers
|
||||
/// crossing `recordingStopSampleTime` to trigger `maybeFinish`.
|
||||
/// Detaching the feed at the wrong moment (before stop) is what
|
||||
/// caused the 3-second watchdog to be the only thing finishing
|
||||
/// the writer.
|
||||
func stopVideoRecording(
|
||||
completion: @escaping (Result<URL, NSError>) -> Void
|
||||
) {
|
||||
guard let recorder = videoRecorder else {
|
||||
completion(.failure(NSError(
|
||||
domain: "ux.camera",
|
||||
code: -22,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No recording in flight"]
|
||||
)))
|
||||
return
|
||||
}
|
||||
|
||||
recorder.stop { [weak self] outcome in
|
||||
guard let self = self else {
|
||||
completion(outcome)
|
||||
return
|
||||
}
|
||||
self.sessionQueue.async {
|
||||
self.fanout.recorder = nil
|
||||
self.videoRecorder = nil
|
||||
}
|
||||
completion(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func configureSession(
|
||||
forDeviceUniqueID cameraId: String,
|
||||
replacing: Bool
|
||||
) throws {
|
||||
guard let device = AVCaptureDevice(uniqueID: cameraId) else {
|
||||
throw NSError(
|
||||
domain: "ux.camera",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Camera \(cameraId) not found"]
|
||||
)
|
||||
}
|
||||
|
||||
var caughtError: NSError?
|
||||
|
||||
session.configure {
|
||||
if replacing, let oldInput = deviceInput {
|
||||
session.av.removeInput(oldInput)
|
||||
}
|
||||
|
||||
do {
|
||||
let newInput = try AVCaptureDeviceInput(device: device)
|
||||
guard session.av.canAddInput(newInput) else {
|
||||
throw NSError(
|
||||
domain: "ux.camera",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Cannot add input"]
|
||||
)
|
||||
}
|
||||
session.av.addInput(newInput)
|
||||
deviceInput = newInput
|
||||
} catch let error as NSError {
|
||||
caughtError = error
|
||||
return
|
||||
}
|
||||
|
||||
if !replacing {
|
||||
if session.av.canSetSessionPreset(.high) {
|
||||
session.av.sessionPreset = .high
|
||||
}
|
||||
|
||||
let videoOutput = AVCaptureVideoDataOutput()
|
||||
videoOutput.videoSettings = [
|
||||
kCVPixelBufferPixelFormatTypeKey as String:
|
||||
kCVPixelFormatType_32BGRA,
|
||||
]
|
||||
videoOutput.alwaysDiscardsLateVideoFrames = true
|
||||
videoOutput.setSampleBufferDelegate(
|
||||
fanout,
|
||||
queue: videoBufferQueue
|
||||
)
|
||||
if session.av.canAddOutput(videoOutput) {
|
||||
session.av.addOutput(videoOutput)
|
||||
}
|
||||
videoDataOutput = videoOutput
|
||||
|
||||
if session.av.canAddOutput(photoOutput.avOutput) {
|
||||
session.av.addOutput(photoOutput.avOutput)
|
||||
}
|
||||
|
||||
if enableAudio,
|
||||
let mic = AVCaptureDevice.default(for: .audio) {
|
||||
do {
|
||||
let audioInput = try AVCaptureDeviceInput(device: mic)
|
||||
if session.av.canAddInput(audioInput) {
|
||||
session.av.addInput(audioInput)
|
||||
audioDevice = mic
|
||||
audioDeviceInput = audioInput
|
||||
}
|
||||
} catch {
|
||||
// Don't fail the whole setup over audio — fall
|
||||
// through; the recording will simply have no
|
||||
// audio track.
|
||||
}
|
||||
|
||||
let audioOutput = AVCaptureAudioDataOutput()
|
||||
audioOutput.setSampleBufferDelegate(
|
||||
fanout,
|
||||
queue: audioBufferQueue
|
||||
)
|
||||
if session.av.canAddOutput(audioOutput) {
|
||||
session.av.addOutput(audioOutput)
|
||||
audioDataOutput = audioOutput
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply preview-output orientation. Mirroring is deliberately
|
||||
// NOT set here — the data output feeds both the preview
|
||||
// texture and the recorder, so mirroring at the connection
|
||||
// would land in the recorded MP4 too. Telegram avoids this
|
||||
// by mirroring at the preview-LAYER level (CALayer transform
|
||||
// in `CameraPreviewView.mirroring`). Our FlutterTexture
|
||||
// equivalent is a `Transform.flip` in [CameraThumb] for the
|
||||
// front camera — raw sensor feed at capture, mirror as a
|
||||
// playback decision.
|
||||
if let videoConn = videoDataOutput?.connection(with: .video) {
|
||||
if videoConn.isVideoOrientationSupported {
|
||||
videoConn.videoOrientation = lockedOrientation?.avVideoOrientation
|
||||
?? orientation.current.avVideoOrientation
|
||||
}
|
||||
if videoConn.isVideoMirroringSupported {
|
||||
videoConn.automaticallyAdjustsVideoMirroring = false
|
||||
videoConn.isVideoMirrored = false
|
||||
}
|
||||
}
|
||||
|
||||
self.device = device
|
||||
CaptureDevice.applyDefaults(device)
|
||||
|
||||
let dims = CMVideoFormatDescriptionGetDimensions(
|
||||
device.activeFormat.formatDescription
|
||||
)
|
||||
previewSize = CGSize(width: CGFloat(dims.width), height: CGFloat(dims.height))
|
||||
}
|
||||
|
||||
if let error = caughtError {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func applyOrientationFollowDevice(_ next: DeviceOrientationFlutter) {
|
||||
// When a lock is in effect the preview ignores physical
|
||||
// rotation — the lock wins.
|
||||
guard lockedOrientation == nil else { return }
|
||||
applyVideoOrientationOnPreview(next)
|
||||
}
|
||||
|
||||
private func applyVideoOrientationOnPreview(_ next: DeviceOrientationFlutter) {
|
||||
guard let conn = videoDataOutput?.connection(with: .video),
|
||||
conn.isVideoOrientationSupported else {
|
||||
return
|
||||
}
|
||||
conn.videoOrientation = next.avVideoOrientation
|
||||
}
|
||||
|
||||
private func emit(_ extras: [String: Any]) {
|
||||
var payload: [String: Any] = ["handle": handle]
|
||||
payload.merge(extras, uniquingKeysWith: { _, new in new })
|
||||
DispatchQueue.main.async { [weak self] in self?.onEvent?(payload) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Single sample-buffer delegate for both video + audio outputs.
|
||||
/// Forwards video frames to [PreviewSink] and (when a recording is
|
||||
/// active) both video and audio sample buffers to [VideoRecorder].
|
||||
///
|
||||
/// The `recorder` reference is cross-queue: written from
|
||||
/// `sessionQueue` (set on startVideoRecording, cleared on
|
||||
/// stopVideoRecording), read from `videoBufferQueue` and
|
||||
/// `audioBufferQueue` (once per sample). An `NSLock` guards each
|
||||
/// access — cheap, ~tens of nanoseconds per frame.
|
||||
private final class SampleFanout: NSObject,
|
||||
AVCaptureVideoDataOutputSampleBufferDelegate,
|
||||
AVCaptureAudioDataOutputSampleBufferDelegate
|
||||
{
|
||||
private let sink: PreviewSink
|
||||
private let recorderLock = NSLock()
|
||||
private var _recorder: VideoRecorder?
|
||||
|
||||
var recorder: VideoRecorder? {
|
||||
get {
|
||||
recorderLock.lock(); defer { recorderLock.unlock() }
|
||||
return _recorder
|
||||
}
|
||||
set {
|
||||
recorderLock.lock(); defer { recorderLock.unlock() }
|
||||
_recorder = newValue
|
||||
}
|
||||
}
|
||||
|
||||
init(sink: PreviewSink) {
|
||||
self.sink = sink
|
||||
}
|
||||
|
||||
func captureOutput(
|
||||
_ output: AVCaptureOutput,
|
||||
didOutput sampleBuffer: CMSampleBuffer,
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
if output is AVCaptureVideoDataOutput {
|
||||
sink.receive(sampleBuffer: sampleBuffer)
|
||||
recorder?.appendVideo(sampleBuffer)
|
||||
} else if output is AVCaptureAudioDataOutput {
|
||||
recorder?.appendAudio(sampleBuffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
import AVFoundation
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
/// `ux/camera` + `ux/camera/events` registrar. Routes channel calls
|
||||
/// to per-handle [CameraInstance]s. Enforces device + audio claims
|
||||
/// across instances so a second controller against an already-held
|
||||
/// camera or audio session fails fast with the right error code.
|
||||
///
|
||||
/// Phase 2 scope: no video recording. `startVideoRecording` /
|
||||
/// `stopVideoRecording` return `unsupported_format`.
|
||||
public class CameraPlugin: NSObject, NativePlugin, FlutterStreamHandler {
|
||||
private weak var textureRegistry: FlutterTextureRegistry?
|
||||
|
||||
private var instances: [Int: CameraInstance] = [:]
|
||||
private var nextHandle: Int = 1
|
||||
|
||||
// Multi-instance contention bookkeeping.
|
||||
private var devicesInUse: Set<String> = []
|
||||
private var audioInUse: Bool = false
|
||||
|
||||
private var eventSink: FlutterEventSink?
|
||||
|
||||
public func register(with registrar: FlutterPluginRegistrar) {
|
||||
textureRegistry = registrar.textures()
|
||||
|
||||
let methods = FlutterMethodChannel(
|
||||
name: "ux/camera",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
methods.setMethodCallHandler { [weak self] call, result in
|
||||
self?.handle(call, result: result)
|
||||
}
|
||||
|
||||
let events = FlutterEventChannel(
|
||||
name: "ux/camera/events",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
events.setStreamHandler(self)
|
||||
}
|
||||
|
||||
// MARK: - FlutterStreamHandler
|
||||
|
||||
public func onListen(
|
||||
withArguments arguments: Any?,
|
||||
eventSink events: @escaping FlutterEventSink
|
||||
) -> FlutterError? {
|
||||
eventSink = events
|
||||
return nil
|
||||
}
|
||||
|
||||
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
eventSink = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Method dispatch
|
||||
|
||||
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "availableCameras":
|
||||
availableCameras(result: result)
|
||||
|
||||
case "create":
|
||||
create(args: call.arguments, result: result)
|
||||
|
||||
case "initialize":
|
||||
withInstance(call.arguments, result: result) { instance in
|
||||
self.requestPermissions(audio: instance.audioClaimed) { granted, kind in
|
||||
guard granted else {
|
||||
result(FlutterError(
|
||||
code: "permission_denied",
|
||||
message: kind,
|
||||
details: nil
|
||||
))
|
||||
return
|
||||
}
|
||||
instance.sessionQueueAsync {
|
||||
instance.initialize()
|
||||
DispatchQueue.main.async { result(nil) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "dispose":
|
||||
withInstance(call.arguments, result: result) { instance in
|
||||
// Release the device + audio claim *immediately* on .main
|
||||
// so a follow-up `create` for the same camera can succeed
|
||||
// even while the slow session teardown
|
||||
// (stopRunning / removeInput / removeOutput) is still in
|
||||
// flight on the per-instance sessionQueue. Two
|
||||
// AVCaptureSessions can briefly coexist against the same
|
||||
// device — AVFoundation serializes the underlying hardware
|
||||
// access. Without this, a quick page-pop → re-push hits
|
||||
// `device_busy` because the old claim hasn't been
|
||||
// released yet.
|
||||
self.releaseClaim(for: instance)
|
||||
self.instances.removeValue(forKey: instance.handle)
|
||||
instance.sessionQueueAsync {
|
||||
instance.dispose()
|
||||
DispatchQueue.main.async { result(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
case "setDescription":
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let cameraId = args["cameraId"] as? String else {
|
||||
result(badArgs("setDescription"))
|
||||
return
|
||||
}
|
||||
withInstance(args, result: result) { instance in
|
||||
// Contention check against other instances. The instance's
|
||||
// current cameraId is held by us in `devicesInUse`; only a
|
||||
// foreign holder should block. (A no-op flip — same id —
|
||||
// also passes.)
|
||||
let oldId = instance.currentCameraId
|
||||
if oldId != cameraId, self.devicesInUse.contains(cameraId) {
|
||||
result(FlutterError(
|
||||
code: "device_busy",
|
||||
message: cameraId,
|
||||
details: nil
|
||||
))
|
||||
return
|
||||
}
|
||||
// Tentatively claim the new id before the async swap so a
|
||||
// concurrent create can't race us. Roll back on failure.
|
||||
self.devicesInUse.insert(cameraId)
|
||||
instance.sessionQueueAsync {
|
||||
do {
|
||||
let size = try instance.setDescription(cameraId: cameraId)
|
||||
DispatchQueue.main.async {
|
||||
if let oldId, oldId != cameraId {
|
||||
self.devicesInUse.remove(oldId)
|
||||
}
|
||||
result([
|
||||
"previewSize": [
|
||||
"width": size.width,
|
||||
"height": size.height,
|
||||
],
|
||||
"previewRotationQuarterTurns": 0,
|
||||
])
|
||||
}
|
||||
} catch let error as NSError {
|
||||
DispatchQueue.main.async {
|
||||
if oldId != cameraId {
|
||||
self.devicesInUse.remove(cameraId)
|
||||
}
|
||||
result(FlutterError(
|
||||
code: "init_failed",
|
||||
message: error.localizedDescription,
|
||||
details: nil
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "setFlashMode":
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let modeName = args["mode"] as? String else {
|
||||
result(badArgs("setFlashMode"))
|
||||
return
|
||||
}
|
||||
withInstance(args, result: result) { instance in
|
||||
instance.sessionQueueAsync {
|
||||
instance.setFlashMode(flashMode(from: modeName))
|
||||
DispatchQueue.main.async { result(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
case "lockCaptureOrientation":
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let raw = args["orientation"] as? String else {
|
||||
result(badArgs("lockCaptureOrientation"))
|
||||
return
|
||||
}
|
||||
withInstance(args, result: result) { instance in
|
||||
let o = DeviceOrientationFlutter.parse(raw)
|
||||
instance.sessionQueueAsync {
|
||||
instance.lockCaptureOrientation(o)
|
||||
DispatchQueue.main.async { result(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
case "unlockCaptureOrientation":
|
||||
withInstance(call.arguments, result: result) { instance in
|
||||
instance.sessionQueueAsync {
|
||||
instance.unlockCaptureOrientation()
|
||||
DispatchQueue.main.async { result(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
case "takePicture":
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let raw = args["snapshotOrientation"] as? String else {
|
||||
result(badArgs("takePicture"))
|
||||
return
|
||||
}
|
||||
withInstance(args, result: result) { instance in
|
||||
let snapshot = DeviceOrientationFlutter.parse(raw)
|
||||
instance.sessionQueueAsync {
|
||||
instance.takePicture(snapshot: snapshot) { outcome in
|
||||
switch outcome {
|
||||
case .success(let path):
|
||||
result(["path": path])
|
||||
case .failure(let error):
|
||||
result(FlutterError(
|
||||
code: "take_picture_failed",
|
||||
message: error.localizedDescription,
|
||||
details: nil
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "startVideoRecording":
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let raw = args["snapshotOrientation"] as? String else {
|
||||
result(badArgs("startVideoRecording"))
|
||||
return
|
||||
}
|
||||
withInstance(args, result: result) { instance in
|
||||
let snapshot = DeviceOrientationFlutter.parse(raw)
|
||||
instance.sessionQueueAsync {
|
||||
do {
|
||||
try instance.startVideoRecording(snapshot: snapshot)
|
||||
DispatchQueue.main.async { result(nil) }
|
||||
} catch let error as NSError {
|
||||
DispatchQueue.main.async {
|
||||
result(FlutterError(
|
||||
code: "recorder_failed",
|
||||
message: error.localizedDescription,
|
||||
details: nil
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "stopVideoRecording":
|
||||
withInstance(call.arguments, result: result) { instance in
|
||||
instance.sessionQueueAsync {
|
||||
instance.stopVideoRecording { outcome in
|
||||
// `outcome` arrives on recorderQueue from
|
||||
// VideoRecorder.deliver. Bounce to main for
|
||||
// the FlutterResult.
|
||||
DispatchQueue.main.async {
|
||||
switch outcome {
|
||||
case .success(let url):
|
||||
result(["path": url.path])
|
||||
case .failure(let error):
|
||||
result(FlutterError(
|
||||
code: "recorder_failed",
|
||||
message: error.localizedDescription,
|
||||
details: nil
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "audioPermissionStatus":
|
||||
let granted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
result(granted)
|
||||
|
||||
case "openSettings":
|
||||
DispatchQueue.main.async {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
result(nil)
|
||||
}
|
||||
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Method implementations
|
||||
|
||||
private func availableCameras(result: @escaping FlutterResult) {
|
||||
let cameras = CaptureDevice.discover().map { $0.toWire() }
|
||||
result(cameras)
|
||||
}
|
||||
|
||||
private func create(args: Any?, result: @escaping FlutterResult) {
|
||||
guard let args = args as? [String: Any],
|
||||
let cameraId = args["cameraId"] as? String,
|
||||
let enableAudio = args["enableAudio"] as? Bool else {
|
||||
result(badArgs("create"))
|
||||
return
|
||||
}
|
||||
guard let registry = textureRegistry else {
|
||||
result(FlutterError(
|
||||
code: "init_failed",
|
||||
message: "Texture registry unavailable",
|
||||
details: nil
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
if devicesInUse.contains(cameraId) {
|
||||
result(FlutterError(
|
||||
code: "device_busy",
|
||||
message: cameraId,
|
||||
details: nil
|
||||
))
|
||||
return
|
||||
}
|
||||
if enableAudio && audioInUse {
|
||||
result(FlutterError(
|
||||
code: "audio_busy",
|
||||
message: nil,
|
||||
details: nil
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
let handle = nextHandle
|
||||
nextHandle += 1
|
||||
|
||||
let instance = CameraInstance(handle: handle)
|
||||
instance.onEvent = { [weak self] payload in
|
||||
self?.eventSink?(payload)
|
||||
}
|
||||
instances[handle] = instance
|
||||
devicesInUse.insert(cameraId)
|
||||
if enableAudio { audioInUse = true }
|
||||
|
||||
instance.sessionQueueAsync {
|
||||
do {
|
||||
try instance.create(
|
||||
cameraId: cameraId,
|
||||
enableAudio: enableAudio,
|
||||
registry: registry
|
||||
)
|
||||
let size = instance.previewSize
|
||||
DispatchQueue.main.async {
|
||||
result([
|
||||
"handle": handle,
|
||||
"textureId": instance.textureId,
|
||||
"previewSize": [
|
||||
"width": size.width,
|
||||
"height": size.height,
|
||||
],
|
||||
// iOS pre-rotates frames via the data-output
|
||||
// connection's videoOrientation, so the Flutter
|
||||
// Texture displays upright as-is.
|
||||
"previewRotationQuarterTurns": 0,
|
||||
])
|
||||
}
|
||||
} catch let error as NSError {
|
||||
DispatchQueue.main.async {
|
||||
// Can't rely on `releaseClaim(for: instance)` here —
|
||||
// if `configureSession` threw before `instance.device`
|
||||
// was set, `instance.currentCameraId` is nil and the
|
||||
// claim we inserted on line above would leak. Drop the
|
||||
// ids we know we inserted explicitly.
|
||||
self.devicesInUse.remove(cameraId)
|
||||
if enableAudio { self.audioInUse = false }
|
||||
self.instances.removeValue(forKey: handle)
|
||||
result(FlutterError(
|
||||
code: "init_failed",
|
||||
message: error.localizedDescription,
|
||||
details: nil
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func withInstance(
|
||||
_ args: Any?,
|
||||
result: @escaping FlutterResult,
|
||||
body: @escaping (CameraInstance) -> Void
|
||||
) {
|
||||
guard let map = args as? [String: Any],
|
||||
let handle = (map["handle"] as? NSNumber)?.intValue else {
|
||||
result(badArgs("missing handle"))
|
||||
return
|
||||
}
|
||||
guard let instance = instances[handle] else {
|
||||
result(FlutterError(
|
||||
code: "disposed",
|
||||
message: "No camera for handle \(handle)",
|
||||
details: nil
|
||||
))
|
||||
return
|
||||
}
|
||||
body(instance)
|
||||
}
|
||||
|
||||
private func releaseClaim(for instance: CameraInstance) {
|
||||
if let id = instance.currentCameraId {
|
||||
devicesInUse.remove(id)
|
||||
}
|
||||
if instance.audioClaimed {
|
||||
audioInUse = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Request camera (always) and microphone (when [audio]) access.
|
||||
/// Completion fires on `.main` with `(true, "")` on camera grant.
|
||||
/// A denied microphone is **not** a fatal failure — the recording
|
||||
/// pipeline tolerates a missing audio output cleanly (no audio
|
||||
/// track in the file), and the user is more annoyed by "camera
|
||||
/// won't even open" than by "video has no sound." Camera denial
|
||||
/// is the only hard failure path here.
|
||||
private func requestPermissions(
|
||||
audio: Bool,
|
||||
_ completion: @escaping (Bool, String) -> Void
|
||||
) {
|
||||
requestAccess(for: .video) { videoGranted in
|
||||
guard videoGranted else {
|
||||
completion(false, "camera")
|
||||
return
|
||||
}
|
||||
guard audio else {
|
||||
completion(true, "")
|
||||
return
|
||||
}
|
||||
// Prompt for mic if we haven't asked yet; result doesn't
|
||||
// gate initialize either way. CameraInstance.configureSession
|
||||
// already tolerates a missing mic (the audio device input
|
||||
// throws on `AVCaptureDeviceInput(device:)`, the catch
|
||||
// swallows, and audioDeviceInput stays nil — which
|
||||
// [CameraInstance.startVideoRecording] reads as "skip
|
||||
// adding an audio input to the writer").
|
||||
self.requestAccess(for: .audio) { _ in
|
||||
completion(true, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func requestAccess(
|
||||
for mediaType: AVMediaType,
|
||||
_ completion: @escaping (Bool) -> Void
|
||||
) {
|
||||
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
|
||||
case .authorized:
|
||||
DispatchQueue.main.async { completion(true) }
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: mediaType) { granted in
|
||||
DispatchQueue.main.async { completion(granted) }
|
||||
}
|
||||
default:
|
||||
DispatchQueue.main.async { completion(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Free functions
|
||||
|
||||
private func badArgs(_ where_: String) -> FlutterError {
|
||||
return FlutterError(
|
||||
code: "bad_args",
|
||||
message: "Bad arguments for \(where_)",
|
||||
details: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func flashMode(from raw: String) -> AVCaptureDevice.FlashMode {
|
||||
switch raw {
|
||||
case "always": return .on
|
||||
case "off": return .off
|
||||
default: return .off
|
||||
}
|
||||
}
|
||||
59
ios/Classes/Camera/CameraSession+iOS.swift
Normal file
59
ios/Classes/Camera/CameraSession+iOS.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// iOS-only platform setup for [CameraSession]: enables the
|
||||
/// "application owns the AVAudioSession" flags (so AVCaptureSession
|
||||
/// doesn't yank our `.playAndRecord` category back), and subscribes
|
||||
/// to `AVCaptureSessionWasInterrupted` / `…InterruptionEnded`
|
||||
/// notifications (which only fire on iOS — `AVCaptureSession.InterruptionReason`
|
||||
/// isn't even declared on macOS).
|
||||
extension CameraSession {
|
||||
func setupPlatform() {
|
||||
// Telegram + camera_avfoundation both set these — keeps
|
||||
// AVFoundation from yanking our audio session category out
|
||||
// from under the app. iOS-only properties on AVCaptureSession.
|
||||
av.automaticallyConfiguresApplicationAudioSession = false
|
||||
av.usesApplicationAudioSession = true
|
||||
|
||||
interruptedObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVCaptureSessionWasInterrupted,
|
||||
object: av,
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
let code = note.userInfo?[AVCaptureSessionInterruptionReasonKey]
|
||||
as? Int ?? 0
|
||||
self?.onInterrupted?(reasonName(for: code))
|
||||
}
|
||||
|
||||
resumedObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVCaptureSessionInterruptionEnded,
|
||||
object: av,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.onResumed?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the integer reason that comes with
|
||||
/// `AVCaptureSessionWasInterrupted`. Used in the event payload sent
|
||||
/// to Dart.
|
||||
private func reasonName(for code: Int) -> String {
|
||||
guard let reason = AVCaptureSession.InterruptionReason(rawValue: code) else {
|
||||
return "unknown"
|
||||
}
|
||||
switch reason {
|
||||
case .videoDeviceNotAvailableInBackground:
|
||||
return "videoDeviceNotAvailableInBackground"
|
||||
case .audioDeviceInUseByAnotherClient:
|
||||
return "audioDeviceInUseByAnotherClient"
|
||||
case .videoDeviceInUseByAnotherClient:
|
||||
return "videoDeviceInUseByAnotherClient"
|
||||
case .videoDeviceNotAvailableWithMultipleForegroundApps:
|
||||
return "videoDeviceNotAvailableWithMultipleForegroundApps"
|
||||
case .videoDeviceNotAvailableDueToSystemPressure:
|
||||
return "videoDeviceNotAvailableDueToSystemPressure"
|
||||
@unknown default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// Thin wrapper around `AVCaptureSession` that owns the lifecycle
|
||||
/// helpers and observes the runtime-error / interruption
|
||||
/// notifications, surfacing them through closures so
|
||||
/// [CameraInstance] doesn't repeat the boilerplate.
|
||||
///
|
||||
/// All `AVCaptureSession` mutations (input/output add/remove, start,
|
||||
/// stop) must run on the caller's `sessionQueue`. This class doesn't
|
||||
/// enforce that; the contract is that
|
||||
/// [CameraInstance.sessionQueue.async { … }] wraps every call site.
|
||||
final class CameraSession {
|
||||
let av: AVCaptureSession
|
||||
|
||||
/// Called on `.main` when the session reports an unrecoverable
|
||||
/// runtime error. Notable case: `.mediaServicesWereReset` — the
|
||||
/// caller typically tears down and recreates the session.
|
||||
var onRuntimeError: ((NSError) -> Void)?
|
||||
|
||||
/// Called on `.main` when the session is interrupted (e.g. video
|
||||
/// device taken by another foreground client, audio session
|
||||
/// interruption, or app backgrounded with `usesApplicationAudioSession`).
|
||||
/// String describes the reason — `"videoDeviceInUseByAnotherClient"`,
|
||||
/// `"audioDeviceInUseByAnotherClient"`, `"videoDeviceNotAvailableInBackground"`, etc.
|
||||
var onInterrupted: ((String) -> Void)?
|
||||
|
||||
/// Called on `.main` when an earlier interruption ends.
|
||||
var onResumed: (() -> Void)?
|
||||
|
||||
private var runtimeErrorObserver: NSObjectProtocol?
|
||||
private var interruptedObserver: NSObjectProtocol?
|
||||
private var resumedObserver: NSObjectProtocol?
|
||||
|
||||
init() {
|
||||
av = AVCaptureSession()
|
||||
|
||||
// Telegram + camera_avfoundation both set this — keeps
|
||||
// AVFoundation from yanking our audio session category out
|
||||
// from under the app.
|
||||
av.automaticallyConfiguresApplicationAudioSession = false
|
||||
av.usesApplicationAudioSession = true
|
||||
|
||||
runtimeErrorObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVCaptureSessionRuntimeError,
|
||||
object: av,
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
let error = note.userInfo?[AVCaptureSessionErrorKey] as? NSError
|
||||
?? NSError(domain: "ux.camera", code: -1)
|
||||
self?.onRuntimeError?(error)
|
||||
}
|
||||
|
||||
interruptedObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVCaptureSessionWasInterrupted,
|
||||
object: av,
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
let reason = note.userInfo?[AVCaptureSessionInterruptionReasonKey]
|
||||
as? Int ?? 0
|
||||
self?.onInterrupted?(reasonName(for: reason))
|
||||
}
|
||||
|
||||
resumedObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVCaptureSessionInterruptionEnded,
|
||||
object: av,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.onResumed?()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let o = runtimeErrorObserver { NotificationCenter.default.removeObserver(o) }
|
||||
if let o = interruptedObserver { NotificationCenter.default.removeObserver(o) }
|
||||
if let o = resumedObserver { NotificationCenter.default.removeObserver(o) }
|
||||
}
|
||||
|
||||
/// Configure block; pairs `beginConfiguration` /
|
||||
/// `commitConfiguration` so every add/remove batch lands as one
|
||||
/// session update. Caller must be on sessionQueue.
|
||||
func configure(_ block: () -> Void) {
|
||||
av.beginConfiguration()
|
||||
block()
|
||||
av.commitConfiguration()
|
||||
}
|
||||
|
||||
/// Start the session if it isn't already running.
|
||||
/// Caller must be on sessionQueue.
|
||||
func start() {
|
||||
if !av.isRunning { av.startRunning() }
|
||||
}
|
||||
|
||||
/// Stop the session if it's running.
|
||||
/// Caller must be on sessionQueue.
|
||||
func stop() {
|
||||
if av.isRunning { av.stopRunning() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the integer reason that comes with
|
||||
/// `AVCaptureSessionWasInterrupted`. Used in the event payload sent
|
||||
/// to Dart.
|
||||
private func reasonName(for code: Int) -> String {
|
||||
guard let reason = AVCaptureSession.InterruptionReason(rawValue: code) else {
|
||||
return "unknown"
|
||||
}
|
||||
switch reason {
|
||||
case .videoDeviceNotAvailableInBackground:
|
||||
return "videoDeviceNotAvailableInBackground"
|
||||
case .audioDeviceInUseByAnotherClient:
|
||||
return "audioDeviceInUseByAnotherClient"
|
||||
case .videoDeviceInUseByAnotherClient:
|
||||
return "videoDeviceInUseByAnotherClient"
|
||||
case .videoDeviceNotAvailableWithMultipleForegroundApps:
|
||||
return "videoDeviceNotAvailableWithMultipleForegroundApps"
|
||||
case .videoDeviceNotAvailableDueToSystemPressure:
|
||||
return "videoDeviceNotAvailableDueToSystemPressure"
|
||||
@unknown default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
14
ios/Classes/Camera/CameraSettings.swift
Normal file
14
ios/Classes/Camera/CameraSettings.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
import UIKit
|
||||
|
||||
/// Deep-link the user into this app's Settings entry. iOS exposes a
|
||||
/// dedicated `openSettingsURLString` that lands directly on the
|
||||
/// per-app permission pane. Called from the shared
|
||||
/// `CameraPlugin.handle("openSettings")`.
|
||||
enum CameraSettings {
|
||||
@MainActor
|
||||
static func openAppSettings() {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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 front + back wide-angle cameras the system
|
||||
/// exposes. Order: back first, front second. Stable for the
|
||||
/// lifetime of the process — iOS doesn't hot-swap cameras.
|
||||
static func discover() -> [DiscoveredCamera] {
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.builtInWideAngleCamera],
|
||||
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.
|
||||
return session.devices
|
||||
.sorted { positionRank($0.position) < positionRank($1.position) }
|
||||
.map { device in
|
||||
DiscoveredCamera(
|
||||
device: device,
|
||||
lens: lensName(for: device.position),
|
||||
// iOS doesn't expose sensor orientation directly;
|
||||
// 90° matches what `camera_avfoundation` reports
|
||||
// and what banlu's `normalizeCameraCapture` math
|
||||
// assumes for iOS sensors.
|
||||
sensorOrientation: 90
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
/// Translates between Flutter's `DeviceOrientation` (4 enum values
|
||||
/// shipped as strings across the channel) and AVFoundation's
|
||||
/// `AVCaptureVideoOrientation`, and bridges physical-device rotation
|
||||
/// notifications to a closure callback.
|
||||
///
|
||||
/// Observed orientation source is `UIDevice.current.orientation` —
|
||||
/// independent of any UI orientation lock, so this fires even while
|
||||
/// the app's window is portrait-locked. `.faceUp` / `.faceDown` are
|
||||
/// ignored (no useful direction).
|
||||
/// Bridges physical-device rotation notifications to a closure callback.
|
||||
/// Observed source is `UIDevice.current.orientation` — independent of
|
||||
/// any UI orientation lock, so this fires even while the app's window
|
||||
/// is portrait-locked. `.faceUp` / `.faceDown` are ignored (no useful
|
||||
/// direction).
|
||||
///
|
||||
/// `UIDevice.beginGeneratingDeviceOrientationNotifications()` must be
|
||||
/// called on main, balanced with `end…()`; this class enforces both.
|
||||
///
|
||||
/// iOS-only. The macOS counterpart in
|
||||
/// `macos/Classes/Camera/DeviceOrientationBridge.swift` is a no-op —
|
||||
/// desktops don't rotate, so `current` stays at the initial
|
||||
/// `portraitUp` and the listener never fires.
|
||||
final class DeviceOrientationBridge {
|
||||
typealias Listener = (DeviceOrientationFlutter) -> Void
|
||||
|
||||
@@ -74,23 +75,7 @@ final class DeviceOrientationBridge {
|
||||
deinit { stop() }
|
||||
}
|
||||
|
||||
/// Mirrors Flutter's `DeviceOrientation` — the four cardinal values
|
||||
/// that travel over the `ux/camera` channel as wire strings. `public`
|
||||
/// so the host app's XCTest target can verify the
|
||||
/// `DeviceOrientationFlutter` / `AVCaptureVideoOrientation` mapping
|
||||
/// without `@testable import`.
|
||||
public enum DeviceOrientationFlutter: String {
|
||||
case portraitUp
|
||||
case landscapeLeft
|
||||
case portraitDown
|
||||
case landscapeRight
|
||||
|
||||
/// Parse a wire string. Returns `.portraitUp` for unknown inputs
|
||||
/// (matches the Dart-side fallback in `MethodChannelUxCameraBackend`).
|
||||
public static func parse(_ raw: String?) -> DeviceOrientationFlutter {
|
||||
return DeviceOrientationFlutter(rawValue: raw ?? "") ?? .portraitUp
|
||||
}
|
||||
|
||||
extension DeviceOrientationFlutter {
|
||||
/// `UIDeviceOrientation` → Flutter convention is a direct 1:1 by
|
||||
/// name. Despite the AV-side flip, `UIDeviceOrientation.landscapeLeft`
|
||||
/// and Flutter's `landscapeLeft` describe the same physical pose
|
||||
@@ -105,16 +90,4 @@ public enum DeviceOrientationFlutter: String {
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// AVFoundation video orientation. Translates Flutter's portrait-
|
||||
/// relative convention to AVFoundation's hardware-relative one.
|
||||
/// Used to drive `AVCaptureConnection.videoOrientation`.
|
||||
public var avVideoOrientation: AVCaptureVideoOrientation {
|
||||
switch self {
|
||||
case .portraitUp: return .portrait
|
||||
case .portraitDown: return .portraitUpsideDown
|
||||
case .landscapeLeft: return .landscapeRight
|
||||
case .landscapeRight: return .landscapeLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
ios/Classes/Camera/FlutterRegistrar+iOS.swift
Normal file
12
ios/Classes/Camera/FlutterRegistrar+iOS.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import Flutter
|
||||
|
||||
/// Bridges the per-platform difference in `FlutterPluginRegistrar`:
|
||||
/// `textures` and `messenger` are **methods** on the iOS variant
|
||||
/// (`func textures() -> ...`) but **properties** on the macOS variant
|
||||
/// (`var textures: ...`). Shared `CameraPlugin` calls
|
||||
/// `registrar.uxTextures` and `registrar.uxMessenger` so the call
|
||||
/// site stays platform-agnostic.
|
||||
extension FlutterPluginRegistrar {
|
||||
var uxTextures: FlutterTextureRegistry { textures() }
|
||||
var uxMessenger: FlutterBinaryMessenger { messenger() }
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// 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?
|
||||
|
||||
/// 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
|
||||
}
|
||||
if connection.isVideoOrientationSupported {
|
||||
connection.videoOrientation = orientation.avVideoOrientation
|
||||
}
|
||||
// The recorded photo carries no mirror; mirroring is a
|
||||
// preview-only concern.
|
||||
if connection.isVideoMirroringSupported {
|
||||
connection.automaticallyAdjustsVideoMirroring = false
|
||||
connection.isVideoMirrored = false
|
||||
}
|
||||
|
||||
let settings = AVCapturePhotoSettings()
|
||||
if avOutput.supportedFlashModes.contains(flashMode) {
|
||||
settings.flashMode = flashMode
|
||||
}
|
||||
|
||||
let delegate = PhotoCaptureDelegate { [weak self] result in
|
||||
// Reset orientation on the photo connection so a future
|
||||
// capture without a snapshot defaults to portrait.
|
||||
if let conn = self?.avOutput.connection(with: .video),
|
||||
conn.isVideoOrientationSupported {
|
||||
conn.videoOrientation = .portrait
|
||||
}
|
||||
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 completion: (Result<String, NSError>) -> Void
|
||||
|
||||
init(completion: @escaping (Result<String, NSError>) -> Void) {
|
||||
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
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import AVFoundation
|
||||
import CoreVideo
|
||||
import Flutter
|
||||
|
||||
/// Single-slot latest-pixel-buffer sink that feeds a `FlutterTexture`.
|
||||
///
|
||||
/// AVCaptureVideoDataOutput's sample-buffer delegate fires on
|
||||
/// `videoBufferQueue`; we extract the `CVPixelBuffer`, store it as
|
||||
/// `latestPixelBuffer`, and notify the texture registry on `main` so
|
||||
/// Flutter pulls the new frame via `copyPixelBuffer()` on the engine
|
||||
/// thread.
|
||||
///
|
||||
/// We retain *only the most recent* buffer — the previous one is
|
||||
/// released the moment a new sample arrives. This matches the
|
||||
/// `camera_avfoundation` lifetime invariant and bounds memory at one
|
||||
/// frame regardless of how fast we produce vs. consume.
|
||||
final class PreviewSink: NSObject, FlutterTexture {
|
||||
private weak var registry: FlutterTextureRegistry?
|
||||
private var textureId: Int64 = -1
|
||||
|
||||
/// Serial queue for the `latestPixelBuffer` swap. Sample-buffer
|
||||
/// delegate writes; `copyPixelBuffer()` reads. Without this the
|
||||
/// pointer could be freed mid-read on a different thread.
|
||||
private let bufferQueue = DispatchQueue(
|
||||
label: "ux.camera.preview.buffer",
|
||||
qos: .userInitiated
|
||||
)
|
||||
private var latestPixelBuffer: CVPixelBuffer?
|
||||
|
||||
func register(with registry: FlutterTextureRegistry) -> Int64 {
|
||||
self.registry = registry
|
||||
textureId = registry.register(self)
|
||||
return textureId
|
||||
}
|
||||
|
||||
func unregister() {
|
||||
registry?.unregisterTexture(textureId)
|
||||
bufferQueue.sync { latestPixelBuffer = nil }
|
||||
}
|
||||
|
||||
// MARK: - FlutterTexture
|
||||
|
||||
func copyPixelBuffer() -> Unmanaged<CVPixelBuffer>? {
|
||||
var pb: CVPixelBuffer?
|
||||
bufferQueue.sync {
|
||||
pb = latestPixelBuffer
|
||||
latestPixelBuffer = nil
|
||||
}
|
||||
if let pb = pb { return Unmanaged.passRetained(pb) }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Receives a new frame. Called from the
|
||||
/// `AVCaptureVideoDataOutputSampleBufferDelegate` on
|
||||
/// `videoBufferQueue`. Cheap — just swaps the pointer + notifies
|
||||
/// the registry.
|
||||
func receive(sampleBuffer: CMSampleBuffer) {
|
||||
guard let pb = CMSampleBufferGetImageBuffer(sampleBuffer) else {
|
||||
return
|
||||
}
|
||||
bufferQueue.sync { latestPixelBuffer = pb }
|
||||
if let registry = registry {
|
||||
registry.textureFrameAvailable(textureId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,563 +0,0 @@
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
/// Owns one `AVAssetWriter`. Mirrors telegram-ios's
|
||||
/// [VideoRecorder.swift](file:///Users/agra/projects/telegram-ios/submodules/Camera/Sources/VideoRecorder.swift)
|
||||
/// state machine closely — lazy per-type input creation,
|
||||
/// gated `startWriting`, pending-audio-buffer queue:
|
||||
///
|
||||
/// 1. `start()` only creates the `AVAssetWriter` shell and sets
|
||||
/// `recordingStartSampleTime` to wall-clock now. No inputs yet.
|
||||
/// 2. First **video** sample → create `videoInput` from its
|
||||
/// `CMFormatDescription` (`sourceFormatHint:`) + transform.
|
||||
/// Pre-`startWriting` because audio input may still be pending.
|
||||
/// 3. First **audio** sample (if `hasAudio`) → create `audioInput`
|
||||
/// with sample-rate / channel-layout extracted from the audio
|
||||
/// `CMFormatDescription` merged into `baseAudioSettings`
|
||||
/// (`recommendedAudioSettingsForAssetWriter`).
|
||||
/// 4. Next video sample arrives with both inputs added →
|
||||
/// `assetWriter.startWriting()`. Sample is dropped (telegram's
|
||||
/// behaviour — initial frame loss is acceptable, the writer needs
|
||||
/// one cycle to settle).
|
||||
/// 5. Subsequent video sample → `startSession(atSourceTime: pts)`,
|
||||
/// `recordingStartSampleTime = pts`. Appends begin.
|
||||
/// 6. Audio samples that arrive before `recordingStartSampleTime` is
|
||||
/// set are queued in `pendingAudioSampleBuffers`. After each
|
||||
/// successful video append, the queue is drained for samples whose
|
||||
/// `endTime <= lastVideoSampleTime`.
|
||||
/// 7. `stop()` sets `recordingStopSampleTime` to wall-clock now.
|
||||
/// Sample callbacks set `hasAllVideoBuffers` / `hasAllAudioBuffers`
|
||||
/// when their PTS crosses the stop time. `maybeFinish()` runs when
|
||||
/// both flags are set, gates `finishWriting` on
|
||||
/// `writer.status == .writing`. If audio never arrived, the audio
|
||||
/// flag is set synchronously in `stop()` so the video side can
|
||||
/// complete on its own.
|
||||
///
|
||||
/// Sample-count diagnostics emit via [onDiagnostic] at each major
|
||||
/// checkpoint so the operator can verify "audio actually captured"
|
||||
/// without instrumenting the call sites. The closure is wired to the
|
||||
/// Dart-side `Log.tag('camera').i(...)` via the `ux/camera/events`
|
||||
/// channel — visible in `~/banlu/tools/log_server/data/banlu.jsonl`.
|
||||
final class VideoRecorder {
|
||||
/// Maps Flutter's `DeviceOrientation` to the rotation transform
|
||||
/// embedded as `AVAssetWriterInput.transform`. Source buffers
|
||||
/// are portrait-shape (see [CameraInstance.applyVideoOrientationOnPreview]),
|
||||
/// so the table assumes portrait source — see
|
||||
/// [CameraOrientationTests] for the four cases.
|
||||
public static func transform(
|
||||
for orientation: DeviceOrientationFlutter
|
||||
) -> CGAffineTransform {
|
||||
switch orientation {
|
||||
case .portraitUp: return .identity
|
||||
case .portraitDown: return CGAffineTransform(rotationAngle: .pi)
|
||||
case .landscapeLeft: return CGAffineTransform(rotationAngle: -.pi / 2)
|
||||
case .landscapeRight: return CGAffineTransform(rotationAngle: .pi / 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - immutable config
|
||||
|
||||
private let url: URL
|
||||
private let videoTransform: CGAffineTransform
|
||||
private let hasAudio: Bool
|
||||
private let baseVideoSettings: [String: Any]?
|
||||
private let baseAudioSettings: [String: Any]
|
||||
private let recorderQueue: DispatchQueue
|
||||
|
||||
// MARK: - mutable state (always touched on recorderQueue)
|
||||
|
||||
private var writer: AVAssetWriter?
|
||||
private var videoInput: AVAssetWriterInput?
|
||||
private var audioInput: AVAssetWriterInput?
|
||||
|
||||
/// Wall-clock "start" time set by [start], then overwritten to the
|
||||
/// first video sample's PTS once the session is started. Used to
|
||||
/// gate samples whose PTS is older than start.
|
||||
private var recordingStartSampleTime: CMTime = .invalid
|
||||
|
||||
/// Set by [stop]. Samples whose PTS crosses this set the matching
|
||||
/// `hasAllXBuffers` flag and trigger [maybeFinish].
|
||||
private var recordingStopSampleTime: CMTime = .invalid
|
||||
|
||||
/// PTS of the last video sample successfully appended. Used to
|
||||
/// gate audio drains (audio samples whose `endTime` exceeds this
|
||||
/// stay queued until video catches up).
|
||||
private var lastVideoSampleTime: CMTime = .invalid
|
||||
|
||||
private var startedSession = false
|
||||
private var stopped = false
|
||||
private var hasAllVideoBuffers = false
|
||||
private var hasAllAudioBuffers = false
|
||||
private var failed = false
|
||||
|
||||
/// Audio samples arriving before video has caught up. Drained
|
||||
/// after each successful video append.
|
||||
private var pendingAudioSampleBuffers: [CMSampleBuffer] = []
|
||||
|
||||
private var completion: ((Result<URL, NSError>) -> Void)?
|
||||
|
||||
// MARK: - diagnostics (emit via [onDiagnostic] → ux.Log)
|
||||
|
||||
private func diag(_ message: String) {
|
||||
onDiagnostic?(message)
|
||||
}
|
||||
|
||||
private var videoReceived: Int = 0
|
||||
private var videoAppended: Int = 0
|
||||
private var audioReceived: Int = 0
|
||||
private var audioAppended: Int = 0
|
||||
private var audioQueued: Int = 0
|
||||
|
||||
/// Set by [CameraInstance] to ship diagnostic messages over the
|
||||
/// `ux/camera/events` channel as `{event: "diagnostic"}`. The
|
||||
/// Dart-side controller turns those into `Log.tag('camera').i(...)`
|
||||
/// — so they land in the log_server pipeline and can be tailed
|
||||
/// from `~/banlu/tools/log_server/data/banlu.jsonl`.
|
||||
var onDiagnostic: ((String) -> Void)?
|
||||
|
||||
// MARK: - init / start
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
orientation: DeviceOrientationFlutter,
|
||||
hasAudio: Bool,
|
||||
baseVideoSettings: [String: Any]?,
|
||||
baseAudioSettings: [String: Any],
|
||||
recorderQueue: DispatchQueue
|
||||
) {
|
||||
self.url = url
|
||||
self.videoTransform = VideoRecorder.transform(for: orientation)
|
||||
self.hasAudio = hasAudio
|
||||
self.baseVideoSettings = baseVideoSettings
|
||||
self.baseAudioSettings = baseAudioSettings
|
||||
self.recorderQueue = recorderQueue
|
||||
}
|
||||
|
||||
/// Open the file. Inputs are created lazily on the first sample
|
||||
/// of each type — see class doc. Throws on `AVAssetWriter`
|
||||
/// allocation failure (typically a path / file-system issue).
|
||||
func start() throws {
|
||||
let writer = try AVAssetWriter(url: url, fileType: .mp4)
|
||||
self.writer = writer
|
||||
// Sentinel until the first video sample's PTS overwrites it —
|
||||
// see [handleVideo] when it calls `writer.startSession`.
|
||||
recordingStartSampleTime = CMTime(
|
||||
seconds: CACurrentMediaTime(),
|
||||
preferredTimescale: CMTimeScale(NSEC_PER_SEC)
|
||||
)
|
||||
diag("start: file=\(url.lastPathComponent) hasAudio=\(hasAudio)")
|
||||
}
|
||||
|
||||
/// Hard cancel — drop pending audio, `cancelWriting` if the writer
|
||||
/// is writing, delete the partial file. Mirrors telegram-ios's
|
||||
/// [`VideoRecorder.cancelRecording`](file:///Users/agra/projects/telegram-ios/submodules/Camera/Sources/VideoRecorder.swift#L329).
|
||||
/// Used by [CameraInstance.dispose] when a recording is in flight
|
||||
/// at teardown — there's no caller to deliver the file to, so no
|
||||
/// reason to wait for `finishWriting` to flush.
|
||||
func cancel(completion: (() -> Void)? = nil) {
|
||||
recorderQueue.async {
|
||||
if self.stopped || self.failed {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
self.stopped = true
|
||||
self.pendingAudioSampleBuffers = []
|
||||
if let writer = self.writer, writer.status == .writing {
|
||||
writer.cancelWriting()
|
||||
}
|
||||
try? FileManager.default.removeItem(at: self.url)
|
||||
self.diag("cancel: vRecv=\(self.videoReceived) aRecv=\(self.audioReceived)")
|
||||
// Resolve any pending stop() completion so the caller's
|
||||
// Future doesn't dangle.
|
||||
if let cb = self.completion {
|
||||
self.completion = nil
|
||||
cb(.failure(NSError(
|
||||
domain: "ux.camera", code: -12,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Recording cancelled"]
|
||||
)))
|
||||
}
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop. Sets `recordingStopSampleTime` so the next video / audio
|
||||
/// sample crossing it flips the matching `hasAllXBuffers` flag,
|
||||
/// which triggers `maybeFinish` → `finishWriting`. Completion
|
||||
/// fires once when the writer finishes.
|
||||
///
|
||||
/// Idempotent: a second call while a stop is already in flight is
|
||||
/// silently dropped.
|
||||
func stop(completion: @escaping (Result<URL, NSError>) -> Void) {
|
||||
recorderQueue.async {
|
||||
if self.completion != nil { return }
|
||||
self.completion = completion
|
||||
|
||||
let stopTime = CMTime(
|
||||
seconds: CACurrentMediaTime(),
|
||||
preferredTimescale: CMTimeScale(NSEC_PER_SEC)
|
||||
)
|
||||
self.recordingStopSampleTime = stopTime
|
||||
|
||||
self.diag("stop: vRecv=\(self.videoReceived) vApp=\(self.videoAppended)"
|
||||
+ " aRecv=\(self.audioReceived) aApp=\(self.audioAppended)"
|
||||
+ " aQueued=\(self.pendingAudioSampleBuffers.count)")
|
||||
|
||||
// Nothing ever arrived — no sample callback will ever
|
||||
// trigger `maybeFinish`. Cancel the writer instead.
|
||||
if !self.startedSession {
|
||||
self.writer?.cancelWriting()
|
||||
self.failed = true
|
||||
self.deliver(.failure(NSError(
|
||||
domain: "ux.camera", code: -11,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey:
|
||||
"Recording stopped before any samples were written"
|
||||
]
|
||||
)))
|
||||
return
|
||||
}
|
||||
|
||||
// No audio path (mic permission denied, etc.) — the audio
|
||||
// side is "drained" by definition. `maybeFinish` then only
|
||||
// waits for the next video sample whose PTS crosses
|
||||
// `stopTime` (~one frame later, ~33ms at 30fps).
|
||||
if self.audioInput == nil || self.audioReceived == 0 {
|
||||
self.hasAllAudioBuffers = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - sample append (from videoBufferQueue / audioBufferQueue)
|
||||
|
||||
func appendVideo(_ sampleBuffer: CMSampleBuffer) {
|
||||
recorderQueue.async { self.handleVideo(sampleBuffer) }
|
||||
}
|
||||
|
||||
func appendAudio(_ sampleBuffer: CMSampleBuffer) {
|
||||
recorderQueue.async { self.handleAudio(sampleBuffer) }
|
||||
}
|
||||
|
||||
// MARK: - recorderQueue handlers
|
||||
|
||||
private func handleVideo(_ sampleBuffer: CMSampleBuffer) {
|
||||
guard !stopped, !failed else { return }
|
||||
guard let writer = writer else { return }
|
||||
guard
|
||||
let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer),
|
||||
CMFormatDescriptionGetMediaType(formatDescription) == kCMMediaType_Video
|
||||
else { return }
|
||||
|
||||
videoReceived += 1
|
||||
let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
||||
|
||||
// 1. Lazy create the video input on first video sample, with
|
||||
// the buffer's format description as `sourceFormatHint`.
|
||||
if videoInput == nil {
|
||||
let videoSettings = baseVideoSettings ?? [:]
|
||||
if writer.canApply(outputSettings: videoSettings, forMediaType: .video) {
|
||||
let input = AVAssetWriterInput(
|
||||
mediaType: .video,
|
||||
outputSettings: videoSettings,
|
||||
sourceFormatHint: formatDescription
|
||||
)
|
||||
input.expectsMediaDataInRealTime = true
|
||||
input.transform = videoTransform
|
||||
if writer.canAdd(input) {
|
||||
writer.add(input)
|
||||
videoInput = input
|
||||
diag("video input added")
|
||||
} else {
|
||||
fail(NSError(domain: "ux.camera", code: -30,
|
||||
userInfo: [NSLocalizedDescriptionKey: "canAdd videoInput failed"]))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
fail(NSError(domain: "ux.camera", code: -31,
|
||||
userInfo: [NSLocalizedDescriptionKey: "canApply videoSettings failed"]))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Writer state machine
|
||||
if writer.status == .unknown {
|
||||
// Drop samples that arrived BEFORE the wall-clock start
|
||||
// (rare, but happens if the session was already running
|
||||
// before start() was called).
|
||||
if presentationTime < recordingStartSampleTime {
|
||||
return
|
||||
}
|
||||
// Only start the writer when ALL needed inputs are ready.
|
||||
if videoInput != nil && (audioInput != nil || !hasAudio) {
|
||||
if !writer.startWriting() {
|
||||
fail(writer.error)
|
||||
return
|
||||
}
|
||||
diag("startWriting")
|
||||
}
|
||||
// Drop this sample regardless — the writer needs a cycle
|
||||
// to settle. Next sample will hit the `.writing` branch.
|
||||
return
|
||||
} else if writer.status == .writing && !startedSession {
|
||||
writer.startSession(atSourceTime: presentationTime)
|
||||
recordingStartSampleTime = presentationTime
|
||||
lastVideoSampleTime = presentationTime
|
||||
startedSession = true
|
||||
diag(String(format: "startSession at %.3fs", presentationTime.seconds))
|
||||
}
|
||||
|
||||
// Drop pre-start samples (post-startSession).
|
||||
if recordingStartSampleTime == .invalid
|
||||
|| presentationTime < recordingStartSampleTime {
|
||||
return
|
||||
}
|
||||
|
||||
if writer.status == .writing && startedSession {
|
||||
// 3. Stop-time gating — set hasAllVideoBuffers when we
|
||||
// see a sample past stop time, trigger finish.
|
||||
if recordingStopSampleTime.isValid
|
||||
&& presentationTime > recordingStopSampleTime {
|
||||
hasAllVideoBuffers = true
|
||||
maybeFinish()
|
||||
return
|
||||
}
|
||||
|
||||
guard let input = videoInput else { return }
|
||||
// Busy-wait briefly if the input isn't ready. Matches
|
||||
// telegram-ios's pattern at VideoRecorder.swift:202-206.
|
||||
// Real-time capture; we can't backpressure the camera.
|
||||
while !input.isReadyForMoreMediaData {
|
||||
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
}
|
||||
|
||||
if input.append(sampleBuffer) {
|
||||
lastVideoSampleTime = presentationTime
|
||||
videoAppended += 1
|
||||
}
|
||||
|
||||
// 4. Drain any pending audio whose endTime now fits
|
||||
// under lastVideoSampleTime.
|
||||
if !tryAppendingPendingAudioBuffers() {
|
||||
fail(writer.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAudio(_ sampleBuffer: CMSampleBuffer) {
|
||||
guard !stopped, !failed, hasAudio else { return }
|
||||
guard let writer = writer else { return }
|
||||
guard
|
||||
let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer),
|
||||
CMFormatDescriptionGetMediaType(formatDescription) == kCMMediaType_Audio
|
||||
else { return }
|
||||
|
||||
audioReceived += 1
|
||||
|
||||
// 1. Lazy create audio input on first audio sample, with
|
||||
// sample-rate / channel-layout extracted from the
|
||||
// sample's CMAudioFormatDescription.
|
||||
if audioInput == nil {
|
||||
var audioSettings = baseAudioSettings
|
||||
|
||||
if let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription) {
|
||||
audioSettings[AVSampleRateKey] = asbd.pointee.mSampleRate
|
||||
audioSettings[AVNumberOfChannelsKey] = asbd.pointee.mChannelsPerFrame
|
||||
}
|
||||
|
||||
var channelLayoutSize: Int = 0
|
||||
let channelLayoutPtr = CMAudioFormatDescriptionGetChannelLayout(
|
||||
formatDescription, sizeOut: &channelLayoutSize
|
||||
)
|
||||
let channelLayoutData: Data
|
||||
if let ptr = channelLayoutPtr, channelLayoutSize > 0 {
|
||||
channelLayoutData = Data(bytes: ptr, count: channelLayoutSize)
|
||||
} else {
|
||||
channelLayoutData = Data()
|
||||
}
|
||||
audioSettings[AVChannelLayoutKey] = channelLayoutData
|
||||
|
||||
if writer.canApply(outputSettings: audioSettings, forMediaType: .audio) {
|
||||
let input = AVAssetWriterInput(
|
||||
mediaType: .audio,
|
||||
outputSettings: audioSettings,
|
||||
sourceFormatHint: formatDescription
|
||||
)
|
||||
input.expectsMediaDataInRealTime = true
|
||||
if writer.canAdd(input) {
|
||||
writer.add(input)
|
||||
audioInput = input
|
||||
diag("audio input added"
|
||||
+ " sr=\(audioSettings[AVSampleRateKey] ?? "?")"
|
||||
+ " ch=\(audioSettings[AVNumberOfChannelsKey] ?? "?")")
|
||||
} else {
|
||||
diag("canAdd audioInput failed")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
diag("canApply audioSettings failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Need the video stream to have given us a session start
|
||||
// time before any audio can be appended.
|
||||
if recordingStartSampleTime == .invalid { return }
|
||||
|
||||
let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
||||
if presentationTime < recordingStartSampleTime { return }
|
||||
|
||||
// 3. Stop-time gating.
|
||||
if recordingStopSampleTime.isValid
|
||||
&& presentationTime > recordingStopSampleTime {
|
||||
hasAllAudioBuffers = true
|
||||
maybeFinish()
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Append (or queue) — drain pending first, then this
|
||||
// sample. tryAppendingAudioSampleBuffer chooses queue vs
|
||||
// immediate-append based on its endTime vs lastVideoSampleTime.
|
||||
if !tryAppendingPendingAudioBuffers()
|
||||
|| !tryAppendingAudioSampleBuffer(sampleBuffer) {
|
||||
fail(writer.error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - audio buffer queue
|
||||
|
||||
/// Append [sampleBuffer] immediately if its `endTime` doesn't
|
||||
/// run past the latest video sample; otherwise enqueue.
|
||||
private func tryAppendingAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer) -> Bool {
|
||||
if sampleBuffer.endTime > lastVideoSampleTime {
|
||||
pendingAudioSampleBuffers.append(sampleBuffer)
|
||||
audioQueued += 1
|
||||
return true
|
||||
}
|
||||
return internalAppendAudioSampleBuffer(sampleBuffer)
|
||||
}
|
||||
|
||||
/// Drain queued audio samples that have caught up to the latest
|
||||
/// video sample. Called after every video append.
|
||||
private func tryAppendingPendingAudioBuffers() -> Bool {
|
||||
guard !pendingAudioSampleBuffers.isEmpty else { return true }
|
||||
|
||||
var stillPending: [CMSampleBuffer] = []
|
||||
stillPending.reserveCapacity(pendingAudioSampleBuffers.count)
|
||||
var ok = true
|
||||
for sample in pendingAudioSampleBuffers {
|
||||
if !ok {
|
||||
stillPending.append(sample)
|
||||
continue
|
||||
}
|
||||
if sample.endTime <= lastVideoSampleTime {
|
||||
if !internalAppendAudioSampleBuffer(sample) {
|
||||
ok = false
|
||||
}
|
||||
} else {
|
||||
stillPending.append(sample)
|
||||
}
|
||||
}
|
||||
pendingAudioSampleBuffers = stillPending
|
||||
return ok
|
||||
}
|
||||
|
||||
private func internalAppendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer) -> Bool {
|
||||
guard startedSession, let input = audioInput else { return true }
|
||||
while !input.isReadyForMoreMediaData {
|
||||
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
}
|
||||
if input.append(sampleBuffer) {
|
||||
audioAppended += 1
|
||||
return true
|
||||
}
|
||||
if writer?.error != nil {
|
||||
return false
|
||||
}
|
||||
// Append returned false but no writer error — treat as
|
||||
// recoverable. Telegram does the same.
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - finish
|
||||
|
||||
private func maybeFinish() {
|
||||
guard hasAllVideoBuffers,
|
||||
(!hasAudio || hasAllAudioBuffers),
|
||||
!stopped, !failed else { return }
|
||||
stopped = true
|
||||
finish()
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
// Drain any audio buffer still pending up to the stop time.
|
||||
_ = tryAppendingPendingAudioBuffers()
|
||||
|
||||
guard let writer = writer else {
|
||||
deliver(.failure(NSError(
|
||||
domain: "ux.camera", code: -40,
|
||||
userInfo: [NSLocalizedDescriptionKey: "writer missing on finish"]
|
||||
)))
|
||||
return
|
||||
}
|
||||
|
||||
// Only `finishWriting` when the writer reached `.writing`.
|
||||
guard writer.status == .writing else {
|
||||
diag("finish skipped — writer.status=\(writer.status.rawValue)")
|
||||
failOnError(writer.error)
|
||||
return
|
||||
}
|
||||
|
||||
let url = self.url
|
||||
diag("finishWriting:"
|
||||
+ " vRecv=\(videoReceived) vApp=\(videoAppended)"
|
||||
+ " aRecv=\(audioReceived) aApp=\(audioAppended)"
|
||||
+ " aQueuedDrop=\(pendingAudioSampleBuffers.count)")
|
||||
|
||||
writer.finishWriting { [weak self] in
|
||||
self?.recorderQueue.async {
|
||||
guard let self = self else { return }
|
||||
if writer.status == .completed {
|
||||
self.deliver(.success(url))
|
||||
} else {
|
||||
self.failOnError(writer.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fail(_ error: Error?) {
|
||||
if failed { return }
|
||||
failed = true
|
||||
failOnError(error)
|
||||
}
|
||||
|
||||
private func failOnError(_ error: Error?) {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
let ns = (error as NSError?) ?? NSError(
|
||||
domain: "ux.camera", code: -41,
|
||||
userInfo: [NSLocalizedDescriptionKey: "AVAssetWriter failed"]
|
||||
)
|
||||
deliver(.failure(ns))
|
||||
}
|
||||
|
||||
private func deliver(_ outcome: Result<URL, NSError>) {
|
||||
let cb = completion
|
||||
completion = nil
|
||||
cb?(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CMSampleBuffer ergonomics
|
||||
|
||||
private extension CMSampleBuffer {
|
||||
/// `presentationTime + duration` — last instant covered by this
|
||||
/// buffer. Used to pace audio against video.
|
||||
var endTime: CMTime {
|
||||
let pts = CMSampleBufferGetPresentationTimeStamp(self)
|
||||
let dur = CMSampleBufferGetDuration(self)
|
||||
if dur.flags.contains(.valid) {
|
||||
return pts + dur
|
||||
}
|
||||
return pts
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,12 @@ Pod::Spec.new do |s|
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Swipelab' => 'hello@swipelab.co' }
|
||||
s.source = { :path => '.' }
|
||||
# Mirror the shared `darwin/Camera/` Swift files into a local
|
||||
# `Classes/Camera-shared/` so CocoaPods picks them up via the
|
||||
# normal glob — neither symlinks nor `../` escapes work
|
||||
# (Pathname.glob bails on both). The mirror runs on every
|
||||
# `pod install`; the destination is `.gitignore`'d.
|
||||
s.prepare_command = 'rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared'
|
||||
s.source_files = 'Classes/**/*.swift'
|
||||
s.frameworks = ['AVFoundation', 'CoreMedia', 'CoreVideo', 'Photos', 'PhotosUI']
|
||||
s.dependency 'Flutter'
|
||||
|
||||
Reference in New Issue
Block a user