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 } }