Compare commits

..

No commits in common. "e1c1491cdda161b0782363406554cd11d91f5e02" and "74d12878566c391d540cccfb335a90360ba948ba" have entirely different histories.

30 changed files with 26 additions and 2386 deletions

View file

@ -1,69 +0,0 @@
import Foundation
import ForgeGrade
/// Rate-limits camera look pushes. Preview updates instantly elsewhere;
/// camera gets at most one push per interval, trailing edge guaranteed
/// final panel position always lands on camera.
public actor DebouncedPusher {
private let driver: any CameraDriver
private let interval: Duration
private let latticeSize: Int
private var latestGrade: GradeStack?
private var lastPushAt: ContinuousClock.Instant?
private var pending: Task<Void, Never>?
private var stopped = false
public init(driver: any CameraDriver, interval: Duration = .milliseconds(150), latticeSize: Int = 33) {
self.driver = driver
self.interval = interval
self.latticeSize = latticeSize
}
/// Record new grade; push now if interval elapsed, else schedule trailing push.
public func update(grade: GradeStack) {
guard !stopped else { return }
latestGrade = grade
let now = ContinuousClock.now
if let last = lastPushAt, last.duration(to: now) < interval {
scheduleTrailing(after: interval - last.duration(to: now))
} else {
pushNow()
}
}
private func scheduleTrailing(after delay: Duration) {
guard pending == nil else { return } // one trailing push covers all updates
pending = Task {
try? await Task.sleep(for: delay)
await self.firePending()
}
}
private func firePending() {
pending = nil
guard !stopped else { return }
pushNow()
}
private func pushNow() {
guard let grade = latestGrade else { return }
lastPushAt = ContinuousClock.now
let caps = driver.capabilities
let look = Flattener.flatten(
stack: grade,
latticeSize: caps.lut3dSizes.contains(latticeSize) ? latticeSize : (caps.lut3dSizes.max() ?? latticeSize),
splitLeadingCDL: caps.supportsNativeCDL)
let driver = self.driver
Task {
try? await driver.push(look: look)
}
}
public func stop() {
stopped = true
pending?.cancel()
pending = nil
}
}

View file

@ -1,133 +0,0 @@
import Foundation
/// Per-file offload result.
public struct FileRecord: Sendable, Equatable {
public let relativePath: String
public let size: UInt64
public let xxh64: String
/// Destination copy verified by post-copy re-read.
public let verified: Bool
/// Already present + identical at all destinations.
public let skipped: Bool
}
/// Offload job report.
public struct OffloadReport: Sendable {
public let source: String
public let destinations: [String]
public let files: [FileRecord]
public let totalBytes: UInt64
public let duration: TimeInterval
public let startedAt: Date
}
/// Verified multi-destination copy: source hashed while copying, destinations
/// re-read + hashed after write. Never silently overwrites mismatched files.
public struct CopyEngine {
public init() {}
/// Copy source tree to all destinations.
/// - overwritePartial: re-copy destination files whose *size* differs
/// (interrupted copy detection). Size-matching mismatched hashes always error.
public func offload(
source: String,
destinations: [String],
overwritePartial: Bool = false
) throws -> OffloadReport {
let fm = FileManager.default
var isDir: ObjCBool = false
guard fm.fileExists(atPath: source, isDirectory: &isDir), isDir.boolValue else {
throw OffloadError.fileNotFound(source)
}
let start = Date()
let clock = ContinuousClock.now
let files = try listFiles(root: source).sorted()
var records = [FileRecord]()
var totalBytes: UInt64 = 0
for rel in files {
let srcPath = source + "/" + rel
let srcHash = try FileHasher.xxh64(path: srcPath)
let attrs = try fm.attributesOfItem(atPath: srcPath)
let size = attrs[.size] as? UInt64 ?? 0
totalBytes += size
var anyCopied = false
for dest in destinations {
let dstPath = dest + "/" + rel
let dstDir = (dstPath as NSString).deletingLastPathComponent
try fm.createDirectory(atPath: dstDir, withIntermediateDirectories: true)
if fm.fileExists(atPath: dstPath) {
let dstAttrs = try fm.attributesOfItem(atPath: dstPath)
let dstSize = dstAttrs[.size] as? UInt64 ?? 0
if dstSize != size {
// Partial/interrupted copy.
if overwritePartial {
try fm.removeItem(atPath: dstPath)
} else {
throw OffloadError.destinationConflict(
"\(dstPath): size \(dstSize) != source \(size) (partial? rerun with overwritePartial)")
}
} else {
let dstHash = try FileHasher.xxh64(path: dstPath)
if dstHash == srcHash {
continue // identical skip
}
throw OffloadError.destinationConflict(
"\(dstPath): exists with different content (hash \(dstHash) != \(srcHash))")
}
}
// Copy via temp + atomic rename, then verify by re-read.
let tmpPath = dstPath + ".forgetmp"
try? fm.removeItem(atPath: tmpPath)
try fm.copyItem(atPath: srcPath, toPath: tmpPath)
let copiedHash = try FileHasher.xxh64(path: tmpPath)
guard copiedHash == srcHash else {
try? fm.removeItem(atPath: tmpPath)
throw OffloadError.verifyFailed(path: dstPath, expected: srcHash, actual: copiedHash)
}
_ = try? fm.replaceItemAt(URL(fileURLWithPath: dstPath), withItemAt: URL(fileURLWithPath: tmpPath))
if !fm.fileExists(atPath: dstPath) {
// replaceItemAt fallback (some Linux Foundation builds).
try fm.moveItem(atPath: tmpPath, toPath: dstPath)
}
anyCopied = true
}
records.append(FileRecord(
relativePath: rel,
size: size,
xxh64: srcHash,
verified: true,
skipped: !anyCopied))
}
return OffloadReport(
source: source,
destinations: destinations,
files: records,
totalBytes: totalBytes,
duration: max(Double(clock.duration(to: .now).components.seconds) +
Double(clock.duration(to: .now).components.attoseconds) / 1e18, 0.000001),
startedAt: start)
}
/// Relative paths of all regular files under root.
func listFiles(root: String) throws -> [String] {
let fm = FileManager.default
guard let enumerator = fm.enumerator(atPath: root) else {
throw OffloadError.readFailed(root)
}
var out = [String]()
while let rel = enumerator.nextObject() as? String {
var isDir: ObjCBool = false
if fm.fileExists(atPath: root + "/" + rel, isDirectory: &isDir), !isDir.boolValue {
out.append(rel)
}
}
return out
}
}

View file

@ -1,37 +0,0 @@
import Foundation
public enum OffloadError: Error, Equatable {
case fileNotFound(String)
case readFailed(String)
case writeFailed(String)
case verifyFailed(path: String, expected: String, actual: String)
case destinationConflict(String)
}
/// Chunked file hashing constant memory over multi-GB camera files.
public enum FileHasher {
static let chunkSize = 4 * 1024 * 1024
public static func xxh64(path: String) throws -> String {
var hasher = XXHash64()
try stream(path: path) { hasher.update($0) }
return XXHash64.hexString(hasher.finalize())
}
public static func md5(path: String) throws -> String {
var hasher = MD5()
try stream(path: path) { hasher.update($0) }
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
private static func stream(path: String, _ body: (Data) -> Void) throws {
guard let handle = FileHandle(forReadingAtPath: path) else {
throw OffloadError.fileNotFound(path)
}
defer { try? handle.close() }
while true {
guard let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty else { break }
body(chunk)
}
}
}

View file

@ -0,0 +1 @@
// ForgeOffload

View file

@ -1,108 +0,0 @@
import Foundation
/// MD5 (RFC 1321) pure Swift. Legacy-post-workflow compatibility only;
/// integrity verification uses xxHash64.
public struct MD5 {
private var a0: UInt32 = 0x67452301
private var b0: UInt32 = 0xefcdab89
private var c0: UInt32 = 0x98badcfe
private var d0: UInt32 = 0x10325476
private var buffer = [UInt8]()
private var totalLength: UInt64 = 0
private static let s: [UInt32] = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
]
private static let k: [UInt32] = (0..<64).map {
UInt32(truncatingIfNeeded: Int64(abs(sin(Double($0 + 1))) * 4294967296.0))
}
public init() {}
public mutating func update(_ data: Data) {
totalLength += UInt64(data.count)
buffer.append(contentsOf: data)
var offset = 0
while buffer.count - offset >= 64 {
processBlock(Array(buffer[offset..<offset + 64]))
offset += 64
}
if offset > 0 {
buffer.removeFirst(offset)
}
}
private mutating func processBlock(_ block: [UInt8]) {
var m = [UInt32](repeating: 0, count: 16)
for i in 0..<16 {
m[i] = UInt32(block[i * 4])
| (UInt32(block[i * 4 + 1]) << 8)
| (UInt32(block[i * 4 + 2]) << 16)
| (UInt32(block[i * 4 + 3]) << 24)
}
var a = a0, b = b0, c = c0, d = d0
for i in 0..<64 {
var f: UInt32
var g: Int
switch i {
case 0..<16:
f = (b & c) | (~b & d)
g = i
case 16..<32:
f = (d & b) | (~d & c)
g = (5 * i + 1) % 16
case 32..<48:
f = b ^ c ^ d
g = (3 * i + 5) % 16
default:
f = c ^ (b | ~d)
g = (7 * i) % 16
}
f = f &+ a &+ Self.k[i] &+ m[g]
a = d
d = c
c = b
b = b &+ ((f << Self.s[i]) | (f >> (32 - Self.s[i])))
}
a0 = a0 &+ a
b0 = b0 &+ b
c0 = c0 &+ c
d0 = d0 &+ d
}
public mutating func finalize() -> Data {
let bitLength = totalLength * 8
// Padding: 0x80 then zeros to 56 mod 64, then length LE64.
var padding: [UInt8] = [0x80]
let remainder = (buffer.count + 1) % 64
let zeros = remainder <= 56 ? 56 - remainder : 120 - remainder
padding.append(contentsOf: [UInt8](repeating: 0, count: zeros))
var lenLE = bitLength.littleEndian
withUnsafeBytes(of: &lenLE) { padding.append(contentsOf: $0) }
buffer.append(contentsOf: padding)
var offset = 0
while buffer.count - offset >= 64 {
processBlock(Array(buffer[offset..<offset + 64]))
offset += 64
}
buffer.removeAll()
var out = Data()
for v in [a0, b0, c0, d0] {
var le = v.littleEndian
withUnsafeBytes(of: &le) { out.append(contentsOf: $0) }
}
return out
}
public static func hexString(_ data: Data) -> String {
var md5 = MD5()
md5.update(data)
return md5.finalize().map { String(format: "%02x", $0) }.joined()
}
}

View file

