add plugin camera desktop

This commit is contained in:
kyo
2026-06-22 09:39:31 +07:00
parent 92daeef0c5
commit 5407afbd6e
144 changed files with 15900 additions and 1 deletions

View File

@@ -0,0 +1,21 @@
Pod::Spec.new do |s|
s.name = 'camera_desktop'
s.version = '1.2.1'
s.summary = 'Flutter camera plugin for macOS using AVFoundation.'
s.description = <<-DESC
A Flutter camera plugin for desktop platforms. On macOS, uses AVFoundation
for camera capture, preview, photo capture, and video recording.
DESC
s.homepage = 'https://github.com/hugocornellier/camera_desktop'
s.license = { :type => 'MIT', :file => '../LICENSE' }
s.author = { 'Hugo Cornellier' => 'hugo@hugocornellier.com' }
s.source = { :http => 'https://github.com/hugocornellier/camera_desktop' }
s.source_files = 'camera_desktop/Sources/camera_desktop/**/*.{swift,h,m}'
s.dependency 'FlutterMacOS'
s.platform = :osx, '10.15'
s.swift_version = '5.0'
s.resource_bundles = { 'camera_desktop_privacy' => ['camera_desktop/Sources/camera_desktop/PrivacyInfo.xcprivacy'] }
s.frameworks = 'AVFoundation', 'CoreMedia', 'CoreVideo', 'CoreImage', 'QuartzCore'
end

View File

@@ -0,0 +1,27 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "camera_desktop",
platforms: [
.macOS("10.15")
],
products: [
.library(name: "camera-desktop", targets: ["camera_desktop"])
],
dependencies: [
.package(name: "FlutterFramework", path: "../FlutterFramework")
],
targets: [
.target(
name: "camera_desktop",
dependencies: [
.product(name: "FlutterFramework", package: "FlutterFramework")
],
resources: [
.process("PrivacyInfo.xcprivacy"),
]
)
]
)

View File

@@ -0,0 +1,29 @@
import AVFoundation
extension AVCaptureDevice {
/// Returns all capture devices matching the given media type.
/// Uses DiscoverySession on macOS 10.15+.
static func captureDevices(mediaType: AVMediaType) -> [AVCaptureDevice] {
let deviceTypes: [AVCaptureDevice.DeviceType]
if mediaType == .video {
if #available(macOS 14.0, *) {
deviceTypes = [.builtInWideAngleCamera, .external]
} else {
deviceTypes = [.builtInWideAngleCamera, .externalUnknown]
}
} else {
if #available(macOS 14.0, *) {
deviceTypes = [.microphone]
} else {
deviceTypes = [.builtInMicrophone]
}
}
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: deviceTypes,
mediaType: mediaType,
position: .unspecified
)
return session.devices
}
}

View File

