Add hosted landing page

This commit is contained in:
Meta Vens 2026-04-26 23:40:43 +01:00
commit 20c610edee
5 changed files with 315 additions and 1 deletions

View file

@ -38,7 +38,7 @@ Do not trust this file for the exact live HEAD. Always check git.
- The old tray optimizer is dead and should stay dead unless the entire machine map changes for a real reason.
- `E:\Projects\Heimdall\docs\architecture.md` is the future shared design for reusable GameCult-hosted access across experiments.
- `E:\Projects\Heimdall\docs\app-profiles\repixelizer.md` is the Repixelizer-specific binding onto that future shared access layer.
- current hosted-demo truth is still just the queue and limit machinery in `src/repixelizer/gui.py` plus the frontend runtime-config plumbing.
- current hosted-demo truth lives in `src/repixelizer/gui.py`: queue and limit machinery, hosted direct-autocorr inference defaults, and a hosted-only landing page at `/` while self-host/local runs still redirect root straight to `/app/`.
- product strategy is hosted convenience first: keep the code open, let self-hosters suffer voluntarily, and do not burn time on native desktop packaging unless it clearly beats hosting on revenue, support, or strategy.
## Critical Doctrine

View file

@ -861,6 +861,285 @@ def _versioned_gui_index(static_dir: Path) -> str:
return html
def _landing_page_html(config: HostedDemoConfig) -> str:
demo_limits = (
f"{config.max_output_dimension}px output cap, "
f"{config.default_steps} default solver steps, "
f"{config.queue_capacity} waiting slots"
)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Repixelizer</title>
<style>
@import url("https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap");
:root {{
color-scheme: dark;
--bg-top: #01040b;
--bg-mid: #020813;
--bg-bottom: #01050d;
--panel-top: #0a2239;
--panel-bottom: #061627;
--surface-top: #061424;
--surface-bottom: #030b16;
--text-bright: #f7efd8;
--text: #efe5c9;
--text-muted: #d3bb7f;
--accent: #ffd84a;
--accent-strong: #ffae1a;
--border: #0a2740;
--shadow: rgba(0, 0, 0, 0.55);
}}
* {{ box-sizing: border-box; }}
html, body {{ min-height: 100%; }}
body {{
margin: 0;
background:
repeating-linear-gradient(0deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 3px),
repeating-linear-gradient(90deg, rgba(255,255,255,0.035) 0 1px, transparent 1px 4px),
linear-gradient(180deg, var(--bg-top) 0%, var(--bg-mid) 46%, var(--bg-bottom) 100%);
color: var(--text);
font-family: "VT323", "Courier New", monospace;
font-size: 26px;
line-height: 1.22;
letter-spacing: 0.01em;
}}
body::before {{
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at 14% 18%, rgba(255, 216, 74, 0.08), transparent 26%),
radial-gradient(circle at 84% 20%, rgba(255, 174, 26, 0.11), transparent 24%),
radial-gradient(circle at 24% 76%, rgba(255, 216, 74, 0.04), transparent 22%),
radial-gradient(circle at 78% 82%, rgba(255, 174, 26, 0.04), transparent 24%);
opacity: 0.35;
}}
a {{ color: inherit; }}
.shell {{
width: min(1160px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 48px;
}}
.hero {{
position: relative;
overflow: hidden;
border: 3px solid rgba(158, 112, 22, 0.94);
background:
linear-gradient(180deg, rgba(9, 28, 46, 0.96), rgba(4, 13, 24, 0.98)),
linear-gradient(135deg, rgba(255, 216, 74, 0.06), transparent 34%);
box-shadow: inset 0 0 0 2px rgba(7, 24, 44, 0.9), 0 18px 52px var(--shadow);
padding: 24px;
}}
.hero::after {{
content: "";
position: absolute;
inset: 10px;
border: 1px solid rgba(255, 216, 74, 0.1);
pointer-events: none;
}}
.eyebrow, h1, h2, .button {{
font-family: "Press Start 2P", "VT323", monospace;
}}
.eyebrow {{
margin: 0 0 18px;
color: var(--accent);
font-size: 0.7rem;
line-height: 1.6;
text-transform: uppercase;
}}
h1 {{
margin: 0;
color: var(--text-bright);
font-size: clamp(1.7rem, 4vw, 3.15rem);
line-height: 1.15;
max-width: 13ch;
}}
.lede {{
max-width: 26ch;
margin: 20px 0 0;
color: var(--text);
font-size: clamp(1.1rem, 2vw, 1.45rem);
}}
.hero-grid {{
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.95fr);
gap: 20px;
align-items: end;
}}
.cta-row {{
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 24px;
}}
.button {{
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 54px;
padding: 0 18px;
text-decoration: none;
text-transform: uppercase;
font-size: 0.72rem;
line-height: 1.45;
border: 2px solid var(--accent-strong);
background: linear-gradient(180deg, #ffd84a, #ffb01f);
color: #1b1202;
box-shadow: inset 0 -2px 0 rgba(0,0,0,0.24);
}}
.button.secondary {{
background: linear-gradient(180deg, rgba(15, 40, 64, 0.96), rgba(7, 19, 32, 0.98));
color: var(--text-bright);
border-color: var(--border);
}}
.stats, .grid {{
display: grid;
gap: 14px;
}}
.stats {{
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 22px;
}}
.stat, .panel {{
border: 2px solid var(--border);
background: linear-gradient(180deg, var(--panel-top), var(--panel-bottom));
box-shadow: inset 0 0 0 1px rgba(255, 216, 74, 0.05);
}}
.stat {{
padding: 14px;
}}
.stat strong {{
display: block;
color: var(--text-bright);
font-size: 1.05rem;
}}
.stat span {{
display: block;
color: var(--text-muted);
margin-top: 8px;
}}
.hero-note {{
padding: 16px 18px;
}}
.hero-note p {{
margin: 0 0 12px;
}}
.hero-note p:last-child {{
margin-bottom: 0;
}}
.stack {{
margin-top: 20px;
}}
.stack h2 {{
margin: 0 0 14px;
color: var(--text-bright);
font-size: 1rem;
line-height: 1.5;
}}
.grid {{
grid-template-columns: repeat(3, minmax(0, 1fr));
}}
.panel {{
padding: 16px;
}}
.panel p {{
margin: 0;
}}
.panel strong {{
display: block;
margin-bottom: 10px;
color: var(--accent);
font-family: "Press Start 2P", "VT323", monospace;
font-size: 0.62rem;
line-height: 1.5;
text-transform: uppercase;
}}
.footer {{
margin-top: 18px;
color: var(--text-muted);
}}
@media (max-width: 900px) {{
.hero-grid, .grid, .stats {{
grid-template-columns: 1fr;
}}
body {{
font-size: 23px;
}}
.shell {{
width: min(100% - 18px, 1160px);
padding-top: 12px;
padding-bottom: 24px;
}}
.hero {{
padding: 18px;
}}
}}
</style>
</head>
<body>
<main class="shell">
<section class="hero">
<div class="hero-grid">
<div>
<p class="eyebrow">Repixelizer hosted demo</p>
<h1>Force fake pixel art back onto a real grid.</h1>
<p class="lede">
Repixelizer takes AI-slop pixel cosplay, infers the lattice it should have had, then drags it back onto an actual coherent mosaic.
</p>
<div class="cta-row">
<a class="button" href="/app/">Open the demo</a>
<a class="button secondary" href="https://github.com/GameCult/repixelizer">View the repo</a>
</div>
<div class="stats">
<div class="stat">
<strong>One engine</strong>
<span>Phase-field reconstruction. No pipeline graveyard.</span>
</div>
<div class="stat">
<strong>Hosted limits</strong>
<span>{demo_limits}</span>
</div>
<div class="stat">
<strong>Lean inference</strong>
<span>Autocorr lattice, cheap phase probe, then the real solve.</span>
</div>
</div>
</div>
<aside class="panel hero-note">
<h2>What it is for</h2>
<p>Bad AI badge art. Mushy fake sprites. Grid-shaped lies with enough latent structure left to salvage.</p>
<p>It is not a miracle worker, and it is definitely not here to pretend it invented hand-authored pixel art from nothing.</p>
</aside>
</div>
</section>
<section class="stack">
<h2>How the machine behaves</h2>
<div class="grid">
<article class="panel">
<strong>1. Read the grid</strong>
<p>Infer one plausible lattice from autocorrelation instead of wandering through a candidate carnival all day.</p>
</article>
<article class="panel">
<strong>2. Find the phase</strong>
<p>Probe a tiny phase grid up front so the solver does not begin life already standing in a pothole.</p>
</article>
<article class="panel">
<strong>3. Let the field work</strong>
<p>Run one displacement field with enough leash to recover detail instead of compensating with decorative nonsense.</p>
</article>
</div>
<p class="footer">Self-hosters can still go straight to <a href="/app/">the app</a>. Hosted mode just gets a front door instead of making everyone trip over raw controls immediately.</p>
</section>
</main>
</body>
</html>
"""
def create_app():
deps = _require_gui_dependencies()
FastAPI = deps["FastAPI"]
@ -1026,6 +1305,8 @@ def create_app():
@app.get("/")
def root():
if config.hosted_demo:
return HTMLResponse(_landing_page_html(config))
return RedirectResponse(url="/app/")
else: # pragma: no cover - only relevant before frontend assets exist
@app.get("/")

View file

@ -11,3 +11,4 @@
{"ts": "2026-04-26T17:46:30+00:00", "type": "research", "status": "ok", "note": "Removed pixel-walk / change-interval sizing from src/repixelizer/inference.py and switched searched lattice inference to autocorrelation-only priors and candidate hints. Badge auto inference no longer gets pruned into the old 115-family by spacing-spectrum guidance; it now searches inside the 120-126 neighborhood while dense landscape still lands at 418x418."}
{"ts": "2026-04-26T17:58:43+00:00", "type": "research", "status": "ok", "note": "Autocorr inference now keeps a tiny near-best lag plateau and searches for cross-axis consensus before collapsing to one lattice size. On the real badge fixture this preserves x-axis support for 10px alongside 11px, raises the shared prior to the 125-family, and restores auto inference to 125 with top candidates 125/126/124/127/123 while dense landscape remains 418x418."}
{"ts": "2026-04-26T18:19:52+00:00", "type": "design", "status": "accepted", "note": "Hosted demo jobs now use direct autocorr lattice inference with one cheap phase probe, capped by the hosted output limit. The hosted path no longer silently pins unspecified jobs to max_output_dimension, and phase rerank is forced off there while the full local workflow keeps searched autocorr inference available."}
{"ts": "2026-04-26T22:40:02+00:00", "type": "design", "status": "accepted", "note": "Hosted mode now owns a distinct public front door in src/repixelizer/gui.py: / serves a landing page there, while local and self-host runs still redirect root straight to /app/. This keeps future auth and member-gating surgery in the hosted web layer instead of smearing it into the self-deploy path or the solver stack."}

View file

@ -66,6 +66,7 @@ accepted_design:
- `src/repixelizer/analysis.py` provides the normalized edge scout used for loss and cleanup guidance.
- `src/repixelizer/phase_field.py` owns the one honest optimization variable: `disp_t`.
- Hosted demo jobs now use a direct autocorr lattice path: one consensus size, one cheap phase probe, capped by the hosted output limit, with phase rerank disabled.
- Hosted mode owns the public front door in `src/repixelizer/gui.py`: `/` is a landing page there, while local/self-host runs keep root as a plain redirect to `/app/`.
- The optimizer starts from fixed lattice centers, uses a wider displacement budget than the earlier tighter leash, optimizes displacement with projected Adam, then rounds to real source pixels for final output.
- Cleanup is local and secondary; it does not define the core reconstruction model.
- `source_fidelity` is useful but incomplete; `source_structure` exists because visible structure can improve while lattice-only scoring complains.

View file

@ -306,6 +306,37 @@ def test_gui_static_assets_disable_browser_caching() -> None:
assert "./app.js?v=" in html_text
def test_gui_hosted_root_serves_landing_page(monkeypatch, tmp_path: Path) -> None:
from repixelizer.gui import create_app
monkeypatch.setenv("REPIXELIZER_HOSTED_DEMO", "1")
monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool"))
app = create_app()
status, headers, body = asyncio.run(_get_response(app, "/"))
html = body.decode("utf-8")
assert status == 200
assert headers["cache-control"] == "no-store, no-cache, must-revalidate, max-age=0"
assert "Repixelizer hosted demo" in html
assert "Force fake pixel art back onto a real grid." in html
assert 'href="/app/"' in html
def test_gui_local_root_keeps_redirect_to_app(monkeypatch, tmp_path: Path) -> None:
from repixelizer.gui import create_app
monkeypatch.delenv("REPIXELIZER_HOSTED_DEMO", raising=False)
monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool"))
app = create_app()
status, headers, _body = asyncio.run(_get_response(app, "/"))
assert status in {302, 307}
assert headers["location"] == "/app/"
assert headers["cache-control"] == "no-store, no-cache, must-revalidate, max-age=0"
def test_infer_lattice_emits_search_progress_events(monkeypatch) -> None:
class _FakeCuda:
@staticmethod