Native plugin owning AVCaptureSession + AVAssetWriter, mirroring
telegram-iOS's Camera module decomposition. Photo + video capture with
the writer-track transform set from a per-call orientation snapshot
(the three-way preview/capture/device split that camera_avfoundation
can't give us).
Modules:
CameraPlugin channels + per-handle instance map
CameraInstance session + texture + outputs + recorder
CameraSession AVCaptureSession + runtime-error/interrupt obs
CaptureDevice front/back discovery, per-device config
PhotoOutput AVCapturePhotoOutput, per-shot orientation
VideoRecorder AVAssetWriter, lazy inputs, pending-audio queue,
stop()/cancel() pair (matches telegram)
PreviewSink CVPixelBuffer → FlutterTexture
AudioSession setCategory + setActive(true) (only-widen)
DeviceOrientationBridge
Recorder details:
- Lazy videoInput/audioInput on first sample, sourceFormatHint:.
- Audio settings derived from CMAudioFormatDescriptionGet*
+ recommendedAudioSettingsForAssetWriter, gated startWriting.
- Stop sets stopSampleTime; next sample crossing it triggers
maybeFinish → finishWriting. No watchdog — telegram pattern.
- cancel() drops pending audio + cancelWriting + deletes file,
used by CameraInstance.dispose when teardown finds in-flight
recording.
- Diagnostic stream → ux/camera/events {event: "diagnostic"}.
Dart surface extensions over Phase 1:
- UxCameraValue.audioPermissionGranted
- UxCameraController.refreshAudioPermission()
- Static UxCameraController.audioPermissionGranted() /
openSystemSettings()
- UxCameraDiagnostic event variant
- FakeUxCameraBackend.{emitDiagnostic, audioPermission,
openSettingsCalls}
Tests: 32/32 in test/camera (controller + channel) green.
67 lines
2.3 KiB
Swift
67 lines
2.3 KiB
Swift
import AVFoundation
|
|
import CoreVideo
|
|
import Flutter
|
|
|
|
/// Single-slot latest-pixel-buffer sink that feeds a `FlutterTexture`.
|
|
///
|
|
/// AVCaptureVideoDataOutput's sample-buffer delegate fires on
|
|
/// `videoBufferQueue`; we extract the `CVPixelBuffer`, store it as
|
|
/// `latestPixelBuffer`, and notify the texture registry on `main` so
|
|
/// Flutter pulls the new frame via `copyPixelBuffer()` on the engine
|
|
/// thread.
|
|
///
|
|
/// We retain *only the most recent* buffer — the previous one is
|
|
/// released the moment a new sample arrives. This matches the
|
|
/// `camera_avfoundation` lifetime invariant and bounds memory at one
|
|
/// frame regardless of how fast we produce vs. consume.
|
|
final class PreviewSink: NSObject, FlutterTexture {
|
|
private weak var registry: FlutterTextureRegistry?
|
|
private var textureId: Int64 = -1
|
|
|
|
/// Serial queue for the `latestPixelBuffer` swap. Sample-buffer
|
|
/// delegate writes; `copyPixelBuffer()` reads. Without this the
|
|
/// pointer could be freed mid-read on a different thread.
|
|
private let bufferQueue = DispatchQueue(
|
|
label: "ux.camera.preview.buffer",
|
|
qos: .userInitiated
|
|
)
|
|
private var latestPixelBuffer: CVPixelBuffer?
|
|
|
|
func register(with registry: FlutterTextureRegistry) -> Int64 {
|
|
self.registry = registry
|
|
textureId = registry.register(self)
|
|
return textureId
|
|
}
|
|
|
|
func unregister() {
|
|
registry?.unregisterTexture(textureId)
|
|
bufferQueue.sync { latestPixelBuffer = nil }
|
|
}
|
|
|
|
// MARK: - FlutterTexture
|
|
|
|
func copyPixelBuffer() -> Unmanaged<CVPixelBuffer>? {
|
|
var pb: CVPixelBuffer?
|
|
bufferQueue.sync {
|
|
pb = latestPixelBuffer
|
|
latestPixelBuffer = nil
|
|
}
|
|
if let pb = pb { return Unmanaged.passRetained(pb) }
|
|
return nil
|
|
}
|
|
|
|
/// Receives a new frame. Called from the
|
|
/// `AVCaptureVideoDataOutputSampleBufferDelegate` on
|
|
/// `videoBufferQueue`. Cheap — just swaps the pointer + notifies
|
|
/// the registry.
|
|
func receive(sampleBuffer: CMSampleBuffer) {
|
|
guard let pb = CMSampleBufferGetImageBuffer(sampleBuffer) else {
|
|
return
|
|
}
|
|
bufferQueue.sync { latestPixelBuffer = pb }
|
|
if let registry = registry {
|
|
registry.textureFrameAvailable(textureId)
|
|
}
|
|
}
|
|
}
|