@@ -0,0 +1,319 @@
import FlutterMacOS
import AVFoundation
/// Flutter plugin entry point for camera_desktop on macOS.
///
/// Routes MethodChannel calls to the appropriate CameraSession instance.
/// Speaks the exact same protocol as the Linux native side so the shared
/// Dart CameraDesktopPlugin class works on both platforms.
public class CameraDesktopPlugin: NSObject, FlutterPlugin, NSApplicationDelegate {
private var sessions: [Int: CameraSession] = [:]
private let sessionsLock = UnfairLock()
private var nextCameraId = 1
private let textureRegistry: FlutterTextureRegistry
private let methodChannel: FlutterMethodChannel
init(textureRegistry: FlutterTextureRegistry, methodChannel: FlutterMethodChannel) {
self.textureRegistry = textureRegistry
self.methodChannel = methodChannel
super.init()
}
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "plugins.flutter.io/camera_desktop",
binaryMessenger: registrar.messenger
)
let instance = CameraDesktopPlugin(
textureRegistry: registrar.textures,
methodChannel: channel
)
registrar.addMethodCallDelegate(instance, channel: channel)
registrar.addApplicationDelegate(instance)
}
deinit {
disposeAllSessions()
}
/// Called by the Flutter engine when it is being detached/destroyed.
///
/// Note: on macOS this does NOT reliably fire during hot restart, but it
/// may fire during other teardown paths. Kept as defense-in-depth.
public func detachFromEngine(for registrar: FlutterPluginRegistrar) {
disposeAllSessions()
}
/// Called by NSApplication on normal app termination.
public func applicationWillTerminate(_ notification: Notification) {
disposeAllSessions()
}
private func disposeAllSessions() {
sessionsLock.lock()
let snapshot = sessions
sessions.removeAll()
sessionsLock.unlock()
for (cameraId, session) in snapshot {
ImageStreamHandleBridge.releaseHandles(forCameraId: cameraId)
session.dispose()
}
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "availableCameras":
handleAvailableCameras(result: result)
case "getPlatformCapabilities":
handleGetPlatformCapabilities(result: result)
case "create":
handleCreate(call: call, result: result)
case "initialize":
handleInitialize(call: call, result: result)
case "takePicture":
handleTakePicture(call: call, result: result)
case "startVideoRecording":
handleStartVideoRecording(call: call, result: result)
case "stopVideoRecording":
handleStopVideoRecording(call: call, result: result)
case "startImageStream":
handleStartImageStream(call: call, result: result)
case "stopImageStream":
handleStopImageStream(call: call, result: result)
case "pausePreview":
handlePausePreview(call: call, result: result)
case "resumePreview":
handleResumePreview(call: call, result: result)
case "setMirror":
handleSetMirror(call: call, result: result)
case "dispose":
handleDispose(call: call, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
// MARK: - Method Handlers
private func handleGetPlatformCapabilities(result: @escaping FlutterResult) {
result([
"supportsMirrorControl": true,
"supportsVideoFpsControl": true,
"supportsVideoBitrateControl": true,
])
}
private func handleAvailableCameras(result: @escaping FlutterResult) {
DispatchQueue.global(qos: .userInitiated).async {
let devices = DeviceEnumerator.enumerateDevices()
let list = devices.map { device -> [String: Any] in
return [
"name": device.name,
"lensDirection": device.lensDirection,
"sensorOrientation": device.sensorOrientation,
]
}
DispatchQueue.main.async {
result(list)
}
}
}
private func handleCreate(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let cameraName = args["cameraName"] as? String,
let resolutionPreset = args["resolutionPreset"] as? Int else {
result(FlutterError(code: "invalid_args",
message: "Missing required arguments for create",
details: nil))
return
}
let enableAudio = args["enableAudio"] as? Bool ?? false
var targetFps = 30
if let fps = args["fps"] as? Int {
targetFps = fps
} else if let fps = args["fps"] as? Double {
targetFps = Int(fps)
}
let rawFps = targetFps
if targetFps < 5 { targetFps = 5 }
if targetFps > 60 { targetFps = 60 }
if targetFps != rawFps {
}
var targetBitrate = 0
if let bitrate = args["videoBitrate"] as? Int {
targetBitrate = bitrate
} else if let bitrate = args["videoBitrate"] as? Double {
targetBitrate = Int(bitrate)
}
let rawBitrate = targetBitrate
if targetBitrate < 0 { targetBitrate = 0 }
if targetBitrate != rawBitrate {
}
var targetAudioBitrate = 0
if let bitrate = args["audioBitrate"] as? Int {
targetAudioBitrate = bitrate
} else if let bitrate = args["audioBitrate"] as? Double {
targetAudioBitrate = Int(bitrate)
}
let rawAudioBitrate = targetAudioBitrate
if targetAudioBitrate < 0 { targetAudioBitrate = 0 }
if targetAudioBitrate != rawAudioBitrate {
}
// Extract device ID from camera name: "Friendly Name (deviceId)"
guard let deviceId = DeviceEnumerator.extractDeviceId(from: cameraName) else {
result(FlutterError(code: "invalid_camera_name",
message: "Could not extract device ID from camera name",
details: nil))
return
}
let cameraId = nextCameraId
nextCameraId += 1
let config = CameraSession.CameraConfig(
deviceId: deviceId,
resolutionPreset: resolutionPreset,
enableAudio: enableAudio,
targetFps: targetFps,
targetBitrate: targetBitrate,
audioBitrate: targetAudioBitrate
)
let session = CameraSession(
cameraId: cameraId,
config: config,
textureRegistry: textureRegistry,
methodChannel: methodChannel
)
let textureId = session.registerTexture()
if textureId < 0 {
result(FlutterError(code: "texture_registration_failed",
message: "Failed to register Flutter texture",
details: nil))
return
}
sessionsLock.lock()
sessions[cameraId] = session
sessionsLock.unlock()
result([
"cameraId": cameraId,
"textureId": textureId,
])
}
private func handleInitialize(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let session = findSession(call: call, result: result) else { return }
session.initialize(result: result)
}
private func handleTakePicture(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let session = findSession(call: call, result: result) else { return }
session.takePicture(result: result)
}
private func handleStartVideoRecording(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let session = findSession(call: call, result: result) else { return }
session.startVideoRecording(result: result)
}
private func handleStopVideoRecording(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let session = findSession(call: call, result: result) else { return }
session.stopVideoRecording(result: result)
}
private func handleStartImageStream(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let session = findSession(call: call, result: result) else { return }
session.startImageStream()
let streamHandle = ImageStreamHandleBridge.registerSession(session)
result(["streamHandle": streamHandle])
}
private func handleStopImageStream(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let session = findSession(call: call, result: result) else { return }
if let args = call.arguments as? [String: Any] {
if let streamHandle = args["streamHandle"] as? Int64 {
ImageStreamHandleBridge.releaseHandle(streamHandle)
} else if let streamHandleInt = args["streamHandle"] as? Int {
ImageStreamHandleBridge.releaseHandle(Int64(streamHandleInt))
}
}
// Reply only after the native buffer free has actually completed, so
// Dart's `await stopImageStream` resolves once the memory is reclaimed.
session.stopImageStream {
result(nil)
}
}
private func handlePausePreview(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let session = findSession(call: call, result: result) else { return }
session.pausePreview()
result(nil)
}
private func handleResumePreview(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let session = findSession(call: call, result: result) else { return }
session.resumePreview()
result(nil)
}
private func handleSetMirror(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let session = findSession(call: call, result: result) else { return }
guard let args = call.arguments as? [String: Any],
let mirrored = args["mirrored"] as? Bool else {
result(FlutterError(code: "invalid_args",
message: "Missing 'mirrored' argument",
details: nil))
return
}
session.setMirror(mirrored: mirrored)
result(nil)
}
private func handleDispose(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let cameraId = args["cameraId"] as? Int else {
result(nil)
return
}
sessionsLock.lock()
let session = sessions.removeValue(forKey: cameraId)
sessionsLock.unlock()
ImageStreamHandleBridge.releaseHandles(forCameraId: cameraId)
session?.dispose()
result(nil)
}
// MARK: - Helpers
private func findSession(call: FlutterMethodCall,
result: @escaping FlutterResult) -> CameraSession? {
guard let args = call.arguments as? [String: Any],
let cameraId = args["cameraId"] as? Int else {
result(FlutterError(code: "invalid_args",
message: "Missing cameraId argument",
details: nil))
return nil
}
guard let session = sessions[cameraId] else {
result(FlutterError(code: "camera_not_found",
message: "No camera found with the given ID",
details: nil))
return nil
}
return session
}
}

View File

@@ -0,0 +1,570 @@
import AVFoundation
import FlutterMacOS
import QuartzCore
// ImageStreamFFI lives in ImageStreamFFI.swift.
/// Manages a single camera session, AVCaptureSession lifecycle, preview texture,
/// photo capture, video recording, and image streaming.
///
/// One CameraSession instance exists per active camera (identified by cameraId).
class CameraSession: NSObject {
let cameraId: Int
private(set) var textureId: Int64 = -1
private let config: CameraConfig
private var captureSession: AVCaptureSession?
private var videoDevice: AVCaptureDevice?
private var videoOutput: AVCaptureVideoDataOutput?
private var audioOutput: AVCaptureAudioDataOutput?
private var texture: CameraTexture?
private weak var textureRegistry: FlutterTextureRegistry?
private weak var methodChannel: FlutterMethodChannel?
private let captureQueue = DispatchQueue(label: "com.hugocornellier.camera_desktop.capture")
private let audioQueue = DispatchQueue(label: "com.hugocornellier.camera_desktop.audio")
private let sessionQueue = DispatchQueue(label: "com.hugocornellier.camera_desktop.session")
private let bufferLock = UnfairLock()
private let flagsLock = UnfairLock()
private var lastTextureNotification: CFTimeInterval = 0
private let textureNotificationInterval: CFTimeInterval = 1.0 / 120.0
private var recordHandler = RecordHandler()
private let imageStreamFFI = ImageStreamFFI()
private var _previewPaused = false
private var _imageStreaming = false
private var _isDisposed = false
private var latestBuffer: CVPixelBuffer?
private var previewPaused: Bool {
get { flagsLock.lock(); defer { flagsLock.unlock() }; return _previewPaused }
set { flagsLock.lock(); _previewPaused = newValue; flagsLock.unlock() }
}
private var imageStreaming: Bool {
get { flagsLock.lock(); defer { flagsLock.unlock() }; return _imageStreaming }
set { flagsLock.lock(); _imageStreaming = newValue; flagsLock.unlock() }
}
private var actualWidth: Int = 0
private var actualHeight: Int = 0
private var firstFrameReceived = false
/// Pending initialization result callback, called when the first frame arrives.
private var pendingInitResult: FlutterResult?
struct CameraConfig {
let deviceId: String
let resolutionPreset: Int
let enableAudio: Bool
let targetFps: Int
let targetBitrate: Int
let audioBitrate: Int
}
init(cameraId: Int, config: CameraConfig,
textureRegistry: FlutterTextureRegistry,
methodChannel: FlutterMethodChannel) {
self.cameraId = cameraId
self.config = config
self.textureRegistry = textureRegistry
self.methodChannel = methodChannel
super.init()
}
// MARK: - Texture Registration
/// Registers a FlutterTexture and returns the texture ID.
func registerTexture() -> Int64 {
let tex = CameraTexture()
texture = tex
guard let registry = textureRegistry else { return -1 }
textureId = registry.register(tex)
return textureId
}
// MARK: - Initialization
/// Initializes the AVCaptureSession. Responds asynchronously when the first frame arrives.
func initialize(result: @escaping FlutterResult) {
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
guard let self = self else { return }
if !granted {
DispatchQueue.main.async {
result(FlutterError(code: "permission_denied",
message: "Camera permission was denied",
details: nil))
}
return
}
self.sessionQueue.async {
self.setupSession(result: result)
}
}
}
private func setupSession(result: @escaping FlutterResult) {
let session = AVCaptureSession()
// Find the video device FIRST so we can validate preset support against it.
let devices = AVCaptureDevice.captureDevices(mediaType: .video)
let exactMatch = devices.first(where: { $0.uniqueID == config.deviceId })
if exactMatch == nil {
}
guard let device = exactMatch ?? devices.first else {
DispatchQueue.main.async {
result(FlutterError(code: "no_camera",
message: "No camera device found for ID: \(self.config.deviceId)",
details: nil))
}
return
}
videoDevice = device
// Select preset based on what the device actually supports.
let desiredPreset = DeviceEnumerator.sessionPreset(for: config.resolutionPreset)
let fallbackPresets: [AVCaptureSession.Preset] = [.hd1920x1080, .hd1280x720, .high, .medium]
var chosenPreset: AVCaptureSession.Preset = .medium
if device.supportsSessionPreset(desiredPreset) && session.canSetSessionPreset(desiredPreset) {
chosenPreset = desiredPreset
} else {
for fp in fallbackPresets {
if device.supportsSessionPreset(fp) && session.canSetSessionPreset(fp) {
chosenPreset = fp
break
}
}
}
session.sessionPreset = chosenPreset
// Configure device.
do {
try device.lockForConfiguration()
if device.isFocusModeSupported(.continuousAutoFocus) {
device.focusMode = .continuousAutoFocus
}
if device.isExposureModeSupported(.continuousAutoExposure) {
device.exposureMode = .continuousAutoExposure
}
device.unlockForConfiguration()
} catch {
// Non-fatal, continue with default settings.
}
// Add video input.
do {
let videoInput = try AVCaptureDeviceInput(device: device)
let canAdd = session.canAddInput(videoInput)
guard canAdd else {
DispatchQueue.main.async {
result(FlutterError(code: "input_failed",
message: "canAddInput returned false for device=\(device.uniqueID) preset=\(chosenPreset.rawValue) format=BGRA",
details: nil))
}
return
}
session.addInput(videoInput)
} catch {
let message = error.localizedDescription
DispatchQueue.main.async {
result(FlutterError(code: "input_failed",
message: "Failed to create video input: \(message)",
details: nil))
}
return
}
// Add audio input if enabled.
if config.enableAudio {
let audioDevices = AVCaptureDevice.captureDevices(mediaType: .audio)
let audioDevice = audioDevices.first
if let audioDevice = audioDevice {
do {
let audioInput = try AVCaptureDeviceInput(device: audioDevice)
if session.canAddInput(audioInput) {
session.addInput(audioInput)
}
} catch {
// Non-fatal, continue without audio.
}
}
}
// Add video output.
let vOutput = AVCaptureVideoDataOutput()
vOutput.alwaysDiscardsLateVideoFrames = true
vOutput.videoSettings = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
]
vOutput.setSampleBufferDelegate(self, queue: captureQueue)
let canAddOutput = session.canAddOutput(vOutput)
guard canAddOutput else {
DispatchQueue.main.async {
result(FlutterError(code: "output_failed",
message: "canAddOutput returned false for device=\(device.uniqueID) preset=\(chosenPreset.rawValue) format=BGRA",
details: nil))
}
return
}
session.addOutput(vOutput)
videoOutput = vOutput
// Mirror at the capture source so all consumers get mirrored frames.
if let connection = vOutput.connection(with: .video) {
if connection.isVideoMirroringSupported {
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = true
}
} else {
}
// Add audio output if enabled.
if config.enableAudio {
let aOutput = AVCaptureAudioDataOutput()
aOutput.setSampleBufferDelegate(self, queue: audioQueue)
if session.canAddOutput(aOutput) {
session.addOutput(aOutput)
}
audioOutput = aOutput
}
// Subscribe to runtime error and interruption notifications.
let nc = NotificationCenter.default
nc.addObserver(self,
selector: #selector(sessionRuntimeError(_:)),
name: .AVCaptureSessionRuntimeError,
object: session)
nc.addObserver(self,
selector: #selector(sessionWasInterrupted(_:)),
name: .AVCaptureSessionWasInterrupted,
object: session)
nc.addObserver(self,
selector: #selector(sessionInterruptionEnded(_:)),
name: .AVCaptureSessionInterruptionEnded,
object: session)
captureSession = session
pendingInitResult = result
firstFrameReceived = false
// Start running, the first frame callback will respond to the pending result.
session.startRunning()
// Timeout: if no frame arrives in 15 seconds, fail.
DispatchQueue.main.asyncAfter(deadline: .now() + 15.0) { [weak self] in
guard let self = self, let pending = self.pendingInitResult else { return }
self.pendingInitResult = nil
pending(FlutterError(code: "initialization_timeout",
message: "Camera initialization timed out, no frames received",
details: nil))
}
}
@objc private func sessionRuntimeError(_ notification: Notification) {
let error = notification.userInfo?[AVCaptureSessionErrorKey] as? Error
let message = error?.localizedDescription ?? "Unknown runtime error"
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.methodChannel?.invokeMethod("cameraError", arguments: [
"cameraId": self.cameraId,
"message": message,
])
}
}
@objc private func sessionWasInterrupted(_ notification: Notification) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.methodChannel?.invokeMethod("cameraError", arguments: [
"cameraId": self.cameraId,
"message": "Camera session interrupted",
])
}
}
@objc private func sessionInterruptionEnded(_ notification: Notification) {
}
// MARK: - Photo Capture
func takePicture(result: @escaping FlutterResult) {
bufferLock.lock()
let buffer = latestBuffer
bufferLock.unlock()
guard let buffer = buffer else {
result(FlutterError(code: "no_frame",
message: "No frame available for capture",
details: nil))
return
}
let path = PhotoHandler.generatePath(cameraId: cameraId)
sessionQueue.async {
let success = PhotoHandler.takePicture(from: buffer, outputPath: path)
DispatchQueue.main.async {
if success {
result(path)
} else {
result(FlutterError(code: "capture_failed",
message: "Failed to write JPEG to disk",
details: nil))
}
}
}
}
// MARK: - Video Recording
func startVideoRecording(result: @escaping FlutterResult) {
let enableAudio = config.enableAudio
sessionQueue.async { [self] in
do {
_ = try self.recordHandler.startRecording(
width: self.actualWidth,
height: self.actualHeight,
targetFps: self.config.targetFps,
targetBitrate: self.config.targetBitrate,
audioBitrate: self.config.audioBitrate,
enableAudio: enableAudio
)
DispatchQueue.main.async { result(nil) }
} catch {
let message = error.localizedDescription
DispatchQueue.main.async {
result(FlutterError(code: "recording_failed",
message: "Failed to start recording: \(message)",
details: nil))
}
}
}
}
func stopVideoRecording(result: @escaping FlutterResult) {
guard recordHandler.isRecording else {
result(FlutterError(code: "not_recording",
message: "No recording in progress",
details: nil))
return
}
recordHandler.stopRecording { path in
DispatchQueue.main.async {
if let path = path {
result(path)
} else {
result(FlutterError(code: "recording_failed",
message: "Failed to finalize recording",
details: nil))
}
}
}
}
// MARK: - Image Streaming
func startImageStream() {
imageStreaming = true
}
/// Stops image streaming and reclaims the shared FFI buffers.
///
/// `completion` is invoked (on the main queue) only AFTER the buffers have
/// actually been freed, so the Dart-side `await stopImageStream` resolves
/// only once the memory is gone. This is what closes the fast stop/restart
/// window: were the reply sent before the free ran, a freshly-started
/// poller could obtain and then read a buffer this stop is about to
/// deallocate (stale frame / use-after-free).
///
/// The free is serialized onto the capture queue so it can never race an
/// in-flight writeFrame(): captureOutput() runs on this same serial queue,
/// and future callbacks observe imageStreaming == false and skip
/// writeFrame() entirely, so by the time this block runs no writer is
/// active and none will start.
func stopImageStream(completion: @escaping () -> Void) {
imageStreaming = false
captureQueue.async { [weak self] in
self?.imageStreamFFI.releaseBuffers()
DispatchQueue.main.async {
completion()
}
}
}
// MARK: - FFI Image Stream Access
func getImageStreamBufferPointer() -> UnsafeMutableRawPointer? {
return imageStreamFFI.getBufferPointer()
}
func registerImageStreamCallback(_ callback: @convention(c) (Int32) -> Void) {
imageStreamFFI.registerCallback(callback)
}
func unregisterImageStreamCallback() {
imageStreamFFI.unregisterCallback()
}
// MARK: - Preview Control
func pausePreview() {
previewPaused = true
}
func resumePreview() {
previewPaused = false
}
// MARK: - Mirror Control
/// Toggles horizontal mirroring on the live video output connection.
/// Can be called while the session is running, no restart needed.
func setMirror(mirrored: Bool) {
sessionQueue.async { [self] in
guard let connection = self.videoOutput?.connection(with: .video) else {
return
}
guard connection.isVideoMirroringSupported else {
return
}
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = mirrored
}
}
// MARK: - Disposal
/// Disposes the camera session. Safe to call multiple times (idempotent).
///
/// Synchronously unregisters the FFI callback, stops image streaming, stops
/// the AVCaptureSession (which blocks until all in-flight delegate calls
/// complete), and tears down the session graph. After this method returns,
/// the capture queue will not invoke any more callbacks.
/// Texture unregistration and the cameraClosing event are dispatched to the
/// main queue as they require UI-thread access.
func dispose() {
// Idempotency guard, first caller wins.
flagsLock.lock()
if _isDisposed { flagsLock.unlock(); return }
_isDisposed = true
_imageStreaming = false
flagsLock.unlock()
// Null out the FFI callback under lock, guarantees no in-flight
// invocation reaches Dart after this returns.
imageStreamFFI.unregisterCallback()
// Remove notification observers before stopping the session.
NotificationCenter.default.removeObserver(self)
// stopRunning() blocks until all in-flight AVCaptureOutput delegate
// calls have returned, so after this line captureOutput() cannot fire.
recordHandler.stopRecording { _ in }
captureSession?.stopRunning()
captureSession = nil
videoDevice = nil
videoOutput = nil
audioOutput = nil
// UI cleanup must happen on the main thread.
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.texture != nil, let registry = self.textureRegistry {
registry.unregisterTexture(self.textureId)
}
self.texture = nil
self.methodChannel?.invokeMethod("cameraClosing", arguments: ["cameraId": self.cameraId])
}
}
}
// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate & AVCaptureAudioDataOutputSampleBufferDelegate
extension CameraSession: AVCaptureVideoDataOutputSampleBufferDelegate,
AVCaptureAudioDataOutputSampleBufferDelegate {
func captureOutput(_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection) {
// Route audio buffers to the record handler.
if output == audioOutput {
recordHandler.appendAudioBuffer(sampleBuffer)
return
}
// Video frame handling.
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}
let width = CVPixelBufferGetWidth(pixelBuffer)
let height = CVPixelBufferGetHeight(pixelBuffer)
// Store the latest buffer for photo capture.
bufferLock.lock()
latestBuffer = pixelBuffer
bufferLock.unlock()
// Handle first-frame initialization response.
let isFirstFrame = !firstFrameReceived
if isFirstFrame {
firstFrameReceived = true
actualWidth = width
actualHeight = height
DispatchQueue.main.async { [weak self] in
guard let self = self, let pending = self.pendingInitResult else { return }
self.pendingInitResult = nil
pending([
"previewWidth": Double(width),
"previewHeight": Double(height),
])
}
}
// Update the texture for Flutter preview.
if !previewPaused || isFirstFrame {
texture?.update(buffer: pixelBuffer)
let now = CACurrentMediaTime()
if isFirstFrame || (now - lastTextureNotification) >= textureNotificationInterval {
lastTextureNotification = now
DispatchQueue.main.async { [weak self] in
guard let self = self, let registry = self.textureRegistry else { return }
registry.textureFrameAvailable(self.textureId)
}
}
}
// Append to recording if active.
recordHandler.appendVideoBuffer(sampleBuffer)
// Send frame to Dart image stream if active.
if imageStreaming {
if imageStreamFFI.hasCallback {
imageStreamFFI.writeFrame(pixelBuffer: pixelBuffer, cameraId: cameraId)
} else {
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { return }
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
let dataSize = bytesPerRow * height
let data = Data(bytes: baseAddress, count: dataSize)
let capturedBytesPerRow = bytesPerRow
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.methodChannel?.invokeMethod("imageStreamFrame", arguments: [
"cameraId": self.cameraId,
"width": width,
"height": height,
"bytesPerRow": capturedBytesPerRow,
"bytes": FlutterStandardTypedData(bytes: data),
])
}
}
}
}
}

