screenshots: generate light/dark/collage in one run with mtime caching; collage 1080p

This commit is contained in:
2026-08-01 03:56:44 +02:00
parent a37f0a6752
commit 1f3e058484
5 changed files with 77 additions and 24 deletions
+16 -6
View File
@@ -7,7 +7,7 @@ numeric metrics, and keeps recent history in SQLite.
The backend is a single Go binary, the frontend is a Preact + Vite page, and The backend is a single Go binary, the frontend is a Preact + Vite page, and
everything is configured with one YAML file. everything is configured with one YAML file.
![statuspage](assets/screenshots/status.png) ![statuspage](assets/screenshots/status-collage.png)
## Features ## Features
@@ -120,16 +120,26 @@ README image, but is handy for previewing the UI quickly:
### Screenshot ### Screenshot
`scripts/generate-screenshot` builds the frontend and server, starts the app in `scripts/generate-screenshot` builds the frontend and server, starts the app in
mock mode, captures the top of the page with headless Chromium, and frames it mock mode, captures the top of the page with headless Chromium in both light and
with a transparent background, border, and shadow. It needs `chromium` and dark, and frames each with a transparent background, border, and shadow. It
`python3-PIL` on PATH: needs `chromium` and `python3-PIL` on PATH:
```sh ```sh
./scripts/generate-screenshot ./scripts/generate-screenshot
``` ```
The PNG is written to `assets/screenshots/status.png` — the image at the top of This writes three PNGs to `assets/screenshots/`:
this README.
| file | contents |
| --------------------------- | ------------------------------------------------- |
| `status.png` | light capture |
| `status-dark.png` | dark capture |
| `status-collage.png` | dark behind, offset top-left, light on top |
Runs are incremental: a capture is skipped when no frontend/server source is
newer than the existing PNG, and the collage is rebuilt only when the mtime of a
light/dark variant is newer than the collage. `status-collage.png` is the image
at the top of this README.
### Flags ### Flags
Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 532 KiB

After

Width:  |  Height:  |  Size: 537 KiB

+57 -14
View File
@@ -3,7 +3,10 @@ set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
OUT="assets/screenshots/status.png" ASSETS="assets/screenshots"
LIGHT="$ASSETS/status.png"
DARK="$ASSETS/status-dark.png"
COLLAGE="$ASSETS/status-collage.png"
PORT="${PORT:-17645}" PORT="${PORT:-17645}"
URL="http://127.0.0.1:$PORT" URL="http://127.0.0.1:$PORT"
@@ -13,6 +16,13 @@ if [ -z "$CHROMIUM" ]; then
exit 1 exit 1
fi fi
if python3 -c 'import PIL' 2>/dev/null; then
HAVE_PIL=1
else
HAVE_PIL=0
echo "note: python3-PIL not found, skipping framing and collage" >&2
fi
echo "building frontend..." echo "building frontend..."
bun run build bun run build
@@ -41,23 +51,29 @@ if ! curl -sf "$URL/api/status" >/dev/null 2>&1; then
exit 1 exit 1
fi fi
mkdir -p "$(dirname "$OUT")" mkdir -p "$ASSETS"
# $1=theme $2=out $3=shadow $4=border
capture() {
local theme="$1" out="$2" shadow="$3" border="$4"
if [ -f "$out" ] &&
! find src index.html public server go.mod go.sum \
-type f -newer "$out" -print -quit 2>/dev/null | grep -q .; then
echo "up to date, skipping ($out)"
return
fi
"$CHROMIUM" --headless=new --disable-gpu --hide-scrollbars \ "$CHROMIUM" --headless=new --disable-gpu --hide-scrollbars \
--window-size=1280,720 \ --window-size=1280,720 \
--force-device-scale-factor=2 \ --force-device-scale-factor=2 \
--screenshot="$OUT" \ --screenshot="$out" \
--virtual-time-budget=8000 \ --virtual-time-budget=8000 \
"$URL/" 2>/dev/null "$URL/?theme=$theme" 2>/dev/null
# frame the capture: rounded corners, 2px border, hard offset shadow (2x for device scale) # frame the capture: rounded corners, 2px border, hard offset shadow (2x for device scale)
if ! python3 -c 'import PIL' 2>/dev/null; then if [ "$HAVE_PIL" = 1 ]; then
echo "note: python3-PIL not found, skipping frame" >&2 python3 - "$out" "$shadow" "$border" <<'PY'
echo "screenshot written to $OUT"
exit 0
fi
python3 - "$OUT" <<'PY'
import sys import sys
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
@@ -68,8 +84,8 @@ r = 16 # 0.5rem radius
b = 4 # 2px border b = 4 # 2px border
s = 12 # 0.35rem shadow offset s = 12 # 0.35rem shadow offset
m = 24 # margin around the frame m = 24 # margin around the frame
BORDER = (17, 17, 17) BORDER = tuple(int(c) for c in sys.argv[3].split(","))
SHADOW = (136, 136, 136) SHADOW = tuple(int(c) for c in sys.argv[2].split(","))
mask = Image.new("L", (W, H), 0) mask = Image.new("L", (W, H), 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, W - 1, H - 1], radius=r, fill=255) ImageDraw.Draw(mask).rounded_rectangle([0, 0, W - 1, H - 1], radius=r, fill=255)
@@ -89,5 +105,32 @@ canvas.alpha_composite(img_r, (m + b, m + b))
canvas.save(path) canvas.save(path)
print(f"framed {path}") print(f"framed {path}")
PY PY
fi
echo "wrote $out"
}
echo "screenshot written to $OUT" capture light "$LIGHT" "136,136,136" "17,17,17"
capture dark "$DARK" "0,0,0" "58,64,58"
# collage: rebuild only when a variant is newer than the existing collage
if [ "$HAVE_PIL" = 1 ]; then
if [ -f "$COLLAGE" ] && [ "$COLLAGE" -nt "$LIGHT" ] && [ "$COLLAGE" -nt "$DARK" ]; then
echo "collage up to date, skipping ($COLLAGE)"
else
python3 - "$LIGHT" "$DARK" "$COLLAGE" <<'PY'
import sys
from PIL import Image
light = Image.open(sys.argv[1]).convert("RGBA")
dark = Image.open(sys.argv[2]).convert("RGBA")
W, H = light.size
ox, oy = 480, 160
canvas = Image.new("RGBA", (W + ox, H + oy), (242, 242, 242, 255))
canvas.alpha_composite(dark, (0, 0))
canvas.alpha_composite(light, (ox, oy))
canvas.thumbnail((1920, 1080), Image.LANCZOS)
canvas.save(sys.argv[3])
print(f"wrote {sys.argv[3]}")
PY
fi
fi