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:
parent
ea08f8fcb7
commit
6a4a3e0976
4 changed files with 277 additions and 6 deletions
92
Sources/ForgeLogger/ClipLogger.swift
Normal file
92
Sources/ForgeLogger/ClipLogger.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
// ForgeLogger
|
|
||||||
185
Tests/ForgeLoggerTests/ClipLoggerTests.swift
Normal file
185
Tests/ForgeLoggerTests/ClipLoggerTests.swift
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import XCTest
|
|
||||||
|
|
||||||
final class ForgeLoggerPlaceholderTests: XCTestCase {
|
|
||||||
func testPlaceholder() { XCTAssertTrue(true) }
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue