119 lines
5.2 KiB
Swift
119 lines
5.2 KiB
Swift
|
|
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>
|
||
|
|
"""
|
||
|
|
}
|
||
|
|
}
|