diff --git a/tools/vmshot.py b/tools/vmshot.py index 6a12e05..5587df1 100755 --- a/tools/vmshot.py +++ b/tools/vmshot.py @@ -45,6 +45,22 @@ Usage tools/vmshot.py --one 140 # full-size single guest, no sheet tools/vmshot.py --cols 3 --width 900 +**Run it as ``tools/vmshot.py``, not ``python3 tools/vmshot.py``.** The shebang is +``uv run --with pillow``, so invoking the file directly supplies Pillow even on a host +that has none; putting an interpreter in front of it throws that away. Lane BR hit +exactly this and concluded the tool was unusable from the WSL host. ``--one`` now works +either way -- it writes the guest's framebuffer bytes verbatim and needs no Pillow -- +and only the contact sheet requires it, with the install line in the error. + +For a single frame during a measurement run, going straight to the host is better still +and is what lane BR ended up doing:: + + ssh spicy "echo 'screendump /tmp/x.png -f png' | qm monitor 146" + +That is the *live* framebuffer. The wall at http://192.168.3.201:8140/ serves a cached +frame up to a poll cycle (~5 s) old, which reads exactly like a swallowed click and has +cost two lanes wasted clicks. + Output lands in ``~/sots-re/dumps/vmshot/`` (``dumps/`` is gitignored). A guest that is stopped, paused, or whose screendump fails gets a labelled @@ -68,7 +84,27 @@ import time from datetime import datetime from pathlib import Path -from PIL import Image, ImageDraw, ImageFont +# Pillow is needed only to composite the contact sheet. `--one` writes the guest's +# framebuffer bytes straight through, and that is the mode a measurement lane actually +# wants -- lane BR could not use this tool at all from the WSL host because the import +# was unconditional and Pillow is not installed there. Fail at the point of use, with +# the fix in the message, rather than at import. +try: + from PIL import Image, ImageDraw, ImageFont + + _PIL_ERROR = None +except ImportError as e: # pragma: no cover - depends on the host + Image = ImageDraw = ImageFont = None # type: ignore[assignment] + _PIL_ERROR = e + + +def _require_pil() -> None: + if _PIL_ERROR is not None: + sys.exit( + f"vmshot: the contact sheet needs Pillow, which is not installed here ({_PIL_ERROR}).\n" + " `--one ` works without it and writes the guest's framebuffer verbatim.\n" + " To build sheets on this host: uv pip install pillow" + ) HOST = "spicy" SERVICE = os.environ.get("VMWATCH_URL", "http://192.168.3.201:8140") @@ -204,7 +240,7 @@ def grab(ids: list[int], per_vm_timeout: int = 20) -> dict[int, dict]: return out -def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: +def _font(size: int, bold: bool = False): try: return ImageFont.truetype(FONT_BOLD if bold else FONT_PATH, size) except OSError: @@ -223,7 +259,7 @@ def _clean_monitor_log(log: str) -> str: return "; ".join(keep)[:180] -def make_tile(vmid: int, name: str, info: dict, width: int, stamp: str) -> Image.Image: +def make_tile(vmid: int, name: str, info: dict, width: int, stamp: str): png = info.get("png") status = info.get("status", "?") err = _clean_monitor_log(info.get("log", "")) @@ -301,7 +337,8 @@ def make_tile(vmid: int, name: str, info: dict, width: int, stamp: str) -> Image return tile -def contact_sheet(fleet, shots, tile_w: int, cols: int, stamp: str) -> Image.Image: +def contact_sheet(fleet, shots, tile_w: int, cols: int, stamp: str): + _require_pil() tiles = [make_tile(vid, name, shots.get(vid, {}), tile_w, stamp) for vid, name, _ in fleet] rows = (len(tiles) + cols - 1) // cols row_h = [