From 388ad50dd38b6254d63b13df87b958d4b2565378 Mon Sep 17 00:00:00 2001 From: Forge Dev Date: Fri, 10 Jul 2026 18:59:45 +0000 Subject: [PATCH] =?UTF-8?q?camera:=20CameraDriver=20seam,=20capabilities,?= =?UTF-8?q?=20ConnectionSupervisor=20w/=20backoff=20retry=20+=20state=20re?= =?UTF-8?q?publish,=20SlotRegistry=20=E2=80=94=206=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ForgeCamera/CameraDriver.swift | 98 ++++++++++++ .../ForgeCamera/ConnectionSupervisor.swift | 139 +++++++++++++++++ Sources/ForgeCamera/ForgeCamera.swift | 1 - Sources/ForgeCamera/SlotRegistry.swift | 40 +++++ Tests/ForgeCameraTests/CameraSeamTests.swift | 147 ++++++++++++++++++ Tests/ForgeCameraTests/PlaceholderTests.swift | 5 - 6 files changed, 424 insertions(+), 6 deletions(-) create mode 100644 Sources/ForgeCamera/CameraDriver.swift create mode 100644 Sources/ForgeCamera/ConnectionSupervisor.swift delete mode 100644 Sources/ForgeCamera/ForgeCamera.swift create mode 100644 Sources/ForgeCamera/SlotRegistry.swift create mode 100644 Tests/ForgeCameraTests/CameraSeamTests.swift delete mode 100644 Tests/ForgeCameraTests/PlaceholderTests.swift diff --git a/Sources/ForgeCamera/CameraDriver.swift b/Sources/ForgeCamera/CameraDriver.swift new file mode 100644 index 0000000..9aa7dff --- /dev/null +++ b/Sources/ForgeCamera/CameraDriver.swift @@ -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 { get } + func connect() async throws + func disconnect() async + func push(look: FlattenedLook) async throws +} diff --git a/Sources/ForgeCamera/ConnectionSupervisor.swift b/Sources/ForgeCamera/ConnectionSupervisor.swift new file mode 100644 index 0000000..b26a841 --- /dev/null +++ b/Sources/ForgeCamera/ConnectionSupervisor.swift @@ -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? + private var pumpTask: Task? + + public private(set) var health: ConnectionStatus = .disconnected + + private var stateContinuations: [UUID: AsyncStream.Continuation] = [:] + private var connectionWaiters: [CheckedContinuation] = [] + + public init(driver: any CameraDriver, backoff: BackoffPolicy) { + self.driver = driver + self.backoff = backoff + } + + /// New subscription to republished states. + public nonisolated var states: 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) + } + + 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) 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() } + } +} diff --git a/Sources/ForgeCamera/ForgeCamera.swift b/Sources/ForgeCamera/ForgeCamera.swift deleted file mode 100644 index 6fa1b1e..0000000 --- a/Sources/ForgeCamera/ForgeCamera.swift +++ /dev/null @@ -1 +0,0 @@ -// ForgeCamera diff --git a/Sources/ForgeCamera/SlotRegistry.swift b/Sources/ForgeCamera/SlotRegistry.swift new file mode 100644 index 0000000..b96b028 --- /dev/null +++ b/Sources/ForgeCamera/SlotRegistry.swift @@ -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 } + } +} diff --git a/Tests/ForgeCameraTests/CameraSeamTests.swift b/Tests/ForgeCameraTests/CameraSeamTests.swift new file mode 100644 index 0000000..d27f637 --- /dev/null +++ b/Tests/ForgeCameraTests/CameraSeamTests.swift @@ -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.Continuation? + private let stream: AsyncStream + + init(failFirst: Int) { + failuresRemaining = failFirst + var cont: AsyncStream.Continuation! + stream = AsyncStream { cont = $0 } + continuation = cont + } + + nonisolated var state: AsyncStream { 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)) + } +} diff --git a/Tests/ForgeCameraTests/PlaceholderTests.swift b/Tests/ForgeCameraTests/PlaceholderTests.swift deleted file mode 100644 index d7f4e67..0000000 --- a/Tests/ForgeCameraTests/PlaceholderTests.swift +++ /dev/null @@ -1,5 +0,0 @@ -import XCTest - -final class ForgeCameraPlaceholderTests: XCTestCase { - func testPlaceholder() { XCTAssertTrue(true) } -}