Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
377 lines
13 KiB
Swift
377 lines
13 KiB
Swift
import AVFoundation
|
|
import CoreGraphics
|
|
import Foundation
|
|
import OpenClawIPC
|
|
import OpenClawKit
|
|
import OSLog
|
|
|
|
actor CameraCaptureService {
|
|
struct CameraDeviceInfo: Encodable {
|
|
let id: String
|
|
let name: String
|
|
let position: String
|
|
let deviceType: String
|
|
}
|
|
|
|
enum CameraError: LocalizedError {
|
|
case cameraUnavailable
|
|
case microphoneUnavailable
|
|
case permissionDenied(kind: 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 .captureFailed(msg):
|
|
msg
|
|
case let .exportFailed(msg):
|
|
msg
|
|
}
|
|
}
|
|
}
|
|
|
|
private let logger = Logger(subsystem: "ai.openclaw", category: "camera")
|
|
|
|
func listDevices() -> [CameraDeviceInfo] {
|
|
Self.availableCameras().map { device in
|
|
CameraDeviceInfo(
|
|
id: device.uniqueID,
|
|
name: device.localizedName,
|
|
position: Self.positionLabel(device.position),
|
|
deviceType: device.deviceType.rawValue)
|
|
}
|
|
}
|
|
|
|
func snap(
|
|
facing: CameraFacing?,
|
|
maxWidth: Int?,
|
|
quality: Double?,
|
|
deviceId: String?,
|
|
delayMs: Int) async throws -> (data: Data, size: CGSize)
|
|
{
|
|
let facing = facing ?? .front
|
|
let normalized = Self.normalizeSnap(maxWidth: maxWidth, quality: quality)
|
|
let maxWidth = normalized.maxWidth
|
|
let quality = normalized.quality
|
|
let delayMs = max(0, delayMs)
|
|
let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
try await self.ensureAccess(for: .video)
|
|
|
|
let prepared = try CameraCapturePipelineSupport.preparePhotoSession(
|
|
preferFrontCamera: facing == .front,
|
|
deviceId: 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 device = prepared.device
|
|
let output = prepared.output
|
|
|
|
session.startRunning()
|
|
defer { session.stopRunning() }
|
|
await CameraCapturePipelineSupport.warmUpCaptureSession()
|
|
await self.waitForExposureAndWhiteBalance(device: device)
|
|
await self.sleepDelayMs(delayMs)
|
|
|
|
var delegate: PhotoCaptureDelegate?
|
|
let rawData: Data = try await withCheckedThrowingContinuation { continuation in
|
|
let captureDelegate = PhotoCaptureDelegate(continuation)
|
|
delegate = captureDelegate
|
|
output.capturePhoto(
|
|
with: CameraCapturePipelineSupport.makePhotoSettings(output: output),
|
|
delegate: captureDelegate)
|
|
}
|
|
withExtendedLifetime(delegate) {}
|
|
|
|
let res: (data: Data, widthPx: Int, heightPx: Int)
|
|
do {
|
|
res = try PhotoCapture.transcodeJPEGForGateway(
|
|
rawData: rawData,
|
|
maxWidthPx: maxWidth,
|
|
quality: quality)
|
|
} catch {
|
|
throw CameraError.captureFailed(error.localizedDescription)
|
|
}
|
|
|
|
return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx))
|
|
}
|
|
|
|
func clip(
|
|
facing: CameraFacing?,
|
|
durationMs: Int?,
|
|
includeAudio: Bool,
|
|
deviceId: String?,
|
|
outPath: String?) async throws -> (path: String, durationMs: Int, hasAudio: Bool)
|
|
{
|
|
let facing = facing ?? .front
|
|
let durationMs = Self.clampDurationMs(durationMs)
|
|
let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
try await self.ensureAccess(for: .video)
|
|
if includeAudio {
|
|
try await self.ensureAccess(for: .audio)
|
|
}
|
|
|
|
let prepared = try await CameraCapturePipelineSupport.prepareWarmMovieSession(
|
|
preferFrontCamera: facing == .front,
|
|
deviceId: deviceId,
|
|
includeAudio: includeAudio,
|
|
durationMs: durationMs,
|
|
pickCamera: { preferFrontCamera, deviceId in
|
|
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
|
|
},
|
|
cameraUnavailableError: CameraError.cameraUnavailable,
|
|
mapSetupError: Self.mapMovieSetupError)
|
|
let session = prepared.session
|
|
let output = prepared.output
|
|
defer { session.stopRunning() }
|
|
|
|
let tmpMovURL = FileManager().temporaryDirectory
|
|
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov")
|
|
defer { try? FileManager().removeItem(at: tmpMovURL) }
|
|
|
|
let outputURL: URL = {
|
|
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
return URL(fileURLWithPath: outPath)
|
|
}
|
|
return FileManager().temporaryDirectory
|
|
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4")
|
|
}()
|
|
// Ensure we don't fail exporting due to an existing file.
|
|
try? FileManager().removeItem(at: outputURL)
|
|
|
|
let logger = self.logger
|
|
var delegate: MovieFileDelegate?
|
|
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
|
|
let d = MovieFileDelegate(cont, logger: logger)
|
|
delegate = d
|
|
output.startRecording(to: tmpMovURL, recordingDelegate: d)
|
|
}
|
|
withExtendedLifetime(delegate) {}
|
|
try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL)
|
|
return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio)
|
|
}
|
|
|
|
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
|
if await !(CameraAuthorization.isAuthorized(for: mediaType)) {
|
|
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
|
}
|
|
}
|
|
|
|
private nonisolated static func availableCameras() -> [AVCaptureDevice] {
|
|
var types: [AVCaptureDevice.DeviceType] = [
|
|
.builtInWideAngleCamera,
|
|
.continuityCamera,
|
|
]
|
|
if let external = externalDeviceType() {
|
|
types.append(external)
|
|
}
|
|
let session = AVCaptureDevice.DiscoverySession(
|
|
deviceTypes: types,
|
|
mediaType: .video,
|
|
position: .unspecified)
|
|
return session.devices
|
|
}
|
|
|
|
private nonisolated static func externalDeviceType() -> AVCaptureDevice.DeviceType? {
|
|
if #available(macOS 14.0, *) {
|
|
return .external
|
|
}
|
|
// Use raw value to avoid deprecated symbol in the SDK.
|
|
return AVCaptureDevice.DeviceType(rawValue: "AVCaptureDeviceTypeExternalUnknown")
|
|
}
|
|
|
|
private nonisolated static func pickCamera(
|
|
facing: CameraFacing,
|
|
deviceId: String?) -> AVCaptureDevice?
|
|
{
|
|
if let deviceId, !deviceId.isEmpty {
|
|
if let match = availableCameras().first(where: { $0.uniqueID == deviceId }) {
|
|
return match
|
|
}
|
|
}
|
|
let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back
|
|
|
|
if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) {
|
|
return device
|
|
}
|
|
|
|
// Many macOS cameras report `unspecified` position; fall back to any default.
|
|
return AVCaptureDevice.default(for: .video)
|
|
}
|
|
|
|
private nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
|
let q = quality ?? 0.9
|
|
return min(1.0, max(0.05, q))
|
|
}
|
|
|
|
nonisolated static func normalizeSnap(maxWidth: Int?, quality: Double?) -> (maxWidth: Int, quality: Double) {
|
|
// Default to a reasonable max width to keep downstream payload sizes manageable.
|
|
// If you need full-res, explicitly request a larger maxWidth.
|
|
let maxWidth = maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
|
|
let quality = Self.clampQuality(quality)
|
|
return (maxWidth: maxWidth, quality: quality)
|
|
}
|
|
|
|
private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
|
|
let v = ms ?? 3000
|
|
return min(60000, max(250, v))
|
|
}
|
|
|
|
private nonisolated static func mapMovieSetupError(_ setupError: CameraSessionConfigurationError) -> CameraError {
|
|
CameraCapturePipelineSupport.mapMovieSetupError(
|
|
setupError,
|
|
microphoneUnavailableError: .microphoneUnavailable,
|
|
captureFailed: { .captureFailed($0) })
|
|
}
|
|
|
|
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
|
|
let asset = AVURLAsset(url: inputURL)
|
|
guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
|
|
throw CameraError.exportFailed("Failed to create export session")
|
|
}
|
|
export.shouldOptimizeForNetworkUse = true
|
|
|
|
if #available(macOS 15.0, *) {
|
|
do {
|
|
try await export.export(to: outputURL, as: .mp4)
|
|
return
|
|
} catch {
|
|
throw CameraError.exportFailed(error.localizedDescription)
|
|
}
|
|
} else {
|
|
export.outputURL = outputURL
|
|
export.outputFileType = .mp4
|
|
|
|
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
|
export.exportAsynchronously {
|
|
cont.resume(returning: ())
|
|
}
|
|
}
|
|
|
|
switch export.status {
|
|
case .completed:
|
|
return
|
|
case .failed:
|
|
throw CameraError.exportFailed(export.error?.localizedDescription ?? "export failed")
|
|
case .cancelled:
|
|
throw CameraError.exportFailed("export cancelled")
|
|
default:
|
|
throw CameraError.exportFailed("export did not complete (\(export.status.rawValue))")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async {
|
|
let stepNs: UInt64 = 50_000_000
|
|
let maxSteps = 30 // ~1.5s
|
|
for _ in 0..<maxSteps {
|
|
if !(device.isAdjustingExposure || device.isAdjustingWhiteBalance) {
|
|
return
|
|
}
|
|
try? await Task.sleep(nanoseconds: stepNs)
|
|
}
|
|
}
|
|
|
|
private func sleepDelayMs(_ delayMs: Int) async {
|
|
guard delayMs > 0 else { return }
|
|
let ns = UInt64(min(delayMs, 10000)) * 1_000_000
|
|
try? await Task.sleep(nanoseconds: ns)
|
|
}
|
|
|
|
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
|
|
CameraCapturePipelineSupport.positionLabel(position)
|
|
}
|
|
}
|
|
|
|
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
|
private var cont: CheckedContinuation<Data, Error>?
|
|
private var didResume = false
|
|
|
|
init(_ cont: CheckedContinuation<Data, Error>) {
|
|
self.cont = cont
|
|
}
|
|
|
|
func photoOutput(
|
|
_ output: AVCapturePhotoOutput,
|
|
didFinishProcessingPhoto photo: AVCapturePhoto,
|
|
error: Error?)
|
|
{
|
|
guard !self.didResume, let cont else { return }
|
|
self.didResume = true
|
|
self.cont = nil
|
|
if let error {
|
|
cont.resume(throwing: error)
|
|
return
|
|
}
|
|
guard let data = photo.fileDataRepresentation() else {
|
|
cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("No photo data"))
|
|
return
|
|
}
|
|
if data.isEmpty {
|
|
cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("Photo data empty"))
|
|
return
|
|
}
|
|
cont.resume(returning: data)
|
|
}
|
|
|
|
func photoOutput(
|
|
_ output: AVCapturePhotoOutput,
|
|
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
|
|
error: Error?)
|
|
{
|
|
guard let error else { return }
|
|
guard !self.didResume, let cont else { return }
|
|
self.didResume = true
|
|
self.cont = nil
|
|
cont.resume(throwing: error)
|
|
}
|
|
}
|
|
|
|
private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
|
|
private var cont: CheckedContinuation<URL, Error>?
|
|
private let logger: Logger
|
|
|
|
init(_ cont: CheckedContinuation<URL, Error>, logger: Logger) {
|
|
self.cont = cont
|
|
self.logger = logger
|
|
}
|
|
|
|
func fileOutput(
|
|
_ output: AVCaptureFileOutput,
|
|
didFinishRecordingTo outputFileURL: URL,
|
|
from connections: [AVCaptureConnection],
|
|
error: Error?)
|
|
{
|
|
guard let cont else { return }
|
|
self.cont = nil
|
|
|
|
if let error {
|
|
let ns = error as NSError
|
|
if ns.domain == AVFoundationErrorDomain,
|
|
ns.code == AVError.maximumDurationReached.rawValue
|
|
{
|
|
cont.resume(returning: outputFileURL)
|
|
return
|
|
}
|
|
|
|
self.logger.error("camera record failed: \(error.localizedDescription, privacy: .public)")
|
|
cont.resume(throwing: error)
|
|
return
|
|
}
|
|
|
|
cont.resume(returning: outputFileURL)
|
|
}
|
|
}
|