diff --git a/Sources/ForgeExport/IngestMatcher.swift b/Sources/ForgeExport/IngestMatcher.swift new file mode 100644 index 0000000..d02c9d8 --- /dev/null +++ b/Sources/ForgeExport/IngestMatcher.swift @@ -0,0 +1,134 @@ +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 + } +} diff --git a/Sources/forge-cli/Main.swift b/Sources/forge-cli/Main.swift index 5b57b8f..6f7bea0 100644 --- a/Sources/forge-cli/Main.swift +++ b/Sources/forge-cli/Main.swift @@ -15,7 +15,61 @@ struct Forge: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "forge", abstract: "On-set live grading + DIT toolkit: camera look push, clip logging, offload, proxies.", - subcommands: [Push.self, Status.self, Sim.self, Export.self, Offload.self, Verify.self, Proxy.self]) + subcommands: [Push.self, Status.self, Sim.self, Export.self, Offload.self, Verify.self, Proxy.self, IngestMatch.self]) +} + +struct IngestMatch: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "ingest-match", + abstract: "Match offloaded media to logged shots by clip name; write grade sidecars (.cdl/.cube) beside each clip.") + + @Argument(help: "Media root (offloaded card / RAID folder)") + var media: String + + @Option(name: .long, help: "Library database path") + var db: String + + @Option(name: .long, help: "Day label") + var day: String + + @Flag(name: .long, help: "Skip .cdl sidecars") + var noCdl = false + + @Flag(name: .long, help: "Skip .cube sidecars") + var noCube = false + + @Option(name: .long, help: "Cube lattice size") + var lattice: Int = 33 + + @Flag(name: .long, help: "Overwrite existing sidecars") + var force = false + + func run() async throws { + let store = try LibraryStore(path: db) + var target: ForgeLibrary.Day? + for p in try store.listProjects() { + if let d = try store.listDays(projectID: p.id).first(where: { $0.label == day }) { + target = d + break + } + } + guard let target else { throw ValidationError("day '\(day)' not found in \(db)") } + + let result = try IngestMatcher.match( + mediaRoot: media, store: store, dayID: target.id, + options: .init(writeCDL: !noCdl, writeCube: !noCube, latticeSize: lattice, force: force)) + + let withSidecars = result.matched.filter(\.sidecarsWritten).count + print("matched \(result.matched.count) clips (\(withSidecars) sidecars written)") + if !result.unmatchedMedia.isEmpty { + print("unmatched media (\(result.unmatchedMedia.count)):") + for m in result.unmatchedMedia { print(" \(m)") } + } + if !result.shotsWithoutMedia.isEmpty { + print("shots without media (\(result.shotsWithoutMedia.count)):") + for s in result.shotsWithoutMedia { print(" \(s)") } + } + } } struct Proxy: AsyncParsableCommand { diff --git a/Tests/ForgeExportTests/IngestMatchTests.swift b/Tests/ForgeExportTests/IngestMatchTests.swift new file mode 100644 index 0000000..9680c88 --- /dev/null +++ b/Tests/ForgeExportTests/IngestMatchTests.swift @@ -0,0 +1,145 @@ +import XCTest +import Foundation +import ForgeColor +import ForgeGrade +import ForgeLibrary +@testable import ForgeExport + +final class IngestMatchTests: XCTestCase { + + var mediaDir: String! + var dbPath: String! + var store: LibraryStore! + var day: ForgeLibrary.Day! + + override func setUpWithError() throws { + mediaDir = NSTemporaryDirectory() + "forge-ingest-\(UUID().uuidString)" + try FileManager.default.createDirectory(atPath: mediaDir + "/CLIPS", withIntermediateDirectories: true) + dbPath = NSTemporaryDirectory() + "forge-ingest-\(UUID().uuidString).db" + store = try LibraryStore(path: dbPath) + let p = try store.createProject(name: "P") + day = try store.createDay(projectID: p.id, label: "Day 03", date: "2026-07-11") + } + + override func tearDown() { + try? FileManager.default.removeItem(atPath: mediaDir) + try? FileManager.default.removeItem(atPath: dbPath) + } + + func makeShot(clip: String, withGrade: Bool = true) throws { + var snapshot: Data? + if withGrade { + let stack = GradeStack(nodes: [GradeNode(kind: .cdl(CDL( + slope: SIMD3(1.2, 1.0, 0.9), offset: SIMD3(0.01, 0, 0), + power: .one, saturation: 1.1)))]) + snapshot = try GradeSnapshot.encode(stack) + } + _ = try store.createShot(Shot( + dayID: day.id, clipName: clip, cameraSlot: "A-Cam", + timecodeIn: "10:00:00:00", timecodeOut: "10:01:00:00", + gradeSnapshot: snapshot)) + } + + func makeMedia(_ name: String) throws { + try Data("fake clip".utf8).write(to: URL(fileURLWithPath: mediaDir + "/CLIPS/\(name)")) + } + + // Clip file matches shot by basename -> sidecar .cdl + .cube written next to media. + func testMatchWritesSidecars() throws { + try makeShot(clip: "R001C001") + try makeMedia("R001C001.mov") + + let result = try IngestMatcher.match( + mediaRoot: mediaDir, store: store, dayID: day.id, + options: IngestMatcher.Options(writeCDL: true, writeCube: true, latticeSize: 17)) + + XCTAssertEqual(result.matched.count, 1) + XCTAssertEqual(result.matched[0].clipName, "R001C001") + XCTAssertTrue(FileManager.default.fileExists(atPath: mediaDir + "/CLIPS/R001C001.cdl")) + XCTAssertTrue(FileManager.default.fileExists(atPath: mediaDir + "/CLIPS/R001C001.cube")) + + // CDL sidecar parses back to shot's grade. + let cdlText = try String(contentsOfFile: mediaDir + "/CLIPS/R001C001.cdl", encoding: .utf8) + let parsed = try CDLExport.parseCDL(cdlText) + XCTAssertEqual(parsed.slope.x, 1.2, accuracy: 1e-5) + XCTAssertEqual(parsed.saturation, 1.1, accuracy: 1e-5) + } + + // Case-insensitive + extension-agnostic matching. + func testMatchIsCaseAndExtensionAgnostic() throws { + try makeShot(clip: "R001C002") + try makeMedia("r001c002.MXF") + let result = try IngestMatcher.match( + mediaRoot: mediaDir, store: store, dayID: day.id, + options: .init(writeCDL: true, writeCube: false, latticeSize: 17)) + XCTAssertEqual(result.matched.count, 1) + XCTAssertTrue(FileManager.default.fileExists(atPath: mediaDir + "/CLIPS/r001c002.cdl")) + } + + // Media with no shot record -> unmatched list. + func testUnmatchedMediaListed() throws { + try makeShot(clip: "R001C003") + try makeMedia("R001C003.mov") + try makeMedia("R001C099.mov") // no shot + let result = try IngestMatcher.match( + mediaRoot: mediaDir, store: store, dayID: day.id, + options: .init(writeCDL: false, writeCube: false, latticeSize: 17)) + XCTAssertEqual(result.matched.count, 1) + XCTAssertEqual(result.unmatchedMedia, ["CLIPS/R001C099.mov"]) + } + + // Shot with no media -> missing list. + func testShotsWithoutMediaListed() throws { + try makeShot(clip: "R001C004") + try makeShot(clip: "R001C005") + try makeMedia("R001C004.mov") + let result = try IngestMatcher.match( + mediaRoot: mediaDir, store: store, dayID: day.id, + options: .init(writeCDL: false, writeCube: false, latticeSize: 17)) + XCTAssertEqual(result.shotsWithoutMedia, ["R001C005"]) + } + + // Shot without grade snapshot matches but writes no sidecars. + func testNoGradeNoSidecar() throws { + try makeShot(clip: "R001C006", withGrade: false) + try makeMedia("R001C006.mov") + let result = try IngestMatcher.match( + mediaRoot: mediaDir, store: store, dayID: day.id, + options: .init(writeCDL: true, writeCube: true, latticeSize: 17)) + XCTAssertEqual(result.matched.count, 1) + XCTAssertFalse(result.matched[0].sidecarsWritten) + XCTAssertFalse(FileManager.default.fileExists(atPath: mediaDir + "/CLIPS/R001C006.cdl")) + } + + // Non-media files ignored. + func testNonMediaIgnored() throws { + try makeShot(clip: "R001C007") + try makeMedia("R001C007.mov") + try Data("x".utf8).write(to: URL(fileURLWithPath: mediaDir + "/CLIPS/notes.txt")) + try Data("x".utf8).write(to: URL(fileURLWithPath: mediaDir + "/manifest.mhl")) + let result = try IngestMatcher.match( + mediaRoot: mediaDir, store: store, dayID: day.id, + options: .init(writeCDL: false, writeCube: false, latticeSize: 17)) + XCTAssertEqual(result.matched.count, 1) + XCTAssertTrue(result.unmatchedMedia.isEmpty) + } + + // Existing sidecar not overwritten unless force. + func testExistingSidecarPreserved() throws { + try makeShot(clip: "R001C008") + try makeMedia("R001C008.mov") + try Data("existing".utf8).write(to: URL(fileURLWithPath: mediaDir + "/CLIPS/R001C008.cdl")) + + _ = try IngestMatcher.match( + mediaRoot: mediaDir, store: store, dayID: day.id, + options: .init(writeCDL: true, writeCube: false, latticeSize: 17)) + let content = try String(contentsOfFile: mediaDir + "/CLIPS/R001C008.cdl", encoding: .utf8) + XCTAssertEqual(content, "existing") + + _ = try IngestMatcher.match( + mediaRoot: mediaDir, store: store, dayID: day.id, + options: .init(writeCDL: true, writeCube: false, latticeSize: 17, force: true)) + let after = try String(contentsOfFile: mediaDir + "/CLIPS/R001C008.cdl", encoding: .utf8) + XCTAssertTrue(after.contains("ColorDecisionList")) + } +}