Compare commits

...

4 commits

14 changed files with 1758 additions and 424 deletions

View file

@ -18,6 +18,8 @@ let package = Package(
// Simulated cameras // Simulated cameras
.target(name: "ForgeSim", dependencies: [ .target(name: "ForgeSim", dependencies: [
"ForgeCamera", "ForgeCamera",
"ForgeCameraARRI", // CAP frame codec shared by ARRI sim
"ForgeOffload", // MD5 for CAP challenge verification
.product(name: "NIO", package: "swift-nio"), .product(name: "NIO", package: "swift-nio"),
.product(name: "NIOHTTP1", package: "swift-nio"), .product(name: "NIOHTTP1", package: "swift-nio"),
.product(name: "NIOWebSocket", package: "swift-nio"), .product(name: "NIOWebSocket", package: "swift-nio"),
@ -25,8 +27,8 @@ let package = Package(
// Vendor drivers // Vendor drivers
.target(name: "ForgeCameraARRI", dependencies: [ .target(name: "ForgeCameraARRI", dependencies: [
"ForgeCamera", "ForgeCamera",
"ForgeOffload", // MD5 for CAP password challenge
.product(name: "NIO", package: "swift-nio"), .product(name: "NIO", package: "swift-nio"),
.product(name: "NIOHTTP1", package: "swift-nio"),
]), ]),
.target(name: "ForgeCameraRED", dependencies: [ .target(name: "ForgeCameraRED", dependencies: [
"ForgeCamera", "ForgeCamera",

61
README.md Normal file
View file

@ -0,0 +1,61 @@
# Rainbow Dragon (Forge)
On-set live grading + DIT toolkit. Grades live **in the camera** over IP — no LUT box
required — plus verified offload, ASC MHL, clip logging, and proxy generation.
Sister product to dragonflight.
## What it does
- **In-camera live grading over IP**
- ARRI ALEXA 35 / Mini LF — CAP protocol (binary TCP :5055): native ASC CDL push,
3D LUT (experimental), EVENT-driven metadata
- RED DSMC3 (Komodo/Raptor) — RCP2 (WebSocket :9998): native CDL via per-channel params
- Sony VENICE 2 — monitoring-only (protocol closed; grade via SDI chain)
- **Node-based grade engine** — input transform (LogC4 / Log3G10 / S-Log3 + gamuts),
CDL, saturation, monotone curves, 3D LUT, output transform; flattens to camera-ready
looks with CDL-split for live trim
- **Multicam** — gang slots, per-camera capability-aware flattening, debounced push
- **Auto clip logging** — rec events → shot records w/ camera metadata + grade snapshot
- **Deliverables** — ALE, CMX3600 EDL (ASC_SOP/SAT), CDL/CCC, CUBE, CSV
- **Offload** — multi-destination verified copy (xxHash64/MD5), **ASC MHL v2.0**
manifests (validates against official XSD), tamper detection, HTML/CSV reports
- **Proxies** — ffmpeg ProRes/DNxHR/H.264, grade bake via LUT, TC/clip burn-ins,
watch folders
- **Panels** — Tangent Wave control mapping (Hub TIPC shim lands in macOS phase)
## Status
Core engine + protocol layer complete: **200 tests**, all camera drivers verified against
wire-faithful simulators built from real protocol research (`docs/research/`).
macOS app (SwiftUI + Metal preview + DeckLink + Tangent Hub) is the next phase.
## CLI
```
forge sim # run an ARRI CAP camera simulator
forge status --host <ip> --port 5055 # connection/rec state + metadata
forge push --host <ip> --look l.json --split-cdl
forge export --db forge.db --day "Day 01" --format ale|edl|ccc|cube|csv --out f
forge offload <card> --to /raid --to /shuttle --report report.html
forge verify <manifest.mhl>
forge proxy --db forge.db --day "Day 01" --media /raid --out /proxies --burn-look
```
## Build
Swift 6.0+, Linux or macOS:
```
swift build && swift test
```
## Layout
- `Sources/ForgeColor` — color math (CDL, transfer functions, LUTs, curves)
- `Sources/ForgeGrade` — node stack, flattening, snapshots
- `Sources/ForgeCamera*` — driver seam + ARRI/RED/Sony drivers
- `Sources/ForgeSim` — protocol-faithful camera simulators
- `Sources/ForgeLogger` / `ForgeLibrary` — clip logging, SQLite shot store
- `Sources/ForgeExport` / `ForgeOffload` / `ForgeProxy` — deliverables, offload/MHL, proxies
- `Sources/ForgePanel` / `ForgeVideo` — control surfaces, video I/O seams
- `docs/plans/` — implementation plans; `docs/research/` — protocol + competitive research

View file

@ -1,22 +1,18 @@
import Foundation import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import ForgeCamera import ForgeCamera
import ForgeGrade import ForgeGrade
import ForgeColor import ForgeColor
import ForgeOffload
/// ARRI REST endpoint paths single source of truth for this driver. #if canImport(Glibc)
/// NOTE: shapes modeled on ARRI's documented Camera Companion/REST API family; import Glibc
/// verify + correct against hardware/API docs in macOS phase. Sim mirrors these. #endif
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. /// 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.
public actor ArriDriver: CameraDriver { public actor ArriDriver: CameraDriver {
public nonisolated let capabilities = CameraCapabilities( public nonisolated let capabilities = CameraCapabilities(
vendor: .arri, vendor: .arri,
@ -26,25 +22,27 @@ public actor ArriDriver: CameraDriver {
private let host: String private let host: String
private let port: Int private let port: Int
private let pollInterval: Duration private let password: String
private let session: URLSession private let clientName: String
private var pollTask: Task<Void, Never>? 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>] = [:]
// Variable cache -> CameraState.
private var varCache: [UInt16: Data] = [:]
private var lastState = CameraState(connection: .disconnected) private var lastState = CameraState(connection: .disconnected)
// NOTE: registration is async (actor hop). Safe here: the poll loop
// re-emits on every state *change* vs lastState, so a subscriber attached
// moments late still receives the next change. (A lock-based
// nonisolated-let variant crashed swiftc 6.0.3 codegen on Linux.)
private var stateContinuations: [UUID: AsyncStream<CameraState>.Continuation] = [:] private var stateContinuations: [UUID: AsyncStream<CameraState>.Continuation] = [:]
public init(host: String, port: Int, pollInterval: Duration = .milliseconds(150)) { public init(host: String, port: Int = 5055, password: String = "arri", clientName: String = "Forge") {
self.host = host self.host = host
self.port = port self.port = port
self.pollInterval = pollInterval self.password = password
let config = URLSessionConfiguration.ephemeral self.clientName = clientName
config.timeoutIntervalForRequest = 3
session = URLSession(configuration: config)
} }
public nonisolated var state: AsyncStream<CameraState> { public nonisolated var state: AsyncStream<CameraState> {
@ -72,127 +70,289 @@ public actor ArriDriver: CameraDriver {
} }
} }
// MARK: HTTP // MARK: Socket
private func url(_ path: String) -> URL { private func openSocket() throws {
URL(string: "http://\(host):\(port)\(path)")! 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
} }
/// Completion-handler dataTask wrapped in a continuation. The async private func closeSocket() {
/// URLSession API hangs intermittently on Linux (dead-connection reuse); if socketFD >= 0 {
/// the completion path always fires success, error, or timeout. close(socketFD)
private func perform(_ req: URLRequest) async throws -> (Data, HTTPURLResponse) { socketFD = -1
try await withCheckedThrowingContinuation { cont in }
let task = session.dataTask(with: req) { data, resp, err in }
if let err {
cont.resume(throwing: err) 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 return
} }
guard let http = resp as? HTTPURLResponse else { Task {
cont.resume(throwing: CameraError.connectionFailed("no HTTP response")) 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()
return
} else {
// EAGAIN on timeout: keep looping unless cancelled.
if errno != EAGAIN && errno != EWOULDBLOCK {
await self.handleDisconnect()
return return
} }
cont.resume(returning: (data ?? Data(), http))
} }
task.resume() }
} }
} }
private func getJSON(_ path: String) async throws -> [String: Any] { private func ingest(_ data: Data) {
let (data, http) = try await perform(URLRequest(url: url(path))) readBuffer.append(data)
guard http.statusCode == 200 else { while let frame = try? CAP.decodeFirst(&readBuffer) {
throw CameraError.connectionFailed("GET \(path) -> \(http.statusCode)") route(frame)
} }
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 { private func route(_ frame: CAP.Frame) {
var req = URLRequest(url: url(path)) switch frame.msgType {
req.httpMethod = "PUT" case CAP.MsgType.reply.rawValue:
req.httpBody = body if let cont = pendingReplies.removeValue(forKey: frame.msgId) {
req.setValue("application/json", forHTTPHeaderField: "Content-Type") cont.resume(returning: frame)
let (_, http) = try await perform(req) } else if frame.cmdCode == CAP.Command.welcome.rawValue || frame.msgId == 0 {
guard http.statusCode == 200 else { // Unsolicited Welcome on connect stored implicitly; nothing pending.
throw CameraError.pushFailed("PUT \(path) -> \(http.statusCode)") }
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"))
}
pendingReplies.removeAll()
if lastState.connection == .connected {
emit(CameraState(connection: .disconnected))
}
}
// 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)
} }
} }
// MARK: CameraDriver // MARK: CameraDriver
public func connect() async throws { public func connect() async throws {
// Verify camera reachable via system info. closeSocket()
do { readBuffer.removeAll()
_ = try await getJSON(ArriPaths.systemInfo) varCache.removeAll()
} catch { try openSocket()
throw CameraError.connectionFailed("system info unreachable: \(error)") startReadLoop()
// Camera sends Welcome unsolicited; brief settle for it to arrive.
try? await Task.sleep(for: .milliseconds(50))
// 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")
} }
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))")
}
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)
}
_ = try await request(.requestVariables, payload: subPayload)
emit(CameraState(connection: .connected)) emit(CameraState(connection: .connected))
startPolling() startKeepAlive()
}
private func teardownAfterFailure() async {
readTask?.cancel()
readTask = nil
closeSocket()
} }
public func disconnect() async { public func disconnect() async {
pollTask?.cancel() keepAliveTask?.cancel()
pollTask = nil keepAliveTask = nil
readTask?.cancel()
readTask = nil
closeSocket()
emit(CameraState(connection: .disconnected)) emit(CameraState(connection: .disconnected))
} }
/// CAP requires client traffic every 1 s.
private func startKeepAlive() {
keepAliveTask?.cancel()
keepAliveTask = Task {
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(900))
_ = try? await self.request(.live, timeout: .seconds(2))
}
}
}
public func push(look: FlattenedLook) async throws { public func push(look: FlattenedLook) async throws {
if let lut = look.lut, !capabilities.lut3dSizes.contains(lut.size) { 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)")
}
}
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)") throw CameraError.unsupportedLook("LUT size \(lut.size) not in \(capabilities.lut3dSizes)")
} }
var payload: [String: Any] = [:] // EXPERIMENTAL: Set3DLutData payload layout unverified on hardware
if let cdl = look.cdl { // (chunk framing sections unavailable publicly). Sim mirrors this
payload["cdl"] = [ // simple layout: U16 lattice size + F32 table.
"slope": [cdl.slope.x, cdl.slope.y, cdl.slope.z], var payload = Data()
"offset": [cdl.offset.x, cdl.offset.y, cdl.offset.z], CAP.putU16(UInt16(lut.size), into: &payload)
"power": [cdl.power.x, cdl.power.y, cdl.power.z], for v in lut.table {
"saturation": cdl.saturation, CAP.putF32(v, into: &payload)
]
} }
if let lut = look.lut { let reply = try await request(.set3DLutData, payload: payload, timeout: .seconds(10))
payload["lut3dSize"] = lut.size guard reply.cmdCode == CAP.Result.ok.rawValue else {
payload["lut3dTable"] = lut.table throw CameraError.pushFailed("Set3DLutData -> result \(reply.cmdCode)")
}
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)
} }
} }
} if look.cdl == nil && (look.lut == nil || look.lut!.isApproximatelyIdentity()) {
throw CameraError.unsupportedLook("nothing to push")
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))
}
} }
} }
} }

View file

