rainbow-dragon/Sources/ForgeLogger/ClipLogger.swift

93 lines
3.3 KiB
Swift
Raw Permalink Normal View History

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