import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif import ForgeCamera import ForgeGrade import ForgeColor /// ARRI REST endpoint paths — single source of truth for this driver. /// NOTE: shapes modeled on ARRI's documented Camera Companion/REST API family; /// verify + correct against hardware/API docs in macOS phase. Sim mirrors these. enum ArriPaths { static let systemInfo = "/api/v1/system/info" static let recordingStatus = "/api/v1/recording/status" static let metadata = "/api/v1/camera/metadata" static let lookCurrent = "/api/v1/look/current" } /// ALEXA 35 driver: REST over IP. Polls rec state + metadata, pushes CDL+LUT looks. public actor ArriDriver: CameraDriver { public nonisolated let capabilities = CameraCapabilities( vendor: .arri, supportsNativeCDL: true, lut3dSizes: [17, 33, 65], metadataFields: [.clipName, .exposureIndex, .whiteBalance, .tint, .fps, .timecode]) private let host: String private let port: Int private let pollInterval: Duration private let session: URLSession private var pollTask: Task? private var lastState = CameraState(connection: .disconnected) private var stateContinuations: [UUID: AsyncStream.Continuation] = [:] public init(host: String, port: Int, pollInterval: Duration = .milliseconds(150)) { self.host = host self.port = port self.pollInterval = pollInterval let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = 3 session = URLSession(configuration: config) } public nonisolated var state: AsyncStream { AsyncStream { continuation in let id = UUID() Task { await self.register(id: id, continuation: continuation) } continuation.onTermination = { _ in Task { await self.unregister(id: id) } } } } private func register(id: UUID, continuation: AsyncStream.Continuation) { stateContinuations[id] = continuation } private func unregister(id: UUID) { stateContinuations.removeValue(forKey: id) } private func emit(_ s: CameraState) { lastState = s for c in stateContinuations.values { c.yield(s) } } // MARK: HTTP private func url(_ path: String) -> URL { URL(string: "http://\(host):\(port)\(path)")! } /// Completion-handler dataTask wrapped in a continuation. The async /// URLSession API hangs intermittently on Linux (dead-connection reuse); /// the completion path always fires — success, error, or timeout. private func perform(_ req: URLRequest) async throws -> (Data, HTTPURLResponse) { try await withCheckedThrowingContinuation { cont in let task = session.dataTask(with: req) { data, resp, err in if let err { cont.resume(throwing: err) return } guard let http = resp as? HTTPURLResponse else { cont.resume(throwing: CameraError.connectionFailed("no HTTP response")) return } cont.resume(returning: (data ?? Data(), http)) } task.resume() } } private func getJSON(_ path: String) async throws -> [String: Any] { let (data, http) = try await perform(URLRequest(url: url(path))) guard http.statusCode == 200 else { throw CameraError.connectionFailed("GET \(path) -> \(http.statusCode)") } guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw CameraError.connectionFailed("GET \(path): bad JSON") } return obj } private func putJSON(_ path: String, body: Data) async throws { var req = URLRequest(url: url(path)) req.httpMethod = "PUT" req.httpBody = body req.setValue("application/json", forHTTPHeaderField: "Content-Type") let (_, http) = try await perform(req) guard http.statusCode == 200 else { throw CameraError.pushFailed("PUT \(path) -> \(http.statusCode)") } } // MARK: CameraDriver public func connect() async throws { // Verify camera reachable via system info. do { _ = try await getJSON(ArriPaths.systemInfo) } catch { throw CameraError.connectionFailed("system info unreachable: \(error)") } emit(CameraState(connection: .connected)) startPolling() } public func disconnect() async { pollTask?.cancel() pollTask = nil emit(CameraState(connection: .disconnected)) } public func push(look: FlattenedLook) async throws { if let lut = look.lut, !capabilities.lut3dSizes.contains(lut.size) { throw CameraError.unsupportedLook("LUT size \(lut.size) not in \(capabilities.lut3dSizes)") } var payload: [String: Any] = [:] if let cdl = look.cdl { payload["cdl"] = [ "slope": [cdl.slope.x, cdl.slope.y, cdl.slope.z], "offset": [cdl.offset.x, cdl.offset.y, cdl.offset.z], "power": [cdl.power.x, cdl.power.y, cdl.power.z], "saturation": cdl.saturation, ] } if let lut = look.lut { payload["lut3dSize"] = lut.size payload["lut3dTable"] = lut.table } let body = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) try await putJSON(ArriPaths.lookCurrent, body: body) } // MARK: Polling private func startPolling() { pollTask?.cancel() pollTask = Task { while !Task.isCancelled { await pollOnce() try? await Task.sleep(for: pollInterval) } } } private func pollOnce() async { do { let rec = try await getJSON(ArriPaths.recordingStatus) let md = try await getJSON(ArriPaths.metadata) let newState = CameraState( connection: .connected, isRecording: rec["recording"] as? Bool ?? false, clipName: rec["clipName"] as? String, metadata: CameraMetadata( exposureIndex: md["exposureIndex"] as? Int, whiteBalance: md["whiteBalance"] as? Int, tint: md["tint"] as? Int, fps: md["fps"] as? Double, timecode: md["timecode"] as? String)) if newState != lastState { emit(newState) } } catch { if lastState.connection == .connected { emit(CameraState(connection: .disconnected)) } } } }