add plugin camera desktop
This commit is contained in:
21
plugins/camera_desktop/macos/camera_desktop.podspec
Normal file
21
plugins/camera_desktop/macos/camera_desktop.podspec
Normal 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
|
||||
27
plugins/camera_desktop/macos/camera_desktop/Package.swift
Normal file
27
plugins/camera_desktop/macos/camera_desktop/Package.swift
Normal 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"),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user