rainbow-dragon/Sources/ForgeCameraSony/SonyDriver.swift

177 lines
6.1 KiB
Swift

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<Void, Never>?
private var lastState = CameraState(connection: .disconnected)
// Async registration OK here poll loop re-emits on change (see ArriDriver note).
private var stateContinuations: [UUID: AsyncStream<CameraState>.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<CameraState> {
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<CameraState>.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))
}
}
}
}