logger+cli: e2e test (sim->driver->logger->store->all exports), forge export subcommand (ale/csv/edl/ccc/cube); fix supervisor subscription race w/ lock-guarded continuations — 8x soak stable @ 111 tests
This commit is contained in:
parent
80c5ab0604
commit
bb1ef127ed
4 changed files with 210 additions and 16 deletions
|
|
@ -39,7 +39,34 @@ public actor ConnectionSupervisor {
|
||||||
|
|
||||||
public private(set) var health: ConnectionStatus = .disconnected
|
public private(set) var health: ConnectionStatus = .disconnected
|
||||||
|
|
||||||
private var stateContinuations: [UUID: AsyncStream<CameraState>.Continuation] = [:]
|
/// Subscriber continuations behind a lock — registration is synchronous
|
||||||
|
/// at subscription time, so states emitted immediately after `states`
|
||||||
|
/// is accessed are never dropped (async actor-hop registration raced).
|
||||||
|
private final class SubscriberBox: @unchecked Sendable {
|
||||||
|
let lock = NSLock()
|
||||||
|
var continuations: [UUID: AsyncStream<CameraState>.Continuation] = [:]
|
||||||
|
|
||||||
|
func add(_ id: UUID, _ c: AsyncStream<CameraState>.Continuation) {
|
||||||
|
lock.lock()
|
||||||
|
continuations[id] = c
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ id: UUID) {
|
||||||
|
lock.lock()
|
||||||
|
continuations.removeValue(forKey: id)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func yieldAll(_ s: CameraState) {
|
||||||
|
lock.lock()
|
||||||
|
let conts = Array(continuations.values)
|
||||||
|
lock.unlock()
|
||||||
|
for c in conts { c.yield(s) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated let subscribers = SubscriberBox()
|
||||||
private var connectionWaiters: [UUID] = []
|
private var connectionWaiters: [UUID] = []
|
||||||
private var waiterMap: [UUID: CheckedContinuation<Void, Never>] = [:]
|
private var waiterMap: [UUID: CheckedContinuation<Void, Never>] = [:]
|
||||||
|
|
||||||
|
|
@ -52,21 +79,13 @@ public actor ConnectionSupervisor {
|
||||||
public nonisolated var states: AsyncStream<CameraState> {
|
public nonisolated var states: AsyncStream<CameraState> {
|
||||||
AsyncStream { continuation in
|
AsyncStream { continuation in
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
Task { await self.register(id: id, continuation: continuation) }
|
subscribers.add(id, continuation)
|
||||||
continuation.onTermination = { _ in
|
continuation.onTermination = { [subscribers] _ in
|
||||||
Task { await self.unregister(id: id) }
|
subscribers.remove(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 {
|
public func start() throws {
|
||||||
guard connectTask == nil else { return }
|
guard connectTask == nil else { return }
|
||||||
health = .connecting
|
health = .connecting
|
||||||
|
|
@ -102,9 +121,7 @@ public actor ConnectionSupervisor {
|
||||||
backoff.reset()
|
backoff.reset()
|
||||||
resumeAllWaiters()
|
resumeAllWaiters()
|
||||||
}
|
}
|
||||||
for c in stateContinuations.values {
|
subscribers.yieldAll(state)
|
||||||
c.yield(state)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Await connected health. Returns false on timeout.
|
/// Await connected health. Returns false on timeout.
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ public actor ArriDriver: CameraDriver {
|
||||||
private var pollTask: Task<Void, Never>?
|
private var pollTask: Task<Void, Never>?
|
||||||
private var lastState = CameraState(connection: .disconnected)
|
private var lastState = CameraState(connection: .disconnected)
|
||||||
|
|
||||||
|
// NOTE: registration is async (actor hop). Safe here: the poll loop
|
||||||
|
// re-emits on every state *change* vs lastState, so a subscriber attached
|
||||||
|
// moments late still receives the next change. (A lock-based
|
||||||
|
// nonisolated-let variant crashed swiftc 6.0.3 codegen on Linux.)
|
||||||
private var stateContinuations: [UUID: AsyncStream<CameraState>.Continuation] = [:]
|
private var stateContinuations: [UUID: AsyncStream<CameraState>.Continuation] = [:]
|
||||||
|
|
||||||
public init(host: String, port: Int, pollInterval: Duration = .milliseconds(150)) {
|
public init(host: String, port: Int, pollInterval: Duration = .milliseconds(150)) {
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,95 @@ import ForgeCameraARRI
|
||||||
import ForgeGrade
|
import ForgeGrade
|
||||||
import ForgeColor
|
import ForgeColor
|
||||||
import ForgeSim
|
import ForgeSim
|
||||||
|
import ForgeLibrary
|
||||||
|
import ForgeExport
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct Forge: AsyncParsableCommand {
|
struct Forge: AsyncParsableCommand {
|
||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: "forge",
|
commandName: "forge",
|
||||||
abstract: "On-set live grading: push looks to cameras over IP.",
|
abstract: "On-set live grading: push looks to cameras over IP.",
|
||||||
subcommands: [Push.self, Status.self, Sim.self])
|
subcommands: [Push.self, Status.self, Sim.self, Export.self])
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Export: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(abstract: "Export a day's shots for post.")
|
||||||
|
|
||||||
|
@Option(name: .long, help: "Library database path")
|
||||||
|
var db: String
|
||||||
|
|
||||||
|
@Option(name: .long, help: "Day label (e.g. 'Day 01')")
|
||||||
|
var day: String
|
||||||
|
|
||||||
|
@Option(name: .long, help: "Format: ale|csv|edl|ccc|cube")
|
||||||
|
var format: String
|
||||||
|
|
||||||
|
@Option(name: .long, help: "Output file (or directory for cube)")
|
||||||
|
var out: String
|
||||||
|
|
||||||
|
@Option(name: .long, help: "Timebase fps for ALE/EDL")
|
||||||
|
var fps: Int = 24
|
||||||
|
|
||||||
|
@Option(name: .long, help: "Cube lattice size")
|
||||||
|
var lattice: Int = 33
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let store = try LibraryStore(path: db)
|
||||||
|
// Find day by label across all projects.
|
||||||
|
var target: ForgeLibrary.Day?
|
||||||
|
for p in try store.listProjects() {
|
||||||
|
if let d = try store.listDays(projectID: p.id).first(where: { $0.label == day }) {
|
||||||
|
target = d
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let target else {
|
||||||
|
throw ValidationError("day '\(day)' not found in \(db)")
|
||||||
|
}
|
||||||
|
let shots = try store.listShots(dayID: target.id)
|
||||||
|
guard !shots.isEmpty else {
|
||||||
|
throw ValidationError("no shots logged for '\(day)'")
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstCDL(_ s: Shot) -> ForgeColor.CDL? {
|
||||||
|
guard let data = s.gradeSnapshot,
|
||||||
|
let stack = try? GradeSnapshot.decode(data) else { return nil }
|
||||||
|
for n in stack.nodes {
|
||||||
|
if case .cdl(let c) = n.kind, n.isEnabled { return c }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch format {
|
||||||
|
case "ale":
|
||||||
|
try PostExport.writeALE(shots: shots, fps: fps)
|
||||||
|
.write(toFile: out, atomically: true, encoding: .utf8)
|
||||||
|
case "csv":
|
||||||
|
try PostExport.writeCSV(shots: shots)
|
||||||
|
.write(toFile: out, atomically: true, encoding: .utf8)
|
||||||
|
case "edl":
|
||||||
|
try PostExport.writeEDL(shots: shots, title: day, fps: fps)
|
||||||
|
.write(toFile: out, atomically: true, encoding: .utf8)
|
||||||
|
case "ccc":
|
||||||
|
let entries = shots.compactMap { s in firstCDL(s).map { (s.clipName, $0) } }
|
||||||
|
try CDLExport.writeCCC(entries)
|
||||||
|
.write(toFile: out, atomically: true, encoding: .utf8)
|
||||||
|
case "cube":
|
||||||
|
try FileManager.default.createDirectory(atPath: out, withIntermediateDirectories: true)
|
||||||
|
var written = 0
|
||||||
|
for s in shots {
|
||||||
|
guard let snap = s.gradeSnapshot else { continue }
|
||||||
|
let cube = try CubeExport.fromSnapshot(snap, latticeSize: lattice, title: s.clipName)
|
||||||
|
try cube.write(toFile: "\(out)/\(s.clipName).cube", atomically: true, encoding: .utf8)
|
||||||
|
written += 1
|
||||||
|
}
|
||||||
|
print("wrote \(written) cubes to \(out)/")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
throw ValidationError("unknown format '\(format)' (ale|csv|edl|ccc|cube)")
|
||||||
|
}
|
||||||
|
print("wrote \(format): \(out) (\(shots.count) shots)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CameraOptions: ParsableArguments {
|
struct CameraOptions: ParsableArguments {
|
||||||
|
|
|
||||||
91
Tests/ForgeLoggerTests/EndToEndExportTests.swift
Normal file
91
Tests/ForgeLoggerTests/EndToEndExportTests.swift
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import XCTest
|
||||||
|
import Foundation
|
||||||
|
import ForgeCamera
|
||||||
|
import ForgeCameraARRI
|
||||||
|
import ForgeColor
|
||||||
|
import ForgeGrade
|
||||||
|
import ForgeLibrary
|
||||||
|
import ForgeExport
|
||||||
|
import ForgeSim
|
||||||
|
@testable import ForgeLogger
|
||||||
|
|
||||||
|
/// Full pipeline: ARRI sim -> driver poll -> ClipLogger -> LibraryStore -> exports.
|
||||||
|
final class EndToEndExportTests: XCTestCase {
|
||||||
|
|
||||||
|
func testSimSessionToExports() async throws {
|
||||||
|
// Infra.
|
||||||
|
let sim = ArriSimulator()
|
||||||
|
try await sim.start(port: 0)
|
||||||
|
defer { Task { try? await sim.stop() } }
|
||||||
|
let port = await sim.boundPort!
|
||||||
|
|
||||||
|
let dbPath = NSTemporaryDirectory() + "forge-e2e-\(UUID().uuidString).db"
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: dbPath) }
|
||||||
|
let store = try LibraryStore(path: dbPath)
|
||||||
|
let project = try store.createProject(name: "E2E")
|
||||||
|
let day = try store.createDay(projectID: project.id, label: "Day 01", date: "2026-07-10")
|
||||||
|
|
||||||
|
let driver = ArriDriver(host: "127.0.0.1", port: port, pollInterval: .milliseconds(15))
|
||||||
|
let logger = ClipLogger(store: store, dayID: day.id)
|
||||||
|
|
||||||
|
let grade = GradeStack(nodes: [GradeNode(kind: .cdl(CDL(
|
||||||
|
slope: SIMD3(1.15, 1.0, 0.92), offset: SIMD3(0.01, 0, -0.01),
|
||||||
|
power: .one, saturation: 1.1)))])
|
||||||
|
let snapshotForProvider = grade
|
||||||
|
|
||||||
|
await logger.attach(slotName: "A-Cam", driver: driver) { snapshotForProvider }
|
||||||
|
try await driver.connect()
|
||||||
|
|
||||||
|
// Three takes.
|
||||||
|
let takes: [(clip: String, tcIn: String, tcOut: String)] = [
|
||||||
|
("A001C001_260710", "10:00:00:00", "10:00:30:00"),
|
||||||
|
("A001C002_260710", "10:05:00:00", "10:05:45:00"),
|
||||||
|
("A001C003_260710", "10:12:00:00", "10:13:00:00"),
|
||||||
|
]
|
||||||
|
for take in takes {
|
||||||
|
await sim.setMetadata(ei: 800, wb: 5600, tint: 0, fps: 24, timecode: take.tcIn)
|
||||||
|
try await Task.sleep(for: .milliseconds(60))
|
||||||
|
await sim.setRecording(true, clipName: take.clip)
|
||||||
|
try await Task.sleep(for: .milliseconds(80))
|
||||||
|
await sim.setMetadata(ei: 800, wb: 5600, tint: 0, fps: 24, timecode: take.tcOut)
|
||||||
|
try await Task.sleep(for: .milliseconds(60))
|
||||||
|
await sim.setRecording(false)
|
||||||
|
try await Task.sleep(for: .milliseconds(80))
|
||||||
|
}
|
||||||
|
|
||||||
|
await driver.disconnect()
|
||||||
|
await logger.stop()
|
||||||
|
|
||||||
|
// Three shots logged with TCs + snapshots.
|
||||||
|
let shots = try store.listShots(dayID: day.id)
|
||||||
|
XCTAssertEqual(shots.count, 3)
|
||||||
|
XCTAssertEqual(shots.map(\.clipName), takes.map(\.clip))
|
||||||
|
for (shot, take) in zip(shots, takes) {
|
||||||
|
XCTAssertEqual(shot.timecodeIn, take.tcIn)
|
||||||
|
XCTAssertEqual(shot.timecodeOut, take.tcOut)
|
||||||
|
XCTAssertNotNil(shot.gradeSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All export formats produce sane output.
|
||||||
|
let ale = PostExport.writeALE(shots: shots, fps: 24)
|
||||||
|
XCTAssertEqual(ale.components(separatedBy: "A001C").count - 1, 3)
|
||||||
|
|
||||||
|
let csv = PostExport.writeCSV(shots: shots)
|
||||||
|
XCTAssertEqual(csv.split(separator: "\n").count, 4) // header + 3
|
||||||
|
|
||||||
|
let edl = PostExport.writeEDL(shots: shots, title: "DAY01", fps: 24)
|
||||||
|
XCTAssertTrue(edl.contains("003 A001C003_260710"))
|
||||||
|
XCTAssertEqual(edl.components(separatedBy: "* ASC_SOP").count - 1, 3)
|
||||||
|
|
||||||
|
let ccc = CDLExport.writeCCC(shots.compactMap { s in
|
||||||
|
guard let data = s.gradeSnapshot,
|
||||||
|
let stack = try? GradeSnapshot.decode(data),
|
||||||
|
case .cdl(let c)? = stack.nodes.first?.kind else { return nil }
|
||||||
|
return (s.clipName, c)
|
||||||
|
})
|
||||||
|
XCTAssertEqual(ccc.components(separatedBy: "<ColorCorrection id=").count - 1, 3)
|
||||||
|
|
||||||
|
let cube = try CubeExport.fromSnapshot(shots[0].gradeSnapshot!, latticeSize: 17, title: shots[0].clipName)
|
||||||
|
XCTAssertTrue(cube.contains("LUT_3D_SIZE 17"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue