driver-arri: REST driver — connect/poll/rec events/look push, LUT size gate; fix Linux URLSession hang via completion-handler path; sim closes connections after response — 8 tests
This commit is contained in:
parent
cf980f70db
commit
5730fb53a2
7 changed files with 368 additions and 17 deletions
194
Sources/ForgeCameraARRI/ArriDriver.swift
Normal file
194
Sources/ForgeCameraARRI/ArriDriver.swift
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
import ForgeCamera
|
||||
import ForgeGrade
|
||||
import ForgeColor
|
||||
|
||||
/// ARRI REST endpoint paths — single source of truth for this driver.
|
||||
/// NOTE: shapes modeled on ARRI's documented Camera Companion/REST API family;
|
||||
/// verify + correct against hardware/API docs in macOS phase. Sim mirrors these.
|
||||
enum ArriPaths {
|
||||
static let systemInfo = "/api/v1/system/info"
|
||||
static let recordingStatus = "/api/v1/recording/status"
|
||||
static let metadata = "/api/v1/camera/metadata"
|
||||
static let lookCurrent = "/api/v1/look/current"
|
||||
}
|
||||
|
||||
/// ALEXA 35 driver: REST over IP. Polls rec state + metadata, pushes CDL+LUT looks.
|
||||
public actor ArriDriver: CameraDriver {
|
||||
public nonisolated let capabilities = CameraCapabilities(
|
||||
vendor: .arri,
|
||||
supportsNativeCDL: true,
|
||||
lut3dSizes: [17, 33, 65],
|
||||
metadataFields: [.clipName, .exposureIndex, .whiteBalance, .tint, .fps, .timecode])
|
||||
|
||||
private let host: String
|
||||
private let port: Int
|
||||
private let pollInterval: Duration
|
||||
private let session: URLSession
|
||||
|
||||
private var pollTask: Task<Void, Never>?
|
||||
private var lastState = CameraState(connection: .disconnected)
|
||||
|
||||
private var stateContinuations: [UUID: AsyncStream<CameraState>.Continuation] = [:]
|
||||
|
||||
public init(host: String, port: Int, pollInterval: Duration = .milliseconds(150)) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.pollInterval = pollInterval
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 3
|
||||
session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
public nonisolated var state: AsyncStream<CameraState> {
|
||||
AsyncStream { continuation in
|
||||
let id = UUID()
|
||||
Task { await self.register(id: id, continuation: continuation) }
|
||||
continuation.onTermination = { _ in
|
||||
Task { await self.unregister(id: id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func register(id: UUID, continuation: AsyncStream<CameraState>.Continuation) {
|
||||
stateContinuations[id] = continuation
|
||||
}
|
||||
|
||||
private func unregister(id: UUID) {
|
||||
stateContinuations.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
private func emit(_ s: CameraState) {
|
||||
lastState = s
|
||||
for c in stateContinuations.values {
|
||||
c.yield(s)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: HTTP
|
||||
|
||||
private func url(_ path: String) -> URL {
|
||||
URL(string: "http://\(host):\(port)\(path)")!
|
||||
}
|
||||
|
||||
/// Completion-handler dataTask wrapped in a continuation. The async
|
||||
/// URLSession API hangs intermittently on Linux (dead-connection reuse);
|
||||
/// the completion path always fires — success, error, or timeout.
|
||||
private func perform(_ req: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
let task = session.dataTask(with: req) { data, resp, err in
|
||||
if let err {
|
||||
cont.resume(throwing: err)
|
||||
return
|
||||
}
|
||||
guard let http = resp as? HTTPURLResponse else {
|
||||
cont.resume(throwing: CameraError.connectionFailed("no HTTP response"))
|
||||
return
|
||||
}
|
||||
cont.resume(returning: (data ?? Data(), http))
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func getJSON(_ path: String) async throws -> [String: Any] {
|
||||
let (data, http) = try await perform(URLRequest(url: url(path)))
|
||||
guard http.statusCode == 200 else {
|
||||
throw CameraError.connectionFailed("GET \(path) -> \(http.statusCode)")
|
||||
}
|
||||
guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw CameraError.connectionFailed("GET \(path): bad JSON")
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
private func putJSON(_ path: String, body: Data) async throws {
|
||||
var req = URLRequest(url: url(path))
|
||||
req.httpMethod = "PUT"
|
||||
req.httpBody = body
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let (_, http) = try await perform(req)
|
||||
guard http.statusCode == 200 else {
|
||||
throw CameraError.pushFailed("PUT \(path) -> \(http.statusCode)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: CameraDriver
|
||||
|
||||
public func connect() async throws {
|
||||
// Verify camera reachable via system info.
|
||||
do {
|
||||
_ = try await getJSON(ArriPaths.systemInfo)
|
||||
} catch {
|
||||
throw CameraError.connectionFailed("system info unreachable: \(error)")
|
||||
}
|
||||
emit(CameraState(connection: .connected))
|
||||
startPolling()
|
||||
}
|
||||
|
||||
public func disconnect() async {
|
||||
pollTask?.cancel()
|
||||
pollTask = nil
|
||||
emit(CameraState(connection: .disconnected))
|
||||
}
|
||||
|
||||
public func push(look: FlattenedLook) async throws {
|
||||
if let lut = look.lut, !capabilities.lut3dSizes.contains(lut.size) {
|
||||
throw CameraError.unsupportedLook("LUT size \(lut.size) not in \(capabilities.lut3dSizes)")
|
||||
}
|
||||
var payload: [String: Any] = [:]
|
||||
if let cdl = look.cdl {
|
||||
payload["cdl"] = [
|
||||
"slope": [cdl.slope.x, cdl.slope.y, cdl.slope.z],
|
||||
"offset": [cdl.offset.x, cdl.offset.y, cdl.offset.z],
|
||||
"power": [cdl.power.x, cdl.power.y, cdl.power.z],
|
||||
"saturation": cdl.saturation,
|
||||
]
|
||||
}
|
||||
if let lut = look.lut {
|
||||
payload["lut3dSize"] = lut.size
|
||||
payload["lut3dTable"] = lut.table
|
||||
}
|
||||
let body = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])
|
||||
try await putJSON(ArriPaths.lookCurrent, body: body)
|
||||
}
|
||||
|
||||
// MARK: Polling
|
||||
|
||||
private func startPolling() {
|
||||
pollTask?.cancel()
|
||||
pollTask = Task {
|
||||
while !Task.isCancelled {
|
||||
await pollOnce()
|
||||
try? await Task.sleep(for: pollInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pollOnce() async {
|
||||
do {
|
||||
let rec = try await getJSON(ArriPaths.recordingStatus)
|
||||
let md = try await getJSON(ArriPaths.metadata)
|
||||
let newState = CameraState(
|
||||
connection: .connected,
|
||||
isRecording: rec["recording"] as? Bool ?? false,
|
||||
clipName: rec["clipName"] as? String,
|
||||
metadata: CameraMetadata(
|
||||
exposureIndex: md["exposureIndex"] as? Int,
|
||||
whiteBalance: md["whiteBalance"] as? Int,
|
||||
tint: md["tint"] as? Int,
|
||||
fps: md["fps"] as? Double,
|
||||
timecode: md["timecode"] as? String))
|
||||
if newState != lastState {
|
||||
emit(newState)
|
||||
}
|
||||
} catch {
|
||||
if lastState.connection == .connected {
|
||||
emit(CameraState(connection: .disconnected))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
// ForgeCameraARRI
|
||||
|
|
@ -154,12 +154,17 @@ final class HTTPSimHandler: ChannelInboundHandler, @unchecked Sendable {
|
|||
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), promise: nil)
|
||||
channel.writeAndFlush(HTTPServerResponsePart.end(nil)).whenComplete { _ in
|
||||
channel.close(promise: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
168
Tests/ForgeCameraARRITests/ArriDriverTests.swift
Normal file
168
Tests/ForgeCameraARRITests/ArriDriverTests.swift
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import XCTest
|
||||
import ForgeCamera
|
||||
import ForgeGrade
|
||||
import ForgeColor
|
||||
import ForgeSim
|
||||
@testable import ForgeCameraARRI
|
||||
|
||||
final class ArriDriverTests: XCTestCase {
|
||||
|
||||
var sim: ArriSimulator!
|
||||
|
||||
override func setUp() async throws {
|
||||
sim = ArriSimulator()
|
||||
try await sim.start(port: 0)
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
try? await sim.stop()
|
||||
sim = nil
|
||||
}
|
||||
|
||||
func makeDriver(pollInterval: Duration = .milliseconds(20)) async -> ArriDriver {
|
||||
let port = await sim.boundPort!
|
||||
return ArriDriver(host: "127.0.0.1", port: port, pollInterval: pollInterval)
|
||||
}
|
||||
|
||||
// Capabilities: ARRI = native CDL + 33/65 LUTs.
|
||||
func testCapabilities() async {
|
||||
let driver = await makeDriver()
|
||||
XCTAssertEqual(driver.capabilities.vendor, .arri)
|
||||
XCTAssertTrue(driver.capabilities.supportsNativeCDL)
|
||||
XCTAssertTrue(driver.capabilities.lut3dSizes.contains(33))
|
||||
XCTAssertTrue(driver.capabilities.metadataFields.contains(.clipName))
|
||||
}
|
||||
|
||||
// Connect verifies system info and emits connected state.
|
||||
func testConnectEmitsConnected() async throws {
|
||||
let driver = await makeDriver()
|
||||
var iterator = driver.state.makeAsyncIterator()
|
||||
try await driver.connect()
|
||||
var connected = false
|
||||
for _ in 0..<5 {
|
||||
if let s = await iterator.next(), s.connection == .connected {
|
||||
connected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
await driver.disconnect()
|
||||
}
|
||||
|
||||
// Connect to dead port throws.
|
||||
func testConnectFailsOnDeadPort() async {
|
||||
let driver = ArriDriver(host: "127.0.0.1", port: 1, pollInterval: .milliseconds(20))
|
||||
do {
|
||||
try await driver.connect()
|
||||
XCTFail("expected throw")
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let driver = await makeDriver()
|
||||
var iterator = driver.state.makeAsyncIterator()
|
||||
try await driver.connect()
|
||||
|
||||
await sim.setRecording(true, clipName: "A001C007_250710_R1CD")
|
||||
var sawRec = false
|
||||
for _ in 0..<30 {
|
||||
if let s = await iterator.next(), s.isRecording {
|
||||
XCTAssertEqual(s.clipName, "A001C007_250710_R1CD")
|
||||
sawRec = true
|
||||
break
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(sawRec)
|
||||
|
||||
await sim.setRecording(false)
|
||||
var sawStop = false
|
||||
for _ in 0..<30 {
|
||||
if let s = await iterator.next(), !s.isRecording {
|
||||
sawStop = true
|
||||
break
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(sawStop)
|
||||
await driver.disconnect()
|
||||
}
|
||||
|
||||
// push(look:) uploads CDL + LUT payload sim can parse.
|
||||
func testPushLook() async throws {
|
||||
let driver = await makeDriver()
|
||||
try await driver.connect()
|
||||
|
||||
let cdl = CDL(slope: SIMD3(1.1, 1.0, 0.9), offset: .zero, power: .one, saturation: 1.2)
|
||||
let lut = Lut3D.identity(size: 33)
|
||||
let look = FlattenedLook(cdl: cdl, lut: lut, latticeSize: 33)
|
||||
try await driver.push(look: look)
|
||||
|
||||
let uploads = await sim.uploadedLooks
|
||||
XCTAssertEqual(uploads.count, 1)
|
||||
let obj = try JSONSerialization.jsonObject(with: uploads[0]) as? [String: Any]
|
||||
let cdlObj = obj?["cdl"] as? [String: Any]
|
||||
let slope = cdlObj?["slope"] as? [Double]
|
||||
XCTAssertEqual(slope?[0] ?? 0, 1.1, accuracy: 1e-5)
|
||||
XCTAssertEqual(cdlObj?["saturation"] as? Double ?? 0, 1.2, accuracy: 1e-5)
|
||||
XCTAssertEqual(obj?["lut3dSize"] as? Int, 33)
|
||||
let table = obj?["lut3dTable"] as? [Double]
|
||||
XCTAssertEqual(table?.count, 33 * 33 * 33 * 3)
|
||||
await driver.disconnect()
|
||||
}
|
||||
|
||||
// Unsupported LUT size rejected before hitting the wire.
|
||||
func testUnsupportedLutSizeRejected() async throws {
|
||||
let driver = await makeDriver()
|
||||
try await driver.connect()
|
||||
let look = FlattenedLook(cdl: nil, lut: Lut3D.identity(size: 5), latticeSize: 5)
|
||||
do {
|
||||
try await driver.push(look: look)
|
||||
XCTFail("expected unsupportedLook")
|
||||
} catch let error as CameraError {
|
||||
guard case .unsupportedLook = error else { return XCTFail("wrong error") }
|
||||
}
|
||||
let uploads = await sim.uploadedLooks
|
||||
XCTAssertTrue(uploads.isEmpty)
|
||||
await driver.disconnect()
|
||||
}
|
||||
|
||||
// Reconnect after sim restart.
|
||||
func testReconnectAfterSimRestart() async throws {
|
||||
let port = await sim.boundPort!
|
||||
let driver = ArriDriver(host: "127.0.0.1", port: port, pollInterval: .milliseconds(20))
|
||||
try await driver.connect()
|
||||
await driver.disconnect()
|
||||
|
||||
try await sim.stop()
|
||||
sim = ArriSimulator()
|
||||
try await sim.start(port: port)
|
||||
|
||||
try await driver.connect()
|
||||
let look = FlattenedLook(cdl: nil, lut: Lut3D.identity(size: 33), latticeSize: 33)
|
||||
try await driver.push(look: look)
|
||||
let uploads = await sim.uploadedLooks
|
||||
XCTAssertEqual(uploads.count, 1)
|
||||
await driver.disconnect()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import XCTest
|
||||
|
||||
final class ForgeCameraARRIPlaceholderTests: XCTestCase {
|
||||
func testPlaceholder() { XCTAssertTrue(true) }
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import XCTest
|
||||
|
||||
final class ForgeCameraREDPlaceholderTests: XCTestCase {
|
||||
func testPlaceholder() { XCTAssertTrue(true) }
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import XCTest
|
||||
|
||||
final class ForgeCameraSonyPlaceholderTests: XCTestCase {
|
||||
func testPlaceholder() { XCTAssertTrue(true) }
|
||||
}
|
||||
Loading…
Reference in a new issue