Files
resolved/scripts/serve-gallery.py
T
2026-10-18 15:21:05 -04:00

433 lines
18 KiB
Python
Executable File

#!/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, L1 and L2 renders side by side,
all with camera C1, 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.
Delta: the comparison scripts/gallery-delta.py writes to gallery/delta/, the example
scenes before and after a change. Scenes that changed come first, each with its before,
after and difference images; in the full-size view, Space flips between before and
after.
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 json
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"
DELTA = GALLERY / "delta"
SCENES = ROOT / "examples" / "scenes"
TIERS = ["L0", "L1", "L2"]
# The camera tier make-gallery.sh renders with.
CAMERA = "C1"
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); }
.trio { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; }
.stats { margin: 0 0 0.9rem; color: var(--muted); font-size: 0.9rem; }
.stats b { color: var(--text); font-weight: 600; }
.flag { color: var(--accent); }
.same { color: var(--muted); }
.same li { margin: 0.15rem 0; }
.movie code { color: var(--text); }
"""
SCRIPT = """
const live = document.getElementById('live');
const TABS = ['stills', 'movie', 'delta'];
function tab() {
const name = TABS.includes(location.hash.slice(1)) ? location.hash.slice(1) : 'stills';
// The panels' ids differ from the links' anchors, so a link switches the tab
// instead of scrolling the page to it.
TABS.forEach((t) => { document.getElementById(t + '-tab').hidden = t !== name; });
document.querySelectorAll('nav a').forEach(
(a) => a.classList.toggle('on', a.hash === '#' + name));
// The live stream renders only while someone watches: connect only on this tab.
if (live) live.src = name === 'movie' ? live.dataset.src : '';
}
window.addEventListener('hashchange', tab);
tab();
const view = document.getElementById('view');
const big = view.querySelector('img');
const label = view.querySelector('div');
let images = [];
let current = -1;
let flipped = false;
function show(i) {
current = (i + images.length) % images.length;
flipped = false;
big.src = images[current].src;
label.textContent = images[current].alt;
view.classList.add('open');
}
// Delta images carry their pair: Space shows the other one of before and after.
function flip() {
const img = images[current];
if (!img.dataset.other) return;
flipped = !flipped;
big.src = flipped ? img.dataset.other : img.src;
label.textContent = flipped ? img.dataset.otherAlt : img.alt;
}
document.querySelectorAll('figure img').forEach((img) => img.addEventListener('click', () => {
// Step through the images of the tab the clicked one is on.
images = [...img.closest('main').querySelectorAll('figure img')];
show(images.indexOf(img));
}));
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);
if (e.key === ' ') { e.preventDefault(); flip(); }
});
"""
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'<video src="/movie.mp4?{stamp}" controls loop playsinline></video>'
f'<p class="caption"><a class="download" href="/movie.mp4?{stamp}" '
f'download="resolved-movie.mp4">Download the MP4</a> ({size:.1f} MB)</p>'
)
else:
recorded = (
'<p class="caption">No movie yet: run scripts/make-gallery.sh, or '
"<code>cargo run --release -p resolved-showcase -- --out gallery/movie.mp4</code>"
".</p>"
)
stream = f"http://{host}:{live_port}/stream"
return f"""<section class="movie"><h2>The movie</h2>
<p class="caption">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 and camera C1. 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.</p>{recorded}</section>
<section class="movie"><h2>Live</h2>
<p class="caption">Rendered in real time on this machine's GPU and streamed as it is
made. Start it with
<code>cargo run --release -p resolved-showcase -- --serve 0.0.0.0:{live_port}</code>;
it renders only while this tab is open.</p>
<img id="live" class="live" data-src="{html.escape(stream)}" alt="live stream (not running)">
</section>"""
def delta_section():
summary = DELTA / "summary.json"
if not summary.is_file():
return (
'<section><h2>No comparison yet</h2><p class="caption">Run '
"<code>python3 scripts/gallery-delta.py</code>, then reload.</p></section>"
)
data = json.loads(summary.read_text())
stamp = int(summary.stat().st_mtime)
noise = "with the detector's noise" if data["noise"] else "noiseless"
head = (
f'<section><h2>{html.escape(data["base"])} → {html.escape(data["head"])}</h2>'
f'<p class="caption">The example scenes at {", ".join(data["tiers"])}, {noise}, '
f'compared {html.escape(data["made"])}. In a difference image the after image is '
"dimmed to gray and every changed pixel is colored, on a log scale of how much: "
"orange brighter, blue darker. Click an image for full size; Space then flips "
"between before and after.</p></section>"
)
def figure(file, alt, caption, other=None, other_alt=None):
pair = ""
if other:
pair = (f' data-other="/delta/{other}?{stamp}" '
f'data-other-alt="{html.escape(other_alt)}"')
return (
f'<figure><img src="/delta/{file}?{stamp}" alt="{html.escape(alt)}"{pair} '
f'loading="lazy"><figcaption>{caption}</figcaption></figure>'
)
changed, same, other = [], [], []
for item in data["items"]:
name = f'{item["scene"]} {item["tier"]}'
note = ' <span class="flag">(scene file changed)</span>' if item["scene_changed"] else ""
if item["status"] == "same":
same.append(f"<li>{html.escape(name)}{note}</li>")
continue
if item["status"] == "changed":
stats = (
f'<b>{item["pixels"]:,} pixels</b> changed ({100 * item["changed"]:.3g} %: '
f'{100 * item["brighter"]:.3g} % brighter, {100 * item["darker"]:.3g} % '
f'darker), up to <b>{item["max_dn"]} DN</b>, '
f'mean {item["mean_dn"]:.3g} DN over the image'
)
else:
errors = [item.get(k) for k in ("before_error", "after_error") if item.get(k)]
stats = f'<b>{item["status"]}</b> ' + html.escape("; ".join(errors))
figures = []
if "before" in item:
figures.append(figure(item["before"], f"{name}, before", "<b>before</b>",
item.get("after"), f"{name}, after"))
if "after" in item:
figures.append(figure(item["after"], f"{name}, after", "<b>after</b>",
item.get("before"), f"{name}, before"))
if "diff" in item:
figures.append(figure(item["diff"], f"{name}, difference", "<b>difference</b>"))
card = (
f"<section><h2>{html.escape(name)}{note}</h2>"
f'<p class="stats">{stats}</p><div class="trio">{"".join(figures)}</div></section>'
)
(changed if item["status"] == "changed" else other).append((item.get("changed", 0), card))
changed.sort(key=lambda c: -c[0])
cards = [c for _, c in changed] + [c for _, c in other]
if same:
cards.append(
f"<section><h2>Unchanged ({len(same)})</h2>"
f'<ul class="same">{"".join(same)}</ul></section>'
)
return head + "".join(cards)
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}, camera {CAMERA}"
figures.append(
f'<figure><img src="/img/{name}-{tier}.png?{stamp}" '
f'alt="{html.escape(alt)}" loading="lazy">'
f"<figcaption><b>{tier}</b> lighting, camera {CAMERA}</figcaption></figure>"
)
else:
figures.append(
f'<figure><div class="missing">no {tier} render</div>'
f"<figcaption><b>{tier}</b> lighting, camera {CAMERA}</figcaption></figure>"
)
first = html.escape(paragraphs[0]) if paragraphs else ""
more = ""
if len(paragraphs) > 1:
body = "".join(f"<p>{html.escape(p)}</p>" for p in paragraphs[1:])
more = f"<details><summary>More</summary>{body}</details>"
cards.append(
f"<section><h2>{html.escape(name)}</h2>"
f'<p class="caption">{first}</p>{more}'
f'<div class="pair">{"".join(figures)}</div></section>'
)
if not cards:
cards.append(
"<section><h2>No images yet</h2><p class=\"caption\">"
"Run scripts/make-gallery.sh, then reload.</p></section>"
)
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"""<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>resolved gallery</title><style>{STYLE}</style></head>
<body><header><h1><span>resolved</span> gallery</h1>
<p>{count} renders of {len(names)} example scenes, last rendered {when}.
Click an image to see it full size.</p></header>
<nav><a href="#stills">Stills</a><a href="#movie">Movie</a><a href="#delta">Delta</a></nav>
<main id="stills-tab">{"".join(cards)}</main>
<main id="movie-tab" hidden>{movie_section(host, live_port)}</main>
<main id="delta-tab" hidden>{delta_section()}</main>
<div id="view"><img alt=""><div></div></div>
<script>{SCRIPT}</script></body></html>"""
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("/delta/"):
name = path[len("/delta/"):]
image = DELTA / 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())
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()