#!/usr/bin/env python3 """Serves gallery/ (from scripts/make-gallery.sh) as a web page on every interface, for looking at the example renders from another machine on the network. python3 scripts/serve-gallery.py [--port 8000] [--live-port 8010] Stills: each example scene gets a card with its L0 and L1 renders side by side, captioned from the comment at the top of its file in examples/scenes/. Click an image to see it full size; arrow keys step through them and Escape closes. Movie: gallery/movie.mp4 (from make-gallery.sh or resolved-movie --out) to play or download, and the live stream from `resolved-movie --serve 0.0.0.0:8010` on this machine, which the page connects to only while the tab is open. The page is built on each request, so rerunning make-gallery.sh and reloading shows the new images. Only the PNG files and movie.mp4 in gallery/ are served. Needs only the Python standard library. """ import argparse import html import socket import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote ROOT = Path(__file__).resolve().parent.parent GALLERY = ROOT / "gallery" SCENES = ROOT / "examples" / "scenes" TIERS = ["L0", "L1"] STYLE = """ :root { --bg: #0b0d12; --card: #141821; --line: #232938; --text: #e6e8ee; --muted: #9aa3b5; --accent: #e8b04a; } * { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; } header { padding: 2.5rem 1.5rem 1rem; max-width: 1400px; margin: 0 auto; } h1 { margin: 0; font-size: 1.9rem; font-weight: 650; letter-spacing: -0.01em; } h1 span { color: var(--accent); } header p { margin: 0.4rem 0 0; color: var(--muted); } main { max-width: 1400px; margin: 0 auto; padding: 1rem 1.5rem 3rem; display: grid; gap: 1.5rem; } section { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 1.25rem 1.25rem 1.1rem; } h2 { margin: 0 0 0.35rem; font-size: 1.15rem; font-weight: 600; } .caption { margin: 0 0 1rem; color: var(--muted); max-width: 75ch; } details { margin: -0.6rem 0 1rem; color: var(--muted); font-size: 0.9rem; } details summary { cursor: pointer; color: var(--accent); } details p { margin: 0.5rem 0 0; max-width: 75ch; } .pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; } figure { margin: 0; } figure img { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; display: block; background: #000; border-radius: 8px; border: 1px solid var(--line); cursor: zoom-in; transition: transform 0.15s ease, border-color 0.15s ease; } figure img:hover { border-color: var(--accent); transform: translateY(-2px); } figcaption { margin-top: 0.4rem; font-size: 0.85rem; color: var(--muted); } figcaption b { color: var(--text); font-weight: 600; } .missing { aspect-ratio: 16 / 9; display: grid; place-items: center; border-radius: 8px; border: 1px dashed var(--line); color: var(--muted); font-size: 0.9rem; } #view { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.93); display: none; align-items: center; justify-content: center; flex-direction: column; gap: 0.75rem; cursor: zoom-out; padding: 1rem; } #view.open { display: flex; } #view img { max-width: 100%; max-height: calc(100vh - 4rem); } #view div { color: var(--muted); font-size: 0.9rem; } [hidden] { display: none !important; } nav { max-width: 1400px; margin: 0 auto; padding: 0 1.5rem; display: flex; gap: 0.5rem; } nav a { color: var(--muted); text-decoration: none; padding: 0.45rem 1rem; border-radius: 999px; border: 1px solid var(--line); } nav a.on { color: var(--bg); background: var(--accent); border-color: var(--accent); } .movie video, .movie .live { width: 100%; aspect-ratio: 16 / 9; display: block; background: #000; border-radius: 8px; border: 1px solid var(--line); } .movie .live { object-fit: contain; } .movie a.download { color: var(--accent); } .movie code { color: var(--text); } """ SCRIPT = """ const live = document.getElementById('live'); function tab() { const movie = location.hash === '#movie'; // The panels' ids differ from the links' anchors, so a link switches the tab // instead of scrolling the page to it. document.getElementById('stills-tab').hidden = movie; document.getElementById('movie-tab').hidden = !movie; document.querySelectorAll('nav a').forEach( (a) => a.classList.toggle('on', (a.hash === '#movie') === movie)); // The live stream renders only while someone watches: connect only on this tab. if (live) live.src = movie ? live.dataset.src : ''; } window.addEventListener('hashchange', tab); tab(); const images = [...document.querySelectorAll('figure img')]; const view = document.getElementById('view'); const big = view.querySelector('img'); const label = view.querySelector('div'); let current = -1; function show(i) { current = (i + images.length) % images.length; big.src = images[current].src; label.textContent = images[current].alt; view.classList.add('open'); } images.forEach((img, i) => img.addEventListener('click', () => show(i))); view.addEventListener('click', () => view.classList.remove('open')); document.addEventListener('keydown', (e) => { if (!view.classList.contains('open')) return; if (e.key === 'Escape') view.classList.remove('open'); if (e.key === 'ArrowRight') show(current + 1); if (e.key === 'ArrowLeft') show(current - 1); }); """ def caption(scene): """The paragraphs of the comment at the top of `scene`, leaving out the example commands.""" paragraphs, lines = [], [] for line in scene.read_text().splitlines(): if not line.startswith("#"): break text = line[1:] if text.strip().startswith("resolved render"): continue if text.strip(): lines.append(text.strip()) elif lines: paragraphs.append(" ".join(lines)) lines = [] if lines: paragraphs.append(" ".join(lines)) return paragraphs def movie_section(host, live_port): movie = GALLERY / "movie.mp4" if movie.is_file(): stamp = int(movie.stat().st_mtime) size = movie.stat().st_size / 1e6 recorded = ( f'' f'

Download the MP4 ({size:.1f} MB)

' ) else: recorded = ( '

No movie yet: run scripts/make-gallery.sh, or ' "cargo run --release -p resolved-showcase -- --out gallery/movie.mp4" ".

" ) stream = f"http://{host}:{live_port}/stream" return f"""

The movie

An inspector holds station 120 m behind and above a Landsat 8 model 705 km up; the target is propagated by astrokit with J2 and everything is rendered at lighting L1. The camera drifts on a small natural relative orbit around its station and sets its exposure frame by frame. Time runs fast over the daylit Earth, slows as the Moon rises over the limb ahead, and slows almost to real time as the spacecraft passes into Earth's shadow.

{recorded}

Live

Rendered in real time on this machine's GPU and streamed as it is made. Start it with cargo run --release -p resolved-showcase -- --serve 0.0.0.0:{live_port}; it renders only while this tab is open.

live stream (not running)
""" def page(host, live_port): names = sorted({p.stem.rsplit("-", 1)[0] for p in GALLERY.glob("*.png")}) cards = [] count = 0 for name in names: scene = SCENES / f"{name}.toml" paragraphs = caption(scene) if scene.is_file() else [] figures = [] for tier in TIERS: image = GALLERY / f"{name}-{tier}.png" if image.is_file(): count += 1 stamp = int(image.stat().st_mtime) alt = f"{name}, lighting {tier}" figures.append( f'
' f"
{tier} lighting
" ) else: figures.append( f'
no {tier} render
' f"
{tier} lighting
" ) first = html.escape(paragraphs[0]) if paragraphs else "" more = "" if len(paragraphs) > 1: body = "".join(f"

{html.escape(p)}

" for p in paragraphs[1:]) more = f"
More{body}
" cards.append( f"

{html.escape(name)}

" f'

{first}

{more}' f'
{"".join(figures)}
' ) if not cards: cards.append( "

No images yet

" "Run scripts/make-gallery.sh, then reload.

" ) newest = max((p.stat().st_mtime for p in GALLERY.glob("*.png")), default=None) when = time.strftime("%Y-%m-%d %H:%M", time.localtime(newest)) if newest else "never" return f""" resolved gallery

resolved gallery

{count} renders of {len(names)} example scenes, last rendered {when}. Click an image to see it full size.

{"".join(cards)}
{movie_section(host, live_port)}
""" class Handler(BaseHTTPRequestHandler): def do_GET(self): path = unquote(self.path.split("?", 1)[0]) if path in ("/", "/index.html"): host = (self.headers.get("Host") or "localhost").rsplit(":", 1)[0] body = page(host, self.server.live_port).encode() self.send(200, "text/html; charset=utf-8", body) elif path == "/movie.mp4" and (GALLERY / "movie.mp4").is_file(): self.send_movie(GALLERY / "movie.mp4") elif path.startswith("/img/"): name = path[len("/img/"):] # Only a PNG directly in gallery/, by its exact name. image = GALLERY / name if "/" in name or not name.endswith(".png") or not image.is_file(): self.send(404, "text/plain", b"not found") else: self.send(200, "image/png", image.read_bytes()) else: self.send(404, "text/plain", b"not found") def send_movie(self, movie): """The movie, or the byte range asked for: browsers seek in a video by range.""" data = movie.read_bytes() wanted = self.headers.get("Range", "") if wanted.startswith("bytes="): first, _, last = wanted[len("bytes="):].partition("-") start = int(first) if first else max(len(data) - int(last), 0) end = int(last) if first and last else len(data) - 1 end = min(end, len(data) - 1) part = data[start:end + 1] self.send_response(206) self.send_header("Content-Range", f"bytes {start}-{end}/{len(data)}") else: part = data self.send_response(200) self.send_header("Content-Type", "video/mp4") self.send_header("Accept-Ranges", "bytes") self.send_header("Content-Length", str(len(part))) self.end_headers() self.wfile.write(part) def send(self, status, kind, body): self.send_response(status) self.send_header("Content-Type", kind) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-cache") self.end_headers() self.wfile.write(body) def log_message(self, format, *args): pass def lan_address(): """This machine's address on the network: the source of a route outward. A UDP connect sends nothing.""" with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: try: s.connect(("10.255.255.255", 1)) return s.getsockname()[0] except OSError: return "127.0.0.1" def main(): parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) parser.add_argument("--port", type=int, default=8000) parser.add_argument("--live-port", type=int, default=8010, help="the port resolved-movie --serve streams on") args = parser.parse_args() server = ThreadingHTTPServer(("0.0.0.0", args.port), Handler) server.live_port = args.live_port print(f"serving {GALLERY} at http://{lan_address()}:{args.port}/ (Ctrl+C to stop)", flush=True) try: server.serve_forever() except KeyboardInterrupt: pass if __name__ == "__main__": main()