#!/usr/bin/env bash # Renders a wallpaper headlessly and takes a screenshot, for environments with no way to see the # real screen (sandboxes, CI, this project's own dev container). When a GPU render node # (/dev/dri/renderD*) is accessible it renders there through the engine's headless EGL driver # (XDG_SESSION_TYPE=headless, no X server, a 4K scene takes ~1s instead of ~20s). Otherwise, or # with HEADLESS_RENDER_SOFTWARE=1, it falls back to Xvfb + Mesa llvmpipe software rendering. # LWE_HEADLESS_DEVICE=/dev/dri/renderDN picks the GPU when there are several. # # Usage: # tools/headless_render.sh [engine args...] # # Example: # tools/headless_render.sh build/output/linux-wallpaperengine /tmp/out.png \ # --assets-dir /path/to/wallpaper_engine/assets --window 0x0x1920x1080 --fps 25 \ # /path/to/workshop/item/dir # # Requirements: a GPU render node, or Xvfb and a software GL renderer (mesa llvmpipe is normally # already present alongside libgl1-mesa-dri); gcc (to build the D-Bus shim once, cached after that). # # Known limitations: # - No real D-Bus session bus is assumed - dbus_noop_shim.so (built automatically) patches the # one call path that isn't already null-safe against a missing bus. # - The engine is stopped as soon as the screenshot file appears (HEADLESS_RENDER_KEEP_RUNNING=1 disables # that and runs until HEADLESS_RENDER_TIMEOUT, default 60s). # - HEADLESS_RENDER_SIZE=WxH changes the window size (default 1920x1080). # - LWE_HEADLESS_CURSOR=x,y puts the GPU path's pointer at that fraction of the output (default 0.5,0.5). # - --screenshot-delay is capped at 5000 frames by the engine (ApplicationContext.cpp). # - CImage.cpp's puppet/effect diagnostics are one-shot logs that fire on the first draw call, # not a chosen frame. set -euo pipefail if [ "$#" -lt 2 ]; then echo "Usage: $0 [engine args...]" >&2 exit 1 fi BINARY="$1"; shift OUTPUT="$1"; shift SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BINARY_DIR="$(cd "$(dirname "$BINARY")" && pwd)" SHIM_SRC="$SCRIPT_DIR/dbus_noop_shim.c" SHIM_SO="/tmp/dbus_noop_shim.so" if [ ! -f "$SHIM_SO" ] || [ "$SHIM_SRC" -nt "$SHIM_SO" ]; then gcc -shared -fPIC -o "$SHIM_SO" "$SHIM_SRC" -ldl fi USE_GPU= if [ -z "${HEADLESS_RENDER_SOFTWARE:-}" ]; then for node in /dev/dri/renderD*; do if [ -r "$node" ] && [ -w "$node" ]; then USE_GPU=1 break fi done fi XVFB_DISPLAY="${HEADLESS_RENDER_DISPLAY:-:99}" XVFB_SOCKET="/tmp/.X11-unix/X${XVFB_DISPLAY#:}" # a private runtime dir and no desktop variables: libwayland falls back to $XDG_RUNTIME_DIR/wayland-0 when # WAYLAND_DISPLAY is unset, so a build without the headless driver would otherwise open a window on the real # desktop session (and reach its D-Bus / audio server) RUNTIME_DIR="$(mktemp -d /tmp/lwe-headless-run.XXXXXX)" chmod 700 "$RUNTIME_DIR" DBUS_PID= trap 'kill $DBUS_PID 2>/dev/null; rm -rf "$RUNTIME_DIR"' EXIT # the engine needs a session bus (MPRIS media source), it gets a private one BUS_ADDRESS=unix:path=/nonexistent-bus if command -v dbus-daemon >/dev/null; then DBUS_PID=$(dbus-daemon --session --fork --print-pid --address="unix:path=$RUNTIME_DIR/bus") BUS_ADDRESS="unix:path=$RUNTIME_DIR/bus" fi ISOLATE=(-u WAYLAND_DISPLAY -u WAYLAND_SOCKET -u KDE_FULL_SESSION -u KDE_SESSION_VERSION -u DESKTOP_SESSION -u XDG_CURRENT_DESKTOP -u XDG_SESSION_DESKTOP XDG_RUNTIME_DIR="$RUNTIME_DIR" DBUS_SESSION_BUS_ADDRESS="$BUS_ADDRESS" PULSE_SERVER=unix:/nonexistent-pulse PIPEWIRE_REMOTE=/nonexistent-pipewire) if [ -n "$USE_GPU" ]; then SESSION=(env -u DISPLAY "${ISOLATE[@]}" XDG_SESSION_TYPE=headless) else SESSION=(env "${ISOLATE[@]}" DISPLAY="$XVFB_DISPLAY" XDG_SESSION_TYPE=x11) fi if [ -z "$USE_GPU" ] && [ ! -S "$XVFB_SOCKET" ]; then Xvfb "$XVFB_DISPLAY" -screen 0 1920x1080x24 & XVFB_PID=$! trap 'kill "$XVFB_PID" $DBUS_PID 2>/dev/null || true; rm -rf "$RUNTIME_DIR"' EXIT # give it a moment to bind before launching anything against it for _ in $(seq 1 20); do [ -S "$XVFB_SOCKET" ] && break sleep 0.2 done fi rm -f "$OUTPUT" "${SESSION[@]}" \ LD_LIBRARY_PATH="$BINARY_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ LD_PRELOAD="$SHIM_SO" \ timeout "${HEADLESS_RENDER_TIMEOUT:-60}" "$BINARY" \ --window "0x0x${HEADLESS_RENDER_SIZE:-1920x1080}" \ --screenshot "$OUTPUT" \ --screenshot-delay "${HEADLESS_RENDER_DELAY:-3}" \ "$@" & ENGINE_PID=$! # the engine keeps rendering after the screenshot is taken, so without this the run always lasts # the full timeout - stop it as soon as the file is written (set HEADLESS_RENDER_KEEP_RUNNING=1 to # let it run until the timeout instead) if [ -z "${HEADLESS_RENDER_KEEP_RUNNING:-}" ]; then while kill -0 "$ENGINE_PID" 2>/dev/null; do if [ -s "$OUTPUT" ]; then # wait for the file to stop growing so a half-written PNG isn't cut off prev=-1 cur=$(stat -c %s "$OUTPUT") while [ "$cur" != "$prev" ]; do sleep 0.3 prev=$cur cur=$(stat -c %s "$OUTPUT") done kill "$ENGINE_PID" 2>/dev/null || true break fi sleep 0.2 done fi wait "$ENGINE_PID" || true if [ -f "$OUTPUT" ]; then echo "Wrote $OUTPUT" else echo "No screenshot was produced - check the engine's stderr output above" >&2 exit 1 fi