#!/usr/bin/env python3
"""Build the offline Phase 3 gallery; run from any working directory.
Reads the plan, records and PNG headers only. Native image bytes are never edited.
Usage: python3 /path/to/saumwacht-godot/tools/build_asset_gallery.py
"""
from __future__ import annotations
import html
import json
import os
from pathlib import Path
import struct
import sys
from urllib.parse import quote
PROJECT = Path(__file__).resolve().parents[1]
ASSET_ROOT = PROJECT / "assets-source" / "phase3"
OUTPUT = ASSET_ROOT / "gallery.html"
FACTIONS = {
"glutpakt": "Glutpakt",
"nietenkonvent": "Nietenkonvent",
"nebelchor": "Nebelchor",
"wurzelbund": "Wurzelbund",
"neutral": "Neutrale",
"resources": "Ressourcen",
"types": "Schaden & Rüstung",
"map": "Karte",
"ui": "Oberfläche",
}
KINDS = {
"unit_icon": "Einheitenicon",
"mercenary_icon": "Söldnericon",
"resource_icon": "Ressourcenicon",
"worker_icon": "Arbeitericon",
"type_icon": "Typicon",
"map_concept": "Kartenkonzept",
"tile_concept": "Bodenkachel",
"ui_frame": "UI-Rahmen",
"ui_button": "Button",
}
def esc(value: object) -> str:
return html.escape(str(value), quote=True)
def local_url(path: Path) -> str:
return quote(Path(os.path.relpath(path, ASSET_ROOT)).as_posix(), safe="/.")
def png_size(path: Path) -> tuple[int, int] | None:
"""Inspect the native PNG header without decoding or changing pixels."""
try:
with path.open("rb") as source:
header = source.read(24)
if len(header) == 24 and header[:8] == b"\x89PNG\r\n\x1a\n" and header[12:16] == b"IHDR":
return struct.unpack(">II", header[16:24])
except OSError:
pass
return None
def read_assets() -> list[dict]:
plan = json.loads((ASSET_ROOT / "asset_plan.json").read_text(encoding="utf-8"))
assets = []
for planned in plan["assets"]:
asset = dict(planned)
record_path = ASSET_ROOT / "records" / f"{planned['id']}.json"
record = {}
issue = "Datensatz und Bild stehen noch aus."
if record_path.is_file():
try:
record = json.loads(record_path.read_text(encoding="utf-8"))
if not isinstance(record, dict) or record.get("id") != planned["id"]:
raise ValueError("Datensatz-ID stimmt nicht mit dem Plan überein")
except (OSError, ValueError) as error:
print(f"Hinweis: {record_path.name}: {error}", file=sys.stderr)
record = {}
issue = "Datensatz ist noch nicht lesbar."
asset.update(record)
asset["id"] = planned["id"]
master = None
if record.get("master_path"):
given = Path(record["master_path"])
candidates = [given] if given.is_absolute() else [ASSET_ROOT / given, PROJECT / given]
# Keep a copied project usable when records retain their original absolute paths.
if "masters" in given.parts:
index = given.parts.index("masters")
candidates.insert(0, ASSET_ROOT.joinpath(*given.parts[index:]))
master = next((path for path in candidates if path.is_file()), None)
issue = "Originaldatei fehlt noch im Projekt."
size = png_size(master) if master else None
if master and not size:
issue = "Originaldatei ist noch kein lesbares PNG."
asset["ready"] = bool(record and master and size)
asset["issue"] = issue
asset["image_url"] = local_url(master) if asset["ready"] else ""
asset["record_url"] = local_url(record_path) if record else ""
asset["dimensions"] = size
assets.append(asset)
return assets
def card(asset: dict) -> str:
asset_id = asset["id"]
name = asset.get("display_name", asset_id)
faction = asset.get("faction", "unknown")
kind = asset.get("kind", "unknown")
faction_name = FACTIONS.get(faction, faction)
kind_name = KINDS.get(kind, kind)
description = asset.get("description_de", "Beschreibung folgt mit dem erzeugten Asset.")
ready = asset["ready"]
state = "ready" if ready else "pending"
is_icon = kind.endswith("_icon")
dimensions = asset["dimensions"]
wide = not is_icon and (kind == "map_concept" or (dimensions and dimensions[0] / dimensions[1] > 1.6))
classes = "asset-card" + (" wide-card" if wide else "") + (" pending-card" if not ready else "")
search = " ".join(str(item) for item in (asset_id, name, faction_name, kind_name, description))
if ready:
width, height = dimensions
preview = f'''
'''
size_label = f"{width} × {height} px"
links = f'''PNG-Original ↗
Datensatz ↗'''
else:
preview = '
◇Ausstehend' + esc(asset["issue"]) + '
'
size_label = "Noch kein Original"
links = f'Datensatz ↗' if asset["record_url"] else ""
alpha = "Alpha vorgesehen" if asset.get("requires_alpha") else "Flächiges Motiv"
prompt = asset.get("prompt")
prompt_html = f'Generierungsprompt
{esc(prompt)} ' if prompt else 'Prompt folgt mit dem Datensatz.
'
stage_class = "icon-stage" if is_icon else "media-stage"
return f'''
{esc(asset_id)}● {"Vorhanden" if ready else "Ausstehend"}
{preview}
{esc(faction_name)} / {esc(kind_name)}
{esc(name)}
{esc(description)}
{size_label}{alpha}
{prompt_html}
{links}
'''
CSS = r"""
:root { color-scheme: dark; --page:#101a20; --panel:#18262e; --line:#30424a; --ink:#edf0e9; --muted:#a9b9b8; --gold:#d9b66b; --mint:#82dbce; --icon-size:128px; }
* { box-sizing:border-box; }
body { margin:0; background:var(--page); color:var(--ink); font:15px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
button,input,select { font:inherit; }
button,select { cursor:pointer; }
a { color:var(--mint); text-underline-offset:4px; }
a:hover { color:#fff; }
:focus-visible { outline:3px solid var(--mint); outline-offset:4px; }
.skip { position:absolute; top:-100px; left:20px; padding:12px; background:var(--page); z-index:20; }
.skip:focus { top:10px; }
.shell { max-width:1580px; margin:0 auto; padding:0 36px; }
.hero { padding:48px 0 28px; display:flex; gap:36px; justify-content:space-between; align-items:flex-end; border-bottom:1px solid var(--line); }
.kicker { margin:0 0 12px; color:var(--gold); text-transform:uppercase; letter-spacing:.2em; font-size:11px; font-weight:750; }
h1 { margin:0; font-size:clamp(36px,5vw,66px); letter-spacing:-.045em; line-height:1.05; font-weight:650; }
.intro { margin:18px 0 0; color:var(--muted); max-width:700px; }
.stats { display:flex; gap:32px; margin:0 0 2px; flex-shrink:0; }
.stats div { min-width:68px; display:flex; flex-direction:column; }
.stats dt { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.1em; }
.stats dd { order:-1; margin:0 0 6px; font-size:35px; line-height:1; color:var(--gold); font-variant-numeric:tabular-nums; }
.toolbar { margin:26px 0 0; padding:22px; background:var(--panel); border:1px solid var(--line); border-radius:14px; }
.filter-row { display:grid; grid-template-columns:minmax(220px,2fr) repeat(3,minmax(155px,1fr)); gap:14px; }
label { display:block; color:var(--muted); font-size:12px; font-weight:600; }
input,select { display:block; width:100%; height:44px; margin-top:7px; border:1px solid #41545c; border-radius:7px; background:#111e25; color:var(--ink); padding:0 12px; }
input::placeholder { color:#859a9e; }
.view-row { display:flex; flex-wrap:wrap; align-items:center; gap:20px 30px; border-top:1px solid var(--line); padding-top:18px; margin-top:18px; }
fieldset { display:flex; gap:4px; padding:0; border:0; margin:0; min-width:0; }
legend { padding:0; margin-bottom:8px; color:var(--muted); font-size:12px; }
.choice { min-height:36px; padding:6px 12px; color:var(--muted); border:1px solid #41545c; border-radius:6px; background:transparent; }
.choice[aria-pressed="true"] { background:#294844; color:#dafff5; border-color:#71b7a9; }
.choice:hover { border-color:var(--gold); color:var(--ink); }
.view-note { font-size:12px; color:var(--muted); margin:20px 0 0; flex:1; min-width:170px; }
.results-line { display:flex; gap:16px; align-items:center; justify-content:space-between; padding:24px 0 16px; }
#result-count { color:var(--muted); margin:0; font-size:13px; }
.reset { color:var(--mint); background:none; border:0; padding:6px 0; font-size:13px; }
.gallery { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:20px; align-items:start; }
.asset-card { min-width:0; overflow:hidden; border:1px solid var(--line); border-radius:12px; background:var(--panel); }
.asset-card:hover { border-color:#637968; }
.wide-card { grid-column:span 2; }
.card-top { padding:11px 16px; display:flex; justify-content:space-between; gap:10px; align-items:center; }
code { color:#bac9c8; font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace; }
.status { font-size:10px; letter-spacing:.025em; white-space:nowrap; }
.status span { font-size:8px; margin-right:3px; }
.status.ready { color:var(--mint); }
.status.pending { color:var(--gold); }
.preview-stage { display:flex; align-items:center; justify-content:center; overflow:hidden; border-top:1px solid #ffffff09; border-bottom:1px solid #ffffff09; background-color:#26343d; }
body[data-background="checker"] .preview-stage { background-color:#33434b; background-image:linear-gradient(45deg,#27353d 25%,transparent 25%),linear-gradient(-45deg,#27353d 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#27353d 75%),linear-gradient(-45deg,transparent 75%,#27353d 75%); background-size:24px 24px; background-position:0 0,0 12px,12px -12px,-12px 0; }
body[data-background="light"] .preview-stage { background-color:#edf0e8; background-image:none; }
body[data-background="dark"] .preview-stage { background-color:#0a1118; background-image:none; }
.icon-stage { height:286px; padding:14px; }
.preview-link { display:flex; width:100%; height:100%; align-items:center; justify-content:center; }
.preview-link img { display:block; object-fit:contain; max-width:100%; }
.icon-stage img { width:var(--icon-size); height:auto; max-height:var(--icon-size); }
.media-stage { min-height:230px; padding:18px; }
.media-stage .preview-link { height:auto; }
.media-stage img { width:auto; height:auto; max-width:100%; max-height:390px; }
.card-content { padding:18px; }
.eyebrow { color:var(--gold); text-transform:uppercase; font-size:10px; letter-spacing:.09em; margin:0 0 8px; }
.eyebrow span { color:#64797b; margin:0 5px; }
h2 { font-size:20px; line-height:1.2; letter-spacing:-.025em; margin:0 0 11px; }
.description { font-size:13px; color:#b7c7c7; line-height:1.6; margin:0 0 16px; min-height:62px; }
.metadata { display:flex; flex-wrap:wrap; gap:6px; font-size:10px; color:var(--muted); }
.metadata span { border:1px solid #3c4d52; border-radius:4px; padding:3px 6px; }
details { margin-top:16px; padding-top:12px; border-top:1px solid var(--line); }
summary { font-size:12px; cursor:pointer; color:#d5dfd9; }
pre { white-space:pre-wrap; overflow-wrap:anywhere; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace; color:#b9cbc6; margin:12px 0 0; }
.card-links { display:flex; flex-wrap:wrap; gap:16px; margin-top:15px; font-size:12px; }
.card-links a { text-decoration:none; }
.card-links a:hover { text-decoration:underline; }
.prompt-pending { font-size:12px; color:var(--muted); margin:16px 0 0; }
.placeholder { display:flex; flex-direction:column; gap:6px; align-items:center; text-align:center; color:#c6b88f; max-width:220px; padding:20px; background:#152229de; border:1px dashed #6d735f; border-radius:8px; }
.placeholder-mark { font-size:35px; line-height:1.2; }
.placeholder strong { font-size:14px; font-weight:550; }
.placeholder span:last-child { font-size:11px; }
.pending-card { border-style:dashed; }
.empty { padding:64px 24px; text-align:center; border:1px dashed var(--line); border-radius:12px; color:var(--muted); }
.empty strong { display:block; color:var(--ink); font-size:21px; margin-bottom:8px; }
footer { padding:32px 0 40px; color:#8ba3a5; font-size:12px; display:flex; justify-content:space-between; gap:20px; }
footer p { margin:0; }
noscript p { color:var(--gold); border:1px solid var(--gold); border-radius:8px; padding:12px; }
[hidden] { display:none !important; }
@media (max-width:1050px) { .hero { align-items:flex-start; flex-direction:column; gap:24px; } .stats { gap:40px; } .filter-row { grid-template-columns:repeat(2,minmax(0,1fr)); } }
@media (max-width:680px) { .shell { padding:0 18px; } .hero { padding-top:32px; } .toolbar { padding:16px; } .filter-row { grid-template-columns:1fr; } .gallery { grid-template-columns:1fr; } .wide-card { grid-column:auto; } .stats { gap:24px; } .stats dd { font-size:28px; } .results-line { align-items:flex-start; } footer { flex-direction:column; } }
@media (prefers-reduced-motion:no-preference) { .asset-card,.choice { transition:border-color .15s ease,background-color .15s ease; } }
"""
JS = r"""
(() => {
const cards = [...document.querySelectorAll('.asset-card')];
const search = document.getElementById('search');
const faction = document.getElementById('faction');
const kind = document.getElementById('kind');
const state = document.getElementById('state');
const count = document.getElementById('result-count');
const normalize = value => value.toLocaleLowerCase('de').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss');
const texts = new Map(cards.map(card => [card, normalize(card.dataset.search)]));
function filter() {
const terms = normalize(search.value).trim().split(/\s+/).filter(Boolean);
let visible = 0;
let ready = 0;
for (const card of cards) {
const match = (!faction.value || card.dataset.faction === faction.value)
&& (!kind.value || card.dataset.kind === kind.value)
&& (!state.value || card.dataset.state === state.value)
&& terms.every(term => texts.get(card).includes(term));
card.hidden = !match;
if (match) { visible++; if (card.dataset.state === 'ready') ready++; }
}
count.textContent = `${visible} von ${cards.length} Assets · ${ready} vorhanden · ${visible - ready} ausstehend`;
document.getElementById('empty').hidden = visible !== 0;
}
search.addEventListener('input', filter);
for (const select of [faction, kind, state]) select.addEventListener('change', filter);
document.getElementById('reset').addEventListener('click', () => {
search.value = ''; faction.value = ''; kind.value = ''; state.value = ''; filter(); search.focus();
});
for (const button of document.querySelectorAll('[data-background-choice]')) {
button.addEventListener('click', () => {
document.body.dataset.background = button.dataset.backgroundChoice;
document.querySelectorAll('[data-background-choice]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
});
}
for (const button of document.querySelectorAll('[data-size]')) {
button.addEventListener('click', () => {
document.documentElement.style.setProperty('--icon-size', `${button.dataset.size}px`);
document.querySelectorAll('[data-size]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
});
}
filter();
})();
"""
def options(assets: list[dict], key: str, labels: dict[str, str]) -> str:
values = dict.fromkeys(asset.get(key, "unknown") for asset in assets)
return "".join(f'' for value in values)
def build() -> tuple[int, int]:
assets = read_assets()
ready = sum(asset["ready"] for asset in assets)
pending = len(assets) - ready
content = f'''
Saumwacht · Assetgalerie · Phase 3
Zur Galerie springen
{len(assets)} Assets · {ready} vorhanden · {pending} ausstehend
{''.join(card(asset) for asset in assets)}
Keine passenden Assets.Andere Suchbegriffe wählen oder die Filter zurücksetzen.
'''
OUTPUT.write_text(content, encoding="utf-8")
return len(assets), ready
if __name__ == "__main__":
total, available = build()
print(f"Galerie: {OUTPUT}\n{total} Assets: {available} vorhanden, {total - available} ausstehend.")