2026-07-10 15:30:22 -04:00
|
|
|
|
import Foundation
|
|
|
|
|
|
import ForgeCamera
|
|
|
|
|
|
import ForgeGrade
|
|
|
|
|
|
import ForgeColor
|
2026-07-11 10:02:56 -04:00
|
|
|
|
import ForgeOffload
|
2026-07-10 15:30:22 -04:00
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
#if canImport(Glibc)
|
|
|
|
|
|
import Glibc
|
|
|
|
|
|
#endif
|
2026-07-10 15:30:22 -04:00
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
/// ALEXA 35 / Mini LF driver — real ARRI CAP protocol:
|
|
|
|
|
|
/// binary TCP :5055, big-endian frames, MD5 challenge auth, variable subscription.
|
|
|
|
|
|
/// Verified against CAP v1.12 spec tables (docs/research/arri-cap-protocol.md).
|
|
|
|
|
|
/// CDL pushed via SetVariable 0x0050 (10×F32 blob). 3D LUT via Set3DLutData —
|
|
|
|
|
|
/// marked experimental until chunk framing verified on hardware.
|
2026-07-10 15:30:22 -04:00
|
|
|
|
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
|
2026-07-11 10:02:56 -04:00
|
|
|
|
private let password: String
|
|
|
|
|
|
private let clientName: String
|
2026-07-10 15:30:22 -04:00
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
private var socketFD: Int32 = -1
|
|
|
|
|
|
private var readTask: Task<Void, Never>?
|
|
|
|
|
|
private var keepAliveTask: Task<Void, Never>?
|
|
|
|
|
|
private var nextMsgId: UInt16 = 1
|
|
|
|
|
|
|
|
|
|
|
|
private var readBuffer = Data()
|
|
|
|
|
|
private var pendingReplies: [UInt16: CheckedContinuation<CAP.Frame, Error>] = [:]
|
2026-07-10 15:30:22 -04:00
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
// Variable cache -> CameraState.
|
|
|
|
|
|
private var varCache: [UInt16: Data] = [:]
|
|
|
|
|
|
private var lastState = CameraState(connection: .disconnected)
|
2026-07-10 15:30:22 -04:00
|
|
|
|
private var stateContinuations: [UUID: AsyncStream<CameraState>.Continuation] = [:]
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
public init(host: String, port: Int = 5055, password: String = "arri", clientName: String = "Forge") {
|
2026-07-10 15:30:22 -04:00
|
|
|
|
self.host = host
|
|
|
|
|
|
self.port = port
|
2026-07-11 10:02:56 -04:00
|
|
|
|
self.password = password
|
|
|
|
|
|
self.clientName = clientName
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
// MARK: Socket
|
|
|
|
|
|
|
|
|
|
|
|
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 \(host):\(port) failed")
|
|
|
|
|
|
}
|
|
|
|
|
|
socketFD = fd
|
|
|
|
|
|
}
|
2026-07-10 15:30:22 -04:00
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
private func closeSocket() {
|
|
|
|
|
|
if socketFD >= 0 {
|
|
|
|
|
|
close(socketFD)
|
|
|
|
|
|
socketFD = -1
|
|
|
|
|
|
}
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
private func sendFrame(_ frame: CAP.Frame) throws {
|
|
|
|
|
|
guard socketFD >= 0 else { throw CameraError.connectionFailed("not connected") }
|
|
|
|
|
|
let data = CAP.encode(frame)
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Send command, await matching reply (msgId correlation).
|
|
|
|
|
|
private func request(_ cmd: CAP.Command, payload: Data = Data(), timeout: Duration = .seconds(3)) async throws -> CAP.Frame {
|
|
|
|
|
|
let msgId = nextMsgId
|
|
|
|
|
|
nextMsgId &+= 1
|
|
|
|
|
|
if nextMsgId == 0 { nextMsgId = 1 }
|
|
|
|
|
|
|
|
|
|
|
|
let frame = CAP.Frame(
|
|
|
|
|
|
msgType: CAP.MsgType.command.rawValue,
|
|
|
|
|
|
msgId: msgId,
|
|
|
|
|
|
cmdCode: cmd.rawValue,
|
|
|
|
|
|
payload: payload)
|
|
|
|
|
|
|
|
|
|
|
|
return try await withCheckedThrowingContinuation { cont in
|
|
|
|
|
|
pendingReplies[msgId] = cont
|
|
|
|
|
|
do {
|
|
|
|
|
|
try sendFrame(frame)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
pendingReplies.removeValue(forKey: msgId)
|
|
|
|
|
|
cont.resume(throwing: error)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
Task {
|
|
|
|
|
|
try? await Task.sleep(for: timeout)
|
|
|
|
|
|
self.timeoutReply(msgId: msgId)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func timeoutReply(msgId: UInt16) {
|
|
|
|
|
|
if let cont = pendingReplies.removeValue(forKey: msgId) {
|
|
|
|
|
|
cont.resume(throwing: CameraError.connectionFailed("reply timeout msgId \(msgId)"))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: Read loop
|
|
|
|
|
|
|
|
|
|
|
|
private func startReadLoop() {
|
|
|
|
|
|
readTask?.cancel()
|
|
|
|
|
|
readTask = Task.detached { [weak self] in
|
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
|
let fd = await self.socketFD
|
|
|
|
|
|
var chunk = [UInt8](repeating: 0, count: 64 * 1024)
|
|
|
|
|
|
while !Task.isCancelled {
|
|
|
|
|
|
let n = read(fd, &chunk, chunk.count)
|
|
|
|
|
|
if n > 0 {
|
|
|
|
|
|
await self.ingest(Data(chunk[0..<n]))
|
|
|
|
|
|
} else if n == 0 {
|
|
|
|
|
|
await self.handleDisconnect()
|
2026-07-10 15:30:22 -04:00
|
|
|
|
return
|
2026-07-11 10:02:56 -04:00
|
|
|
|
} else {
|
|
|
|
|
|
// EAGAIN on timeout: keep looping unless cancelled.
|
|
|
|
|
|
if errno != EAGAIN && errno != EWOULDBLOCK {
|
|
|
|
|
|
await self.handleDisconnect()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
private func ingest(_ data: Data) {
|
|
|
|
|
|
readBuffer.append(data)
|
|
|
|
|
|
while let frame = try? CAP.decodeFirst(&readBuffer) {
|
|
|
|
|
|
route(frame)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func route(_ frame: CAP.Frame) {
|
|
|
|
|
|
switch frame.msgType {
|
|
|
|
|
|
case CAP.MsgType.reply.rawValue:
|
|
|
|
|
|
if let cont = pendingReplies.removeValue(forKey: frame.msgId) {
|
|
|
|
|
|
cont.resume(returning: frame)
|
|
|
|
|
|
} else if frame.cmdCode == CAP.Command.welcome.rawValue || frame.msgId == 0 {
|
|
|
|
|
|
// Unsolicited Welcome on connect — stored implicitly; nothing pending.
|
|
|
|
|
|
}
|
|
|
|
|
|
case CAP.MsgType.event.rawValue:
|
|
|
|
|
|
// Variable update: payload = U16 id + value.
|
|
|
|
|
|
if let id = CAP.readU16(frame.payload, at: 0) {
|
|
|
|
|
|
varCache[id] = frame.payload.subdata(in: frame.payload.startIndex + 2..<frame.payload.endIndex)
|
|
|
|
|
|
rebuildState()
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func handleDisconnect() {
|
|
|
|
|
|
closeSocket()
|
|
|
|
|
|
for (_, cont) in pendingReplies {
|
|
|
|
|
|
cont.resume(throwing: CameraError.connectionFailed("connection closed"))
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
2026-07-11 10:02:56 -04:00
|
|
|
|
pendingReplies.removeAll()
|
|
|
|
|
|
if lastState.connection == .connected {
|
|
|
|
|
|
emit(CameraState(connection: .disconnected))
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
// MARK: State assembly
|
|
|
|
|
|
|
|
|
|
|
|
private func rebuildState() {
|
|
|
|
|
|
func u16(_ v: CAP.Variable) -> UInt16? {
|
|
|
|
|
|
varCache[v.rawValue].flatMap { CAP.readU16($0, at: 0) }
|
|
|
|
|
|
}
|
|
|
|
|
|
func u32(_ v: CAP.Variable) -> UInt32? {
|
|
|
|
|
|
varCache[v.rawValue].flatMap { CAP.readU32($0, at: 0) }
|
|
|
|
|
|
}
|
|
|
|
|
|
func f32(_ v: CAP.Variable) -> Float? {
|
|
|
|
|
|
varCache[v.rawValue].flatMap { CAP.readF32($0, at: 0) }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let bits = CAP.CameraStateBits(rawValue: u16(.cameraState) ?? 0)
|
|
|
|
|
|
// Clip name composed from reel + clip number (full names need GetClipList).
|
|
|
|
|
|
var clipName: String?
|
|
|
|
|
|
if let reel = u16(.currentReel), let clip = u16(.clipNumber), clip > 0 {
|
|
|
|
|
|
clipName = String(format: "R%03dC%03d", reel, clip)
|
|
|
|
|
|
}
|
|
|
|
|
|
let newState = CameraState(
|
|
|
|
|
|
connection: .connected,
|
|
|
|
|
|
isRecording: bits.contains(.recording),
|
|
|
|
|
|
clipName: clipName,
|
|
|
|
|
|
metadata: CameraMetadata(
|
|
|
|
|
|
exposureIndex: u32(.exposureIndex).map(Int.init),
|
|
|
|
|
|
whiteBalance: u32(.colorTemperature).map(Int.init),
|
|
|
|
|
|
tint: f32(.tint).map { Int($0.rounded()) },
|
|
|
|
|
|
fps: f32(.sensorFPS).map(Double.init),
|
|
|
|
|
|
timecode: u32(.timecode).map(CAP.decodeTimecodeBCD)))
|
|
|
|
|
|
if newState != lastState {
|
|
|
|
|
|
emit(newState)
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: CameraDriver
|
|
|
|
|
|
|
|
|
|
|
|
public func connect() async throws {
|
2026-07-11 10:02:56 -04:00
|
|
|
|
closeSocket()
|
|
|
|
|
|
readBuffer.removeAll()
|
|
|
|
|
|
varCache.removeAll()
|
|
|
|
|
|
try openSocket()
|
|
|
|
|
|
startReadLoop()
|
2026-07-10 15:30:22 -04:00
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
// Camera sends Welcome unsolicited; brief settle for it to arrive.
|
|
|
|
|
|
try? await Task.sleep(for: .milliseconds(50))
|
2026-07-10 15:30:22 -04:00
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
// Auth: challenge -> MD5(challenge + password) -> client name.
|
|
|
|
|
|
let challengeReply = try await request(.requestPwdChallenge)
|
|
|
|
|
|
guard challengeReply.cmdCode == CAP.Result.ok.rawValue,
|
|
|
|
|
|
let (challenge, _) = CAP.readString(challengeReply.payload, at: 0) else {
|
|
|
|
|
|
await teardownAfterFailure()
|
|
|
|
|
|
throw CameraError.connectionFailed("challenge request failed")
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
2026-07-11 10:02:56 -04:00
|
|
|
|
|
|
|
|
|
|
let hash = MD5.hexString(Data((challenge + password).utf8))
|
|
|
|
|
|
var pwPayload = Data()
|
|
|
|
|
|
CAP.putString(hash, into: &pwPayload)
|
|
|
|
|
|
let pwReply = try await request(.password, payload: pwPayload)
|
|
|
|
|
|
guard pwReply.cmdCode == CAP.Result.ok.rawValue else {
|
|
|
|
|
|
await teardownAfterFailure()
|
|
|
|
|
|
throw CameraError.connectionFailed("password rejected (result \(pwReply.cmdCode))")
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
2026-07-11 10:02:56 -04:00
|
|
|
|
|
|
|
|
|
|
var namePayload = Data()
|
|
|
|
|
|
CAP.putString(clientName, into: &namePayload)
|
|
|
|
|
|
_ = try await request(.clientName, payload: namePayload)
|
|
|
|
|
|
|
|
|
|
|
|
// Subscribe to state + metadata variables — EVENT-driven from here.
|
|
|
|
|
|
var subPayload = Data()
|
|
|
|
|
|
for v in [CAP.Variable.cameraState, .currentReel, .clipNumber, .exposureIndex,
|
|
|
|
|
|
.colorTemperature, .tint, .sensorFPS, .timecode] {
|
|
|
|
|
|
CAP.putU16(v.rawValue, into: &subPayload)
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
2026-07-11 10:02:56 -04:00
|
|
|
|
_ = try await request(.requestVariables, payload: subPayload)
|
|
|
|
|
|
|
|
|
|
|
|
emit(CameraState(connection: .connected))
|
|
|
|
|
|
startKeepAlive()
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
private func teardownAfterFailure() async {
|
|
|
|
|
|
readTask?.cancel()
|
|
|
|
|
|
readTask = nil
|
|
|
|
|
|
closeSocket()
|
|
|
|
|
|
}
|
2026-07-10 15:30:22 -04:00
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
public func disconnect() async {
|
|
|
|
|
|
keepAliveTask?.cancel()
|
|
|
|
|
|
keepAliveTask = nil
|
|
|
|
|
|
readTask?.cancel()
|
|
|
|
|
|
readTask = nil
|
|
|
|
|
|
closeSocket()
|
|
|
|
|
|
emit(CameraState(connection: .disconnected))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// CAP requires client traffic every 1 s.
|
|
|
|
|
|
private func startKeepAlive() {
|
|
|
|
|
|
keepAliveTask?.cancel()
|
|
|
|
|
|
keepAliveTask = Task {
|
2026-07-10 15:30:22 -04:00
|
|
|
|
while !Task.isCancelled {
|
2026-07-11 10:02:56 -04:00
|
|
|
|
try? await Task.sleep(for: .milliseconds(900))
|
|
|
|
|
|
_ = try? await self.request(.live, timeout: .seconds(2))
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 10:02:56 -04:00
|
|
|
|
public func push(look: FlattenedLook) async throws {
|
|
|
|
|
|
if let cdl = look.cdl {
|
|
|
|
|
|
var payload = Data()
|
|
|
|
|
|
CAP.putU16(CAP.Variable.cdlValues.rawValue, into: &payload)
|
|
|
|
|
|
payload.append(CAP.encodeCDLBlob(
|
|
|
|
|
|
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 reply = try await request(.setVariable, payload: payload)
|
|
|
|
|
|
guard reply.cmdCode == CAP.Result.ok.rawValue else {
|
|
|
|
|
|
throw CameraError.pushFailed("SetVariable CDL -> result \(reply.cmdCode)")
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
2026-07-11 10:02:56 -04:00
|
|
|
|
}
|
|
|
|
|
|
if let lut = look.lut, !lut.isApproximatelyIdentity() {
|
|
|
|
|
|
guard capabilities.lut3dSizes.contains(lut.size) else {
|
|
|
|
|
|
throw CameraError.unsupportedLook("LUT size \(lut.size) not in \(capabilities.lut3dSizes)")
|
|
|
|
|
|
}
|
|
|
|
|
|
// EXPERIMENTAL: Set3DLutData payload layout unverified on hardware
|
|
|
|
|
|
// (chunk framing sections unavailable publicly). Sim mirrors this
|
|
|
|
|
|
// simple layout: U16 lattice size + F32 table.
|
|
|
|
|
|
var payload = Data()
|
|
|
|
|
|
CAP.putU16(UInt16(lut.size), into: &payload)
|
|
|
|
|
|
for v in lut.table {
|
|
|
|
|
|
CAP.putF32(v, into: &payload)
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
2026-07-11 10:02:56 -04:00
|
|
|
|
let reply = try await request(.set3DLutData, payload: payload, timeout: .seconds(10))
|
|
|
|
|
|
guard reply.cmdCode == CAP.Result.ok.rawValue else {
|
|
|
|
|
|
throw CameraError.pushFailed("Set3DLutData -> result \(reply.cmdCode)")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if look.cdl == nil && (look.lut == nil || look.lut!.isApproximatelyIdentity()) {
|
|
|
|
|
|
throw CameraError.unsupportedLook("nothing to push")
|
2026-07-10 15:30:22 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|