#!/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''' {esc(name)} ''' 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)}

{prompt_html}
''' 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

Saumwacht / Werkstatt / Phase 03

Die Welt nimmt Form an.

Einheiten, Ressourcen und Oberflächen in einer gemeinsamen Bildsprache. Native PNG-Originale vergleichen, Details prüfen und Prompts nachlesen.

Geplant
{len(assets)}
Vorhanden
{ready}
Ausstehend
{pending}
Hintergrund zur Alpha-Prüfung
Icongröße

Karten und UI behalten ihr Seitenverhältnis. Ein Klick aufs Bild öffnet das Original.

{len(assets)} Assets · {ready} vorhanden · {pending} ausstehend

''' 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.")