@ -1,135 +0,0 @@
import Foundation
/// Media Hash List (ASC MHL-style) manifest: per-file path/size/xxh64.
/// Structure follows MHL v2 hashlist shape; full ASC MHL chain/directory
/// hashes can layer on later without breaking this format.
public enum MHL {
public struct Failure: Equatable, Sendable {
public enum Reason: Equatable, Sendable {
case missing
case hashMismatch
case sizeMismatch
}
public let path: String
public let reason: Reason
}
public struct VerifyResult: Sendable {
public let checked: Int
public let failures: [Failure]
public var passed: Bool { failures.isEmpty }
}
static func escape(_ s: String) -> String {
s.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
static func unescape(_ s: String) -> String {
s.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&amp;", with: "&")
}
/// Build manifest XML for files (relative paths) under root.
public static func generate(root: String, creator: String, files: [String]) throws -> String {
let fm = FileManager.default
let dateFormatter = ISO8601DateFormatter()
var out = """
<?xml version="1.0" encoding="UTF-8"?>
<hashlist version="2.0">
<creatorinfo>
<creationdate>\(dateFormatter.string(from: Date()))</creationdate>
<tool>\(escape(creator))</tool>
</creatorinfo>
<hashes>
"""
for rel in files.sorted() {
let full = root + "/" + rel
guard fm.fileExists(atPath: full) else {
throw OffloadError.fileNotFound(full)
}
let size = (try fm.attributesOfItem(atPath: full)[.size] as? UInt64) ?? 0
let hash = try FileHasher.xxh64(path: full)
out += """
<hash>
<path>\(escape(rel))</path>
<size>\(size)</size>
<xxh64>\(hash)</xxh64>
</hash>
"""
}
out += " </hashes>\n</hashlist>\n"
return out
}
/// Write manifest to file.
public static func write(root: String, creator: String, files: [String], to path: String) throws {
let xml = try generate(root: root, creator: creator, files: files)
try xml.write(toFile: path, atomically: true, encoding: .utf8)
}
/// Verify manifest against tree at root.
public static func verify(manifest: String, root: String) throws -> VerifyResult {
let entries = try parse(manifest)
let fm = FileManager.default
var failures = [Failure]()
for e in entries {
let full = root + "/" + e.path
guard fm.fileExists(atPath: full) else {
failures.append(Failure(path: e.path, reason: .missing))
continue
}
let size = (try fm.attributesOfItem(atPath: full)[.size] as? UInt64) ?? 0
if size != e.size {
failures.append(Failure(path: e.path, reason: .sizeMismatch))
continue
}
let hash = try FileHasher.xxh64(path: full)
if hash != e.xxh64 {
failures.append(Failure(path: e.path, reason: .hashMismatch))
}
}
return VerifyResult(checked: entries.count, failures: failures)
}
struct Entry {
let path: String
let size: UInt64
let xxh64: String
}
/// Scanner-based parse of <hash> blocks.
static func parse(_ xml: String) throws -> [Entry] {
guard xml.contains("<hashlist") else {
throw OffloadError.readFailed("not an MHL manifest")
}
var entries = [Entry]()
var searchRange = xml.startIndex..<xml.endIndex
while let blockStart = xml.range(of: "<hash>", range: searchRange),
let blockEnd = xml.range(of: "</hash>", range: blockStart.upperBound..<xml.endIndex) {
let block = String(xml[blockStart.upperBound..<blockEnd.lowerBound])
func field(_ tag: String) -> String? {
guard let open = block.range(of: "<\(tag)>"),
let close = block.range(of: "</\(tag)>"),
open.upperBound <= close.lowerBound else { return nil }
return String(block[open.upperBound..<close.lowerBound])
}
guard let path = field("path"),
let sizeStr = field("size"), let size = UInt64(sizeStr),
let hash = field("xxh64") else {
throw OffloadError.readFailed("malformed <hash> entry")
}
entries.append(Entry(path: unescape(path), size: size, xxh64: hash))
searchRange = blockEnd.upperBound..<xml.endIndex
}
guard !entries.isEmpty else {
throw OffloadError.readFailed("no hash entries in manifest")
}
return entries
}
}

View file

@ -1,50 +0,0 @@
import Foundation
/// Offload report renderers: CSV (machine) + HTML (producer handoff).
public enum OffloadReportWriter {
public static func csv(_ report: OffloadReport) -> String {
var out = "relative_path,size,xxh64,verified,skipped\n"
for f in report.files {
out += "\(f.relativePath),\(f.size),\(f.xxh64),\(f.verified),\(f.skipped)\n"
}
return out
}
public static func formatBytes(_ bytes: UInt64) -> String {
let mb = Double(bytes) / 1_000_000
if mb >= 1000 {
return String(format: "%.2f GB", mb / 1000)
}
return String(format: "%.1f MB", mb)
}
public static func html(_ report: OffloadReport) -> String {
let allVerified = report.files.allSatisfy(\.verified)
var rows = ""
for f in report.files {
rows += """
<tr><td>\(f.relativePath)</td><td>\(formatBytes(f.size))</td>\
<td><code>\(f.xxh64)</code></td>\
<td>\(f.verified ? "OK" : "FAIL")</td>\
<td>\(f.skipped ? "skipped" : "copied")</td></tr>\n
"""
}
return """
<html>
<head><title>Forge Offload Report</title>
<style>body{font-family:sans-serif}table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:4px 8px}</style>
</head>
<body>
<h1>Offload Report \(allVerified ? "VERIFIED" : "FAILURES PRESENT")</h1>
<p>Source: \(report.source)</p>
<p>Destinations: \(report.destinations.joined(separator: ", "))</p>
<p>\(report.files.count) files, \(formatBytes(report.totalBytes)), \(String(format: "%.1f", report.duration))s</p>
<table>
<tr><th>File</th><th>Size</th><th>xxHash64</th><th>Verify</th><th>Action</th></tr>
\(rows)</table>
</body>
</html>
"""
}
}

View file

@ -1,126 +0,0 @@
import Foundation
/// xxHash64 (seed 0) pure Swift, streaming. Spec: github.com/Cyan4973/xxHash.
public struct XXHash64 {
private static let p1: UInt64 = 0x9E3779B185EBCA87
private static let p2: UInt64 = 0xC2B2AE3D27D4EB4F
private static let p3: UInt64 = 0x165667B19E3779F9
private static let p4: UInt64 = 0x85EBCA77C2B2AE63
private static let p5: UInt64 = 0x27D4EB2F165667C5
private var v1: UInt64
private var v2: UInt64
private var v3: UInt64
private var v4: UInt64
private var buffer = [UInt8]()
private var totalLength: UInt64 = 0
private let seed: UInt64
public init(seed: UInt64 = 0) {
self.seed = seed
v1 = seed &+ Self.p1 &+ Self.p2
v2 = seed &+ Self.p2
v3 = seed
v4 = seed &- Self.p1
}
@inline(__always)
private static func rotl(_ x: UInt64, _ r: UInt64) -> UInt64 {
(x << r) | (x >> (64 - r))
}
@inline(__always)
private static func round(_ acc: UInt64, _ input: UInt64) -> UInt64 {
rotl(acc &+ input &* p2, 31) &* p1
}
@inline(__always)
private static func mergeRound(_ acc: UInt64, _ val: UInt64) -> UInt64 {
(acc ^ round(0, val)) &* p1 &+ p4
}
@inline(__always)
private static func readLE64(_ bytes: [UInt8], _ offset: Int) -> UInt64 {
var v: UInt64 = 0
for i in 0..<8 {
v |= UInt64(bytes[offset + i]) << (8 * UInt64(i))
}
return v
}
@inline(__always)
private static func readLE32(_ bytes: [UInt8], _ offset: Int) -> UInt64 {
var v: UInt64 = 0
for i in 0..<4 {
v |= UInt64(bytes[offset + i]) << (8 * UInt64(i))
}
return v
}
public mutating func update(_ data: Data) {
totalLength += UInt64(data.count)
buffer.append(contentsOf: data)
// Consume full 32-byte stripes, keep remainder buffered.
var offset = 0
while buffer.count - offset >= 32 {
v1 = Self.round(v1, Self.readLE64(buffer, offset))
v2 = Self.round(v2, Self.readLE64(buffer, offset + 8))
v3 = Self.round(v3, Self.readLE64(buffer, offset + 16))
v4 = Self.round(v4, Self.readLE64(buffer, offset + 24))
offset += 32
}
if offset > 0 {
buffer.removeFirst(offset)
}
}
public func finalize() -> UInt64 {
var h: UInt64
if totalLength >= 32 {
h = Self.rotl(v1, 1) &+ Self.rotl(v2, 7) &+ Self.rotl(v3, 12) &+ Self.rotl(v4, 18)
h = Self.mergeRound(h, v1)
h = Self.mergeRound(h, v2)
h = Self.mergeRound(h, v3)
h = Self.mergeRound(h, v4)
} else {
h = seed &+ Self.p5
}
h &+= totalLength
var offset = 0
let tail = buffer
while tail.count - offset >= 8 {
h ^= Self.round(0, Self.readLE64(tail, offset))
h = Self.rotl(h, 27) &* Self.p1 &+ Self.p4
offset += 8
}
if tail.count - offset >= 4 {
h ^= Self.readLE32(tail, offset) &* Self.p1
h = Self.rotl(h, 23) &* Self.p2 &+ Self.p3
offset += 4
}
while offset < tail.count {
h ^= UInt64(tail[offset]) &* Self.p5
h = Self.rotl(h, 11) &* Self.p1
offset += 1
}
h ^= h >> 33
h &*= Self.p2
h ^= h >> 29
h &*= Self.p3
h ^= h >> 32
return h
}
/// One-shot.
public static func hash(_ data: Data, seed: UInt64 = 0) -> UInt64 {
var h = XXHash64(seed: seed)
h.update(data)
return h.finalize()
}
public static func hexString(_ value: UInt64) -> String {
String(format: "%016lx", value)
}
}

View file

@ -1,176 +0,0 @@
import Foundation
import ForgeColor
/// Tangent Wave control IDs mirror of the panel's control layout.
/// Real IDs assigned by Tangent Hub control map; these are our canonical set,
/// remapped at Hub-registration time in the macOS phase.
public enum TangentControls {
// Trackballs: X/Y per ball. Ball 1 = lift(offset), 2 = gamma(power), 3 = gain(slope).
public static let liftBallX: Int = 101
public static let liftBallY: Int = 102
public static let gammaBallX: Int = 111
public static let gammaBallY: Int = 112
public static let gainBallX: Int = 121
public static let gainBallY: Int = 122
// Rings (master per zone).
public static let liftRing: Int = 103
public static let gammaRing: Int = 113
public static let gainRing: Int = 123
// Knobs.
public static let satKnob: Int = 201
public static let contrastKnob: Int = 202
// Buttons.
public static let bypassButton: Int = 301
public static let grabStillButton: Int = 302
public static let nextSlotButton: Int = 303
public static let prevSlotButton: Int = 304
public static let resetButton: Int = 305
}
/// Panel input event.
public enum PanelInput: Equatable, Sendable {
case encoder(id: Int, delta: Int)
case button(id: Int, pressed: Bool)
}
/// CDL parameter targeted by a panel control.
public enum CDLParam: String, Equatable, Sendable {
case slopeRed, slopeGreen, slopeBlue, slopeMaster
case offsetRed, offsetGreen, offsetBlue, offsetMaster
case powerRed, powerGreen, powerBlue, powerMaster
case saturation
case contrast
}
/// Action produced by mapping a panel input.
public enum PanelAction: Equatable, Sendable {
case adjustCDL(param: CDLParam, amount: Float)
case toggleBypass
case grabStill
case nextSlot
case prevSlot
case resetGrade
}
/// Maps panel controls to actions with per-parameter sensitivity.
public struct ControlMap: Sendable {
public var encoderMap: [Int: CDLParam]
public var buttonMap: [Int: PanelAction]
private var sensitivities: [CDLParam: Float]
public func sensitivity(for param: CDLParam) -> Float {
sensitivities[param] ?? 0.001
}
public func action(for input: PanelInput) -> PanelAction? {
switch input {
case .encoder(let id, let delta):
guard let param = encoderMap[id] else { return nil }
return .adjustCDL(param: param, amount: Float(delta) * sensitivity(for: param))
case .button(let id, let pressed):
guard pressed else { return nil } // act on press only
return buttonMap[id]
}
}
/// Default Wave mapping: balls X->red-ish axis, Y->green/blue axis simplification
/// (full 2D chromatic ball math lands with real panel calibration).
public static let waveDefault = ControlMap(
encoderMap: [
TangentControls.liftBallX: .offsetRed,
TangentControls.liftBallY: .offsetBlue,
TangentControls.liftRing: .offsetMaster,
TangentControls.gammaBallX: .powerRed,
TangentControls.gammaBallY: .powerBlue,
TangentControls.gammaRing: .powerMaster,
TangentControls.gainBallX: .slopeRed,
TangentControls.gainBallY: .slopeBlue,
TangentControls.gainRing: .slopeMaster,
TangentControls.satKnob: .saturation,
TangentControls.contrastKnob: .contrast,
],
buttonMap: [
TangentControls.bypassButton: .toggleBypass,
TangentControls.grabStillButton: .grabStill,
TangentControls.nextSlotButton: .nextSlot,
TangentControls.prevSlotButton: .prevSlot,
TangentControls.resetButton: .resetGrade,
],
sensitivities: [
.slopeRed: 0.002, .slopeGreen: 0.002, .slopeBlue: 0.002, .slopeMaster: 0.002,
.offsetRed: 0.001, .offsetGreen: 0.001, .offsetBlue: 0.001, .offsetMaster: 0.001,
.powerRed: 0.002, .powerGreen: 0.002, .powerBlue: 0.002, .powerMaster: 0.002,
.saturation: 0.002, .contrast: 0.002,
])
public init(encoderMap: [Int: CDLParam], buttonMap: [Int: PanelAction], sensitivities: [CDLParam: Float]) {
self.encoderMap = encoderMap
self.buttonMap = buttonMap
self.sensitivities = sensitivities
}
}
/// Panel-editable grade state (CDL layer the panel drives).
public struct PanelGradeState: Sendable, Equatable {
public var cdl: CDL = .identity
public var bypassed = false
public init() {}
public mutating func apply(_ action: PanelAction) {
switch action {
case .adjustCDL(let param, let amount):
switch param {
case .slopeRed: cdl.slope.x += amount
case .slopeGreen: cdl.slope.y += amount
case .slopeBlue: cdl.slope.z += amount
case .slopeMaster:
cdl.slope += SIMD3(repeating: amount)
case .offsetRed: cdl.offset.x += amount
case .offsetGreen: cdl.offset.y += amount
case .offsetBlue: cdl.offset.z += amount
case .offsetMaster:
cdl.offset += SIMD3(repeating: amount)
case .powerRed: cdl.power.x += amount
case .powerGreen: cdl.power.y += amount
case .powerBlue: cdl.power.z += amount
case .powerMaster:
cdl.power += SIMD3(repeating: amount)
case .saturation:
cdl.saturation = max(0, cdl.saturation + amount)
case .contrast:
// Contrast pivot 0.435: slope up, offset compensates.
cdl.slope += SIMD3(repeating: amount)
cdl.offset -= SIMD3(repeating: amount * 0.435)
}
case .toggleBypass:
bypassed.toggle()
case .resetGrade:
cdl = .identity
bypassed = false
case .grabStill, .nextSlot, .prevSlot:
break // handled by app layer
}
}
/// Short readout string for panel displays.
public func displayText(for param: CDLParam) -> String {
func t(_ v: Float) -> String { String(format: "%.3f", v) }
switch param {
case .slopeRed: return "SlpR \(t(cdl.slope.x))"
case .slopeGreen: return "SlpG \(t(cdl.slope.y))"
case .slopeBlue: return "SlpB \(t(cdl.slope.z))"
case .slopeMaster: return "Slp \(t((cdl.slope.x + cdl.slope.y + cdl.slope.z) / 3))"
case .offsetRed: return "OffR \(t(cdl.offset.x))"
case .offsetGreen: return "OffG \(t(cdl.offset.y))"
case .offsetBlue: return "OffB \(t(cdl.offset.z))"
case .offsetMaster: return "Off \(t((cdl.offset.x + cdl.offset.y + cdl.offset.z) / 3))"
case .powerRed: return "PwrR \(t(cdl.power.x))"
case .powerGreen: return "PwrG \(t(cdl.power.y))"
case .powerBlue: return "PwrB \(t(cdl.power.z))"
case .powerMaster: return "Pwr \(t((cdl.power.x + cdl.power.y + cdl.power.z) / 3))"
case .saturation: return "Sat \(t(cdl.saturation))"
case .contrast: return "Con \(t(cdl.slope.x))"
}
}
}

View file

@ -0,0 +1 @@
// ForgePanel

View file

@ -1,71 +0,0 @@
import Foundation
/// Messages exchanged with the Tangent Hub bridge.
/// Wire framing: u32 little-endian length + JSON payload.
/// NOTE: real Tangent Hub TIPC is a binary protocol on TCP 64246; this JSON
/// framing is our internal bridge format the macOS TIPC shim translates.
/// Framing/parse logic (buffering, partial frames) is identical either way.
public enum TangentMessage: Equatable, Sendable {
case controlUpdate(PanelInput)
case displayUpdate(line: Int, text: String)
}
public enum TangentWireError: Error {
case badPayload
}
public enum TangentWire {
public static func encode(_ msg: TangentMessage) -> Data {
var obj: [String: Any] = [:]
switch msg {
case .controlUpdate(.encoder(let id, let delta)):
obj = ["type": "encoder", "id": id, "delta": delta]
case .controlUpdate(.button(let id, let pressed)):
obj = ["type": "button", "id": id, "pressed": pressed]
case .displayUpdate(let line, let text):
obj = ["type": "display", "line": line, "text": text]
}
let payload = (try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys])) ?? Data()
var out = Data()
var len = UInt32(payload.count).littleEndian
withUnsafeBytes(of: &len) { out.append(contentsOf: $0) }
out.append(payload)
return out
}
/// Decode first complete frame from buffer, consuming it. nil if incomplete.
public static func decodeFirst(_ buffer: inout Data) throws -> TangentMessage? {
guard buffer.count >= 4 else { return nil }
// Byte-wise LE assembly (Data slices may be misaligned for u32 loads).
let b = [UInt8](buffer.prefix(4))
let length = UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24)
guard buffer.count >= 4 + Int(length) else { return nil }
let payload = buffer.subdata(in: buffer.startIndex + 4..<buffer.startIndex + 4 + Int(length))
buffer.removeFirst(4 + Int(length))
guard let obj = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
let type = obj["type"] as? String else {
throw TangentWireError.badPayload
}
switch type {
case "encoder":
guard let id = obj["id"] as? Int, let delta = obj["delta"] as? Int else {
throw TangentWireError.badPayload
}
return .controlUpdate(.encoder(id: id, delta: delta))
case "button":
guard let id = obj["id"] as? Int, let pressed = obj["pressed"] as? Bool else {
throw TangentWireError.badPayload
}
return .controlUpdate(.button(id: id, pressed: pressed))
case "display":
guard let line = obj["line"] as? Int, let text = obj["text"] as? String else {
throw TangentWireError.badPayload
}
return .displayUpdate(line: line, text: text)
default:
throw TangentWireError.badPayload
}
}
}

