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.
This commit is contained in:
106
ios/Classes/Camera/PhotoOutput.swift
Normal file
106
ios/Classes/Camera/PhotoOutput.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// Wraps `AVCapturePhotoOutput`. One instance per
|
||||
/// [CameraInstance]; gets added to the session at create time and
|
||||
/// stays for the session's lifetime (no swap on camera flip — the
|
||||
/// output is generic, only the connection's video device changes).
|
||||
///
|
||||
/// `take(orientation:flashMode:completion:)` is the only public entry.
|
||||
/// It sets the photo connection's `videoOrientation` to the
|
||||
/// snapshotted orientation just before firing, hands off to a
|
||||
/// per-capture delegate, and resets the connection back to portrait
|
||||
/// afterward (so a takePicture without an explicit snapshot — should
|
||||
/// the path ever exist — falls back cleanly).
|
||||
final class PhotoOutput {
|
||||
let avOutput = AVCapturePhotoOutput()
|
||||
|
||||
private var inFlight: PhotoCaptureDelegate?
|
||||
|
||||
/// Capture a single still. [orientation] applies to the photo
|
||||
/// connection. [flashMode] is applied to the per-shot
|
||||
/// `AVCapturePhotoSettings`. [completion] is invoked on `.main`
|
||||
/// with either the saved file path or an `NSError`.
|
||||
func take(
|
||||
orientation: DeviceOrientationFlutter,
|
||||
flashMode: AVCaptureDevice.FlashMode,
|
||||
completion: @escaping (Result<String, NSError>) -> Void
|
||||
) {
|
||||
guard let connection = avOutput.connection(with: .video) else {
|
||||
completion(.failure(NSError(
|
||||
domain: "ux.camera",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Photo connection unavailable"]
|
||||
)))
|
||||
return
|
||||
}
|
||||
if connection.isVideoOrientationSupported {
|
||||
connection.videoOrientation = orientation.avVideoOrientation
|
||||
}
|
||||
// The recorded photo carries no mirror; mirroring is a
|
||||
// preview-only concern.
|
||||
if connection.isVideoMirroringSupported {
|
||||
connection.automaticallyAdjustsVideoMirroring = false
|
||||
connection.isVideoMirrored = false
|
||||
}
|
||||
|
||||
let settings = AVCapturePhotoSettings()
|
||||
if avOutput.supportedFlashModes.contains(flashMode) {
|
||||
settings.flashMode = flashMode
|
||||
}
|
||||
|
||||
let delegate = PhotoCaptureDelegate { [weak self] result in
|
||||
// Reset orientation on the photo connection so a future
|
||||
// capture without a snapshot defaults to portrait.
|
||||
if let conn = self?.avOutput.connection(with: .video),
|
||||
conn.isVideoOrientationSupported {
|
||||
conn.videoOrientation = .portrait
|
||||
}
|
||||
self?.inFlight = nil
|
||||
DispatchQueue.main.async { completion(result) }
|
||||
}
|
||||
// Retain the delegate for the duration of the capture —
|
||||
// AVCapturePhotoOutput holds it weakly.
|
||||
inFlight = delegate
|
||||
avOutput.capturePhoto(with: settings, delegate: delegate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-shot delegate. Receives the photo, writes
|
||||
/// `fileDataRepresentation()` to a unique path under
|
||||
/// `NSTemporaryDirectory()`, invokes the completion. The plugin
|
||||
/// retains it via [PhotoOutput.inFlight] across the async hop.
|
||||
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||
private let completion: (Result<String, NSError>) -> Void
|
||||
|
||||
init(completion: @escaping (Result<String, NSError>) -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||
error: Error?
|
||||
) {
|
||||
if let error = error as NSError? {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
guard let data = photo.fileDataRepresentation() else {
|
||||
completion(.failure(NSError(
|
||||
domain: "ux.camera",
|
||||
code: -2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No photo data"]
|
||||
)))
|
||||
return
|
||||
}
|
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("ux_camera_\(UUID().uuidString).jpg")
|
||||
do {
|
||||
try data.write(to: url, options: .atomic)
|
||||
completion(.success(url.path))
|
||||
} catch let error as NSError {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user