From 84a5b45b3ee54a66d075a5024bb56f6dd0d1819e Mon Sep 17 00:00:00 2001 From: Forge Dev Date: Sat, 11 Jul 2026 19:50:27 +0000 Subject: [PATCH] =?UTF-8?q?video+export:=20PNG=20encoder=20(dep-free,=20st?= =?UTF-8?q?ored-deflate=20zlib,=20python-verified),=20StillGrabber=20PNG?= =?UTF-8?q?=20mode,=20Reel-styled=20HTML=20shot=20report=20w/=20base64=20s?= =?UTF-8?q?tills=20+=20CDL=20blocks,=20forge=20report=20CLI=20=E2=80=94=20?= =?UTF-8?q?8=20tests,=20222=20total?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ForgeExport/ShotReport.swift | 118 +++++++++++++++++++ Sources/ForgeVideo/PNGWriter.swift | 103 ++++++++++++++++ Sources/ForgeVideo/Video.swift | 34 ++++-- Sources/forge-cli/Main.swift | 39 +++++- Tests/ForgeExportTests/ShotReportTests.swift | 86 ++++++++++++++ Tests/ForgeVideoTests/PNGTests.swift | 93 +++++++++++++++ 6 files changed, 462 insertions(+), 11 deletions(-) create mode 100644 Sources/ForgeExport/ShotReport.swift create mode 100644 Sources/ForgeVideo/PNGWriter.swift create mode 100644 Tests/ForgeExportTests/ShotReportTests.swift create mode 100644 Tests/ForgeVideoTests/PNGTests.swift 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 = "\"\(escapeHTML(s.clipName))\"" + } 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) +
All grades captured live at record start. CDL values in ASC order (slope / offset / power / saturation).
+ + + """ + } +} 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]) + } +}