Added the delta-tooling

This commit is contained in:
connor committed 2026-10-17 20:14:08 -04:00
1 parent cc70cea7f6
commit a3f62301f4
5 files changed
+470 -10

No files matched your search

+8 -1
View File
@@ -75,6 +75,10 @@ struct RenderArgs {
/// give the same image. /// give the same image.
#[arg(long, default_value_t = 0)] #[arg(long, default_value_t = 0)]
frame: u64, frame: u64,
/// From camera C1: leave out the detector's noise, dark current and fixed pattern
/// (saturation and blooming stay), so renders differ only where their light does.
#[arg(long)]
noiseless: bool,
/// From camera C2: how far in pixels the PSF must reach from a point (default 16). /// From camera C2: how far in pixels the PSF must reach from a point (default 16).
/// The FFT grid grows to hold it, and the PSF then reaches as far as the grid /// The FFT grid grows to hold it, and the PSF then reaches as far as the grid
/// allows: 64 px at 1920 x 1080 and 2 x 2 supersampling, up to 1088 px at 1 x 1. /// allows: 64 px at 1920 x 1080 and 2 x 2 supersampling, up to 1088 px at 1 x 1.
@@ -169,7 +173,10 @@ fn render(args: &RenderArgs) -> Result<(), String> {
let path = args.scene.as_path(); let path = args.scene.as_path();
let file = SceneFile::read(path)?; let file = SceneFile::read(path)?;
let dir = path.parent().unwrap_or(Path::new(".")); let dir = path.parent().unwrap_or(Path::new("."));
let camera = file.camera(dir)?; let mut camera = file.camera(dir)?;
if args.noiseless {
camera.detector = camera.detector.noiseless();
}
// What the tiers need, gathered before the renderer exists so it can check them. // What the tiers need, gathered before the renderer exists so it can check them.
let start = Instant::now(); let start = Instant::now();
+35
View File
@@ -120,6 +120,41 @@ fn example_scene_on_gpu_and_cpu() {
); );
} }
#[test]
fn noiseless_renders_do_not_depend_on_the_seed() {
let render = |seed: &str, noiseless: bool| {
let out = out_dir().join(format!("noiseless-{seed}-{noiseless}.png"));
let mut args = vec![
"render",
"--scene",
"examples/scenes/boxwing.toml",
"--adapter",
"gpu",
"--camera",
"C1",
"--seed",
seed,
"--out",
out.to_str().unwrap(),
];
if noiseless {
args.push("--noiseless");
}
let result = resolved(&args);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
image::open(&out).unwrap().into_rgb16()
};
assert_ne!(render("1", false), render("2", false));
let quiet = render("1", true);
assert_eq!(quiet, render("2", true));
// Empty space reads exactly the bias, with no dark current or read noise.
assert_eq!(quiet.get_pixel(0, 0).0, [64 << 4; 3]);
}
#[test] #[test]
fn pretty_previews_lift_the_darks_and_leave_space_black() { fn pretty_previews_lift_the_darks_and_leave_space_black() {
let preview = |pretty: bool| { let preview = |pretty: bool| {
+13 -1
View File
@@ -150,6 +150,16 @@ camera C1, into `gallery/` with `--pretty`, skipping any whose model or data
is missing, and `scripts/serve-gallery.py` serves the result as a web page on is missing, and `scripts/serve-gallery.py` serves the result as a web page on
port 8000 of every interface, each scene captioned from its file. port 8000 of every interface, each scene captioned from its file.
`scripts/gallery-delta.py` shows what a change did to the images: it renders
the example scenes at two revisions, noiseless, and the gallery's Delta tab
shows each changed image before, after, and with its changed pixels colored
(orange brighter, blue darker), with the change in DN. With no arguments it
compares HEAD with uncommitted changes, or HEAD~1 with HEAD when there are
none; `--base` and `--head` pick others. Each commit is built in a git worktree
under `target/delta/` and its renders are kept, so comparing the next commit
reuses them. About 40 s on the RTX 3060 once built; it needs numpy, Pillow and
ImageMagick.
`crates/resolved-showcase` makes a 30-second movie at lighting L1 and camera `crates/resolved-showcase` makes a 30-second movie at lighting L1 and camera
C1: an inspector holding station behind a Landsat 8 model 705 km up, which C1: an inspector holding station behind a Landsat 8 model 705 km up, which
astrokit propagates with J2. Time runs fast over the daylit Earth, slows as astrokit propagates with J2. Time runs fast over the daylit Earth, slows as
@@ -189,7 +199,9 @@ cargo run --release -p resolved-showcase -- --serve 0.0.0.0:8010
2 x 2). A tier the scene or the data cannot support stops with an error naming 2 x 2). A tier the scene or the data cannot support stops with an error naming
what is missing. what is missing.
- `--seed N` and `--frame N` pick the detector noise at camera C1 (default 0 - `--seed N` and `--frame N` pick the detector noise at camera C1 (default 0
and 0): the same seed and frame give the same image. and 0): the same seed and frame give the same image. `--noiseless` leaves out
the noise, dark current and fixed pattern, so two renders differ only where
their light does.
- `--data DIR` says where the tiers' data is (`earth/bmng-2004-MM.jpg`, - `--data DIR` says where the tiers' data is (`earth/bmng-2004-MM.jpg`,
`moon/wac-hapke-643.png` and `stars/stars.bin`). `moon/wac-hapke-643.png` and `stars/stars.bin`).
Without it the CLI tries `RESOLVED_DATA`, then `./data`, then the Without it the CLI tries `RESOLVED_DATA`, then `./data`, then the
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""Renders the example scenes at two revisions and compares them, for
scripts/serve-gallery.py's Delta tab: what a change did to the images.
python3 scripts/gallery-delta.py [--base REV] [--head REV] [--noise] [--scenes a,b]
With no revisions: with uncommitted changes, HEAD against the working tree; with none,
HEAD~1 against HEAD. A revision is a commit (anything git rev-parse takes) or
`worktree`, the working tree as it is.
Each revision renders every example scene in its own examples/scenes/ at the gallery's
tiers (lighting L0 and L1, camera C1), the pretty preview for looking at and the raw DN
for comparing. Renders are noiseless (`resolved render --noiseless`), so an unchanged
scene comes out identical and a changed one differs only where its light does; --noise
keeps the detector's noise, which then also differs wherever the light does. A commit
is built in its own git worktree under target/delta/, with data/ and the fetched models
linked in, and its renders are kept there by commit, so comparing the next commit
reuses them. The working tree is built and rendered fresh each time.
The comparison goes to gallery/delta/: before, after and difference images, and
summary.json with the change in DN per image. The difference image is the after image
dimmed to gray, with every pixel that changed colored by how much on a log scale:
orange where it got brighter, blue where it got darker.
Needs numpy, Pillow and ImageMagick (`magick`, to read 16-bit PNGs, which Pillow reads
as 8-bit). The first build of a commit takes a few minutes; later ones are incremental.
After deleting target/, `git worktree prune` forgets the worktrees.
"""
import argparse
import concurrent.futures
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
import numpy as np
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
DELTA = ROOT / "target" / "delta"
OUT = ROOT / "gallery" / "delta"
TIERS = ["L0", "L1"]
CAMERA = "C1"
# Renders at once. Each loads its own copy of the Earth and Moon maps; four at once ran
# the RTX 3060 (12 GB) out of memory.
WORKERS = 2
BRIGHTER = np.array([255, 150, 40], dtype=float)
DARKER = np.array([60, 140, 255], dtype=float)
def git(*args, cwd=ROOT):
return subprocess.run(
["git", *args], cwd=cwd, check=True, capture_output=True, text=True
).stdout.strip()
def dirty():
return bool(git("status", "--porcelain"))
class Side:
"""One revision: where its sources are, how to build it, where its renders go."""
def __init__(self, rev, slot):
if rev == "worktree":
self.sha = None
self.label = "working tree"
self.root = ROOT
self.target = ROOT / "target"
self.renders = DELTA / "renders" / "worktree"
else:
self.sha = git("rev-parse", "--verify", f"{rev}^{{commit}}")
self.label = git("log", "-1", "--format=%h %s", self.sha)
self.root = DELTA / slot
self.target = DELTA / f"{slot}-target"
self.renders = DELTA / "renders" / self.sha
def prepare(self):
"""Checks the revision out into its worktree, with the untracked data linked."""
if self.sha is None:
return
git("worktree", "prune")
if (self.root / ".git").exists():
git("checkout", "--detach", "--force", self.sha, cwd=self.root)
else:
DELTA.mkdir(parents=True, exist_ok=True)
git("worktree", "add", "--detach", "--force", str(self.root), self.sha)
links = {
self.root / "data": ROOT / "data",
self.root / "examples" / "models" / "nasa": ROOT / "examples" / "models" / "nasa",
# The library's astrokit path dependency, ../../../astrokit from its crate.
DELTA / "astrokit": (ROOT / ".." / "astrokit").resolve(),
}
for link, target in links.items():
if target.exists() and not link.exists():
link.symlink_to(target)
def build(self):
env = dict(os.environ, CARGO_TARGET_DIR=str(self.target))
print(f"building {self.label}", flush=True)
subprocess.run(
["cargo", "build", "--quiet", "--release", "-p", "resolved-cli"],
cwd=self.root,
env=env,
check=True,
)
self.binary = self.target / "release" / "resolved"
usage = subprocess.run(
[self.binary, "render", "--help"], capture_output=True, text=True
).stdout
self.has_noiseless = "--noiseless" in usage
def scenes(self):
return {p.stem: p for p in sorted((self.root / "examples" / "scenes").glob("*.toml"))}
def render_all(self, names, noise, fresh):
"""Renders each scene at each tier into its own directory, skipping those
already rendered at this commit (failures are tried again). Returns
{(scene, tier): directory or error}."""
mode = "noise" if noise else "noiseless"
base = self.renders / mode
if self.sha is None or fresh:
shutil.rmtree(base, ignore_errors=True)
scenes = self.scenes()
jobs = [(n, t) for n in names if n in scenes for t in TIERS]
def one(job):
name, tier = job
out = base / f"{name}-{tier}"
if (out / "dn.png").is_file():
return job, out
out.mkdir(parents=True, exist_ok=True)
args = [
self.binary, "render", "--scene", str(scenes[name].relative_to(self.root)),
"--lighting", tier, "--camera", CAMERA, "--pretty",
"--preview", str(out / "pretty.png"), "--out", str(out / "dn.png"),
]
if not noise:
args.append("--noiseless")
result = subprocess.run(args, cwd=self.root, capture_output=True, text=True)
if result.returncode != 0:
return job, result.stderr.strip().removeprefix("error: ")
return job, out
print(f"rendering {self.label}: {len(jobs)} images", flush=True)
with concurrent.futures.ThreadPoolExecutor(WORKERS) as pool:
return dict(pool.map(one, jobs))
def read_dn(path):
"""A 16-bit PNG as DN: its values shifted down past the zero padding below them."""
image = Image.open(path)
width, height = image.size
channels = len(image.getbands())
kind = "rgb" if channels == 3 else "gray"
raw = subprocess.run(
["magick", str(path), "-depth", "16", "-endian", "LSB", f"{kind}:-"],
check=True,
capture_output=True,
).stdout
values = np.frombuffer(raw, "<u2").reshape(height, width, -1)
return values
def padding(values):
"""The number of zero bits below every value: 4 for 12-bit DN in 16 bits."""
bits = int(np.bitwise_or.reduce(values, axis=None))
return (bits & -bits).bit_length() - 1 if bits else 0
def difference_image(after_pretty, delta):
"""The after image dimmed to gray, with changed pixels colored on a log scale."""
gray = np.asarray(Image.open(after_pretty).convert("L"), dtype=float)[..., None]
picture = np.repeat(gray * 0.3, 3, axis=2)
size = np.abs(delta).max(axis=2).astype(float)
largest = size.max()
if largest > 0:
changed = size > 0
strength = 0.35 + 0.65 * np.log1p(size) / np.log1p(largest)
color = np.where((delta.sum(axis=2) >= 0)[..., None], BRIGHTER, DARKER)
mix = (strength * changed)[..., None]
picture = picture * (1 - mix) + color * mix
return Image.fromarray(np.clip(picture, 0, 255).astype(np.uint8))
def compare(before, after, scene_changed, name, tier):
item = {"scene": name, "tier": tier, "scene_changed": scene_changed}
stem = f"{name}-{tier}"
if isinstance(before, Path):
shutil.copy(before / "pretty.png", OUT / f"{stem}-before.png")
item["before"] = f"{stem}-before.png"
if isinstance(after, Path):
shutil.copy(after / "pretty.png", OUT / f"{stem}-after.png")
item["after"] = f"{stem}-after.png"
if not isinstance(before, Path) or not isinstance(after, Path):
item["status"] = "new" if before is None else "removed" if after is None else "failed"
for side, value in (("before", before), ("after", after)):
if isinstance(value, str):
item[f"{side}_error"] = value
return item
a, b = read_dn(before / "dn.png"), read_dn(after / "dn.png")
if a.shape != b.shape:
item.update(status="resized", shape=[list(a.shape), list(b.shape)])
return item
shift = min(padding(a), padding(b))
delta = (b.astype(np.int32) >> shift) - (a.astype(np.int32) >> shift)
size = np.abs(delta)
changed = (size.max(axis=2) > 0).mean()
item.update(
status="changed" if changed > 0 else "same",
pixels=int((size.max(axis=2) > 0).sum()),
max_dn=int(size.max()),
mean_dn=float(size.mean()),
changed=float(changed),
brighter=float((delta.sum(axis=2) > 0).mean()),
darker=float((delta.sum(axis=2) < 0).mean()),
)
if changed > 0:
difference_image(after / "pretty.png", delta).save(OUT / f"{stem}-diff.png")
item["diff"] = f"{stem}-diff.png"
return item
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--base", help="the revision before (default: see above)")
parser.add_argument("--head", help="the revision after, or worktree")
parser.add_argument("--noise", action="store_true", help="keep the detector's noise")
parser.add_argument("--scenes", help="comma-separated scene names (default: all)")
parser.add_argument("--fresh", action="store_true", help="render commits again")
args = parser.parse_args()
if args.base is None and args.head is None:
args.base, args.head = ("HEAD", "worktree") if dirty() else ("HEAD~1", "HEAD")
base = Side(args.base or "HEAD", "base")
head = Side(args.head or "worktree", "head")
print(f"before: {base.label}\nafter: {head.label}", flush=True)
started = time.time()
for side in (base, head):
side.prepare()
side.build()
noise = args.noise
if not noise and not (base.has_noiseless and head.has_noiseless):
print("one revision's CLI has no --noiseless: rendering both with noise", flush=True)
noise = True
before_scenes, after_scenes = base.scenes(), head.scenes()
names = sorted(before_scenes.keys() | after_scenes.keys())
if args.scenes:
wanted = set(args.scenes.split(","))
names = [n for n in names if n in wanted]
before = base.render_all(names, noise, args.fresh)
after = head.render_all(names, noise, args.fresh)
shutil.rmtree(OUT, ignore_errors=True)
OUT.mkdir(parents=True)
items = []
for name in names:
scene_changed = (
name in before_scenes
and name in after_scenes
and before_scenes[name].read_bytes() != after_scenes[name].read_bytes()
)
for tier in TIERS:
items.append(
compare(before.get((name, tier)), after.get((name, tier)), scene_changed,
name, tier)
)
summary = {
"base": base.label,
"head": head.label,
"noise": noise,
"tiers": [f"{t}/{CAMERA}" for t in TIERS],
"made": time.strftime("%Y-%m-%d %H:%M"),
"items": items,
}
(OUT / "summary.json").write_text(json.dumps(summary, indent=1))
counts = {}
for item in items:
counts[item["status"]] = counts.get(item["status"], 0) + 1
print(
f"{len(items)} images in {time.time() - started:.0f} s: "
+ ", ".join(f"{n} {s}" for s, n in sorted(counts.items()))
)
for item in items:
if item["status"] == "changed":
print(
f" {item['scene']} {item['tier']}: {item['pixels']} pixels "
f"({100 * item['changed']:.3g} %), "
f"up to {item['max_dn']} DN"
)
print("view with scripts/serve-gallery.py (the Delta tab)")
if __name__ == "__main__":
sys.exit(main())
+114 -8
View File
@@ -12,6 +12,11 @@ Movie: gallery/movie.mp4 (from make-gallery.sh or resolved-movie --out) to play
download, and the live stream from `resolved-movie --serve 0.0.0.0:8010` on this 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. 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 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. the new images. Only the PNG files and movie.mp4 in gallery/ are served.
@@ -20,6 +25,7 @@ Needs only the Python standard library.
import argparse import argparse
import html import html
import json
import socket import socket
import time import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -28,6 +34,7 @@ from urllib.parse import unquote
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
GALLERY = ROOT / "gallery" GALLERY = ROOT / "gallery"
DELTA = GALLERY / "delta"
SCENES = ROOT / "examples" / "scenes" SCENES = ROOT / "examples" / "scenes"
TIERS = ["L0", "L1"] TIERS = ["L0", "L1"]
# The camera tier make-gallery.sh renders with. # The camera tier make-gallery.sh renders with.
@@ -95,42 +102,63 @@ nav a.on { color: var(--bg); background: var(--accent); border-color: var(--acce
} }
.movie .live { object-fit: contain; } .movie .live { object-fit: contain; }
.movie a.download { color: var(--accent); } .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); } .movie code { color: var(--text); }
""" """
SCRIPT = """ SCRIPT = """
const live = document.getElementById('live'); const live = document.getElementById('live');
const TABS = ['stills', 'movie', 'delta'];
function tab() { function tab() {
const movie = location.hash === '#movie'; 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 // The panels' ids differ from the links' anchors, so a link switches the tab
// instead of scrolling the page to it. // instead of scrolling the page to it.
document.getElementById('stills-tab').hidden = movie; TABS.forEach((t) => { document.getElementById(t + '-tab').hidden = t !== name; });
document.getElementById('movie-tab').hidden = !movie;
document.querySelectorAll('nav a').forEach( document.querySelectorAll('nav a').forEach(
(a) => a.classList.toggle('on', (a.hash === '#movie') === movie)); (a) => a.classList.toggle('on', a.hash === '#' + name));
// The live stream renders only while someone watches: connect only on this tab. // The live stream renders only while someone watches: connect only on this tab.
if (live) live.src = movie ? live.dataset.src : ''; if (live) live.src = name === 'movie' ? live.dataset.src : '';
} }
window.addEventListener('hashchange', tab); window.addEventListener('hashchange', tab);
tab(); tab();
const images = [...document.querySelectorAll('figure img')];
const view = document.getElementById('view'); const view = document.getElementById('view');
const big = view.querySelector('img'); const big = view.querySelector('img');
const label = view.querySelector('div'); const label = view.querySelector('div');
let images = [];
let current = -1; let current = -1;
let flipped = false;
function show(i) { function show(i) {
current = (i + images.length) % images.length; current = (i + images.length) % images.length;
flipped = false;
big.src = images[current].src; big.src = images[current].src;
label.textContent = images[current].alt; label.textContent = images[current].alt;
view.classList.add('open'); view.classList.add('open');
} }
images.forEach((img, i) => img.addEventListener('click', () => show(i))); // 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')); view.addEventListener('click', () => view.classList.remove('open'));
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (!view.classList.contains('open')) return; if (!view.classList.contains('open')) return;
if (e.key === 'Escape') view.classList.remove('open'); if (e.key === 'Escape') view.classList.remove('open');
if (e.key === 'ArrowRight') show(current + 1); if (e.key === 'ArrowRight') show(current + 1);
if (e.key === 'ArrowLeft') show(current - 1); if (e.key === 'ArrowLeft') show(current - 1);
if (e.key === ' ') { e.preventDefault(); flip(); }
}); });
""" """
@@ -188,6 +216,76 @@ it renders only while this tab is open.</p>
</section>""" </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): def page(host, live_port):
names = sorted({p.stem.rsplit("-", 1)[0] for p in GALLERY.glob("*.png")}) names = sorted({p.stem.rsplit("-", 1)[0] for p in GALLERY.glob("*.png")})
cards = [] cards = []
@@ -236,9 +334,10 @@ def page(host, live_port):
<body><header><h1><span>resolved</span> gallery</h1> <body><header><h1><span>resolved</span> gallery</h1>
<p>{count} renders of {len(names)} example scenes, last rendered {when}. <p>{count} renders of {len(names)} example scenes, last rendered {when}.
Click an image to see it full size.</p></header> Click an image to see it full size.</p></header>
<nav><a href="#stills">Stills</a><a href="#movie">Movie</a></nav> <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="stills-tab">{"".join(cards)}</main>
<main id="movie-tab" hidden>{movie_section(host, live_port)}</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> <div id="view"><img alt=""><div></div></div>
<script>{SCRIPT}</script></body></html>""" <script>{SCRIPT}</script></body></html>"""
@@ -252,6 +351,13 @@ class Handler(BaseHTTPRequestHandler):
self.send(200, "text/html; charset=utf-8", body) self.send(200, "text/html; charset=utf-8", body)
elif path == "/movie.mp4" and (GALLERY / "movie.mp4").is_file(): elif path == "/movie.mp4" and (GALLERY / "movie.mp4").is_file():
self.send_movie(GALLERY / "movie.mp4") 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/"): elif path.startswith("/img/"):
name = path[len("/img/"):] name = path[len("/img/"):]
# Only a PNG directly in gallery/, by its exact name. # Only a PNG directly in gallery/, by its exact name.