View file

@ -0,0 +1 @@
// ForgeProxy

View file

@ -1,177 +0,0 @@
import Foundation
import ForgeColor
import ForgeGrade
public enum ProxyError: Error {
case ffmpegFailed(code: Int32, stderr: String)
case ffmpegNotFound(String)
case badSnapshot(String)
}
/// Codec presets.
public enum ProxyPreset: String, Sendable, CaseIterable {
case proresProxy = "prores-proxy"
case dnxhrLB = "dnxhr-lb"
case h264 = "h264"
var codecArgs: [String] {
switch self {
case .proresProxy:
return ["-c:v", "prores_ks", "-profile:v", "0"]
case .dnxhrLB:
return ["-c:v", "dnxhd", "-profile:v", "dnxhr_lb"]
case .h264:
return ["-c:v", "libx264", "-preset", "fast", "-crf", "20"]
}
}
}
public enum ProxyScale: String, Sendable {
case full
case half
case quarter
var filter: String? {
switch self {
case .full: return nil
case .half: return "scale=iw/2:ih/2"
case .quarter: return "scale=iw/4:ih/4"
}
}
}
public struct BurnIn: Sendable {
public let clipName: String
public let timecode: String
public let fps: Int
public init(clipName: String, timecode: String, fps: Int) {
self.clipName = clipName
self.timecode = timecode
self.fps = fps
}
}
/// Assembles ffmpeg argv. Filter order: lut3d (grade) -> scale -> drawtext (burn).
public enum FFmpegCommandBuilder {
/// Escape a path for use inside an ffmpeg filter option value.
static func filterEscape(_ path: String) -> String {
path.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "\\'")
.replacingOccurrences(of: ":", with: "\\:")
}
public static func build(
input: String,
output: String,
preset: ProxyPreset,
scale: ProxyScale,
lutPath: String?,
burnIn: BurnIn?
) -> [String] {
var args = ["-y", "-i", input]
var filters = [String]()
if let lut = lutPath {
filters.append("lut3d='\(filterEscape(lut))'")
}
if let s = scale.filter {
filters.append(s)
}
if let burn = burnIn {
let tcEscaped = burn.timecode.replacingOccurrences(of: ":", with: "\\:")
filters.append(
"drawtext=text='\(burn.clipName)':x=20:y=20:fontsize=24:fontcolor=white:box=1:boxcolor=black@0.5")
filters.append(
"drawtext=timecode='\(tcEscaped)':rate=\(burn.fps):x=20:y=h-40:fontsize=24:fontcolor=white:box=1:boxcolor=black@0.5")
}
if !filters.isEmpty {
args += ["-vf", filters.joined(separator: ",")]
}
args += preset.codecArgs
args += ["-c:a", "copy"]
args.append(output)
return args
}
}
/// Runs ffmpeg as subprocess.
public struct ProxyRunner: Sendable {
public let ffmpegPath: String
public init(ffmpegPath: String = "/usr/bin/ffmpeg") {
self.ffmpegPath = ffmpegPath
}
public func run(args: [String]) async throws {
guard FileManager.default.isExecutableFile(atPath: ffmpegPath) else {
throw ProxyError.ffmpegNotFound(ffmpegPath)
}
let process = Process()
process.executableURL = URL(fileURLWithPath: ffmpegPath)
process.arguments = args
let stderrPipe = Pipe()
process.standardError = stderrPipe
process.standardOutput = Pipe()
try process.run()
// Read stderr concurrently to avoid pipe-buffer deadlock on long output.
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw ProxyError.ffmpegFailed(
code: process.terminationStatus,
stderr: String(data: stderrData, encoding: .utf8) ?? "")
}
}
}
/// One proxy render: optional grade snapshot baked to temp .cube, burned via lut3d.
public struct ProxyJob: Sendable {
public let input: String
public let output: String
public let preset: ProxyPreset
public let scale: ProxyScale
public let gradeSnapshot: Data?
public let burnIn: BurnIn?
public init(input: String, output: String, preset: ProxyPreset, scale: ProxyScale,
gradeSnapshot: Data?, burnIn: BurnIn?) {
self.input = input
self.output = output
self.preset = preset
self.scale = scale
self.gradeSnapshot = gradeSnapshot
self.burnIn = burnIn
}
public func run(ffmpegPath: String, latticeSize: Int = 33) async throws {
var lutPath: String?
if let snapshot = gradeSnapshot {
let stack: GradeStack
do {
stack = try GradeSnapshot.decode(snapshot)
} catch {
throw ProxyError.badSnapshot("\(error)")
}
let look = Flattener.flatten(stack: stack, latticeSize: latticeSize, splitLeadingCDL: false)
if let lut = look.lut {
let path = NSTemporaryDirectory() + "forge-proxy-\(UUID().uuidString).cube"
try CubeFile.write(lut, title: "forge proxy grade")
.write(toFile: path, atomically: true, encoding: .utf8)
lutPath = path
}
}
defer {
if let p = lutPath { try? FileManager.default.removeItem(atPath: p) }
}
let args = FFmpegCommandBuilder.build(
input: input, output: output, preset: preset, scale: scale,
lutPath: lutPath, burnIn: burnIn)
try await ProxyRunner(ffmpegPath: ffmpegPath).run(args: args)
}
}

