driver-sony: Venice 2 CNA-style REST driver — LUT-only push, no-native-CDL capability gate, status polling; Sony sim — 5 tests

This commit is contained in:
Forge Dev 2026-07-10 20:02:11 +00:00
parent acce32c493
commit 9b89eb8964
4 changed files with 424 additions and 1 deletions

View file

@ -1 +0,0 @@
// ForgeCameraSony

View file

@ -0,0 +1,177 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import ForgeCamera
import ForgeGrade
import ForgeColor
/// Sony CNA endpoint paths single source of truth. Correct against Sony
/// protocol docs in hardware phase; sim mirrors these.
enum SonyPaths {
static let cameraInfo = "/cna/v1/camera/info"
static let status = "/cna/v1/camera/status"
static let userLut = "/cna/v1/monitoring/lut"
}
/// Venice 2 driver: REST over IP. Capability-narrow user 3D LUT push only,
/// no native CDL (grade pipeline must full-bake for this camera).
public actor SonyDriver: CameraDriver {
public nonisolated let capabilities = CameraCapabilities(
vendor: .sony,
supportsNativeCDL: false,
lut3dSizes: [17, 33],
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)
// Async registration OK here poll loop re-emits on change (see ArriDriver note).
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 (completion-handler path async URLSession hangs on Linux)
private func url(_ path: String) -> URL {
URL(string: "http://\(host):\(port)\(path)")!
}
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,
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw CameraError.connectionFailed("GET \(path) failed")
}
return obj
}
// MARK: CameraDriver
public func connect() async throws {
do {
_ = try await getJSON(SonyPaths.cameraInfo)
} catch {
throw CameraError.connectionFailed("camera 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 {
guard look.cdl == nil else {
throw CameraError.unsupportedLook("Venice has no native CDL — re-flatten with splitLeadingCDL=false")
}
guard let lut = look.lut else {
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
private func startPolling() {
pollTask?.cancel()
pollTask = Task {
while !Task.isCancelled {
await pollOnce()
try? await Task.sleep(for: pollInterval)
}
}
}
private func pollOnce() async {
do {
let s = try await getJSON(SonyPaths.status)
let newState = CameraState(
connection: .connected,
isRecording: s["recording"] as? Bool ?? false,
clipName: s["clipName"] as? String,
metadata: CameraMetadata(
exposureIndex: s["exposureIndex"] as? Int,
whiteBalance: s["whiteBalance"] as? Int,
tint: s["tint"] as? Int,
fps: s["fps"] as? Double,
timecode: s["timecode"] as? String))
if newState != lastState {
emit(newState)
}
} catch {
if lastState.connection == .connected {
emit(CameraState(connection: .disconnected))
}
}
}
}

View file

@ -0,0 +1,148 @@
import Foundation
import NIO
import NIOHTTP1
/// CNA-style HTTP simulator for Venice 2. Sony's Camera Network API family
/// is REST-shaped; paths in one place, correct against protocol docs later.
public enum SonySimPaths {
public static let cameraInfo = "/cna/v1/camera/info"
public static let status = "/cna/v1/camera/status"
public static let userLut = "/cna/v1/monitoring/lut"
}
public actor SonySimulator {
private var group: MultiThreadedEventLoopGroup?
private var channel: Channel?
public private(set) var boundPort: Int?
private var recording = false
private var clipName: String?
private var ei = 800
private var wb = 5500
private var tint = 0
private var fps = 24.0
private var timecode = "00:00:00:00"
public private(set) var uploadedLutCount = 0
public init() {}
public func setState(recording: Bool, clipName: String?, ei: Int, wb: Int, tint: Int, fps: Double, timecode: String) {
self.recording = recording
if let c = clipName { self.clipName = c }
self.ei = ei
self.wb = wb
self.tint = tint
self.fps = fps
self.timecode = timecode
}
func handle(method: HTTPMethod, uri: String, body: Data?) -> (status: HTTPResponseStatus, body: Data) {
switch (method, uri) {
case (.GET, SonySimPaths.cameraInfo):
return (.ok, json(["model": "VENICE 2 SIM", "serial": "SNY-0001"]))
case (.GET, SonySimPaths.status):
var obj: [String: Any] = [
"recording": recording,
"exposureIndex": ei,
"whiteBalance": wb,
"tint": tint,
"fps": fps,
"timecode": timecode,
]
if let c = clipName { obj["clipName"] = c }
return (.ok, json(obj))
case (.PUT, SonySimPaths.userLut):
guard let body,
let obj = try? JSONSerialization.jsonObject(with: body) as? [String: Any],
let size = obj["size"] as? Int,
let table = obj["table"] as? [Any],
table.count == size * size * size * 3 else {
return (.badRequest, json(["error": "bad LUT"]))
}
uploadedLutCount += 1
return (.ok, json(["status": "accepted"]))
default:
return (.notFound, json(["error": "unknown path"]))
}
}
private func json(_ obj: [String: Any]) -> Data {
(try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys])) ?? Data()
}
public func start(port: Int) async throws {
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
self.group = group
let sim = self
let bootstrap = ServerBootstrap(group: group)
.serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
.childChannelInitializer { channel in
channel.pipeline.configureHTTPServerPipeline().flatMap {
channel.pipeline.addHandler(SonyHTTPHandler(sim: sim))
}
}
let channel = try await bootstrap.bind(host: "127.0.0.1", port: port).get()
self.channel = channel
boundPort = channel.localAddress?.port
}
public func stop() async throws {
try await channel?.close()
try await group?.shutdownGracefully()
channel = nil
group = nil
boundPort = nil
}
}
final class SonyHTTPHandler: ChannelInboundHandler, @unchecked Sendable {
typealias InboundIn = HTTPServerRequestPart
typealias OutboundOut = HTTPServerResponsePart
private let sim: SonySimulator
private var method: HTTPMethod = .GET
private var uri: String = "/"
private var bodyBuffer: ByteBuffer?
init(sim: SonySimulator) {
self.sim = sim
}
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
switch unwrapInboundIn(data) {
case .head(let head):
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)")
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)
}
}
}
}
}
}

View file

@ -0,0 +1,99 @@
import XCTest
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import ForgeCamera
import ForgeGrade
import ForgeColor
import ForgeSim
@testable import ForgeCameraSony
final class SonyDriverTests: XCTestCase {
var sim: SonySimulator!
override func setUp() async throws {
sim = SonySimulator()
try await sim.start(port: 0)
}
override func tearDown() async throws {
try? await sim.stop()
sim = nil
}
func makeDriver() async -> SonyDriver {
let port = await sim.boundPort!
return SonyDriver(host: "127.0.0.1", port: port, pollInterval: .milliseconds(20))
}
// Venice 2: NO native CDL capability-narrow.
func testCapabilities() async {
let driver = await makeDriver()
XCTAssertEqual(driver.capabilities.vendor, .sony)
XCTAssertFalse(driver.capabilities.supportsNativeCDL)
XCTAssertTrue(driver.capabilities.lut3dSizes.contains(33))
}
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()
}
func testRecStateAndMetadata() async throws {
let driver = await makeDriver()
var iterator = driver.state.makeAsyncIterator()
try await driver.connect()
await sim.setState(recording: true, clipName: "C0001", ei: 500, wb: 5500, tint: 1, fps: 24, timecode: "09:15:00:00")
var got = false
for _ in 0..<30 {
if let s = await iterator.next(), s.isRecording {
XCTAssertEqual(s.clipName, "C0001")
XCTAssertEqual(s.metadata?.exposureIndex, 500)
got = true
break
}
}
XCTAssertTrue(got)
await driver.disconnect()
}
// LUT-only push accepted.
func testPushLutOnly() async throws {
let driver = await makeDriver()
try await driver.connect()
let look = FlattenedLook(cdl: nil, lut: Lut3D.identity(size: 33), latticeSize: 33)
try await driver.push(look: look)
let count = await sim.uploadedLutCount
XCTAssertEqual(count, 1)
await driver.disconnect()
}
// 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 look = FlattenedLook(cdl: cdl, lut: Lut3D.identity(size: 33), latticeSize: 33)
do {
try await driver.push(look: look)
XCTFail("expected unsupportedLook")
} catch let e as CameraError {
guard case .unsupportedLook = e else { return XCTFail("wrong error") }
}
await driver.disconnect()
}
}