export: ALE/CSV/EDL writers — CMX3600 w/ ASC_SOP+ASC_SAT comments, NDF timecode math, CSV escaping — 5 tests
This commit is contained in:
parent
7395989c26
commit
80c5ab0604
3 changed files with 262 additions and 0 deletions
124
Sources/ForgeExport/PostExport.swift
Normal file
124
Sources/ForgeExport/PostExport.swift
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import Foundation
|
||||
import ForgeColor
|
||||
import ForgeGrade
|
||||
import ForgeLibrary
|
||||
|
||||
/// Post-handoff writers: ALE, CSV, CMX3600 EDL with ASC CDL comments.
|
||||
public enum PostExport {
|
||||
|
||||
private static func f6(_ v: Float) -> String {
|
||||
String(format: "%.6f", v)
|
||||
}
|
||||
|
||||
/// First CDL node from a shot's grade snapshot, if any.
|
||||
static func cdl(from shot: Shot) -> CDL? {
|
||||
guard let data = shot.gradeSnapshot,
|
||||
let stack = try? GradeSnapshot.decode(data) else { return nil }
|
||||
for node in stack.nodes {
|
||||
if case .cdl(let c) = node.kind, node.isEnabled {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func ascSOP(_ c: CDL) -> String {
|
||||
"(\(f6(c.slope.x)) \(f6(c.slope.y)) \(f6(c.slope.z)))"
|
||||
+ "(\(f6(c.offset.x)) \(f6(c.offset.y)) \(f6(c.offset.z)))"
|
||||
+ "(\(f6(c.power.x)) \(f6(c.power.y)) \(f6(c.power.z)))"
|
||||
}
|
||||
|
||||
// MARK: ALE
|
||||
|
||||
public static func writeALE(shots: [Shot], fps: Int) -> String {
|
||||
var out = """
|
||||
Heading
|
||||
FIELD_DELIM\tTABS
|
||||
VIDEO_FORMAT\t1080
|
||||
FPS\t\(fps)
|
||||
|
||||
Column
|
||||
Name\tStart\tEnd\tCamera\tASC_SOP\tASC_SAT\tEI\tWB\tTint\tFPS
|
||||
|
||||
Data
|
||||
|
||||
"""
|
||||
for s in shots {
|
||||
let c = cdl(from: s)
|
||||
var row = [String]()
|
||||
row.append(s.clipName)
|
||||
row.append(s.timecodeIn ?? "")
|
||||
row.append(s.timecodeOut ?? "")
|
||||
row.append(s.cameraSlot)
|
||||
row.append(c.map(ascSOP) ?? "")
|
||||
row.append(c.map { f6($0.saturation) } ?? "")
|
||||
row.append(s.exposureIndex.map(String.init) ?? "")
|
||||
row.append(s.whiteBalance.map(String.init) ?? "")
|
||||
row.append(s.tint.map(String.init) ?? "")
|
||||
row.append(s.fps.map { String(format: "%g", $0) } ?? "")
|
||||
out += row.joined(separator: "\t") + "\n"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: CSV
|
||||
|
||||
private static func csvEscape(_ s: String) -> String {
|
||||
if s.contains(",") || s.contains("\"") || s.contains("\n") {
|
||||
return "\"" + s.replacingOccurrences(of: "\"", with: "\"\"") + "\""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
public static func writeCSV(shots: [Shot]) -> String {
|
||||
var out = "clip_name,camera_slot,tc_in,tc_out,ei,wb,tint,fps,asc_sop,asc_sat\n"
|
||||
for s in shots {
|
||||
let c = cdl(from: s)
|
||||
var row = [String]()
|
||||
row.append(s.clipName)
|
||||
row.append(s.cameraSlot)
|
||||
row.append(s.timecodeIn ?? "")
|
||||
row.append(s.timecodeOut ?? "")
|
||||
row.append(s.exposureIndex.map(String.init) ?? "")
|
||||
row.append(s.whiteBalance.map(String.init) ?? "")
|
||||
row.append(s.tint.map(String.init) ?? "")
|
||||
row.append(s.fps.map { String(format: "%g", $0) } ?? "")
|
||||
row.append(c.map(ascSOP) ?? "")
|
||||
row.append(c.map { f6($0.saturation) } ?? "")
|
||||
out += row.map(csvEscape).joined(separator: ",") + "\n"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: EDL (CMX3600 + CDL comments)
|
||||
|
||||
public static func writeEDL(shots: [Shot], title: String, fps: Int) -> String {
|
||||
var out = "TITLE: \(title)\nFCM: NON-DROP FRAME\n\n"
|
||||
var recordTC = Timecode.parse("01:00:00:00", fps: fps)!
|
||||
var event = 1
|
||||
|
||||
for s in shots {
|
||||
guard let srcIn = s.timecodeIn.flatMap({ Timecode.parse($0, fps: fps) }),
|
||||
let srcOut = s.timecodeOut.flatMap({ Timecode.parse($0, fps: fps) }),
|
||||
srcOut.frames > srcIn.frames else { continue }
|
||||
let duration = srcOut - srcIn
|
||||
let recIn = recordTC
|
||||
let recOut = recordTC + duration
|
||||
|
||||
// Reel column: clip name truncated to CMX-friendly 8+ (modern EDLs allow long names).
|
||||
out += String(format: "%03d %@ V C %@ %@ %@ %@\n",
|
||||
event, s.clipName, srcIn.description, srcOut.description,
|
||||
recIn.description, recOut.description)
|
||||
out += "* FROM CLIP NAME: \(s.clipName)\n"
|
||||
if let c = cdl(from: s) {
|
||||
out += "* ASC_SOP \(ascSOP(c))\n"
|
||||
out += "* ASC_SAT \(f6(c.saturation))\n"
|
||||
}
|
||||
out += "\n"
|
||||
|
||||
recordTC = recOut
|
||||
event += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
39
Sources/ForgeExport/Timecode.swift
Normal file
39
Sources/ForgeExport/Timecode.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import Foundation
|
||||
|
||||
/// Non-drop-frame timecode. Frame count + fps.
|
||||
public struct Timecode: Sendable, Equatable, CustomStringConvertible {
|
||||
public var frames: Int
|
||||
public var fps: Int
|
||||
|
||||
public init(frames: Int, fps: Int) {
|
||||
self.frames = frames
|
||||
self.fps = fps
|
||||
}
|
||||
|
||||
/// Parse HH:MM:SS:FF.
|
||||
public static func parse(_ s: String, fps: Int) -> Timecode? {
|
||||
let parts = s.split(separator: ":")
|
||||
guard parts.count == 4,
|
||||
let h = Int(parts[0]), let m = Int(parts[1]),
|
||||
let sec = Int(parts[2]), let f = Int(parts[3]),
|
||||
m < 60, sec < 60, f < fps else { return nil }
|
||||
return Timecode(frames: ((h * 3600 + m * 60 + sec) * fps) + f, fps: fps)
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
let totalSeconds = frames / fps
|
||||
let f = frames % fps
|
||||
let h = totalSeconds / 3600
|
||||
let m = (totalSeconds % 3600) / 60
|
||||
let s = totalSeconds % 60
|
||||
return String(format: "%02d:%02d:%02d:%02d", h, m, s, f)
|
||||
}
|
||||
|
||||
public static func + (l: Timecode, r: Timecode) -> Timecode {
|
||||
Timecode(frames: l.frames + r.frames, fps: l.fps)
|
||||
}
|
||||
|
||||
public static func - (l: Timecode, r: Timecode) -> Timecode {
|
||||
Timecode(frames: max(0, l.frames - r.frames), fps: l.fps)
|
||||
}
|
||||
}
|
||||
99
Tests/ForgeExportTests/PostExportTests.swift
Normal file
99
Tests/ForgeExportTests/PostExportTests.swift
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import XCTest
|
||||
import Foundation
|
||||
import ForgeColor
|
||||
import ForgeGrade
|
||||
import ForgeLibrary
|
||||
@testable import ForgeExport
|
||||
|
||||
final class PostExportTests: XCTestCase {
|
||||
|
||||
func makeShots() throws -> [Shot] {
|
||||
let dayID = UUID()
|
||||
let cdl = CDL(slope: SIMD3(1.2, 1.0, 0.85), offset: SIMD3(0.01, -0.02, 0), power: SIMD3(0.95, 1, 1.1), saturation: 1.25)
|
||||
let snapshot = try GradeSnapshot.encode(GradeStack(nodes: [GradeNode(kind: .cdl(cdl))]))
|
||||
return [
|
||||
Shot(dayID: dayID, clipName: "A001C001", cameraSlot: "A-Cam",
|
||||
timecodeIn: "10:00:00:00", timecodeOut: "10:00:30:00",
|
||||
exposureIndex: 800, whiteBalance: 5600, tint: 0, fps: 24,
|
||||
gradeSnapshot: snapshot),
|
||||
Shot(dayID: dayID, clipName: "A001C002", cameraSlot: "A-Cam",
|
||||
timecodeIn: "10:05:00:00", timecodeOut: "10:06:00:00",
|
||||
exposureIndex: 1280, whiteBalance: 3200, tint: -2, fps: 24,
|
||||
gradeSnapshot: snapshot),
|
||||
]
|
||||
}
|
||||
|
||||
// ALE: header block + tab-delimited columns.
|
||||
func testALEGolden() throws {
|
||||
let shots = try makeShots()
|
||||
let ale = PostExport.writeALE(shots: shots, fps: 24)
|
||||
let lines = ale.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
|
||||
XCTAssertEqual(lines[0], "Heading")
|
||||
XCTAssertTrue(ale.contains("FIELD_DELIM\tTABS"))
|
||||
XCTAssertTrue(ale.contains("FPS\t24"))
|
||||
XCTAssertTrue(ale.contains("Column"))
|
||||
// Column row includes standard + metadata fields.
|
||||
let columnLine = lines[lines.firstIndex(of: "Column")! + 1]
|
||||
XCTAssertEqual(columnLine, "Name\tStart\tEnd\tCamera\tASC_SOP\tASC_SAT\tEI\tWB\tTint\tFPS")
|
||||
// Data rows under "Data".
|
||||
let dataIdx = lines.firstIndex(of: "Data")!
|
||||
let row1 = lines[dataIdx + 1].split(separator: "\t").map(String.init)
|
||||
XCTAssertEqual(row1[0], "A001C001")
|
||||
XCTAssertEqual(row1[1], "10:00:00:00")
|
||||
XCTAssertEqual(row1[2], "10:00:30:00")
|
||||
XCTAssertEqual(row1[3], "A-Cam")
|
||||
XCTAssertEqual(row1[4], "(1.200000 1.000000 0.850000)(0.010000 -0.020000 0.000000)(0.950000 1.000000 1.100000)")
|
||||
XCTAssertEqual(row1[5], "1.250000")
|
||||
XCTAssertEqual(row1[6], "800")
|
||||
}
|
||||
|
||||
// CSV with proper escaping.
|
||||
func testCSVEscaping() throws {
|
||||
var shots = try makeShots()
|
||||
shots[0].clipName = "clip,with\"quotes\""
|
||||
let csv = PostExport.writeCSV(shots: shots)
|
||||
let lines = csv.split(separator: "\n").map(String.init)
|
||||
XCTAssertEqual(lines[0], "clip_name,camera_slot,tc_in,tc_out,ei,wb,tint,fps,asc_sop,asc_sat")
|
||||
XCTAssertTrue(lines[1].hasPrefix("\"clip,with\"\"quotes\"\"\","))
|
||||
}
|
||||
|
||||
// CMX3600 EDL with ASC_SOP/ASC_SAT comments.
|
||||
func testEDLGolden() throws {
|
||||
let shots = try makeShots()
|
||||
let edl = PostExport.writeEDL(shots: shots, title: "DAY01", fps: 24)
|
||||
let lines = edl.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
|
||||
XCTAssertEqual(lines[0], "TITLE: DAY01")
|
||||
XCTAssertEqual(lines[1], "FCM: NON-DROP FRAME")
|
||||
// Event 001: record TC builds sequentially from 01:00:00:00.
|
||||
XCTAssertTrue(edl.contains("001 A001C001 V C 10:00:00:00 10:00:30:00 01:00:00:00 01:00:30:00"))
|
||||
XCTAssertTrue(edl.contains("* FROM CLIP NAME: A001C001"))
|
||||
XCTAssertTrue(edl.contains("* ASC_SOP (1.200000 1.000000 0.850000)(0.010000 -0.020000 0.000000)(0.950000 1.000000 1.100000)"))
|
||||
XCTAssertTrue(edl.contains("* ASC_SAT 1.250000"))
|
||||
// Event 002 record-in continues from event 001 record-out.
|
||||
XCTAssertTrue(edl.contains("002 A001C002 V C 10:05:00:00 10:06:00:00 01:00:30:00 01:01:30:00"))
|
||||
}
|
||||
|
||||
// Shots without CDL node get no ASC lines but stay in EDL.
|
||||
func testEDLNoGradeStillListed() throws {
|
||||
var shots = try makeShots()
|
||||
shots[1].gradeSnapshot = nil
|
||||
let edl = PostExport.writeEDL(shots: shots, title: "D", fps: 24)
|
||||
XCTAssertTrue(edl.contains("002 A001C002"))
|
||||
// Only one ASC_SOP line (for shot 1).
|
||||
let count = edl.components(separatedBy: "* ASC_SOP").count - 1
|
||||
XCTAssertEqual(count, 1)
|
||||
}
|
||||
|
||||
// Timecode math: duration/offset arithmetic.
|
||||
func testTimecodeMath() {
|
||||
XCTAssertEqual(Timecode.parse("10:00:30:12", fps: 24)?.frames, ((10 * 3600 + 30) * 24) + 12)
|
||||
let tc = Timecode(frames: 90000, fps: 24)
|
||||
XCTAssertEqual(tc.description, "01:02:30:00")
|
||||
// add
|
||||
let sum = Timecode(frames: 10, fps: 24) + Timecode(frames: 20, fps: 24)
|
||||
XCTAssertEqual(sum.frames, 30)
|
||||
// invalid strings
|
||||
XCTAssertNil(Timecode.parse("bogus", fps: 24))
|
||||
XCTAssertNil(Timecode.parse("10:00:00", fps: 24))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue