Got the movie working in the gallery

This commit is contained in:
connor committed 2026-10-15 13:35:00 -04:00
1 parent 8861858f0d
commit 782820261e
15 files changed
+1291 -111

No files matched your search

+105 -9
View File
@@ -2,13 +2,18 @@
"""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]
python3 scripts/serve-gallery.py [--port 8000] [--live-port 8010]
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. The page is built on each
request, so rerunning make-gallery.sh and reloading shows the new images. Only the
PNG files in gallery/ are served.
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.
"""
@@ -75,9 +80,37 @@ figcaption b { color: var(--text); font-weight: 600; }
#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');
@@ -120,7 +153,40 @@ def caption(scene):
return paragraphs
def page():
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
@@ -168,7 +234,9 @@ def page():
<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>
<main>{"".join(cards)}</main>
<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>"""
@@ -177,7 +245,11 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self):
path = unquote(self.path.split("?", 1)[0])
if path in ("/", "/index.html"):
self.send(200, "text/html; charset=utf-8", page().encode())
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.
@@ -189,6 +261,27 @@ class Handler(BaseHTTPRequestHandler):
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)
@@ -215,8 +308,11 @@ def lan_address():
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()