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 — POSTURE PER RESEARCH (docs/research/arri-cap-protocol.md, Sony /// section): Venice IP protocol is CLOSED (partner products speak it natively; public /// path is only the authenticated web remote at /rmt.html). This driver keeps our /// provisional REST shape for the simulator and future backend-capture correction, /// but capabilities are downgraded: NO look push claimed (lut3dSizes empty, /// no native CDL) until verified on hardware/partner docs. On-set Venice grading /// path today = SDI-chain LUT box / DeckLink output, not camera-internal. public actor SonyDriver: CameraDriver { public nonisolated let capabilities = CameraCapabilities( vendor: .sony, supportsNativeCDL: false, lut3dSizes: [], 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 { // Venice IP look control is closed/unverified (see research doc). // Grade Venice via SDI-chain LUT box or DeckLink output. Re-enable // once a real wire path is proven (partner SDK or backend capture). throw CameraError.unsupportedLook( "Venice look push over IP unverified — use SDI-chain LUT box; monitoring-only driver") } // 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)) } } } }