diff --git a/Sources/ForgeOffload/OffloadReportWriter.swift b/Sources/ForgeOffload/OffloadReportWriter.swift
new file mode 100644
index 0000000..cd02b60
--- /dev/null
+++ b/Sources/ForgeOffload/OffloadReportWriter.swift
@@ -0,0 +1,50 @@
+import Foundation
+
+/// Offload report renderers: CSV (machine) + HTML (producer handoff).
+public enum OffloadReportWriter {
+
+ public static func csv(_ report: OffloadReport) -> String {
+ var out = "relative_path,size,xxh64,verified,skipped\n"
+ for f in report.files {
+ out += "\(f.relativePath),\(f.size),\(f.xxh64),\(f.verified),\(f.skipped)\n"
+ }
+ return out
+ }
+
+ public static func formatBytes(_ bytes: UInt64) -> String {
+ let mb = Double(bytes) / 1_000_000
+ if mb >= 1000 {
+ return String(format: "%.2f GB", mb / 1000)
+ }
+ return String(format: "%.1f MB", mb)
+ }
+
+ public static func html(_ report: OffloadReport) -> String {
+ let allVerified = report.files.allSatisfy(\.verified)
+ var rows = ""
+ for f in report.files {
+ rows += """
+
| \(f.relativePath) | \(formatBytes(f.size)) | \
+ \(f.xxh64) | \
+ \(f.verified ? "OK" : "FAIL") | \
+ \(f.skipped ? "skipped" : "copied") |
\n
+ """
+ }
+ return """
+
+ Forge Offload Report
+
+
+
+ Offload Report — \(allVerified ? "VERIFIED" : "FAILURES PRESENT")
+ Source: \(report.source)
+ Destinations: \(report.destinations.joined(separator: ", "))
+ \(report.files.count) files, \(formatBytes(report.totalBytes)), \(String(format: "%.1f", report.duration))s
+
+ | File | Size | xxHash64 | Verify | Action |
+ \(rows)
+
+
+ """
+ }
+}
diff --git a/Sources/forge-cli/Main.swift b/Sources/forge-cli/Main.swift
index b05ac16..f1dc084 100644
--- a/Sources/forge-cli/Main.swift
+++ b/Sources/forge-cli/Main.swift
@@ -7,13 +7,74 @@ import ForgeColor
import ForgeSim
import ForgeLibrary
import ForgeExport
+import ForgeOffload
@main
struct Forge: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "forge",
- abstract: "On-set live grading: push looks to cameras over IP.",
- subcommands: [Push.self, Status.self, Sim.self, Export.self])
+ abstract: "On-set live grading + DIT toolkit: camera look push, clip logging, offload, proxies.",
+ subcommands: [Push.self, Status.self, Sim.self, Export.self, Offload.self, Verify.self])
+}
+
+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 {
diff --git a/Tests/ForgeOffloadTests/ReportTests.swift b/Tests/ForgeOffloadTests/ReportTests.swift
new file mode 100644
index 0000000..6770f95
--- /dev/null
+++ b/Tests/ForgeOffloadTests/ReportTests.swift
@@ -0,0 +1,40 @@
+import XCTest
+import Foundation
+@testable import ForgeOffload
+
+final class ReportTests: XCTestCase {
+
+ func makeReport() -> OffloadReport {
+ OffloadReport(
+ source: "/Volumes/CARD_A001",
+ destinations: ["/RAID/A001", "/SHUTTLE/A001"],
+ files: [
+ FileRecord(relativePath: "CLIPS/C001.mov", size: 1_000_000, xxh64: "abc123def4567890", verified: true, skipped: false),
+ FileRecord(relativePath: "CLIPS/C002.mov", size: 2_500_000, xxh64: "1111222233334444", verified: true, skipped: true),
+ ],
+ totalBytes: 3_500_000,
+ duration: 12.5,
+ startedAt: Date(timeIntervalSince1970: 1_780_000_000))
+ }
+
+ // CSV report golden shape.
+ func testCSVReport() {
+ let csv = OffloadReportWriter.csv(makeReport())
+ let lines = csv.split(separator: "\n").map(String.init)
+ XCTAssertEqual(lines[0], "relative_path,size,xxh64,verified,skipped")
+ XCTAssertEqual(lines[1], "CLIPS/C001.mov,1000000,abc123def4567890,true,false")
+ XCTAssertEqual(lines[2], "CLIPS/C002.mov,2500000,1111222233334444,true,true")
+ }
+
+ // HTML report contains summary + rows.
+ func testHTMLReport() {
+ let html = OffloadReportWriter.html(makeReport())
+ XCTAssertTrue(html.contains(""))
+ 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"))
+ }
+}