export: IngestMatcher — look-match bridge, media↔shot by clip name, .cdl/.cube sidecars, unmatched/missing reporting; forge ingest-match CLI — 7 tests

This commit is contained in:
Forge Dev 2026-07-11 19:39:08 +00:00
parent 8f805c1249
commit e32ee6eced
3 changed files with 334 additions and 1 deletions

View file

@ -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 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
}
}

View file

@ -15,7 +15,61 @@ struct Forge: AsyncParsableCommand {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
commandName: "forge", commandName: "forge",
abstract: "On-set live grading + DIT toolkit: camera look push, clip logging, offload, proxies.", 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 { struct Proxy: AsyncParsableCommand {

View file

@ -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"))
}
}