Compare commits
No commits in common. "e1c1491cdda161b0782363406554cd11d91f5e02" and "74d12878566c391d540cccfb335a90360ba948ba" have entirely different histories.
e1c1491cdd
...
74d1287856
30 changed files with 26 additions and 2386 deletions
|
|
@ -1,69 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import ForgeGrade
|
|
||||||
|
|
||||||
/// Rate-limits camera look pushes. Preview updates instantly elsewhere;
|
|
||||||
/// camera gets at most one push per interval, trailing edge guaranteed —
|
|
||||||
/// final panel position always lands on camera.
|
|
||||||
public actor DebouncedPusher {
|
|
||||||
private let driver: any CameraDriver
|
|
||||||
private let interval: Duration
|
|
||||||
private let latticeSize: Int
|
|
||||||
|
|
||||||
private var latestGrade: GradeStack?
|
|
||||||
private var lastPushAt: ContinuousClock.Instant?
|
|
||||||
private var pending: Task<Void, Never>?
|
|
||||||
private var stopped = false
|
|
||||||
|
|
||||||
public init(driver: any CameraDriver, interval: Duration = .milliseconds(150), latticeSize: Int = 33) {
|
|
||||||
self.driver = driver
|
|
||||||
self.interval = interval
|
|
||||||
self.latticeSize = latticeSize
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record new grade; push now if interval elapsed, else schedule trailing push.
|
|
||||||
public func update(grade: GradeStack) {
|
|
||||||
guard !stopped else { return }
|
|
||||||
latestGrade = grade
|
|
||||||
|
|
||||||
let now = ContinuousClock.now
|
|
||||||
if let last = lastPushAt, last.duration(to: now) < interval {
|
|
||||||
scheduleTrailing(after: interval - last.duration(to: now))
|
|
||||||
} else {
|
|
||||||
pushNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scheduleTrailing(after delay: Duration) {
|
|
||||||
guard pending == nil else { return } // one trailing push covers all updates
|
|
||||||
pending = Task {
|
|
||||||
try? await Task.sleep(for: delay)
|
|
||||||
await self.firePending()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func firePending() {
|
|
||||||
pending = nil
|
|
||||||
guard !stopped else { return }
|
|
||||||
pushNow()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pushNow() {
|
|
||||||
guard let grade = latestGrade else { return }
|
|
||||||
lastPushAt = ContinuousClock.now
|
|
||||||
let caps = driver.capabilities
|
|
||||||
let look = Flattener.flatten(
|
|
||||||
stack: grade,
|
|
||||||
latticeSize: caps.lut3dSizes.contains(latticeSize) ? latticeSize : (caps.lut3dSizes.max() ?? latticeSize),
|
|
||||||
splitLeadingCDL: caps.supportsNativeCDL)
|
|
||||||
let driver = self.driver
|
|
||||||
Task {
|
|
||||||
try? await driver.push(look: look)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func stop() {
|
|
||||||
stopped = true
|
|
||||||
pending?.cancel()
|
|
||||||
pending = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Per-file offload result.
|
|
||||||
public struct FileRecord: Sendable, Equatable {
|
|
||||||
public let relativePath: String
|
|
||||||
public let size: UInt64
|
|
||||||
public let xxh64: String
|
|
||||||
/// Destination copy verified by post-copy re-read.
|
|
||||||
public let verified: Bool
|
|
||||||
/// Already present + identical at all destinations.
|
|
||||||
public let skipped: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Offload job report.
|
|
||||||
public struct OffloadReport: Sendable {
|
|
||||||
public let source: String
|
|
||||||
public let destinations: [String]
|
|
||||||
public let files: [FileRecord]
|
|
||||||
public let totalBytes: UInt64
|
|
||||||
public let duration: TimeInterval
|
|
||||||
public let startedAt: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verified multi-destination copy: source hashed while copying, destinations
|
|
||||||
/// re-read + hashed after write. Never silently overwrites mismatched files.
|
|
||||||
public struct CopyEngine {
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
/// Copy source tree to all destinations.
|
|
||||||
/// - overwritePartial: re-copy destination files whose *size* differs
|
|
||||||
/// (interrupted copy detection). Size-matching mismatched hashes always error.
|
|
||||||
public func offload(
|
|
||||||
source: String,
|
|
||||||
destinations: [String],
|
|
||||||
overwritePartial: Bool = false
|
|
||||||
) throws -> OffloadReport {
|
|
||||||
let fm = FileManager.default
|
|
||||||
var isDir: ObjCBool = false
|
|
||||||
guard fm.fileExists(atPath: source, isDirectory: &isDir), isDir.boolValue else {
|
|
||||||
throw OffloadError.fileNotFound(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
let start = Date()
|
|
||||||
let clock = ContinuousClock.now
|
|
||||||
let files = try listFiles(root: source).sorted()
|
|
||||||
var records = [FileRecord]()
|
|
||||||
var totalBytes: UInt64 = 0
|
|
||||||
|
|
||||||
for rel in files {
|
|
||||||
let srcPath = source + "/" + rel
|
|
||||||
let srcHash = try FileHasher.xxh64(path: srcPath)
|
|
||||||
let attrs = try fm.attributesOfItem(atPath: srcPath)
|
|
||||||
let size = attrs[.size] as? UInt64 ?? 0
|
|
||||||
totalBytes += size
|
|
||||||
|
|
||||||
var anyCopied = false
|
|
||||||
for dest in destinations {
|
|
||||||
let dstPath = dest + "/" + rel
|
|
||||||
let dstDir = (dstPath as NSString).deletingLastPathComponent
|
|
||||||
try fm.createDirectory(atPath: dstDir, withIntermediateDirectories: true)
|
|
||||||
|
|
||||||
if fm.fileExists(atPath: dstPath) {
|
|
||||||
let dstAttrs = try fm.attributesOfItem(atPath: dstPath)
|
|
||||||
let dstSize = dstAttrs[.size] as? UInt64 ?? 0
|
|
||||||
if dstSize != size {
|
|
||||||
// Partial/interrupted copy.
|
|
||||||
if overwritePartial {
|
|
||||||
try fm.removeItem(atPath: dstPath)
|
|
||||||
} else {
|
|
||||||
throw OffloadError.destinationConflict(
|
|
||||||
"\(dstPath): size \(dstSize) != source \(size) (partial? rerun with overwritePartial)")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let dstHash = try FileHasher.xxh64(path: dstPath)
|
|
||||||
if dstHash == srcHash {
|
|
||||||
continue // identical — skip
|
|
||||||
}
|
|
||||||
throw OffloadError.destinationConflict(
|
|
||||||
"\(dstPath): exists with different content (hash \(dstHash) != \(srcHash))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy via temp + atomic rename, then verify by re-read.
|
|
||||||
let tmpPath = dstPath + ".forgetmp"
|
|
||||||
try? fm.removeItem(atPath: tmpPath)
|
|
||||||
try fm.copyItem(atPath: srcPath, toPath: tmpPath)
|
|
||||||
let copiedHash = try FileHasher.xxh64(path: tmpPath)
|
|
||||||
guard copiedHash == srcHash else {
|
|
||||||
try? fm.removeItem(atPath: tmpPath)
|
|
||||||
throw OffloadError.verifyFailed(path: dstPath, expected: srcHash, actual: copiedHash)
|
|
||||||
}
|
|
||||||
_ = try? fm.replaceItemAt(URL(fileURLWithPath: dstPath), withItemAt: URL(fileURLWithPath: tmpPath))
|
|
||||||
if !fm.fileExists(atPath: dstPath) {
|
|
||||||
// replaceItemAt fallback (some Linux Foundation builds).
|
|
||||||
try fm.moveItem(atPath: tmpPath, toPath: dstPath)
|
|
||||||
}
|
|
||||||
anyCopied = true
|
|
||||||
}
|
|
||||||
|
|
||||||
records.append(FileRecord(
|
|
||||||
relativePath: rel,
|
|
||||||
size: size,
|
|
||||||
xxh64: srcHash,
|
|
||||||
verified: true,
|
|
||||||
skipped: !anyCopied))
|
|
||||||
}
|
|
||||||
|
|
||||||
return OffloadReport(
|
|
||||||
source: source,
|
|
||||||
destinations: destinations,
|
|
||||||
files: records,
|
|
||||||
totalBytes: totalBytes,
|
|
||||||
duration: max(Double(clock.duration(to: .now).components.seconds) +
|
|
||||||
Double(clock.duration(to: .now).components.attoseconds) / 1e18, 0.000001),
|
|
||||||
startedAt: start)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Relative paths of all regular files under root.
|
|
||||||
func listFiles(root: String) throws -> [String] {
|
|
||||||
let fm = FileManager.default
|
|
||||||
guard let enumerator = fm.enumerator(atPath: root) else {
|
|
||||||
throw OffloadError.readFailed(root)
|
|
||||||
}
|
|
||||||
var out = [String]()
|
|
||||||
while let rel = enumerator.nextObject() as? String {
|
|
||||||
var isDir: ObjCBool = false
|
|
||||||
if fm.fileExists(atPath: root + "/" + rel, isDirectory: &isDir), !isDir.boolValue {
|
|
||||||
out.append(rel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public enum OffloadError: Error, Equatable {
|
|
||||||
case fileNotFound(String)
|
|
||||||
case readFailed(String)
|
|
||||||
case writeFailed(String)
|
|
||||||
case verifyFailed(path: String, expected: String, actual: String)
|
|
||||||
case destinationConflict(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Chunked file hashing — constant memory over multi-GB camera files.
|
|
||||||
public enum FileHasher {
|
|
||||||
static let chunkSize = 4 * 1024 * 1024
|
|
||||||
|
|
||||||
public static func xxh64(path: String) throws -> String {
|
|
||||||
var hasher = XXHash64()
|
|
||||||
try stream(path: path) { hasher.update($0) }
|
|
||||||
return XXHash64.hexString(hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func md5(path: String) throws -> String {
|
|
||||||
var hasher = MD5()
|
|
||||||
try stream(path: path) { hasher.update($0) }
|
|
||||||
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func stream(path: String, _ body: (Data) -> Void) throws {
|
|
||||||
guard let handle = FileHandle(forReadingAtPath: path) else {
|
|
||||||
throw OffloadError.fileNotFound(path)
|
|
||||||
}
|
|
||||||
defer { try? handle.close() }
|
|
||||||
while true {
|
|
||||||
guard let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty else { break }
|
|
||||||
body(chunk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
Sources/ForgeOffload/ForgeOffload.swift
Normal file
1
Sources/ForgeOffload/ForgeOffload.swift
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
// ForgeOffload
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// MD5 (RFC 1321) — pure Swift. Legacy-post-workflow compatibility only;
|
|
||||||
/// integrity verification uses xxHash64.
|
|
||||||
public struct MD5 {
|
|
||||||
private var a0: UInt32 = 0x67452301
|
|
||||||
private var b0: UInt32 = 0xefcdab89
|
|
||||||
private var c0: UInt32 = 0x98badcfe
|
|
||||||
private var d0: UInt32 = 0x10325476
|
|
||||||
private var buffer = [UInt8]()
|
|
||||||
private var totalLength: UInt64 = 0
|
|
||||||
|
|
||||||
private static let s: [UInt32] = [
|
|
||||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
|
||||||
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
|
||||||
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
|
||||||
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
|
|
||||||
]
|
|
||||||
|
|
||||||
private static let k: [UInt32] = (0..<64).map {
|
|
||||||
UInt32(truncatingIfNeeded: Int64(abs(sin(Double($0 + 1))) * 4294967296.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
public mutating func update(_ data: Data) {
|
|
||||||
totalLength += UInt64(data.count)
|
|
||||||
buffer.append(contentsOf: data)
|
|
||||||
var offset = 0
|
|
||||||
while buffer.count - offset >= 64 {
|
|
||||||
processBlock(Array(buffer[offset..<offset + 64]))
|
|
||||||
offset += 64
|
|
||||||
}
|
|
||||||
if offset > 0 {
|
|
||||||
buffer.removeFirst(offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mutating func processBlock(_ block: [UInt8]) {
|
|
||||||
var m = [UInt32](repeating: 0, count: 16)
|
|
||||||
for i in 0..<16 {
|
|
||||||
m[i] = UInt32(block[i * 4])
|
|
||||||
| (UInt32(block[i * 4 + 1]) << 8)
|
|
||||||
| (UInt32(block[i * 4 + 2]) << 16)
|
|
||||||
| (UInt32(block[i * 4 + 3]) << 24)
|
|
||||||
}
|
|
||||||
var a = a0, b = b0, c = c0, d = d0
|
|
||||||
for i in 0..<64 {
|
|
||||||
var f: UInt32
|
|
||||||
var g: Int
|
|
||||||
switch i {
|
|
||||||
case 0..<16:
|
|
||||||
f = (b & c) | (~b & d)
|
|
||||||
g = i
|
|
||||||
case 16..<32:
|
|
||||||
f = (d & b) | (~d & c)
|
|
||||||
g = (5 * i + 1) % 16
|
|
||||||
case 32..<48:
|
|
||||||
f = b ^ c ^ d
|
|
||||||
g = (3 * i + 5) % 16
|
|
||||||
default:
|
|
||||||
f = c ^ (b | ~d)
|
|
||||||
g = (7 * i) % 16
|
|
||||||
}
|
|
||||||
f = f &+ a &+ Self.k[i] &+ m[g]
|
|
||||||
a = d
|
|
||||||
d = c
|
|
||||||
c = b
|
|
||||||
b = b &+ ((f << Self.s[i]) | (f >> (32 - Self.s[i])))
|
|
||||||
}
|
|
||||||
a0 = a0 &+ a
|
|
||||||
b0 = b0 &+ b
|
|
||||||
c0 = c0 &+ c
|
|
||||||
d0 = d0 &+ d
|
|
||||||
}
|
|
||||||
|
|
||||||
public mutating func finalize() -> Data {
|
|
||||||
let bitLength = totalLength * 8
|
|
||||||
// Padding: 0x80 then zeros to 56 mod 64, then length LE64.
|
|
||||||
var padding: [UInt8] = [0x80]
|
|
||||||
let remainder = (buffer.count + 1) % 64
|
|
||||||
let zeros = remainder <= 56 ? 56 - remainder : 120 - remainder
|
|
||||||
padding.append(contentsOf: [UInt8](repeating: 0, count: zeros))
|
|
||||||
var lenLE = bitLength.littleEndian
|
|
||||||
withUnsafeBytes(of: &lenLE) { padding.append(contentsOf: $0) }
|
|
||||||
|
|
||||||
buffer.append(contentsOf: padding)
|
|
||||||
var offset = 0
|
|
||||||
while buffer.count - offset >= 64 {
|
|
||||||
processBlock(Array(buffer[offset..<offset + 64]))
|
|
||||||
offset += 64
|
|
||||||
}
|
|
||||||
buffer.removeAll()
|
|
||||||
|
|
||||||
var out = Data()
|
|
||||||
for v in [a0, b0, c0, d0] {
|
|
||||||
var le = v.littleEndian
|
|
||||||
withUnsafeBytes(of: &le) { out.append(contentsOf: $0) }
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func hexString(_ data: Data) -> String {
|
|
||||||
var md5 = MD5()
|
|
||||||
md5.update(data)
|
|
||||||
return md5.finalize().map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Media Hash List (ASC MHL-style) manifest: per-file path/size/xxh64.
|
|
||||||
/// Structure follows MHL v2 hashlist shape; full ASC MHL chain/directory
|
|
||||||
/// hashes can layer on later without breaking this format.
|
|
||||||
public enum MHL {
|
|
||||||
|
|
||||||
public struct Failure: Equatable, Sendable {
|
|
||||||
public enum Reason: Equatable, Sendable {
|
|
||||||
case missing
|
|
||||||
case hashMismatch
|
|
||||||
case sizeMismatch
|
|
||||||
}
|
|
||||||
public let path: String
|
|
||||||
public let reason: Reason
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct VerifyResult: Sendable {
|
|
||||||
public let checked: Int
|
|
||||||
public let failures: [Failure]
|
|
||||||
public var passed: Bool { failures.isEmpty }
|
|
||||||
}
|
|
||||||
|
|
||||||
static func escape(_ s: String) -> String {
|
|
||||||
s.replacingOccurrences(of: "&", with: "&")
|
|
||||||
.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Offload report renderers: CSV (machine) + HTML (producer handoff).
|
|
||||||
public enum OffloadReportWriter {
|
|
||||||
|
|
||||||
public static func csv(_ report: OffloadReport) -> String {
|
|
||||||
var out = "relative_path,size,xxh64,verified,skipped\n"
|
|
||||||
for f in report.files {
|
|
||||||
out += "\(f.relativePath),\(f.size),\(f.xxh64),\(f.verified),\(f.skipped)\n"
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func formatBytes(_ bytes: UInt64) -> String {
|
|
||||||
let mb = Double(bytes) / 1_000_000
|
|
||||||
if mb >= 1000 {
|
|
||||||
return String(format: "%.2f GB", mb / 1000)
|
|
||||||
}
|
|
||||||
return String(format: "%.1f MB", mb)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func html(_ report: OffloadReport) -> String {
|
|
||||||
let allVerified = report.files.allSatisfy(\.verified)
|
|
||||||
var rows = ""
|
|
||||||
for f in report.files {
|
|
||||||
rows += """
|
|
||||||
<tr><td>\(f.relativePath)</td><td>\(formatBytes(f.size))</td>\
|
|
||||||
<td><code>\(f.xxh64)</code></td>\
|
|
||||||
<td>\(f.verified ? "OK" : "FAIL")</td>\
|
|
||||||
<td>\(f.skipped ? "skipped" : "copied")</td></tr>\n
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
return """
|
|
||||||
<html>
|
|
||||||
<head><title>Forge Offload Report</title>
|
|
||||||
<style>body{font-family:sans-serif}table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:4px 8px}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Offload Report — \(allVerified ? "VERIFIED" : "FAILURES PRESENT")</h1>
|
|
||||||
<p>Source: \(report.source)</p>
|
|
||||||
<p>Destinations: \(report.destinations.joined(separator: ", "))</p>
|
|
||||||
<p>\(report.files.count) files, \(formatBytes(report.totalBytes)), \(String(format: "%.1f", report.duration))s</p>
|
|
||||||
<table>
|
|
||||||
<tr><th>File</th><th>Size</th><th>xxHash64</th><th>Verify</th><th>Action</th></tr>
|
|
||||||
\(rows)</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// xxHash64 (seed 0) — pure Swift, streaming. Spec: github.com/Cyan4973/xxHash.
|
|
||||||
public struct XXHash64 {
|
|
||||||
private static let p1: UInt64 = 0x9E3779B185EBCA87
|
|
||||||
private static let p2: UInt64 = 0xC2B2AE3D27D4EB4F
|
|
||||||
private static let p3: UInt64 = 0x165667B19E3779F9
|
|
||||||
private static let p4: UInt64 = 0x85EBCA77C2B2AE63
|
|
||||||
private static let p5: UInt64 = 0x27D4EB2F165667C5
|
|
||||||
|
|
||||||
private var v1: UInt64
|
|
||||||
private var v2: UInt64
|
|
||||||
private var v3: UInt64
|
|
||||||
private var v4: UInt64
|
|
||||||
private var buffer = [UInt8]()
|
|
||||||
private var totalLength: UInt64 = 0
|
|
||||||
private let seed: UInt64
|
|
||||||
|
|
||||||
public init(seed: UInt64 = 0) {
|
|
||||||
self.seed = seed
|
|
||||||
v1 = seed &+ Self.p1 &+ Self.p2
|
|
||||||
v2 = seed &+ Self.p2
|
|
||||||
v3 = seed
|
|
||||||
v4 = seed &- Self.p1
|
|
||||||
}
|
|
||||||
|
|
||||||
@inline(__always)
|
|
||||||
private static func rotl(_ x: UInt64, _ r: UInt64) -> UInt64 {
|
|
||||||
(x << r) | (x >> (64 - r))
|
|
||||||
}
|
|
||||||
|
|
||||||
@inline(__always)
|
|
||||||
private static func round(_ acc: UInt64, _ input: UInt64) -> UInt64 {
|
|
||||||
rotl(acc &+ input &* p2, 31) &* p1
|
|
||||||
}
|
|
||||||
|
|
||||||
@inline(__always)
|
|
||||||
private static func mergeRound(_ acc: UInt64, _ val: UInt64) -> UInt64 {
|
|
||||||
(acc ^ round(0, val)) &* p1 &+ p4
|
|
||||||
}
|
|
||||||
|
|
||||||
@inline(__always)
|
|
||||||
private static func readLE64(_ bytes: [UInt8], _ offset: Int) -> UInt64 {
|
|
||||||
var v: UInt64 = 0
|
|
||||||
for i in 0..<8 {
|
|
||||||
v |= UInt64(bytes[offset + i]) << (8 * UInt64(i))
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
@inline(__always)
|
|
||||||
private static func readLE32(_ bytes: [UInt8], _ offset: Int) -> UInt64 {
|
|
||||||
var v: UInt64 = 0
|
|
||||||
for i in 0..<4 {
|
|
||||||
v |= UInt64(bytes[offset + i]) << (8 * UInt64(i))
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
public mutating func update(_ data: Data) {
|
|
||||||
totalLength += UInt64(data.count)
|
|
||||||
buffer.append(contentsOf: data)
|
|
||||||
// Consume full 32-byte stripes, keep remainder buffered.
|
|
||||||
var offset = 0
|
|
||||||
while buffer.count - offset >= 32 {
|
|
||||||
v1 = Self.round(v1, Self.readLE64(buffer, offset))
|
|
||||||
v2 = Self.round(v2, Self.readLE64(buffer, offset + 8))
|
|
||||||
v3 = Self.round(v3, Self.readLE64(buffer, offset + 16))
|
|
||||||
v4 = Self.round(v4, Self.readLE64(buffer, offset + 24))
|
|
||||||
offset += 32
|
|
||||||
}
|
|
||||||
if offset > 0 {
|
|
||||||
buffer.removeFirst(offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func finalize() -> UInt64 {
|
|
||||||
var h: UInt64
|
|
||||||
if totalLength >= 32 {
|
|
||||||
h = Self.rotl(v1, 1) &+ Self.rotl(v2, 7) &+ Self.rotl(v3, 12) &+ Self.rotl(v4, 18)
|
|
||||||
h = Self.mergeRound(h, v1)
|
|
||||||
h = Self.mergeRound(h, v2)
|
|
||||||
h = Self.mergeRound(h, v3)
|
|
||||||
h = Self.mergeRound(h, v4)
|
|
||||||
} else {
|
|
||||||
h = seed &+ Self.p5
|
|
||||||
}
|
|
||||||
h &+= totalLength
|
|
||||||
|
|
||||||
var offset = 0
|
|
||||||
let tail = buffer
|
|
||||||
while tail.count - offset >= 8 {
|
|
||||||
h ^= Self.round(0, Self.readLE64(tail, offset))
|
|
||||||
h = Self.rotl(h, 27) &* Self.p1 &+ Self.p4
|
|
||||||
offset += 8
|
|
||||||
}
|
|
||||||
if tail.count - offset >= 4 {
|
|
||||||
h ^= Self.readLE32(tail, offset) &* Self.p1
|
|
||||||
h = Self.rotl(h, 23) &* Self.p2 &+ Self.p3
|
|
||||||
offset += 4
|
|
||||||
}
|
|
||||||
while offset < tail.count {
|
|
||||||
h ^= UInt64(tail[offset]) &* Self.p5
|
|
||||||
h = Self.rotl(h, 11) &* Self.p1
|
|
||||||
offset += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
h ^= h >> 33
|
|
||||||
h &*= Self.p2
|
|
||||||
h ^= h >> 29
|
|
||||||
h &*= Self.p3
|
|
||||||
h ^= h >> 32
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One-shot.
|
|
||||||
public static func hash(_ data: Data, seed: UInt64 = 0) -> UInt64 {
|
|
||||||
var h = XXHash64(seed: seed)
|
|
||||||
h.update(data)
|
|
||||||
return h.finalize()
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func hexString(_ value: UInt64) -> String {
|
|
||||||
String(format: "%016lx", value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import ForgeColor
|
|
||||||
|
|
||||||
/// Tangent Wave control IDs — mirror of the panel's control layout.
|
|
||||||
/// Real IDs assigned by Tangent Hub control map; these are our canonical set,
|
|
||||||
/// remapped at Hub-registration time in the macOS phase.
|
|
||||||
public enum TangentControls {
|
|
||||||
// Trackballs: X/Y per ball. Ball 1 = lift(offset), 2 = gamma(power), 3 = gain(slope).
|
|
||||||
public static let liftBallX: Int = 101
|
|
||||||
public static let liftBallY: Int = 102
|
|
||||||
public static let gammaBallX: Int = 111
|
|
||||||
public static let gammaBallY: Int = 112
|
|
||||||
public static let gainBallX: Int = 121
|
|
||||||
public static let gainBallY: Int = 122
|
|
||||||
// Rings (master per zone).
|
|
||||||
public static let liftRing: Int = 103
|
|
||||||
public static let gammaRing: Int = 113
|
|
||||||
public static let gainRing: Int = 123
|
|
||||||
// Knobs.
|
|
||||||
public static let satKnob: Int = 201
|
|
||||||
public static let contrastKnob: Int = 202
|
|
||||||
// Buttons.
|
|
||||||
public static let bypassButton: Int = 301
|
|
||||||
public static let grabStillButton: Int = 302
|
|
||||||
public static let nextSlotButton: Int = 303
|
|
||||||
public static let prevSlotButton: Int = 304
|
|
||||||
public static let resetButton: Int = 305
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Panel input event.
|
|
||||||
public enum PanelInput: Equatable, Sendable {
|
|
||||||
case encoder(id: Int, delta: Int)
|
|
||||||
case button(id: Int, pressed: Bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// CDL parameter targeted by a panel control.
|
|
||||||
public enum CDLParam: String, Equatable, Sendable {
|
|
||||||
case slopeRed, slopeGreen, slopeBlue, slopeMaster
|
|
||||||
case offsetRed, offsetGreen, offsetBlue, offsetMaster
|
|
||||||
case powerRed, powerGreen, powerBlue, powerMaster
|
|
||||||
case saturation
|
|
||||||
case contrast
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Action produced by mapping a panel input.
|
|
||||||
public enum PanelAction: Equatable, Sendable {
|
|
||||||
case adjustCDL(param: CDLParam, amount: Float)
|
|
||||||
case toggleBypass
|
|
||||||
case grabStill
|
|
||||||
case nextSlot
|
|
||||||
case prevSlot
|
|
||||||
case resetGrade
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maps panel controls to actions with per-parameter sensitivity.
|
|
||||||
public struct ControlMap: Sendable {
|
|
||||||
public var encoderMap: [Int: CDLParam]
|
|
||||||
public var buttonMap: [Int: PanelAction]
|
|
||||||
private var sensitivities: [CDLParam: Float]
|
|
||||||
|
|
||||||
public func sensitivity(for param: CDLParam) -> Float {
|
|
||||||
sensitivities[param] ?? 0.001
|
|
||||||
}
|
|
||||||
|
|
||||||
public func action(for input: PanelInput) -> PanelAction? {
|
|
||||||
switch input {
|
|
||||||
case .encoder(let id, let delta):
|
|
||||||
guard let param = encoderMap[id] else { return nil }
|
|
||||||
return .adjustCDL(param: param, amount: Float(delta) * sensitivity(for: param))
|
|
||||||
case .button(let id, let pressed):
|
|
||||||
guard pressed else { return nil } // act on press only
|
|
||||||
return buttonMap[id]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default Wave mapping: balls X->red-ish axis, Y->green/blue axis simplification
|
|
||||||
/// (full 2D chromatic ball math lands with real panel calibration).
|
|
||||||
public static let waveDefault = ControlMap(
|
|
||||||
encoderMap: [
|
|
||||||
TangentControls.liftBallX: .offsetRed,
|
|
||||||
TangentControls.liftBallY: .offsetBlue,
|
|
||||||
TangentControls.liftRing: .offsetMaster,
|
|
||||||
TangentControls.gammaBallX: .powerRed,
|
|
||||||
TangentControls.gammaBallY: .powerBlue,
|
|
||||||
TangentControls.gammaRing: .powerMaster,
|
|
||||||
TangentControls.gainBallX: .slopeRed,
|
|
||||||
TangentControls.gainBallY: .slopeBlue,
|
|
||||||
TangentControls.gainRing: .slopeMaster,
|
|
||||||
TangentControls.satKnob: .saturation,
|
|
||||||
TangentControls.contrastKnob: .contrast,
|
|
||||||
],
|
|
||||||
buttonMap: [
|
|
||||||
TangentControls.bypassButton: .toggleBypass,
|
|
||||||
TangentControls.grabStillButton: .grabStill,
|
|
||||||
TangentControls.nextSlotButton: .nextSlot,
|
|
||||||
TangentControls.prevSlotButton: .prevSlot,
|
|
||||||
TangentControls.resetButton: .resetGrade,
|
|
||||||
],
|
|
||||||
sensitivities: [
|
|
||||||
.slopeRed: 0.002, .slopeGreen: 0.002, .slopeBlue: 0.002, .slopeMaster: 0.002,
|
|
||||||
.offsetRed: 0.001, .offsetGreen: 0.001, .offsetBlue: 0.001, .offsetMaster: 0.001,
|
|
||||||
.powerRed: 0.002, .powerGreen: 0.002, .powerBlue: 0.002, .powerMaster: 0.002,
|
|
||||||
.saturation: 0.002, .contrast: 0.002,
|
|
||||||
])
|
|
||||||
|
|
||||||
public init(encoderMap: [Int: CDLParam], buttonMap: [Int: PanelAction], sensitivities: [CDLParam: Float]) {
|
|
||||||
self.encoderMap = encoderMap
|
|
||||||
self.buttonMap = buttonMap
|
|
||||||
self.sensitivities = sensitivities
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Panel-editable grade state (CDL layer the panel drives).
|
|
||||||
public struct PanelGradeState: Sendable, Equatable {
|
|
||||||
public var cdl: CDL = .identity
|
|
||||||
public var bypassed = false
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
public mutating func apply(_ action: PanelAction) {
|
|
||||||
switch action {
|
|
||||||
case .adjustCDL(let param, let amount):
|
|
||||||
switch param {
|
|
||||||
case .slopeRed: cdl.slope.x += amount
|
|
||||||
case .slopeGreen: cdl.slope.y += amount
|
|
||||||
case .slopeBlue: cdl.slope.z += amount
|
|
||||||
case .slopeMaster:
|
|
||||||
cdl.slope += SIMD3(repeating: amount)
|
|
||||||
case .offsetRed: cdl.offset.x += amount
|
|
||||||
case .offsetGreen: cdl.offset.y += amount
|
|
||||||
case .offsetBlue: cdl.offset.z += amount
|
|
||||||
case .offsetMaster:
|
|
||||||
cdl.offset += SIMD3(repeating: amount)
|
|
||||||
case .powerRed: cdl.power.x += amount
|
|
||||||
case .powerGreen: cdl.power.y += amount
|
|
||||||
case .powerBlue: cdl.power.z += amount
|
|
||||||
case .powerMaster:
|
|
||||||
cdl.power += SIMD3(repeating: amount)
|
|
||||||
case .saturation:
|
|
||||||
cdl.saturation = max(0, cdl.saturation + amount)
|
|
||||||
case .contrast:
|
|
||||||
// Contrast pivot 0.435: slope up, offset compensates.
|
|
||||||
cdl.slope += SIMD3(repeating: amount)
|
|
||||||
cdl.offset -= SIMD3(repeating: amount * 0.435)
|
|
||||||
}
|
|
||||||
case .toggleBypass:
|
|
||||||
bypassed.toggle()
|
|
||||||
case .resetGrade:
|
|
||||||
cdl = .identity
|
|
||||||
bypassed = false
|
|
||||||
case .grabStill, .nextSlot, .prevSlot:
|
|
||||||
break // handled by app layer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Short readout string for panel displays.
|
|
||||||
public func displayText(for param: CDLParam) -> String {
|
|
||||||
func t(_ v: Float) -> String { String(format: "%.3f", v) }
|
|
||||||
switch param {
|
|
||||||
case .slopeRed: return "SlpR \(t(cdl.slope.x))"
|
|
||||||
case .slopeGreen: return "SlpG \(t(cdl.slope.y))"
|
|
||||||
case .slopeBlue: return "SlpB \(t(cdl.slope.z))"
|
|
||||||
case .slopeMaster: return "Slp \(t((cdl.slope.x + cdl.slope.y + cdl.slope.z) / 3))"
|
|
||||||
case .offsetRed: return "OffR \(t(cdl.offset.x))"
|
|
||||||
case .offsetGreen: return "OffG \(t(cdl.offset.y))"
|
|
||||||
case .offsetBlue: return "OffB \(t(cdl.offset.z))"
|
|
||||||
case .offsetMaster: return "Off \(t((cdl.offset.x + cdl.offset.y + cdl.offset.z) / 3))"
|
|
||||||
case .powerRed: return "PwrR \(t(cdl.power.x))"
|
|
||||||
case .powerGreen: return "PwrG \(t(cdl.power.y))"
|
|
||||||
case .powerBlue: return "PwrB \(t(cdl.power.z))"
|
|
||||||
case .powerMaster: return "Pwr \(t((cdl.power.x + cdl.power.y + cdl.power.z) / 3))"
|
|
||||||
case .saturation: return "Sat \(t(cdl.saturation))"
|
|
||||||
case .contrast: return "Con \(t(cdl.slope.x))"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
Sources/ForgePanel/ForgePanel.swift
Normal file
1
Sources/ForgePanel/ForgePanel.swift
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
// ForgePanel
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Messages exchanged with the Tangent Hub bridge.
|
|
||||||
/// Wire framing: u32 little-endian length + JSON payload.
|
|
||||||
/// NOTE: real Tangent Hub TIPC is a binary protocol on TCP 64246; this JSON
|
|
||||||
/// framing is our internal bridge format — the macOS TIPC shim translates.
|
|
||||||
/// Framing/parse logic (buffering, partial frames) is identical either way.
|
|
||||||
public enum TangentMessage: Equatable, Sendable {
|
|
||||||
case controlUpdate(PanelInput)
|
|
||||||
case displayUpdate(line: Int, text: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum TangentWireError: Error {
|
|
||||||
case badPayload
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum TangentWire {
|
|
||||||
|
|
||||||
public static func encode(_ msg: TangentMessage) -> Data {
|
|
||||||
var obj: [String: Any] = [:]
|
|
||||||
switch msg {
|
|
||||||
case .controlUpdate(.encoder(let id, let delta)):
|
|
||||||
obj = ["type": "encoder", "id": id, "delta": delta]
|
|
||||||
case .controlUpdate(.button(let id, let pressed)):
|
|
||||||
obj = ["type": "button", "id": id, "pressed": pressed]
|
|
||||||
case .displayUpdate(let line, let text):
|
|
||||||
obj = ["type": "display", "line": line, "text": text]
|
|
||||||
}
|
|
||||||
let payload = (try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys])) ?? Data()
|
|
||||||
var out = Data()
|
|
||||||
var len = UInt32(payload.count).littleEndian
|
|
||||||
withUnsafeBytes(of: &len) { out.append(contentsOf: $0) }
|
|
||||||
out.append(payload)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode first complete frame from buffer, consuming it. nil if incomplete.
|
|
||||||
public static func decodeFirst(_ buffer: inout Data) throws -> TangentMessage? {
|
|
||||||
guard buffer.count >= 4 else { return nil }
|
|
||||||
// Byte-wise LE assembly (Data slices may be misaligned for u32 loads).
|
|
||||||
let b = [UInt8](buffer.prefix(4))
|
|
||||||
let length = UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24)
|
|
||||||
guard buffer.count >= 4 + Int(length) else { return nil }
|
|
||||||
let payload = buffer.subdata(in: buffer.startIndex + 4..<buffer.startIndex + 4 + Int(length))
|
|
||||||
buffer.removeFirst(4 + Int(length))
|
|
||||||
|
|
||||||
guard let obj = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
|
||||||
let type = obj["type"] as? String else {
|
|
||||||
throw TangentWireError.badPayload
|
|
||||||
}
|
|
||||||
switch type {
|
|
||||||
case "encoder":
|
|
||||||
guard let id = obj["id"] as? Int, let delta = obj["delta"] as? Int else {
|
|
||||||
throw TangentWireError.badPayload
|
|
||||||
}
|
|
||||||
return .controlUpdate(.encoder(id: id, delta: delta))
|
|
||||||
case "button":
|
|
||||||
guard let id = obj["id"] as? Int, let pressed = obj["pressed"] as? Bool else {
|
|
||||||
throw TangentWireError.badPayload
|
|
||||||
}
|
|
||||||
return .controlUpdate(.button(id: id, pressed: pressed))
|
|
||||||
case "display":
|
|
||||||
guard let line = obj["line"] as? Int, let text = obj["text"] as? String else {
|
|
||||||
throw TangentWireError.badPayload
|
|
||||||
}
|
|
||||||
return .displayUpdate(line: line, text: text)
|
|
||||||
default:
|
|
||||||
throw TangentWireError.badPayload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
Sources/ForgeProxy/ForgeProxy.swift
Normal file
1
Sources/ForgeProxy/ForgeProxy.swift
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
// ForgeProxy
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import ForgeColor
|
|
||||||
import ForgeGrade
|
|
||||||
|
|
||||||
public enum ProxyError: Error {
|
|
||||||
case ffmpegFailed(code: Int32, stderr: String)
|
|
||||||
case ffmpegNotFound(String)
|
|
||||||
case badSnapshot(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Codec presets.
|
|
||||||
public enum ProxyPreset: String, Sendable, CaseIterable {
|
|
||||||
case proresProxy = "prores-proxy"
|
|
||||||
case dnxhrLB = "dnxhr-lb"
|
|
||||||
case h264 = "h264"
|
|
||||||
|
|
||||||
var codecArgs: [String] {
|
|
||||||
switch self {
|
|
||||||
case .proresProxy:
|
|
||||||
return ["-c:v", "prores_ks", "-profile:v", "0"]
|
|
||||||
case .dnxhrLB:
|
|
||||||
return ["-c:v", "dnxhd", "-profile:v", "dnxhr_lb"]
|
|
||||||
case .h264:
|
|
||||||
return ["-c:v", "libx264", "-preset", "fast", "-crf", "20"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum ProxyScale: String, Sendable {
|
|
||||||
case full
|
|
||||||
case half
|
|
||||||
case quarter
|
|
||||||
|
|
||||||
var filter: String? {
|
|
||||||
switch self {
|
|
||||||
case .full: return nil
|
|
||||||
case .half: return "scale=iw/2:ih/2"
|
|
||||||
case .quarter: return "scale=iw/4:ih/4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct BurnIn: Sendable {
|
|
||||||
public let clipName: String
|
|
||||||
public let timecode: String
|
|
||||||
public let fps: Int
|
|
||||||
|
|
||||||
public init(clipName: String, timecode: String, fps: Int) {
|
|
||||||
self.clipName = clipName
|
|
||||||
self.timecode = timecode
|
|
||||||
self.fps = fps
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Assembles ffmpeg argv. Filter order: lut3d (grade) -> scale -> drawtext (burn).
|
|
||||||
public enum FFmpegCommandBuilder {
|
|
||||||
|
|
||||||
/// Escape a path for use inside an ffmpeg filter option value.
|
|
||||||
static func filterEscape(_ path: String) -> String {
|
|
||||||
path.replacingOccurrences(of: "\\", with: "\\\\")
|
|
||||||
.replacingOccurrences(of: "'", with: "\\'")
|
|
||||||
.replacingOccurrences(of: ":", with: "\\:")
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func build(
|
|
||||||
input: String,
|
|
||||||
output: String,
|
|
||||||
preset: ProxyPreset,
|
|
||||||
scale: ProxyScale,
|
|
||||||
lutPath: String?,
|
|
||||||
burnIn: BurnIn?
|
|
||||||
) -> [String] {
|
|
||||||
var args = ["-y", "-i", input]
|
|
||||||
|
|
||||||
var filters = [String]()
|
|
||||||
if let lut = lutPath {
|
|
||||||
filters.append("lut3d='\(filterEscape(lut))'")
|
|
||||||
}
|
|
||||||
if let s = scale.filter {
|
|
||||||
filters.append(s)
|
|
||||||
}
|
|
||||||
if let burn = burnIn {
|
|
||||||
let tcEscaped = burn.timecode.replacingOccurrences(of: ":", with: "\\:")
|
|
||||||
filters.append(
|
|
||||||
"drawtext=text='\(burn.clipName)':x=20:y=20:fontsize=24:fontcolor=white:box=1:boxcolor=black@0.5")
|
|
||||||
filters.append(
|
|
||||||
"drawtext=timecode='\(tcEscaped)':rate=\(burn.fps):x=20:y=h-40:fontsize=24:fontcolor=white:box=1:boxcolor=black@0.5")
|
|
||||||
}
|
|
||||||
if !filters.isEmpty {
|
|
||||||
args += ["-vf", filters.joined(separator: ",")]
|
|
||||||
}
|
|
||||||
|
|
||||||
args += preset.codecArgs
|
|
||||||
args += ["-c:a", "copy"]
|
|
||||||
args.append(output)
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs ffmpeg as subprocess.
|
|
||||||
public struct ProxyRunner: Sendable {
|
|
||||||
public let ffmpegPath: String
|
|
||||||
|
|
||||||
public init(ffmpegPath: String = "/usr/bin/ffmpeg") {
|
|
||||||
self.ffmpegPath = ffmpegPath
|
|
||||||
}
|
|
||||||
|
|
||||||
public func run(args: [String]) async throws {
|
|
||||||
guard FileManager.default.isExecutableFile(atPath: ffmpegPath) else {
|
|
||||||
throw ProxyError.ffmpegNotFound(ffmpegPath)
|
|
||||||
}
|
|
||||||
let process = Process()
|
|
||||||
process.executableURL = URL(fileURLWithPath: ffmpegPath)
|
|
||||||
process.arguments = args
|
|
||||||
let stderrPipe = Pipe()
|
|
||||||
process.standardError = stderrPipe
|
|
||||||
process.standardOutput = Pipe()
|
|
||||||
|
|
||||||
try process.run()
|
|
||||||
// Read stderr concurrently to avoid pipe-buffer deadlock on long output.
|
|
||||||
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
process.waitUntilExit()
|
|
||||||
|
|
||||||
guard process.terminationStatus == 0 else {
|
|
||||||
throw ProxyError.ffmpegFailed(
|
|
||||||
code: process.terminationStatus,
|
|
||||||
stderr: String(data: stderrData, encoding: .utf8) ?? "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One proxy render: optional grade snapshot baked to temp .cube, burned via lut3d.
|
|
||||||
public struct ProxyJob: Sendable {
|
|
||||||
public let input: String
|
|
||||||
public let output: String
|
|
||||||
public let preset: ProxyPreset
|
|
||||||
public let scale: ProxyScale
|
|
||||||
public let gradeSnapshot: Data?
|
|
||||||
public let burnIn: BurnIn?
|
|
||||||
|
|
||||||
public init(input: String, output: String, preset: ProxyPreset, scale: ProxyScale,
|
|
||||||
gradeSnapshot: Data?, burnIn: BurnIn?) {
|
|
||||||
self.input = input
|
|
||||||
self.output = output
|
|
||||||
self.preset = preset
|
|
||||||
self.scale = scale
|
|
||||||
self.gradeSnapshot = gradeSnapshot
|
|
||||||
self.burnIn = burnIn
|
|
||||||
}
|
|
||||||
|
|
||||||
public func run(ffmpegPath: String, latticeSize: Int = 33) async throws {
|
|
||||||
var lutPath: String?
|
|
||||||
if let snapshot = gradeSnapshot {
|
|
||||||
let stack: GradeStack
|
|
||||||
do {
|
|
||||||
stack = try GradeSnapshot.decode(snapshot)
|
|
||||||
} catch {
|
|
||||||
throw ProxyError.badSnapshot("\(error)")
|
|
||||||
}
|
|
||||||
let look = Flattener.flatten(stack: stack, latticeSize: latticeSize, splitLeadingCDL: false)
|
|
||||||
if let lut = look.lut {
|
|
||||||
let path = NSTemporaryDirectory() + "forge-proxy-\(UUID().uuidString).cube"
|
|
||||||
try CubeFile.write(lut, title: "forge proxy grade")
|
|
||||||
.write(toFile: path, atomically: true, encoding: .utf8)
|
|
||||||
lutPath = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer {
|
|
||||||
if let p = lutPath { try? FileManager.default.removeItem(atPath: p) }
|
|
||||||
}
|
|
||||||
|
|
||||||
let args = FFmpegCommandBuilder.build(
|
|
||||||
input: input, output: output, preset: preset, scale: scale,
|
|
||||||
lutPath: lutPath, burnIn: burnIn)
|
|
||||||
try await ProxyRunner(ffmpegPath: ffmpegPath).run(args: args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Tracks files already handed off — shared across watcher restarts.
|
|
||||||
public actor ProcessedSkipList {
|
|
||||||
private var processed = Set<String>()
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
public func contains(_ path: String) -> Bool {
|
|
||||||
processed.contains(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func mark(_ path: String) {
|
|
||||||
processed.insert(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Polling watch folder. Emits file paths once they are:
|
|
||||||
/// - matching extension
|
|
||||||
/// - size-stable for `stabilityChecks` consecutive polls (partial-write guard)
|
|
||||||
/// - not in the skip list
|
|
||||||
/// Polling (not inotify): cheap at DIT-cart scale, identical behavior on
|
|
||||||
/// network volumes where inotify is unreliable.
|
|
||||||
public actor WatchFolder {
|
|
||||||
private let path: String
|
|
||||||
private let extensions: Set<String>
|
|
||||||
private let pollInterval: Duration
|
|
||||||
private let stabilityChecks: Int
|
|
||||||
private let skipList: ProcessedSkipList
|
|
||||||
|
|
||||||
private var sizeHistory: [String: (size: UInt64, stableCount: Int)] = [:]
|
|
||||||
private var task: Task<Void, Never>?
|
|
||||||
private var continuation: AsyncStream<String>.Continuation?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
path: String,
|
|
||||||
extensions: [String],
|
|
||||||
pollInterval: Duration = .seconds(2),
|
|
||||||
stabilityChecks: Int = 2,
|
|
||||||
skipList: ProcessedSkipList = ProcessedSkipList()
|
|
||||||
) {
|
|
||||||
self.path = path
|
|
||||||
self.extensions = Set(extensions.map { $0.lowercased() })
|
|
||||||
self.pollInterval = pollInterval
|
|
||||||
self.stabilityChecks = stabilityChecks
|
|
||||||
self.skipList = skipList
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start watching; returns stream of ready file paths.
|
|
||||||
public func start() -> AsyncStream<String> {
|
|
||||||
let stream = AsyncStream<String> { continuation in
|
|
||||||
self.continuation = continuation
|
|
||||||
}
|
|
||||||
task = Task {
|
|
||||||
while !Task.isCancelled {
|
|
||||||
await pollOnce()
|
|
||||||
try? await Task.sleep(for: pollInterval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return stream
|
|
||||||
}
|
|
||||||
|
|
||||||
public func stop() {
|
|
||||||
task?.cancel()
|
|
||||||
task = nil
|
|
||||||
continuation?.finish()
|
|
||||||
continuation = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pollOnce() async {
|
|
||||||
let fm = FileManager.default
|
|
||||||
guard let entries = try? fm.contentsOfDirectory(atPath: path) else { return }
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
let ext = (entry as NSString).pathExtension.lowercased()
|
|
||||||
guard extensions.contains(ext) else { continue }
|
|
||||||
let full = path + "/" + entry
|
|
||||||
if await skipList.contains(full) { continue }
|
|
||||||
|
|
||||||
guard let attrs = try? fm.attributesOfItem(atPath: full),
|
|
||||||
let size = attrs[.size] as? UInt64 else { continue }
|
|
||||||
|
|
||||||
if let prev = sizeHistory[full] {
|
|
||||||
if prev.size == size {
|
|
||||||
let stable = prev.stableCount + 1
|
|
||||||
if stable >= stabilityChecks {
|
|
||||||
await skipList.mark(full)
|
|
||||||
sizeHistory.removeValue(forKey: full)
|
|
||||||
continuation?.yield(full)
|
|
||||||
} else {
|
|
||||||
sizeHistory[full] = (size, stable)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sizeHistory[full] = (size, 0) // still growing
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sizeHistory[full] = (size, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
Sources/ForgeVideo/ForgeVideo.swift
Normal file
1
Sources/ForgeVideo/ForgeVideo.swift
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
// ForgeVideo
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import ForgeGrade
|
|
||||||
import ForgeColor
|
|
||||||
|
|
||||||
/// One video frame: linear float RGB pixels, row-major.
|
|
||||||
public struct VideoFrame: Sendable {
|
|
||||||
public let width: Int
|
|
||||||
public let height: Int
|
|
||||||
public var pixels: [SIMD3<Float>]
|
|
||||||
public var timecode: String?
|
|
||||||
|
|
||||||
public init(width: Int, height: Int, pixels: [SIMD3<Float>], timecode: String? = nil) {
|
|
||||||
precondition(pixels.count == width * height)
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self.pixels = pixels
|
|
||||||
self.timecode = timecode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Video input seam. DeckLink capture implements this on macOS.
|
|
||||||
public protocol FrameSource: Actor {
|
|
||||||
func nextFrame() async throws -> VideoFrame
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Video output seam. DeckLink playback implements this on macOS.
|
|
||||||
public protocol FrameSink: Actor {
|
|
||||||
func consume(frame: VideoFrame) async throws
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Solid-color generator for tests/demo.
|
|
||||||
public actor TestPatternSource: FrameSource {
|
|
||||||
private let width: Int
|
|
||||||
private let height: Int
|
|
||||||
private let color: SIMD3<Float>
|
|
||||||
private var frameCount = 0
|
|
||||||
|
|
||||||
public init(width: Int, height: Int, color: SIMD3<Float>) {
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self.color = color
|
|
||||||
}
|
|
||||||
|
|
||||||
public func nextFrame() async throws -> VideoFrame {
|
|
||||||
frameCount += 1
|
|
||||||
let f = frameCount % 24
|
|
||||||
let s = (frameCount / 24) % 60
|
|
||||||
return VideoFrame(
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
pixels: [SIMD3<Float>](repeating: color, count: width * height),
|
|
||||||
timecode: String(format: "00:00:%02d:%02d", s, f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Discards frames; counts consumption. SDI-out stand-in for tests.
|
|
||||||
public actor NullFrameSink: FrameSink {
|
|
||||||
public private(set) var consumedCount = 0
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
public func consume(frame: VideoFrame) async throws {
|
|
||||||
consumedCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// CPU grade application (preview path stand-in; Metal on macOS).
|
|
||||||
public enum FrameProcessor {
|
|
||||||
public static func apply(stack: GradeStack, to frame: VideoFrame) -> VideoFrame {
|
|
||||||
var out = frame
|
|
||||||
for i in out.pixels.indices {
|
|
||||||
out.pixels[i] = stack.evaluate(out.pixels[i])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes frame grabs as binary PPM (P6) — dependency-free still format.
|
|
||||||
public struct StillGrabber: Sendable {
|
|
||||||
public let directory: String
|
|
||||||
|
|
||||||
public init(directory: String) {
|
|
||||||
self.directory = directory
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns written file path.
|
|
||||||
@discardableResult
|
|
||||||
public func grab(frame: VideoFrame, clipName: String) throws -> String {
|
|
||||||
try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
|
|
||||||
let path = "\(directory)/\(clipName)_\(timestamp).ppm"
|
|
||||||
|
|
||||||
var data = Data("P6\n\(frame.width) \(frame.height)\n255\n".utf8)
|
|
||||||
data.reserveCapacity(data.count + frame.pixels.count * 3)
|
|
||||||
for px in frame.pixels {
|
|
||||||
data.append(UInt8(min(max(px.x, 0), 1) * 255 + 0.5))
|
|
||||||
data.append(UInt8(min(max(px.y, 0), 1) * 255 + 0.5))
|
|
||||||
data.append(UInt8(min(max(px.z, 0), 1) * 255 + 0.5))
|
|
||||||
}
|
|
||||||
try data.write(to: URL(fileURLWithPath: path))
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,155 +7,13 @@ import ForgeColor
|
||||||
import ForgeSim
|
import ForgeSim
|
||||||
import ForgeLibrary
|
import ForgeLibrary
|
||||||
import ForgeExport
|
import ForgeExport
|
||||||
import ForgeOffload
|
|
||||||
import ForgeProxy
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct Forge: AsyncParsableCommand {
|
struct Forge: AsyncParsableCommand {
|
||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: "forge",
|
commandName: "forge",
|
||||||
abstract: "On-set live grading + DIT toolkit: camera look push, clip logging, offload, proxies.",
|
abstract: "On-set live grading: push looks to cameras over IP.",
|
||||||
subcommands: [Push.self, Status.self, Sim.self, Export.self, Offload.self, Verify.self, Proxy.self])
|
subcommands: [Push.self, Status.self, Sim.self, Export.self])
|
||||||
}
|
|
||||||
|
|
||||||
struct Proxy: AsyncParsableCommand {
|
|
||||||
static let configuration = CommandConfiguration(abstract: "Render proxies for a day's shots, optionally with grade + burn-ins.")
|
|
||||||
|
|
||||||
@Option(name: .long, help: "Library database path")
|
|
||||||
var db: String
|
|
||||||
|
|
||||||
@Option(name: .long, help: "Day label")
|
|
||||||
var day: String
|
|
||||||
|
|
||||||
@Option(name: .long, help: "Directory containing source clips (matched by clip name)")
|
|
||||||
var media: String
|
|
||||||
|
|
||||||
@Option(name: .long, help: "Output directory")
|
|
||||||
var out: String
|
|
||||||
|
|
||||||
@Option(name: .long, help: "Preset: prores-proxy|dnxhr-lb|h264")
|
|
||||||
var preset: String = "h264"
|
|
||||||
|
|
||||||
@Option(name: .long, help: "Scale: full|half|quarter")
|
|
||||||
var scale: String = "half"
|
|
||||||
|
|
||||||
@Flag(name: .long, help: "Bake shot grade into proxy")
|
|
||||||
var burnLook = false
|
|
||||||
|
|
||||||
@Flag(name: .long, help: "Burn clip name + timecode overlay")
|
|
||||||
var burnText = false
|
|
||||||
|
|
||||||
@Option(name: .long, help: "ffmpeg binary path")
|
|
||||||
var ffmpeg: String = "/usr/bin/ffmpeg"
|
|
||||||
|
|
||||||
func run() async throws {
|
|
||||||
guard let presetVal = ProxyPreset(rawValue: preset) else {
|
|
||||||
throw ValidationError("unknown preset '\(preset)'")
|
|
||||||
}
|
|
||||||
guard let scaleVal = ProxyScale(rawValue: scale) else {
|
|
||||||
throw ValidationError("unknown scale '\(scale)'")
|
|
||||||
}
|
|
||||||
let store = try LibraryStore(path: db)
|
|
||||||
var target: ForgeLibrary.Day?
|
|
||||||
for p in try store.listProjects() {
|
|
||||||
if let d = try store.listDays(projectID: p.id).first(where: { $0.label == day }) {
|
|
||||||
target = d
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard let target else { throw ValidationError("day '\(day)' not found") }
|
|
||||||
let shots = try store.listShots(dayID: target.id)
|
|
||||||
try FileManager.default.createDirectory(atPath: out, withIntermediateDirectories: true)
|
|
||||||
|
|
||||||
var rendered = 0
|
|
||||||
var missing = 0
|
|
||||||
for shot in shots {
|
|
||||||
// Find source by clip name with common extensions.
|
|
||||||
let candidates = ["mov", "mxf", "mp4", "braw", "arriraw"].map {
|
|
||||||
"\(media)/\(shot.clipName).\($0)"
|
|
||||||
}
|
|
||||||
guard let input = candidates.first(where: { FileManager.default.fileExists(atPath: $0) }) else {
|
|
||||||
print("skip \(shot.clipName): no source in \(media)")
|
|
||||||
missing += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let ext = presetVal == .h264 ? "mp4" : (presetVal == .dnxhrLB ? "mxf" : "mov")
|
|
||||||
let job = ProxyJob(
|
|
||||||
input: input,
|
|
||||||
output: "\(out)/\(shot.clipName).\(ext)",
|
|
||||||
preset: presetVal,
|
|
||||||
scale: scaleVal,
|
|
||||||
gradeSnapshot: burnLook ? shot.gradeSnapshot : nil,
|
|
||||||
burnIn: burnText ? BurnIn(
|
|
||||||
clipName: shot.clipName,
|
|
||||||
timecode: shot.timecodeIn ?? "00:00:00:00",
|
|
||||||
fps: Int(shot.fps ?? 24)) : nil)
|
|
||||||
try await job.run(ffmpegPath: ffmpeg)
|
|
||||||
print("rendered \(shot.clipName)")
|
|
||||||
rendered += 1
|
|
||||||
}
|
|
||||||
print("proxies: \(rendered) rendered, \(missing) missing sources")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Offload: AsyncParsableCommand {
|
|
||||||
static let configuration = CommandConfiguration(abstract: "Verified copy of a card to one or more destinations, with MHL manifest.")
|
|
||||||
|
|
||||||
@Argument(help: "Source directory (camera card)")
|
|
||||||
var source: String
|
|
||||||
|
|
||||||
@Option(name: [.customLong("to")], help: "Destination directory (repeatable)")
|
|
||||||
var destinations: [String]
|
|
||||||
|
|
||||||
@Flag(name: .long, help: "Re-copy destination files with mismatched size (interrupted copies)")
|
|
||||||
var overwritePartial = false
|
|
||||||
|
|
||||||
@Option(name: .long, help: "Write HTML report to this path")
|
|
||||||
var report: String?
|
|
||||||
|
|
||||||
func run() async throws {
|
|
||||||
guard !destinations.isEmpty else {
|
|
||||||
throw ValidationError("at least one --to destination required")
|
|
||||||
}
|
|
||||||
let engine = CopyEngine()
|
|
||||||
let result = try engine.offload(source: source, destinations: destinations, overwritePartial: overwritePartial)
|
|
||||||
|
|
||||||
// MHL manifest at each destination root.
|
|
||||||
let files = result.files.map(\.relativePath)
|
|
||||||
for dest in destinations {
|
|
||||||
try MHL.write(root: dest, creator: "Forge", files: files, to: dest + "/" + ((source as NSString).lastPathComponent) + ".mhl")
|
|
||||||
}
|
|
||||||
if let reportPath = report {
|
|
||||||
try OffloadReportWriter.html(result).write(toFile: reportPath, atomically: true, encoding: .utf8)
|
|
||||||
}
|
|
||||||
let copied = result.files.filter { !$0.skipped }.count
|
|
||||||
print("offloaded \(result.files.count) files (\(copied) copied, \(result.files.count - copied) skipped), \(OffloadReportWriter.formatBytes(result.totalBytes)) to \(destinations.count) destination(s) — all verified")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Verify: AsyncParsableCommand {
|
|
||||||
static let configuration = CommandConfiguration(abstract: "Verify a tree against an MHL manifest.")
|
|
||||||
|
|
||||||
@Argument(help: "Path to .mhl manifest")
|
|
||||||
var manifest: String
|
|
||||||
|
|
||||||
@Option(name: .long, help: "Tree root (default: manifest's directory)")
|
|
||||||
var root: String?
|
|
||||||
|
|
||||||
func run() async throws {
|
|
||||||
let manifestText = try String(contentsOfFile: manifest, encoding: .utf8)
|
|
||||||
let treeRoot = root ?? (manifest as NSString).deletingLastPathComponent
|
|
||||||
let result = try MHL.verify(manifest: manifestText, root: treeRoot)
|
|
||||||
if result.passed {
|
|
||||||
print("PASSED: \(result.checked) files verified")
|
|
||||||
} else {
|
|
||||||
print("FAILED: \(result.failures.count)/\(result.checked) files:")
|
|
||||||
for f in result.failures {
|
|
||||||
print(" \(f.path): \(f.reason)")
|
|
||||||
}
|
|
||||||
throw ExitCode(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Export: AsyncParsableCommand {
|
struct Export: AsyncParsableCommand {
|
||||||
|
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import XCTest
|
|
||||||
import Foundation
|
|
||||||
import ForgeGrade
|
|
||||||
import ForgeColor
|
|
||||||
@testable import ForgeCamera
|
|
||||||
|
|
||||||
/// Counts pushes with timestamps.
|
|
||||||
actor CountingDriver: CameraDriver {
|
|
||||||
nonisolated let capabilities = CameraCapabilities(
|
|
||||||
vendor: .simulated, supportsNativeCDL: true, lut3dSizes: [17, 33], metadataFields: [])
|
|
||||||
|
|
||||||
private(set) var pushes = [(look: FlattenedLook, at: ContinuousClock.Instant)]()
|
|
||||||
|
|
||||||
nonisolated var state: AsyncStream<CameraState> {
|
|
||||||
AsyncStream { _ in }
|
|
||||||
}
|
|
||||||
|
|
||||||
func connect() async throws {}
|
|
||||||
func disconnect() async {}
|
|
||||||
func push(look: FlattenedLook) async throws {
|
|
||||||
pushes.append((look, ContinuousClock.now))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class DebouncedPushTests: XCTestCase {
|
|
||||||
|
|
||||||
func grade(slope: Float) -> GradeStack {
|
|
||||||
GradeStack(nodes: [GradeNode(kind: .cdl(CDL(
|
|
||||||
slope: SIMD3(repeating: slope), offset: .zero, power: .one, saturation: 1)))])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rapid updates coalesce: >= 1 push, far fewer than updates, trailing value wins.
|
|
||||||
func testCoalescing() async throws {
|
|
||||||
let driver = CountingDriver()
|
|
||||||
let pusher = DebouncedPusher(driver: driver, interval: .milliseconds(50), latticeSize: 17)
|
|
||||||
|
|
||||||
// 30 rapid updates (panel spin).
|
|
||||||
for i in 0..<30 {
|
|
||||||
await pusher.update(grade: grade(slope: 1.0 + Float(i) * 0.01))
|
|
||||||
try await Task.sleep(for: .milliseconds(2))
|
|
||||||
}
|
|
||||||
// Allow trailing push to flush.
|
|
||||||
try await Task.sleep(for: .milliseconds(150))
|
|
||||||
|
|
||||||
let pushes = await driver.pushes
|
|
||||||
XCTAssertGreaterThanOrEqual(pushes.count, 1)
|
|
||||||
XCTAssertLessThan(pushes.count, 10, "expected coalescing, got \(pushes.count) pushes for 30 updates")
|
|
||||||
// Trailing edge: final pushed look reflects last update (slope 1.29).
|
|
||||||
let lastCDL = pushes.last!.look.cdl
|
|
||||||
XCTAssertEqual(lastCDL?.slope.x ?? 0, 1.29, accuracy: 1e-4)
|
|
||||||
await pusher.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single update pushes exactly once.
|
|
||||||
func testSingleUpdateSinglePush() async throws {
|
|
||||||
let driver = CountingDriver()
|
|
||||||
let pusher = DebouncedPusher(driver: driver, interval: .milliseconds(30), latticeSize: 17)
|
|
||||||
await pusher.update(grade: grade(slope: 1.5))
|
|
||||||
try await Task.sleep(for: .milliseconds(120))
|
|
||||||
let pushes = await driver.pushes
|
|
||||||
XCTAssertEqual(pushes.count, 1)
|
|
||||||
await pusher.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pushes respect min interval spacing.
|
|
||||||
func testMinIntervalRespected() async throws {
|
|
||||||
let driver = CountingDriver()
|
|
||||||
let pusher = DebouncedPusher(driver: driver, interval: .milliseconds(60), latticeSize: 17)
|
|
||||||
for i in 0..<20 {
|
|
||||||
await pusher.update(grade: grade(slope: 1.0 + Float(i) * 0.01))
|
|
||||||
try await Task.sleep(for: .milliseconds(10))
|
|
||||||
}
|
|
||||||
try await Task.sleep(for: .milliseconds(200))
|
|
||||||
let pushes = await driver.pushes
|
|
||||||
for pair in zip(pushes, pushes.dropFirst()) {
|
|
||||||
let gap = pair.0.at.duration(to: pair.1.at)
|
|
||||||
XCTAssertGreaterThanOrEqual(gap, .milliseconds(55), "pushes too close: \(gap)")
|
|
||||||
}
|
|
||||||
await pusher.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Independent pushers per camera: no cross-interference.
|
|
||||||
func testPerCameraIndependence() async throws {
|
|
||||||
let driverA = CountingDriver()
|
|
||||||
let driverB = CountingDriver()
|
|
||||||
let pusherA = DebouncedPusher(driver: driverA, interval: .milliseconds(30), latticeSize: 17)
|
|
||||||
let pusherB = DebouncedPusher(driver: driverB, interval: .milliseconds(30), latticeSize: 17)
|
|
||||||
|
|
||||||
await pusherA.update(grade: grade(slope: 1.1))
|
|
||||||
// B gets nothing.
|
|
||||||
try await Task.sleep(for: .milliseconds(120))
|
|
||||||
let pushesA = await driverA.pushes
|
|
||||||
let pushesB = await driverB.pushes
|
|
||||||
XCTAssertEqual(pushesA.count, 1)
|
|
||||||
XCTAssertEqual(pushesB.count, 0)
|
|
||||||
await pusherA.stop()
|
|
||||||
await pusherB.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// stop() cancels pending trailing push.
|
|
||||||
func testStopCancelsPending() async throws {
|
|
||||||
let driver = CountingDriver()
|
|
||||||
let pusher = DebouncedPusher(driver: driver, interval: .milliseconds(100), latticeSize: 17)
|
|
||||||
await pusher.update(grade: grade(slope: 1.2))
|
|
||||||
await pusher.update(grade: grade(slope: 1.3)) // pending trailing
|
|
||||||
await pusher.stop()
|
|
||||||
try await Task.sleep(for: .milliseconds(200))
|
|
||||||
let pushes = await driver.pushes
|
|
||||||
// First push may have fired immediately; trailing must not after stop.
|
|
||||||
XCTAssertLessThanOrEqual(pushes.count, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import XCTest
|
|
||||||
import Foundation
|
|
||||||
@testable import ForgeOffload
|
|
||||||
|
|
||||||
final class CopyEngineTests: XCTestCase {
|
|
||||||
|
|
||||||
var root: String!
|
|
||||||
var source: String!
|
|
||||||
var dest1: String!
|
|
||||||
var dest2: String!
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
root = NSTemporaryDirectory() + "forge-copy-\(UUID().uuidString)"
|
|
||||||
source = root + "/CARD_A001"
|
|
||||||
dest1 = root + "/RAID1/A001"
|
|
||||||
dest2 = root + "/SHUTTLE/A001"
|
|
||||||
try FileManager.default.createDirectory(atPath: source + "/CLIPS", withIntermediateDirectories: true)
|
|
||||||
|
|
||||||
// Source tree: two clips + sidecar.
|
|
||||||
try makeFile(source + "/CLIPS/A001C001.mov", size: 200_000, seed: 1)
|
|
||||||
try makeFile(source + "/CLIPS/A001C002.mov", size: 350_000, seed: 2)
|
|
||||||
try makeFile(source + "/A001.xml", size: 512, seed: 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
try? FileManager.default.removeItem(atPath: root)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeFile(_ path: String, size: Int, seed: UInt64) throws {
|
|
||||||
var data = Data(capacity: size)
|
|
||||||
var s = seed
|
|
||||||
while data.count < size {
|
|
||||||
s = s &* 6364136223846793005 &+ 1442695040888963407
|
|
||||||
withUnsafeBytes(of: s) { data.append(contentsOf: $0.prefix(min(8, size - data.count))) }
|
|
||||||
}
|
|
||||||
try data.write(to: URL(fileURLWithPath: path))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy to two destinations: byte-identical, verified, report per file.
|
|
||||||
func testCopyTwoDestinationsVerified() throws {
|
|
||||||
let engine = CopyEngine()
|
|
||||||
let report = try engine.offload(source: source, destinations: [dest1, dest2])
|
|
||||||
|
|
||||||
XCTAssertEqual(report.files.count, 3)
|
|
||||||
XCTAssertTrue(report.files.allSatisfy { $0.verified })
|
|
||||||
|
|
||||||
for dest in [dest1!, dest2!] {
|
|
||||||
for rel in ["CLIPS/A001C001.mov", "CLIPS/A001C002.mov", "A001.xml"] {
|
|
||||||
let srcHash = try FileHasher.xxh64(path: source + "/" + rel)
|
|
||||||
let dstHash = try FileHasher.xxh64(path: dest + "/" + rel)
|
|
||||||
XCTAssertEqual(srcHash, dstHash, "hash mismatch for \(rel) in \(dest)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Report carries source hashes.
|
|
||||||
XCTAssertEqual(Set(report.files.map(\.relativePath)),
|
|
||||||
["CLIPS/A001C001.mov", "CLIPS/A001C002.mov", "A001.xml"])
|
|
||||||
XCTAssertTrue(report.files.allSatisfy { $0.xxh64.count == 16 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Existing identical file at destination: skipped, still verified.
|
|
||||||
func testExistingIdenticalSkipped() throws {
|
|
||||||
let engine = CopyEngine()
|
|
||||||
_ = try engine.offload(source: source, destinations: [dest1])
|
|
||||||
// Second run: all files exist + match.
|
|
||||||
let report2 = try engine.offload(source: source, destinations: [dest1])
|
|
||||||
XCTAssertTrue(report2.files.allSatisfy { $0.skipped })
|
|
||||||
XCTAssertTrue(report2.files.allSatisfy { $0.verified })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Existing file with different content: hard error (never silently overwrite).
|
|
||||||
func testConflictErrors() throws {
|
|
||||||
let engine = CopyEngine()
|
|
||||||
_ = try engine.offload(source: source, destinations: [dest1])
|
|
||||||
// Tamper destination clip.
|
|
||||||
try makeFile(dest1 + "/CLIPS/A001C001.mov", size: 200_000, seed: 99)
|
|
||||||
XCTAssertThrowsError(try engine.offload(source: source, destinations: [dest1])) { error in
|
|
||||||
guard case OffloadError.destinationConflict = error else {
|
|
||||||
return XCTFail("wrong error: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Partial file (interrupted copy simulation): re-copied, ends verified.
|
|
||||||
func testPartialFileRecopied() throws {
|
|
||||||
let engine = CopyEngine()
|
|
||||||
// Simulate interrupted copy: partial temp content at final path with wrong size.
|
|
||||||
try FileManager.default.createDirectory(atPath: dest1 + "/CLIPS", withIntermediateDirectories: true)
|
|
||||||
try makeFile(dest1 + "/CLIPS/A001C001.mov", size: 50_000, seed: 1) // truncated
|
|
||||||
let report = try engine.offload(source: source, destinations: [dest1], overwritePartial: true)
|
|
||||||
let entry = report.files.first { $0.relativePath == "CLIPS/A001C001.mov" }!
|
|
||||||
XCTAssertFalse(entry.skipped)
|
|
||||||
XCTAssertTrue(entry.verified)
|
|
||||||
let srcHash = try FileHasher.xxh64(path: source + "/CLIPS/A001C001.mov")
|
|
||||||
XCTAssertEqual(try FileHasher.xxh64(path: dest1 + "/CLIPS/A001C001.mov"), srcHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Missing source dir throws.
|
|
||||||
func testMissingSourceThrows() {
|
|
||||||
XCTAssertThrowsError(try CopyEngine().offload(source: root + "/nope", destinations: [dest1]))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report totals.
|
|
||||||
func testReportTotals() throws {
|
|
||||||
let report = try CopyEngine().offload(source: source, destinations: [dest1])
|
|
||||||
XCTAssertEqual(report.totalBytes, 200_000 + 350_000 + 512)
|
|
||||||
XCTAssertEqual(report.destinations, [dest1])
|
|
||||||
XCTAssertGreaterThan(report.duration, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import XCTest
|
|
||||||
import Foundation
|
|
||||||
@testable import ForgeOffload
|
|
||||||
|
|
||||||
final class HashingTests: XCTestCase {
|
|
||||||
|
|
||||||
// xxHash64 official spec vectors (seed 0).
|
|
||||||
func testXXHash64SpecVectors() {
|
|
||||||
XCTAssertEqual(XXHash64.hash(Data()), 0xEF46_DB37_51D8_E999)
|
|
||||||
XCTAssertEqual(XXHash64.hash(Data([0x9E])), 0x4FCE_394C_C88952D8)
|
|
||||||
// "Nobody inspects the spammish repetition" — widely published vector.
|
|
||||||
let spam = Data("Nobody inspects the spammish repetition".utf8)
|
|
||||||
XCTAssertEqual(XXHash64.hash(spam), 0xFBCE_A83C_8A378BF1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Known ASCII vector.
|
|
||||||
func testXXHash64Ascii() {
|
|
||||||
XCTAssertEqual(XXHash64.hash(Data("xxhash".utf8)), 0x32DD_38952C_4BC720)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Streaming hash == whole-buffer hash for multi-MB input crossing block sizes.
|
|
||||||
func testStreamingMatchesWhole() {
|
|
||||||
var data = Data()
|
|
||||||
var seed: UInt64 = 0x12345678
|
|
||||||
for _ in 0..<(3 * 1024 * 1024 / 8) {
|
|
||||||
seed = seed &* 6364136223846793005 &+ 1442695040888963407
|
|
||||||
withUnsafeBytes(of: seed) { data.append(contentsOf: $0) }
|
|
||||||
}
|
|
||||||
let whole = XXHash64.hash(data)
|
|
||||||
|
|
||||||
var streamer = XXHash64()
|
|
||||||
// Uneven chunk sizes to exercise buffering.
|
|
||||||
var offset = 0
|
|
||||||
let chunks = [1, 31, 32, 33, 4096, 1_000_000]
|
|
||||||
var i = 0
|
|
||||||
while offset < data.count {
|
|
||||||
let size = min(chunks[i % chunks.count], data.count - offset)
|
|
||||||
streamer.update(data.subdata(in: offset..<offset + size))
|
|
||||||
offset += size
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
XCTAssertEqual(streamer.finalize(), whole)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hex formatting: 16 lowercase hex chars, zero-padded.
|
|
||||||
func testHexFormat() {
|
|
||||||
let h = XXHash64.hexString(XXHash64.hash(Data("xxhash".utf8)))
|
|
||||||
XCTAssertEqual(h, "32dd38952c4bc720")
|
|
||||||
// Zero-padding for small values.
|
|
||||||
XCTAssertEqual(XXHash64.hexString(0xAB), "00000000000000ab")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MD5 known vectors (RFC 1321).
|
|
||||||
func testMD5Vectors() {
|
|
||||||
XCTAssertEqual(MD5.hexString(Data()), "d41d8cd98f00b204e9800998ecf8427e")
|
|
||||||
XCTAssertEqual(MD5.hexString(Data("abc".utf8)), "900150983cd24fb0d6963f7d28e17f72")
|
|
||||||
XCTAssertEqual(
|
|
||||||
MD5.hexString(Data("message digest".utf8)),
|
|
||||||
"f96b697d7cb7938d525a2f31aaf161d0")
|
|
||||||
}
|
|
||||||
|
|
||||||
// File hashing: chunked reader over a temp file matches in-memory hash.
|
|
||||||
func testFileHashing() throws {
|
|
||||||
let path = NSTemporaryDirectory() + "forge-hash-\(UUID().uuidString).bin"
|
|
||||||
defer { try? FileManager.default.removeItem(atPath: path) }
|
|
||||||
var data = Data()
|
|
||||||
for i in 0..<100_000 {
|
|
||||||
data.append(UInt8(truncatingIfNeeded: i))
|
|
||||||
}
|
|
||||||
try data.write(to: URL(fileURLWithPath: path))
|
|
||||||
|
|
||||||
let fileHash = try FileHasher.xxh64(path: path)
|
|
||||||
XCTAssertEqual(fileHash, XXHash64.hexString(XXHash64.hash(data)))
|
|
||||||
|
|
||||||
let fileMD5 = try FileHasher.md5(path: path)
|
|
||||||
XCTAssertEqual(fileMD5, MD5.hexString(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMissingFileThrows() {
|
|
||||||
XCTAssertThrowsError(try FileHasher.xxh64(path: "/nonexistent/nope.bin"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import XCTest
|
|
||||||
import Foundation
|
|
||||||
@testable import ForgeOffload
|
|
||||||
|
|
||||||
final class MHLTests: XCTestCase {
|
|
||||||
|
|
||||||
var root: String!
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
root = NSTemporaryDirectory() + "forge-mhl-\(UUID().uuidString)"
|
|
||||||
try FileManager.default.createDirectory(atPath: root + "/CLIPS", withIntermediateDirectories: true)
|
|
||||||
try Data("clip one contents".utf8).write(to: URL(fileURLWithPath: root + "/CLIPS/C001.mov"))
|
|
||||||
try Data("clip two contents, longer".utf8).write(to: URL(fileURLWithPath: root + "/CLIPS/C002.mov"))
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
try? FileManager.default.removeItem(atPath: root)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MHL XML structure: hashlist root, creatorinfo, hash entries with file/size/xxh64.
|
|
||||||
func testWriteStructure() throws {
|
|
||||||
let xml = try MHL.generate(root: root, creator: "Forge 0.1", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"])
|
|
||||||
XCTAssertTrue(xml.hasPrefix("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<hashlist version=\"2.0\">"))
|
|
||||||
XCTAssertTrue(xml.contains("<creatorinfo>"))
|
|
||||||
XCTAssertTrue(xml.contains("<tool>Forge 0.1</tool>"))
|
|
||||||
XCTAssertTrue(xml.contains("<path>CLIPS/C001.mov</path>"))
|
|
||||||
XCTAssertTrue(xml.contains("<size>17</size>"))
|
|
||||||
XCTAssertTrue(xml.contains("<xxh64>"))
|
|
||||||
XCTAssertTrue(xml.hasSuffix("</hashlist>\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify: clean tree passes.
|
|
||||||
func testVerifyClean() throws {
|
|
||||||
let xml = try MHL.generate(root: root, creator: "Forge", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"])
|
|
||||||
let result = try MHL.verify(manifest: xml, root: root)
|
|
||||||
XCTAssertTrue(result.passed)
|
|
||||||
XCTAssertEqual(result.checked, 2)
|
|
||||||
XCTAssertTrue(result.failures.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tamper detection: flipped byte fails that file.
|
|
||||||
func testVerifyTamperDetected() throws {
|
|
||||||
let xml = try MHL.generate(root: root, creator: "Forge", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"])
|
|
||||||
try Data("clip one CONTENTS".utf8).write(to: URL(fileURLWithPath: root + "/CLIPS/C001.mov"))
|
|
||||||
let result = try MHL.verify(manifest: xml, root: root)
|
|
||||||
XCTAssertFalse(result.passed)
|
|
||||||
XCTAssertEqual(result.failures.count, 1)
|
|
||||||
XCTAssertEqual(result.failures[0].path, "CLIPS/C001.mov")
|
|
||||||
XCTAssertEqual(result.failures[0].reason, .hashMismatch)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Missing file fails verify.
|
|
||||||
func testVerifyMissingFile() throws {
|
|
||||||
let xml = try MHL.generate(root: root, creator: "Forge", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"])
|
|
||||||
try FileManager.default.removeItem(atPath: root + "/CLIPS/C002.mov")
|
|
||||||
let result = try MHL.verify(manifest: xml, root: root)
|
|
||||||
XCTAssertFalse(result.passed)
|
|
||||||
XCTAssertEqual(result.failures[0].reason, .missing)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Round trip through file on disk.
|
|
||||||
func testManifestFileRoundTrip() throws {
|
|
||||||
let mhlPath = root + "/A001.mhl"
|
|
||||||
try MHL.write(root: root!, creator: "Forge", files: ["CLIPS/C001.mov"], to: mhlPath)
|
|
||||||
let loaded = try String(contentsOfFile: mhlPath, encoding: .utf8)
|
|
||||||
let result = try MHL.verify(manifest: loaded, root: root)
|
|
||||||
XCTAssertTrue(result.passed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Malformed manifest rejected.
|
|
||||||
func testMalformedManifestRejected() {
|
|
||||||
XCTAssertThrowsError(try MHL.verify(manifest: "<not-mhl/>", root: root))
|
|
||||||
}
|
|
||||||
|
|
||||||
// XML escaping in paths.
|
|
||||||
func testPathEscaping() throws {
|
|
||||||
try Data("x".utf8).write(to: URL(fileURLWithPath: root + "/a&b.mov"))
|
|
||||||
let xml = try MHL.generate(root: root, creator: "Forge", files: ["a&b.mov"])
|
|
||||||
XCTAssertTrue(xml.contains("<path>a&b.mov</path>"))
|
|
||||||
let result = try MHL.verify(manifest: xml, root: root)
|
|
||||||
XCTAssertTrue(result.passed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
5
Tests/ForgeOffloadTests/PlaceholderTests.swift
Normal file
5
Tests/ForgeOffloadTests/PlaceholderTests.swift
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class ForgeOffloadPlaceholderTests: XCTestCase {
|
||||||
|
func testPlaceholder() { XCTAssertTrue(true) }
|
||||||
|
}
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import XCTest
|
|
||||||
import Foundation
|
|
||||||
@testable import ForgeOffload
|
|
||||||
|
|
||||||
final class ReportTests: XCTestCase {
|
|
||||||
|
|
||||||
func makeReport() -> OffloadReport {
|
|
||||||
OffloadReport(
|
|
||||||
source: "/Volumes/CARD_A001",
|
|
||||||
destinations: ["/RAID/A001", "/SHUTTLE/A001"],
|
|
||||||
files: [
|
|
||||||
FileRecord(relativePath: "CLIPS/C001.mov", size: 1_000_000, xxh64: "abc123def4567890", verified: true, skipped: false),
|
|
||||||
FileRecord(relativePath: "CLIPS/C002.mov", size: 2_500_000, xxh64: "1111222233334444", verified: true, skipped: true),
|
|
||||||
],
|
|
||||||
totalBytes: 3_500_000,
|
|
||||||
duration: 12.5,
|
|
||||||
startedAt: Date(timeIntervalSince1970: 1_780_000_000))
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSV report golden shape.
|
|
||||||
func testCSVReport() {
|
|
||||||
let csv = OffloadReportWriter.csv(makeReport())
|
|
||||||
let lines = csv.split(separator: "\n").map(String.init)
|
|
||||||
XCTAssertEqual(lines[0], "relative_path,size,xxh64,verified,skipped")
|
|
||||||
XCTAssertEqual(lines[1], "CLIPS/C001.mov,1000000,abc123def4567890,true,false")
|
|
||||||
XCTAssertEqual(lines[2], "CLIPS/C002.mov,2500000,1111222233334444,true,true")
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTML report contains summary + rows.
|
|
||||||
func testHTMLReport() {
|
|
||||||
let html = OffloadReportWriter.html(makeReport())
|
|
||||||
XCTAssertTrue(html.contains("<html>"))
|
|
||||||
XCTAssertTrue(html.contains("/Volumes/CARD_A001"))
|
|
||||||
XCTAssertTrue(html.contains("/RAID/A001"))
|
|
||||||
XCTAssertTrue(html.contains("CLIPS/C001.mov"))
|
|
||||||
XCTAssertTrue(html.contains("2 files"))
|
|
||||||
XCTAssertTrue(html.contains("3.5 MB") || html.contains("3,5 MB"))
|
|
||||||
XCTAssertTrue(html.contains("VERIFIED"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
import XCTest
|
|
||||||
import Foundation
|
|
||||||
import ForgeGrade
|
|
||||||
import ForgeColor
|
|
||||||
@testable import ForgePanel
|
|
||||||
|
|
||||||
final class ControlMapTests: XCTestCase {
|
|
||||||
|
|
||||||
// Trackball delta maps to CDL offset per ball (lift/gamma/gain zones map to SOP).
|
|
||||||
func testTrackballToCDL() {
|
|
||||||
var grade = PanelGradeState()
|
|
||||||
let map = ControlMap.waveDefault
|
|
||||||
|
|
||||||
// Gain ball (ball 3) X delta -> slope red+.
|
|
||||||
let action = map.action(for: .encoder(id: TangentControls.gainBallX, delta: 10))
|
|
||||||
guard case .adjustCDL(let param, let amount) = action else { return XCTFail("wrong action") }
|
|
||||||
XCTAssertEqual(param, .slopeRed)
|
|
||||||
XCTAssertEqual(amount, 10 * map.sensitivity(for: .slopeRed), accuracy: 1e-8)
|
|
||||||
|
|
||||||
grade.apply(action!)
|
|
||||||
XCTAssertEqual(grade.cdl.slope.x, 1 + 10 * map.sensitivity(for: .slopeRed), accuracy: 1e-6)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Master ring adjusts all three slope channels.
|
|
||||||
func testMasterRing() {
|
|
||||||
var grade = PanelGradeState()
|
|
||||||
let map = ControlMap.waveDefault
|
|
||||||
let action = map.action(for: .encoder(id: TangentControls.gainRing, delta: 5))
|
|
||||||
guard case .adjustCDL(let param, _) = action, param == .slopeMaster else {
|
|
||||||
return XCTFail("wrong mapping")
|
|
||||||
}
|
|
||||||
grade.apply(action!)
|
|
||||||
XCTAssertEqual(grade.cdl.slope.x, grade.cdl.slope.y, accuracy: 1e-7)
|
|
||||||
XCTAssertEqual(grade.cdl.slope.y, grade.cdl.slope.z, accuracy: 1e-7)
|
|
||||||
XCTAssertGreaterThan(grade.cdl.slope.x, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sat knob.
|
|
||||||
func testSatKnob() {
|
|
||||||
var grade = PanelGradeState()
|
|
||||||
let map = ControlMap.waveDefault
|
|
||||||
let action = map.action(for: .encoder(id: TangentControls.satKnob, delta: -20))
|
|
||||||
grade.apply(action!)
|
|
||||||
XCTAssertLessThan(grade.cdl.saturation, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buttons dispatch commands.
|
|
||||||
func testButtons() {
|
|
||||||
let map = ControlMap.waveDefault
|
|
||||||
XCTAssertEqual(map.action(for: .button(id: TangentControls.bypassButton, pressed: true)), .toggleBypass)
|
|
||||||
XCTAssertEqual(map.action(for: .button(id: TangentControls.grabStillButton, pressed: true)), .grabStill)
|
|
||||||
XCTAssertEqual(map.action(for: .button(id: TangentControls.nextSlotButton, pressed: true)), .nextSlot)
|
|
||||||
XCTAssertEqual(map.action(for: .button(id: TangentControls.resetButton, pressed: true)), .resetGrade)
|
|
||||||
// Release events ignored.
|
|
||||||
XCTAssertNil(map.action(for: .button(id: TangentControls.bypassButton, pressed: false)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unmapped control -> nil.
|
|
||||||
func testUnmappedControl() {
|
|
||||||
XCTAssertNil(ControlMap.waveDefault.action(for: .encoder(id: 9999, delta: 1)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset action restores identity.
|
|
||||||
func testReset() {
|
|
||||||
var grade = PanelGradeState()
|
|
||||||
grade.apply(.adjustCDL(param: .slopeRed, amount: 0.5))
|
|
||||||
grade.apply(.adjustCDL(param: .offsetBlue, amount: -0.1))
|
|
||||||
XCTAssertNotEqual(grade.cdl, CDL.identity)
|
|
||||||
grade.apply(.resetGrade)
|
|
||||||
XCTAssertEqual(grade.cdl, CDL.identity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display text for current params (panel readout strings).
|
|
||||||
func testDisplayText() {
|
|
||||||
var grade = PanelGradeState()
|
|
||||||
grade.apply(.adjustCDL(param: .slopeRed, amount: 0.234))
|
|
||||||
let text = grade.displayText(for: .slopeRed)
|
|
||||||
XCTAssertEqual(text, "SlpR 1.234")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class TangentProtocolTests: XCTestCase {
|
|
||||||
|
|
||||||
// Frame encode: u32 LE length + JSON payload. Round trip.
|
|
||||||
func testFraming() throws {
|
|
||||||
let msg = TangentMessage.controlUpdate(.encoder(id: 42, delta: -3))
|
|
||||||
let framed = TangentWire.encode(msg)
|
|
||||||
var buf = framed
|
|
||||||
let decoded = try XCTUnwrap(TangentWire.decodeFirst(&buf))
|
|
||||||
guard case .controlUpdate(.encoder(let id, let delta)) = decoded else {
|
|
||||||
return XCTFail("wrong decode")
|
|
||||||
}
|
|
||||||
XCTAssertEqual(id, 42)
|
|
||||||
XCTAssertEqual(delta, -3)
|
|
||||||
XCTAssertTrue(buf.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Partial frames buffer until complete.
|
|
||||||
func testPartialFrames() throws {
|
|
||||||
let msg = TangentMessage.controlUpdate(.button(id: 7, pressed: true))
|
|
||||||
let framed = TangentWire.encode(msg)
|
|
||||||
var buf = Data(framed.prefix(3))
|
|
||||||
XCTAssertNil(try TangentWire.decodeFirst(&buf))
|
|
||||||
buf.append(framed.suffix(from: 3))
|
|
||||||
XCTAssertNotNil(try TangentWire.decodeFirst(&buf))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two frames in one read both decode.
|
|
||||||
func testTwoFramesOneBuffer() throws {
|
|
||||||
var buf = TangentWire.encode(.controlUpdate(.encoder(id: 1, delta: 1)))
|
|
||||||
buf.append(TangentWire.encode(.controlUpdate(.encoder(id: 2, delta: 2))))
|
|
||||||
let first = try XCTUnwrap(TangentWire.decodeFirst(&buf))
|
|
||||||
let second = try XCTUnwrap(TangentWire.decodeFirst(&buf))
|
|
||||||
guard case .controlUpdate(.encoder(let id1, _)) = first,
|
|
||||||
case .controlUpdate(.encoder(let id2, _)) = second else {
|
|
||||||
return XCTFail()
|
|
||||||
}
|
|
||||||
XCTAssertEqual(id1, 1)
|
|
||||||
XCTAssertEqual(id2, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display update encodes.
|
|
||||||
func testDisplayUpdate() throws {
|
|
||||||
let msg = TangentMessage.displayUpdate(line: 0, text: "SlpR 1.234")
|
|
||||||
var buf = TangentWire.encode(msg)
|
|
||||||
let decoded = try XCTUnwrap(TangentWire.decodeFirst(&buf))
|
|
||||||
guard case .displayUpdate(let line, let text) = decoded else { return XCTFail() }
|
|
||||||
XCTAssertEqual(line, 0)
|
|
||||||
XCTAssertEqual(text, "SlpR 1.234")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Garbage payload throws.
|
|
||||||
func testGarbageRejected() {
|
|
||||||
var lenBytes = Data()
|
|
||||||
var len = UInt32(4).littleEndian
|
|
||||||
withUnsafeBytes(of: &len) { lenBytes.append(contentsOf: $0) }
|
|
||||||
var buf = lenBytes + Data("zzzz".utf8)
|
|
||||||
XCTAssertThrowsError(try TangentWire.decodeFirst(&buf))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
5
Tests/ForgePanelTests/PlaceholderTests.swift
Normal file
5
Tests/ForgePanelTests/PlaceholderTests.swift
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class ForgePanelPlaceholderTests: XCTestCase {
|
||||||
|
func testPlaceholder() { XCTAssertTrue(true) }
|
||||||
|
}
|
||||||
5
Tests/ForgeProxyTests/PlaceholderTests.swift
Normal file
5
Tests/ForgeProxyTests/PlaceholderTests.swift
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class ForgeProxyPlaceholderTests: XCTestCase {
|
||||||
|
func testPlaceholder() { XCTAssertTrue(true) }
|
||||||
|
}
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
import XCTest
|
|
||||||
import Foundation
|
|
||||||
import ForgeColor
|
|
||||||
import ForgeGrade
|
|
||||||
@testable import ForgeProxy
|
|
||||||
|
|
||||||
final class FFmpegCommandTests: XCTestCase {
|
|
||||||
|
|
||||||
// ProRes proxy preset argv golden.
|
|
||||||
func testProResProxyArgs() {
|
|
||||||
let cmd = FFmpegCommandBuilder.build(
|
|
||||||
input: "/media/A001C001.mov",
|
|
||||||
output: "/proxies/A001C001.mov",
|
|
||||||
preset: .proresProxy,
|
|
||||||
scale: .half,
|
|
||||||
lutPath: nil,
|
|
||||||
burnIn: nil)
|
|
||||||
XCTAssertEqual(cmd.first, "-y")
|
|
||||||
XCTAssertTrue(cmd.contains("-i"))
|
|
||||||
XCTAssertTrue(cmd.contains("/media/A001C001.mov"))
|
|
||||||
XCTAssertTrue(cmd.contains("prores_ks"))
|
|
||||||
// Profile 0 = proxy.
|
|
||||||
let profileIdx = cmd.firstIndex(of: "-profile:v")!
|
|
||||||
XCTAssertEqual(cmd[profileIdx + 1], "0")
|
|
||||||
XCTAssertTrue(cmd.contains("scale=iw/2:ih/2"))
|
|
||||||
XCTAssertEqual(cmd.last, "/proxies/A001C001.mov")
|
|
||||||
}
|
|
||||||
|
|
||||||
// H264 preset.
|
|
||||||
func testH264Args() {
|
|
||||||
let cmd = FFmpegCommandBuilder.build(
|
|
||||||
input: "in.mov", output: "out.mp4",
|
|
||||||
preset: .h264, scale: .quarter, lutPath: nil, burnIn: nil)
|
|
||||||
XCTAssertTrue(cmd.contains("libx264"))
|
|
||||||
XCTAssertTrue(cmd.contains("scale=iw/4:ih/4"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// DNxHR preset.
|
|
||||||
func testDNxHRArgs() {
|
|
||||||
let cmd = FFmpegCommandBuilder.build(
|
|
||||||
input: "in.mov", output: "out.mxf",
|
|
||||||
preset: .dnxhrLB, scale: .full, lutPath: nil, burnIn: nil)
|
|
||||||
XCTAssertTrue(cmd.contains("dnxhd"))
|
|
||||||
XCTAssertTrue(cmd.contains("dnxhr_lb"))
|
|
||||||
// Full scale: no scale filter unless LUT/burn present.
|
|
||||||
XCTAssertFalse(cmd.joined(separator: " ").contains("scale="))
|
|
||||||
}
|
|
||||||
|
|
||||||
// LUT filter: lut3d with escaped path joins filter chain before scale.
|
|
||||||
func testLutFilter() {
|
|
||||||
let cmd = FFmpegCommandBuilder.build(
|
|
||||||
input: "in.mov", output: "out.mov",
|
|
||||||
preset: .proresProxy, scale: .half,
|
|
||||||
lutPath: "/tmp/my look's.cube", burnIn: nil)
|
|
||||||
let vf = cmd[cmd.firstIndex(of: "-vf")! + 1]
|
|
||||||
XCTAssertTrue(vf.contains("lut3d="))
|
|
||||||
// ffmpeg filter escaping: ' -> '\'' inside quoted value, or backslash escapes.
|
|
||||||
XCTAssertTrue(vf.contains("my look"))
|
|
||||||
XCTAssertTrue(vf.contains("lut3d="), "lut3d must precede scale")
|
|
||||||
XCTAssertLessThan(vf.range(of: "lut3d=")!.lowerBound, vf.range(of: "scale=")!.lowerBound)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Burn-in: drawtext with clip name + timecode.
|
|
||||||
func testBurnIn() {
|
|
||||||
let cmd = FFmpegCommandBuilder.build(
|
|
||||||
input: "in.mov", output: "out.mov",
|
|
||||||
preset: .h264, scale: .half,
|
|
||||||
lutPath: nil,
|
|
||||||
burnIn: BurnIn(clipName: "A001C001", timecode: "10:00:00:00", fps: 24))
|
|
||||||
let vf = cmd[cmd.firstIndex(of: "-vf")! + 1]
|
|
||||||
XCTAssertTrue(vf.contains("drawtext"))
|
|
||||||
XCTAssertTrue(vf.contains("A001C001"))
|
|
||||||
XCTAssertTrue(vf.contains("timecode"))
|
|
||||||
// TC colons escaped for filter syntax.
|
|
||||||
XCTAssertTrue(vf.contains("10\\:00\\:00\\:00"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio: copy through.
|
|
||||||
func testAudioCopied() {
|
|
||||||
let cmd = FFmpegCommandBuilder.build(
|
|
||||||
input: "in.mov", output: "out.mov",
|
|
||||||
preset: .proresProxy, scale: .full, lutPath: nil, burnIn: nil)
|
|
||||||
let aIdx = cmd.firstIndex(of: "-c:a")!
|
|
||||||
XCTAssertEqual(cmd[aIdx + 1], "copy")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class ProxyJobTests: XCTestCase {
|
|
||||||
|
|
||||||
// Runner invokes executable with built argv; fake ffmpeg script records args.
|
|
||||||
func testRunnerInvokesWithArgs() async throws {
|
|
||||||
let dir = NSTemporaryDirectory() + "forge-proxy-\(UUID().uuidString)"
|
|
||||||
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
|
||||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
|
||||||
|
|
||||||
let fake = dir + "/fake-ffmpeg"
|
|
||||||
try #"""
|
|
||||||
#!/bin/bash
|
|
||||||
echo "$@" > "$(dirname "$0")/argv.txt"
|
|
||||||
exit 0
|
|
||||||
"""#.write(toFile: fake, atomically: true, encoding: .utf8)
|
|
||||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fake)
|
|
||||||
|
|
||||||
let runner = ProxyRunner(ffmpegPath: fake)
|
|
||||||
try await runner.run(args: ["-y", "-i", "in.mov", "out.mov"])
|
|
||||||
|
|
||||||
let argv = try String(contentsOfFile: dir + "/argv.txt", encoding: .utf8)
|
|
||||||
XCTAssertEqual(argv.trimmingCharacters(in: .whitespacesAndNewlines), "-y -i in.mov out.mov")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nonzero exit throws with stderr.
|
|
||||||
func testRunnerThrowsOnFailure() async throws {
|
|
||||||
let dir = NSTemporaryDirectory() + "forge-proxy-\(UUID().uuidString)"
|
|
||||||
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
|
||||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
|
||||||
let fake = dir + "/fake-ffmpeg"
|
|
||||||
try "#!/bin/bash\necho 'boom' >&2\nexit 1\n".write(toFile: fake, atomically: true, encoding: .utf8)
|
|
||||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fake)
|
|
||||||
|
|
||||||
let runner = ProxyRunner(ffmpegPath: fake)
|
|
||||||
do {
|
|
||||||
try await runner.run(args: ["x"])
|
|
||||||
XCTFail("expected throw")
|
|
||||||
} catch let e as ProxyError {
|
|
||||||
guard case .ffmpegFailed(_, let stderr) = e else { return XCTFail("wrong error") }
|
|
||||||
XCTAssertTrue(stderr.contains("boom"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graded job: snapshot -> temp .cube written -> lut3d arg references it -> cleaned up after.
|
|
||||||
func testGradedJobWritesCube() async throws {
|
|
||||||
let dir = NSTemporaryDirectory() + "forge-proxy-\(UUID().uuidString)"
|
|
||||||
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
|
||||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
|
||||||
let fake = dir + "/fake-ffmpeg"
|
|
||||||
try #"""
|
|
||||||
#!/bin/bash
|
|
||||||
echo "$@" > "$(dirname "$0")/argv.txt"
|
|
||||||
# capture cube existence at run time
|
|
||||||
if [[ "$@" == *lut3d* ]]; then
|
|
||||||
cube=$(echo "$@" | grep -oE "[^ =']*\.cube" | head -1)
|
|
||||||
[[ -f "$cube" ]] && echo "cube-exists" > "$(dirname "$0")/cube.txt"
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
"""#.write(toFile: fake, atomically: true, encoding: .utf8)
|
|
||||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fake)
|
|
||||||
|
|
||||||
let stack = GradeStack(nodes: [GradeNode(kind: .cdl(CDL(
|
|
||||||
slope: SIMD3(1.2, 1, 1), offset: .zero, power: .one, saturation: 1)))])
|
|
||||||
let snapshot = try GradeSnapshot.encode(stack)
|
|
||||||
|
|
||||||
let job = ProxyJob(
|
|
||||||
input: "in.mov",
|
|
||||||
output: dir + "/out.mov",
|
|
||||||
preset: .proresProxy,
|
|
||||||
scale: .half,
|
|
||||||
gradeSnapshot: snapshot,
|
|
||||||
burnIn: nil)
|
|
||||||
try await job.run(ffmpegPath: fake, latticeSize: 9)
|
|
||||||
|
|
||||||
let argv = try String(contentsOfFile: dir + "/argv.txt", encoding: .utf8)
|
|
||||||
XCTAssertTrue(argv.contains("lut3d="))
|
|
||||||
XCTAssertEqual(try String(contentsOfFile: dir + "/cube.txt", encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines), "cube-exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ungraded fallback: no snapshot -> no lut3d in args.
|
|
||||||
func testUngradedNoLut() async throws {
|
|
||||||
let dir = NSTemporaryDirectory() + "forge-proxy-\(UUID().uuidString)"
|
|
||||||
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
|
||||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
|
||||||
let fake = dir + "/fake-ffmpeg"
|
|
||||||
try "#!/bin/bash\necho \"$@\" > \"$(dirname \"$0\")/argv.txt\"\nexit 0\n".write(toFile: fake, atomically: true, encoding: .utf8)
|
|
||||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fake)
|
|
||||||
|
|
||||||
let job = ProxyJob(input: "in.mov", output: dir + "/out.mov", preset: .h264, scale: .half, gradeSnapshot: nil, burnIn: nil)
|
|
||||||
try await job.run(ffmpegPath: fake, latticeSize: 9)
|
|
||||||
let argv = try String(contentsOfFile: dir + "/argv.txt", encoding: .utf8)
|
|
||||||
XCTAssertFalse(argv.contains("lut3d"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
import XCTest
|
|
||||||
import Foundation
|
|
||||||
@testable import ForgeProxy
|
|
||||||
|
|
||||||
final class WatchFolderTests: XCTestCase {
|
|
||||||
|
|
||||||
var dir: String!
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
dir = NSTemporaryDirectory() + "forge-watch-\(UUID().uuidString)"
|
|
||||||
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
try? FileManager.default.removeItem(atPath: dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// New stable file enqueued exactly once.
|
|
||||||
func testNewFileEnqueuedOnce() async throws {
|
|
||||||
let watcher = WatchFolder(
|
|
||||||
path: dir,
|
|
||||||
extensions: ["mov"],
|
|
||||||
pollInterval: .milliseconds(30),
|
|
||||||
stabilityChecks: 2)
|
|
||||||
var enqueued = [String]()
|
|
||||||
let stream = await watcher.start()
|
|
||||||
|
|
||||||
try Data("clip".utf8).write(to: URL(fileURLWithPath: dir + "/C001.mov"))
|
|
||||||
|
|
||||||
let collector = Task {
|
|
||||||
for await path in stream {
|
|
||||||
enqueued.append(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try await Task.sleep(for: .milliseconds(400))
|
|
||||||
await watcher.stop()
|
|
||||||
_ = await collector.result
|
|
||||||
|
|
||||||
XCTAssertEqual(enqueued.count, 1)
|
|
||||||
XCTAssertTrue(enqueued[0].hasSuffix("C001.mov"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Growing file (partial write) not enqueued until size stable.
|
|
||||||
func testGrowingFileWaitsForStability() async throws {
|
|
||||||
let watcher = WatchFolder(
|
|
||||||
path: dir,
|
|
||||||
extensions: ["mov"],
|
|
||||||
pollInterval: .milliseconds(40),
|
|
||||||
stabilityChecks: 2)
|
|
||||||
let path = dir + "/GROW.mov"
|
|
||||||
try Data("start".utf8).write(to: URL(fileURLWithPath: path))
|
|
||||||
|
|
||||||
let stream = await watcher.start()
|
|
||||||
actor Seen {
|
|
||||||
var paths = [String]()
|
|
||||||
var at: [ContinuousClock.Instant] = []
|
|
||||||
func add(_ p: String) {
|
|
||||||
paths.append(p)
|
|
||||||
at.append(.now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let seen = Seen()
|
|
||||||
let collector = Task {
|
|
||||||
for await p in stream {
|
|
||||||
await seen.add(p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep growing for ~200ms.
|
|
||||||
for i in 0..<5 {
|
|
||||||
try await Task.sleep(for: .milliseconds(40))
|
|
||||||
let h = FileHandle(forWritingAtPath: path)!
|
|
||||||
h.seekToEndOfFile()
|
|
||||||
h.write(Data("more\(i)".utf8))
|
|
||||||
try h.close()
|
|
||||||
}
|
|
||||||
let growEnd = ContinuousClock.now
|
|
||||||
try await Task.sleep(for: .milliseconds(400))
|
|
||||||
await watcher.stop()
|
|
||||||
_ = await collector.result
|
|
||||||
|
|
||||||
let paths = await seen.paths
|
|
||||||
let times = await seen.at
|
|
||||||
XCTAssertEqual(paths.count, 1)
|
|
||||||
// Enqueue happened after growth stopped.
|
|
||||||
XCTAssertGreaterThan(times[0], growEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extension filter.
|
|
||||||
func testExtensionFilter() async throws {
|
|
||||||
let watcher = WatchFolder(path: dir, extensions: ["mov"], pollInterval: .milliseconds(30), stabilityChecks: 1)
|
|
||||||
try Data("x".utf8).write(to: URL(fileURLWithPath: dir + "/note.txt"))
|
|
||||||
try Data("y".utf8).write(to: URL(fileURLWithPath: dir + "/C002.mov"))
|
|
||||||
|
|
||||||
let stream = await watcher.start()
|
|
||||||
var enqueued = [String]()
|
|
||||||
let collector = Task {
|
|
||||||
for await p in stream { enqueued.append(p) }
|
|
||||||
}
|
|
||||||
try await Task.sleep(for: .milliseconds(300))
|
|
||||||
await watcher.stop()
|
|
||||||
_ = await collector.result
|
|
||||||
|
|
||||||
XCTAssertEqual(enqueued.count, 1)
|
|
||||||
XCTAssertTrue(enqueued[0].hasSuffix(".mov"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already-processed file skipped across watcher runs via skip list.
|
|
||||||
func testSkipListPersistsAcrossRuns() async throws {
|
|
||||||
try Data("x".utf8).write(to: URL(fileURLWithPath: dir + "/C003.mov"))
|
|
||||||
|
|
||||||
let skipList = ProcessedSkipList()
|
|
||||||
let w1 = WatchFolder(path: dir, extensions: ["mov"], pollInterval: .milliseconds(30), stabilityChecks: 1, skipList: skipList)
|
|
||||||
let s1 = await w1.start()
|
|
||||||
var first = [String]()
|
|
||||||
let c1 = Task { for await p in s1 { first.append(p) } }
|
|
||||||
try await Task.sleep(for: .milliseconds(250))
|
|
||||||
await w1.stop()
|
|
||||||
_ = await c1.result
|
|
||||||
XCTAssertEqual(first.count, 1)
|
|
||||||
|
|
||||||
// Second watcher with same skip list: nothing new.
|
|
||||||
let w2 = WatchFolder(path: dir, extensions: ["mov"], pollInterval: .milliseconds(30), stabilityChecks: 1, skipList: skipList)
|
|
||||||
let s2 = await w2.start()
|
|
||||||
var second = [String]()
|
|
||||||
let c2 = Task { for await p in s2 { second.append(p) } }
|
|
||||||
try await Task.sleep(for: .milliseconds(250))
|
|
||||||
await w2.stop()
|
|
||||||
_ = await c2.result
|
|
||||||
XCTAssertTrue(second.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
5
Tests/ForgeVideoTests/PlaceholderTests.swift
Normal file
5
Tests/ForgeVideoTests/PlaceholderTests.swift
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class ForgeVideoPlaceholderTests: XCTestCase {
|
||||||
|
func testPlaceholder() { XCTAssertTrue(true) }
|
||||||
|
}
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
import XCTest
|
|
||||||
import Foundation
|
|
||||||
import ForgeGrade
|
|
||||||
import ForgeColor
|
|
||||||
@testable import ForgeVideo
|
|
||||||
|
|
||||||
final class VideoTests: XCTestCase {
|
|
||||||
|
|
||||||
// Solid-color test source produces frames of declared size.
|
|
||||||
func testTestPatternSource() async throws {
|
|
||||||
let source = TestPatternSource(width: 8, height: 4, color: SIMD3(0.5, 0.25, 0.75))
|
|
||||||
let frame = try await source.nextFrame()
|
|
||||||
XCTAssertEqual(frame.width, 8)
|
|
||||||
XCTAssertEqual(frame.height, 4)
|
|
||||||
XCTAssertEqual(frame.pixels.count, 8 * 4)
|
|
||||||
XCTAssertEqual(frame.pixels[0].x, 0.5, accuracy: 1e-6)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grading a frame applies the stack per pixel.
|
|
||||||
func testGradeFrame() async throws {
|
|
||||||
let source = TestPatternSource(width: 4, height: 2, color: SIMD3(0.25, 0.25, 0.25))
|
|
||||||
let stack = GradeStack(nodes: [GradeNode(kind: .cdl(CDL(
|
|
||||||
slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1)))])
|
|
||||||
let frame = try await source.nextFrame()
|
|
||||||
let graded = FrameProcessor.apply(stack: stack, to: frame)
|
|
||||||
XCTAssertEqual(graded.pixels[0].x, 0.5, accuracy: 1e-6)
|
|
||||||
XCTAssertEqual(graded.width, 4)
|
|
||||||
// Timecode preserved.
|
|
||||||
XCTAssertEqual(graded.timecode, frame.timecode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Still grab writes PPM (portable, no image lib dep) and returns path.
|
|
||||||
func testStillGrabWritesPPM() async throws {
|
|
||||||
let source = TestPatternSource(width: 4, height: 4, color: SIMD3(1, 0, 0))
|
|
||||||
let frame = try await source.nextFrame()
|
|
||||||
let dir = NSTemporaryDirectory() + "forge-stills-\(UUID().uuidString)"
|
|
||||||
let grabber = StillGrabber(directory: dir)
|
|
||||||
let path = try grabber.grab(frame: frame, clipName: "A001C001")
|
|
||||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
|
||||||
|
|
||||||
XCTAssertTrue(FileManager.default.fileExists(atPath: path))
|
|
||||||
XCTAssertTrue(path.hasSuffix(".ppm"))
|
|
||||||
XCTAssertTrue(path.contains("A001C001"))
|
|
||||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
|
||||||
// Compare header bytes directly — string decoding of Data slices is
|
|
||||||
// unreliable across Foundation implementations.
|
|
||||||
let expectedHeader = [UInt8]("P6\n4 4\n255\n".utf8)
|
|
||||||
XCTAssertEqual([UInt8](data.prefix(expectedHeader.count)), expectedHeader)
|
|
||||||
// First pixel red: 255 0 0.
|
|
||||||
let headerLen = "P6\n4 4\n255\n".utf8.count
|
|
||||||
XCTAssertEqual(data[headerLen], 255)
|
|
||||||
XCTAssertEqual(data[headerLen + 1], 0)
|
|
||||||
XCTAssertEqual(data[headerLen + 2], 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Values clamp to 0-255 on write.
|
|
||||||
func testStillClampsRange() async throws {
|
|
||||||
let source = TestPatternSource(width: 2, height: 2, color: SIMD3(2.0, -0.5, 0.5))
|
|
||||||
let frame = try await source.nextFrame()
|
|
||||||
let dir = NSTemporaryDirectory() + "forge-stills-\(UUID().uuidString)"
|
|
||||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
|
||||||
let path = try StillGrabber(directory: dir).grab(frame: frame, clipName: "clamp")
|
|
||||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
|
||||||
let headerLen = "P6\n2 2\n255\n".utf8.count
|
|
||||||
XCTAssertEqual(data[headerLen], 255) // 2.0 -> 255
|
|
||||||
XCTAssertEqual(data[headerLen + 1], 0) // -0.5 -> 0
|
|
||||||
XCTAssertEqual(data[headerLen + 2], 128) // 0.5 -> 128 (rounded)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Null sink accepts frames without error (SDI-out stand-in).
|
|
||||||
func testNullSink() async throws {
|
|
||||||
let sink = NullFrameSink()
|
|
||||||
let source = TestPatternSource(width: 2, height: 2, color: .zero)
|
|
||||||
try await sink.consume(frame: try await source.nextFrame())
|
|
||||||
let count = await sink.consumedCount
|
|
||||||
XCTAssertEqual(count, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue