rainbow-dragon/Sources/ForgeExport/IngestMatcher.swift

135 lines
5.1 KiB
Swift
Raw Normal View History

import Foundation
import ForgeColor
import ForgeGrade
import ForgeLibrary
/// Look-match ingest bridge: connects offloaded media files back to logged shots
/// by clip name and materializes each shot's grade as sidecars next to the media.
/// This is the LivegradeSilverstack "look matching" flow, without the cloud
/// a dailies tool (or dragonflight) picks up the .cdl/.cube beside every clip.
public enum IngestMatcher {
public struct Options: Sendable {
public var writeCDL: Bool
public var writeCube: Bool
public var latticeSize: Int
public var force: Bool
public init(writeCDL: Bool, writeCube: Bool, latticeSize: Int, force: Bool = false) {
self.writeCDL = writeCDL
self.writeCube = writeCube
self.latticeSize = latticeSize
self.force = force
}
}
public struct Match: Sendable {
public let clipName: String
public let mediaPath: String
public let sidecarsWritten: Bool
}
public struct Result: Sendable {
public let matched: [Match]
/// Media files with no shot record (relative to mediaRoot).
public let unmatchedMedia: [String]
/// Shot clip names with no media file found.
public let shotsWithoutMedia: [String]
}
/// Media extensions considered clips.
static let mediaExtensions: Set<String> = [
"mov", "mxf", "mp4", "braw", "arri", "ari", "r3d", "crm", "dng", "avi",
]
public static func match(
mediaRoot: String,
store: LibraryStore,
dayID: UUID,
options: Options
) throws -> Result {
let fm = FileManager.default
let shots = try store.listShots(dayID: dayID)
// Index shots by lowercased clip name.
var shotByClip: [String: Shot] = [:]
for s in shots {
shotByClip[s.clipName.lowercased()] = s
}
// Walk media tree.
guard let enumerator = fm.enumerator(atPath: mediaRoot) else {
throw ExportError.malformed("cannot enumerate \(mediaRoot)")
}
var matched = [Match]()
var unmatched = [String]()
var matchedClipNames = Set<String>()
while let rel = enumerator.nextObject() as? String {
let full = mediaRoot + "/" + rel
var isDir: ObjCBool = false
guard fm.fileExists(atPath: full, isDirectory: &isDir), !isDir.boolValue else { continue }
let ext = (rel as NSString).pathExtension.lowercased()
guard mediaExtensions.contains(ext) else { continue }
let base = ((rel as NSString).lastPathComponent as NSString).deletingPathExtension
guard let shot = shotByClip[base.lowercased()] else {
unmatched.append(rel)
continue
}
matchedClipNames.insert(shot.clipName)
var wroteSidecar = false
if let snapshot = shot.gradeSnapshot {
let sidecarBase = (full as NSString).deletingPathExtension
if options.writeCDL {
wroteSidecar = try writeCDLSidecar(
snapshot: snapshot, clipName: shot.clipName,
path: sidecarBase + ".cdl", force: options.force) || wroteSidecar
}
if options.writeCube {
wroteSidecar = try writeCubeSidecar(
snapshot: snapshot, clipName: shot.clipName,
path: sidecarBase + ".cube", latticeSize: options.latticeSize,
force: options.force) || wroteSidecar
}
}
matched.append(Match(clipName: shot.clipName, mediaPath: rel, sidecarsWritten: wroteSidecar))
}
let missing = shots
.map(\.clipName)
.filter { !matchedClipNames.contains($0) }
return Result(
matched: matched.sorted { $0.clipName < $1.clipName },
unmatchedMedia: unmatched.sorted(),
shotsWithoutMedia: missing)
}
/// Returns true if file written.
private static func writeCDLSidecar(snapshot: Data, clipName: String, path: String, force: Bool) throws -> Bool {
if !force && FileManager.default.fileExists(atPath: path) {
return false
}
guard let stack = try? GradeSnapshot.decode(snapshot) else { return false }
for node in stack.nodes {
if case .cdl(let cdl) = node.kind, node.isEnabled {
try CDLExport.writeCDL(cdl, id: clipName)
.write(toFile: path, atomically: true, encoding: .utf8)
return true
}
}
return false
}
private static func writeCubeSidecar(snapshot: Data, clipName: String, path: String, latticeSize: Int, force: Bool) throws -> Bool {
if !force && FileManager.default.fileExists(atPath: path) {
return false
}
let cube = try CubeExport.fromSnapshot(snapshot, latticeSize: latticeSize, title: clipName)
try cube.write(toFile: path, atomically: true, encoding: .utf8)
return true
}
}