195 lines
6.8 KiB
Swift
195 lines
6.8 KiB
Swift
|
|
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<Void, Never>?
|
||
|
|
private var lastState = CameraState(connection: .disconnected)
|
||
|
|
|
||
|
|
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
|
||
|
|
|
||
|
|
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))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|