@ -0,0 +1,208 @@
import Foundation
/// ARRI Camera Access Protocol (CAP) binary over TCP :5055, big-endian.
/// Frame: [length U16][msgType U8][msgId U16][cmdCode U16][payload].
/// length includes the 7-byte header. Verified: docs/research/arri-cap-protocol.md.
public enum CAP {
public enum MsgType: UInt8, Sendable {
case command = 0x01
case reply = 0x02
case event = 0x03
}
/// Command codes (verified subset).
public enum Command: UInt16, Sendable {
case live = 0x0080
case requestPwdChallenge = 0x0081
case password = 0x0082
case clientName = 0x0083
case requestVariables = 0x0084
case unRequestVariables = 0x0085
case setVariable = 0x0086
case welcome = 0x0087
case getFrameGrab = 0x0088
case set3DLutData = 0x008c
case saveLookFile = 0x008d
case getVariable = 0x0090
case recordStart = 0x00a0
case recordStop = 0x00a1
case getClipList = 0x00a4
}
/// Result codes in reply cmdCode field.
public enum Result: UInt16, Sendable {
case ok = 0x0000
case noSuchCommand = 0x0001
case notAllowed = 0x0002
case noSuchVariables = 0x0003
case wrongPassword = 0x0004
case wrongType = 0x0006
case notAuthorized = 0x0007
case tooManyClients = 0x0008
case protocolError = 0x0009
}
/// Variable IDs (verified subset).
public enum Variable: UInt16, Sendable, CaseIterable {
case cameraState = 0x0040
case lookFilename = 0x0041
case cdlValues = 0x0050
case colorTemperature = 0x0051
case tint = 0x0052
case exposureIndex = 0x0053
case currentReel = 0x005C
case clipNumber = 0x005D
case sensorFPS = 0x0061
case timecode = 0x0078
}
/// Camera state bitfield (variable 0x0040).
public struct CameraStateBits: OptionSet, Sendable {
public let rawValue: UInt16
public init(rawValue: UInt16) {
self.rawValue = rawValue
}
public static let recording = CameraStateBits(rawValue: 0x0001)
public static let playback = CameraStateBits(rawValue: 0x0002)
public static let standbyReady = CameraStateBits(rawValue: 0x0004)
public static let idle = CameraStateBits(rawValue: 0x0080)
}
public struct Frame: Equatable, Sendable {
public var msgType: UInt8
public var msgId: UInt16
public var cmdCode: UInt16
public var payload: Data
public init(msgType: UInt8, msgId: UInt16, cmdCode: UInt16, payload: Data = Data()) {
self.msgType = msgType
self.msgId = msgId
self.cmdCode = cmdCode
self.payload = payload
}
}
public enum CodecError: Error, Equatable {
case frameTooShort
case lengthMismatch
}
// MARK: Encoding primitives (big-endian)
public static func putU16(_ v: UInt16, into data: inout Data) {
data.append(UInt8(v >> 8))
data.append(UInt8(v & 0xFF))
}
public static func putU32(_ v: UInt32, into data: inout Data) {
data.append(UInt8((v >> 24) & 0xFF))
data.append(UInt8((v >> 16) & 0xFF))
data.append(UInt8((v >> 8) & 0xFF))
data.append(UInt8(v & 0xFF))
}
public static func putF32(_ v: Float, into data: inout Data) {
putU32(v.bitPattern, into: &data)
}
/// CAP string: U16 length + UTF-8.
public static func putString(_ s: String, into data: inout Data) {
let bytes = Data(s.utf8)
putU16(UInt16(bytes.count), into: &data)
data.append(bytes)
}
public static func readU16(_ data: Data, at offset: Int) -> UInt16? {
guard data.count >= offset + 2 else { return nil }
let i = data.startIndex + offset
return UInt16(data[i]) << 8 | UInt16(data[i + 1])
}
public static func readU32(_ data: Data, at offset: Int) -> UInt32? {
guard data.count >= offset + 4 else { return nil }
let i = data.startIndex + offset
return UInt32(data[i]) << 24 | UInt32(data[i + 1]) << 16
| UInt32(data[i + 2]) << 8 | UInt32(data[i + 3])
}
public static func readF32(_ data: Data, at offset: Int) -> Float? {
readU32(data, at: offset).map { Float(bitPattern: $0) }
}
public static func readString(_ data: Data, at offset: Int) -> (value: String, consumed: Int)? {
guard let len = readU16(data, at: offset),
data.count >= offset + 2 + Int(len) else { return nil }
let start = data.startIndex + offset + 2
let s = String(data: data.subdata(in: start..<start + Int(len)), encoding: .utf8) ?? ""
return (s, 2 + Int(len))
}
// MARK: Frame codec
public static func encode(_ frame: Frame) -> Data {
var out = Data()
let total = UInt16(7 + frame.payload.count)
putU16(total, into: &out)
out.append(frame.msgType)
putU16(frame.msgId, into: &out)
putU16(frame.cmdCode, into: &out)
out.append(frame.payload)
return out
}
/// Decode first complete frame from buffer, consuming it. nil if incomplete.
public static func decodeFirst(_ buffer: inout Data) throws -> Frame? {
guard buffer.count >= 2 else { return nil }
guard let length = readU16(buffer, at: 0) else { return nil }
guard length >= 7 else { throw CodecError.frameTooShort }
guard buffer.count >= Int(length) else { return nil }
let base = buffer.startIndex
let msgType = buffer[base + 2]
guard let msgId = readU16(buffer, at: 3),
let cmdCode = readU16(buffer, at: 5) else { return nil }
let payload = buffer.subdata(in: base + 7..<base + Int(length))
buffer.removeFirst(Int(length))
return Frame(msgType: msgType, msgId: msgId, cmdCode: cmdCode, payload: payload)
}
// MARK: Timecode
/// U32 BCD HHMMSSFF -> "HH:MM:SS:FF".
public static func decodeTimecodeBCD(_ v: UInt32) -> String {
func bcd(_ byte: UInt32) -> UInt32 {
(byte >> 4) * 10 + (byte & 0xF)
}
let h = bcd((v >> 24) & 0xFF)
let m = bcd((v >> 16) & 0xFF)
let s = bcd((v >> 8) & 0xFF)
let f = bcd(v & 0xFF)
return String(format: "%02d:%02d:%02d:%02d", h, m, s, f)
}
public static func encodeTimecodeBCD(_ tc: String) -> UInt32? {
let parts = tc.split(separator: ":").compactMap { UInt32($0) }
guard parts.count == 4 else { return nil }
func toBCD(_ v: UInt32) -> UInt32 {
(v / 10) << 4 | (v % 10)
}
return toBCD(parts[0]) << 24 | toBCD(parts[1]) << 16 | toBCD(parts[2]) << 8 | toBCD(parts[3])
}
/// CDL BLOB: 10×F32 slope(3) offset(3) power(3) sat order per ASC convention (INFERRED).
public static func encodeCDLBlob(slope: (Float, Float, Float), offset: (Float, Float, Float),
power: (Float, Float, Float), saturation: Float) -> Data {
var out = Data()
for v in [slope.0, slope.1, slope.2, offset.0, offset.1, offset.2,
power.0, power.1, power.2, saturation] {
putF32(v, into: &out)
}
return out
}
public static func decodeCDLBlob(_ data: Data) -> [Float]? {
guard data.count == 40 else { return nil }
return (0..<10).compactMap { readF32(data, at: $0 * 4) }
}
}

View file

@ -14,13 +14,18 @@ enum SonyPaths {
static let userLut = "/cna/v1/monitoring/lut" static let userLut = "/cna/v1/monitoring/lut"
} }
/// Venice 2 driver: REST over IP. Capability-narrow user 3D LUT push only, /// Venice 2 driver POSTURE PER RESEARCH (docs/research/arri-cap-protocol.md, Sony
/// no native CDL (grade pipeline must full-bake for this camera). /// 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 actor SonyDriver: CameraDriver {
public nonisolated let capabilities = CameraCapabilities( public nonisolated let capabilities = CameraCapabilities(
vendor: .sony, vendor: .sony,
supportsNativeCDL: false, supportsNativeCDL: false,
lut3dSizes: [17, 33], lut3dSizes: [],
metadataFields: [.clipName, .exposureIndex, .whiteBalance, .tint, .fps, .timecode]) metadataFields: [.clipName, .exposureIndex, .whiteBalance, .tint, .fps, .timecode])
private let host: String private let host: String
@ -118,26 +123,11 @@ public actor SonyDriver: CameraDriver {
} }
public func push(look: FlattenedLook) async throws { public func push(look: FlattenedLook) async throws {
guard look.cdl == nil else { // Venice IP look control is closed/unverified (see research doc).
throw CameraError.unsupportedLook("Venice has no native CDL — re-flatten with splitLeadingCDL=false") // Grade Venice via SDI-chain LUT box or DeckLink output. Re-enable
} // once a real wire path is proven (partner SDK or backend capture).
guard let lut = look.lut else { throw CameraError.unsupportedLook(
throw CameraError.unsupportedLook("nothing to push") "Venice look push over IP unverified — use SDI-chain LUT box; monitoring-only driver")
}
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 // MARK: Polling

View file

@ -1,92 +1,279 @@
import Foundation import Foundation
import NIO import NIO
import NIOHTTP1 import ForgeCameraARRI
import ForgeOffload
/// In-process HTTP server mimicking ARRI ALEXA 35 REST API shape.
/// Endpoint paths kept in one place (`ArriSimPaths`) mirror of the driver's
/// constants file; correct both together when verified against hardware.
public enum ArriSimPaths {
public static let systemInfo = "/api/v1/system/info"
public static let recordingStatus = "/api/v1/recording/status"
public static let metadata = "/api/v1/camera/metadata"
public static let lookCurrent = "/api/v1/look/current"
}
/// CAP-faithful ARRI ALEXA 35 simulator.
/// Binary TCP per docs/research/arri-cap-protocol.md:
/// Welcome on connect -> RequestPwdChallenge -> Password(MD5(challenge+password))
/// -> ClientName -> Get/Set/RequestVariables. EVENT frames on subscribed changes.
public actor ArriSimulator { public actor ArriSimulator {
private var group: MultiThreadedEventLoopGroup? private var group: MultiThreadedEventLoopGroup?
private var channel: Channel? private var channel: Channel?
public private(set) var boundPort: Int? public private(set) var boundPort: Int?
// Scriptable state. /// Camera password (camera GUI setting; ARRI default "arri").
private var recording = false public let password: String
private var clipName: String? private let challenge = "SIMCHAL01"
private var ei = 800
private var wb = 5600
private var tint = 0
private var fps = 24.0
private var timecode = "00:00:00:00"
/// Raw JSON bodies of accepted look uploads (Sendable; decode in consumers).
public private(set) var uploadedLooks: [Data] = []
public init() {} // Scriptable camera state.
private var stateBits: CAP.CameraStateBits = [.standbyReady]
private var reel: UInt16 = 1
private var clipNumber: UInt16 = 0
private var ei: UInt32 = 800
private var colorTemp: UInt32 = 5600
private var tint: Float = 0
private var fps: Float = 24
private var timecodeBCD: UInt32 = 0
/// Last CDL blob written via SetVariable 0x0050 (10 F32).
public private(set) var lastCDL: [Float]?
/// Raw 3D LUT payloads received via Set3DLutData.
public private(set) var lutUploads: [Data] = []
public private(set) var clientNames: [String] = []
// MARK: Scripting hooks private struct Session {
var authenticated = false
var challengeSent = false
var subscriptions = Set<UInt16>()
}
private var sessions: [ObjectIdentifier: Session] = [:]
private var channels: [ObjectIdentifier: Channel] = [:]
public func setRecording(_ on: Bool, clipName: String? = nil) { public init(password: String = "arri") {
recording = on self.password = password
if let c = clipName { self.clipName = c }
if !on { /* keep clip name for last-clip queries */ }
} }
public func setMetadata(ei: Int, wb: Int, tint: Int, fps: Double, timecode: String) { // MARK: Scripting
public func setRecording(_ on: Bool, clipNumber: UInt16? = nil) {
if on {
stateBits.insert(.recording)
if let c = clipNumber { self.clipNumber = c } else { self.clipNumber += 1 }
} else {
stateBits.remove(.recording)
}
// Clip number first so rec-start state already carries the new clip.
notifySubscribers(variable: .clipNumber)
notifySubscribers(variable: .cameraState)
}
public func setMetadata(ei: UInt32, colorTemp: UInt32, tint: Float, fps: Float, timecode: String) {
self.ei = ei self.ei = ei
self.wb = wb self.colorTemp = colorTemp
self.tint = tint self.tint = tint
self.fps = fps self.fps = fps
self.timecode = timecode self.timecodeBCD = CAP.encodeTimecodeBCD(timecode) ?? 0
for v in [CAP.Variable.exposureIndex, .colorTemperature, .tint, .sensorFPS, .timecode] {
notifySubscribers(variable: v)
}
} }
// MARK: Request handling (called from channel handler) // MARK: Variable encoding
func handle(method: HTTPMethod, uri: String, body: Data?) -> (status: HTTPResponseStatus, body: Data) { private func encodeVariable(_ id: UInt16) -> Data? {
switch (method, uri) { guard let variable = CAP.Variable(rawValue: id) else { return nil }
case (.GET, ArriSimPaths.systemInfo): var out = Data()
return (.ok, json([ CAP.putU16(id, into: &out)
"model": "ALEXA 35", switch variable {
"serialNumber": "SIM-35-0001", case .cameraState:
"firmwareVersion": "SUP 2.0-sim", CAP.putU16(stateBits.rawValue, into: &out)
])) case .lookFilename:
case (.GET, ArriSimPaths.recordingStatus): CAP.putString("forge-sim-look", into: &out)
var obj: [String: Any] = ["recording": recording] case .cdlValues:
if let c = clipName { obj["clipName"] = c } let blob = lastCDL ?? [1, 1, 1, 0, 0, 0, 1, 1, 1, 1]
return (.ok, json(obj)) for v in blob { CAP.putF32(v, into: &out) }
case (.GET, ArriSimPaths.metadata): case .colorTemperature:
return (.ok, json([ CAP.putU32(colorTemp, into: &out)
"exposureIndex": ei, case .tint:
"whiteBalance": wb, CAP.putF32(tint, into: &out)
"tint": tint, case .exposureIndex:
"fps": fps, CAP.putU32(ei, into: &out)
"timecode": timecode, case .currentReel:
])) CAP.putU16(reel, into: &out)
case (.GET, ArriSimPaths.lookCurrent): case .clipNumber:
guard let last = uploadedLooks.last else { CAP.putU16(clipNumber, into: &out)
return (.notFound, json(["error": "no look set"])) case .sensorFPS:
CAP.putF32(fps, into: &out)
case .timecode:
CAP.putU32(timecodeBCD, into: &out)
} }
return (.ok, last) return out
case (.PUT, ArriSimPaths.lookCurrent):
guard let body,
(try? JSONSerialization.jsonObject(with: body) as? [String: Any]) != nil else {
return (.badRequest, json(["error": "invalid JSON"]))
} }
uploadedLooks.append(body)
return (.ok, json(["status": "accepted"])) private func notifySubscribers(variable: CAP.Variable) {
guard let payload = encodeVariable(variable.rawValue) else { return }
for (key, session) in sessions where session.authenticated && session.subscriptions.contains(variable.rawValue) {
guard let ch = channels[key] else { continue }
let frame = CAP.Frame(
msgType: CAP.MsgType.event.rawValue,
msgId: 0,
cmdCode: CAP.Command.requestVariables.rawValue,
payload: payload)
Self.write(frame, to: ch)
}
}
// MARK: Connection lifecycle
func clientConnected(_ ch: Channel) {
let key = ObjectIdentifier(ch)
sessions[key] = Session()
channels[key] = ch
// Welcome: protocol version string + result OK.
var payload = Data()
CAP.putString("CAP 1.12 sim", into: &payload)
CAP.putU16(CAP.Result.ok.rawValue, into: &payload)
Self.write(CAP.Frame(
msgType: CAP.MsgType.reply.rawValue,
msgId: 0,
cmdCode: CAP.Command.welcome.rawValue,
payload: payload), to: ch)
}
func clientDisconnected(_ ch: Channel) {
let key = ObjectIdentifier(ch)
sessions.removeValue(forKey: key)
channels.removeValue(forKey: key)
}
func handleFrame(_ frame: CAP.Frame, from ch: Channel) {
let key = ObjectIdentifier(ch)
guard var session = sessions[key] else { return }
guard let cmd = CAP.Command(rawValue: frame.cmdCode) else {
reply(to: frame, result: .noSuchCommand, on: ch)
return
}
func requireAuth() -> Bool {
if !session.authenticated {
reply(to: frame, result: .notAuthorized, on: ch)
return false
}
return true
}
switch cmd {
case .live:
reply(to: frame, result: .ok, on: ch)
case .requestPwdChallenge:
session.challengeSent = true
var payload = Data()
CAP.putString(challenge, into: &payload)
replyPayload(to: frame, result: .ok, payload: payload, on: ch)
case .password:
guard session.challengeSent,
let (hash, _) = CAP.readString(frame.payload, at: 0) else {
reply(to: frame, result: .protocolError, on: ch)
break
}
// Spec: MD5(challenge concat password).
let expected = MD5.hexString(Data((challenge + password).utf8))
if hash.lowercased() == expected {
session.authenticated = true
reply(to: frame, result: .ok, on: ch)
} else {
reply(to: frame, result: .wrongPassword, on: ch)
}
case .clientName:
guard requireAuth() else { break }
if let (name, _) = CAP.readString(frame.payload, at: 0) {
clientNames.append(name)
}
reply(to: frame, result: .ok, on: ch)
case .getVariable:
guard requireAuth() else { break }
guard let id = CAP.readU16(frame.payload, at: 0),
let payload = encodeVariable(id) else {
reply(to: frame, result: .noSuchVariables, on: ch)
break
}
replyPayload(to: frame, result: .ok, payload: payload, on: ch)
case .setVariable:
guard requireAuth() else { break }
guard let id = CAP.readU16(frame.payload, at: 0) else {
reply(to: frame, result: .protocolError, on: ch)
break
}
if id == CAP.Variable.cdlValues.rawValue {
let blobData = frame.payload.subdata(in: frame.payload.startIndex + 2..<frame.payload.endIndex)
guard let blob = CAP.decodeCDLBlob(blobData) else {
reply(to: frame, result: .wrongType, on: ch)
break
}
lastCDL = blob
reply(to: frame, result: .ok, on: ch)
} else {
reply(to: frame, result: .notAllowed, on: ch)
}
case .requestVariables:
guard requireAuth() else { break }
// Payload: sequence of U16 variable IDs.
var offset = 0
while let id = CAP.readU16(frame.payload, at: offset) {
session.subscriptions.insert(id)
offset += 2
}
reply(to: frame, result: .ok, on: ch)
// Push current values immediately (initial state).
sessions[key] = session
for id in session.subscriptions {
if let payload = encodeVariable(id) {
Self.write(CAP.Frame(
msgType: CAP.MsgType.event.rawValue,
msgId: 0,
cmdCode: CAP.Command.requestVariables.rawValue,
payload: payload), to: ch)
}
}
case .set3DLutData:
guard requireAuth() else { break }
lutUploads.append(frame.payload)
reply(to: frame, result: .ok, on: ch)
case .recordStart:
guard requireAuth() else { break }
setRecording(true)
reply(to: frame, result: .ok, on: ch)
case .recordStop:
guard requireAuth() else { break }
setRecording(false)
reply(to: frame, result: .ok, on: ch)
default: default:
return (.notFound, json(["error": "unknown path"])) reply(to: frame, result: .noSuchCommand, on: ch)
}
} }
private func json(_ obj: [String: Any]) -> Data { sessions[key] = session
(try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys])) ?? Data() }
private func reply(to frame: CAP.Frame, result: CAP.Result, on ch: Channel) {
Self.write(CAP.Frame(
msgType: CAP.MsgType.reply.rawValue,
msgId: frame.msgId,
cmdCode: result.rawValue), to: ch)
}
private func replyPayload(to frame: CAP.Frame, result: CAP.Result, payload: Data, on ch: Channel) {
Self.write(CAP.Frame(
msgType: CAP.MsgType.reply.rawValue,
msgId: frame.msgId,
cmdCode: result.rawValue,
payload: payload), to: ch)
}
private static func write(_ frame: CAP.Frame, to ch: Channel) {
let data = CAP.encode(frame)
var buf = ch.allocator.buffer(capacity: data.count)
buf.writeBytes(data)
ch.writeAndFlush(NIOAny(buf), promise: nil)
} }
// MARK: Lifecycle // MARK: Lifecycle
@ -98,9 +285,7 @@ public actor ArriSimulator {
let bootstrap = ServerBootstrap(group: group) let bootstrap = ServerBootstrap(group: group)
.serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
.childChannelInitializer { channel in .childChannelInitializer { channel in
channel.pipeline.configureHTTPServerPipeline().flatMap { channel.pipeline.addHandler(CAPServerHandler(sim: sim))
channel.pipeline.addHandler(HTTPSimHandler(sim: sim))
}
} }
let channel = try await bootstrap.bind(host: "127.0.0.1", port: port).get() let channel = try await bootstrap.bind(host: "127.0.0.1", port: port).get()
self.channel = channel self.channel = channel
@ -108,6 +293,11 @@ public actor ArriSimulator {
} }
public func stop() async throws { public func stop() async throws {
for ch in channels.values {
_ = try? await ch.close()
}
channels.removeAll()
sessions.removeAll()
try await channel?.close() try await channel?.close()
try await group?.shutdownGracefully() try await group?.shutdownGracefully()
channel = nil channel = nil
@ -116,57 +306,37 @@ public actor ArriSimulator {
} }
} }
/// Minimal HTTP1 request handler bridging into the actor. /// Byte stream -> CAP frames -> sim actor.
final class HTTPSimHandler: ChannelInboundHandler, @unchecked Sendable { final class CAPServerHandler: ChannelInboundHandler, @unchecked Sendable {
typealias InboundIn = HTTPServerRequestPart typealias InboundIn = ByteBuffer
typealias OutboundOut = HTTPServerResponsePart
private let sim: ArriSimulator private let sim: ArriSimulator
private var method: HTTPMethod = .GET private var buffer = Data()
private var uri: String = "/"
private var bodyBuffer: ByteBuffer?
init(sim: ArriSimulator) { init(sim: ArriSimulator) {
self.sim = sim self.sim = sim
} }
func channelActive(context: ChannelHandlerContext) {
let ch = context.channel
Task { await sim.clientConnected(ch) }
context.fireChannelActive()
}
func channelInactive(context: ChannelHandlerContext) {
let ch = context.channel
Task { await sim.clientDisconnected(ch) }
context.fireChannelInactive()
}
func channelRead(context: ChannelHandlerContext, data: NIOAny) { func channelRead(context: ChannelHandlerContext, data: NIOAny) {
switch unwrapInboundIn(data) { var incoming = unwrapInboundIn(data)
case .head(let head): if let bytes = incoming.readBytes(length: incoming.readableBytes) {
method = head.method buffer.append(contentsOf: bytes)
uri = head.uri
bodyBuffer = nil
case .body(var buf):
if bodyBuffer == nil {
bodyBuffer = buf
} else {
bodyBuffer?.writeBuffer(&buf)
}
case .end:
let body = bodyBuffer.map { Data($0.readableBytesView) }
let m = method
let u = uri
let channel = context.channel
let loop = context.eventLoop
Task {
let (status, respBody) = await self.sim.handle(method: m, uri: u, body: body)
loop.execute {
var headers = HTTPHeaders()
headers.add(name: "Content-Type", value: "application/json")
headers.add(name: "Content-Length", value: "\(respBody.count)")
// Close after response: idle keep-alive channels block
// shutdownGracefully() and hang test teardown.
headers.add(name: "Connection", value: "close")
let head = HTTPResponseHead(version: .http1_1, status: status, headers: headers)
channel.write(HTTPServerResponsePart.head(head), promise: nil)
var buf = channel.allocator.buffer(capacity: respBody.count)
buf.writeBytes(respBody)
channel.write(HTTPServerResponsePart.body(.byteBuffer(buf)), promise: nil)
channel.writeAndFlush(HTTPServerResponsePart.end(nil)).whenComplete { _ in
channel.close(promise: nil)
}
}
} }
let ch = context.channel
while let frame = try? CAP.decodeFirst(&buffer) {
Task { await sim.handleFrame(frame, from: ch) }
} }
} }
} }

View file

@ -1,16 +1,19 @@
import XCTest import XCTest
import Foundation
import ForgeCamera import ForgeCamera
import ForgeGrade import ForgeGrade
import ForgeColor import ForgeColor
import ForgeSim import ForgeSim
@testable import ForgeCameraARRI @testable import ForgeCameraARRI
/// Tests against CAP-faithful simulator (binary TCP, MD5 challenge auth,
/// variable subscription docs/research/arri-cap-protocol.md).
final class ArriDriverTests: XCTestCase { final class ArriDriverTests: XCTestCase {
var sim: ArriSimulator! var sim: ArriSimulator!
override func setUp() async throws { override func setUp() async throws {
sim = ArriSimulator() sim = ArriSimulator(password: "arri")
try await sim.start(port: 0) try await sim.start(port: 0)
} }
@ -19,77 +22,69 @@ final class ArriDriverTests: XCTestCase {
sim = nil sim = nil
} }
func makeDriver(pollInterval: Duration = .milliseconds(20)) async -> ArriDriver { func makeDriver(password: String = "arri") async -> ArriDriver {
let port = await sim.boundPort! let port = await sim.boundPort!
return ArriDriver(host: "127.0.0.1", port: port, pollInterval: pollInterval) return ArriDriver(host: "127.0.0.1", port: port, password: password)
} }
// Capabilities: ARRI = native CDL + 33/65 LUTs.
func testCapabilities() async { func testCapabilities() async {
let driver = await makeDriver() let driver = await makeDriver()
XCTAssertEqual(driver.capabilities.vendor, .arri) XCTAssertEqual(driver.capabilities.vendor, .arri)
XCTAssertTrue(driver.capabilities.supportsNativeCDL) XCTAssertTrue(driver.capabilities.supportsNativeCDL)
XCTAssertTrue(driver.capabilities.lut3dSizes.contains(33)) XCTAssertTrue(driver.capabilities.lut3dSizes.contains(33))
XCTAssertTrue(driver.capabilities.metadataFields.contains(.clipName))
} }
// Connect verifies system info and emits connected state. // Full handshake: challenge -> MD5 password -> client name -> subscribed + connected.
func testConnectEmitsConnected() async throws { func testConnectHandshake() async throws {
let driver = await makeDriver() let driver = await makeDriver()
var iterator = driver.state.makeAsyncIterator() var iterator = driver.state.makeAsyncIterator()
try await driver.connect() try await driver.connect()
var connected = false var connected = false
for _ in 0..<5 { for _ in 0..<10 {
if let s = await iterator.next(), s.connection == .connected { if let s = await iterator.next(), s.connection == .connected {
connected = true connected = true
break break
} }
} }
XCTAssertTrue(connected) XCTAssertTrue(connected)
let names = await sim.clientNames
XCTAssertEqual(names, ["Forge"])
await driver.disconnect() await driver.disconnect()
} }
// Connect to dead port throws. // Wrong password -> connectionFailed, never authenticated.
func testWrongPasswordRejected() async throws {
let driver = await makeDriver(password: "wrong")
do {
try await driver.connect()
XCTFail("expected throw")
} catch let e as CameraError {
guard case .connectionFailed(let msg) = e else { return XCTFail("wrong error") }
XCTAssertTrue(msg.contains("password"))
}
let names = await sim.clientNames
XCTAssertTrue(names.isEmpty)
}
func testConnectFailsOnDeadPort() async { func testConnectFailsOnDeadPort() async {
let driver = ArriDriver(host: "127.0.0.1", port: 1, pollInterval: .milliseconds(20)) let driver = ArriDriver(host: "127.0.0.1", port: 1)
do { do {
try await driver.connect() try await driver.connect()
XCTFail("expected throw") XCTFail("expected throw")
} catch {} } catch {}
} }
// Poll loop surfaces metadata. // Subscription events: rec start/stop with composed clip name RxxxCxxx.
func testMetadataPolling() async throws {
await sim.setMetadata(ei: 1280, wb: 3200, tint: -2, fps: 25, timecode: "10:20:30:12")
let driver = await makeDriver()
var iterator = driver.state.makeAsyncIterator()
try await driver.connect()
var got = false
for _ in 0..<20 {
if let s = await iterator.next(),
let md = s.metadata,
md.exposureIndex == 1280, md.whiteBalance == 3200 {
XCTAssertEqual(md.timecode, "10:20:30:12")
got = true
break
}
}
XCTAssertTrue(got)
await driver.disconnect()
}
// Rec start/stop events with clip name.
func testRecStateEvents() async throws { func testRecStateEvents() async throws {
let driver = await makeDriver() let driver = await makeDriver()
var iterator = driver.state.makeAsyncIterator() var iterator = driver.state.makeAsyncIterator()
try await driver.connect() try await driver.connect()
await sim.setRecording(true, clipName: "A001C007_250710_R1CD") await sim.setRecording(true, clipNumber: 7)
// Events arrive per-variable; wait for a state with BOTH rec + clip.
var sawRec = false var sawRec = false
for _ in 0..<30 { for _ in 0..<40 {
if let s = await iterator.next(), s.isRecording { if let s = await iterator.next(), s.isRecording, s.clipName == "R001C007" {
XCTAssertEqual(s.clipName, "A001C007_250710_R1CD")
sawRec = true sawRec = true
break break
} }
@ -108,61 +103,100 @@ final class ArriDriverTests: XCTestCase {
await driver.disconnect() await driver.disconnect()
} }
// push(look:) uploads CDL + LUT payload sim can parse. // Metadata via subscribed variables incl. BCD timecode decode.
func testPushLook() async throws { func testMetadataEvents() async throws {
let driver = await makeDriver() let driver = await makeDriver()
var iterator = driver.state.makeAsyncIterator()
try await driver.connect() try await driver.connect()
let cdl = CDL(slope: SIMD3(1.1, 1.0, 0.9), offset: .zero, power: .one, saturation: 1.2) await sim.setMetadata(ei: 1280, colorTemp: 3200, tint: -2, fps: 25, timecode: "10:20:30:12")
let lut = Lut3D.identity(size: 33) var got = false
let look = FlattenedLook(cdl: cdl, lut: lut, latticeSize: 33) for _ in 0..<40 {
try await driver.push(look: look) if let s = await iterator.next(), let md = s.metadata,
md.exposureIndex == 1280, md.whiteBalance == 3200,
let uploads = await sim.uploadedLooks md.tint == -2, md.timecode == "10:20:30:12",
XCTAssertEqual(uploads.count, 1) let fps = md.fps, abs(fps - 25) < 0.01 {
let obj = try JSONSerialization.jsonObject(with: uploads[0]) as? [String: Any] got = true
let cdlObj = obj?["cdl"] as? [String: Any] break
let slope = cdlObj?["slope"] as? [Double] }
XCTAssertEqual(slope?[0] ?? 0, 1.1, accuracy: 1e-5) }
XCTAssertEqual(cdlObj?["saturation"] as? Double ?? 0, 1.2, accuracy: 1e-5) XCTAssertTrue(got)
XCTAssertEqual(obj?["lut3dSize"] as? Int, 33)
let table = obj?["lut3dTable"] as? [Double]
XCTAssertEqual(table?.count, 33 * 33 * 33 * 3)
await driver.disconnect() await driver.disconnect()
} }
// Unsupported LUT size rejected before hitting the wire. // CDL push -> SetVariable 0x0050 blob lands in sim with correct 10 floats.
func testPushCDL() async throws {
let driver = await makeDriver()
try await driver.connect()
let cdl = CDL(
slope: SIMD3(1.1, 1.0, 0.9),
offset: SIMD3(0.02, 0.0, -0.01),
power: SIMD3(0.95, 1.0, 1.05),
saturation: 1.3)
try await driver.push(look: FlattenedLook(cdl: cdl, lut: nil, latticeSize: 33))
let blob = await sim.lastCDL
XCTAssertNotNil(blob)
XCTAssertEqual(blob![0], 1.1, accuracy: 1e-5) // slope R
XCTAssertEqual(blob![2], 0.9, accuracy: 1e-5) // slope B
XCTAssertEqual(blob![3], 0.02, accuracy: 1e-5) // offset R
XCTAssertEqual(blob![5], -0.01, accuracy: 1e-5)
XCTAssertEqual(blob![6], 0.95, accuracy: 1e-5) // power R
XCTAssertEqual(blob![9], 1.3, accuracy: 1e-5) // sat
await driver.disconnect()
}
// CDL + LUT push: both land (LUT path experimental but sim accepts).
func testPushCDLAndLut() async throws {
let driver = await makeDriver()
try await driver.connect()
let look = FlattenedLook(
cdl: CDL(slope: SIMD3(1.2, 1, 1), offset: .zero, power: .one, saturation: 1),
lut: Lut3D.build(size: 17) { $0 * 0.9 },
latticeSize: 17)
try await driver.push(look: look)
let cdl = await sim.lastCDL
XCTAssertEqual(cdl?[0] ?? 0, 1.2, accuracy: 1e-5)
let luts = await sim.lutUploads
XCTAssertEqual(luts.count, 1)
// U16 size prefix + 17³×3 floats.
XCTAssertEqual(luts[0].count, 2 + 17 * 17 * 17 * 3 * 4)
await driver.disconnect()
}
// Unsupported LUT size rejected before wire.
func testUnsupportedLutSizeRejected() async throws { func testUnsupportedLutSizeRejected() async throws {
let driver = await makeDriver() let driver = await makeDriver()
try await driver.connect() try await driver.connect()
let look = FlattenedLook(cdl: nil, lut: Lut3D.identity(size: 5), latticeSize: 5) let look = FlattenedLook(cdl: nil, lut: Lut3D.build(size: 5) { $0 * 0.5 }, latticeSize: 5)
do { do {
try await driver.push(look: look) try await driver.push(look: look)
XCTFail("expected unsupportedLook") XCTFail("expected unsupportedLook")
} catch let error as CameraError { } catch let e as CameraError {
guard case .unsupportedLook = error else { return XCTFail("wrong error") } guard case .unsupportedLook = e else { return XCTFail("wrong error") }
} }
let uploads = await sim.uploadedLooks
XCTAssertTrue(uploads.isEmpty)
await driver.disconnect() await driver.disconnect()
} }
// Reconnect after sim restart. // Reconnect after sim restart on same port.
func testReconnectAfterSimRestart() async throws { func testReconnectAfterSimRestart() async throws {
let port = await sim.boundPort! let port = await sim.boundPort!
let driver = ArriDriver(host: "127.0.0.1", port: port, pollInterval: .milliseconds(20)) let driver = ArriDriver(host: "127.0.0.1", port: port)
try await driver.connect() try await driver.connect()
await driver.disconnect() await driver.disconnect()
try await sim.stop() try await sim.stop()
sim = ArriSimulator() sim = ArriSimulator(password: "arri")
try await sim.start(port: port) try await sim.start(port: port)
try await driver.connect() try await driver.connect()
let look = FlattenedLook(cdl: nil, lut: Lut3D.identity(size: 33), latticeSize: 33) try await driver.push(look: FlattenedLook(
try await driver.push(look: look) cdl: .identity, lut: nil, latticeSize: 33))
let uploads = await sim.uploadedLooks let blob = await sim.lastCDL
XCTAssertEqual(uploads.count, 1) XCTAssertNotNil(blob)
await driver.disconnect() await driver.disconnect()
} }
} }

View file

@ -0,0 +1,107 @@
import XCTest
import Foundation
@testable import ForgeCameraARRI
final class CAPProtocolTests: XCTestCase {
// Frame encode: length includes 7-byte header, big-endian fields.
func testEncodeFrame() {
let frame = CAP.Frame(
msgType: CAP.MsgType.command.rawValue,
msgId: 0x1234,
cmdCode: CAP.Command.live.rawValue,
payload: Data([0xAA, 0xBB]))
let data = CAP.encode(frame)
XCTAssertEqual([UInt8](data), [
0x00, 0x09, // length 9 = 7 + 2
0x01, // COMMAND
0x12, 0x34, // msgId
0x00, 0x80, // Live
0xAA, 0xBB,
])
}
// Round trip.
func testDecodeRoundTrip() throws {
let frame = CAP.Frame(msgType: 0x02, msgId: 7, cmdCode: 0x0000, payload: Data([1, 2, 3]))
var buf = CAP.encode(frame)
let decoded = try XCTUnwrap(CAP.decodeFirst(&buf))
XCTAssertEqual(decoded, frame)
XCTAssertTrue(buf.isEmpty)
}
// Partial frame buffers.
func testPartialFrame() throws {
let full = CAP.encode(CAP.Frame(msgType: 0x01, msgId: 1, cmdCode: 0x0080))
var buf = Data(full.prefix(4))
XCTAssertNil(try CAP.decodeFirst(&buf))
buf.append(full.suffix(from: 4))
XCTAssertNotNil(try CAP.decodeFirst(&buf))
}
// Two frames in one buffer.
func testTwoFrames() throws {
var buf = CAP.encode(CAP.Frame(msgType: 0x01, msgId: 1, cmdCode: 0x0080))
buf.append(CAP.encode(CAP.Frame(msgType: 0x01, msgId: 2, cmdCode: 0x0090)))
let f1 = try XCTUnwrap(CAP.decodeFirst(&buf))
let f2 = try XCTUnwrap(CAP.decodeFirst(&buf))
XCTAssertEqual(f1.msgId, 1)
XCTAssertEqual(f2.msgId, 2)
}
// Bogus length rejected.
func testShortLengthRejected() {
var buf = Data([0x00, 0x03, 0x01])
XCTAssertThrowsError(try CAP.decodeFirst(&buf))
}
// CAP string encoding.
func testStringCodec() {
var data = Data()
CAP.putString("Forge", into: &data)
XCTAssertEqual([UInt8](data.prefix(2)), [0x00, 0x05])
let read = CAP.readString(data, at: 0)
XCTAssertEqual(read?.value, "Forge")
XCTAssertEqual(read?.consumed, 7)
}
// BCD timecode.
func testTimecodeBCD() {
// 12:34:56:23 -> BCD 0x12345623
XCTAssertEqual(CAP.decodeTimecodeBCD(0x12345623), "12:34:56:23")
XCTAssertEqual(CAP.encodeTimecodeBCD("12:34:56:23"), 0x12345623)
XCTAssertEqual(CAP.decodeTimecodeBCD(CAP.encodeTimecodeBCD("09:59:00:01")!), "09:59:00:01")
}
// CDL blob: 10 F32 big-endian, 40 bytes.
func testCDLBlob() {
let blob = CAP.encodeCDLBlob(
slope: (1.1, 1.0, 0.9),
offset: (0.01, 0.0, -0.01),
power: (0.95, 1.0, 1.05),
saturation: 1.2)
XCTAssertEqual(blob.count, 40)
let values = CAP.decodeCDLBlob(blob)!
XCTAssertEqual(values[0], 1.1, accuracy: 1e-6)
XCTAssertEqual(values[2], 0.9, accuracy: 1e-6)
XCTAssertEqual(values[5], -0.01, accuracy: 1e-6)
XCTAssertEqual(values[9], 1.2, accuracy: 1e-6)
}
// Camera state bitfield.
func testCameraStateBits() {
let bits = CAP.CameraStateBits(rawValue: 0x0005)
XCTAssertTrue(bits.contains(.recording))
XCTAssertTrue(bits.contains(.standbyReady))
XCTAssertFalse(bits.contains(.playback))
}
// U32/F32 round trip.
func testNumericCodecs() {
var data = Data()
CAP.putU32(0xDEADBEEF, into: &data)
CAP.putF32(3.14159, into: &data)
XCTAssertEqual(CAP.readU32(data, at: 0), 0xDEADBEEF)
XCTAssertEqual(CAP.readF32(data, at: 4) ?? 0, 3.14159, accuracy: 1e-5)
}
}

View file

@ -28,12 +28,12 @@ final class SonyDriverTests: XCTestCase {
return SonyDriver(host: "127.0.0.1", port: port, pollInterval: .milliseconds(20)) return SonyDriver(host: "127.0.0.1", port: port, pollInterval: .milliseconds(20))
} }
// Venice 2: NO native CDL capability-narrow. // Venice 2: protocol closed (research) no look-push capability claimed.
func testCapabilities() async { func testCapabilities() async {
let driver = await makeDriver() let driver = await makeDriver()
XCTAssertEqual(driver.capabilities.vendor, .sony) XCTAssertEqual(driver.capabilities.vendor, .sony)
XCTAssertFalse(driver.capabilities.supportsNativeCDL) XCTAssertFalse(driver.capabilities.supportsNativeCDL)
XCTAssertTrue(driver.capabilities.lut3dSizes.contains(33)) XCTAssertTrue(driver.capabilities.lut3dSizes.isEmpty)
} }
func testConnectEmitsConnected() async throws { func testConnectEmitsConnected() async throws {
@ -70,26 +70,24 @@ final class SonyDriverTests: XCTestCase {
await driver.disconnect() await driver.disconnect()
} }
// LUT-only push accepted. // Any look push rejected: Venice look control unverified/closed (research).
func testPushLutOnly() async throws { // Metadata/rec-state monitoring is the only claimed capability.
func testAnyPushRejected() async throws {
let driver = await makeDriver() let driver = await makeDriver()
try await driver.connect() try await driver.connect()
let look = FlattenedLook(cdl: nil, lut: Lut3D.identity(size: 33), latticeSize: 33)
try await driver.push(look: look) let lutOnly = FlattenedLook(cdl: nil, lut: Lut3D.identity(size: 33), latticeSize: 33)
let count = await sim.uploadedLutCount do {
XCTAssertEqual(count, 1) try await driver.push(look: lutOnly)
await driver.disconnect() XCTFail("expected unsupportedLook")
} catch let e as CameraError {
guard case .unsupportedLook = e else { return XCTFail("wrong error") }
} }
// Push with split CDL to a no-native-CDL camera must throw unsupportedLook
// caller (gang layer) is responsible for re-flattening full-bake.
func testPushSplitCDLRejected() async throws {
let driver = await makeDriver()
try await driver.connect()
let cdl = CDL(slope: SIMD3(1.2, 1, 1), offset: .zero, power: .one, saturation: 1) let cdl = CDL(slope: SIMD3(1.2, 1, 1), offset: .zero, power: .one, saturation: 1)
let look = FlattenedLook(cdl: cdl, lut: Lut3D.identity(size: 33), latticeSize: 33) let withCDL = FlattenedLook(cdl: cdl, lut: nil, latticeSize: 33)
do { do {
try await driver.push(look: look) try await driver.push(look: withCDL)
XCTFail("expected unsupportedLook") XCTFail("expected unsupportedLook")
} catch let e as CameraError { } catch let e as CameraError {
guard case .unsupportedLook = e else { return XCTFail("wrong error") } guard case .unsupportedLook = e else { return XCTFail("wrong error") }

View file

@ -1,106 +1,187 @@
import XCTest import XCTest
import Foundation import Foundation
#if canImport(FoundationNetworking) import ForgeCameraARRI
import FoundationNetworking import ForgeOffload
#endif
@testable import ForgeSim @testable import ForgeSim
/// CAP-level sim tests: raw TCP client speaking binary CAP frames.
final class ArriSimTests: XCTestCase { final class ArriSimTests: XCTestCase {
var sim: ArriSimulator! var sim: ArriSimulator!
override func setUp() async throws { override func setUp() async throws {
sim = ArriSimulator() sim = ArriSimulator(password: "arri")
try await sim.start(port: 0) // ephemeral try await sim.start(port: 0)
} }
override func tearDown() async throws { override func tearDown() async throws {
try await sim.stop() try? await sim.stop()
sim = nil sim = nil
} }
func get(_ path: String) async throws -> (Int, Data) { /// Minimal blocking CAP test client.
final class TestClient {
let fd: Int32
var buffer = Data()
var msgId: UInt16 = 100
init?(port: Int) {
fd = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0)
guard fd >= 0 else { return nil }
var tv = timeval(tv_sec: 2, tv_usec: 0)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &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)
_ = inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr)
let rc = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard rc == 0 else {
close(fd)
return nil
}
}
deinit {
close(fd)
}
func send(_ cmd: CAP.Command, payload: Data = Data()) -> UInt16 {
msgId &+= 1
let frame = CAP.Frame(
msgType: CAP.MsgType.command.rawValue,
msgId: msgId,
cmdCode: cmd.rawValue,
payload: payload)
let data = CAP.encode(frame)
_ = data.withUnsafeBytes { write(fd, $0.baseAddress, $0.count) }
return msgId
}
func readFrame() -> CAP.Frame? {
var chunk = [UInt8](repeating: 0, count: 8192)
for _ in 0..<40 {
if let f = try? CAP.decodeFirst(&buffer), f != nil {
return f
}
let n = read(fd, &chunk, chunk.count)
if n > 0 {
buffer.append(contentsOf: chunk[0..<n])
} else if n == 0 {
return nil
}
}
return try? CAP.decodeFirst(&buffer) ?? nil
}
/// Read frames until predicate matches (skips events/other replies).
func readUntil(_ predicate: (CAP.Frame) -> Bool) -> CAP.Frame? {
for _ in 0..<50 {
guard let f = readFrame() else { return nil }
if predicate(f) { return f }
}
return nil
}
func authenticate(password: String) -> Bool {
// Welcome arrives unsolicited.
guard readUntil({ $0.cmdCode == CAP.Command.welcome.rawValue }) != nil else { return false }
let chalId = send(.requestPwdChallenge)
guard let chalReply = readUntil({ $0.msgId == chalId }),
let (challenge, _) = CAP.readString(chalReply.payload, at: 0) else { return false }
var pw = Data()
CAP.putString(MD5.hexString(Data((challenge + password).utf8)), into: &pw)
let pwId = send(.password, payload: pw)
guard let pwReply = readUntil({ $0.msgId == pwId }),
pwReply.cmdCode == CAP.Result.ok.rawValue else { return false }
return true
}
}
func testWelcomeOnConnect() async throws {
let port = await sim.boundPort! let port = await sim.boundPort!
let url = URL(string: "http://127.0.0.1:\(port)\(path)")! let client = try XCTUnwrap(TestClient(port: port))
let (data, resp) = try await URLSession.shared.data(from: url) let welcome = client.readUntil { $0.cmdCode == CAP.Command.welcome.rawValue }
return ((resp as! HTTPURLResponse).statusCode, data) XCTAssertNotNil(welcome)
let (version, _) = CAP.readString(welcome!.payload, at: 0) ?? ("", 0)
XCTAssertTrue(version.contains("CAP"))
} }
func put(_ path: String, body: Data) async throws -> (Int, Data) { func testAuthFlow() async throws {
let port = await sim.boundPort! let port = await sim.boundPort!
var req = URLRequest(url: URL(string: "http://127.0.0.1:\(port)\(path)")!) let client = try XCTUnwrap(TestClient(port: port))
req.httpMethod = "PUT" XCTAssertTrue(client.authenticate(password: "arri"))
req.httpBody = body
let (data, resp) = try await URLSession.shared.data(for: req)
return ((resp as! HTTPURLResponse).statusCode, data)
} }
// System info endpoint serves model + serial JSON. func testWrongPassword() async throws {
func testSystemInfo() async throws { let port = await sim.boundPort!
let (status, data) = try await get("/api/v1/system/info") let client = try XCTUnwrap(TestClient(port: port))
XCTAssertEqual(status, 200) XCTAssertFalse(client.authenticate(password: "nope"))
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]
XCTAssertEqual(obj?["model"] as? String, "ALEXA 35")
XCTAssertNotNil(obj?["serialNumber"])
} }
// Rec state reflects scripted changes. func testUnauthenticatedGetRejected() async throws {
func testRecStateScriptable() async throws { let port = await sim.boundPort!
var (_, data) = try await get("/api/v1/recording/status") let client = try XCTUnwrap(TestClient(port: port))
var obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] _ = client.readUntil { $0.cmdCode == CAP.Command.welcome.rawValue }
XCTAssertEqual(obj?["recording"] as? Bool, false) var payload = Data()
CAP.putU16(CAP.Variable.exposureIndex.rawValue, into: &payload)
await sim.setRecording(true, clipName: "A001C003_250710_R1CD") let id = client.send(.getVariable, payload: payload)
(_, data) = try await get("/api/v1/recording/status") let reply = client.readUntil { $0.msgId == id }
obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] XCTAssertEqual(reply?.cmdCode, CAP.Result.notAuthorized.rawValue)
XCTAssertEqual(obj?["recording"] as? Bool, true)
XCTAssertEqual(obj?["clipName"] as? String, "A001C003_250710_R1CD")
} }
// Metadata endpoint. func testGetVariable() async throws {
func testMetadata() async throws { await sim.setMetadata(ei: 1280, colorTemp: 3200, tint: -2, fps: 25, timecode: "01:02:03:04")
await sim.setMetadata(ei: 1280, wb: 3200, tint: -2, fps: 25, timecode: "10:20:30:12") let port = await sim.boundPort!
let (status, data) = try await get("/api/v1/camera/metadata") let client = try XCTUnwrap(TestClient(port: port))
XCTAssertEqual(status, 200) XCTAssertTrue(client.authenticate(password: "arri"))
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]
XCTAssertEqual(obj?["exposureIndex"] as? Int, 1280) var payload = Data()
XCTAssertEqual(obj?["whiteBalance"] as? Int, 3200) CAP.putU16(CAP.Variable.exposureIndex.rawValue, into: &payload)
XCTAssertEqual(obj?["tint"] as? Int, -2) let id = client.send(.getVariable, payload: payload)
XCTAssertEqual(obj?["timecode"] as? String, "10:20:30:12") let reply = client.readUntil { $0.msgId == id }
XCTAssertEqual(reply?.cmdCode, CAP.Result.ok.rawValue)
XCTAssertEqual(CAP.readU16(reply!.payload, at: 0), CAP.Variable.exposureIndex.rawValue)
XCTAssertEqual(CAP.readU32(reply!.payload, at: 2), 1280)
} }
// Look upload stored and retrievable. func testSetCDLStored() async throws {
func testLookUploadStored() async throws { let port = await sim.boundPort!
let payload = try JSONSerialization.data(withJSONObject: [ let client = try XCTUnwrap(TestClient(port: port))
"name": "shot42", XCTAssertTrue(client.authenticate(password: "arri"))
"cdl": ["slope": [1.1, 1.0, 0.9], "offset": [0, 0, 0], "power": [1, 1, 1], "saturation": 1.2],
"lut3dSize": 2,
"lut3dTable": Array(repeating: 0.5, count: 24),
])
let (status, _) = try await put("/api/v1/look/current", body: payload)
XCTAssertEqual(status, 200)
let stored = await sim.uploadedLooks var payload = Data()
XCTAssertEqual(stored.count, 1) CAP.putU16(CAP.Variable.cdlValues.rawValue, into: &payload)
let storedObj = try JSONSerialization.jsonObject(with: stored[0]) as? [String: Any] payload.append(CAP.encodeCDLBlob(
XCTAssertEqual(storedObj?["name"] as? String, "shot42") slope: (1.5, 1, 1), offset: (0, 0, 0), power: (1, 1, 1), saturation: 0.8))
let id = client.send(.setVariable, payload: payload)
let reply = client.readUntil { $0.msgId == id }
XCTAssertEqual(reply?.cmdCode, CAP.Result.ok.rawValue)
// Retrievable over HTTP too. let stored = await sim.lastCDL
let (gs, gd) = try await get("/api/v1/look/current") XCTAssertEqual(stored?[0] ?? 0, 1.5, accuracy: 1e-5)
XCTAssertEqual(gs, 200) XCTAssertEqual(stored?[9] ?? 0, 0.8, accuracy: 1e-5)
let obj = try JSONSerialization.jsonObject(with: gd) as? [String: Any]
XCTAssertEqual(obj?["name"] as? String, "shot42")
} }
// Malformed look rejected 400. func testSubscriptionPushesEvents() async throws {
func testMalformedLookRejected() async throws { let port = await sim.boundPort!
let (status, _) = try await put("/api/v1/look/current", body: Data("not json".utf8)) let client = try XCTUnwrap(TestClient(port: port))
XCTAssertEqual(status, 400) XCTAssertTrue(client.authenticate(password: "arri"))
}
// Unknown path 404. var payload = Data()
func testUnknownPath404() async throws { CAP.putU16(CAP.Variable.cameraState.rawValue, into: &payload)
let (status, _) = try await get("/api/v1/warp/drive") let id = client.send(.requestVariables, payload: payload)
XCTAssertEqual(status, 404) _ = client.readUntil { $0.msgId == id }
await sim.setRecording(true)
let event = client.readUntil { frame in
frame.msgType == CAP.MsgType.event.rawValue
&& CAP.readU16(frame.payload, at: 0) == CAP.Variable.cameraState.rawValue
&& CAP.CameraStateBits(rawValue: CAP.readU16(frame.payload, at: 2) ?? 0).contains(.recording)
}
XCTAssertNotNil(event)
} }
} }

View file

@ -25,7 +25,7 @@ final class EndToEndExportTests: XCTestCase {
let project = try store.createProject(name: "E2E") let project = try store.createProject(name: "E2E")
let day = try store.createDay(projectID: project.id, label: "Day 01", date: "2026-07-10") let day = try store.createDay(projectID: project.id, label: "Day 01", date: "2026-07-10")
let driver = ArriDriver(host: "127.0.0.1", port: port, pollInterval: .milliseconds(15)) let driver = ArriDriver(host: "127.0.0.1", port: port)
let logger = ClipLogger(store: store, dayID: day.id) let logger = ClipLogger(store: store, dayID: day.id)
let grade = GradeStack(nodes: [GradeNode(kind: .cdl(CDL( let grade = GradeStack(nodes: [GradeNode(kind: .cdl(CDL(
@ -36,18 +36,18 @@ final class EndToEndExportTests: XCTestCase {
await logger.attach(slotName: "A-Cam", driver: driver) { snapshotForProvider } await logger.attach(slotName: "A-Cam", driver: driver) { snapshotForProvider }
try await driver.connect() try await driver.connect()
// Three takes. // Three takes. CAP sim composes clip names as RxxxCxxx from reel+clip vars.
let takes: [(clip: String, tcIn: String, tcOut: String)] = [ let takes: [(clipNo: UInt16, clip: String, tcIn: String, tcOut: String)] = [
("A001C001_260710", "10:00:00:00", "10:00:30:00"), (1, "R001C001", "10:00:00:00", "10:00:30:00"),
("A001C002_260710", "10:05:00:00", "10:05:45:00"), (2, "R001C002", "10:05:00:00", "10:05:45:00"),
("A001C003_260710", "10:12:00:00", "10:13:00:00"), (3, "R001C003", "10:12:00:00", "10:13:00:00"),
] ]
for take in takes { for take in takes {
await sim.setMetadata(ei: 800, wb: 5600, tint: 0, fps: 24, timecode: take.tcIn) await sim.setMetadata(ei: 800, colorTemp: 5600, tint: 0, fps: 24, timecode: take.tcIn)
try await Task.sleep(for: .milliseconds(60)) try await Task.sleep(for: .milliseconds(60))
await sim.setRecording(true, clipName: take.clip) await sim.setRecording(true, clipNumber: take.clipNo)
try await Task.sleep(for: .milliseconds(80)) try await Task.sleep(for: .milliseconds(80))
await sim.setMetadata(ei: 800, wb: 5600, tint: 0, fps: 24, timecode: take.tcOut) await sim.setMetadata(ei: 800, colorTemp: 5600, tint: 0, fps: 24, timecode: take.tcOut)
try await Task.sleep(for: .milliseconds(60)) try await Task.sleep(for: .milliseconds(60))
await sim.setRecording(false) await sim.setRecording(false)
try await Task.sleep(for: .milliseconds(80)) try await Task.sleep(for: .milliseconds(80))
@ -68,13 +68,13 @@ final class EndToEndExportTests: XCTestCase {
// All export formats produce sane output. // All export formats produce sane output.
let ale = PostExport.writeALE(shots: shots, fps: 24) let ale = PostExport.writeALE(shots: shots, fps: 24)
XCTAssertEqual(ale.components(separatedBy: "A001C").count - 1, 3) XCTAssertEqual(ale.components(separatedBy: "R001C").count - 1, 3)
let csv = PostExport.writeCSV(shots: shots) let csv = PostExport.writeCSV(shots: shots)
XCTAssertEqual(csv.split(separator: "\n").count, 4) // header + 3 XCTAssertEqual(csv.split(separator: "\n").count, 4) // header + 3
let edl = PostExport.writeEDL(shots: shots, title: "DAY01", fps: 24) let edl = PostExport.writeEDL(shots: shots, title: "DAY01", fps: 24)
XCTAssertTrue(edl.contains("003 A001C003_260710")) XCTAssertTrue(edl.contains("003 R001C003"))
XCTAssertEqual(edl.components(separatedBy: "* ASC_SOP").count - 1, 3) XCTAssertEqual(edl.components(separatedBy: "* ASC_SOP").count - 1, 3)
let ccc = CDLExport.writeCCC(shots.compactMap { s in let ccc = CDLExport.writeCCC(shots.compactMap { s in

342
docs/design/wireframes.html Normal file
View file

@ -0,0 +1,342 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Forge — Wireframes (Reel tokens)</title>
<style>
/* ============ Reel design tokens (from dragonflight styles.css) ============ */
:root {
--bg-0: #08090C; --bg-1: #0C0E12; --bg-2: #111318; --bg-3: #181B22; --bg-4: #22262F;
--text-1: #F6F4EF; --text-2: #B4B0A6; --text-3: #86837B; --text-4: #63615B;
--accent: #F0A63C; --accent-hover: #F7B857; --accent-text: #F6C583;
--accent-soft: rgba(240,166,60,0.12);
--success: #5FBF97; --warning: #E6A93C; --danger: #E86A6A; --live: #FF3B30;
--live-soft: rgba(255,59,48,0.12); --success-soft: rgba(95,191,151,0.12);
--hairline: rgba(255,255,255,0.07); --hairline-2: rgba(255,255,255,0.12);
--hover-fill: rgba(255,255,255,0.028);
--mono: "Geist Mono", "SF Mono", ui-monospace, monospace;
--sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #04050 6; background: #040506; color: var(--text-1);
font-family: var(--sans); font-size: 13px; letter-spacing: -0.006em; padding: 40px; }
h1 { font-size: 22px; font-weight: 600; margin-bottom: 6px; }
.intro { color: var(--text-3); margin-bottom: 36px; max-width: 720px; line-height: 1.5; }
h2 { font-size: 15px; font-weight: 600; margin: 42px 0 4px; color: var(--accent-text); }
.philosophy { color: var(--text-3); margin-bottom: 14px; max-width: 720px; line-height: 1.5; }
/* window chrome */
.window { background: var(--bg-0); border: 1px solid var(--hairline-2); border-radius: 10px;
overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.6); max-width: 1240px; }
.titlebar { height: 38px; background: var(--bg-1); border-bottom: 1px solid var(--hairline);
display: flex; align-items: center; padding: 0 14px; gap: 8px; }
.traffic { display: flex; gap: 7px; }
.traffic span { width: 11px; height: 11px; border-radius: 50%; background: var(--bg-4); }
.titlebar .title { flex: 1; text-align: center; color: var(--text-3); font-size: 12px; }
.toolbar { height: 44px; background: var(--bg-1); border-bottom: 1px solid var(--hairline);
display: flex; align-items: center; padding: 0 14px; gap: 10px; }
.tbtn { height: 28px; padding: 0 12px; border-radius: 4px; background: var(--bg-3);
border: 1px solid var(--hairline-2); color: var(--text-2); font-size: 12px;
display: inline-flex; align-items: center; gap: 6px; }
.tbtn.primary { background: var(--accent); color: #1A1206; border: none; font-weight: 600; }
.tspacer { flex: 1; }
.eyebrow { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.16em;
font-weight: 600; color: var(--text-4); padding: 12px 14px 6px; }
.mono { font-family: var(--mono); }
/* badges + dots */
.badge { font-size: 9.5px; text-transform: uppercase; font-weight: 600; letter-spacing: 0.08em;
padding: 2px 7px; border-radius: 999px; }
.badge.rec { background: var(--live-soft); color: var(--live); }
.badge.ok { background: var(--success-soft); color: var(--success); }
.badge.neutral { background: var(--bg-4); color: var(--text-3); }
.dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.dot.rec { background: var(--live); box-shadow: 0 0 6px var(--live); animation: pulse 1.2s infinite; }
.dot.ok { background: var(--success); }
.dot.off { background: var(--text-4); }
@keyframes pulse { 50% { opacity: 0.4; } }
/* ============ LAYOUT A: grade-centric ============ */
.layoutA { display: grid; grid-template-columns: 232px 1fr 300px; height: 660px; }
.colA { border-right: 1px solid var(--hairline); background: var(--bg-1);
display: flex; flex-direction: column; }
.slot { margin: 0 8px 8px; border: 1px solid var(--hairline); border-radius: 5px;
background: var(--bg-2); padding: 10px 12px; }
.slot.active { border-color: var(--accent); background: var(--accent-soft); }
.slot .row1 { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.slot .name { font-weight: 600; font-size: 12.5px; flex: 1; }
.slot .meta { font-family: var(--mono); font-size: 10.5px; color: var(--text-3);
display: flex; gap: 10px; }
.gang { margin: auto 8px 12px; padding: 10px 12px; border: 1px dashed var(--hairline-2);
border-radius: 5px; color: var(--text-3); font-size: 11.5px; }
.gang b { color: var(--accent-text); font-weight: 600; }
.center { background: var(--bg-0); display: flex; flex-direction: column; }
.preview { flex: 1; margin: 12px; border-radius: 6px; background:
linear-gradient(135deg, #1a1d24 0%, #232833 50%, #1a1d24 100%);
border: 1px solid var(--hairline); position: relative; min-height: 260px; }
.preview .tc { position: absolute; top: 10px; right: 12px; font-family: var(--mono);
font-size: 13px; color: var(--text-2); background: rgba(0,0,0,0.55);
padding: 3px 8px; border-radius: 4px; }
.preview .cam { position: absolute; top: 10px; left: 12px; font-family: var(--mono);
font-size: 11px; color: var(--accent-text); background: rgba(0,0,0,0.55);
padding: 3px 8px; border-radius: 4px; }
.preview .recbug { position: absolute; bottom: 10px; left: 12px; display: flex;
align-items: center; gap: 6px; font-size: 11px; color: var(--live);
background: rgba(0,0,0,0.55); padding: 3px 8px; border-radius: 4px; }
.nodes { border-top: 1px solid var(--hairline); background: var(--bg-1); padding-bottom: 12px; }
.nodestrip { display: flex; gap: 8px; padding: 0 14px; }
.node { min-width: 118px; border: 1px solid var(--hairline-2); border-radius: 5px;
background: var(--bg-2); padding: 8px 10px; }
.node.sel { border-color: var(--accent); }
.node .k { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em;
color: var(--text-4); font-weight: 600; margin-bottom: 4px; }
.node .v { font-family: var(--mono); font-size: 11px; color: var(--text-2); line-height: 1.5; }
.node.bypass { opacity: 0.45; }
.colC { border-left: 1px solid var(--hairline); background: var(--bg-1); display: flex;
flex-direction: column; }
.shotrow { display: grid; grid-template-columns: 44px 1fr auto; gap: 10px; align-items: center;
padding: 8px 14px; border-bottom: 1px solid var(--hairline); }
.shotrow:hover { background: var(--hover-fill); }
.thumb { width: 44px; height: 26px; border-radius: 3px;
background: linear-gradient(135deg, #2a2f3a, #3a4150); }
.shotrow .cname { font-family: var(--mono); font-size: 11.5px; color: var(--text-1); }
.shotrow .ctc { font-family: var(--mono); font-size: 10px; color: var(--text-4); }
/* ============ LAYOUT B: library-centric ============ */
.layoutB { display: grid; grid-template-columns: 232px 1fr 320px; height: 660px; }
.libtable { flex: 1; overflow: hidden; }
.librow { display: grid; grid-template-columns: 56px 1.2fr 1fr 1fr 0.7fr 0.7fr auto;
gap: 12px; align-items: center; padding: 9px 14px;
border-bottom: 1px solid var(--hairline); font-size: 12px; }
.librow.head { color: var(--text-4); font-size: 10.5px; text-transform: uppercase;
letter-spacing: 0.12em; font-weight: 600; border-bottom: 1px solid var(--hairline-2); }
.librow:not(.head):hover { background: var(--hover-fill); }
.librow.sel { background: var(--accent-soft); }
.lthumb { width: 56px; height: 32px; border-radius: 3px;
background: linear-gradient(135deg, #2a2f3a, #3a4150); }
.side-section { border-bottom: 1px solid var(--hairline); padding-bottom: 10px; }
.kv { display: flex; justify-content: space-between; padding: 3px 14px; font-size: 11.5px; }
.kv .k { color: var(--text-4); }
.kv .v { font-family: var(--mono); color: var(--text-2); }
.treeitem { padding: 6px 14px; color: var(--text-2); font-size: 12.5px; display: flex;
gap: 8px; align-items: center; }
.treeitem.sel { background: var(--accent-soft); color: var(--accent-text);
border-left: 2px solid var(--accent); padding-left: 12px; }
.treeitem .cnt { margin-left: auto; font-family: var(--mono); font-size: 10.5px;
color: var(--text-4); }
.footnote { color: var(--text-4); font-size: 11.5px; margin-top: 10px; max-width: 720px;
line-height: 1.5; }
.verdict { margin-top: 40px; padding: 18px 20px; border: 1px solid var(--hairline-2);
border-radius: 8px; background: var(--bg-1); max-width: 1240px; }
.verdict h3 { font-size: 13px; color: var(--accent-text); margin-bottom: 8px; }
.verdict p { color: var(--text-2); font-size: 12.5px; line-height: 1.6; margin-bottom: 6px; }
</style>
</head>
<body>
<h1>Forge — main window wireframes</h1>
<p class="intro">Two layout philosophies, both rendered in Dragonflight "Reel" tokens
(cinema blacks, amber accent as tally, broadcast red = recording only, mono for all
technical values, hairline separation, 10.5px eyebrows). Structure is macOS-native:
NavigationSplitView three-pane, real toolbar. Same skin — different center of gravity.</p>
<!-- ================= LAYOUT A ================= -->
<h2>Layout A — grade-centric ("Livegrade philosophy")</h2>
<p class="philosophy">The preview + node stack IS the app. Cameras left, shots accumulate
right as a passive log. Optimized for the 95% moment: DP behind you, trackball in hand,
grade now. Library is glanceable, not navigable.</p>
<div class="window">
<div class="titlebar"><div class="traffic"><span></span><span></span><span></span></div>
<div class="title">Forge — Day 03 · Project Nightfall</div></div>
<div class="toolbar">
<button class="tbtn primary">● Push Look</button>
<button class="tbtn">Grab Still</button>
<button class="tbtn">Bypass</button>
<div class="tspacer"></div>
<span class="badge ok">3 CAMERAS ONLINE</span>
<button class="tbtn">Export Day…</button>
</div>
<div class="layoutA">
<!-- left: camera slots -->
<div class="colA">
<div class="eyebrow">Camera Slots</div>
<div class="slot active">
<div class="row1"><span class="dot rec"></span><span class="name">A — ALEXA 35</span>
<span class="badge rec">REC</span></div>
<div class="meta"><span>R001C007</span><span>EI 800</span><span>5600K</span></div>
</div>
<div class="slot">
<div class="row1"><span class="dot ok"></span><span class="name">B — V-RAPTOR</span>
<span class="badge ok">CDL</span></div>
<div class="meta"><span>B001_C012</span><span>ISO 800</span><span>24.0</span></div>
</div>
<div class="slot">
<div class="row1"><span class="dot off"></span><span class="name">C — VENICE 2</span>
<span class="badge neutral">MONITOR</span></div>
<div class="meta"><span></span><span>SDI chain</span></div>
</div>
<div class="gang">GANG: <b>A + B linked</b> · edits propagate · C solo</div>
</div>
<!-- center: preview + nodes -->
<div class="center">
<div class="preview">
<span class="cam">A · LogC4 → Rec709</span>
<span class="tc mono">14:23:07:11</span>
<span class="recbug"><span class="dot rec"></span>REC R001C007</span>
</div>
<div class="nodes">
<div class="eyebrow">Node Stack — A (ganged with B)</div>
<div class="nodestrip">
<div class="node"><div class="k">Input</div><div class="v">LogC4 / AWG4</div></div>
<div class="node sel"><div class="k">CDL · native</div>
<div class="v">S 1.12 0.98 1.04<br>O +.02 .00 .01<br>P 0.96 1.00 1.02 · Sat 1.10</div></div>
<div class="node"><div class="k">Curves</div><div class="v">master +2pt</div></div>
<div class="node bypass"><div class="k">Sat</div><div class="v">0.92 · bypassed</div></div>
<div class="node"><div class="k">LUT</div><div class="v">K1S1_Film.cube</div></div>
<div class="node"><div class="k">Output</div><div class="v">Rec709 2.4</div></div>
</div>
</div>
</div>
<!-- right: shot log -->
<div class="colC">
<div class="eyebrow">Today — 23 Shots</div>
<div class="shotrow"><div class="thumb"></div>
<div><div class="cname">R001C007</div><div class="ctc">14:22:31 → rolling</div></div>
<span class="badge rec">REC</span></div>
<div class="shotrow"><div class="thumb"></div>
<div><div class="cname">R001C006</div><div class="ctc">14:18:02 → 14:19:44</div></div>
<span class="badge neutral">A</span></div>
<div class="shotrow"><div class="thumb"></div>
<div><div class="cname">B001_C012</div><div class="ctc">14:17:58 → 14:19:41</div></div>
<span class="badge neutral">B</span></div>
<div class="shotrow"><div class="thumb"></div>
<div><div class="cname">R001C005</div><div class="ctc">13:58:11 → 14:01:03</div></div>
<span class="badge neutral">A</span></div>
<div class="shotrow"><div class="thumb"></div>
<div><div class="cname">R001C004</div><div class="ctc">13:41:47 → 13:44:12</div></div>
<span class="badge neutral">A</span></div>
</div>
</div>
</div>
<p class="footnote">Trackball focus always lands on the selected node of the active slot.
Slot switch = one keystroke / panel button. Shot log rows are read-only here — clicking
one recalls its grade as a new version, never edits history.</p>
<!-- ================= LAYOUT B ================= -->
<h2>Layout B — library-centric ("Silverstack philosophy")</h2>
<p class="philosophy">The shot table IS the app. Days/bins tree left, big sortable clip
table center, inspector right with grade + metadata for selection. Live grading is a mode
the inspector enters when a camera is selected. Optimized for data management, review,
exports — the DIT-cart-as-workstation story.</p>
<div class="window">
<div class="titlebar"><div class="traffic"><span></span><span></span><span></span></div>
<div class="title">Forge — Project Nightfall</div></div>
<div class="toolbar">
<button class="tbtn">Offload…</button>
<button class="tbtn">Verify</button>
<button class="tbtn">Proxies…</button>
<div class="tspacer"></div>
<span class="badge rec">A REC</span>
<button class="tbtn primary">Reports…</button>
</div>
<div class="layoutB">
<!-- left: project tree -->
<div class="colA">
<div class="eyebrow">Project</div>
<div class="treeitem">Day 01 <span class="cnt">41</span></div>
<div class="treeitem">Day 02 <span class="cnt">38</span></div>
<div class="treeitem sel">Day 03 <span class="cnt">23</span></div>
<div class="eyebrow">Cameras</div>
<div class="treeitem"><span class="dot rec"></span> A — ALEXA 35</div>
<div class="treeitem"><span class="dot ok"></span> B — V-RAPTOR</div>
<div class="treeitem"><span class="dot off"></span> C — VENICE 2</div>
<div class="eyebrow">Jobs</div>
<div class="treeitem">Offload A001 <span class="cnt"></span></div>
<div class="treeitem">Proxies Day 02 <span class="cnt">72%</span></div>
</div>
<!-- center: clip table -->
<div class="center">
<div class="libtable">
<div class="librow head"><span></span><span>Clip</span><span>TC In / Out</span>
<span>Camera · Look</span><span>EI / WB</span><span>Grade</span><span></span></div>
<div class="librow"><div class="lthumb"></div>
<span class="mono">R001C007</span>
<span class="mono" style="color:var(--text-3)">14:22:31 / —</span>
<span>A · K1S1_Film</span><span class="mono" style="color:var(--text-3)">800 · 5600K</span>
<span class="badge ok">CDL+LUT</span><span class="badge rec">REC</span></div>
<div class="librow sel"><div class="lthumb"></div>
<span class="mono">R001C006</span>
<span class="mono" style="color:var(--text-3)">14:18:02 / 14:19:44</span>
<span>A · K1S1_Film</span><span class="mono" style="color:var(--text-3)">800 · 5600K</span>
<span class="badge ok">CDL+LUT</span><span></span></div>
<div class="librow"><div class="lthumb"></div>
<span class="mono">B001_C012</span>
<span class="mono" style="color:var(--text-3)">14:17:58 / 14:19:41</span>
<span>B · CDL only</span><span class="mono" style="color:var(--text-3)">800 · 5600K</span>
<span class="badge ok">CDL</span><span></span></div>
<div class="librow"><div class="lthumb"></div>
<span class="mono">R001C005</span>
<span class="mono" style="color:var(--text-3)">13:58:11 / 14:01:03</span>
<span>A · K1S1_Film</span><span class="mono" style="color:var(--text-3)">800 · 5600K</span>
<span class="badge ok">CDL+LUT</span><span></span></div>
<div class="librow"><div class="lthumb"></div>
<span class="mono">R001C004</span>
<span class="mono" style="color:var(--text-3)">13:41:47 / 13:44:12</span>
<span>A · K1S1_Film</span><span class="mono" style="color:var(--text-3)">800 · 5600K</span>
<span class="badge ok">CDL+LUT</span><span></span></div>
<div class="librow"><div class="lthumb"></div>
<span class="mono">R001C003</span>
<span class="mono" style="color:var(--text-3)">13:22:09 / 13:25:40</span>
<span>A · K1S1_Film</span><span class="mono" style="color:var(--text-3)">1280 · 3200K</span>
<span class="badge ok">CDL+LUT</span><span></span></div>
</div>
</div>
<!-- right: inspector -->
<div class="colC">
<div class="eyebrow">Inspector — R001C006</div>
<div class="side-section">
<div class="preview" style="height:120px; margin:0 14px 10px; flex:none;"></div>
<div class="kv"><span class="k">Duration</span><span class="v">00:01:42:03</span></div>
<div class="kv"><span class="k">EI / WB / Tint</span><span class="v">800 / 5600K / 0</span></div>
<div class="kv"><span class="k">FPS</span><span class="v">24.000</span></div>
<div class="kv"><span class="k">Offload</span><span class="v" style="color:var(--success)">verified ×2</span></div>
</div>
<div class="eyebrow">Grade Snapshot</div>
<div class="side-section">
<div class="kv"><span class="k">Slope</span><span class="v">1.12 0.98 1.04</span></div>
<div class="kv"><span class="k">Offset</span><span class="v">+.02 .00 .01</span></div>
<div class="kv"><span class="k">Power</span><span class="v">0.96 1.00 1.02</span></div>
<div class="kv"><span class="k">Sat</span><span class="v">1.10</span></div>
<div class="kv"><span class="k">LUT</span><span class="v">K1S1_Film.cube</span></div>
</div>
<div class="eyebrow">Actions</div>
<div style="padding: 4px 14px; display:flex; gap:8px; flex-wrap:wrap;">
<button class="tbtn">Recall Grade</button>
<button class="tbtn">Export CDL</button>
<button class="tbtn">Still</button>
</div>
</div>
</div>
</div>
<p class="footnote">Grading here happens in the inspector (or a summonable grade drawer) —
one step removed from the image. Stronger for offload/verify/report days; weaker for
the "roll sound, grade now" moment.</p>
<div class="verdict">
<h3>Recommendation</h3>
<p><b>Layout A as the main window, with B's table one ⌘-key away (Library tab/window).</b>
Forge's differentiator is in-camera live grading — the app should open into the moment
it wins. The library table (B) is genuinely better for end-of-day work, so ship it as a
second surface using the same panes, not a compromise hybrid of both.</p>
<p>Shared skin either way: bg-0 chrome, amber tally accent, #FF3B30 strictly for REC,
SF Mono for TC/CDL/clip names, hairline dividers, 10.5px eyebrows — family resemblance
to dragonflight without pretending to be a web app.</p>
</div>
</body>
</html>

View file

@ -0,0 +1,66 @@
# Roadmap 002 — Feature gaps vs Pomfort (from competitive research)
Source: docs/research/livegrade-silverstack-research.md + protocol research docs.
Status of core: 200 tests green; ARRI CAP + RED RCP2 drivers wire-verified; Sony monitoring-only.
## Tier 1 — differentiators / near-term (build next)
### T1.1 Look-match bridge (the Pomfort two-product moat, ours for free)
Grade snapshots already stored per shot keyed by clip name. Add:
- `forge ingest-match`: scan offloaded card, match clips to shot records by clip name,
auto-attach grade snapshot + emit per-clip .cdl/.cube alongside media
- This is Livegrade→Silverstack ShotHub flow without the cloud. Sister-product hook for
dragonflight later (shared JSON grade snapshot format already deterministic).
### T1.2 Offload scheduler upgrade (biggest Lab-parity gap)
- Separate copy vs verify jobs (verify-later scheduling)
- Per-drive concurrency caps (don't saturate slow shuttle drives)
- Source re-read verification mode (card read integrity, not just copy)
- Cascading offload (fast RAID first, then RAID→LTO/archive as second-stage job)
- Job model: small DAG, per-resource queues. ForgeOffload already has verified-copy core.
### T1.3 Checksum breadth
- Add XXH3/XXH128 (ASC MHL v2 supports both; spec'd) + C4ID (entertainment standard)
- SHA1 for legacy post houses. All slot into FileHasher + MHL writer cleanly.
### T1.4 Report engine
- PDF/HTML shot reports w/ still thumbnails + grades (library has stills + snapshots)
- Offload reports already HTML — unify styling w/ Reel design tokens.
## Tier 2 — solid adds
### T2.1 Grading model extensions
- Printer lights + LGG interaction modes over existing CDL (interaction layer, same math)
- HSL secondaries node; 1D LUT node
- ACES: IDT/ODT via existing ColorSpace machinery; AMF export later
### T2.2 Stream Deck + MIDI control
- DITs own Stream Decks. Cheap panel option beside Tangent. ForgePanel action model
already abstracted (PanelAction) — new input adapters only.
### T2.3 Slot filters (non-grade image ops)
- Frame lines, anamorphic desqueeze, flip, metadata burn-in as per-slot filter chain
in preview/output path (ForgeVideo FrameProcessor extension).
### T2.4 Audio/LTC (dailies-tier)
- BWF detection + LTC-from-audio TC extraction + auto sync. Feeds proxy engine.
## Tier 3 — explicitly deferred (Pomfort sells these as paid add-ons = niche signal)
- LED wall control, internet streaming, chroma keyer, composite multi-view slots,
50-input routing, LTFS/LTO, RAW debayer playback (huge scope; proxies via ffmpeg
sidestep), NDI input, router control (KUMO/Videohub).
## Verified protocol status (carry-forward)
| Camera | Transport | Look push | State |
|---|---|---|---|
| ARRI Alexa 35 / Mini LF | CAP binary TCP :5055 | CDL native (0x0050) + LUT experimental | driver rewritten, sim-verified |
| RED DSMC3 | RCP2 WebSocket :9998/rcp | CDL native (per-channel params), no live LUT | driver rewritten, sim-verified |
| Sony Venice 2 | closed (web remote only public) | none claimed | monitoring-only; SDI-chain grading path |
Note: Livegrade 7.2 ships Venice 2 in-camera grading — proves a partner wire path exists.
Escalation: Sony partner SDK or packet capture on hardware.
## Hardware-phase checklist (macOS, unchanged from plan 001 Phase 6)
1. SwiftUI shell + Metal preview + DeckLink + Tangent Hub TIPC
2. On-hardware verification: CAP port/MD5-concat-order/LUT chunking; RCP2 against Komodo;
Venice web-remote backend capture

View file

@ -0,0 +1,115 @@
# ARRI CAP (Camera Access Protocol) — verified findings
Sources:
- `gambithunt/arri-camera-control` (GitHub) — contains ARRI's CAP v1.12 spec (June 2024,
covers ALEXA 35 1.3.0 / Mini LF / SXT) reformatted as markdown, plus Node + Swift clients
- ARRI public CAP feature-list PDF (arri.com, v1.11, 2026-03)
- Cyanview integration docs (field-verified default password, camera menu path)
## Verdict
Our v1 driver's REST paths are fiction. Real protocol: **CAP — binary over TCP, port 5055,
big-endian**. No REST/WebSocket/JSON on camera. Full spec behind ARRI Partner (CAP) Program;
enough is public to build the real driver.
## Transport — VERIFIED
- TCP port **5055** (spec). ⚠️ One client repo defaults 7777 — trust spec, confirm on hardware.
- Frame: `[length U16][msgType U8][msgId U16][cmdCode U16][payload]`, length includes 7-byte header.
- msgType: 0x01 COMMAND, 0x02 REPLY (same msgId), 0x03 EVENT (subscription push).
- Strings: U16 length + UTF-8.
- Keep-alive: `Live` (0x0080) every 1 s or connection drops.
- Discovery: Bonjour/mDNS (service type string unconfirmed — INFERRED).
## Handshake — VERIFIED (order); MD5 concat order INFERRED
1. TCP connect → camera sends `Welcome` (0x0087) w/ protocol version + result
(`Result_OK` / `Result_TooManyClients`)
2. → `RequestPwdChallenge` (0x0081) → challenge
3. → `Password` (0x0082) = MD5(challenge concat password) — **test both concat orders**;
default camera password `arri` (Cyanview, field practice)
4. → `ClientName` (0x0083)
5. Normal traffic.
Result codes: OK=0, NO_SUCH_COMMAND=1, NOT_ALLOWED=2, NO_SUCH_VARIABLES=3, WRONG_PASSWD=4,
WRONG_TYPE=6, NOT_AUTHORIZED=7, TOO_MANY_CLIENTS=8, PROTOCOL_ERROR=9.
## Commands we need — VERIFIED
| Command | Code |
|---|---|
| Live (keep-alive) | 0x0080 |
| RequestPwdChallenge / Password / ClientName | 0x0081 / 0x0082 / 0x0083 |
| RequestVariables (subscribe) / Un-Request | 0x0084 / 0x0085 |
| SetVariable / GetVariable | 0x0086 / 0x0090 |
| Welcome | 0x0087 |
| GetFrameGrab | 0x0088 |
| Set3DLutFile / Set3DLutData / Set3DLutDataEx | 0x008a / 0x008c / 0x00F5 |
| SaveLookFile / LoadLookFile / AddLookFile / InstallLookFile | 0x008d / 0x00b1 / 0x00F0 / 0x00b2 |
| RecordStart / RecordStop | 0x00a0 / 0x00a1 |
| GetClipList (ALE metadata) | 0x00a4 |
## Variables we need — VERIFIED
| Purpose | ID | Type | Notes |
|---|---|---|---|
| Camera state | 0x0040 | U16 bitfield | RECORDING=0x0001, STDBY_READY=0x0004, IDLE=0x0080... |
| **CDL values** | **0x0050** | BLOB 10×F32 | slope×3 offset×3 power×3 sat (order INFERRED from ASC convention) |
| Color temperature | 0x0051 | U32 | Kelvin |
| Tint | 0x0052 | F32 | |
| Exposure index | 0x0053 | U32 | ASA |
| Reel / clip number | 0x005C / 0x005D | U16 | clip names via GetClipList |
| Sensor FPS | 0x0061 | F32 | |
| Timecode | 0x0078 | U32 BCD | HHMMSSFF |
| Look filename | 0x0041 | string | read-only |
| Look intensity (A35) | 0x00A9 | F32 | 01 |
Live status: `RequestVariables` once → EVENT frames on change (preferred over polling).
## Look control summary
- **Native CDL: yes** — SetVariable 0x0050, 10 floats, live-tweakable. Perfect for our CDL-split flow.
- **3D LUT: yes** — Set3DLutData/Ex + SaveLookFile; ALF4 on ALEXA 35 (ALF2 Mini LF);
file-transfer chunk framing NOT publicly documented (spec sections mangled) — INFERRED,
needs partner spec or hardware sniffing.
- Look intensity + parametric looks (A35, mesh points 0x00AB).
## Driver rework plan
1. Replace REST client with TCP binary CAP client (frame codec fully testable).
2. Handshake state machine incl. MD5 challenge (both concat orders behind a flag).
3. Subscribe to state/metadata variables; EVENT-driven CameraState.
4. Look push: CDL via SetVariable 0x0050 always; LUT via Set3DLutData behind a
"verified-on-hardware" flag until chunk framing confirmed.
5. capabilities: supportsNativeCDL=true; lut3dSizes stays [17,33,65] but LUT push marked
experimental.
# Sony VENICE 2 — verified findings
Sources: `sethdotfm/MultiVENICETool` (Python, archived), Cyanview + Bit Part integration docs.
## Verdict
**Closed protocol.** No public REST API; our /cna/v1 paths are fiction.
- VERIFIED: camera serves authenticated web remote at `http://<ip>/rmt.html`
(HTTP Basic, port 80, enable via Technical > Network > Setting = LAN, auth user/pass).
MultiVENICETool drives it via headless browser (DOM clicks e.g. BUTTON_REC_BUTTON) —
i.e. even open-source tooling could not find a clean wire API.
- VERIFIED (Cyanview capability list, wire format proprietary): IP control exists for
iris, ISO/gain, shutter, ND, WB (temp+tint+AWB), REC start/stop, tally, TC, scene files —
spoken natively by partner products (Cyanview RCP, Pomfort, ARRI NIA-1). Protocol
undocumented publicly.
- Look/LUT/CDL push over IP: **unknown/likely partner-only.** Monitor LUT selection exists
on camera; no public evidence of live LUT upload over IP.
## Driver posture (revised)
- Keep SonyDriver as **capability-minimal**: metadata/rec-state via whatever transport is
verified on hardware later; look push marked unsupported until partner docs or packet
capture prove otherwise. supportsNativeCDL=false; lut3dSizes=[] (was [17,33] — WRONG,
downgraded pending verification).
- Practical on-set path for Venice today: grade via SDI-chain LUT box (our DeckLink output
or Teradek/BoxIO), not camera-internal. This matches Livegrade's own Venice story.
- Escalation options: Sony partner SDK; packet-capture rmt.html backend traffic on real
camera; monitor Sony's newer BURANO/FX-line REST APIs for convergence.