View file

@ -1,101 +0,0 @@
import Foundation
/// Tracks files already handed off shared across watcher restarts.
public actor ProcessedSkipList {
private var processed = Set<String>()
public init() {}
public func contains(_ path: String) -> Bool {
processed.contains(path)
}
public func mark(_ path: String) {
processed.insert(path)
}
}
/// Polling watch folder. Emits file paths once they are:
/// - matching extension
/// - size-stable for `stabilityChecks` consecutive polls (partial-write guard)
/// - not in the skip list
/// Polling (not inotify): cheap at DIT-cart scale, identical behavior on
/// network volumes where inotify is unreliable.
public actor WatchFolder {
private let path: String
private let extensions: Set<String>
private let pollInterval: Duration
private let stabilityChecks: Int
private let skipList: ProcessedSkipList
private var sizeHistory: [String: (size: UInt64, stableCount: Int)] = [:]
private var task: Task<Void, Never>?
private var continuation: AsyncStream<String>.Continuation?
public init(
path: String,
extensions: [String],
pollInterval: Duration = .seconds(2),
stabilityChecks: Int = 2,
skipList: ProcessedSkipList = ProcessedSkipList()
) {
self.path = path
self.extensions = Set(extensions.map { $0.lowercased() })
self.pollInterval = pollInterval
self.stabilityChecks = stabilityChecks
self.skipList = skipList
}
/// Start watching; returns stream of ready file paths.
public func start() -> AsyncStream<String> {
let stream = AsyncStream<String> { continuation in
self.continuation = continuation
}
task = Task {
while !Task.isCancelled {
await pollOnce()
try? await Task.sleep(for: pollInterval)
}
}
return stream
}
public func stop() {
task?.cancel()
task = nil
continuation?.finish()
continuation = nil
}
private func pollOnce() async {
let fm = FileManager.default
guard let entries = try? fm.contentsOfDirectory(atPath: path) else { return }
for entry in entries {
let ext = (entry as NSString).pathExtension.lowercased()
guard extensions.contains(ext) else { continue }
let full = path + "/" + entry
if await skipList.contains(full) { continue }
guard let attrs = try? fm.attributesOfItem(atPath: full),
let size = attrs[.size] as? UInt64 else { continue }
if let prev = sizeHistory[full] {
if prev.size == size {
let stable = prev.stableCount + 1
if stable >= stabilityChecks {
await skipList.mark(full)
sizeHistory.removeValue(forKey: full)
continuation?.yield(full)
} else {
sizeHistory[full] = (size, stable)
}
} else {
sizeHistory[full] = (size, 0) // still growing
}
} else {
sizeHistory[full] = (size, 0)
}
}
}
}

View file

@ -0,0 +1 @@
// ForgeVideo

View file

@ -1,103 +0,0 @@
import Foundation
import ForgeGrade
import ForgeColor
/// One video frame: linear float RGB pixels, row-major.
public struct VideoFrame: Sendable {
public let width: Int
public let height: Int
public var pixels: [SIMD3<Float>]
public var timecode: String?
public init(width: Int, height: Int, pixels: [SIMD3<Float>], timecode: String? = nil) {
precondition(pixels.count == width * height)
self.width = width
self.height = height
self.pixels = pixels
self.timecode = timecode
}
}
/// Video input seam. DeckLink capture implements this on macOS.
public protocol FrameSource: Actor {
func nextFrame() async throws -> VideoFrame
}
/// Video output seam. DeckLink playback implements this on macOS.
public protocol FrameSink: Actor {
func consume(frame: VideoFrame) async throws
}
/// Solid-color generator for tests/demo.
public actor TestPatternSource: FrameSource {
private let width: Int
private let height: Int
private let color: SIMD3<Float>
private var frameCount = 0
public init(width: Int, height: Int, color: SIMD3<Float>) {
self.width = width
self.height = height
self.color = color
}
public func nextFrame() async throws -> VideoFrame {
frameCount += 1
let f = frameCount % 24
let s = (frameCount / 24) % 60
return VideoFrame(
width: width,
height: height,
pixels: [SIMD3<Float>](repeating: color, count: width * height),
timecode: String(format: "00:00:%02d:%02d", s, f))
}
}
/// Discards frames; counts consumption. SDI-out stand-in for tests.
public actor NullFrameSink: FrameSink {
public private(set) var consumedCount = 0
public init() {}
public func consume(frame: VideoFrame) async throws {
consumedCount += 1
}
}
/// CPU grade application (preview path stand-in; Metal on macOS).
public enum FrameProcessor {
public static func apply(stack: GradeStack, to frame: VideoFrame) -> VideoFrame {
var out = frame
for i in out.pixels.indices {
out.pixels[i] = stack.evaluate(out.pixels[i])
}
return out
}
}
/// Writes frame grabs as binary PPM (P6) dependency-free still format.
public struct StillGrabber: Sendable {
public let directory: String
public init(directory: String) {
self.directory = directory
}
/// Returns written file path.
@discardableResult
public func grab(frame: VideoFrame, clipName: String) throws -> String {
try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
let path = "\(directory)/\(clipName)_\(timestamp).ppm"
var data = Data("P6\n\(frame.width) \(frame.height)\n255\n".utf8)
data.reserveCapacity(data.count + frame.pixels.count * 3)
for px in frame.pixels {
data.append(UInt8(min(max(px.x, 0), 1) * 255 + 0.5))
data.append(UInt8(min(max(px.y, 0), 1) * 255 + 0.5))
data.append(UInt8(min(max(px.z, 0), 1) * 255 + 0.5))
}
try data.write(to: URL(fileURLWithPath: path))
return path
}
}

View file

@ -7,155 +7,13 @@ import ForgeColor
import ForgeSim import ForgeSim
import ForgeLibrary import ForgeLibrary
import ForgeExport import ForgeExport
import ForgeOffload
import ForgeProxy
@main @main
struct Forge: AsyncParsableCommand { 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: push looks to cameras over IP.",
subcommands: [Push.self, Status.self, Sim.self, Export.self, Offload.self, Verify.self, Proxy.self]) subcommands: [Push.self, Status.self, Sim.self, Export.self])
}
struct Proxy: AsyncParsableCommand {
static let configuration = CommandConfiguration(abstract: "Render proxies for a day's shots, optionally with grade + burn-ins.")
@Option(name: .long, help: "Library database path")
var db: String
@Option(name: .long, help: "Day label")
var day: String
@Option(name: .long, help: "Directory containing source clips (matched by clip name)")
var media: String
@Option(name: .long, help: "Output directory")
var out: String
@Option(name: .long, help: "Preset: prores-proxy|dnxhr-lb|h264")
var preset: String = "h264"
@Option(name: .long, help: "Scale: full|half|quarter")
var scale: String = "half"
@Flag(name: .long, help: "Bake shot grade into proxy")
var burnLook = false
@Flag(name: .long, help: "Burn clip name + timecode overlay")
var burnText = false
@Option(name: .long, help: "ffmpeg binary path")
var ffmpeg: String = "/usr/bin/ffmpeg"
func run() async throws {
guard let presetVal = ProxyPreset(rawValue: preset) else {
throw ValidationError("unknown preset '\(preset)'")
}
guard let scaleVal = ProxyScale(rawValue: scale) else {
throw ValidationError("unknown scale '\(scale)'")
}
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") }
let shots = try store.listShots(dayID: target.id)
try FileManager.default.createDirectory(atPath: out, withIntermediateDirectories: true)
var rendered = 0
var missing = 0
for shot in shots {
// Find source by clip name with common extensions.
let candidates = ["mov", "mxf", "mp4", "braw", "arriraw"].map {
"\(media)/\(shot.clipName).\($0)"
}
guard let input = candidates.first(where: { FileManager.default.fileExists(atPath: $0) }) else {
print("skip \(shot.clipName): no source in \(media)")
missing += 1
continue
}
let ext = presetVal == .h264 ? "mp4" : (presetVal == .dnxhrLB ? "mxf" : "mov")
let job = ProxyJob(
input: input,
output: "\(out)/\(shot.clipName).\(ext)",
preset: presetVal,
scale: scaleVal,
gradeSnapshot: burnLook ? shot.gradeSnapshot : nil,
burnIn: burnText ? BurnIn(
clipName: shot.clipName,
timecode: shot.timecodeIn ?? "00:00:00:00",
fps: Int(shot.fps ?? 24)) : nil)
try await job.run(ffmpegPath: ffmpeg)
print("rendered \(shot.clipName)")
rendered += 1
}
print("proxies: \(rendered) rendered, \(missing) missing sources")
}
}
struct Offload: AsyncParsableCommand {
static let configuration = CommandConfiguration(abstract: "Verified copy of a card to one or more destinations, with MHL manifest.")
@Argument(help: "Source directory (camera card)")
var source: String
@Option(name: [.customLong("to")], help: "Destination directory (repeatable)")
var destinations: [String]
@Flag(name: .long, help: "Re-copy destination files with mismatched size (interrupted copies)")
var overwritePartial = false
@Option(name: .long, help: "Write HTML report to this path")
var report: String?
func run() async throws {
guard !destinations.isEmpty else {
throw ValidationError("at least one --to destination required")
}
let engine = CopyEngine()
let result = try engine.offload(source: source, destinations: destinations, overwritePartial: overwritePartial)
// MHL manifest at each destination root.
let files = result.files.map(\.relativePath)
for dest in destinations {
try MHL.write(root: dest, creator: "Forge", files: files, to: dest + "/" + ((source as NSString).lastPathComponent) + ".mhl")
}
if let reportPath = report {
try OffloadReportWriter.html(result).write(toFile: reportPath, atomically: true, encoding: .utf8)
}
let copied = result.files.filter { !$0.skipped }.count
print("offloaded \(result.files.count) files (\(copied) copied, \(result.files.count - copied) skipped), \(OffloadReportWriter.formatBytes(result.totalBytes)) to \(destinations.count) destination(s) — all verified")
}
}
struct Verify: AsyncParsableCommand {
static let configuration = CommandConfiguration(abstract: "Verify a tree against an MHL manifest.")
@Argument(help: "Path to .mhl manifest")
var manifest: String
@Option(name: .long, help: "Tree root (default: manifest's directory)")
var root: String?
func run() async throws {
let manifestText = try String(contentsOfFile: manifest, encoding: .utf8)
let treeRoot = root ?? (manifest as NSString).deletingLastPathComponent
let result = try MHL.verify(manifest: manifestText, root: treeRoot)
if result.passed {
print("PASSED: \(result.checked) files verified")
} else {
print("FAILED: \(result.failures.count)/\(result.checked) files:")
for f in result.failures {
print(" \(f.path): \(f.reason)")
}
throw ExitCode(1)
}
}
} }
struct Export: AsyncParsableCommand { struct Export: AsyncParsableCommand {

View file

@ -1,112 +0,0 @@
import XCTest
import Foundation
import ForgeGrade
import ForgeColor
@testable import ForgeCamera
/// Counts pushes with timestamps.
actor CountingDriver: CameraDriver {
nonisolated let capabilities = CameraCapabilities(
vendor: .simulated, supportsNativeCDL: true, lut3dSizes: [17, 33], metadataFields: [])
private(set) var pushes = [(look: FlattenedLook, at: ContinuousClock.Instant)]()
nonisolated var state: AsyncStream<CameraState> {
AsyncStream { _ in }
}
func connect() async throws {}
func disconnect() async {}
func push(look: FlattenedLook) async throws {
pushes.append((look, ContinuousClock.now))
}
}
final class DebouncedPushTests: XCTestCase {
func grade(slope: Float) -> GradeStack {
GradeStack(nodes: [GradeNode(kind: .cdl(CDL(
slope: SIMD3(repeating: slope), offset: .zero, power: .one, saturation: 1)))])
}
// Rapid updates coalesce: >= 1 push, far fewer than updates, trailing value wins.
func testCoalescing() async throws {
let driver = CountingDriver()
let pusher = DebouncedPusher(driver: driver, interval: .milliseconds(50), latticeSize: 17)
// 30 rapid updates (panel spin).
for i in 0..<30 {
await pusher.update(grade: grade(slope: 1.0 + Float(i) * 0.01))
try await Task.sleep(for: .milliseconds(2))
}
// Allow trailing push to flush.
try await Task.sleep(for: .milliseconds(150))
let pushes = await driver.pushes
XCTAssertGreaterThanOrEqual(pushes.count, 1)
XCTAssertLessThan(pushes.count, 10, "expected coalescing, got \(pushes.count) pushes for 30 updates")
// Trailing edge: final pushed look reflects last update (slope 1.29).
let lastCDL = pushes.last!.look.cdl
XCTAssertEqual(lastCDL?.slope.x ?? 0, 1.29, accuracy: 1e-4)
await pusher.stop()
}
// Single update pushes exactly once.
func testSingleUpdateSinglePush() async throws {
let driver = CountingDriver()
let pusher = DebouncedPusher(driver: driver, interval: .milliseconds(30), latticeSize: 17)
await pusher.update(grade: grade(slope: 1.5))
try await Task.sleep(for: .milliseconds(120))
let pushes = await driver.pushes
XCTAssertEqual(pushes.count, 1)
await pusher.stop()
}
// Pushes respect min interval spacing.
func testMinIntervalRespected() async throws {
let driver = CountingDriver()
let pusher = DebouncedPusher(driver: driver, interval: .milliseconds(60), latticeSize: 17)
for i in 0..<20 {
await pusher.update(grade: grade(slope: 1.0 + Float(i) * 0.01))
try await Task.sleep(for: .milliseconds(10))
}
try await Task.sleep(for: .milliseconds(200))
let pushes = await driver.pushes
for pair in zip(pushes, pushes.dropFirst()) {
let gap = pair.0.at.duration(to: pair.1.at)
XCTAssertGreaterThanOrEqual(gap, .milliseconds(55), "pushes too close: \(gap)")
}
await pusher.stop()
}
// Independent pushers per camera: no cross-interference.
func testPerCameraIndependence() async throws {
let driverA = CountingDriver()
let driverB = CountingDriver()
let pusherA = DebouncedPusher(driver: driverA, interval: .milliseconds(30), latticeSize: 17)
let pusherB = DebouncedPusher(driver: driverB, interval: .milliseconds(30), latticeSize: 17)
await pusherA.update(grade: grade(slope: 1.1))
// B gets nothing.
try await Task.sleep(for: .milliseconds(120))
let pushesA = await driverA.pushes
let pushesB = await driverB.pushes
XCTAssertEqual(pushesA.count, 1)
XCTAssertEqual(pushesB.count, 0)
await pusherA.stop()
await pusherB.stop()
}
// stop() cancels pending trailing push.
func testStopCancelsPending() async throws {
let driver = CountingDriver()
let pusher = DebouncedPusher(driver: driver, interval: .milliseconds(100), latticeSize: 17)
await pusher.update(grade: grade(slope: 1.2))
await pusher.update(grade: grade(slope: 1.3)) // pending trailing
await pusher.stop()
try await Task.sleep(for: .milliseconds(200))
let pushes = await driver.pushes
// First push may have fired immediately; trailing must not after stop.
XCTAssertLessThanOrEqual(pushes.count, 1)
}
}

View file

@ -1,109 +0,0 @@
import XCTest
import Foundation
@testable import ForgeOffload
final class CopyEngineTests: XCTestCase {
var root: String!
var source: String!
var dest1: String!
var dest2: String!
override func setUpWithError() throws {
root = NSTemporaryDirectory() + "forge-copy-\(UUID().uuidString)"
source = root + "/CARD_A001"
dest1 = root + "/RAID1/A001"
dest2 = root + "/SHUTTLE/A001"
try FileManager.default.createDirectory(atPath: source + "/CLIPS", withIntermediateDirectories: true)
// Source tree: two clips + sidecar.
try makeFile(source + "/CLIPS/A001C001.mov", size: 200_000, seed: 1)
try makeFile(source + "/CLIPS/A001C002.mov", size: 350_000, seed: 2)
try makeFile(source + "/A001.xml", size: 512, seed: 3)
}
override func tearDown() {
try? FileManager.default.removeItem(atPath: root)
}
func makeFile(_ path: String, size: Int, seed: UInt64) throws {
var data = Data(capacity: size)
var s = seed
while data.count < size {
s = s &* 6364136223846793005 &+ 1442695040888963407
withUnsafeBytes(of: s) { data.append(contentsOf: $0.prefix(min(8, size - data.count))) }
}
try data.write(to: URL(fileURLWithPath: path))
}
// Copy to two destinations: byte-identical, verified, report per file.
func testCopyTwoDestinationsVerified() throws {
let engine = CopyEngine()
let report = try engine.offload(source: source, destinations: [dest1, dest2])
XCTAssertEqual(report.files.count, 3)
XCTAssertTrue(report.files.allSatisfy { $0.verified })
for dest in [dest1!, dest2!] {
for rel in ["CLIPS/A001C001.mov", "CLIPS/A001C002.mov", "A001.xml"] {
let srcHash = try FileHasher.xxh64(path: source + "/" + rel)
let dstHash = try FileHasher.xxh64(path: dest + "/" + rel)
XCTAssertEqual(srcHash, dstHash, "hash mismatch for \(rel) in \(dest)")
}
}
// Report carries source hashes.
XCTAssertEqual(Set(report.files.map(\.relativePath)),
["CLIPS/A001C001.mov", "CLIPS/A001C002.mov", "A001.xml"])
XCTAssertTrue(report.files.allSatisfy { $0.xxh64.count == 16 })
}
// Existing identical file at destination: skipped, still verified.
func testExistingIdenticalSkipped() throws {
let engine = CopyEngine()
_ = try engine.offload(source: source, destinations: [dest1])
// Second run: all files exist + match.
let report2 = try engine.offload(source: source, destinations: [dest1])
XCTAssertTrue(report2.files.allSatisfy { $0.skipped })
XCTAssertTrue(report2.files.allSatisfy { $0.verified })
}
// Existing file with different content: hard error (never silently overwrite).
func testConflictErrors() throws {
let engine = CopyEngine()
_ = try engine.offload(source: source, destinations: [dest1])
// Tamper destination clip.
try makeFile(dest1 + "/CLIPS/A001C001.mov", size: 200_000, seed: 99)
XCTAssertThrowsError(try engine.offload(source: source, destinations: [dest1])) { error in
guard case OffloadError.destinationConflict = error else {
return XCTFail("wrong error: \(error)")
}
}
}
// Partial file (interrupted copy simulation): re-copied, ends verified.
func testPartialFileRecopied() throws {
let engine = CopyEngine()
// Simulate interrupted copy: partial temp content at final path with wrong size.
try FileManager.default.createDirectory(atPath: dest1 + "/CLIPS", withIntermediateDirectories: true)
try makeFile(dest1 + "/CLIPS/A001C001.mov", size: 50_000, seed: 1) // truncated
let report = try engine.offload(source: source, destinations: [dest1], overwritePartial: true)
let entry = report.files.first { $0.relativePath == "CLIPS/A001C001.mov" }!
XCTAssertFalse(entry.skipped)
XCTAssertTrue(entry.verified)
let srcHash = try FileHasher.xxh64(path: source + "/CLIPS/A001C001.mov")
XCTAssertEqual(try FileHasher.xxh64(path: dest1 + "/CLIPS/A001C001.mov"), srcHash)
}
// Missing source dir throws.
func testMissingSourceThrows() {
XCTAssertThrowsError(try CopyEngine().offload(source: root + "/nope", destinations: [dest1]))
}
// Report totals.
func testReportTotals() throws {
let report = try CopyEngine().offload(source: source, destinations: [dest1])
XCTAssertEqual(report.totalBytes, 200_000 + 350_000 + 512)
XCTAssertEqual(report.destinations, [dest1])
XCTAssertGreaterThan(report.duration, 0)
}
}

View file

@ -1,82 +0,0 @@
import XCTest
import Foundation
@testable import ForgeOffload
final class HashingTests: XCTestCase {
// xxHash64 official spec vectors (seed 0).
func testXXHash64SpecVectors() {
XCTAssertEqual(XXHash64.hash(Data()), 0xEF46_DB37_51D8_E999)
XCTAssertEqual(XXHash64.hash(Data([0x9E])), 0x4FCE_394C_C88952D8)
// "Nobody inspects the spammish repetition" widely published vector.
let spam = Data("Nobody inspects the spammish repetition".utf8)
XCTAssertEqual(XXHash64.hash(spam), 0xFBCE_A83C_8A378BF1)
}
// Known ASCII vector.
func testXXHash64Ascii() {
XCTAssertEqual(XXHash64.hash(Data("xxhash".utf8)), 0x32DD_38952C_4BC720)
}
// Streaming hash == whole-buffer hash for multi-MB input crossing block sizes.
func testStreamingMatchesWhole() {
var data = Data()
var seed: UInt64 = 0x12345678
for _ in 0..<(3 * 1024 * 1024 / 8) {
seed = seed &* 6364136223846793005 &+ 1442695040888963407
withUnsafeBytes(of: seed) { data.append(contentsOf: $0) }
}
let whole = XXHash64.hash(data)
var streamer = XXHash64()
// Uneven chunk sizes to exercise buffering.
var offset = 0
let chunks = [1, 31, 32, 33, 4096, 1_000_000]
var i = 0
while offset < data.count {
let size = min(chunks[i % chunks.count], data.count - offset)
streamer.update(data.subdata(in: offset..<offset + size))
offset += size
i += 1
}
XCTAssertEqual(streamer.finalize(), whole)
}
// Hex formatting: 16 lowercase hex chars, zero-padded.
func testHexFormat() {
let h = XXHash64.hexString(XXHash64.hash(Data("xxhash".utf8)))
XCTAssertEqual(h, "32dd38952c4bc720")
// Zero-padding for small values.
XCTAssertEqual(XXHash64.hexString(0xAB), "00000000000000ab")
}
// MD5 known vectors (RFC 1321).
func testMD5Vectors() {
XCTAssertEqual(MD5.hexString(Data()), "d41d8cd98f00b204e9800998ecf8427e")
XCTAssertEqual(MD5.hexString(Data("abc".utf8)), "900150983cd24fb0d6963f7d28e17f72")
XCTAssertEqual(
MD5.hexString(Data("message digest".utf8)),
"f96b697d7cb7938d525a2f31aaf161d0")
}
// File hashing: chunked reader over a temp file matches in-memory hash.
func testFileHashing() throws {
let path = NSTemporaryDirectory() + "forge-hash-\(UUID().uuidString).bin"
defer { try? FileManager.default.removeItem(atPath: path) }
var data = Data()
for i in 0..<100_000 {
data.append(UInt8(truncatingIfNeeded: i))
}
try data.write(to: URL(fileURLWithPath: path))
let fileHash = try FileHasher.xxh64(path: path)
XCTAssertEqual(fileHash, XXHash64.hexString(XXHash64.hash(data)))
let fileMD5 = try FileHasher.md5(path: path)
XCTAssertEqual(fileMD5, MD5.hexString(data))
}
func testMissingFileThrows() {
XCTAssertThrowsError(try FileHasher.xxh64(path: "/nonexistent/nope.bin"))
}
}

View file

@ -1,83 +0,0 @@
import XCTest
import Foundation
@testable import ForgeOffload
final class MHLTests: XCTestCase {
var root: String!
override func setUpWithError() throws {
root = NSTemporaryDirectory() + "forge-mhl-\(UUID().uuidString)"
try FileManager.default.createDirectory(atPath: root + "/CLIPS", withIntermediateDirectories: true)
try Data("clip one contents".utf8).write(to: URL(fileURLWithPath: root + "/CLIPS/C001.mov"))
try Data("clip two contents, longer".utf8).write(to: URL(fileURLWithPath: root + "/CLIPS/C002.mov"))
}
override func tearDown() {
try? FileManager.default.removeItem(atPath: root)
}
// MHL XML structure: hashlist root, creatorinfo, hash entries with file/size/xxh64.
func testWriteStructure() throws {
let xml = try MHL.generate(root: root, creator: "Forge 0.1", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"])
XCTAssertTrue(xml.hasPrefix("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<hashlist version=\"2.0\">"))
XCTAssertTrue(xml.contains("<creatorinfo>"))
XCTAssertTrue(xml.contains("<tool>Forge 0.1</tool>"))
XCTAssertTrue(xml.contains("<path>CLIPS/C001.mov</path>"))
XCTAssertTrue(xml.contains("<size>17</size>"))
XCTAssertTrue(xml.contains("<xxh64>"))
XCTAssertTrue(xml.hasSuffix("</hashlist>\n"))
}
// Verify: clean tree passes.
func testVerifyClean() throws {
let xml = try MHL.generate(root: root, creator: "Forge", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"])
let result = try MHL.verify(manifest: xml, root: root)
XCTAssertTrue(result.passed)
XCTAssertEqual(result.checked, 2)
XCTAssertTrue(result.failures.isEmpty)
}
// Tamper detection: flipped byte fails that file.
func testVerifyTamperDetected() throws {
let xml = try MHL.generate(root: root, creator: "Forge", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"])
try Data("clip one CONTENTS".utf8).write(to: URL(fileURLWithPath: root + "/CLIPS/C001.mov"))
let result = try MHL.verify(manifest: xml, root: root)
XCTAssertFalse(result.passed)
XCTAssertEqual(result.failures.count, 1)
XCTAssertEqual(result.failures[0].path, "CLIPS/C001.mov")
XCTAssertEqual(result.failures[0].reason, .hashMismatch)
}
// Missing file fails verify.
func testVerifyMissingFile() throws {
let xml = try MHL.generate(root: root, creator: "Forge", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"])
try FileManager.default.removeItem(atPath: root + "/CLIPS/C002.mov")
let result = try MHL.verify(manifest: xml, root: root)
XCTAssertFalse(result.passed)
XCTAssertEqual(result.failures[0].reason, .missing)
}
// Round trip through file on disk.
func testManifestFileRoundTrip() throws {
let mhlPath = root + "/A001.mhl"
try MHL.write(root: root!, creator: "Forge", files: ["CLIPS/C001.mov"], to: mhlPath)
let loaded = try String(contentsOfFile: mhlPath, encoding: .utf8)
let result = try MHL.verify(manifest: loaded, root: root)
XCTAssertTrue(result.passed)
}
// Malformed manifest rejected.
func testMalformedManifestRejected() {
XCTAssertThrowsError(try MHL.verify(manifest: "<not-mhl/>", root: root))
}
// XML escaping in paths.
func testPathEscaping() throws {
try Data("x".utf8).write(to: URL(fileURLWithPath: root + "/a&b.mov"))
let xml = try MHL.generate(root: root, creator: "Forge", files: ["a&b.mov"])
XCTAssertTrue(xml.contains("<path>a&amp;b.mov</path>"))
let result = try MHL.verify(manifest: xml, root: root)
XCTAssertTrue(result.passed)
}
}

View file

@ -0,0 +1,5 @@
import XCTest
final class ForgeOffloadPlaceholderTests: XCTestCase {
func testPlaceholder() { XCTAssertTrue(true) }
}

View file

@ -1,40 +0,0 @@
import XCTest
import Foundation
@testable import ForgeOffload
final class ReportTests: XCTestCase {
func makeReport() -> OffloadReport {
OffloadReport(
source: "/Volumes/CARD_A001",
destinations: ["/RAID/A001", "/SHUTTLE/A001"],
files: [
FileRecord(relativePath: "CLIPS/C001.mov", size: 1_000_000, xxh64: "abc123def4567890", verified: true, skipped: false),
FileRecord(relativePath: "CLIPS/C002.mov", size: 2_500_000, xxh64: "1111222233334444", verified: true, skipped: true),
],
totalBytes: 3_500_000,
duration: 12.5,
startedAt: Date(timeIntervalSince1970: 1_780_000_000))
}
// CSV report golden shape.
func testCSVReport() {
let csv = OffloadReportWriter.csv(makeReport())
let lines = csv.split(separator: "\n").map(String.init)
XCTAssertEqual(lines[0], "relative_path,size,xxh64,verified,skipped")
XCTAssertEqual(lines[1], "CLIPS/C001.mov,1000000,abc123def4567890,true,false")
XCTAssertEqual(lines[2], "CLIPS/C002.mov,2500000,1111222233334444,true,true")
}
// HTML report contains summary + rows.
func testHTMLReport() {
let html = OffloadReportWriter.html(makeReport())
XCTAssertTrue(html.contains("<html>"))
XCTAssertTrue(html.contains("/Volumes/CARD_A001"))
XCTAssertTrue(html.contains("/RAID/A001"))
XCTAssertTrue(html.contains("CLIPS/C001.mov"))
XCTAssertTrue(html.contains("2 files"))
XCTAssertTrue(html.contains("3.5 MB") || html.contains("3,5 MB"))
XCTAssertTrue(html.contains("VERIFIED"))
}
}

View file

@ -1,140 +0,0 @@
import XCTest
import Foundation
import ForgeGrade
import ForgeColor
@testable import ForgePanel
final class ControlMapTests: XCTestCase {
// Trackball delta maps to CDL offset per ball (lift/gamma/gain zones map to SOP).
func testTrackballToCDL() {
var grade = PanelGradeState()
let map = ControlMap.waveDefault
// Gain ball (ball 3) X delta -> slope red+.
let action = map.action(for: .encoder(id: TangentControls.gainBallX, delta: 10))
guard case .adjustCDL(let param, let amount) = action else { return XCTFail("wrong action") }
XCTAssertEqual(param, .slopeRed)
XCTAssertEqual(amount, 10 * map.sensitivity(for: .slopeRed), accuracy: 1e-8)
grade.apply(action!)
XCTAssertEqual(grade.cdl.slope.x, 1 + 10 * map.sensitivity(for: .slopeRed), accuracy: 1e-6)
}
// Master ring adjusts all three slope channels.
func testMasterRing() {
var grade = PanelGradeState()
let map = ControlMap.waveDefault
let action = map.action(for: .encoder(id: TangentControls.gainRing, delta: 5))
guard case .adjustCDL(let param, _) = action, param == .slopeMaster else {
return XCTFail("wrong mapping")
}
grade.apply(action!)
XCTAssertEqual(grade.cdl.slope.x, grade.cdl.slope.y, accuracy: 1e-7)
XCTAssertEqual(grade.cdl.slope.y, grade.cdl.slope.z, accuracy: 1e-7)
XCTAssertGreaterThan(grade.cdl.slope.x, 1)
}
// Sat knob.
func testSatKnob() {
var grade = PanelGradeState()
let map = ControlMap.waveDefault
let action = map.action(for: .encoder(id: TangentControls.satKnob, delta: -20))
grade.apply(action!)
XCTAssertLessThan(grade.cdl.saturation, 1)
}
// Buttons dispatch commands.
func testButtons() {
let map = ControlMap.waveDefault
XCTAssertEqual(map.action(for: .button(id: TangentControls.bypassButton, pressed: true)), .toggleBypass)
XCTAssertEqual(map.action(for: .button(id: TangentControls.grabStillButton, pressed: true)), .grabStill)
XCTAssertEqual(map.action(for: .button(id: TangentControls.nextSlotButton, pressed: true)), .nextSlot)
XCTAssertEqual(map.action(for: .button(id: TangentControls.resetButton, pressed: true)), .resetGrade)
// Release events ignored.
XCTAssertNil(map.action(for: .button(id: TangentControls.bypassButton, pressed: false)))
}
// Unmapped control -> nil.
func testUnmappedControl() {
XCTAssertNil(ControlMap.waveDefault.action(for: .encoder(id: 9999, delta: 1)))
}
// Reset action restores identity.
func testReset() {
var grade = PanelGradeState()
grade.apply(.adjustCDL(param: .slopeRed, amount: 0.5))
grade.apply(.adjustCDL(param: .offsetBlue, amount: -0.1))
XCTAssertNotEqual(grade.cdl, CDL.identity)
grade.apply(.resetGrade)
XCTAssertEqual(grade.cdl, CDL.identity)
}
// Display text for current params (panel readout strings).
func testDisplayText() {
var grade = PanelGradeState()
grade.apply(.adjustCDL(param: .slopeRed, amount: 0.234))
let text = grade.displayText(for: .slopeRed)
XCTAssertEqual(text, "SlpR 1.234")
}
}
final class TangentProtocolTests: XCTestCase {
// Frame encode: u32 LE length + JSON payload. Round trip.
func testFraming() throws {
let msg = TangentMessage.controlUpdate(.encoder(id: 42, delta: -3))
let framed = TangentWire.encode(msg)
var buf = framed
let decoded = try XCTUnwrap(TangentWire.decodeFirst(&buf))
guard case .controlUpdate(.encoder(let id, let delta)) = decoded else {
return XCTFail("wrong decode")
}
XCTAssertEqual(id, 42)
XCTAssertEqual(delta, -3)
XCTAssertTrue(buf.isEmpty)
}
// Partial frames buffer until complete.
func testPartialFrames() throws {
let msg = TangentMessage.controlUpdate(.button(id: 7, pressed: true))
let framed = TangentWire.encode(msg)
var buf = Data(framed.prefix(3))
XCTAssertNil(try TangentWire.decodeFirst(&buf))
buf.append(framed.suffix(from: 3))
XCTAssertNotNil(try TangentWire.decodeFirst(&buf))
}
// Two frames in one read both decode.
func testTwoFramesOneBuffer() throws {
var buf = TangentWire.encode(.controlUpdate(.encoder(id: 1, delta: 1)))
buf.append(TangentWire.encode(.controlUpdate(.encoder(id: 2, delta: 2))))
let first = try XCTUnwrap(TangentWire.decodeFirst(&buf))
let second = try XCTUnwrap(TangentWire.decodeFirst(&buf))
guard case .controlUpdate(.encoder(let id1, _)) = first,
case .controlUpdate(.encoder(let id2, _)) = second else {
return XCTFail()
}
XCTAssertEqual(id1, 1)
XCTAssertEqual(id2, 2)
}
// Display update encodes.
func testDisplayUpdate() throws {
let msg = TangentMessage.displayUpdate(line: 0, text: "SlpR 1.234")
var buf = TangentWire.encode(msg)
let decoded = try XCTUnwrap(TangentWire.decodeFirst(&buf))
guard case .displayUpdate(let line, let text) = decoded else { return XCTFail() }
XCTAssertEqual(line, 0)
XCTAssertEqual(text, "SlpR 1.234")
}
// Garbage payload throws.
func testGarbageRejected() {
var lenBytes = Data()
var len = UInt32(4).littleEndian
withUnsafeBytes(of: &len) { lenBytes.append(contentsOf: $0) }
var buf = lenBytes + Data("zzzz".utf8)
XCTAssertThrowsError(try TangentWire.decodeFirst(&buf))
}
}

View file

@ -0,0 +1,5 @@
import XCTest
final class ForgePanelPlaceholderTests: XCTestCase {
func testPlaceholder() { XCTAssertTrue(true) }
}

View file

@ -0,0 +1,5 @@
import XCTest
final class ForgeProxyPlaceholderTests: XCTestCase {
func testPlaceholder() { XCTAssertTrue(true) }
}

View file

@ -1,180 +0,0 @@
import XCTest
import Foundation
import ForgeColor
import ForgeGrade
@testable import ForgeProxy
final class FFmpegCommandTests: XCTestCase {
// ProRes proxy preset argv golden.
func testProResProxyArgs() {
let cmd = FFmpegCommandBuilder.build(
input: "/media/A001C001.mov",
output: "/proxies/A001C001.mov",
preset: .proresProxy,
scale: .half,
lutPath: nil,
burnIn: nil)
XCTAssertEqual(cmd.first, "-y")
XCTAssertTrue(cmd.contains("-i"))
XCTAssertTrue(cmd.contains("/media/A001C001.mov"))
XCTAssertTrue(cmd.contains("prores_ks"))
// Profile 0 = proxy.
let profileIdx = cmd.firstIndex(of: "-profile:v")!
XCTAssertEqual(cmd[profileIdx + 1], "0")
XCTAssertTrue(cmd.contains("scale=iw/2:ih/2"))
XCTAssertEqual(cmd.last, "/proxies/A001C001.mov")
}
// H264 preset.
func testH264Args() {
let cmd = FFmpegCommandBuilder.build(
input: "in.mov", output: "out.mp4",
preset: .h264, scale: .quarter, lutPath: nil, burnIn: nil)
XCTAssertTrue(cmd.contains("libx264"))
XCTAssertTrue(cmd.contains("scale=iw/4:ih/4"))
}
// DNxHR preset.
func testDNxHRArgs() {
let cmd = FFmpegCommandBuilder.build(
input: "in.mov", output: "out.mxf",
preset: .dnxhrLB, scale: .full, lutPath: nil, burnIn: nil)
XCTAssertTrue(cmd.contains("dnxhd"))
XCTAssertTrue(cmd.contains("dnxhr_lb"))
// Full scale: no scale filter unless LUT/burn present.
XCTAssertFalse(cmd.joined(separator: " ").contains("scale="))
}
// LUT filter: lut3d with escaped path joins filter chain before scale.
func testLutFilter() {
let cmd = FFmpegCommandBuilder.build(
input: "in.mov", output: "out.mov",
preset: .proresProxy, scale: .half,
lutPath: "/tmp/my look's.cube", burnIn: nil)
let vf = cmd[cmd.firstIndex(of: "-vf")! + 1]
XCTAssertTrue(vf.contains("lut3d="))
// ffmpeg filter escaping: ' -> '\'' inside quoted value, or backslash escapes.
XCTAssertTrue(vf.contains("my look"))
XCTAssertTrue(vf.contains("lut3d="), "lut3d must precede scale")
XCTAssertLessThan(vf.range(of: "lut3d=")!.lowerBound, vf.range(of: "scale=")!.lowerBound)
}
// Burn-in: drawtext with clip name + timecode.
func testBurnIn() {
let cmd = FFmpegCommandBuilder.build(
input: "in.mov", output: "out.mov",
preset: .h264, scale: .half,
lutPath: nil,
burnIn: BurnIn(clipName: "A001C001", timecode: "10:00:00:00", fps: 24))
let vf = cmd[cmd.firstIndex(of: "-vf")! + 1]
XCTAssertTrue(vf.contains("drawtext"))
XCTAssertTrue(vf.contains("A001C001"))
XCTAssertTrue(vf.contains("timecode"))
// TC colons escaped for filter syntax.
XCTAssertTrue(vf.contains("10\\:00\\:00\\:00"))
}
// Audio: copy through.
func testAudioCopied() {
let cmd = FFmpegCommandBuilder.build(
input: "in.mov", output: "out.mov",
preset: .proresProxy, scale: .full, lutPath: nil, burnIn: nil)
let aIdx = cmd.firstIndex(of: "-c:a")!
XCTAssertEqual(cmd[aIdx + 1], "copy")
}
}
final class ProxyJobTests: XCTestCase {
// Runner invokes executable with built argv; fake ffmpeg script records args.
func testRunnerInvokesWithArgs() async throws {
let dir = NSTemporaryDirectory() + "forge-proxy-\(UUID().uuidString)"
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(atPath: dir) }
let fake = dir + "/fake-ffmpeg"
try #"""
#!/bin/bash
echo "$@" > "$(dirname "$0")/argv.txt"
exit 0
"""#.write(toFile: fake, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fake)
let runner = ProxyRunner(ffmpegPath: fake)
try await runner.run(args: ["-y", "-i", "in.mov", "out.mov"])
let argv = try String(contentsOfFile: dir + "/argv.txt", encoding: .utf8)
XCTAssertEqual(argv.trimmingCharacters(in: .whitespacesAndNewlines), "-y -i in.mov out.mov")
}
// Nonzero exit throws with stderr.
func testRunnerThrowsOnFailure() async throws {
let dir = NSTemporaryDirectory() + "forge-proxy-\(UUID().uuidString)"
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(atPath: dir) }
let fake = dir + "/fake-ffmpeg"
try "#!/bin/bash\necho 'boom' >&2\nexit 1\n".write(toFile: fake, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fake)
let runner = ProxyRunner(ffmpegPath: fake)
do {
try await runner.run(args: ["x"])
XCTFail("expected throw")
} catch let e as ProxyError {
guard case .ffmpegFailed(_, let stderr) = e else { return XCTFail("wrong error") }
XCTAssertTrue(stderr.contains("boom"))
}
}
// Graded job: snapshot -> temp .cube written -> lut3d arg references it -> cleaned up after.
func testGradedJobWritesCube() async throws {
let dir = NSTemporaryDirectory() + "forge-proxy-\(UUID().uuidString)"
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(atPath: dir) }
let fake = dir + "/fake-ffmpeg"
try #"""
#!/bin/bash
echo "$@" > "$(dirname "$0")/argv.txt"
# capture cube existence at run time
if [[ "$@" == *lut3d* ]]; then
cube=$(echo "$@" | grep -oE "[^ =']*\.cube" | head -1)
[[ -f "$cube" ]] && echo "cube-exists" > "$(dirname "$0")/cube.txt"
fi
exit 0
"""#.write(toFile: fake, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fake)
let stack = GradeStack(nodes: [GradeNode(kind: .cdl(CDL(
slope: SIMD3(1.2, 1, 1), offset: .zero, power: .one, saturation: 1)))])
let snapshot = try GradeSnapshot.encode(stack)
let job = ProxyJob(
input: "in.mov",
output: dir + "/out.mov",
preset: .proresProxy,
scale: .half,
gradeSnapshot: snapshot,
burnIn: nil)
try await job.run(ffmpegPath: fake, latticeSize: 9)
let argv = try String(contentsOfFile: dir + "/argv.txt", encoding: .utf8)
XCTAssertTrue(argv.contains("lut3d="))
XCTAssertEqual(try String(contentsOfFile: dir + "/cube.txt", encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines), "cube-exists")
}
// Ungraded fallback: no snapshot -> no lut3d in args.
func testUngradedNoLut() async throws {
let dir = NSTemporaryDirectory() + "forge-proxy-\(UUID().uuidString)"
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(atPath: dir) }
let fake = dir + "/fake-ffmpeg"
try "#!/bin/bash\necho \"$@\" > \"$(dirname \"$0\")/argv.txt\"\nexit 0\n".write(toFile: fake, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fake)
let job = ProxyJob(input: "in.mov", output: dir + "/out.mov", preset: .h264, scale: .half, gradeSnapshot: nil, burnIn: nil)
try await job.run(ffmpegPath: fake, latticeSize: 9)
let argv = try String(contentsOfFile: dir + "/argv.txt", encoding: .utf8)
XCTAssertFalse(argv.contains("lut3d"))
}
}

View file

@ -1,132 +0,0 @@
import XCTest
import Foundation
@testable import ForgeProxy
final class WatchFolderTests: XCTestCase {
var dir: String!
override func setUpWithError() throws {
dir = NSTemporaryDirectory() + "forge-watch-\(UUID().uuidString)"
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
}
override func tearDown() {
try? FileManager.default.removeItem(atPath: dir)
}
// New stable file enqueued exactly once.
func testNewFileEnqueuedOnce() async throws {
let watcher = WatchFolder(
path: dir,
extensions: ["mov"],
pollInterval: .milliseconds(30),
stabilityChecks: 2)
var enqueued = [String]()
let stream = await watcher.start()
try Data("clip".utf8).write(to: URL(fileURLWithPath: dir + "/C001.mov"))
let collector = Task {
for await path in stream {
enqueued.append(path)
}
}
try await Task.sleep(for: .milliseconds(400))
await watcher.stop()
_ = await collector.result
XCTAssertEqual(enqueued.count, 1)
XCTAssertTrue(enqueued[0].hasSuffix("C001.mov"))
}
// Growing file (partial write) not enqueued until size stable.
func testGrowingFileWaitsForStability() async throws {
let watcher = WatchFolder(
path: dir,
extensions: ["mov"],
pollInterval: .milliseconds(40),
stabilityChecks: 2)
let path = dir + "/GROW.mov"
try Data("start".utf8).write(to: URL(fileURLWithPath: path))
let stream = await watcher.start()
actor Seen {
var paths = [String]()
var at: [ContinuousClock.Instant] = []
func add(_ p: String) {
paths.append(p)
at.append(.now)
}
}
let seen = Seen()
let collector = Task {
for await p in stream {
await seen.add(p)
}
}
// Keep growing for ~200ms.
for i in 0..<5 {
try await Task.sleep(for: .milliseconds(40))
let h = FileHandle(forWritingAtPath: path)!
h.seekToEndOfFile()
h.write(Data("more\(i)".utf8))
try h.close()
}
let growEnd = ContinuousClock.now
try await Task.sleep(for: .milliseconds(400))
await watcher.stop()
_ = await collector.result
let paths = await seen.paths
let times = await seen.at
XCTAssertEqual(paths.count, 1)
// Enqueue happened after growth stopped.
XCTAssertGreaterThan(times[0], growEnd)
}
// Extension filter.
func testExtensionFilter() async throws {
let watcher = WatchFolder(path: dir, extensions: ["mov"], pollInterval: .milliseconds(30), stabilityChecks: 1)
try Data("x".utf8).write(to: URL(fileURLWithPath: dir + "/note.txt"))
try Data("y".utf8).write(to: URL(fileURLWithPath: dir + "/C002.mov"))
let stream = await watcher.start()
var enqueued = [String]()
let collector = Task {
for await p in stream { enqueued.append(p) }
}
try await Task.sleep(for: .milliseconds(300))
await watcher.stop()
_ = await collector.result
XCTAssertEqual(enqueued.count, 1)
XCTAssertTrue(enqueued[0].hasSuffix(".mov"))
}
// Already-processed file skipped across watcher runs via skip list.
func testSkipListPersistsAcrossRuns() async throws {
try Data("x".utf8).write(to: URL(fileURLWithPath: dir + "/C003.mov"))
let skipList = ProcessedSkipList()
let w1 = WatchFolder(path: dir, extensions: ["mov"], pollInterval: .milliseconds(30), stabilityChecks: 1, skipList: skipList)
let s1 = await w1.start()
var first = [String]()
let c1 = Task { for await p in s1 { first.append(p) } }
try await Task.sleep(for: .milliseconds(250))
await w1.stop()
_ = await c1.result
XCTAssertEqual(first.count, 1)
// Second watcher with same skip list: nothing new.
let w2 = WatchFolder(path: dir, extensions: ["mov"], pollInterval: .milliseconds(30), stabilityChecks: 1, skipList: skipList)
let s2 = await w2.start()
var second = [String]()
let c2 = Task { for await p in s2 { second.append(p) } }
try await Task.sleep(for: .milliseconds(250))
await w2.stop()
_ = await c2.result
XCTAssertTrue(second.isEmpty)
}
}

View file

@ -0,0 +1,5 @@
import XCTest
final class ForgeVideoPlaceholderTests: XCTestCase {
func testPlaceholder() { XCTAssertTrue(true) }
}

View file

@ -1,78 +0,0 @@
import XCTest
import Foundation
import ForgeGrade
import ForgeColor
@testable import ForgeVideo
final class VideoTests: XCTestCase {
// Solid-color test source produces frames of declared size.
func testTestPatternSource() async throws {
let source = TestPatternSource(width: 8, height: 4, color: SIMD3(0.5, 0.25, 0.75))
let frame = try await source.nextFrame()
XCTAssertEqual(frame.width, 8)
XCTAssertEqual(frame.height, 4)
XCTAssertEqual(frame.pixels.count, 8 * 4)
XCTAssertEqual(frame.pixels[0].x, 0.5, accuracy: 1e-6)
}
// Grading a frame applies the stack per pixel.
func testGradeFrame() async throws {
let source = TestPatternSource(width: 4, height: 2, color: SIMD3(0.25, 0.25, 0.25))
let stack = GradeStack(nodes: [GradeNode(kind: .cdl(CDL(
slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1)))])
let frame = try await source.nextFrame()
let graded = FrameProcessor.apply(stack: stack, to: frame)
XCTAssertEqual(graded.pixels[0].x, 0.5, accuracy: 1e-6)
XCTAssertEqual(graded.width, 4)
// Timecode preserved.
XCTAssertEqual(graded.timecode, frame.timecode)
}
// Still grab writes PPM (portable, no image lib dep) and returns path.
func testStillGrabWritesPPM() async throws {
let source = TestPatternSource(width: 4, height: 4, color: SIMD3(1, 0, 0))
let frame = try await source.nextFrame()
let dir = NSTemporaryDirectory() + "forge-stills-\(UUID().uuidString)"
let grabber = StillGrabber(directory: dir)
let path = try grabber.grab(frame: frame, clipName: "A001C001")
defer { try? FileManager.default.removeItem(atPath: dir) }
XCTAssertTrue(FileManager.default.fileExists(atPath: path))
XCTAssertTrue(path.hasSuffix(".ppm"))
XCTAssertTrue(path.contains("A001C001"))
let data = try Data(contentsOf: URL(fileURLWithPath: path))
// Compare header bytes directly string decoding of Data slices is
// unreliable across Foundation implementations.
let expectedHeader = [UInt8]("P6\n4 4\n255\n".utf8)
XCTAssertEqual([UInt8](data.prefix(expectedHeader.count)), expectedHeader)
// First pixel red: 255 0 0.
let headerLen = "P6\n4 4\n255\n".utf8.count
XCTAssertEqual(data[headerLen], 255)
XCTAssertEqual(data[headerLen + 1], 0)
XCTAssertEqual(data[headerLen + 2], 0)
}
// Values clamp to 0-255 on write.
func testStillClampsRange() async throws {
let source = TestPatternSource(width: 2, height: 2, color: SIMD3(2.0, -0.5, 0.5))
let frame = try await source.nextFrame()
let dir = NSTemporaryDirectory() + "forge-stills-\(UUID().uuidString)"
defer { try? FileManager.default.removeItem(atPath: dir) }
let path = try StillGrabber(directory: dir).grab(frame: frame, clipName: "clamp")
let data = try Data(contentsOf: URL(fileURLWithPath: path))
let headerLen = "P6\n2 2\n255\n".utf8.count
XCTAssertEqual(data[headerLen], 255) // 2.0 -> 255
XCTAssertEqual(data[headerLen + 1], 0) // -0.5 -> 0
XCTAssertEqual(data[headerLen + 2], 128) // 0.5 -> 128 (rounded)
}
// Null sink accepts frames without error (SDI-out stand-in).
func testNullSink() async throws {
let sink = NullFrameSink()
let source = TestPatternSource(width: 2, height: 2, color: .zero)
try await sink.consume(frame: try await source.nextFrame())
let count = await sink.consumedCount
XCTAssertEqual(count, 1)
}
}