View File

@@ -0,0 +1,24 @@
import FlutterMacOS
import CoreVideo
/// Thread-safe FlutterTexture that delivers CVPixelBuffer frames to Flutter's renderer.
class CameraTexture: NSObject, FlutterTexture {
private var latestBuffer: CVPixelBuffer?
private let lock = UnfairLock()
/// Updates the pixel buffer with a new frame from the camera.
/// Called from the AVCaptureVideoDataOutput callback queue.
func update(buffer: CVPixelBuffer) {
lock.lock()
latestBuffer = buffer
lock.unlock()
}
/// Called by Flutter's rendering engine to get the latest frame.
func copyPixelBuffer() -> Unmanaged<CVPixelBuffer>? {
lock.lock()
defer { lock.unlock() }
guard let buffer = latestBuffer else { return nil }
return Unmanaged.passRetained(buffer)
}
}

View File

@@ -0,0 +1,115 @@
import AVFoundation
struct DeviceInfo {
let deviceId: String
let name: String
let lensDirection: Int // 0=front, 1=back, 2=external
let sensorOrientation: Int
}
class DeviceEnumerator {
/// Enumerates available video capture devices.
static func enumerateDevices() -> [DeviceInfo] {
let devices = AVCaptureDevice.captureDevices(mediaType: .video)
let infos = devices.map { device -> (AVCaptureDevice, DeviceInfo) in
let lensDirection: Int
switch device.position {
case .front:
lensDirection = 0
case .back:
lensDirection = 1
default:
lensDirection = 2
}
let displayName = "\(device.localizedName) (\(device.uniqueID))"
let info = DeviceInfo(
deviceId: device.uniqueID,
name: displayName,
lensDirection: lensDirection,
sensorOrientation: 0
)
return (device, info)
}
// Sort: built-in cameras first, Continuity Camera last.
let sorted = infos.sorted { a, b in
let aScore = DeviceEnumerator.sortScore(for: a.0)
let bScore = DeviceEnumerator.sortScore(for: b.0)
return aScore < bScore
}
return sorted.map { $0.1 }
}
/// Returns a sort score for camera ordering.
/// Lower = higher priority (appears first in the list).
/// 0 = built-in camera (preferred)
/// 1 = other/external camera
/// 2 = Continuity Camera (least preferred, often causes grey frames)
private static func sortScore(for device: AVCaptureDevice) -> Int {
// macOS 14+: AVCaptureDevice.DeviceType.continuityCamera is available
if #available(macOS 14.0, *) {
if device.deviceType == .continuityCamera {
return 2
}
}
// Fallback heuristic for pre-macOS 14 or unrecognized Continuity devices
let modelId = device.modelID.lowercased()
let name = device.localizedName.lowercased()
if modelId.contains("iphone") || modelId.contains("ipad") ||
name.contains("iphone") || name.contains("continuity") {
return 2
}
// Built-in cameras have position .front or .back
if device.position == .front || device.position == .back {
return 0
}
// External cameras
return 1
}
/// Extracts the device ID from a camera name in the format "Friendly Name (deviceId)".
static func extractDeviceId(from cameraName: String) -> String? {
guard let parenStart = cameraName.lastIndex(of: "("),
let parenEnd = cameraName.lastIndex(of: ")"),
parenEnd > parenStart else {
return nil
}
let startIdx = cameraName.index(after: parenStart)
return String(cameraName[startIdx..<parenEnd])
}
/// Maps a resolution preset integer to an AVCaptureSession.Preset.
static func sessionPreset(for preset: Int) -> AVCaptureSession.Preset {
switch preset {
case 0: return .low
case 1: return .medium
case 2: return .high
case 3: return .hd1280x720
case 4, 5: return .hd1920x1080
default: return .high
}
}
/// Gets the actual output dimensions for a device with a given session preset.
static func outputDimensions(for device: AVCaptureDevice,
preset: AVCaptureSession.Preset) -> (width: Int, height: Int) {
let format = device.activeFormat
let desc = format.formatDescription
let dims = CMVideoFormatDescriptionGetDimensions(desc)
if dims.width > 0 && dims.height > 0 {
return (Int(dims.width), Int(dims.height))
}
// Fallback based on preset
switch preset {
case .low: return (320, 240)
case .medium: return (480, 360)
case .hd1280x720: return (1280, 720)
case .hd1920x1080: return (1920, 1080)
default: return (1280, 720)
}
}
}

View File

@@ -0,0 +1,174 @@
import AVFoundation
/// Manages a persistent shared buffer for zero-copy FFI image stream delivery.
/// Native writes frame data here; Dart reads it directly via FFI pointer.
/// Uses a double-buffer strategy so writeFrame() never holds the lock during memcpy.
class ImageStreamFFI {
// Buffer layout matches C struct ImageStreamBuffer:
// int64_t sequence (8 bytes, offset 0)
// int32_t width (4 bytes, offset 8)
// int32_t height (4 bytes, offset 12)
// int32_t bytes_per_row (4 bytes, offset 16)
// int32_t format (4 bytes, offset 20) -- 0=BGRA, 1=RGBA
// int32_t ready (4 bytes, offset 24) -- 1=ready for Dart, 0=being written
// int32_t _pad (4 bytes, offset 28)
// uint8_t pixels[] (offset 32)
static let headerSize = 32
private var buffers: (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) = (nil, nil)
private var bufferSizes: (Int, Int) = (0, 0)
private var frontIndex: Int = 0 // 0 or 1, which buffer Dart reads from
private var callback: (@convention(c) (Int32) -> Void)?
private var sequence: Int64 = 0
private var _disposed = false
private let lock = UnfairLock()
func getBufferPointer() -> UnsafeMutableRawPointer? {
lock.lock()
guard !_disposed else { lock.unlock(); return nil }
let idx = frontIndex
let ptr = idx == 0 ? buffers.0 : buffers.1
lock.unlock()
return ptr
}
/// Total bytes currently held by the shared buffers.
///
/// Diagnostic/test hook: lets callers assert that buffers are reclaimed
/// after `releaseBuffers()` without measuring process-level memory.
var allocatedByteCount: Int {
lock.lock()
defer { lock.unlock() }
let s0 = buffers.0 != nil ? bufferSizes.0 : 0
let s1 = buffers.1 != nil ? bufferSizes.1 : 0
return s0 + s1
}
var hasCallback: Bool {
lock.lock()
defer { lock.unlock() }
return callback != nil
}
func registerCallback(_ cb: @convention(c) (Int32) -> Void) {
lock.lock()
callback = cb
lock.unlock()
}
func unregisterCallback() {
lock.lock()
callback = nil
lock.unlock()
}
/// Frees both shared buffers without permanently disposing the instance.
///
/// Unlike `dispose()`, the instance remains usable: a subsequent
/// `writeFrame()` re-allocates lazily. Used to reclaim memory when image
/// streaming stops but the camera session stays open.
///
/// Thread-safety: the deallocation happens after the buffer pointers are
/// nulled under the lock, so `getBufferPointer()` can never hand out a
/// freed pointer. The caller is responsible for ensuring no `writeFrame()`
/// is in flight (CameraSession serializes this onto the capture queue).
func releaseBuffers() {
lock.lock()
guard !_disposed else { lock.unlock(); return }
let b0 = buffers.0
let b1 = buffers.1
buffers = (nil, nil)
bufferSizes = (0, 0)
frontIndex = 0
lock.unlock()
b0?.deallocate()
b1?.deallocate()
}
/// Releases the shared buffers and permanently disables further writes.
///
/// Precondition: the caller MUST guarantee no `writeFrame()` is in flight.
/// `writeFrame()` performs its `memcpy` without holding the lock, so freeing
/// a buffer here concurrently with a write would be a use-after-free. This
/// holds today because the sole caller is `deinit`, which only runs after
/// the owning `CameraSession` has stopped the capture session and
/// `AVCaptureSession.stopRunning()` blocks until every in-flight
/// `captureOutput`/`writeFrame` call has returned. It is therefore NOT safe
/// to call from an arbitrary thread while capture is live.
func dispose() {
lock.lock()
guard !_disposed else { lock.unlock(); return }
_disposed = true
callback = nil
let b0 = buffers.0
let b1 = buffers.1
buffers = (nil, nil)
bufferSizes = (0, 0)
lock.unlock()
b0?.deallocate()
b1?.deallocate()
}
func writeFrame(pixelBuffer: CVPixelBuffer, cameraId: Int) {
// Bail out immediately if disposed, no lock held during memcpy below.
lock.lock()
if _disposed { lock.unlock(); return }
let backIdx = 1 - frontIndex
lock.unlock()
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { return }
let width = CVPixelBufferGetWidth(pixelBuffer)
let height = CVPixelBufferGetHeight(pixelBuffer)
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
let dataSize = bytesPerRow * height
let totalSize = ImageStreamFFI.headerSize + dataSize
// Resize back buffer if needed, hold lock for the pointer swap only.
lock.lock()
if _disposed { lock.unlock(); return }
let backSize = backIdx == 0 ? bufferSizes.0 : bufferSizes.1
var backBuf = backIdx == 0 ? buffers.0 : buffers.1
if backSize < totalSize {
let newBuf = UnsafeMutableRawPointer.allocate(byteCount: totalSize, alignment: 8)
backBuf?.deallocate()
backBuf = newBuf
if backIdx == 0 {
buffers.0 = newBuf
bufferSizes.0 = totalSize
} else {
buffers.1 = newBuf
bufferSizes.1 = totalSize
}
}
lock.unlock()
guard let buf = backBuf else { return }
// Write to back buffer, no lock held during memcpy
buf.storeBytes(of: Int32(0), toByteOffset: 24, as: Int32.self) // ready=0
memcpy(buf.advanced(by: ImageStreamFFI.headerSize), baseAddress, dataSize)
sequence += 1
buf.storeBytes(of: sequence, toByteOffset: 0, as: Int64.self)
buf.storeBytes(of: Int32(width), toByteOffset: 8, as: Int32.self)
buf.storeBytes(of: Int32(height), toByteOffset: 12, as: Int32.self)
buf.storeBytes(of: Int32(bytesPerRow), toByteOffset: 16, as: Int32.self)
buf.storeBytes(of: Int32(0), toByteOffset: 20, as: Int32.self) // format=BGRA
buf.storeBytes(of: Int32(1), toByteOffset: 24, as: Int32.self) // ready=1
// Swap front/back and invoke callback (a native no-op symbol) under
// the lock. Safe because the callback is a trivial C function.
lock.lock()
if _disposed { lock.unlock(); return }
frontIndex = backIdx
callback?(Int32(cameraId))
lock.unlock()
}
deinit {
dispose()
}
}

View File

@@ -0,0 +1,114 @@
import Foundation
public typealias ImageStreamCallback = @convention(c) (Int32) -> Void
@_cdecl("camera_desktop_image_stream_noop_callback")
public func cameraDesktopImageStreamNoopCallback(_ cameraId: Int32) {
_ = cameraId
}
@_cdecl("camera_desktop_get_image_stream_buffer")
public func cameraDesktopGetImageStreamBuffer(_ streamHandle: Int64) -> UnsafeMutableRawPointer? {
ImageStreamHandleBridge.getImageStreamBuffer(forHandle: streamHandle)
}
@_cdecl("camera_desktop_register_image_stream_callback")
public func cameraDesktopRegisterImageStreamCallback(
_ streamHandle: Int64,
_ callback: ImageStreamCallback?
) {
guard let callback else { return }
ImageStreamHandleBridge.registerImageStreamCallback(callback, forHandle: streamHandle)
}
@_cdecl("camera_desktop_unregister_image_stream_callback")
public func cameraDesktopUnregisterImageStreamCallback(_ streamHandle: Int64) {
ImageStreamHandleBridge.unregisterImageStreamCallback(forHandle: streamHandle)
}
private final class WeakCameraSession {
weak var value: CameraSession?
init(_ value: CameraSession) {
self.value = value
}
}
final class ImageStreamHandleBridge {
private static var nextHandle: Int64 = 1
private static var sessionsByHandle: [Int64: WeakCameraSession] = [:]
private static var cameraIdByHandle: [Int64: Int] = [:]
private static let lock = UnfairLock()
static func registerSession(_ session: CameraSession) -> Int64 {
lock.lock()
defer { lock.unlock() }
let handle = nextHandle
nextHandle += 1
sessionsByHandle[handle] = WeakCameraSession(session)
cameraIdByHandle[handle] = session.cameraId
return handle
}
static func releaseHandle(_ handle: Int64) {
if handle == 0 { return }
lock.lock()
sessionsByHandle.removeValue(forKey: handle)
cameraIdByHandle.removeValue(forKey: handle)
lock.unlock()
}
static func releaseHandles(forCameraId cameraId: Int) {
lock.lock()
defer { lock.unlock() }
let handlesToRemove = cameraIdByHandle.compactMap { entry in
entry.value == cameraId ? entry.key : nil
}
if !handlesToRemove.isEmpty {
}
for handle in handlesToRemove {
sessionsByHandle.removeValue(forKey: handle)
cameraIdByHandle.removeValue(forKey: handle)
}
}
static func getImageStreamBuffer(forHandle handle: Int64) -> UnsafeMutableRawPointer? {
lock.lock()
let wrapper = sessionsByHandle[handle]
let session = wrapper?.value
if wrapper != nil && session == nil {
sessionsByHandle.removeValue(forKey: handle)
cameraIdByHandle.removeValue(forKey: handle)
} else if wrapper == nil && handle != 0 {
}
lock.unlock()
return session?.getImageStreamBufferPointer()
}
static func registerImageStreamCallback(
_ callback: ImageStreamCallback,
forHandle handle: Int64
) {
lock.lock()
let wrapper = sessionsByHandle[handle]
let session = wrapper?.value
if wrapper != nil && session == nil {
sessionsByHandle.removeValue(forKey: handle)
cameraIdByHandle.removeValue(forKey: handle)
}
lock.unlock()
session?.registerImageStreamCallback(callback)
}
static func unregisterImageStreamCallback(forHandle handle: Int64) {
lock.lock()
let wrapper = sessionsByHandle[handle]
let session = wrapper?.value
if wrapper != nil && session == nil {
sessionsByHandle.removeValue(forKey: handle)
cameraIdByHandle.removeValue(forKey: handle)
}
lock.unlock()
session?.unregisterImageStreamCallback()
}
}

