Compare commits
10 commits
74d1287856
...
e1c1491cdd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1c1491cdd | ||
|
|
92a29d5a60 | ||
|
|
b7d43785cf | ||
|
|
297477f342 | ||
|
|
17fdaa150d | ||
|
|
7894ecf033 | ||
|
|
f52c67707f | ||
|
|
1ac4e6a754 | ||
|
|
bc90bfdf8e | ||
|
|
7b8563d02b |
30 changed files with 2386 additions and 26 deletions
69
Sources/ForgeCamera/DebouncedPusher.swift
Normal file
69
Sources/ForgeCamera/DebouncedPusher.swift
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
133
Sources/ForgeOffload/CopyEngine.swift
Normal file
133
Sources/ForgeOffload/CopyEngine.swift
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
37
Sources/ForgeOffload/FileHasher.swift
Normal file
37
Sources/ForgeOffload/FileHasher.swift
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
// ForgeOffload
|
||||
108
Sources/ForgeOffload/MD5.swift
Normal file
108
Sources/ForgeOffload/MD5.swift
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
135
Sources/ForgeOffload/MHL.swift
Normal file
135
Sources/ForgeOffload/MHL.swift
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
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: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
}
|
||||
|
||||
static func unescape(_ s: String) -> String {
|
||||
s.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "&", 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
|
||||
}
|
||||
}
|
||||
50
Sources/ForgeOffload/OffloadReportWriter.swift
Normal file
50
Sources/ForgeOffload/OffloadReportWriter.swift
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
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>
|
||||
"""
|
||||
}
|
||||
}
|
||||
126
Sources/ForgeOffload/XXHash64.swift
Normal file
126
Sources/ForgeOffload/XXHash64.swift
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
176
Sources/ForgePanel/ControlMap.swift
Normal file
176
Sources/ForgePanel/ControlMap.swift
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
// ForgePanel
|
||||
71
Sources/ForgePanel/TangentWire.swift
Normal file
71
Sources/ForgePanel/TangentWire.swift
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
// ForgeProxy
|
||||
177
Sources/ForgeProxy/Proxy.swift
Normal file
177
Sources/ForgeProxy/Proxy.swift
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
101
Sources/ForgeProxy/WatchFolder.swift
Normal file
101
Sources/ForgeProxy/WatchFolder.swift
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
// ForgeVideo
|
||||
103
Sources/ForgeVideo/Video.swift
Normal file
103
Sources/ForgeVideo/Video.swift
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -7,13 +7,155 @@ import ForgeColor
|
|||
import ForgeSim
|
||||
import ForgeLibrary
|
||||
import ForgeExport
|
||||
import ForgeOffload
|
||||
import ForgeProxy
|
||||
|
||||
@main
|
||||
struct Forge: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "forge",
|
||||
abstract: "On-set live grading: push looks to cameras over IP.",
|
||||
subcommands: [Push.self, Status.self, Sim.self, Export.self])
|
||||
abstract: "On-set live grading + DIT toolkit: camera look push, clip logging, offload, proxies.",
|
||||
subcommands: [Push.self, Status.self, Sim.self, Export.self, Offload.self, Verify.self, Proxy.self])
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
112
Tests/ForgeCameraTests/DebouncedPushTests.swift
Normal file
112
Tests/ForgeCameraTests/DebouncedPushTests.swift
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
109
Tests/ForgeOffloadTests/CopyEngineTests.swift
Normal file
109
Tests/ForgeOffloadTests/CopyEngineTests.swift
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
82
Tests/ForgeOffloadTests/HashingTests.swift
Normal file
82
Tests/ForgeOffloadTests/HashingTests.swift
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
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"))
|
||||
}
|
||||
}
|
||||
83
Tests/ForgeOffloadTests/MHLTests.swift
Normal file
83
Tests/ForgeOffloadTests/MHLTests.swift
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
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&b.mov</path>"))
|
||||
let result = try MHL.verify(manifest: xml, root: root)
|
||||
XCTAssertTrue(result.passed)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import XCTest
|
||||
|
||||
final class ForgeOffloadPlaceholderTests: XCTestCase {
|
||||
func testPlaceholder() { XCTAssertTrue(true) }
|
||||
}
|
||||
40
Tests/ForgeOffloadTests/ReportTests.swift
Normal file
40
Tests/ForgeOffloadTests/ReportTests.swift
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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"))
|
||||
}
|
||||
}
|
||||
140
Tests/ForgePanelTests/PanelTests.swift
Normal file
140
Tests/ForgePanelTests/PanelTests.swift
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import XCTest
|
||||
|
||||
final class ForgePanelPlaceholderTests: XCTestCase {
|
||||
func testPlaceholder() { XCTAssertTrue(true) }
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import XCTest
|
||||
|
||||
final class ForgeProxyPlaceholderTests: XCTestCase {
|
||||
func testPlaceholder() { XCTAssertTrue(true) }
|
||||
}
|
||||
180
Tests/ForgeProxyTests/ProxyTests.swift
Normal file
180
Tests/ForgeProxyTests/ProxyTests.swift
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
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"))
|
||||
}
|
||||
}
|
||||
132
Tests/ForgeProxyTests/WatchFolderTests.swift
Normal file
132
Tests/ForgeProxyTests/WatchFolderTests.swift
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import XCTest
|
||||
|
||||
final class ForgeVideoPlaceholderTests: XCTestCase {
|
||||
func testPlaceholder() { XCTAssertTrue(true) }
|
||||
}
|
||||
78
Tests/ForgeVideoTests/VideoTests.swift
Normal file
78
Tests/ForgeVideoTests/VideoTests.swift
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue