92 lines
3.3 KiB
Swift
92 lines
3.3 KiB
Swift
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
|
|
}
|
|
}
|