134 lines
5.1 KiB
Swift
134 lines
5.1 KiB
Swift
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 Livegrade→Silverstack "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
|
|
}
|
|
}
|