Files
openclaw/apps/ios/Sources/Camera/CameraController.swift

354 lines
12 KiB
Swift
Raw Normal View History

2025-12-14 00:48:58 +00:00
import AVFoundation
2026-01-30 03:15:10 +01:00
import OpenClawKit
2025-12-14 00:48:58 +00:00
import Foundation
import os
2025-12-14 00:48:58 +00:00
actor CameraController {
struct CameraDeviceInfo: Codable, Sendable {
var id: String
var name: String
var position: String
var deviceType: String
}
2025-12-14 00:48:58 +00:00
enum CameraError: LocalizedError, Sendable {
case cameraUnavailable
case microphoneUnavailable
case permissionDenied(kind: String)
case invalidParams(String)
case captureFailed(String)
case exportFailed(String)
var errorDescription: String? {
switch self {
case .cameraUnavailable:
"Camera unavailable"
case .microphoneUnavailable:
"Microphone unavailable"
case let .permissionDenied(kind):
"\(kind) permission denied"
case let .invalidParams(msg):
msg
case let .captureFailed(msg):
msg
case let .exportFailed(msg):
msg
}
}
}
2026-01-30 03:15:10 +01:00
func snap(params: OpenClawCameraSnapParams) async throws -> (
2025-12-14 00:48:58 +00:00
format: String,
base64: String,
width: Int,
height: Int)
{
let facing = params.facing ?? .front
2025-12-14 04:30:21 +00:00
let format = params.format ?? .jpg
// Default to a reasonable max width to keep gateway payload sizes manageable.
// If you need the full-res photo, explicitly request a larger maxWidth.
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
2025-12-14 00:48:58 +00:00
let quality = Self.clampQuality(params.quality)
let delayMs = max(0, params.delayMs ?? 0)
2025-12-14 00:48:58 +00:00
try await self.ensureAccess(for: .video)
let prepared = try CameraCapturePipelineSupport.preparePhotoSession(
preferFrontCamera: facing == .front,
deviceId: params.deviceId,
pickCamera: { preferFrontCamera, deviceId in
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
},
cameraUnavailableError: CameraError.cameraUnavailable,
mapSetupError: { setupError in
CameraError.captureFailed(setupError.localizedDescription)
})
let session = prepared.session
let output = prepared.output
2025-12-14 00:48:58 +00:00
session.startRunning()
defer { session.stopRunning() }
await CameraCapturePipelineSupport.warmUpCaptureSession()
await Self.sleepDelayMs(delayMs)
2025-12-14 00:48:58 +00:00
let rawData = try await CameraCapturePipelineSupport.capturePhotoData(output: output) { continuation in
PhotoCaptureDelegate(continuation)
2025-12-14 00:48:58 +00:00
}
let res = try PhotoCapture.transcodeJPEGForGateway(
rawData: rawData,
maxWidthPx: maxWidth,
quality: quality)
2025-12-14 00:48:58 +00:00
return (
2025-12-14 04:30:21 +00:00
format: format.rawValue,
base64: res.data.base64EncodedString(),
width: res.widthPx,
height: res.heightPx)
2025-12-14 00:48:58 +00:00
}
2026-01-30 03:15:10 +01:00
func clip(params: OpenClawCameraClipParams) async throws -> (
2025-12-14 00:48:58 +00:00
format: String,
base64: String,
durationMs: Int,
hasAudio: Bool)
{
let facing = params.facing ?? .front
let durationMs = Self.clampDurationMs(params.durationMs)
let includeAudio = params.includeAudio ?? true
2025-12-14 04:30:21 +00:00
let format = params.format ?? .mp4
2025-12-14 00:48:58 +00:00
try await self.ensureAccess(for: .video)
if includeAudio {
try await self.ensureAccess(for: .audio)
}
let movURL = FileManager().temporaryDirectory
2026-01-30 03:15:10 +01:00
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov")
let mp4URL = FileManager().temporaryDirectory
2026-01-30 03:15:10 +01:00
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4")
2025-12-14 00:48:58 +00:00
defer {
try? FileManager().removeItem(at: movURL)
try? FileManager().removeItem(at: mp4URL)
2025-12-14 00:48:58 +00:00
}
let data = try await CameraCapturePipelineSupport.withWarmMovieSession(
preferFrontCamera: facing == .front,
deviceId: params.deviceId,
includeAudio: includeAudio,
durationMs: durationMs,
pickCamera: { preferFrontCamera, deviceId in
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
},
cameraUnavailableError: CameraError.cameraUnavailable,
mapSetupError: Self.mapMovieSetupError,
operation: { output in
var delegate: MovieFileDelegate?
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
let d = MovieFileDelegate(cont)
delegate = d
output.startRecording(to: movURL, recordingDelegate: d)
}
withExtendedLifetime(delegate) {}
// Transcode .mov -> .mp4 for easier downstream handling.
try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
return try Data(contentsOf: mp4URL)
})
2025-12-14 04:30:21 +00:00
return (
format: format.rawValue,
base64: data.base64EncodedString(),
durationMs: durationMs,
hasAudio: includeAudio)
2025-12-14 00:48:58 +00:00
}
func listDevices() -> [CameraDeviceInfo] {
2026-01-16 06:15:37 +00:00
return Self.discoverVideoDevices().map { device in
CameraDeviceInfo(
id: device.uniqueID,
name: device.localizedName,
position: Self.positionLabel(device.position),
deviceType: device.deviceType.rawValue)
}
}
2025-12-14 00:48:58 +00:00
private func ensureAccess(for mediaType: AVMediaType) async throws {
if !(await CameraAuthorization.isAuthorized(for: mediaType)) {
2025-12-14 00:48:58 +00:00
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
}
}
private nonisolated static func pickCamera(
2026-01-30 03:15:10 +01:00
facing: OpenClawCameraFacing,
deviceId: String?) -> AVCaptureDevice?
{
if let deviceId, !deviceId.isEmpty {
2026-01-16 06:15:37 +00:00
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
return match
}
}
2025-12-14 00:48:58 +00:00
let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back
2025-12-14 04:30:21 +00:00
if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) {
return device
}
// Fall back to any default camera (e.g. simulator / unusual device configurations).
return AVCaptureDevice.default(for: .video)
2025-12-14 00:48:58 +00:00
}
private nonisolated static func mapMovieSetupError(_ setupError: CameraSessionConfigurationError) -> CameraError {
CameraCapturePipelineSupport.mapMovieSetupError(
setupError,
microphoneUnavailableError: .microphoneUnavailable,
captureFailed: { .captureFailed($0) })
}
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
CameraCapturePipelineSupport.positionLabel(position)
}
2026-01-16 06:15:37 +00:00
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
let types: [AVCaptureDevice.DeviceType] = [
.builtInWideAngleCamera,
.builtInUltraWideCamera,
.builtInTelephotoCamera,
.builtInDualCamera,
.builtInDualWideCamera,
.builtInTripleCamera,
.builtInTrueDepthCamera,
.builtInLiDARDepthCamera,
]
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: types,
mediaType: .video,
position: .unspecified)
return session.devices
}
nonisolated static func clampQuality(_ quality: Double?) -> Double {
2025-12-14 00:48:58 +00:00
let q = quality ?? 0.9
return min(1.0, max(0.05, q))
}
nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
2025-12-14 00:48:58 +00:00
let v = ms ?? 3000
// Keep clips short by default; avoid huge base64 payloads on the gateway.
return min(60000, max(250, v))
2025-12-14 00:48:58 +00:00
}
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
2025-12-14 02:34:22 +00:00
let asset = AVURLAsset(url: inputURL)
2025-12-14 05:30:34 +00:00
guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
2025-12-14 00:48:58 +00:00
throw CameraError.exportFailed("Failed to create export session")
}
exporter.shouldOptimizeForNetworkUse = true
2025-12-14 02:34:22 +00:00
if #available(iOS 18.0, tvOS 18.0, visionOS 2.0, *) {
do {
try await exporter.export(to: outputURL, as: .mp4)
return
} catch {
throw CameraError.exportFailed(error.localizedDescription)
}
} else {
exporter.outputURL = outputURL
exporter.outputFileType = .mp4
2025-12-14 05:30:34 +00:00
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
2025-12-14 02:34:22 +00:00
exporter.exportAsynchronously {
2025-12-14 05:30:34 +00:00
cont.resume(returning: ())
2025-12-14 00:48:58 +00:00
}
}
2025-12-14 05:30:34 +00:00
switch exporter.status {
case .completed:
return
case .failed:
throw CameraError.exportFailed(exporter.error?.localizedDescription ?? "export failed")
case .cancelled:
throw CameraError.exportFailed("export cancelled")
default:
throw CameraError.exportFailed("export did not complete")
}
2025-12-14 00:48:58 +00:00
}
}
2025-12-14 05:30:34 +00:00
private nonisolated static func sleepDelayMs(_ delayMs: Int) async {
guard delayMs > 0 else { return }
2026-01-02 18:48:05 +01:00
let maxDelayMs = 10 * 1000
let ns = UInt64(min(delayMs, maxDelayMs)) * UInt64(NSEC_PER_MSEC)
try? await Task.sleep(nanoseconds: ns)
}
2025-12-14 00:48:58 +00:00
}
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
private let continuation: CheckedContinuation<Data, Error>
private let resumed = OSAllocatedUnfairLock(initialState: false)
2025-12-14 00:48:58 +00:00
init(_ continuation: CheckedContinuation<Data, Error>) {
self.continuation = continuation
}
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?
) {
let alreadyResumed = self.resumed.withLock { old in
let was = old
old = true
return was
}
guard !alreadyResumed else { return }
2025-12-14 00:48:58 +00:00
if let error {
self.continuation.resume(throwing: error)
return
}
guard let data = photo.fileDataRepresentation() else {
self.continuation.resume(
throwing: NSError(domain: "Camera", code: 1, userInfo: [
NSLocalizedDescriptionKey: "photo data missing",
]))
return
}
2025-12-14 05:30:34 +00:00
if data.isEmpty {
self.continuation.resume(
throwing: NSError(domain: "Camera", code: 2, userInfo: [
NSLocalizedDescriptionKey: "photo data empty",
]))
return
}
2025-12-14 00:48:58 +00:00
self.continuation.resume(returning: data)
}
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
error: Error?
) {
guard let error else { return }
let alreadyResumed = self.resumed.withLock { old in
let was = old
old = true
return was
}
guard !alreadyResumed else { return }
self.continuation.resume(throwing: error)
}
2025-12-14 00:48:58 +00:00
}
private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
private let continuation: CheckedContinuation<URL, Error>
private let resumed = OSAllocatedUnfairLock(initialState: false)
2025-12-14 00:48:58 +00:00
init(_ continuation: CheckedContinuation<URL, Error>) {
self.continuation = continuation
}
func fileOutput(
_ output: AVCaptureFileOutput,
didFinishRecordingTo outputFileURL: URL,
from connections: [AVCaptureConnection],
error: Error?)
{
let alreadyResumed = self.resumed.withLock { old in
let was = old
old = true
return was
}
guard !alreadyResumed else { return }
2025-12-14 00:48:58 +00:00
if let error {
2025-12-14 05:30:34 +00:00
let ns = error as NSError
if ns.domain == AVFoundationErrorDomain,
ns.code == AVError.maximumDurationReached.rawValue
{
self.continuation.resume(returning: outputFileURL)
return
}
2025-12-14 00:48:58 +00:00
self.continuation.resume(throwing: error)
return
}
self.continuation.resume(returning: outputFileURL)
}
}