325 lines
13 KiB
Python
Executable File
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()
|