camera: CameraDriver seam, capabilities, ConnectionSupervisor w/ backoff retry + state republish, SlotRegistry — 6 tests
This commit is contained in:
parent
6f024607ae
commit
388ad50dd3
6 changed files with 424 additions and 6 deletions
98
Sources/ForgeCamera/CameraDriver.swift
Normal file
98
Sources/ForgeCamera/CameraDriver.swift
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import Foundation
|
||||||
|
import ForgeGrade
|
||||||
|
import ForgeColor
|
||||||
|
|
||||||
|
public enum CameraVendor: String, Sendable, Codable {
|
||||||
|
case arri
|
||||||
|
case red
|
||||||
|
case sony
|
||||||
|
case simulated
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MetadataField: String, Sendable, Codable, CaseIterable {
|
||||||
|
case clipName
|
||||||
|
case exposureIndex
|
||||||
|
case whiteBalance
|
||||||
|
case tint
|
||||||
|
case fps
|
||||||
|
case timecode
|
||||||
|
case lens
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a camera can accept / report. Drivers declare, grade pipeline adapts.
|
||||||
|
public struct CameraCapabilities: Sendable {
|
||||||
|
public var vendor: CameraVendor
|
||||||
|
/// Camera applies ASC CDL natively (RED IPP2, ARRI ALF4). If false, CDL must be baked into LUT.
|
||||||
|
public var supportsNativeCDL: Bool
|
||||||
|
/// Accepted 3D LUT lattice sizes.
|
||||||
|
public var lut3dSizes: [Int]
|
||||||
|
public var metadataFields: [MetadataField]
|
||||||
|
|
||||||
|
public init(vendor: CameraVendor, supportsNativeCDL: Bool, lut3dSizes: [Int], metadataFields: [MetadataField]) {
|
||||||
|
self.vendor = vendor
|
||||||
|
self.supportsNativeCDL = supportsNativeCDL
|
||||||
|
self.lut3dSizes = lut3dSizes
|
||||||
|
self.metadataFields = metadataFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ConnectionStatus: String, Sendable, Equatable {
|
||||||
|
case disconnected
|
||||||
|
case connecting
|
||||||
|
case connected
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CameraMetadata: Sendable, Equatable {
|
||||||
|
public var exposureIndex: Int?
|
||||||
|
public var whiteBalance: Int?
|
||||||
|
public var tint: Int?
|
||||||
|
public var fps: Double?
|
||||||
|
public var timecode: String?
|
||||||
|
public var lens: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
exposureIndex: Int? = nil, whiteBalance: Int? = nil, tint: Int? = nil,
|
||||||
|
fps: Double? = nil, timecode: String? = nil, lens: String? = nil
|
||||||
|
) {
|
||||||
|
self.exposureIndex = exposureIndex
|
||||||
|
self.whiteBalance = whiteBalance
|
||||||
|
self.tint = tint
|
||||||
|
self.fps = fps
|
||||||
|
self.timecode = timecode
|
||||||
|
self.lens = lens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CameraState: Sendable, Equatable {
|
||||||
|
public var connection: ConnectionStatus
|
||||||
|
public var isRecording: Bool
|
||||||
|
public var clipName: String?
|
||||||
|
public var metadata: CameraMetadata?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
connection: ConnectionStatus,
|
||||||
|
isRecording: Bool = false,
|
||||||
|
clipName: String? = nil,
|
||||||
|
metadata: CameraMetadata? = nil
|
||||||
|
) {
|
||||||
|
self.connection = connection
|
||||||
|
self.isRecording = isRecording
|
||||||
|
self.clipName = clipName
|
||||||
|
self.metadata = metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CameraError: Error, Equatable {
|
||||||
|
case connectionFailed(String)
|
||||||
|
case pushFailed(String)
|
||||||
|
case unsupportedLook(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hardware seam. One driver instance per physical camera.
|
||||||
|
public protocol CameraDriver: Actor {
|
||||||
|
nonisolated var capabilities: CameraCapabilities { get }
|
||||||
|
nonisolated var state: AsyncStream<CameraState> { get }
|
||||||
|
func connect() async throws
|
||||||
|
func disconnect() async
|
||||||
|
func push(look: FlattenedLook) async throws
|
||||||
|
}
|
||||||
139
Sources/ForgeCamera/ConnectionSupervisor.swift
Normal file
139
Sources/ForgeCamera/ConnectionSupervisor.swift
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Exponential backoff with cap.
|
||||||
|
public struct BackoffPolicy: Sendable {
|
||||||
|
public let initial: Duration
|
||||||
|
public let multiplier: Double
|
||||||
|
public let maxDelay: Duration
|
||||||
|
private var current: Duration?
|
||||||
|
|
||||||
|
public init(initial: Duration, multiplier: Double, maxDelay: Duration) {
|
||||||
|
self.initial = initial
|
||||||
|
self.multiplier = multiplier
|
||||||
|
self.maxDelay = maxDelay
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func nextDelay() -> Duration {
|
||||||
|
guard let c = current else {
|
||||||
|
current = initial
|
||||||
|
return initial
|
||||||
|
}
|
||||||
|
let scaled = Duration.seconds(Double(c.components.seconds) * multiplier
|
||||||
|
+ Double(c.components.attoseconds) * multiplier / 1e18)
|
||||||
|
let next = scaled > maxDelay ? maxDelay : scaled
|
||||||
|
current = next
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func reset() {
|
||||||
|
current = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns connect/retry/health for one driver. Republishes driver states.
|
||||||
|
public actor ConnectionSupervisor {
|
||||||
|
private let driver: any CameraDriver
|
||||||
|
private var backoff: BackoffPolicy
|
||||||
|
private var connectTask: Task<Void, Never>?
|
||||||
|
private var pumpTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
public private(set) var health: ConnectionStatus = .disconnected
|
||||||
|
|
||||||
|
private var stateContinuations: [UUID: AsyncStream<CameraState>.Continuation] = [:]
|
||||||
|
private var connectionWaiters: [CheckedContinuation<Void, Never>] = []
|
||||||
|
|
||||||
|
public init(driver: any CameraDriver, backoff: BackoffPolicy) {
|
||||||
|
self.driver = driver
|
||||||
|
self.backoff = backoff
|
||||||
|
}
|
||||||
|
|
||||||
|
/// New subscription to republished states.
|
||||||
|
public nonisolated var states: 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func start() throws {
|
||||||
|
guard connectTask == nil else { return }
|
||||||
|
health = .connecting
|
||||||
|
|
||||||
|
// Pump driver states into republish + health.
|
||||||
|
pumpTask = Task { [driver] in
|
||||||
|
for await s in driver.state {
|
||||||
|
await self.handle(state: s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect with retry.
|
||||||
|
connectTask = Task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
do {
|
||||||
|
try await driver.connect()
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
let delay = self.nextBackoffDelay()
|
||||||
|
try? await Task.sleep(for: delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nextBackoffDelay() -> Duration {
|
||||||
|
backoff.nextDelay()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(state: CameraState) {
|
||||||
|
health = state.connection
|
||||||
|
if state.connection == .connected {
|
||||||
|
backoff.reset()
|
||||||
|
let waiters = connectionWaiters
|
||||||
|
connectionWaiters.removeAll()
|
||||||
|
for w in waiters { w.resume() }
|
||||||
|
}
|
||||||
|
for c in stateContinuations.values {
|
||||||
|
c.yield(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Await connected health. Returns false on timeout.
|
||||||
|
public func waitForConnection(timeout: Duration) async -> Bool {
|
||||||
|
if health == .connected { return true }
|
||||||
|
let waitTask = Task {
|
||||||
|
await withCheckedContinuation { (c: CheckedContinuation<Void, Never>) in
|
||||||
|
self.connectionWaiters.append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let timeoutTask = Task {
|
||||||
|
try? await Task.sleep(for: timeout)
|
||||||
|
waitTask.cancel()
|
||||||
|
}
|
||||||
|
_ = await waitTask.value
|
||||||
|
timeoutTask.cancel()
|
||||||
|
return health == .connected
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() async {
|
||||||
|
connectTask?.cancel()
|
||||||
|
pumpTask?.cancel()
|
||||||
|
connectTask = nil
|
||||||
|
pumpTask = nil
|
||||||
|
await driver.disconnect()
|
||||||
|
health = .disconnected
|
||||||
|
let waiters = connectionWaiters
|
||||||
|
connectionWaiters.removeAll()
|
||||||
|
for w in waiters { w.resume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
// ForgeCamera
|
|
||||||
40
Sources/ForgeCamera/SlotRegistry.swift
Normal file
40
Sources/ForgeCamera/SlotRegistry.swift
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A camera slot: named position (A-Cam, B-Cam...) bound to a driver.
|
||||||
|
public struct CameraSlot: Identifiable, Sendable {
|
||||||
|
public let id: UUID
|
||||||
|
public var name: String
|
||||||
|
public let driver: any CameraDriver
|
||||||
|
|
||||||
|
public init(id: UUID = UUID(), name: String, driver: any CameraDriver) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.driver = driver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registry of active camera slots.
|
||||||
|
public actor SlotRegistry {
|
||||||
|
public private(set) var slots: [CameraSlot] = []
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func addSlot(name: String, driver: any CameraDriver) -> CameraSlot {
|
||||||
|
let slot = CameraSlot(name: name, driver: driver)
|
||||||
|
slots.append(slot)
|
||||||
|
return slot
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeSlot(id: UUID) {
|
||||||
|
slots.removeAll { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func slot(id: UUID) -> CameraSlot? {
|
||||||
|
slots.first { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func slot(named name: String) -> CameraSlot? {
|
||||||
|
slots.first { $0.name == name }
|
||||||
|
}
|
||||||
|
}
|
||||||
147
Tests/ForgeCameraTests/CameraSeamTests.swift
Normal file
147
Tests/ForgeCameraTests/CameraSeamTests.swift
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import XCTest
|
||||||
|
import ForgeGrade
|
||||||
|
import ForgeColor
|
||||||
|
@testable import ForgeCamera
|
||||||
|
|
||||||
|
/// Scriptable in-test driver: fails N connects, then succeeds. Emits states on demand.
|
||||||
|
actor FlakyDriver: CameraDriver {
|
||||||
|
nonisolated let capabilities = CameraCapabilities(
|
||||||
|
vendor: .arri,
|
||||||
|
supportsNativeCDL: true,
|
||||||
|
lut3dSizes: [33],
|
||||||
|
metadataFields: [.clipName, .exposureIndex])
|
||||||
|
|
||||||
|
private var failuresRemaining: Int
|
||||||
|
private(set) var connectAttempts = 0
|
||||||
|
private(set) var pushedLooks = [FlattenedLook]()
|
||||||
|
private var continuation: AsyncStream<CameraState>.Continuation?
|
||||||
|
private let stream: AsyncStream<CameraState>
|
||||||
|
|
||||||
|
init(failFirst: Int) {
|
||||||
|
failuresRemaining = failFirst
|
||||||
|
var cont: AsyncStream<CameraState>.Continuation!
|
||||||
|
stream = AsyncStream { cont = $0 }
|
||||||
|
continuation = cont
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated var state: AsyncStream<CameraState> { stream }
|
||||||
|
|
||||||
|
func connect() async throws {
|
||||||
|
connectAttempts += 1
|
||||||
|
if failuresRemaining > 0 {
|
||||||
|
failuresRemaining -= 1
|
||||||
|
throw CameraError.connectionFailed("scripted failure")
|
||||||
|
}
|
||||||
|
continuation?.yield(CameraState(connection: .connected))
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() async {
|
||||||
|
continuation?.yield(CameraState(connection: .disconnected))
|
||||||
|
}
|
||||||
|
|
||||||
|
func push(look: FlattenedLook) async throws {
|
||||||
|
pushedLooks.append(look)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hooks.
|
||||||
|
func emit(_ s: CameraState) {
|
||||||
|
continuation?.yield(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CameraSeamTests: XCTestCase {
|
||||||
|
|
||||||
|
// Supervisor retries with backoff until driver connects.
|
||||||
|
func testSupervisorRetriesUntilConnected() async throws {
|
||||||
|
let driver = FlakyDriver(failFirst: 3)
|
||||||
|
let supervisor = ConnectionSupervisor(
|
||||||
|
driver: driver,
|
||||||
|
backoff: BackoffPolicy(initial: .milliseconds(1), multiplier: 1, maxDelay: .milliseconds(2)))
|
||||||
|
try await supervisor.start()
|
||||||
|
// Wait for connected.
|
||||||
|
let ok = await supervisor.waitForConnection(timeout: .seconds(2))
|
||||||
|
XCTAssertTrue(ok)
|
||||||
|
let attempts = await driver.connectAttempts
|
||||||
|
XCTAssertEqual(attempts, 4) // 3 failures + 1 success
|
||||||
|
await supervisor.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health reflects driver state stream.
|
||||||
|
func testHealthTracksState() async throws {
|
||||||
|
let driver = FlakyDriver(failFirst: 0)
|
||||||
|
let supervisor = ConnectionSupervisor(
|
||||||
|
driver: driver,
|
||||||
|
backoff: BackoffPolicy(initial: .milliseconds(1), multiplier: 1, maxDelay: .milliseconds(2)))
|
||||||
|
try await supervisor.start()
|
||||||
|
_ = await supervisor.waitForConnection(timeout: .seconds(2))
|
||||||
|
var health = await supervisor.health
|
||||||
|
XCTAssertEqual(health, .connected)
|
||||||
|
|
||||||
|
await driver.emit(CameraState(connection: .disconnected))
|
||||||
|
try await Task.sleep(for: .milliseconds(50))
|
||||||
|
health = await supervisor.health
|
||||||
|
XCTAssertEqual(health, .disconnected)
|
||||||
|
await supervisor.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recording state + metadata pass through supervisor's published stream.
|
||||||
|
func testStatePassthrough() async throws {
|
||||||
|
let driver = FlakyDriver(failFirst: 0)
|
||||||
|
let supervisor = ConnectionSupervisor(
|
||||||
|
driver: driver,
|
||||||
|
backoff: BackoffPolicy(initial: .milliseconds(1), multiplier: 1, maxDelay: .milliseconds(2)))
|
||||||
|
try await supervisor.start()
|
||||||
|
_ = await supervisor.waitForConnection(timeout: .seconds(2))
|
||||||
|
|
||||||
|
var iterator = supervisor.states.makeAsyncIterator()
|
||||||
|
await driver.emit(CameraState(
|
||||||
|
connection: .connected,
|
||||||
|
isRecording: true,
|
||||||
|
clipName: "A001C002",
|
||||||
|
metadata: CameraMetadata(exposureIndex: 800, whiteBalance: 5600, tint: 0, fps: 24, timecode: "12:00:00:00")))
|
||||||
|
|
||||||
|
// Drain until we see the recording state (connected event may arrive first).
|
||||||
|
var found = false
|
||||||
|
for _ in 0..<5 {
|
||||||
|
if let s = await iterator.next(), s.isRecording, s.clipName == "A001C002" {
|
||||||
|
XCTAssertEqual(s.metadata?.exposureIndex, 800)
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertTrue(found)
|
||||||
|
await supervisor.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slot registry add/remove/lookup.
|
||||||
|
func testSlotRegistry() async throws {
|
||||||
|
let registry = SlotRegistry()
|
||||||
|
let driver = FlakyDriver(failFirst: 0)
|
||||||
|
let slot = await registry.addSlot(name: "A-Cam", driver: driver)
|
||||||
|
let all = await registry.slots
|
||||||
|
XCTAssertEqual(all.count, 1)
|
||||||
|
XCTAssertEqual(all[0].name, "A-Cam")
|
||||||
|
await registry.removeSlot(id: slot.id)
|
||||||
|
let after = await registry.slots
|
||||||
|
XCTAssertTrue(after.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capabilities surfaced through slot.
|
||||||
|
func testCapabilities() async {
|
||||||
|
let driver = FlakyDriver(failFirst: 0)
|
||||||
|
XCTAssertTrue(driver.capabilities.supportsNativeCDL)
|
||||||
|
XCTAssertEqual(driver.capabilities.vendor, .arri)
|
||||||
|
XCTAssertTrue(driver.capabilities.lut3dSizes.contains(33))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backoff policy caps at maxDelay and multiplies.
|
||||||
|
func testBackoffPolicy() {
|
||||||
|
var policy = BackoffPolicy(initial: .milliseconds(100), multiplier: 2, maxDelay: .milliseconds(350))
|
||||||
|
XCTAssertEqual(policy.nextDelay(), .milliseconds(100))
|
||||||
|
XCTAssertEqual(policy.nextDelay(), .milliseconds(200))
|
||||||
|
XCTAssertEqual(policy.nextDelay(), .milliseconds(350)) // capped from 400
|
||||||
|
XCTAssertEqual(policy.nextDelay(), .milliseconds(350))
|
||||||
|
policy.reset()
|
||||||
|
XCTAssertEqual(policy.nextDelay(), .milliseconds(100))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import XCTest
|
|
||||||
|
|
||||||
final class ForgeCameraPlaceholderTests: XCTestCase {
|
|
||||||
func testPlaceholder() { XCTAssertTrue(true) }
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue