import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif import ForgeCamera import ForgeGrade import ForgeColor /// Sony CNA endpoint paths — single source of truth. Correct against Sony /// protocol docs in hardware phase; sim mirrors these. enum SonyPaths { static let cameraInfo = "/cna/v1/camera/info" static let status = "/cna/v1/camera/status" static let userLut = "/cna/v1/monitoring/lut" } /// Venice 2 driver: REST over IP. Capability-narrow — user 3D LUT push only, /// no native CDL (grade pipeline must full-bake for this camera). public actor SonyDriver: CameraDriver { public nonisolated let capabilities = CameraCapabilities( vendor: .sony, supportsNativeCDL: false, lut3dSizes: [17, 33], 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) // Async registration OK here — poll loop re-emits on change (see ArriDriver note). 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 (completion-handler path — async URLSession hangs on Linux) private func url(_ path: String) -> URL { URL(string: "http://\(host):\(port)\(path)")! } 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, let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw CameraError.connectionFailed("GET \(path) failed") } return obj } // MARK: CameraDriver public func connect() async throws { do { _ = try await getJSON(SonyPaths.cameraInfo) } catch { throw CameraError.connectionFailed("camera 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 { guard look.cdl == nil else { throw CameraError.unsupportedLook("Venice has no native CDL — re-flatten with splitLeadingCDL=false") } guard let lut = look.lut else { throw CameraError.unsupportedLook("nothing to push") } guard capabilities.lut3dSizes.contains(lut.size) else { throw CameraError.unsupportedLook("LUT size \(lut.size) not in \(capabilities.lut3dSizes)") } let payload = try JSONSerialization.data( withJSONObject: ["size": lut.size, "table": lut.table], options: [.sortedKeys]) var req = URLRequest(url: url(SonyPaths.userLut)) req.httpMethod = "PUT" req.httpBody = payload req.setValue("application/json", forHTTPHeaderField: "Content-Type") let (_, http) = try await perform(req) guard http.statusCode == 200 else { throw CameraError.pushFailed("LUT upload -> \(http.statusCode)") } } // 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 s = try await getJSON(SonyPaths.status) let newState = CameraState( connection: .connected, isRecording: s["recording"] as? Bool ?? false, clipName: s["clipName"] as? String, metadata: CameraMetadata( exposureIndex: s["exposureIndex"] as? Int, whiteBalance: s["whiteBalance"] as? Int, tint: s["tint"] as? Int, fps: s["fps"] as? Double, timecode: s["timecode"] as? String)) if newState != lastState { emit(newState) } } catch { if lastState.connection == .connected { emit(CameraState(connection: .disconnected)) } } } }