Compare commits

..

3 commits

17 changed files with 8032 additions and 28 deletions

View file

@ -43,10 +43,12 @@ let package = Package(
// Shot logging + storage + exports // Shot logging + storage + exports
.target(name: "ForgeLibrary", dependencies: ["ForgeGrade", "CSQLite"]), .target(name: "ForgeLibrary", dependencies: ["ForgeGrade", "CSQLite"]),
.systemLibrary(name: "CSQLite", path: "Sources/CSQLite"), .systemLibrary(name: "CSQLite", path: "Sources/CSQLite"),
// Vendored xxHash v0.8.2 (BSD-2) for XXH3-64/128
.target(name: "CXXHash"),
.target(name: "ForgeLogger", dependencies: ["ForgeCamera", "ForgeLibrary"]), .target(name: "ForgeLogger", dependencies: ["ForgeCamera", "ForgeLibrary"]),
.target(name: "ForgeExport", dependencies: ["ForgeLibrary", "ForgeGrade"]), .target(name: "ForgeExport", dependencies: ["ForgeLibrary", "ForgeGrade"]),
// Offload + proxy // Offload + proxy
.target(name: "ForgeOffload", dependencies: ["ForgeLibrary"]), .target(name: "ForgeOffload", dependencies: ["ForgeLibrary", "CXXHash"]),
.target(name: "ForgeProxy", dependencies: ["ForgeLibrary", "ForgeGrade", "ForgeColor"]), .target(name: "ForgeProxy", dependencies: ["ForgeLibrary", "ForgeGrade", "ForgeColor"]),
// Panel + video seams // Panel + video seams
.target(name: "ForgePanel", dependencies: [ .target(name: "ForgePanel", dependencies: [

48
Sources/CXXHash/cxxhash.c Normal file
View file

@ -0,0 +1,48 @@
#define XXH_INLINE_ALL
#include "vendor_xxhash.h"
#include "include/cxxhash.h"
#include <stdlib.h>
uint64_t cxx_xxh3_64(const void *data, size_t len) {
return XXH3_64bits(data, len);
}
cxx_hash128 cxx_xxh3_128(const void *data, size_t len) {
XXH128_hash_t h = XXH3_128bits(data, len);
cxx_hash128 out = { h.low64, h.high64 };
return out;
}
struct cxx_xxh3_state {
XXH3_state_t *st;
int is128;
};
cxx_xxh3_state *cxx_xxh3_create(int is128) {
cxx_xxh3_state *s = malloc(sizeof(cxx_xxh3_state));
if (!s) return NULL;
s->st = XXH3_createState();
s->is128 = is128;
if (is128) XXH3_128bits_reset(s->st);
else XXH3_64bits_reset(s->st);
return s;
}
void cxx_xxh3_update(cxx_xxh3_state *s, const void *data, size_t len) {
if (s->is128) XXH3_128bits_update(s->st, data, len);
else XXH3_64bits_update(s->st, data, len);
}
uint64_t cxx_xxh3_digest64(cxx_xxh3_state *s) {
return XXH3_64bits_digest(s->st);
}
cxx_hash128 cxx_xxh3_digest128(cxx_xxh3_state *s) {
XXH128_hash_t h = XXH3_128bits_digest(s->st);
cxx_hash128 out = { h.low64, h.high64 };
return out;
}
void cxx_xxh3_free(cxx_xxh3_state *s) {
if (s) { XXH3_freeState(s->st); free(s); }
}

View file

@ -0,0 +1,20 @@
/* Forge shim over vendored xxHash v0.8.2 (BSD-2, Yann Collet).
Exposes stable one-shot + streaming XXH3-64/128 entry points for Swift. */
#ifndef CXXHASH_H
#define CXXHASH_H
#include <stddef.h>
#include <stdint.h>
typedef struct { uint64_t low64; uint64_t high64; } cxx_hash128;
uint64_t cxx_xxh3_64(const void *data, size_t len);
cxx_hash128 cxx_xxh3_128(const void *data, size_t len);
/* streaming */
typedef struct cxx_xxh3_state cxx_xxh3_state;
cxx_xxh3_state *cxx_xxh3_create(int is128);
void cxx_xxh3_update(cxx_xxh3_state *s, const void *data, size_t len);
uint64_t cxx_xxh3_digest64(cxx_xxh3_state *s);
cxx_hash128 cxx_xxh3_digest128(cxx_xxh3_state *s);
void cxx_xxh3_free(cxx_xxh3_state *s);
#endif

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,134 @@
import Foundation
import ForgeColor
import ForgeGrade
import ForgeLibrary
/// Look-match ingest bridge: connects offloaded media files back to logged shots
/// by clip name and materializes each shot's grade as sidecars next to the media.
/// This is the LivegradeSilverstack "look matching" flow, without the cloud
/// a dailies tool (or dragonflight) picks up the .cdl/.cube beside every clip.
public enum IngestMatcher {
public struct Options: Sendable {
public var writeCDL: Bool
public var writeCube: Bool
public var latticeSize: Int
public var force: Bool
public init(writeCDL: Bool, writeCube: Bool, latticeSize: Int, force: Bool = false) {
self.writeCDL = writeCDL
self.writeCube = writeCube
self.latticeSize = latticeSize
self.force = force
}
}
public struct Match: Sendable {
public let clipName: String
public let mediaPath: String
public let sidecarsWritten: Bool
}
public struct Result: Sendable {
public let matched: [Match]
/// Media files with no shot record (relative to mediaRoot).
public let unmatchedMedia: [String]
/// Shot clip names with no media file found.
public let shotsWithoutMedia: [String]
}
/// Media extensions considered clips.
static let mediaExtensions: Set<String> = [
"mov", "mxf", "mp4", "braw", "arri", "ari", "r3d", "crm", "dng", "avi",
]
public static func match(
mediaRoot: String,
store: LibraryStore,
dayID: UUID,
options: Options
) throws -> Result {
let fm = FileManager.default
let shots = try store.listShots(dayID: dayID)
// Index shots by lowercased clip name.
var shotByClip: [String: Shot] = [:]
for s in shots {
shotByClip[s.clipName.lowercased()] = s
}
// Walk media tree.
guard let enumerator = fm.enumerator(atPath: mediaRoot) else {
throw ExportError.malformed("cannot enumerate \(mediaRoot)")
}
var matched = [Match]()
var unmatched = [String]()
var matchedClipNames = Set<String>()
while let rel = enumerator.nextObject() as? String {
let full = mediaRoot + "/" + rel
var isDir: ObjCBool = false
guard fm.fileExists(atPath: full, isDirectory: &isDir), !isDir.boolValue else { continue }
let ext = (rel as NSString).pathExtension.lowercased()
guard mediaExtensions.contains(ext) else { continue }
let base = ((rel as NSString).lastPathComponent as NSString).deletingPathExtension
guard let shot = shotByClip[base.lowercased()] else {
unmatched.append(rel)
continue
}
matchedClipNames.insert(shot.clipName)
var wroteSidecar = false
if let snapshot = shot.gradeSnapshot {
let sidecarBase = (full as NSString).deletingPathExtension
if options.writeCDL {
wroteSidecar = try writeCDLSidecar(
snapshot: snapshot, clipName: shot.clipName,
path: sidecarBase + ".cdl", force: options.force) || wroteSidecar
}
if options.writeCube {
wroteSidecar = try writeCubeSidecar(
snapshot: snapshot, clipName: shot.clipName,
path: sidecarBase + ".cube", latticeSize: options.latticeSize,
force: options.force) || wroteSidecar
}
}
matched.append(Match(clipName: shot.clipName, mediaPath: rel, sidecarsWritten: wroteSidecar))
}
let missing = shots
.map(\.clipName)
.filter { !matchedClipNames.contains($0) }
return Result(
matched: matched.sorted { $0.clipName < $1.clipName },
unmatchedMedia: unmatched.sorted(),
shotsWithoutMedia: missing)
}
/// Returns true if file written.
private static func writeCDLSidecar(snapshot: Data, clipName: String, path: String, force: Bool) throws -> Bool {
if !force && FileManager.default.fileExists(atPath: path) {
return false
}
guard let stack = try? GradeSnapshot.decode(snapshot) else { return false }
for node in stack.nodes {
if case .cdl(let cdl) = node.kind, node.isEnabled {
try CDLExport.writeCDL(cdl, id: clipName)
.write(toFile: path, atomically: true, encoding: .utf8)
return true
}
}
return false
}
private static func writeCubeSidecar(snapshot: Data, clipName: String, path: String, latticeSize: Int, force: Bool) throws -> Bool {
if !force && FileManager.default.fileExists(atPath: path) {
return false
}
let cube = try CubeExport.fromSnapshot(snapshot, latticeSize: latticeSize, title: clipName)
try cube.write(toFile: path, atomically: true, encoding: .utf8)
return true
}
}

View file

@ -0,0 +1,118 @@
import Foundation
import ForgeColor
import ForgeGrade
import ForgeLibrary
/// Producer/post-facing HTML shot report Reel design tokens, still thumbnails
/// (base64-embedded so the file is self-contained/mailable), grade + metadata per shot.
public enum ShotReport {
static func escapeHTML(_ s: String) -> String {
s.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
static func f3(_ v: Float) -> String {
String(format: "%.3f", v)
}
static func cdl(from shot: Shot) -> CDL? {
guard let data = shot.gradeSnapshot,
let stack = try? GradeSnapshot.decode(data) else { return nil }
for node in stack.nodes {
if case .cdl(let c) = node.kind, node.isEnabled { return c }
}
return nil
}
static func stillDataURI(_ shot: Shot) -> String? {
guard let path = shot.stillPath,
let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil }
let mime = path.hasSuffix(".png") ? "image/png" : "image/x-portable-pixmap"
return "data:\(mime);base64,\(data.base64EncodedString())"
}
public static func html(shots: [Shot], projectName: String, dayLabel: String, date: String) -> String {
var rows = ""
for s in shots {
let c = cdl(from: s)
let still: String
if let uri = stillDataURI(s) {
still = "<img class=\"still\" src=\"\(uri)\" alt=\"\(escapeHTML(s.clipName))\">"
} else {
still = "<div class=\"still no-still\">No still</div>"
}
let gradeBlock: String
if let c {
gradeBlock = """
<div class="mono grade">
<span>S \(f3(c.slope.x)) \(f3(c.slope.y)) \(f3(c.slope.z))</span>
<span>O \(f3(c.offset.x)) \(f3(c.offset.y)) \(f3(c.offset.z))</span>
<span>P \(f3(c.power.x)) \(f3(c.power.y)) \(f3(c.power.z))</span>
<span>Sat \(f3(c.saturation))</span>
</div>
"""
} else {
gradeBlock = "<div class=\"grade none\">No grade</div>"
}
rows += """
<div class="shot">
\(still)
<div class="info">
<div class="clip mono">\(escapeHTML(s.clipName))</div>
<div class="sub">\(escapeHTML(s.cameraSlot)) · <span class="mono">\(s.timecodeIn ?? "") \(s.timecodeOut ?? "")</span></div>
<div class="sub mono">EI \(s.exposureIndex.map(String.init) ?? "") · \(s.whiteBalance.map { "\($0)K" } ?? "") · tint \(s.tint.map(String.init) ?? "") · \(s.fps.map { String(format: "%g", $0) } ?? "") fps</div>
</div>
\(gradeBlock)
</div>
"""
}
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>\(escapeHTML(projectName)) \(escapeHTML(dayLabel)) Shot Report</title>
<style>
:root {
--bg-0: #08090C; --bg-1: #0C0E12; --bg-2: #111318;
--text-1: #F6F4EF; --text-2: #B4B0A6; --text-3: #86837B; --text-4: #63615B;
--accent: #F0A63C; --accent-text: #F6C583;
--hairline: rgba(255,255,255,0.07);
--mono: "SF Mono", ui-monospace, monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg-0); color: var(--text-1);
font-family: -apple-system, "Segoe UI", sans-serif; font-size: 13px;
padding: 40px; max-width: 900px; margin: 0 auto; }
.mono { font-family: var(--mono); }
h1 { font-size: 20px; font-weight: 600; }
h1 span { color: var(--accent-text); }
.meta { color: var(--text-3); margin: 6px 0 26px; }
.shot { display: grid; grid-template-columns: 180px 1fr 300px; gap: 18px;
align-items: center; padding: 14px 0; border-bottom: 1px solid var(--hairline); }
.still { width: 180px; height: 101px; border-radius: 5px; object-fit: cover;
background: var(--bg-2); border: 1px solid var(--hairline); }
.no-still { display: flex; align-items: center; justify-content: center;
color: var(--text-4); font-size: 11px; }
.clip { font-size: 14px; font-weight: 600; }
.sub { color: var(--text-3); font-size: 11.5px; margin-top: 3px; }
.grade { display: flex; flex-direction: column; gap: 2px; font-size: 11px;
color: var(--text-2); }
.grade.none { color: var(--text-4); }
.foot { margin-top: 26px; color: var(--text-4); font-size: 11px; }
</style>
</head>
<body>
<h1>\(escapeHTML(projectName)) <span>\(escapeHTML(dayLabel))</span></h1>
<div class="meta">\(escapeHTML(date)) · \(shots.count) shots · generated by Forge</div>
\(rows)
<div class="foot">All grades captured live at record start. CDL values in ASC order (slope / offset / power / saturation).</div>
</body>
</html>
"""
}
}

View file

@ -0,0 +1,66 @@
import Foundation
/// Checksum algorithms supported for offload/MHL. Element names match
/// ASC MHL v2.0 spec hash-element vocabulary.
public enum HashAlgorithm: String, Sendable, CaseIterable {
case xxh64
case xxh3
case xxh128
case md5
case sha1
public var mhlElement: String {
rawValue
}
/// In-memory one-shot (test/reference path).
public static func hashInMemory(_ data: Data, algorithm: HashAlgorithm) -> String {
switch algorithm {
case .xxh64:
return XXHash64.hexString(XXHash64.hash(data))
case .xxh3:
return XXH3.hexString64(XXH3.hash64(data))
case .xxh128:
return XXH3.hash128(data).hexString
case .md5:
return MD5.hexString(data)
case .sha1:
return SHA1.hexString(data)
}
}
}
extension FileHasher {
/// Chunked file hash with algorithm dispatch.
public static func hash(path: String, algorithm: HashAlgorithm) throws -> String {
switch algorithm {
case .xxh64:
return try xxh64(path: path)
case .md5:
return try md5(path: path)
case .xxh3:
let s = XXH3.Streaming(bits: .h64)
try streamFile(path: path) { s.update($0) }
return XXH3.hexString64(s.digest64())
case .xxh128:
let s = XXH3.Streaming(bits: .h128)
try streamFile(path: path) { s.update($0) }
return s.digest128().hexString
case .sha1:
var sha = SHA1()
try streamFile(path: path) { sha.update($0) }
return sha.finalize().map { String(format: "%02x", $0) }.joined()
}
}
static func streamFile(path: String, _ body: (Data) -> Void) throws {
guard let handle = FileHandle(forReadingAtPath: path) else {
throw OffloadError.fileNotFound(path)
}
defer { try? handle.close() }
while true {
guard let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty else { break }
body(chunk)
}
}
}

View file

@ -49,7 +49,8 @@ public enum MHL {
root: String, root: String,
creator: String, creator: String,
files: [String], files: [String],
process: String = "transfer" process: String = "transfer",
algorithm: HashAlgorithm = .xxh64
) throws -> String { ) throws -> String {
let fm = FileManager.default let fm = FileManager.default
let now = isoNow() let now = isoNow()
@ -73,11 +74,12 @@ public enum MHL {
throw OffloadError.fileNotFound(full) throw OffloadError.fileNotFound(full)
} }
let size = (try fm.attributesOfItem(atPath: full)[.size] as? UInt64) ?? 0 let size = (try fm.attributesOfItem(atPath: full)[.size] as? UInt64) ?? 0
let hash = try FileHasher.xxh64(path: full) let hash = try FileHasher.hash(path: full, algorithm: algorithm)
let el = algorithm.mhlElement
out += """ out += """
<hash> <hash>
<path size="\(size)">\(escape(rel))</path> <path size="\(size)">\(escape(rel))</path>
<xxh64 action="original" hashdate="\(now)">\(hash)</xxh64> <\(el) action="original" hashdate="\(now)">\(hash)</\(el)>
</hash> </hash>
""" """
@ -92,9 +94,10 @@ public enum MHL {
creator: String, creator: String,
files: [String], files: [String],
to path: String, to path: String,
process: String = "transfer" process: String = "transfer",
algorithm: HashAlgorithm = .xxh64
) throws { ) throws {
let xml = try generate(root: root, creator: creator, files: files, process: process) let xml = try generate(root: root, creator: creator, files: files, process: process, algorithm: algorithm)
try xml.write(toFile: path, atomically: true, encoding: .utf8) try xml.write(toFile: path, atomically: true, encoding: .utf8)
} }
@ -116,8 +119,8 @@ public enum MHL {
continue continue
} }
} }
let hash = try FileHasher.xxh64(path: full) let hash = try FileHasher.hash(path: full, algorithm: e.algorithm)
if hash != e.xxh64 { if hash != e.hashValue64 {
failures.append(Failure(path: e.path, reason: .hashMismatch)) failures.append(Failure(path: e.path, reason: .hashMismatch))
} }
} }
@ -127,7 +130,8 @@ public enum MHL {
struct Entry { struct Entry {
let path: String let path: String
let size: UInt64? let size: UInt64?
let xxh64: String let algorithm: HashAlgorithm
let hashValue64: String
} }
/// Scanner-based parse of <hash> blocks. Accepts v2.0 attribute-style size /// Scanner-based parse of <hash> blocks. Accepts v2.0 attribute-style size
@ -163,15 +167,25 @@ public enum MHL {
size = UInt64(block[sizeOpen.upperBound..<sizeClose.lowerBound].trimmingCharacters(in: .whitespacesAndNewlines)) size = UInt64(block[sizeOpen.upperBound..<sizeClose.lowerBound].trimmingCharacters(in: .whitespacesAndNewlines))
} }
// xxh64 element with optional attributes. // Hash element: first recognized algorithm element in the block.
guard let hOpen = block.range(of: "<xxh64"), var found: (HashAlgorithm, String)?
for algo in HashAlgorithm.allCases {
let el = algo.mhlElement
guard let hOpen = block.range(of: "<\(el)"),
let hTagEnd = block.range(of: ">", range: hOpen.upperBound..<block.endIndex), let hTagEnd = block.range(of: ">", range: hOpen.upperBound..<block.endIndex),
let hClose = block.range(of: "</xxh64>", range: hTagEnd.upperBound..<block.endIndex) else { let hClose = block.range(of: "</\(el)>", range: hTagEnd.upperBound..<block.endIndex) else {
throw OffloadError.readFailed("malformed <hash> entry: no xxh64") continue
}
let value = String(block[hTagEnd.upperBound..<hClose.lowerBound])
.trimmingCharacters(in: .whitespacesAndNewlines)
found = (algo, value)
break
}
guard let (algorithm, hash) = found else {
throw OffloadError.readFailed("malformed <hash> entry: no recognized hash element")
} }
let hash = String(block[hTagEnd.upperBound..<hClose.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
entries.append(Entry(path: path, size: size, xxh64: hash)) entries.append(Entry(path: path, size: size, algorithm: algorithm, hashValue64: hash))
searchRange = blockEnd.upperBound..<xml.endIndex searchRange = blockEnd.upperBound..<xml.endIndex
} }
guard !entries.isEmpty else { guard !entries.isEmpty else {

View file

@ -0,0 +1,98 @@
import Foundation
/// SHA-1 (RFC 3174) pure Swift streaming. Legacy post-house compatibility.
public struct SHA1 {
private var h0: UInt32 = 0x67452301
private var h1: UInt32 = 0xEFCDAB89
private var h2: UInt32 = 0x98BADCFE
private var h3: UInt32 = 0x10325476
private var h4: UInt32 = 0xC3D2E1F0
private var buffer = [UInt8]()
private var totalLength: UInt64 = 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)
}
}
@inline(__always)
private static func rotl(_ x: UInt32, _ n: UInt32) -> UInt32 {
(x << n) | (x >> (32 - n))
}
private mutating func processBlock(_ block: [UInt8]) {
var w = [UInt32](repeating: 0, count: 80)
for i in 0..<16 {
w[i] = UInt32(block[i * 4]) << 24 | UInt32(block[i * 4 + 1]) << 16
| UInt32(block[i * 4 + 2]) << 8 | UInt32(block[i * 4 + 3])
}
for i in 16..<80 {
w[i] = Self.rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1)
}
var a = h0, b = h1, c = h2, d = h3, e = h4
for i in 0..<80 {
let (f, k): (UInt32, UInt32)
switch i {
case 0..<20: (f, k) = ((b & c) | (~b & d), 0x5A827999)
case 20..<40: (f, k) = (b ^ c ^ d, 0x6ED9EBA1)
case 40..<60: (f, k) = ((b & c) | (b & d) | (c & d), 0x8F1BBCDC)
default: (f, k) = (b ^ c ^ d, 0xCA62C1D6)
}
let temp = Self.rotl(a, 5) &+ f &+ e &+ k &+ w[i]
e = d
d = c
c = Self.rotl(b, 30)
b = a
a = temp
}
h0 = h0 &+ a
h1 = h1 &+ b
h2 = h2 &+ c
h3 = h3 &+ d
h4 = h4 &+ e
}
public mutating func finalize() -> Data {
let bitLength = totalLength * 8
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))
// Length big-endian.
for shift in stride(from: 56, through: 0, by: -8) {
padding.append(UInt8((bitLength >> UInt64(shift)) & 0xFF))
}
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 [h0, h1, h2, h3, h4] {
out.append(UInt8((v >> 24) & 0xFF))
out.append(UInt8((v >> 16) & 0xFF))
out.append(UInt8((v >> 8) & 0xFF))
out.append(UInt8(v & 0xFF))
}
return out
}
public static func hexString(_ data: Data) -> String {
var sha = SHA1()
sha.update(data)
return sha.finalize().map { String(format: "%02x", $0) }.joined()
}
}

View file

@ -0,0 +1,67 @@
import Foundation
import CXXHash
/// XXH3 64/128 via vendored official xxHash v0.8.2 (BSD-2).
public enum XXH3 {
public struct Hash128: Equatable, Sendable {
public let low64: UInt64
public let high64: UInt64
public var hexString: String {
String(format: "%016lx%016lx", high64, low64)
}
}
public static func hash64(_ data: Data) -> UInt64 {
data.withUnsafeBytes { buf in
cxx_xxh3_64(buf.baseAddress, buf.count)
}
}
public static func hash128(_ data: Data) -> Hash128 {
let h = data.withUnsafeBytes { buf in
cxx_xxh3_128(buf.baseAddress, buf.count)
}
return Hash128(low64: h.low64, high64: h.high64)
}
public static func hexString64(_ v: UInt64) -> String {
String(format: "%016lx", v)
}
/// Streaming state.
public final class Streaming {
public enum Bits {
case h64
case h128
}
private let state: OpaquePointer?
private let bits: Bits
public init(bits: Bits) {
self.bits = bits
state = cxx_xxh3_create(bits == .h128 ? 1 : 0)
}
deinit {
cxx_xxh3_free(state)
}
public func update(_ data: Data) {
data.withUnsafeBytes { buf in
cxx_xxh3_update(state, buf.baseAddress, buf.count)
}
}
public func digest64() -> UInt64 {
cxx_xxh3_digest64(state)
}
public func digest128() -> Hash128 {
let h = cxx_xxh3_digest128(state)
return Hash128(low64: h.low64, high64: h.high64)
}
}
}

View file

@ -0,0 +1,103 @@
import Foundation
/// Minimal dependency-free PNG encoder: 8-bit truecolor, stored (uncompressed)
/// deflate blocks inside a valid zlib stream. Bigger files than real deflate,
/// but universally decodable browsers, ffmpeg, Python all accept it.
/// Purpose: shot-report thumbnails + still grabs without an image library.
public enum PNGWriter {
// MARK: CRC32 (PNG chunk checksums)
private static let crcTable: [UInt32] = {
(0..<256).map { n -> UInt32 in
var c = UInt32(n)
for _ in 0..<8 {
c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1
}
return c
}
}()
static func crc32(_ data: Data) -> UInt32 {
var c: UInt32 = 0xFFFFFFFF
for byte in data {
c = crcTable[Int((c ^ UInt32(byte)) & 0xFF)] ^ (c >> 8)
}
return c ^ 0xFFFFFFFF
}
// MARK: Adler32 (zlib checksum)
static func adler32(_ data: Data) -> UInt32 {
var a: UInt32 = 1, b: UInt32 = 0
for byte in data {
a = (a + UInt32(byte)) % 65521
b = (b + a) % 65521
}
return (b << 16) | a
}
private static func putU32BE(_ v: UInt32, into data: inout Data) {
data.append(UInt8((v >> 24) & 0xFF))
data.append(UInt8((v >> 16) & 0xFF))
data.append(UInt8((v >> 8) & 0xFF))
data.append(UInt8(v & 0xFF))
}
private static func chunk(_ type: String, _ payload: Data) -> Data {
var out = Data()
putU32BE(UInt32(payload.count), into: &out)
var typed = Data(type.utf8)
typed.append(payload)
out.append(typed)
putU32BE(crc32(typed), into: &out)
return out
}
/// zlib stream with stored deflate blocks (BTYPE=00).
static func zlibStored(_ raw: Data) -> Data {
var out = Data([0x78, 0x01]) // CMF/FLG: 32K window, no preset, check bits valid
var offset = 0
let maxBlock = 65535
while offset < raw.count {
let size = min(maxBlock, raw.count - offset)
let isLast: UInt8 = (offset + size >= raw.count) ? 1 : 0
out.append(isLast) // BFINAL + BTYPE=00
out.append(UInt8(size & 0xFF))
out.append(UInt8((size >> 8) & 0xFF))
out.append(UInt8(~size & 0xFF))
out.append(UInt8((~size >> 8) & 0xFF))
out.append(raw.subdata(in: raw.startIndex + offset..<raw.startIndex + offset + size))
offset += size
}
putU32BE(adler32(raw), into: &out)
return out
}
/// Encode frame as 8-bit RGB PNG.
public static func encode(_ frame: VideoFrame) -> Data {
var png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
// IHDR
var ihdr = Data()
putU32BE(UInt32(frame.width), into: &ihdr)
putU32BE(UInt32(frame.height), into: &ihdr)
ihdr.append(contentsOf: [8, 2, 0, 0, 0]) // depth 8, truecolor, deflate, adaptive, no interlace
png.append(chunk("IHDR", ihdr))
// Scanlines: filter byte 0 + RGB triples.
var raw = Data(capacity: frame.height * (1 + frame.width * 3))
for y in 0..<frame.height {
raw.append(0)
for x in 0..<frame.width {
let px = frame.pixels[y * frame.width + x]
raw.append(UInt8(min(max(px.x, 0), 1) * 255 + 0.5))
raw.append(UInt8(min(max(px.y, 0), 1) * 255 + 0.5))
raw.append(UInt8(min(max(px.z, 0), 1) * 255 + 0.5))
}
}
png.append(chunk("IDAT", zlibStored(raw)))
png.append(chunk("IEND", Data()))
return png
}
}

View file

@ -75,12 +75,19 @@ public enum FrameProcessor {
} }
} }
/// Writes frame grabs as binary PPM (P6) dependency-free still format. /// Writes frame grabs PPM (raw) or PNG (report/browser-friendly), both dependency-free.
public struct StillGrabber: Sendable { public struct StillGrabber: Sendable {
public let directory: String public enum Format: String, Sendable {
case ppm
case png
}
public init(directory: String) { public let directory: String
public let format: Format
public init(directory: String, format: Format = .ppm) {
self.directory = directory self.directory = directory
self.format = format
} }
/// Returns written file path. /// Returns written file path.
@ -88,14 +95,21 @@ public struct StillGrabber: Sendable {
public func grab(frame: VideoFrame, clipName: String) throws -> String { public func grab(frame: VideoFrame, clipName: String) throws -> String {
try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
let timestamp = Int(Date().timeIntervalSince1970 * 1000) let timestamp = Int(Date().timeIntervalSince1970 * 1000)
let path = "\(directory)/\(clipName)_\(timestamp).ppm" let path = "\(directory)/\(clipName)_\(timestamp).\(format.rawValue)"
var data = Data("P6\n\(frame.width) \(frame.height)\n255\n".utf8) let data: Data
data.reserveCapacity(data.count + frame.pixels.count * 3) switch format {
case .png:
data = PNGWriter.encode(frame)
case .ppm:
var ppm = Data("P6\n\(frame.width) \(frame.height)\n255\n".utf8)
ppm.reserveCapacity(ppm.count + frame.pixels.count * 3)
for px in frame.pixels { for px in frame.pixels {
data.append(UInt8(min(max(px.x, 0), 1) * 255 + 0.5)) ppm.append(UInt8(min(max(px.x, 0), 1) * 255 + 0.5))
data.append(UInt8(min(max(px.y, 0), 1) * 255 + 0.5)) ppm.append(UInt8(min(max(px.y, 0), 1) * 255 + 0.5))
data.append(UInt8(min(max(px.z, 0), 1) * 255 + 0.5)) ppm.append(UInt8(min(max(px.z, 0), 1) * 255 + 0.5))
}
data = ppm
} }
try data.write(to: URL(fileURLWithPath: path)) try data.write(to: URL(fileURLWithPath: path))
return path return path

View file

@ -15,7 +15,98 @@ 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 + DIT toolkit: camera look push, clip logging, offload, proxies.",
subcommands: [Push.self, Status.self, Sim.self, Export.self, Offload.self, Verify.self, Proxy.self]) subcommands: [Push.self, Status.self, Sim.self, Export.self, Offload.self, Verify.self, Proxy.self, IngestMatch.self, Report.self])
}
struct Report: AsyncParsableCommand {
static let configuration = CommandConfiguration(abstract: "Generate an HTML shot report for a day (stills + grades + metadata).")
@Option(name: .long, help: "Library database path")
var db: String
@Option(name: .long, help: "Day label")
var day: String
@Option(name: .long, help: "Project name for header (default: project record name)")
var project: String?
@Option(name: .long, help: "Output HTML file")
var out: String
func run() async throws {
let store = try LibraryStore(path: db)
var target: ForgeLibrary.Day?
var projectName = project ?? "Project"
for p in try store.listProjects() {
if let d = try store.listDays(projectID: p.id).first(where: { $0.label == day }) {
target = d
if project == nil { projectName = p.name }
break
}
}
guard let target else { throw ValidationError("day '\(day)' not found in \(db)") }
let shots = try store.listShots(dayID: target.id)
guard !shots.isEmpty else { throw ValidationError("no shots logged for '\(day)'") }
let html = ShotReport.html(
shots: shots, projectName: projectName, dayLabel: target.label, date: target.date)
try html.write(toFile: out, atomically: true, encoding: .utf8)
print("wrote report: \(out) (\(shots.count) shots)")
}
}
struct IngestMatch: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "ingest-match",
abstract: "Match offloaded media to logged shots by clip name; write grade sidecars (.cdl/.cube) beside each clip.")
@Argument(help: "Media root (offloaded card / RAID folder)")
var media: String
@Option(name: .long, help: "Library database path")
var db: String
@Option(name: .long, help: "Day label")
var day: String
@Flag(name: .long, help: "Skip .cdl sidecars")
var noCdl = false
@Flag(name: .long, help: "Skip .cube sidecars")
var noCube = false
@Option(name: .long, help: "Cube lattice size")
var lattice: Int = 33
@Flag(name: .long, help: "Overwrite existing sidecars")
var force = false
func run() async throws {
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 in \(db)") }
let result = try IngestMatcher.match(
mediaRoot: media, store: store, dayID: target.id,
options: .init(writeCDL: !noCdl, writeCube: !noCube, latticeSize: lattice, force: force))
let withSidecars = result.matched.filter(\.sidecarsWritten).count
print("matched \(result.matched.count) clips (\(withSidecars) sidecars written)")
if !result.unmatchedMedia.isEmpty {
print("unmatched media (\(result.unmatchedMedia.count)):")
for m in result.unmatchedMedia { print(" \(m)") }
}
if !result.shotsWithoutMedia.isEmpty {
print("shots without media (\(result.shotsWithoutMedia.count)):")
for s in result.shotsWithoutMedia { print(" \(s)") }
}
}
} }
struct Proxy: AsyncParsableCommand { struct Proxy: AsyncParsableCommand {
@ -113,17 +204,26 @@ struct Offload: AsyncParsableCommand {
@Option(name: .long, help: "Write HTML report to this path") @Option(name: .long, help: "Write HTML report to this path")
var report: String? var report: String?
@Option(name: .long, help: "Checksum: xxh64|xxh3|xxh128|md5|sha1")
var checksum: String = "xxh64"
func run() async throws { func run() async throws {
guard !destinations.isEmpty else { guard !destinations.isEmpty else {
throw ValidationError("at least one --to destination required") throw ValidationError("at least one --to destination required")
} }
guard let algo = HashAlgorithm(rawValue: checksum) else {
throw ValidationError("unknown checksum '\(checksum)' (xxh64|xxh3|xxh128|md5|sha1)")
}
let engine = CopyEngine() let engine = CopyEngine()
let result = try engine.offload(source: source, destinations: destinations, overwritePartial: overwritePartial) let result = try engine.offload(source: source, destinations: destinations, overwritePartial: overwritePartial)
// MHL manifest at each destination root. // MHL manifest at each destination root.
let files = result.files.map(\.relativePath) let files = result.files.map(\.relativePath)
for dest in destinations { for dest in destinations {
try MHL.write(root: dest, creator: "Forge", files: files, to: dest + "/" + ((source as NSString).lastPathComponent) + ".mhl") try MHL.write(
root: dest, creator: "Forge", files: files,
to: dest + "/" + ((source as NSString).lastPathComponent) + ".mhl",
algorithm: algo)
} }
if let reportPath = report { if let reportPath = report {
try OffloadReportWriter.html(result).write(toFile: reportPath, atomically: true, encoding: .utf8) try OffloadReportWriter.html(result).write(toFile: reportPath, atomically: true, encoding: .utf8)

View file

@ -0,0 +1,145 @@
import XCTest
import Foundation
import ForgeColor
import ForgeGrade
import ForgeLibrary
@testable import ForgeExport
final class IngestMatchTests: XCTestCase {
var mediaDir: String!
var dbPath: String!
var store: LibraryStore!
var day: ForgeLibrary.Day!
override func setUpWithError() throws {
mediaDir = NSTemporaryDirectory() + "forge-ingest-\(UUID().uuidString)"
try FileManager.default.createDirectory(atPath: mediaDir + "/CLIPS", withIntermediateDirectories: true)
dbPath = NSTemporaryDirectory() + "forge-ingest-\(UUID().uuidString).db"
store = try LibraryStore(path: dbPath)
let p = try store.createProject(name: "P")
day = try store.createDay(projectID: p.id, label: "Day 03", date: "2026-07-11")
}
override func tearDown() {
try? FileManager.default.removeItem(atPath: mediaDir)
try? FileManager.default.removeItem(atPath: dbPath)
}
func makeShot(clip: String, withGrade: Bool = true) throws {
var snapshot: Data?
if withGrade {
let stack = GradeStack(nodes: [GradeNode(kind: .cdl(CDL(
slope: SIMD3(1.2, 1.0, 0.9), offset: SIMD3(0.01, 0, 0),
power: .one, saturation: 1.1)))])
snapshot = try GradeSnapshot.encode(stack)
}
_ = try store.createShot(Shot(
dayID: day.id, clipName: clip, cameraSlot: "A-Cam",
timecodeIn: "10:00:00:00", timecodeOut: "10:01:00:00",
gradeSnapshot: snapshot))
}
func makeMedia(_ name: String) throws {
try Data("fake clip".utf8).write(to: URL(fileURLWithPath: mediaDir + "/CLIPS/\(name)"))
}
// Clip file matches shot by basename -> sidecar .cdl + .cube written next to media.
func testMatchWritesSidecars() throws {
try makeShot(clip: "R001C001")
try makeMedia("R001C001.mov")
let result = try IngestMatcher.match(
mediaRoot: mediaDir, store: store, dayID: day.id,
options: IngestMatcher.Options(writeCDL: true, writeCube: true, latticeSize: 17))
XCTAssertEqual(result.matched.count, 1)
XCTAssertEqual(result.matched[0].clipName, "R001C001")
XCTAssertTrue(FileManager.default.fileExists(atPath: mediaDir + "/CLIPS/R001C001.cdl"))
XCTAssertTrue(FileManager.default.fileExists(atPath: mediaDir + "/CLIPS/R001C001.cube"))
// CDL sidecar parses back to shot's grade.
let cdlText = try String(contentsOfFile: mediaDir + "/CLIPS/R001C001.cdl", encoding: .utf8)
let parsed = try CDLExport.parseCDL(cdlText)
XCTAssertEqual(parsed.slope.x, 1.2, accuracy: 1e-5)
XCTAssertEqual(parsed.saturation, 1.1, accuracy: 1e-5)
}
// Case-insensitive + extension-agnostic matching.
func testMatchIsCaseAndExtensionAgnostic() throws {
try makeShot(clip: "R001C002")
try makeMedia("r001c002.MXF")
let result = try IngestMatcher.match(
mediaRoot: mediaDir, store: store, dayID: day.id,
options: .init(writeCDL: true, writeCube: false, latticeSize: 17))
XCTAssertEqual(result.matched.count, 1)
XCTAssertTrue(FileManager.default.fileExists(atPath: mediaDir + "/CLIPS/r001c002.cdl"))
}
// Media with no shot record -> unmatched list.
func testUnmatchedMediaListed() throws {
try makeShot(clip: "R001C003")
try makeMedia("R001C003.mov")
try makeMedia("R001C099.mov") // no shot
let result = try IngestMatcher.match(
mediaRoot: mediaDir, store: store, dayID: day.id,
options: .init(writeCDL: false, writeCube: false, latticeSize: 17))
XCTAssertEqual(result.matched.count, 1)
XCTAssertEqual(result.unmatchedMedia, ["CLIPS/R001C099.mov"])
}
// Shot with no media -> missing list.
func testShotsWithoutMediaListed() throws {
try makeShot(clip: "R001C004")
try makeShot(clip: "R001C005")
try makeMedia("R001C004.mov")
let result = try IngestMatcher.match(
mediaRoot: mediaDir, store: store, dayID: day.id,
options: .init(writeCDL: false, writeCube: false, latticeSize: 17))
XCTAssertEqual(result.shotsWithoutMedia, ["R001C005"])
}
// Shot without grade snapshot matches but writes no sidecars.
func testNoGradeNoSidecar() throws {
try makeShot(clip: "R001C006", withGrade: false)
try makeMedia("R001C006.mov")
let result = try IngestMatcher.match(
mediaRoot: mediaDir, store: store, dayID: day.id,
options: .init(writeCDL: true, writeCube: true, latticeSize: 17))
XCTAssertEqual(result.matched.count, 1)
XCTAssertFalse(result.matched[0].sidecarsWritten)
XCTAssertFalse(FileManager.default.fileExists(atPath: mediaDir + "/CLIPS/R001C006.cdl"))
}
// Non-media files ignored.
func testNonMediaIgnored() throws {
try makeShot(clip: "R001C007")
try makeMedia("R001C007.mov")
try Data("x".utf8).write(to: URL(fileURLWithPath: mediaDir + "/CLIPS/notes.txt"))
try Data("x".utf8).write(to: URL(fileURLWithPath: mediaDir + "/manifest.mhl"))
let result = try IngestMatcher.match(
mediaRoot: mediaDir, store: store, dayID: day.id,
options: .init(writeCDL: false, writeCube: false, latticeSize: 17))
XCTAssertEqual(result.matched.count, 1)
XCTAssertTrue(result.unmatchedMedia.isEmpty)
}
// Existing sidecar not overwritten unless force.
func testExistingSidecarPreserved() throws {
try makeShot(clip: "R001C008")
try makeMedia("R001C008.mov")
try Data("existing".utf8).write(to: URL(fileURLWithPath: mediaDir + "/CLIPS/R001C008.cdl"))
_ = try IngestMatcher.match(
mediaRoot: mediaDir, store: store, dayID: day.id,
options: .init(writeCDL: true, writeCube: false, latticeSize: 17))
let content = try String(contentsOfFile: mediaDir + "/CLIPS/R001C008.cdl", encoding: .utf8)
XCTAssertEqual(content, "existing")
_ = try IngestMatcher.match(
mediaRoot: mediaDir, store: store, dayID: day.id,
options: .init(writeCDL: true, writeCube: false, latticeSize: 17, force: true))
let after = try String(contentsOfFile: mediaDir + "/CLIPS/R001C008.cdl", encoding: .utf8)
XCTAssertTrue(after.contains("ColorDecisionList"))
}
}

View file

@ -0,0 +1,86 @@
import XCTest
import Foundation
import ForgeColor
import ForgeGrade
import ForgeLibrary
@testable import ForgeExport
final class ShotReportTests: XCTestCase {
func makeShots(stillDir: String? = nil) throws -> [Shot] {
let dayID = UUID()
let cdl = CDL(slope: SIMD3(1.2, 1.0, 0.85), offset: SIMD3(0.01, -0.02, 0),
power: SIMD3(0.95, 1, 1.1), saturation: 1.25)
let snapshot = try GradeSnapshot.encode(GradeStack(nodes: [GradeNode(kind: .cdl(cdl))]))
var stillPath: String?
if let dir = stillDir {
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
stillPath = dir + "/R001C001_test.png"
// Tiny valid PNG via known bytes: reuse writer through raw data simplest:
// 1x1 red PNG generated with our own encoder shape is fine for embed test.
let pngMagic: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
try Data(pngMagic + [0, 0, 0, 0]).write(to: URL(fileURLWithPath: stillPath!))
}
return [
Shot(dayID: dayID, clipName: "R001C001", cameraSlot: "A-Cam",
timecodeIn: "10:00:00:00", timecodeOut: "10:00:30:00",
exposureIndex: 800, whiteBalance: 5600, tint: 0, fps: 24,
gradeSnapshot: snapshot, stillPath: stillPath),
Shot(dayID: dayID, clipName: "B001_C012", cameraSlot: "B-Cam",
timecodeIn: "10:05:00:00", timecodeOut: "10:06:00:00",
exposureIndex: 1600, whiteBalance: 3200, tint: -2, fps: 23.98,
gradeSnapshot: snapshot),
]
}
// Report contains header, shot rows, CDL values, camera metadata.
func testReportContent() throws {
let shots = try makeShots()
let html = ShotReport.html(
shots: shots, projectName: "Nightfall", dayLabel: "Day 03", date: "2026-07-11")
XCTAssertTrue(html.contains("<!DOCTYPE html>"))
XCTAssertTrue(html.contains("Nightfall"))
XCTAssertTrue(html.contains("Day 03"))
XCTAssertTrue(html.contains("R001C001"))
XCTAssertTrue(html.contains("B001_C012"))
// CDL formatted values.
XCTAssertTrue(html.contains("1.200"))
XCTAssertTrue(html.contains("1.250")) // sat
// Metadata.
XCTAssertTrue(html.contains("800"))
XCTAssertTrue(html.contains("5600"))
XCTAssertTrue(html.contains("10:00:00:00"))
// Reel tokens present.
XCTAssertTrue(html.contains("#F0A63C"))
XCTAssertTrue(html.contains("#08090C"))
}
// Stills embedded as base64 data URIs when file exists.
func testStillEmbedded() throws {
let dir = NSTemporaryDirectory() + "forge-report-\(UUID().uuidString)"
defer { try? FileManager.default.removeItem(atPath: dir) }
let shots = try makeShots(stillDir: dir)
let html = ShotReport.html(shots: shots, projectName: "P", dayLabel: "D", date: "2026-07-11")
XCTAssertTrue(html.contains("data:image/png;base64,iVBORw0KGgo"))
// Shot without still gets placeholder, not broken img.
XCTAssertTrue(html.contains("no-still") || html.contains("No still"))
}
// Shot count + totals in summary.
func testSummaryTotals() throws {
let shots = try makeShots()
let html = ShotReport.html(shots: shots, projectName: "P", dayLabel: "D", date: "2026-07-11")
XCTAssertTrue(html.contains("2 shots"))
}
// HTML-escapes clip names.
func testEscaping() throws {
var shots = try makeShots()
shots[0].clipName = "R001<C001>&"
let html = ShotReport.html(shots: shots, projectName: "P", dayLabel: "D", date: "2026-07-11")
XCTAssertTrue(html.contains("R001&lt;C001&gt;&amp;"))
XCTAssertFalse(html.contains("R001<C001>"))
}
}

View file

@ -0,0 +1,123 @@
import XCTest
import Foundation
@testable import ForgeOffload
final class ChecksumBreadthTests: XCTestCase {
// XXH3-64 vectors verified against official xxhsum 0.8.2 (-H3, seed 0).
func testXXH3_64Vectors() {
XCTAssertEqual(XXH3.hash64(Data()), 0x2D06_800538D394C2)
XCTAssertEqual(XXH3.hash64(Data("xxhash".utf8)), 0xAA4C_2B42AE6B13DE)
XCTAssertEqual(
XXH3.hash64(Data("Nobody inspects the spammish repetition".utf8)),
0x6CB0_0603B5CC47E9)
}
// XXH3-128 vectors verified against official xxhsum 0.8.2 (-H128).
// Canonical hex = high64 then low64.
func testXXH3_128Vectors() {
let empty = XXH3.hash128(Data())
XCTAssertEqual(empty.hexString, "99aa06d3014798d86001c324468d497f")
let xx = XXH3.hash128(Data("xxhash".utf8))
XCTAssertEqual(xx.hexString, "9c8b437c78cac00a376072e24bfdf4d2")
}
// Streaming XXH3 == one-shot across chunk boundaries.
func testXXH3StreamingMatchesOneShot() {
var data = Data()
var seed: UInt64 = 0xABCDEF
for _ in 0..<(2 * 1024 * 1024 / 8) {
seed = seed &* 6364136223846793005 &+ 1442695040888963407
withUnsafeBytes(of: seed) { data.append(contentsOf: $0) }
}
let whole64 = XXH3.hash64(data)
let whole128 = XXH3.hash128(data)
let s64 = XXH3.Streaming(bits: .h64)
let s128 = XXH3.Streaming(bits: .h128)
var offset = 0
let chunks = [1, 63, 64, 65, 4096, 999_999]
var i = 0
while offset < data.count {
let size = min(chunks[i % chunks.count], data.count - offset)
let chunk = data.subdata(in: offset..<offset + size)
s64.update(chunk)
s128.update(chunk)
offset += size
i += 1
}
XCTAssertEqual(s64.digest64(), whole64)
let d128 = s128.digest128()
XCTAssertEqual(d128.low64, whole128.low64)
XCTAssertEqual(d128.high64, whole128.high64)
}
// SHA1 RFC 3174 vectors.
func testSHA1Vectors() {
XCTAssertEqual(SHA1.hexString(Data("abc".utf8)), "a9993e364706816aba3e25717850c26c9cd0d89d")
XCTAssertEqual(
SHA1.hexString(Data("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq".utf8)),
"84983e441c3bd26ebaae4aa1f95129e5e54670f1")
XCTAssertEqual(SHA1.hexString(Data()), "da39a3ee5e6b4b0d3255bfef95601890afd80709")
}
// SHA1 streaming across block boundary.
func testSHA1Streaming() {
let text = String(repeating: "a", count: 1_000_000)
// RFC 3174 test: one million 'a' -> 34aa973cd4c4daa4f61eeb2bdbad27316534016f
var sha = SHA1()
let data = Data(text.utf8)
var offset = 0
while offset < data.count {
let size = min(77_777, data.count - offset)
sha.update(data.subdata(in: offset..<offset + size))
offset += size
}
let digest = sha.finalize()
XCTAssertEqual(digest.map { String(format: "%02x", $0) }.joined(),
"34aa973cd4c4daa4f61eeb2bdbad27316534016f")
}
// FileHasher algorithm dispatch: all algos over same temp file.
func testFileHasherAllAlgorithms() throws {
let path = NSTemporaryDirectory() + "forge-algo-\(UUID().uuidString).bin"
defer { try? FileManager.default.removeItem(atPath: path) }
var data = Data()
for i in 0..<50_000 { data.append(UInt8(truncatingIfNeeded: i)) }
try data.write(to: URL(fileURLWithPath: path))
for algo in HashAlgorithm.allCases {
let fileHash = try FileHasher.hash(path: path, algorithm: algo)
let memHash = HashAlgorithm.hashInMemory(data, algorithm: algo)
XCTAssertEqual(fileHash, memHash, "\(algo) file != memory")
XCTAssertFalse(fileHash.isEmpty)
}
}
// MHL element names per algorithm (ASC MHL v2 spec names).
func testMHLElementNames() {
XCTAssertEqual(HashAlgorithm.xxh64.mhlElement, "xxh64")
XCTAssertEqual(HashAlgorithm.xxh3.mhlElement, "xxh3")
XCTAssertEqual(HashAlgorithm.xxh128.mhlElement, "xxh128")
XCTAssertEqual(HashAlgorithm.md5.mhlElement, "md5")
XCTAssertEqual(HashAlgorithm.sha1.mhlElement, "sha1")
}
// MHL generate with alternate algorithm embeds right element + verifies.
func testMHLWithXXH3() throws {
let root = NSTemporaryDirectory() + "forge-mhl3-\(UUID().uuidString)"
defer { try? FileManager.default.removeItem(atPath: root) }
try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true)
try Data("clip data".utf8).write(to: URL(fileURLWithPath: root + "/C1.mov"))
let xml = try MHL.generate(root: root, creator: "Forge", files: ["C1.mov"], algorithm: .xxh3)
XCTAssertTrue(xml.contains("<xxh3 action=\"original\""))
let result = try MHL.verify(manifest: xml, root: root)
XCTAssertTrue(result.passed)
// Tamper still caught with xxh3.
try Data("clip DATA".utf8).write(to: URL(fileURLWithPath: root + "/C1.mov"))
let bad = try MHL.verify(manifest: xml, root: root)
XCTAssertFalse(bad.passed)
}
}

View file

@ -0,0 +1,93 @@
import XCTest
import Foundation
@testable import ForgeVideo
final class PNGTests: XCTestCase {
// PNG signature + chunk layout (IHDR/IDAT/IEND).
func testStructure() {
let frame = VideoFrame(
width: 2, height: 2,
pixels: [SIMD3(1, 0, 0), SIMD3(0, 1, 0), SIMD3(0, 0, 1), SIMD3(1, 1, 1)])
let png = PNGWriter.encode(frame)
// Signature.
XCTAssertEqual([UInt8](png.prefix(8)), [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
// IHDR right after signature.
XCTAssertEqual([UInt8](png[12..<16]), [UInt8]("IHDR".utf8))
// Width/height big-endian in IHDR.
XCTAssertEqual([UInt8](png[16..<24]), [0, 0, 0, 2, 0, 0, 0, 2])
// Bit depth 8, color type 2 (truecolor).
XCTAssertEqual(png[24], 8)
XCTAssertEqual(png[25], 2)
// Contains IDAT and ends with IEND.
XCTAssertNotNil(png.range(of: Data("IDAT".utf8)))
XCTAssertEqual([UInt8](png.suffix(8).prefix(4)), [UInt8]("IEND".utf8))
}
// zlib stream inside IDAT decodes back to original scanlines (via system python zlib).
func testPixelsRoundTripViaPython() throws {
let frame = VideoFrame(
width: 3, height: 2,
pixels: [
SIMD3(1, 0, 0), SIMD3(0, 1, 0), SIMD3(0, 0, 1),
SIMD3(0.5, 0.5, 0.5), SIMD3(0, 0, 0), SIMD3(1, 1, 1),
])
let png = PNGWriter.encode(frame)
let path = NSTemporaryDirectory() + "forge-png-\(UUID().uuidString).png"
defer { try? FileManager.default.removeItem(atPath: path) }
try png.write(to: URL(fileURLWithPath: path))
// Python: parse IDAT, inflate, check first pixel bytes.
let script = """
import struct, zlib, sys
data = open('\(path)', 'rb').read()
pos = 8
idat = b''
while pos < len(data):
length, ctype = struct.unpack('>I4s', data[pos:pos+8])
if ctype == b'IDAT':
idat += data[pos+8:pos+8+length]
pos += 12 + length
raw = zlib.decompress(idat)
# Row 0: filter byte 0 then RGB bytes.
assert raw[0] == 0, 'filter'
assert raw[1:4] == bytes([255, 0, 0]), raw[1:4]
assert raw[4:7] == bytes([0, 255, 0]), raw[4:7]
# Row 1 first pixel = 128 gray.
row1 = raw[1 + 3*3:]
assert row1[0] == 0, 'filter row1'
assert row1[1:4] == bytes([128, 128, 128]), row1[1:4]
print('PNG-OK')
"""
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/python3")
proc.arguments = ["-c", script]
let pipe = Pipe()
proc.standardOutput = pipe
proc.standardError = pipe
try proc.run()
proc.waitUntilExit()
let out = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
XCTAssertEqual(proc.terminationStatus, 0, out)
XCTAssertTrue(out.contains("PNG-OK"), out)
}
// Values clamp.
func testClamping() {
let frame = VideoFrame(width: 1, height: 1, pixels: [SIMD3(2.0, -1.0, 0.5)])
let png = PNGWriter.encode(frame)
XCTAssertGreaterThan(png.count, 40)
}
// StillGrabber PNG mode writes .png beside proper name.
func testStillGrabberPNG() throws {
let dir = NSTemporaryDirectory() + "forge-pngstill-\(UUID().uuidString)"
defer { try? FileManager.default.removeItem(atPath: dir) }
let frame = VideoFrame(width: 4, height: 4, pixels: .init(repeating: SIMD3(0.2, 0.4, 0.8), count: 16))
let path = try StillGrabber(directory: dir, format: .png).grab(frame: frame, clipName: "R001C001")
XCTAssertTrue(path.hasSuffix(".png"))
let data = try Data(contentsOf: URL(fileURLWithPath: path))
XCTAssertEqual([UInt8](data.prefix(4)), [0x89, 0x50, 0x4E, 0x47])
}
}