167 lines
5.9 KiB
Swift
167 lines
5.9 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 — 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<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 {
|
|
// 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))
|
|
}
|
|
}
|
|
}
|
|
}
|