Files
ux/darwin/Camera/PreviewSink.swift
agra de1a9fd25e camera: emit previewSize from the first sample buffer's real dims
`device.activeFormat.formatDescription` reports the device's
*selected* format, but on macOS the session-preset remap means the
data output sometimes delivers a different resolution than what
the active format claims. The mismatch surfaced as a stretched
preview on macOS: camera_thumb's SizedBox was sized for a 4:3
buffer, the FittedBox(cover) was given a 16:9 texture, so the
texture stretched to fill the wrongly-shaped box.

PreviewSink now snapshots `CVPixelBufferGet{Width,Height}` from
the first sample buffer that arrives and forwards it via a
callback. CameraInstance hooks the callback to emit the existing
`previewSizeChanged` event (same shape the Android backend uses).
The Dart controller writes it into `value.previewSize`,
camera_thumb's SizedBox snaps to the real buffer aspect, and
FittedBox(cover) crops cleanly without stretching.

Identical wire shape and event name as the Android equivalent —
no Dart changes needed.
2026-05-13 19:20:00 +03:00

89 lines
3.1 KiB
Swift

import AVFoundation
import CoreVideo
#if canImport(UIKit)
import Flutter
#else
import FlutterMacOS
#endif
/// 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?
/// Fires once after the first sample buffer arrives, carrying the
/// buffer's actual `CVPixelBuffer` dimensions. The active-format
/// dims reported by `device.activeFormat` aren't always what the
/// data output actually delivers (notably on macOS where the
/// session preset gets remapped). [CameraInstance] forwards this
/// to Dart as a `previewSizeChanged` event so the camera_thumb's
/// SizedBox aspect matches the texture's real aspect.
var onFirstFrameSize: ((CGSize) -> Void)?
private var emittedFirstSize = false
func register(with registry: FlutterTextureRegistry) -> Int64 {
self.registry = registry
textureId = registry.register(self)
return textureId
}
func unregister() {
registry?.unregisterTexture(textureId)
bufferQueue.sync { latestPixelBuffer = nil }
}
// MARK: - FlutterTexture
func copyPixelBuffer() -> Unmanaged<CVPixelBuffer>? {
var pb: CVPixelBuffer?
bufferQueue.sync {
pb = latestPixelBuffer
latestPixelBuffer = nil
}
if let pb = pb { return Unmanaged.passRetained(pb) }
return nil
}
/// Receives a new frame. Called from the
/// `AVCaptureVideoDataOutputSampleBufferDelegate` on
/// `videoBufferQueue`. Cheap just swaps the pointer + notifies
/// the registry.
func receive(sampleBuffer: CMSampleBuffer) {
guard let pb = CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}
bufferQueue.sync { latestPixelBuffer = pb }
if !emittedFirstSize {
emittedFirstSize = true
let size = CGSize(
width: CGFloat(CVPixelBufferGetWidth(pb)),
height: CGFloat(CVPixelBufferGetHeight(pb))
)
onFirstFrameSize?(size)
}
if let registry = registry {
registry.textureFrameAvailable(textureId)
}
}
}