Files
ux/ios/Classes/Camera/PreviewSink.swift
agra 6d6a871c53 camera: iOS implementation (Phase 2+3)
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.
2026-05-13 16:56:49 +03:00

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)
}
}
}