View File

@@ -0,0 +1,34 @@
import AVFoundation
import CoreImage
/// Captures a still image from a CVPixelBuffer and writes it to a JPEG file.
class PhotoHandler {
private static let ciContext = CIContext()
/// Takes a picture from the given pixel buffer and writes a JPEG to the output path.
/// Returns true on success, false on failure.
static func takePicture(from buffer: CVPixelBuffer, outputPath: String) -> Bool {
let ciImage = CIImage(cvPixelBuffer: buffer)
let colorSpace = CGColorSpaceCreateDeviceRGB()
guard let jpegData = ciContext.jpegRepresentation(
of: ciImage,
colorSpace: colorSpace,
options: [kCGImageDestinationLossyCompressionQuality as CIImageRepresentationOption: 0.9]
) else {
return false
}
let url = URL(fileURLWithPath: outputPath)
do {
try jpegData.write(to: url)
return true
} catch {
return false
}
}
/// Generates a unique temporary file path for a captured image.
static func generatePath(cameraId: Int) -> String {
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
return NSTemporaryDirectory() + "camera_desktop_\(cameraId)_\(timestamp).jpg"
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,210 @@
import AVFoundation
/// Manages video recording via AVAssetWriter.
class RecordHandler: NSObject {
private var assetWriter: AVAssetWriter?
private var videoInput: AVAssetWriterInput?
private var audioInput: AVAssetWriterInput?
private var outputPath: String?
private var sessionStarted = false
private let lock = UnfairLock()
private(set) var isRecording = false
/// Starts recording to a temporary file.
/// - Parameters:
/// - width: Video frame width.
/// - height: Video frame height.
/// - targetFps: Target frame rate for encoder hints.
/// - targetBitrate: Target average bitrate in bits per second (0 = default).
/// - enableAudio: Whether to record audio.
/// - Returns: The output file path on success.
/// - Throws: If the asset writer cannot be created.
func startRecording(width: Int,
height: Int,
targetFps: Int,
targetBitrate: Int,
audioBitrate: Int = 0,
enableAudio: Bool) throws -> String {
lock.lock()
if isRecording {
lock.unlock()
throw NSError(domain: "camera_desktop", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Already recording"])
}
lock.unlock()
let path = RecordHandler.generatePath()
let url = URL(fileURLWithPath: path)
// Remove any stale file at this path.
do {
try FileManager.default.removeItem(at: url)
} catch {
// Non-fatal: file may simply not exist yet.
let nsError = error as NSError
if nsError.code != NSFileNoSuchFileError {
}
}
let writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
// Video input, H.264 encoding.
var compression: [String: Any] = [
AVVideoExpectedSourceFrameRateKey: targetFps,
AVVideoMaxKeyFrameIntervalKey: max(targetFps, 1),
]
if targetBitrate > 0 {
compression[AVVideoAverageBitRateKey] = targetBitrate
}
let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
AVVideoCompressionPropertiesKey: compression,
]
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
vInput.expectsMediaDataInRealTime = true
if writer.canAdd(vInput) {
writer.add(vInput)
} else {
}
// Audio input, AAC encoding.
var aInput: AVAssetWriterInput?
if enableAudio {
let audioSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 2,
AVEncoderBitRateKey: audioBitrate > 0 ? audioBitrate : 128000,
]
aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
aInput!.expectsMediaDataInRealTime = true
if writer.canAdd(aInput!) {
writer.add(aInput!)
} else {
}
}
writer.startWriting()
lock.lock()
assetWriter = writer
videoInput = vInput
audioInput = aInput
outputPath = path
sessionStarted = false
isRecording = true
lock.unlock()
return path
}
/// Appends a video sample buffer to the recording.
func appendVideoBuffer(_ sampleBuffer: CMSampleBuffer) {
lock.lock()
guard isRecording else {
lock.unlock()
return
}
guard let writer = assetWriter else {
lock.unlock()
return
}
guard writer.status == .writing else {
lock.unlock()
return
}
guard let input = videoInput else {
lock.unlock()
return
}
guard input.isReadyForMoreMediaData else {
lock.unlock()
return
}
if !sessionStarted {
let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
writer.startSession(atSourceTime: timestamp)
sessionStarted = true
}
lock.unlock()
input.append(sampleBuffer)
}
/// Appends an audio sample buffer to the recording.
func appendAudioBuffer(_ sampleBuffer: CMSampleBuffer) {
lock.lock()
guard isRecording else {
lock.unlock()
return
}
guard let writer = assetWriter else {
lock.unlock()
return
}
guard writer.status == .writing else {
lock.unlock()
return
}
guard let input = audioInput else {
lock.unlock()
return
}
guard input.isReadyForMoreMediaData else {
lock.unlock()
return
}
guard sessionStarted else {
lock.unlock()
return
}
lock.unlock()
input.append(sampleBuffer)
}
/// Stops recording and finalizes the file.
/// - Parameter completion: Called with the output file path on success, or nil on failure.
func stopRecording(completion: @escaping (String?) -> Void) {
lock.lock()
guard isRecording, let writer = assetWriter else {
lock.unlock()
completion(nil)
return
}
isRecording = false
let vInput = videoInput
let aInput = audioInput
let path = outputPath
assetWriter = nil
videoInput = nil
audioInput = nil
outputPath = nil
sessionStarted = false
lock.unlock()
vInput?.markAsFinished()
aInput?.markAsFinished()
writer.finishWriting {
if writer.status == .completed {
completion(path)
} else {
completion(nil)
}
}
}
/// Generates a unique temporary file path for a video recording.
static func generatePath() -> String {
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
return NSTemporaryDirectory() + "camera_desktop_video_\(timestamp).mp4"
}
}

View File

@@ -0,0 +1,25 @@
import os
/// A Swift wrapper around os_unfair_lock that heap-allocates the lock to prevent
/// Swift from moving the value type, which would invalidate the lock.
final class UnfairLock {
private let _lock: UnsafeMutablePointer<os_unfair_lock_s>
init() {
_lock = UnsafeMutablePointer<os_unfair_lock_s>.allocate(capacity: 1)
_lock.initialize(to: os_unfair_lock_s())
}
func lock() {
os_unfair_lock_lock(_lock)
}
func unlock() {
os_unfair_lock_unlock(_lock)
}
deinit {
_lock.deinitialize(count: 1)
_lock.deallocate()
}
}