diff --git a/Sources/ForgeCameraARRI/ArriDriver.swift b/Sources/ForgeCameraARRI/ArriDriver.swift new file mode 100644 index 0000000..12eb284 --- /dev/null +++ b/Sources/ForgeCameraARRI/ArriDriver.swift @@ -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? + private var lastState = CameraState(connection: .disconnected) + + private var stateContinuations: [UUID: AsyncStream.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 { + 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.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)) + } + } + } +} diff --git a/Sources/ForgeCameraARRI/ForgeCameraARRI.swift b/Sources/ForgeCameraARRI/ForgeCameraARRI.swift deleted file mode 100644 index 1df3842..0000000 --- a/Sources/ForgeCameraARRI/ForgeCameraARRI.swift +++ /dev/null @@ -1 +0,0 @@ -// ForgeCameraARRI diff --git a/Sources/ForgeSim/ArriSimulator.swift b/Sources/ForgeSim/ArriSimulator.swift index fe3a560..ce92611 100644 --- a/Sources/ForgeSim/ArriSimulator.swift +++ b/Sources/ForgeSim/ArriSimulator.swift @@ -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) + } } } } diff --git a/Tests/ForgeCameraARRITests/ArriDriverTests.swift b/Tests/ForgeCameraARRITests/ArriDriverTests.swift new file mode 100644 index 0000000..8ec309f --- /dev/null +++ b/Tests/ForgeCameraARRITests/ArriDriverTests.swift @@ -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() + } +} diff --git a/Tests/ForgeCameraARRITests/PlaceholderTests.swift b/Tests/ForgeCameraARRITests/PlaceholderTests.swift deleted file mode 100644 index ec80674..0000000 --- a/Tests/ForgeCameraARRITests/PlaceholderTests.swift +++ /dev/null @@ -1,5 +0,0 @@ -import XCTest - -final class ForgeCameraARRIPlaceholderTests: XCTestCase { - func testPlaceholder() { XCTAssertTrue(true) } -} diff --git a/Tests/ForgeCameraREDTests/PlaceholderTests.swift b/Tests/ForgeCameraREDTests/PlaceholderTests.swift deleted file mode 100644 index 229d844..0000000 --- a/Tests/ForgeCameraREDTests/PlaceholderTests.swift +++ /dev/null @@ -1,5 +0,0 @@ -import XCTest - -final class ForgeCameraREDPlaceholderTests: XCTestCase { - func testPlaceholder() { XCTAssertTrue(true) } -} diff --git a/Tests/ForgeCameraSonyTests/PlaceholderTests.swift b/Tests/ForgeCameraSonyTests/PlaceholderTests.swift deleted file mode 100644 index a9151a7..0000000 --- a/Tests/ForgeCameraSonyTests/PlaceholderTests.swift +++ /dev/null @@ -1,5 +0,0 @@ -import XCTest - -final class ForgeCameraSonyPlaceholderTests: XCTestCase { - func testPlaceholder() { XCTAssertTrue(true) } -}