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 = [ "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() 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 } }