diff --git a/Sources/ForgeLogger/ClipLogger.swift b/Sources/ForgeLogger/ClipLogger.swift new file mode 100644 index 0000000..ec3c15d --- /dev/null +++ b/Sources/ForgeLogger/ClipLogger.swift @@ -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 + 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 + } +} diff --git a/Sources/ForgeLogger/ForgeLogger.swift b/Sources/ForgeLogger/ForgeLogger.swift deleted file mode 100644 index bd1b97f..0000000 --- a/Sources/ForgeLogger/ForgeLogger.swift +++ /dev/null @@ -1 +0,0 @@ -// ForgeLogger diff --git a/Tests/ForgeLoggerTests/ClipLoggerTests.swift b/Tests/ForgeLoggerTests/ClipLoggerTests.swift new file mode 100644 index 0000000..5e43340 --- /dev/null +++ b/Tests/ForgeLoggerTests/ClipLoggerTests.swift @@ -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.Continuation] = [:] + } + private nonisolated let box = Box() + + nonisolated var state: AsyncStream { + 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"]) + } +} diff --git a/Tests/ForgeLoggerTests/PlaceholderTests.swift b/Tests/ForgeLoggerTests/PlaceholderTests.swift deleted file mode 100644 index 14b4e2b..0000000 --- a/Tests/ForgeLoggerTests/PlaceholderTests.swift +++ /dev/null @@ -1,5 +0,0 @@ -import XCTest - -final class ForgeLoggerPlaceholderTests: XCTestCase { - func testPlaceholder() { XCTAssertTrue(true) } -}