From 80c5ab0604d7fceeaeb3e4d7d96174b812fb592c Mon Sep 17 00:00:00 2001 From: Forge Dev Date: Fri, 10 Jul 2026 19:46:42 +0000 Subject: [PATCH] =?UTF-8?q?export:=20ALE/CSV/EDL=20writers=20=E2=80=94=20C?= =?UTF-8?q?MX3600=20w/=20ASC=5FSOP+ASC=5FSAT=20comments,=20NDF=20timecode?= =?UTF-8?q?=20math,=20CSV=20escaping=20=E2=80=94=205=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ForgeExport/PostExport.swift | 124 +++++++++++++++++++ Sources/ForgeExport/Timecode.swift | 39 ++++++ Tests/ForgeExportTests/PostExportTests.swift | 99 +++++++++++++++ 3 files changed, 262 insertions(+) create mode 100644 Sources/ForgeExport/PostExport.swift create mode 100644 Sources/ForgeExport/Timecode.swift create mode 100644 Tests/ForgeExportTests/PostExportTests.swift diff --git a/Sources/ForgeExport/PostExport.swift b/Sources/ForgeExport/PostExport.swift new file mode 100644 index 0000000..0d4e036 --- /dev/null +++ b/Sources/ForgeExport/PostExport.swift @@ -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 + } +} diff --git a/Sources/ForgeExport/Timecode.swift b/Sources/ForgeExport/Timecode.swift new file mode 100644 index 0000000..5c86fa1 --- /dev/null +++ b/Sources/ForgeExport/Timecode.swift @@ -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) + } +} diff --git a/Tests/ForgeExportTests/PostExportTests.swift b/Tests/ForgeExportTests/PostExportTests.swift new file mode 100644 index 0000000..37324e6 --- /dev/null +++ b/Tests/ForgeExportTests/PostExportTests.swift @@ -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)) + } +}