Compare commits
No commits in common. "014c7ca283efad4f34bdc678f94b57a95dd848ee" and "172b554205c7d3dc2f5c63cb341980d38e340d0d" have entirely different histories.
014c7ca283
...
172b554205
14 changed files with 413 additions and 1747 deletions
|
|
@ -18,8 +18,6 @@ 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"),
|
||||||
|
|
@ -27,8 +25,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
61
README.md
|
|
@ -1,61 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
import ForgeCamera
|
import ForgeCamera
|
||||||
import ForgeGrade
|
import ForgeGrade
|
||||||
import ForgeColor
|
import ForgeColor
|
||||||
import ForgeOffload
|
|
||||||
|
|
||||||
#if canImport(Glibc)
|
/// ARRI REST endpoint paths — single source of truth for this driver.
|
||||||
import Glibc
|
/// NOTE: shapes modeled on ARRI's documented Camera Companion/REST API family;
|
||||||
#endif
|
/// verify + correct against hardware/API docs in macOS phase. Sim mirrors these.
|
||||||
|
enum ArriPaths {
|
||||||
|
static let systemInfo = "/api/v1/system/info"
|
||||||
|
static let recordingStatus = "/api/v1/recording/status"
|
||||||
|
static let metadata = "/api/v1/camera/metadata"
|
||||||
|
static let lookCurrent = "/api/v1/look/current"
|
||||||
|
}
|
||||||
|
|
||||||
/// ALEXA 35 / Mini LF driver — real ARRI CAP protocol:
|
/// ALEXA 35 driver: REST over IP. Polls rec state + metadata, pushes CDL+LUT looks.
|
||||||
/// 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,
|
||||||
|
|
@ -22,27 +26,25 @@ public actor ArriDriver: CameraDriver {
|
||||||
|
|
||||||
private let host: String
|
private let host: String
|
||||||
private let port: Int
|
private let port: Int
|
||||||
private let password: String
|
private let pollInterval: Duration
|
||||||
private let clientName: String
|
private let session: URLSession
|
||||||
|
|
||||||
private var socketFD: Int32 = -1
|
private var pollTask: Task<Void, Never>?
|
||||||
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 = 5055, password: String = "arri", clientName: String = "Forge") {
|
public init(host: String, port: Int, pollInterval: Duration = .milliseconds(150)) {
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.password = password
|
self.pollInterval = pollInterval
|
||||||
self.clientName = clientName
|
let config = URLSessionConfiguration.ephemeral
|
||||||
|
config.timeoutIntervalForRequest = 3
|
||||||
|
session = URLSession(configuration: config)
|
||||||
}
|
}
|
||||||
|
|
||||||
public nonisolated var state: AsyncStream<CameraState> {
|
public nonisolated var state: AsyncStream<CameraState> {
|
||||||
|
|
@ -70,289 +72,127 @@ public actor ArriDriver: CameraDriver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Socket
|
// MARK: HTTP
|
||||||
|
|
||||||
private func openSocket() throws {
|
private func url(_ path: String) -> URL {
|
||||||
let fd = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0)
|
URL(string: "http://\(host):\(port)\(path)")!
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func closeSocket() {
|
/// Completion-handler dataTask wrapped in a continuation. The async
|
||||||
if socketFD >= 0 {
|
/// URLSession API hangs intermittently on Linux (dead-connection reuse);
|
||||||
close(socketFD)
|
/// the completion path always fires — success, error, or timeout.
|
||||||
socketFD = -1
|
private func perform(_ req: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||||
}
|
try await withCheckedThrowingContinuation { cont in
|
||||||
}
|
let task = session.dataTask(with: req) { data, resp, err in
|
||||||
|
if let err {
|
||||||
private func sendFrame(_ frame: CAP.Frame) throws {
|
cont.resume(throwing: err)
|
||||||
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
|
||||||
}
|
}
|
||||||
Task {
|
guard let http = resp as? HTTPURLResponse else {
|
||||||
try? await Task.sleep(for: timeout)
|
cont.resume(throwing: CameraError.connectionFailed("no HTTP response"))
|
||||||
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 ingest(_ data: Data) {
|
private func getJSON(_ path: String) async throws -> [String: Any] {
|
||||||
readBuffer.append(data)
|
let (data, http) = try await perform(URLRequest(url: url(path)))
|
||||||
while let frame = try? CAP.decodeFirst(&readBuffer) {
|
guard http.statusCode == 200 else {
|
||||||
route(frame)
|
throw CameraError.connectionFailed("GET \(path) -> \(http.statusCode)")
|
||||||
}
|
}
|
||||||
|
guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
throw CameraError.connectionFailed("GET \(path): bad JSON")
|
||||||
|
}
|
||||||
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
private func route(_ frame: CAP.Frame) {
|
private func putJSON(_ path: String, body: Data) async throws {
|
||||||
switch frame.msgType {
|
var req = URLRequest(url: url(path))
|
||||||
case CAP.MsgType.reply.rawValue:
|
req.httpMethod = "PUT"
|
||||||
if let cont = pendingReplies.removeValue(forKey: frame.msgId) {
|
req.httpBody = body
|
||||||
cont.resume(returning: frame)
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
} else if frame.cmdCode == CAP.Command.welcome.rawValue || frame.msgId == 0 {
|
let (_, http) = try await perform(req)
|
||||||
// Unsolicited Welcome on connect — stored implicitly; nothing pending.
|
guard http.statusCode == 200 else {
|
||||||
}
|
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 {
|
||||||
closeSocket()
|
// Verify camera reachable via system info.
|
||||||
readBuffer.removeAll()
|
do {
|
||||||
varCache.removeAll()
|
_ = try await getJSON(ArriPaths.systemInfo)
|
||||||
try openSocket()
|
} catch {
|
||||||
startReadLoop()
|
throw CameraError.connectionFailed("system info unreachable: \(error)")
|
||||||
|
|
||||||
// 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))
|
||||||
startKeepAlive()
|
startPolling()
|
||||||
}
|
|
||||||
|
|
||||||
private func teardownAfterFailure() async {
|
|
||||||
readTask?.cancel()
|
|
||||||
readTask = nil
|
|
||||||
closeSocket()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func disconnect() async {
|
public func disconnect() async {
|
||||||
keepAliveTask?.cancel()
|
pollTask?.cancel()
|
||||||
keepAliveTask = nil
|
pollTask = nil
|
||||||
readTask?.cancel()
|
|
||||||
readTask = nil
|
|
||||||
closeSocket()
|
|
||||||
emit(CameraState(connection: .disconnected))
|
emit(CameraState(connection: .disconnected))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CAP requires client traffic every 1 s.
|
public func push(look: FlattenedLook) async throws {
|
||||||
private func startKeepAlive() {
|
if let lut = look.lut, !capabilities.lut3dSizes.contains(lut.size) {
|
||||||
keepAliveTask?.cancel()
|
throw CameraError.unsupportedLook("LUT size \(lut.size) not in \(capabilities.lut3dSizes)")
|
||||||
keepAliveTask = Task {
|
}
|
||||||
|
var payload: [String: Any] = [:]
|
||||||
|
if let cdl = look.cdl {
|
||||||
|
payload["cdl"] = [
|
||||||
|
"slope": [cdl.slope.x, cdl.slope.y, cdl.slope.z],
|
||||||
|
"offset": [cdl.offset.x, cdl.offset.y, cdl.offset.z],
|
||||||
|
"power": [cdl.power.x, cdl.power.y, cdl.power.z],
|
||||||
|
"saturation": cdl.saturation,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if let lut = look.lut {
|
||||||
|
payload["lut3dSize"] = lut.size
|
||||||
|
payload["lut3dTable"] = lut.table
|
||||||
|
}
|
||||||
|
let body = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])
|
||||||
|
try await putJSON(ArriPaths.lookCurrent, body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Polling
|
||||||
|
|
||||||
|
private func startPolling() {
|
||||||
|
pollTask?.cancel()
|
||||||
|
pollTask = Task {
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(for: .milliseconds(900))
|
await pollOnce()
|
||||||
_ = try? await self.request(.live, timeout: .seconds(2))
|
try? await Task.sleep(for: pollInterval)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func push(look: FlattenedLook) async throws {
|
private func pollOnce() async {
|
||||||
if let cdl = look.cdl {
|
do {
|
||||||
var payload = Data()
|
let rec = try await getJSON(ArriPaths.recordingStatus)
|
||||||
CAP.putU16(CAP.Variable.cdlValues.rawValue, into: &payload)
|
let md = try await getJSON(ArriPaths.metadata)
|
||||||
payload.append(CAP.encodeCDLBlob(
|
let newState = CameraState(
|
||||||
slope: (cdl.slope.x, cdl.slope.y, cdl.slope.z),
|
connection: .connected,
|
||||||
offset: (cdl.offset.x, cdl.offset.y, cdl.offset.z),
|
isRecording: rec["recording"] as? Bool ?? false,
|
||||||
power: (cdl.power.x, cdl.power.y, cdl.power.z),
|
clipName: rec["clipName"] as? String,
|
||||||
saturation: cdl.saturation))
|
metadata: CameraMetadata(
|
||||||
let reply = try await request(.setVariable, payload: payload)
|
exposureIndex: md["exposureIndex"] as? Int,
|
||||||
guard reply.cmdCode == CAP.Result.ok.rawValue else {
|
whiteBalance: md["whiteBalance"] as? Int,
|
||||||
throw CameraError.pushFailed("SetVariable CDL -> result \(reply.cmdCode)")
|
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))
|
||||||
}
|
}
|
||||||
if let lut = look.lut, !lut.isApproximatelyIdentity() {
|
|
||||||
guard capabilities.lut3dSizes.contains(lut.size) else {
|
|
||||||
throw CameraError.unsupportedLook("LUT size \(lut.size) not in \(capabilities.lut3dSizes)")
|
|
||||||
}
|
|
||||||
// EXPERIMENTAL: Set3DLutData payload layout unverified on hardware
|
|
||||||
// (chunk framing sections unavailable publicly). Sim mirrors this
|
|
||||||
// simple layout: U16 lattice size + F32 table.
|
|
||||||
var payload = Data()
|
|
||||||
CAP.putU16(UInt16(lut.size), into: &payload)
|
|
||||||
for v in lut.table {
|
|
||||||
CAP.putF32(v, into: &payload)
|
|
||||||
}
|
|
||||||
let reply = try await request(.set3DLutData, payload: payload, timeout: .seconds(10))
|
|
||||||
guard reply.cmdCode == CAP.Result.ok.rawValue else {
|
|
||||||
throw CameraError.pushFailed("Set3DLutData -> result \(reply.cmdCode)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if look.cdl == nil && (look.lut == nil || look.lut!.isApproximatelyIdentity()) {
|
|
||||||
throw CameraError.unsupportedLook("nothing to push")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -14,18 +14,13 @@ enum SonyPaths {
|
||||||
static let userLut = "/cna/v1/monitoring/lut"
|
static let userLut = "/cna/v1/monitoring/lut"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Venice 2 driver — POSTURE PER RESEARCH (docs/research/arri-cap-protocol.md, Sony
|
/// Venice 2 driver: REST over IP. Capability-narrow — user 3D LUT push only,
|
||||||
/// section): Venice IP protocol is CLOSED (partner products speak it natively; public
|
/// no native CDL (grade pipeline must full-bake for this camera).
|
||||||
/// 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: [],
|
lut3dSizes: [17, 33],
|
||||||
metadataFields: [.clipName, .exposureIndex, .whiteBalance, .tint, .fps, .timecode])
|
metadataFields: [.clipName, .exposureIndex, .whiteBalance, .tint, .fps, .timecode])
|
||||||
|
|
||||||
private let host: String
|
private let host: String
|
||||||
|
|
@ -123,11 +118,26 @@ public actor SonyDriver: CameraDriver {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func push(look: FlattenedLook) async throws {
|
public func push(look: FlattenedLook) async throws {
|
||||||
// Venice IP look control is closed/unverified (see research doc).
|
guard look.cdl == nil else {
|
||||||
// Grade Venice via SDI-chain LUT box or DeckLink output. Re-enable
|
throw CameraError.unsupportedLook("Venice has no native CDL — re-flatten with splitLeadingCDL=false")
|
||||||
// once a real wire path is proven (partner SDK or backend capture).
|
}
|
||||||
throw CameraError.unsupportedLook(
|
guard let lut = look.lut else {
|
||||||
"Venice look push over IP unverified — use SDI-chain LUT box; monitoring-only driver")
|
throw CameraError.unsupportedLook("nothing to push")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,279 +1,92 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import NIO
|
import NIO
|
||||||
import ForgeCameraARRI
|
import NIOHTTP1
|
||||||
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?
|
||||||
|
|
||||||
/// Camera password (camera GUI setting; ARRI default "arri").
|
// Scriptable state.
|
||||||
public let password: String
|
private var recording = false
|
||||||
private let challenge = "SIMCHAL01"
|
private var clipName: String?
|
||||||
|
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] = []
|
||||||
|
|
||||||
// Scriptable camera state.
|
public init() {}
|
||||||
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] = []
|
|
||||||
|
|
||||||
private struct Session {
|
// MARK: Scripting hooks
|
||||||
var authenticated = false
|
|
||||||
var challengeSent = false
|
|
||||||
var subscriptions = Set<UInt16>()
|
|
||||||
}
|
|
||||||
private var sessions: [ObjectIdentifier: Session] = [:]
|
|
||||||
private var channels: [ObjectIdentifier: Channel] = [:]
|
|
||||||
|
|
||||||
public init(password: String = "arri") {
|
public func setRecording(_ on: Bool, clipName: String? = nil) {
|
||||||
self.password = password
|
recording = on
|
||||||
|
if let c = clipName { self.clipName = c }
|
||||||
|
if !on { /* keep clip name for last-clip queries */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Scripting
|
public func setMetadata(ei: Int, wb: Int, tint: Int, fps: Double, timecode: String) {
|
||||||
|
|
||||||
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.colorTemp = colorTemp
|
self.wb = wb
|
||||||
self.tint = tint
|
self.tint = tint
|
||||||
self.fps = fps
|
self.fps = fps
|
||||||
self.timecodeBCD = CAP.encodeTimecodeBCD(timecode) ?? 0
|
self.timecode = timecode
|
||||||
for v in [CAP.Variable.exposureIndex, .colorTemperature, .tint, .sensorFPS, .timecode] {
|
|
||||||
notifySubscribers(variable: v)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Variable encoding
|
// MARK: Request handling (called from channel handler)
|
||||||
|
|
||||||
private func encodeVariable(_ id: UInt16) -> Data? {
|
func handle(method: HTTPMethod, uri: String, body: Data?) -> (status: HTTPResponseStatus, body: Data) {
|
||||||
guard let variable = CAP.Variable(rawValue: id) else { return nil }
|
switch (method, uri) {
|
||||||
var out = Data()
|
case (.GET, ArriSimPaths.systemInfo):
|
||||||
CAP.putU16(id, into: &out)
|
return (.ok, json([
|
||||||
switch variable {
|
"model": "ALEXA 35",
|
||||||
case .cameraState:
|
"serialNumber": "SIM-35-0001",
|
||||||
CAP.putU16(stateBits.rawValue, into: &out)
|
"firmwareVersion": "SUP 2.0-sim",
|
||||||
case .lookFilename:
|
]))
|
||||||
CAP.putString("forge-sim-look", into: &out)
|
case (.GET, ArriSimPaths.recordingStatus):
|
||||||
case .cdlValues:
|
var obj: [String: Any] = ["recording": recording]
|
||||||
let blob = lastCDL ?? [1, 1, 1, 0, 0, 0, 1, 1, 1, 1]
|
if let c = clipName { obj["clipName"] = c }
|
||||||
for v in blob { CAP.putF32(v, into: &out) }
|
return (.ok, json(obj))
|
||||||
case .colorTemperature:
|
case (.GET, ArriSimPaths.metadata):
|
||||||
CAP.putU32(colorTemp, into: &out)
|
return (.ok, json([
|
||||||
case .tint:
|
"exposureIndex": ei,
|
||||||
CAP.putF32(tint, into: &out)
|
"whiteBalance": wb,
|
||||||
case .exposureIndex:
|
"tint": tint,
|
||||||
CAP.putU32(ei, into: &out)
|
"fps": fps,
|
||||||
case .currentReel:
|
"timecode": timecode,
|
||||||
CAP.putU16(reel, into: &out)
|
]))
|
||||||
case .clipNumber:
|
case (.GET, ArriSimPaths.lookCurrent):
|
||||||
CAP.putU16(clipNumber, into: &out)
|
guard let last = uploadedLooks.last else {
|
||||||
case .sensorFPS:
|
return (.notFound, json(["error": "no look set"]))
|
||||||
CAP.putF32(fps, into: &out)
|
|
||||||
case .timecode:
|
|
||||||
CAP.putU32(timecodeBCD, into: &out)
|
|
||||||
}
|
}
|
||||||
return out
|
return (.ok, last)
|
||||||
|
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)
|
||||||
private func notifySubscribers(variable: CAP.Variable) {
|
return (.ok, json(["status": "accepted"]))
|
||||||
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:
|
||||||
reply(to: frame, result: .noSuchCommand, on: ch)
|
return (.notFound, json(["error": "unknown path"]))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions[key] = session
|
private func json(_ obj: [String: Any]) -> Data {
|
||||||
}
|
(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
|
||||||
|
|
@ -285,7 +98,9 @@ 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.addHandler(CAPServerHandler(sim: sim))
|
channel.pipeline.configureHTTPServerPipeline().flatMap {
|
||||||
|
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
|
||||||
|
|
@ -293,11 +108,6 @@ 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
|
||||||
|
|
@ -306,37 +116,57 @@ public actor ArriSimulator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Byte stream -> CAP frames -> sim actor.
|
/// Minimal HTTP1 request handler bridging into the actor.
|
||||||
final class CAPServerHandler: ChannelInboundHandler, @unchecked Sendable {
|
final class HTTPSimHandler: ChannelInboundHandler, @unchecked Sendable {
|
||||||
typealias InboundIn = ByteBuffer
|
typealias InboundIn = HTTPServerRequestPart
|
||||||
|
typealias OutboundOut = HTTPServerResponsePart
|
||||||
|
|
||||||
private let sim: ArriSimulator
|
private let sim: ArriSimulator
|
||||||
private var buffer = Data()
|
private var method: HTTPMethod = .GET
|
||||||
|
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) {
|
||||||
var incoming = unwrapInboundIn(data)
|
switch unwrapInboundIn(data) {
|
||||||
if let bytes = incoming.readBytes(length: incoming.readableBytes) {
|
case .head(let head):
|
||||||
buffer.append(contentsOf: bytes)
|
method = head.method
|
||||||
|
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) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,16 @@
|
||||||
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(password: "arri")
|
sim = ArriSimulator()
|
||||||
try await sim.start(port: 0)
|
try await sim.start(port: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,69 +19,77 @@ final class ArriDriverTests: XCTestCase {
|
||||||
sim = nil
|
sim = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeDriver(password: String = "arri") async -> ArriDriver {
|
func makeDriver(pollInterval: Duration = .milliseconds(20)) async -> ArriDriver {
|
||||||
let port = await sim.boundPort!
|
let port = await sim.boundPort!
|
||||||
return ArriDriver(host: "127.0.0.1", port: port, password: password)
|
return ArriDriver(host: "127.0.0.1", port: port, pollInterval: pollInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full handshake: challenge -> MD5 password -> client name -> subscribed + connected.
|
// Connect verifies system info and emits connected state.
|
||||||
func testConnectHandshake() async throws {
|
func testConnectEmitsConnected() 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..<10 {
|
for _ in 0..<5 {
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrong password -> connectionFailed, never authenticated.
|
// Connect to dead port throws.
|
||||||
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)
|
let driver = ArriDriver(host: "127.0.0.1", port: 1, pollInterval: .milliseconds(20))
|
||||||
do {
|
do {
|
||||||
try await driver.connect()
|
try await driver.connect()
|
||||||
XCTFail("expected throw")
|
XCTFail("expected throw")
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscription events: rec start/stop with composed clip name RxxxCxxx.
|
// Poll loop surfaces metadata.
|
||||||
|
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, clipNumber: 7)
|
await sim.setRecording(true, clipName: "A001C007_250710_R1CD")
|
||||||
// Events arrive per-variable; wait for a state with BOTH rec + clip.
|
|
||||||
var sawRec = false
|
var sawRec = false
|
||||||
for _ in 0..<40 {
|
for _ in 0..<30 {
|
||||||
if let s = await iterator.next(), s.isRecording, s.clipName == "R001C007" {
|
if let s = await iterator.next(), s.isRecording {
|
||||||
|
XCTAssertEqual(s.clipName, "A001C007_250710_R1CD")
|
||||||
sawRec = true
|
sawRec = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -103,100 +108,61 @@ final class ArriDriverTests: XCTestCase {
|
||||||
await driver.disconnect()
|
await driver.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metadata via subscribed variables incl. BCD timecode decode.
|
// push(look:) uploads CDL + LUT payload sim can parse.
|
||||||
func testMetadataEvents() async throws {
|
func testPushLook() async throws {
|
||||||
let driver = await makeDriver()
|
|
||||||
var iterator = driver.state.makeAsyncIterator()
|
|
||||||
try await driver.connect()
|
|
||||||
|
|
||||||
await sim.setMetadata(ei: 1280, colorTemp: 3200, tint: -2, fps: 25, timecode: "10:20:30:12")
|
|
||||||
var got = false
|
|
||||||
for _ in 0..<40 {
|
|
||||||
if let s = await iterator.next(), let md = s.metadata,
|
|
||||||
md.exposureIndex == 1280, md.whiteBalance == 3200,
|
|
||||||
md.tint == -2, md.timecode == "10:20:30:12",
|
|
||||||
let fps = md.fps, abs(fps - 25) < 0.01 {
|
|
||||||
got = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
XCTAssertTrue(got)
|
|
||||||
await driver.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
// CDL push -> SetVariable 0x0050 blob lands in sim with correct 10 floats.
|
|
||||||
func testPushCDL() async throws {
|
|
||||||
let driver = await makeDriver()
|
let driver = await makeDriver()
|
||||||
try await driver.connect()
|
try await driver.connect()
|
||||||
|
|
||||||
let cdl = CDL(
|
let cdl = CDL(slope: SIMD3(1.1, 1.0, 0.9), offset: .zero, power: .one, saturation: 1.2)
|
||||||
slope: SIMD3(1.1, 1.0, 0.9),
|
let lut = Lut3D.identity(size: 33)
|
||||||
offset: SIMD3(0.02, 0.0, -0.01),
|
let look = FlattenedLook(cdl: cdl, lut: lut, latticeSize: 33)
|
||||||
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)
|
try await driver.push(look: look)
|
||||||
|
|
||||||
let cdl = await sim.lastCDL
|
let uploads = await sim.uploadedLooks
|
||||||
XCTAssertEqual(cdl?[0] ?? 0, 1.2, accuracy: 1e-5)
|
XCTAssertEqual(uploads.count, 1)
|
||||||
let luts = await sim.lutUploads
|
let obj = try JSONSerialization.jsonObject(with: uploads[0]) as? [String: Any]
|
||||||
XCTAssertEqual(luts.count, 1)
|
let cdlObj = obj?["cdl"] as? [String: Any]
|
||||||
// U16 size prefix + 17³×3 floats.
|
let slope = cdlObj?["slope"] as? [Double]
|
||||||
XCTAssertEqual(luts[0].count, 2 + 17 * 17 * 17 * 3 * 4)
|
XCTAssertEqual(slope?[0] ?? 0, 1.1, accuracy: 1e-5)
|
||||||
|
XCTAssertEqual(cdlObj?["saturation"] as? Double ?? 0, 1.2, accuracy: 1e-5)
|
||||||
|
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 wire.
|
// Unsupported LUT size rejected before hitting the 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.build(size: 5) { $0 * 0.5 }, latticeSize: 5)
|
let look = FlattenedLook(cdl: nil, lut: Lut3D.identity(size: 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 e as CameraError {
|
} catch let error as CameraError {
|
||||||
guard case .unsupportedLook = e else { return XCTFail("wrong error") }
|
guard case .unsupportedLook = error else { return XCTFail("wrong error") }
|
||||||
}
|
}
|
||||||
|
let uploads = await sim.uploadedLooks
|
||||||
|
XCTAssertTrue(uploads.isEmpty)
|
||||||
await driver.disconnect()
|
await driver.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconnect after sim restart on same port.
|
// Reconnect after sim restart.
|
||||||
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)
|
let driver = ArriDriver(host: "127.0.0.1", port: port, pollInterval: .milliseconds(20))
|
||||||
try await driver.connect()
|
try await driver.connect()
|
||||||
await driver.disconnect()
|
await driver.disconnect()
|
||||||
|
|
||||||
try await sim.stop()
|
try await sim.stop()
|
||||||
sim = ArriSimulator(password: "arri")
|
sim = ArriSimulator()
|
||||||
try await sim.start(port: port)
|
try await sim.start(port: port)
|
||||||
|
|
||||||
try await driver.connect()
|
try await driver.connect()
|
||||||
try await driver.push(look: FlattenedLook(
|
let look = FlattenedLook(cdl: nil, lut: Lut3D.identity(size: 33), latticeSize: 33)
|
||||||
cdl: .identity, lut: nil, latticeSize: 33))
|
try await driver.push(look: look)
|
||||||
let blob = await sim.lastCDL
|
let uploads = await sim.uploadedLooks
|
||||||
XCTAssertNotNil(blob)
|
XCTAssertEqual(uploads.count, 1)
|
||||||
await driver.disconnect()
|
await driver.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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: protocol closed (research) — no look-push capability claimed.
|
// Venice 2: NO native CDL — capability-narrow.
|
||||||
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.isEmpty)
|
XCTAssertTrue(driver.capabilities.lut3dSizes.contains(33))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testConnectEmitsConnected() async throws {
|
func testConnectEmitsConnected() async throws {
|
||||||
|
|
@ -70,24 +70,26 @@ final class SonyDriverTests: XCTestCase {
|
||||||
await driver.disconnect()
|
await driver.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any look push rejected: Venice look control unverified/closed (research).
|
// LUT-only push accepted.
|
||||||
// Metadata/rec-state monitoring is the only claimed capability.
|
func testPushLutOnly() async throws {
|
||||||
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)
|
||||||
let lutOnly = FlattenedLook(cdl: nil, lut: Lut3D.identity(size: 33), latticeSize: 33)
|
try await driver.push(look: look)
|
||||||
do {
|
let count = await sim.uploadedLutCount
|
||||||
try await driver.push(look: lutOnly)
|
XCTAssertEqual(count, 1)
|
||||||
XCTFail("expected unsupportedLook")
|
await driver.disconnect()
|
||||||
} 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 withCDL = FlattenedLook(cdl: cdl, lut: nil, latticeSize: 33)
|
let look = FlattenedLook(cdl: cdl, lut: Lut3D.identity(size: 33), latticeSize: 33)
|
||||||
do {
|
do {
|
||||||
try await driver.push(look: withCDL)
|
try await driver.push(look: look)
|
||||||
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") }
|
||||||
|
|
|
||||||
|
|
@ -1,187 +1,106 @@
|
||||||
import XCTest
|
import XCTest
|
||||||
import Foundation
|
import Foundation
|
||||||
import ForgeCameraARRI
|
#if canImport(FoundationNetworking)
|
||||||
import ForgeOffload
|
import FoundationNetworking
|
||||||
|
#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(password: "arri")
|
sim = ArriSimulator()
|
||||||
try await sim.start(port: 0)
|
try await sim.start(port: 0) // ephemeral
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() async throws {
|
override func tearDown() async throws {
|
||||||
try? await sim.stop()
|
try await sim.stop()
|
||||||
sim = nil
|
sim = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal blocking CAP test client.
|
func get(_ path: String) async throws -> (Int, Data) {
|
||||||
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 client = try XCTUnwrap(TestClient(port: port))
|
let url = URL(string: "http://127.0.0.1:\(port)\(path)")!
|
||||||
let welcome = client.readUntil { $0.cmdCode == CAP.Command.welcome.rawValue }
|
let (data, resp) = try await URLSession.shared.data(from: url)
|
||||||
XCTAssertNotNil(welcome)
|
return ((resp as! HTTPURLResponse).statusCode, data)
|
||||||
let (version, _) = CAP.readString(welcome!.payload, at: 0) ?? ("", 0)
|
|
||||||
XCTAssertTrue(version.contains("CAP"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAuthFlow() async throws {
|
func put(_ path: String, body: Data) async throws -> (Int, Data) {
|
||||||
let port = await sim.boundPort!
|
let port = await sim.boundPort!
|
||||||
let client = try XCTUnwrap(TestClient(port: port))
|
var req = URLRequest(url: URL(string: "http://127.0.0.1:\(port)\(path)")!)
|
||||||
XCTAssertTrue(client.authenticate(password: "arri"))
|
req.httpMethod = "PUT"
|
||||||
|
req.httpBody = body
|
||||||
|
let (data, resp) = try await URLSession.shared.data(for: req)
|
||||||
|
return ((resp as! HTTPURLResponse).statusCode, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testWrongPassword() async throws {
|
// System info endpoint serves model + serial JSON.
|
||||||
let port = await sim.boundPort!
|
func testSystemInfo() async throws {
|
||||||
let client = try XCTUnwrap(TestClient(port: port))
|
let (status, data) = try await get("/api/v1/system/info")
|
||||||
XCTAssertFalse(client.authenticate(password: "nope"))
|
XCTAssertEqual(status, 200)
|
||||||
|
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
XCTAssertEqual(obj?["model"] as? String, "ALEXA 35")
|
||||||
|
XCTAssertNotNil(obj?["serialNumber"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testUnauthenticatedGetRejected() async throws {
|
// Rec state reflects scripted changes.
|
||||||
let port = await sim.boundPort!
|
func testRecStateScriptable() async throws {
|
||||||
let client = try XCTUnwrap(TestClient(port: port))
|
var (_, data) = try await get("/api/v1/recording/status")
|
||||||
_ = client.readUntil { $0.cmdCode == CAP.Command.welcome.rawValue }
|
var obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
var payload = Data()
|
XCTAssertEqual(obj?["recording"] as? Bool, false)
|
||||||
CAP.putU16(CAP.Variable.exposureIndex.rawValue, into: &payload)
|
|
||||||
let id = client.send(.getVariable, payload: payload)
|
await sim.setRecording(true, clipName: "A001C003_250710_R1CD")
|
||||||
let reply = client.readUntil { $0.msgId == id }
|
(_, data) = try await get("/api/v1/recording/status")
|
||||||
XCTAssertEqual(reply?.cmdCode, CAP.Result.notAuthorized.rawValue)
|
obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
XCTAssertEqual(obj?["recording"] as? Bool, true)
|
||||||
|
XCTAssertEqual(obj?["clipName"] as? String, "A001C003_250710_R1CD")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testGetVariable() async throws {
|
// Metadata endpoint.
|
||||||
await sim.setMetadata(ei: 1280, colorTemp: 3200, tint: -2, fps: 25, timecode: "01:02:03:04")
|
func testMetadata() async throws {
|
||||||
let port = await sim.boundPort!
|
await sim.setMetadata(ei: 1280, wb: 3200, tint: -2, fps: 25, timecode: "10:20:30:12")
|
||||||
let client = try XCTUnwrap(TestClient(port: port))
|
let (status, data) = try await get("/api/v1/camera/metadata")
|
||||||
XCTAssertTrue(client.authenticate(password: "arri"))
|
XCTAssertEqual(status, 200)
|
||||||
|
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
var payload = Data()
|
XCTAssertEqual(obj?["exposureIndex"] as? Int, 1280)
|
||||||
CAP.putU16(CAP.Variable.exposureIndex.rawValue, into: &payload)
|
XCTAssertEqual(obj?["whiteBalance"] as? Int, 3200)
|
||||||
let id = client.send(.getVariable, payload: payload)
|
XCTAssertEqual(obj?["tint"] as? Int, -2)
|
||||||
let reply = client.readUntil { $0.msgId == id }
|
XCTAssertEqual(obj?["timecode"] as? String, "10:20:30:12")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSetCDLStored() async throws {
|
// Look upload stored and retrievable.
|
||||||
let port = await sim.boundPort!
|
func testLookUploadStored() async throws {
|
||||||
let client = try XCTUnwrap(TestClient(port: port))
|
let payload = try JSONSerialization.data(withJSONObject: [
|
||||||
XCTAssertTrue(client.authenticate(password: "arri"))
|
"name": "shot42",
|
||||||
|
"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)
|
||||||
|
|
||||||
var payload = Data()
|
let stored = await sim.uploadedLooks
|
||||||
CAP.putU16(CAP.Variable.cdlValues.rawValue, into: &payload)
|
XCTAssertEqual(stored.count, 1)
|
||||||
payload.append(CAP.encodeCDLBlob(
|
let storedObj = try JSONSerialization.jsonObject(with: stored[0]) as? [String: Any]
|
||||||
slope: (1.5, 1, 1), offset: (0, 0, 0), power: (1, 1, 1), saturation: 0.8))
|
XCTAssertEqual(storedObj?["name"] as? String, "shot42")
|
||||||
let id = client.send(.setVariable, payload: payload)
|
|
||||||
let reply = client.readUntil { $0.msgId == id }
|
|
||||||
XCTAssertEqual(reply?.cmdCode, CAP.Result.ok.rawValue)
|
|
||||||
|
|
||||||
let stored = await sim.lastCDL
|
// Retrievable over HTTP too.
|
||||||
XCTAssertEqual(stored?[0] ?? 0, 1.5, accuracy: 1e-5)
|
let (gs, gd) = try await get("/api/v1/look/current")
|
||||||
XCTAssertEqual(stored?[9] ?? 0, 0.8, accuracy: 1e-5)
|
XCTAssertEqual(gs, 200)
|
||||||
|
let obj = try JSONSerialization.jsonObject(with: gd) as? [String: Any]
|
||||||
|
XCTAssertEqual(obj?["name"] as? String, "shot42")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSubscriptionPushesEvents() async throws {
|
// Malformed look rejected 400.
|
||||||
let port = await sim.boundPort!
|
func testMalformedLookRejected() async throws {
|
||||||
let client = try XCTUnwrap(TestClient(port: port))
|
let (status, _) = try await put("/api/v1/look/current", body: Data("not json".utf8))
|
||||||
XCTAssertTrue(client.authenticate(password: "arri"))
|
XCTAssertEqual(status, 400)
|
||||||
|
|
||||||
var payload = Data()
|
|
||||||
CAP.putU16(CAP.Variable.cameraState.rawValue, into: &payload)
|
|
||||||
let id = client.send(.requestVariables, payload: payload)
|
|
||||||
_ = 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)
|
|
||||||
|
// Unknown path 404.
|
||||||
|
func testUnknownPath404() async throws {
|
||||||
|
let (status, _) = try await get("/api/v1/warp/drive")
|
||||||
|
XCTAssertEqual(status, 404)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
let driver = ArriDriver(host: "127.0.0.1", port: port, pollInterval: .milliseconds(15))
|
||||||
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. CAP sim composes clip names as RxxxCxxx from reel+clip vars.
|
// Three takes.
|
||||||
let takes: [(clipNo: UInt16, clip: String, tcIn: String, tcOut: String)] = [
|
let takes: [(clip: String, tcIn: String, tcOut: String)] = [
|
||||||
(1, "R001C001", "10:00:00:00", "10:00:30:00"),
|
("A001C001_260710", "10:00:00:00", "10:00:30:00"),
|
||||||
(2, "R001C002", "10:05:00:00", "10:05:45:00"),
|
("A001C002_260710", "10:05:00:00", "10:05:45:00"),
|
||||||
(3, "R001C003", "10:12:00:00", "10:13:00:00"),
|
("A001C003_260710", "10:12:00:00", "10:13:00:00"),
|
||||||
]
|
]
|
||||||
for take in takes {
|
for take in takes {
|
||||||
await sim.setMetadata(ei: 800, colorTemp: 5600, tint: 0, fps: 24, timecode: take.tcIn)
|
await sim.setMetadata(ei: 800, wb: 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, clipNumber: take.clipNo)
|
await sim.setRecording(true, clipName: take.clip)
|
||||||
try await Task.sleep(for: .milliseconds(80))
|
try await Task.sleep(for: .milliseconds(80))
|
||||||
await sim.setMetadata(ei: 800, colorTemp: 5600, tint: 0, fps: 24, timecode: take.tcOut)
|
await sim.setMetadata(ei: 800, wb: 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: "R001C").count - 1, 3)
|
XCTAssertEqual(ale.components(separatedBy: "A001C").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 R001C003"))
|
XCTAssertTrue(edl.contains("003 A001C003_260710"))
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,342 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
# 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 | 0–1 |
|
|
||||||
|
|
||||||
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.
|
|
||||||
Loading…
Reference in a new issue