import AVFoundation import CoreVideo import Flutter /// Single-slot latest-pixel-buffer sink that feeds a `FlutterTexture`. /// /// AVCaptureVideoDataOutput's sample-buffer delegate fires on /// `videoBufferQueue`; we extract the `CVPixelBuffer`, store it as /// `latestPixelBuffer`, and notify the texture registry on `main` so /// Flutter pulls the new frame via `copyPixelBuffer()` on the engine /// thread. /// /// We retain *only the most recent* buffer — the previous one is /// released the moment a new sample arrives. This matches the /// `camera_avfoundation` lifetime invariant and bounds memory at one /// frame regardless of how fast we produce vs. consume. final class PreviewSink: NSObject, FlutterTexture { private weak var registry: FlutterTextureRegistry? private var textureId: Int64 = -1 /// Serial queue for the `latestPixelBuffer` swap. Sample-buffer /// delegate writes; `copyPixelBuffer()` reads. Without this the /// pointer could be freed mid-read on a different thread. private let bufferQueue = DispatchQueue( label: "ux.camera.preview.buffer", qos: .userInitiated ) private var latestPixelBuffer: CVPixelBuffer? func register(with registry: FlutterTextureRegistry) -> Int64 { self.registry = registry textureId = registry.register(self) return textureId } func unregister() { registry?.unregisterTexture(textureId) bufferQueue.sync { latestPixelBuffer = nil } } // MARK: - FlutterTexture func copyPixelBuffer() -> Unmanaged? { var pb: CVPixelBuffer? bufferQueue.sync { pb = latestPixelBuffer latestPixelBuffer = nil } if let pb = pb { return Unmanaged.passRetained(pb) } return nil } /// Receives a new frame. Called from the /// `AVCaptureVideoDataOutputSampleBufferDelegate` on /// `videoBufferQueue`. Cheap — just swaps the pointer + notifies /// the registry. func receive(sampleBuffer: CMSampleBuffer) { guard let pb = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } bufferQueue.sync { latestPixelBuffer = pb } if let registry = registry { registry.textureFrameAvailable(textureId) } } }