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