video+export: PNG encoder (dep-free, stored-deflate zlib, python-verified), StillGrabber PNG mode, Reel-styled HTML shot report w/ base64 stills + CDL blocks, forge report CLI — 8 tests, 222 total

This commit is contained in:
Forge Dev 2026-07-11 19:50:27 +00:00
parent 03fa5ca2d4
commit 84a5b45b3e
6 changed files with 462 additions and 11 deletions

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: "&")
.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,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,44 @@ 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, 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 { struct IngestMatch: AsyncParsableCommand {

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,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])
}
}