logger: ClipLogger — rec transitions to shot records w/ metadata+grade snapshots, TC out stamping, debounce, multi-slot, manual entry — 6 tests

This commit is contained in:
Forge Dev 2026-07-10 19:41:45 +00:00
parent ea08f8fcb7
commit 6a4a3e0976
4 changed files with 277 additions and 6 deletions

View file

@ -0,0 +1,92 @@
import Foundation
import ForgeCamera
import ForgeGrade
import ForgeLibrary
/// Watches camera state streams; rec start -> shot record with metadata +
/// grade snapshot, rec stop -> TC out. One logger per shooting day.
public actor ClipLogger {
/// Supplies the slot's current grade stack at rec start.
public typealias GradeProvider = @Sendable () -> GradeStack
private struct SlotWatch {
var task: Task<Void, Never>
var openShotID: UUID?
var wasRecording = false
}
private let store: LibraryStore
private let dayID: UUID
private var watches: [String: SlotWatch] = [:]
public init(store: LibraryStore, dayID: UUID) {
self.store = store
self.dayID = dayID
}
/// Attach a camera slot; its state stream drives shot creation.
/// Stream subscribed synchronously here events emitted right after
/// attach returns are never missed.
public func attach(slotName: String, driver: any CameraDriver, gradeProvider: @escaping GradeProvider) {
detach(slotName: slotName)
let stream = driver.state
let task = Task { [weak self] in
for await s in stream {
await self?.handle(slotName: slotName, state: s, gradeProvider: gradeProvider)
}
}
watches[slotName] = SlotWatch(task: task)
}
public func detach(slotName: String) {
watches.removeValue(forKey: slotName)?.task.cancel()
}
public func stop() {
for (_, w) in watches { w.task.cancel() }
watches.removeAll()
}
private func handle(slotName: String, state: CameraState, gradeProvider: GradeProvider) {
guard var watch = watches[slotName] else { return }
if state.isRecording && !watch.wasRecording {
// Rec start one shot per transition (debounce poll noise via wasRecording).
let snapshot = try? GradeSnapshot.encode(gradeProvider())
let shot = Shot(
dayID: dayID,
clipName: state.clipName ?? "UNKNOWN",
cameraSlot: slotName,
timecodeIn: state.metadata?.timecode,
exposureIndex: state.metadata?.exposureIndex,
whiteBalance: state.metadata?.whiteBalance,
tint: state.metadata?.tint,
fps: state.metadata?.fps,
gradeSnapshot: snapshot)
if (try? store.createShot(shot)) != nil {
watch.openShotID = shot.id
}
watch.wasRecording = true
} else if !state.isRecording && watch.wasRecording {
// Rec stop stamp TC out on the open shot.
if let id = watch.openShotID,
var shot = try? store.shot(id: id) {
shot.timecodeOut = state.metadata?.timecode
try? store.updateShot(shot)
}
watch.openShotID = nil
watch.wasRecording = false
}
watches[slotName] = watch
}
/// Manual shot entry for offline cameras.
@discardableResult
public func logManualShot(slotName: String, clipName: String, grade: GradeStack?) throws -> Shot {
let snapshot = grade.flatMap { try? GradeSnapshot.encode($0) }
let shot = Shot(dayID: dayID, clipName: clipName, cameraSlot: slotName, gradeSnapshot: snapshot)
try store.createShot(shot)
return shot
}
}

View file

@ -1 +0,0 @@
// ForgeLogger

View file

@ -0,0 +1,185 @@
import XCTest
import Foundation
import ForgeCamera
import ForgeGrade
import ForgeColor
import ForgeLibrary
@testable import ForgeLogger
/// Minimal scriptable driver for logger tests.
/// Continuations registered synchronously under a lock emits immediately
/// after `state` subscription are never lost.
actor ScriptedDriver: CameraDriver {
nonisolated let capabilities = CameraCapabilities(
vendor: .simulated, supportsNativeCDL: true, lut3dSizes: [33],
metadataFields: MetadataField.allCases)
private final class Box: @unchecked Sendable {
let lock = NSLock()
var continuations: [UUID: AsyncStream<CameraState>.Continuation] = [:]
}
private nonisolated let box = Box()
nonisolated var state: AsyncStream<CameraState> {
AsyncStream { continuation in
let id = UUID()
box.lock.lock()
box.continuations[id] = continuation
box.lock.unlock()
continuation.onTermination = { [box] _ in
box.lock.lock()
box.continuations.removeValue(forKey: id)
box.lock.unlock()
}
}
}
func connect() async throws {}
func disconnect() async {}
func push(look: FlattenedLook) async throws {}
nonisolated func emit(_ s: CameraState) {
box.lock.lock()
let conts = Array(box.continuations.values)
box.lock.unlock()
for c in conts { c.yield(s) }
}
}
final class ClipLoggerTests: XCTestCase {
var dbPath: String!
var store: LibraryStore!
var day: ForgeLibrary.Day!
var driver: ScriptedDriver!
var logger: ClipLogger!
override func setUp() async throws {
dbPath = NSTemporaryDirectory() + "forge-logger-\(UUID().uuidString).db"
store = try LibraryStore(path: dbPath)
let p = try store.createProject(name: "P")
day = try store.createDay(projectID: p.id, label: "D1", date: "2026-07-10")
driver = ScriptedDriver()
logger = ClipLogger(store: store, dayID: day.id)
}
override func tearDown() async throws {
await logger.stop()
logger = nil
store = nil
try? FileManager.default.removeItem(atPath: dbPath)
}
static func currentGrade() -> GradeStack {
GradeStack(nodes: [GradeNode(kind: .cdl(CDL(
slope: SIMD3(1.2, 1, 1), offset: .zero, power: .one, saturation: 1)))])
}
func currentGrade() -> GradeStack { Self.currentGrade() }
// Rec start creates shot with clip name, TC in, metadata, grade snapshot.
func testRecStartCreatesShot() async throws {
await logger.attach(slotName: "A-Cam", driver: driver) { Self.currentGrade() }
await driver.emit(CameraState(
connection: .connected,
isRecording: true,
clipName: "A001C001",
metadata: CameraMetadata(exposureIndex: 800, whiteBalance: 5600, tint: 0, fps: 24, timecode: "10:00:00:00")))
try await Task.sleep(for: .milliseconds(100))
let shots = try store.listShots(dayID: day.id)
XCTAssertEqual(shots.count, 1)
let s = shots[0]
XCTAssertEqual(s.clipName, "A001C001")
XCTAssertEqual(s.cameraSlot, "A-Cam")
XCTAssertEqual(s.timecodeIn, "10:00:00:00")
XCTAssertEqual(s.exposureIndex, 800)
XCTAssertNotNil(s.gradeSnapshot)
// Snapshot decodes to the grade we supplied.
let stack = try GradeSnapshot.decode(s.gradeSnapshot!)
XCTAssertEqual(stack.nodes.count, 1)
XCTAssertNil(s.timecodeOut)
}
// Rec stop stamps TC out.
func testRecStopStampsTCOut() async throws {
await logger.attach(slotName: "A-Cam", driver: driver) { Self.currentGrade() }
await driver.emit(CameraState(
connection: .connected, isRecording: true, clipName: "A001C002",
metadata: CameraMetadata(timecode: "11:00:00:00")))
try await Task.sleep(for: .milliseconds(80))
await driver.emit(CameraState(
connection: .connected, isRecording: false, clipName: "A001C002",
metadata: CameraMetadata(timecode: "11:00:45:10")))
try await Task.sleep(for: .milliseconds(80))
let shots = try store.listShots(dayID: day.id)
XCTAssertEqual(shots.count, 1)
XCTAssertEqual(shots[0].timecodeOut, "11:00:45:10")
}
// Repeated recording-true states (poll noise) don't duplicate shots.
func testDuplicateRecStartDebounced() async throws {
await logger.attach(slotName: "A-Cam", driver: driver) { Self.currentGrade() }
for _ in 0..<5 {
await driver.emit(CameraState(
connection: .connected, isRecording: true, clipName: "A001C003",
metadata: CameraMetadata(timecode: "12:00:00:00")))
}
try await Task.sleep(for: .milliseconds(100))
let shots = try store.listShots(dayID: day.id)
XCTAssertEqual(shots.count, 1)
}
// Two takes = two shots.
func testTwoTakesTwoShots() async throws {
await logger.attach(slotName: "A-Cam", driver: driver) { Self.currentGrade() }
await driver.emit(CameraState(connection: .connected, isRecording: true, clipName: "A001C004",
metadata: CameraMetadata(timecode: "13:00:00:00")))
try await Task.sleep(for: .milliseconds(50))
await driver.emit(CameraState(connection: .connected, isRecording: false, clipName: "A001C004",
metadata: CameraMetadata(timecode: "13:00:30:00")))
try await Task.sleep(for: .milliseconds(50))
await driver.emit(CameraState(connection: .connected, isRecording: true, clipName: "A001C005",
metadata: CameraMetadata(timecode: "13:05:00:00")))
try await Task.sleep(for: .milliseconds(50))
await driver.emit(CameraState(connection: .connected, isRecording: false, clipName: "A001C005",
metadata: CameraMetadata(timecode: "13:05:20:00")))
try await Task.sleep(for: .milliseconds(80))
let shots = try store.listShots(dayID: day.id)
XCTAssertEqual(shots.map(\.clipName), ["A001C004", "A001C005"])
XCTAssertEqual(shots[0].timecodeOut, "13:00:30:00")
XCTAssertEqual(shots[1].timecodeOut, "13:05:20:00")
}
// Manual shot when no camera attached.
func testManualShot() async throws {
let shot = try await logger.logManualShot(
slotName: "B-Cam", clipName: "B001C001", grade: currentGrade())
XCTAssertEqual(shot.clipName, "B001C001")
let shots = try store.listShots(dayID: day.id)
XCTAssertEqual(shots.count, 1)
XCTAssertNotNil(shots[0].gradeSnapshot)
}
// Multi-slot: A and B log independently.
func testMultiSlot() async throws {
let driverB = ScriptedDriver()
await logger.attach(slotName: "A-Cam", driver: driver) { Self.currentGrade() }
await logger.attach(slotName: "B-Cam", driver: driverB) { Self.currentGrade() }
await driver.emit(CameraState(connection: .connected, isRecording: true, clipName: "A001C006",
metadata: CameraMetadata(timecode: "14:00:00:00")))
await driverB.emit(CameraState(connection: .connected, isRecording: true, clipName: "B001C002",
metadata: CameraMetadata(timecode: "14:00:00:00")))
try await Task.sleep(for: .milliseconds(120))
let shots = try store.listShots(dayID: day.id)
XCTAssertEqual(shots.count, 2)
XCTAssertEqual(Set(shots.map(\.cameraSlot)), ["A-Cam", "B-Cam"])
}
}

View file

@ -1,5 +0,0 @@
import XCTest
final class ForgeLoggerPlaceholderTests: XCTestCase {
func testPlaceholder() { XCTAssertTrue(true) }
}