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:
parent
8f805c1249
commit
e32ee6eced
3 changed files with 334 additions and 1 deletions
134
Sources/ForgeExport/IngestMatcher.swift
Normal file
134
Sources/ForgeExport/IngestMatcher.swift
Normal 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 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
145
Tests/ForgeExportTests/IngestMatchTests.swift
Normal file
145
Tests/ForgeExportTests/IngestMatchTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue