This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

tools.realwallpaper: add realwallpaper-server

HTTP service rendering wallpapers on demand for a location, variant,
overlay and size. Renders stay fresh for 10 minutes; ones that were
picked up are re-rendered in the background when they go stale.
realwallpaper gains --cache-dir so locations share one data cache, and
its cache cleanup now tracks last use instead of fetch time.

+403 -3
+11 -1
tools/realwallpaper/flake-module.nix
··· 1 1 { 2 2 perSystem = 3 - { pkgs, ... }: 3 + { pkgs, self', ... }: 4 4 let 5 5 python = pkgs.python3.withPackages ( 6 6 ps: with ps; [ ··· 39 39 ''; 40 40 }).overrideAttrs 41 41 { passthru.usage = builtins.readFile ./usage.kdl; }; 42 + 43 + packages.realwallpaper-server = 44 + (pkgs.writeShellApplication { 45 + name = "realwallpaper-server"; 46 + runtimeInputs = [ pkgs.python3 ]; 47 + text = '' 48 + exec python3 ${./server.py} --realwallpaper ${self'.packages.realwallpaper}/bin/realwallpaper "$@" 49 + ''; 50 + }).overrideAttrs 51 + { passthru.usage = builtins.readFile ./server-usage.kdl; }; 42 52 }; 43 53 }
+27 -2
tools/realwallpaper/realwallpaper.py
··· 692 692 return path if path.exists() else None 693 693 694 694 def cleanup(self, max_age: float = 3 * 86400) -> None: 695 + """Delete files no run has used for max_age. 696 + 697 + File mtimes are the fetch times the TTLs run on, so last use is kept 698 + in a separate index; that also lets several homes share one cache 699 + without deleting each other's local imagery. 700 + """ 701 + index_path = self.root / "last-used.json" 702 + try: 703 + last_used = json.loads(index_path.read_text()) 704 + except (OSError, ValueError): 705 + last_used = {} 706 + now = time.time() 707 + for p in self.used: 708 + last_used[p.name] = now 695 709 for p in self.root.iterdir(): 696 - if p not in self.used and time.time() - p.stat().st_mtime > max_age: 710 + if p == index_path or p.name.startswith("."): 711 + continue 712 + seen = last_used.get(p.name, p.stat().st_mtime) 713 + if now - seen > max_age: 697 714 p.unlink() 715 + last_used.pop(p.name, None) 716 + last_used = {name: t for name, t in last_used.items() if (self.root / name).exists()} 717 + tmp = index_path.with_name(f".{index_path.name}.tmp") 718 + tmp.write_text(json.dumps(last_used)) 719 + os.replace(tmp, index_path) 698 720 699 721 700 722 def snow_fraction(ndsi: np.ndarray) -> np.ndarray: ··· 1288 1310 p = argparse.ArgumentParser(prog="realwallpaper", description=__doc__.splitlines()[0]) 1289 1311 p.add_argument("--dir", type=Path, default=Path("."), 1290 1312 help="output and cache directory (default: .)") 1313 + p.add_argument("--cache-dir", type=Path, default=None, 1314 + help="downloaded data, may be shared between runs for different homes " 1315 + "(default: DIR/cache)") 1291 1316 p.add_argument("--lat", type=float, default=52.520008, 1292 1317 help="home latitude in degrees, south negative (default Berlin)") 1293 1318 p.add_argument("--lon", type=float, default=13.404954, ··· 1322 1347 1323 1348 out_dir = args.dir.resolve() 1324 1349 out_dir.mkdir(parents=True, exist_ok=True) 1325 - cache = Cache(out_dir / "cache") 1350 + cache = Cache(args.cache_dir.resolve() if args.cache_dir else out_dir / "cache") 1326 1351 proj = LensMap(args.lat, args.lon, args.width, args.height, 1327 1352 args.magnification, args.lens, args.lens_shape, args.focus, args.falloff, 1328 1353 args.projection)
+33
tools/realwallpaper/server-usage.kdl
··· 1 + bin "realwallpaper-server" 2 + about "HTTP service rendering realwallpaper on demand: /?location=LAT,LON&variant=lens|plain|mercator&overlay=none|sky|krebs|stars&size=WxH" 3 + 4 + flag "--state-dir" help="Renders and the shared data cache (required)" { 5 + arg "<dir>" 6 + } 7 + flag "--listen" help="HOST:PORT (default 127.0.0.1:8477)" { 8 + arg "<addr>" 9 + } 10 + flag "--ttl" help="Seconds a render stays fresh (default 600)" { 11 + arg "<seconds>" 12 + } 13 + flag "--keep" help="Drop renders not requested for this many seconds (default 7 days)" { 14 + arg "<seconds>" 15 + } 16 + flag "--wait" help="How long a request waits for its render (default 900)" { 17 + arg "<seconds>" 18 + } 19 + flag "--render-timeout" help="Seconds a render may take (default 1800)" { 20 + arg "<seconds>" 21 + } 22 + flag "--max-queue" help="Queued renders before new locations get 503 (default 8)" { 23 + arg "<n>" 24 + } 25 + flag "--marker-url" help="JSON {name: {latitude, longitude}} for krebs markers" { 26 + arg "<url>" 27 + } 28 + flag "--lat" help="Default location latitude (Berlin)" { 29 + arg "<deg>" 30 + } 31 + flag "--lon" help="Default location longitude (Berlin)" { 32 + arg "<deg>" 33 + }
+332
tools/realwallpaper/server.py
··· 1 + """Serve realwallpaper renders over HTTP, rendered on demand. 2 + 3 + GET /?location=52.52,13.40&variant=lens&overlay=sky&size=3840x2160 4 + 5 + A render for a (location, variant, size) is fresh for --ttl seconds (10 min). 6 + A request for a stale or missing render queues one and waits for it. A 7 + render that was picked up (served at least once) is re-rendered in the 8 + background as soon as it goes stale, so regular clients always get a fresh 9 + image straight away; renders nobody picked up are left alone and deleted 10 + after --keep. Renders run one at a time (a 4K frame needs a few GB of RAM) 11 + and share one data cache. 12 + """ 13 + 14 + from __future__ import annotations 15 + 16 + import argparse 17 + import collections 18 + import email.utils 19 + import http.server 20 + import shutil 21 + import subprocess 22 + import sys 23 + import threading 24 + import time 25 + import urllib.parse 26 + from dataclasses import dataclass, field 27 + from pathlib import Path 28 + 29 + VARIANTS = ("lens", "plain", "mercator") 30 + OVERLAYS = { 31 + "none": "realwallpaper.png", 32 + "sky": "realwallpaper-marker.png", # sun, moon, planets 33 + "krebs": "realwallpaper-krebs.png", # sky plus krebs markers 34 + "stars": "realwallpaper-krebs-stars.png", # plus constellation lines 35 + } 36 + DEFAULT_SIZE = (3840, 2160) 37 + MIN_SIZE = (320, 180) 38 + MAX_SIZE = (3840, 2160) 39 + 40 + HELP = """realwallpaper, a live world map centred on a location 41 + 42 + /?location=LAT,LON&variant=VARIANT&overlay=OVERLAY&size=WxH 43 + 44 + location degrees, south/west negative, rounded to 0.01 (default Berlin) 45 + variant lens (zoomed in on location), plain (no distortion), 46 + mercator (classic north-up map) (default lens) 47 + overlay none, sky (sun, moon, planets), krebs (sky and krebs nodes), 48 + stars (all of that plus constellations) (default sky) 49 + size up to 3840x2160 (default 3840x2160) 50 + 51 + Renders are cached for {ttl} minutes. Ones that get picked up are kept 52 + fresh in the background, so after the first request they come instantly. 53 + """ 54 + 55 + 56 + def log(msg: str) -> None: 57 + print(msg, file=sys.stderr, flush=True) 58 + 59 + 60 + class BadRequest(Exception): 61 + pass 62 + 63 + 64 + class Busy(Exception): 65 + pass 66 + 67 + 68 + @dataclass(frozen=True) 69 + class Job: 70 + lat: float 71 + lon: float 72 + variant: str 73 + width: int 74 + height: int 75 + 76 + @property 77 + def key(self) -> str: 78 + return f"{self.lat:.2f}_{self.lon:.2f}_{self.variant}_{self.width}x{self.height}" 79 + 80 + @classmethod 81 + def from_key(cls, key: str) -> Job: 82 + lat, lon, variant, size = key.split("_") 83 + width, height = size.split("x") 84 + return cls(float(lat), float(lon), variant, int(width), int(height)) 85 + 86 + 87 + @dataclass 88 + class Entry: 89 + job: Job 90 + dir: Path 91 + picked_up: bool = False 92 + last_request: float = field(default_factory=time.time) 93 + queued: bool = False 94 + rendering: bool = False 95 + error: str | None = None 96 + 97 + def rendered_at(self) -> float | None: 98 + try: 99 + return (self.dir / OVERLAYS["none"]).stat().st_mtime 100 + except OSError: 101 + return None 102 + 103 + 104 + def parse_query(query: str, default_location: tuple[float, float]) -> tuple[Job, str]: 105 + params = urllib.parse.parse_qs(query) 106 + 107 + def one(name: str, default: str) -> str: 108 + values = params.get(name) 109 + return values[-1] if values else default 110 + 111 + try: 112 + lat, lon = (float(v) for v in one("location", "%s,%s" % default_location).split(",")) 113 + except ValueError: 114 + raise BadRequest("location must be LAT,LON") from None 115 + if not (-90 <= lat <= 90 and -180 <= lon <= 180): 116 + raise BadRequest("location out of range") 117 + variant = one("variant", "lens") 118 + if variant not in VARIANTS: 119 + raise BadRequest(f"variant must be one of {', '.join(VARIANTS)}") 120 + overlay = one("overlay", "sky") 121 + if overlay not in OVERLAYS: 122 + raise BadRequest(f"overlay must be one of {', '.join(OVERLAYS)}") 123 + try: 124 + width, height = (int(v) for v in one("size", "%dx%d" % DEFAULT_SIZE).lower().split("x")) 125 + except ValueError: 126 + raise BadRequest("size must be WIDTHxHEIGHT") from None 127 + if not (MIN_SIZE[0] <= width <= MAX_SIZE[0] and MIN_SIZE[1] <= height <= MAX_SIZE[1]): 128 + raise BadRequest("size must be between %dx%d and %dx%d" % (*MIN_SIZE, *MAX_SIZE)) 129 + return Job(round(lat, 2), round(lon, 2), variant, width, height), overlay 130 + 131 + 132 + class Renderer: 133 + def __init__(self, args: argparse.Namespace) -> None: 134 + self.args = args 135 + self.renders = args.state_dir / "renders" 136 + self.cache = args.state_dir / "cache" 137 + self.renders.mkdir(parents=True, exist_ok=True) 138 + self.cache.mkdir(parents=True, exist_ok=True) 139 + self.cond = threading.Condition() 140 + self.entries: dict[str, Entry] = {} 141 + self.queue: collections.deque[str] = collections.deque() 142 + for d in self.renders.iterdir(): 143 + try: 144 + job = Job.from_key(d.name) 145 + except ValueError: 146 + continue 147 + entry = Entry(job, d) 148 + entry.last_request = entry.rendered_at() or 0.0 149 + self.entries[job.key] = entry 150 + threading.Thread(target=self._worker, daemon=True, name="render").start() 151 + threading.Thread(target=self._scheduler, daemon=True, name="schedule").start() 152 + 153 + def fresh(self, entry: Entry) -> bool: 154 + at = entry.rendered_at() 155 + return at is not None and time.time() - at < self.args.ttl 156 + 157 + def _enqueue(self, entry: Entry, front: bool) -> None: 158 + if entry.queued or entry.rendering: 159 + return 160 + if front: 161 + if len(self.queue) >= self.args.max_queue: 162 + raise Busy() 163 + self.queue.appendleft(entry.job.key) 164 + else: 165 + self.queue.append(entry.job.key) 166 + entry.queued = True 167 + self.cond.notify_all() 168 + 169 + def get(self, job: Job, overlay: str) -> tuple[Path, Entry]: 170 + """Path of a fresh render, waiting for one if needed.""" 171 + with self.cond: 172 + entry = self.entries.get(job.key) 173 + if entry is None: 174 + entry = self.entries[job.key] = Entry(job, self.renders / job.key) 175 + entry.last_request = time.time() 176 + if not self.fresh(entry): 177 + self._enqueue(entry, front=True) 178 + deadline = time.time() + self.args.wait 179 + while (entry.queued or entry.rendering) and time.time() < deadline: 180 + self.cond.wait(deadline - time.time()) 181 + path = entry.dir / OVERLAYS[overlay] 182 + if path.exists(): 183 + # served: keep it fresh from now on 184 + entry.picked_up = True 185 + return path, entry 186 + 187 + def _scheduler(self) -> None: 188 + while True: 189 + time.sleep(30) 190 + now = time.time() 191 + with self.cond: 192 + for key, entry in list(self.entries.items()): 193 + if entry.queued or entry.rendering: 194 + continue 195 + if entry.picked_up and not self.fresh(entry): 196 + self._enqueue(entry, front=False) 197 + elif now - entry.last_request > self.args.keep: 198 + log(f"dropping {key}, not requested for {self.args.keep / 3600:.0f}h") 199 + shutil.rmtree(entry.dir, ignore_errors=True) 200 + del self.entries[key] 201 + 202 + def _worker(self) -> None: 203 + while True: 204 + with self.cond: 205 + while not self.queue: 206 + self.cond.wait() 207 + entry = self.entries[self.queue.popleft()] 208 + entry.queued = False 209 + entry.rendering = True 210 + # needs to be picked up again to be kept fresh after this 211 + entry.picked_up = False 212 + error = self._render(entry.job, entry.dir) 213 + with self.cond: 214 + entry.rendering = False 215 + entry.error = error 216 + self.cond.notify_all() 217 + 218 + def _render(self, job: Job, out: Path) -> str | None: 219 + out.mkdir(parents=True, exist_ok=True) 220 + cmd = [ 221 + self.args.realwallpaper, 222 + "--dir", str(out), 223 + "--cache-dir", str(self.cache), 224 + "--lat", str(job.lat), 225 + "--lon", str(job.lon), 226 + "--projection", job.variant, 227 + "--width", str(job.width), 228 + "--height", str(job.height), 229 + "--no-archive", 230 + ] 231 + if self.args.marker_url: 232 + cmd += ["--marker-url", self.args.marker_url] 233 + t0 = time.monotonic() 234 + try: 235 + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=self.args.render_timeout) 236 + except subprocess.TimeoutExpired: 237 + log(f"render {job.key}: timed out") 238 + return "render timed out" 239 + if proc.returncode != 0: 240 + tail = proc.stderr.strip().splitlines()[-5:] 241 + log(f"render {job.key} failed ({proc.returncode}): " + " | ".join(tail)) 242 + return "render failed: " + (tail[-1] if tail else f"exit {proc.returncode}") 243 + log(f"rendered {job.key} in {time.monotonic() - t0:.0f}s") 244 + return None 245 + 246 + 247 + def make_handler(renderer: Renderer, args: argparse.Namespace): 248 + class Handler(http.server.BaseHTTPRequestHandler): 249 + server_version = "realwallpaper" 250 + 251 + def log_message(self, fmt: str, *a) -> None: 252 + log("%s %s" % (self.address_string(), fmt % a)) 253 + 254 + def _text(self, code: int, body: str, extra: dict[str, str] | None = None) -> None: 255 + data = body.encode() 256 + self.send_response(code) 257 + self.send_header("Content-Type", "text/plain; charset=utf-8") 258 + self.send_header("Content-Length", str(len(data))) 259 + for k, v in (extra or {}).items(): 260 + self.send_header(k, v) 261 + self.end_headers() 262 + if self.command != "HEAD": 263 + self.wfile.write(data) 264 + 265 + def do_HEAD(self) -> None: 266 + self.do_GET() 267 + 268 + def do_GET(self) -> None: 269 + url = urllib.parse.urlsplit(self.path) 270 + if url.path != "/": 271 + return self._text(404, "not found\n") 272 + if not url.query: 273 + return self._text(200, HELP.format(ttl=round(args.ttl / 60))) 274 + try: 275 + job, overlay = parse_query(url.query, (args.lat, args.lon)) 276 + path, entry = renderer.get(job, overlay) 277 + except BadRequest as e: 278 + return self._text(400, f"{e}\n") 279 + except Busy: 280 + return self._text(503, "too many renders queued, try again later\n", 281 + {"Retry-After": "60"}) 282 + try: 283 + data = path.read_bytes() 284 + rendered = path.stat().st_mtime 285 + except OSError: 286 + return self._text(502, f"{entry.error or 'render still running, try again'}\n", 287 + {"Retry-After": "30"}) 288 + age = time.time() - rendered 289 + self.send_response(200) 290 + self.send_header("Content-Type", "image/png") 291 + self.send_header("Content-Length", str(len(data))) 292 + self.send_header("Last-Modified", email.utils.formatdate(rendered, usegmt=True)) 293 + self.send_header("Cache-Control", f"public, max-age={max(0, int(args.ttl - age))}") 294 + if age >= args.ttl: 295 + # the refresh failed or is still running; this is the last good one 296 + self.send_header("Warning", '110 - "stale render"') 297 + self.end_headers() 298 + if self.command != "HEAD": 299 + self.wfile.write(data) 300 + 301 + return Handler 302 + 303 + 304 + def main() -> None: 305 + p = argparse.ArgumentParser(prog="realwallpaper-server", description=__doc__.splitlines()[0]) 306 + p.add_argument("--listen", default="127.0.0.1:8477", help="HOST:PORT (default 127.0.0.1:8477)") 307 + p.add_argument("--state-dir", type=Path, required=True, help="renders and the shared data cache") 308 + p.add_argument("--realwallpaper", required=True, help="path of the realwallpaper binary") 309 + p.add_argument("--ttl", type=float, default=600, help="seconds a render stays fresh (default 600)") 310 + p.add_argument("--keep", type=float, default=7 * 86400, 311 + help="drop renders not requested for this many seconds (default 7 days)") 312 + p.add_argument("--wait", type=float, default=900, 313 + help="how long a request waits for its render (default 900 s)") 314 + p.add_argument("--render-timeout", type=float, default=1800, help="seconds (default 1800)") 315 + p.add_argument("--max-queue", type=int, default=8, 316 + help="queued renders before new locations get 503 (default 8)") 317 + p.add_argument("--marker-url", default=None, help="JSON {name: {latitude, longitude}} for krebs markers") 318 + p.add_argument("--lat", type=float, default=52.52, help="default location (Berlin)") 319 + p.add_argument("--lon", type=float, default=13.40) 320 + args = p.parse_args() 321 + 322 + host, _, port = args.listen.rpartition(":") 323 + renderer = Renderer(args) 324 + server = http.server.ThreadingHTTPServer((host.strip("[]") or "127.0.0.1", int(port)), 325 + make_handler(renderer, args)) 326 + server.daemon_threads = True 327 + log(f"listening on {args.listen}") 328 + server.serve_forever() 329 + 330 + 331 + if __name__ == "__main__": 332 + main()