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:
parent
03fa5ca2d4
commit
84a5b45b3e
6 changed files with 462 additions and 11 deletions
118
Sources/ForgeExport/ShotReport.swift
Normal file
118
Sources/ForgeExport/ShotReport.swift
Normal 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: "<")
|
||||
.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 = "<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>
|
||||
"""
|
||||
}
|
||||
}
|
||||
103
Sources/ForgeVideo/PNGWriter.swift
Normal file
103
Sources/ForgeVideo/PNGWriter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 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.format = format
|
||||
}
|
||||
|
||||
/// Returns written file path.
|
||||
|
|
@ -88,14 +95,21 @@ public struct StillGrabber: Sendable {
|
|||
public func grab(frame: VideoFrame, clipName: String) throws -> 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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
86
Tests/ForgeExportTests/ShotReportTests.swift
Normal file
86
Tests/ForgeExportTests/ShotReportTests.swift
Normal 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<C001>&"))
|
||||
XCTAssertFalse(html.contains("R001<C001>"))
|
||||
}
|
||||
}
|
||||
93
Tests/ForgeVideoTests/PNGTests.swift
Normal file
93
Tests/ForgeVideoTests/PNGTests.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue