diff --git a/Sources/ForgeExport/ShotReport.swift b/Sources/ForgeExport/ShotReport.swift
new file mode 100644
index 0000000..2b2b13e
--- /dev/null
+++ b/Sources/ForgeExport/ShotReport.swift
@@ -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: "&")
+ .replacingOccurrences(of: "<", with: "<")
+ .replacingOccurrences(of: ">", with: ">")
+ }
+
+ 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 = "
"
+ } else {
+ still = "
No still
"
+ }
+ let gradeBlock: String
+ if let c {
+ gradeBlock = """
+
+ S \(f3(c.slope.x)) \(f3(c.slope.y)) \(f3(c.slope.z))
+ O \(f3(c.offset.x)) \(f3(c.offset.y)) \(f3(c.offset.z))
+ P \(f3(c.power.x)) \(f3(c.power.y)) \(f3(c.power.z))
+ Sat \(f3(c.saturation))
+
+ """
+ } else {
+ gradeBlock = "No grade
"
+ }
+ rows += """
+
+ \(still)
+
+
\(escapeHTML(s.clipName))
+
\(escapeHTML(s.cameraSlot)) · \(s.timecodeIn ?? "—") → \(s.timecodeOut ?? "—")
+
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
+
+ \(gradeBlock)
+
+
+ """
+ }
+
+ return """
+
+
+
+
+ \(escapeHTML(projectName)) — \(escapeHTML(dayLabel)) Shot Report
+
+
+
+ \(escapeHTML(projectName)) — \(escapeHTML(dayLabel))
+ \(escapeHTML(date)) · \(shots.count) shots · generated by Forge
+ \(rows)
+
+
+
+ """
+ }
+}
diff --git a/Sources/ForgeVideo/PNGWriter.swift b/Sources/ForgeVideo/PNGWriter.swift
new file mode 100644
index 0000000..5d58073
--- /dev/null
+++ b/Sources/ForgeVideo/PNGWriter.swift
@@ -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.. 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.. String {
try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
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)
- 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))
+ let data: Data
+ 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 {
+ ppm.append(UInt8(min(max(px.x, 0), 1) * 255 + 0.5))
+ ppm.append(UInt8(min(max(px.y, 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))
return path
diff --git a/Sources/forge-cli/Main.swift b/Sources/forge-cli/Main.swift
index b2effab..46baaeb 100644
--- a/Sources/forge-cli/Main.swift
+++ b/Sources/forge-cli/Main.swift
@@ -15,7 +15,44 @@ struct Forge: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "forge",
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, IngestMatch.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 {
diff --git a/Tests/ForgeExportTests/ShotReportTests.swift b/Tests/ForgeExportTests/ShotReportTests.swift
new file mode 100644
index 0000000..f6e9f8e
--- /dev/null
+++ b/Tests/ForgeExportTests/ShotReportTests.swift
@@ -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(""))
+ 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&"
+ let html = ShotReport.html(shots: shots, projectName: "P", dayLabel: "D", date: "2026-07-11")
+ XCTAssertTrue(html.contains("R001<C001>&"))
+ XCTAssertFalse(html.contains("R001"))
+ }
+}
diff --git a/Tests/ForgeVideoTests/PNGTests.swift b/Tests/ForgeVideoTests/PNGTests.swift
new file mode 100644
index 0000000..d1ec39e
--- /dev/null
+++ b/Tests/ForgeVideoTests/PNGTests.swift
@@ -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])
+ }
+}