rainbow-dragon/Sources/ForgeCameraRED/RedDriver.swift

245 lines
8.7 KiB
Swift

import Foundation
import ForgeCamera
import ForgeGrade
import ForgeColor
#if canImport(Glibc)
import Glibc
#endif
/// RED parameter names single source of truth, correct against RED SDK
/// (RCP2) when hardware/SDK agreement lands. Sim mirrors these.
enum RedParams {
static let recordState = "RECORD_STATE"
static let clipName = "CLIP_NAME"
static let iso = "ISO"
static let colorTemp = "COLOR_TEMP"
static let tint = "TINT"
static let sensorFPS = "SENSOR_FPS"
static let timecode = "TIMECODE"
static let ipp2CDL = "IPP2_CDL"
}
/// DSMC3 driver: RCP2-style length-prefixed JSON over TCP.
/// Request/response serialized through the actor one in-flight request.
public actor RedDriver: CameraDriver {
public nonisolated let capabilities = CameraCapabilities(
vendor: .red,
supportsNativeCDL: true,
lut3dSizes: [17, 33],
metadataFields: [.clipName, .exposureIndex, .whiteBalance, .tint, .fps, .timecode])
private let host: String
private let port: Int
private let pollInterval: Duration
private var socketFD: Int32 = -1
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
}
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: Socket transport (blocking, actor-serialized, short timeouts)
private func openSocket() throws {
let fd = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0)
guard fd >= 0 else { throw CameraError.connectionFailed("socket() failed") }
var tv = timeval(tv_sec: 3, tv_usec: 0)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = in_port_t(UInt16(port).bigEndian)
guard inet_pton(AF_INET, host, &addr.sin_addr) == 1 else {
close(fd)
throw CameraError.connectionFailed("bad host \(host)")
}
let rc = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
Glibc.connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard rc == 0 else {
close(fd)
throw CameraError.connectionFailed("connect to \(host):\(port) failed")
}
socketFD = fd
}
private func closeSocket() {
if socketFD >= 0 {
close(socketFD)
socketFD = -1
}
}
private func sendAll(_ data: Data) throws {
var sent = 0
try data.withUnsafeBytes { (buf: UnsafeRawBufferPointer) in
while sent < buf.count {
let n = write(socketFD, buf.baseAddress!.advanced(by: sent), buf.count - sent)
guard n > 0 else { throw CameraError.connectionFailed("write failed") }
sent += n
}
}
}
private func recvExact(_ count: Int) throws -> Data {
var out = Data(capacity: count)
var remaining = count
var chunk = [UInt8](repeating: 0, count: 64 * 1024)
while remaining > 0 {
let n = read(socketFD, &chunk, min(remaining, chunk.count))
guard n > 0 else { throw CameraError.connectionFailed("read failed/timeout") }
out.append(contentsOf: chunk[0..<n])
remaining -= n
}
return out
}
/// Send framed JSON request, await framed JSON reply.
private func request(_ obj: [String: Any]) throws -> [String: Any] {
guard socketFD >= 0 else { throw CameraError.connectionFailed("not connected") }
let payload = try JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys])
var frame = Data()
var lenBE = UInt32(payload.count).bigEndian
withUnsafeBytes(of: &lenBE) { frame.append(contentsOf: $0) }
frame.append(payload)
try sendAll(frame)
let header = try recvExact(4)
// Byte-wise assembly Data slices may be misaligned for a u32 load.
let length = header.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
let body = try recvExact(Int(length))
guard let reply = try JSONSerialization.jsonObject(with: body) as? [String: Any] else {
throw CameraError.connectionFailed("bad reply JSON")
}
return reply
}
private func getParam(_ name: String) throws -> String {
let reply = try request(["type": "get", "param": name])
guard reply["ok"] as? Bool == true, let value = reply["value"] as? String else {
throw CameraError.connectionFailed("get \(name) failed")
}
return value
}
private func setParam(_ name: String, _ value: String) throws {
let reply = try request(["type": "set", "param": name, "value": value])
guard reply["ok"] as? Bool == true else {
throw CameraError.pushFailed("set \(name) failed")
}
}
// MARK: CameraDriver
public func connect() async throws {
closeSocket()
try openSocket()
let reply = try request(["type": "handshake"])
guard reply["ok"] as? Bool == true else {
closeSocket()
throw CameraError.connectionFailed("handshake rejected")
}
emit(CameraState(connection: .connected))
startPolling()
}
public func disconnect() async {
pollTask?.cancel()
pollTask = nil
closeSocket()
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)")
}
if let cdl = look.cdl {
let obj: [String: Any] = [
"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,
]
let json = String(data: try JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]), encoding: .utf8)!
try setParam(RedParams.ipp2CDL, json)
}
if let lut = look.lut {
let reply = try request(["type": "lut", "size": lut.size, "table": lut.table])
guard reply["ok"] as? Bool == true else {
throw CameraError.pushFailed("LUT upload rejected")
}
}
}
// MARK: Polling
private func startPolling() {
pollTask?.cancel()
pollTask = Task {
while !Task.isCancelled {
pollOnce()
try? await Task.sleep(for: pollInterval)
}
}
}
private func pollOnce() {
do {
let rec = try getParam(RedParams.recordState) == "1"
let clip = try getParam(RedParams.clipName)
let newState = CameraState(
connection: .connected,
isRecording: rec,
clipName: clip.isEmpty ? nil : clip,
metadata: CameraMetadata(
exposureIndex: Int(try getParam(RedParams.iso)),
whiteBalance: Int(try getParam(RedParams.colorTemp)),
tint: Int(try getParam(RedParams.tint)),
fps: Double(try getParam(RedParams.sensorFPS)),
timecode: try getParam(RedParams.timecode)))
if newState != lastState {
emit(newState)
}
} catch {
if lastState.connection == .connected {
emit(CameraState(connection: .disconnected))
}
}
}
}