Files
resolved/scripts/serve-gallery.py
T

325 lines
13 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 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'<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. 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 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'<figure><img src="/img/{name}-{tier}.png?{stamp}" '
f'alt="{html.escape(alt)}" loading="lazy">'
f"<figcaption><b>{tier}</b> lighting</figcaption></figure>"
)
else:
figures.append(
f'<figure><div class="missing">no {tier} render</div>'
f"<figcaption><b>{tier}</b> lighting</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></nav>
<main id="stills-tab">{"".join(cards)}</main>
<main id="movie-tab" hidden>{movie_section(host, live_port)}</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("/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()