summaryrefslogtreecommitdiffhomepage
path: root/bin/screenshot
blob: 6a35132f437a1d12d0aad1991600bc4ff634f196 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
#!/bin/sh
# bin/screenshot — capture a game frame to a PNG, generically (web + desktop).
#
# This repo is WEB-FIRST: the relay serves the running game at
# http://localhost:8080/game.html (start it: `node tools/agent-bridge/server.js`),
# and the SAME shaders (SMAA/FXAA/CRT) run there. The web capture path works
# headlessly under WSL via a headless browser. The desktop path uses raylib's own
# TakeScreenshot framebuffer capture and needs a real display (see WSLg gotcha).
#
# Usage:
#   bin/screenshot <game-script.rb> <output.png> [options]
#
#   <game-script.rb>  which game to capture. For --target=web this MUST match the
#                     game currently set in web/shell.html (Module.arguments) —
#                     the relay serves whatever the web build was built with. The
#                     arg is used only to label output + for --target=desktop.
#   <output.png>      output path. Put it in /tmp/ or .live/ — do NOT commit PNGs.
#
# Options:
#   --target web|desktop   capture target (default: web). web=headless browser
#                          of the relay-served game; desktop=raylib framebuffer.
#   --frames N      web: ms to wait after load before capture (default 3000).
#                   desktop: frames to render before capture (default 30).
#   --delay SECS    desktop only: extra wall-clock seconds before capture (0).
#   --timeout SECS  hard timeout for the whole capture (default 30).
#   --win WxH       viewport size (default 1280x720 web, 720x720 desktop).
#   --url URL       web only: relay URL (default http://localhost:8080/game.html).
#   --browser chrome|puppeteer  web only: capture backend. 'chrome' uses a system
#                   google-chrome/chromium --headless --screenshot if present;
#                   'puppeteer' uses the project-local puppeteer (downloads a
#                   headless Chromium on first use). Default: try chrome, else
#                   puppeteer.
#   --keep-running  desktop only: capture but do NOT exit the game (default exit).
#   --game-bin PATH  desktop only: game binary (default zig-out/bin/game).
#   --ffmpeg         desktop only: force the ffmpeg x11grab fallback.
#   -h, --help       show this help.
#
# Exit 0 + prints the absolute PNG path on success; non-zero on failure. PNGs are
# validated (file(1) checks the PNG signature + dimensions). See
# tools/SCREENSHOT.md for the full guide, gotchas, and the WSLg display limit.
set -eu

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"

# --- PATH setup (mirror bin/lint + rebuild.sh: strip /mnt/c) ---
CLEANPATH=$(echo "$PATH" | tr ':' '\n' | grep -v '^/mnt/c' | paste -sd:)
export PATH="$CLEANPATH"

TARGET="web"
FRAMES=""          # filled per-target default below
DELAY=0
TIMEOUT=30
KEEP_RUNNING=0
FORCE_FFMPEG=0
WIN_W=0; WIN_H=0    # 0 => per-target default
URL="http://localhost:8080/game.html"
BROWSER=""
GAME_BIN="zig-out/bin/game"
GAME_SCRIPT=""
OUT_PNG=""

show_help() { sed -n '2,40p' "$0"; }

while [ $# -gt 0 ]; do
  case "$1" in
    --target)        TARGET="$2"; shift 2 ;;
    --frames)        FRAMES="$2"; shift 2 ;;
    --delay)         DELAY="$2"; shift 2 ;;
    --timeout)       TIMEOUT="$2"; shift 2 ;;
    --keep-running)  KEEP_RUNNING=1; shift ;;
    --ffmpeg)        FORCE_FFMPEG=1; shift ;;
    --win)           WIN_W="${2%x*}"; WIN_H="${2#*x}"; shift 2 ;;
    --url)           URL="$2"; shift 2 ;;
    --browser)       BROWSER="$2"; shift 2 ;;
    --game-bin)      GAME_BIN="$2"; shift 2 ;;
    -h|--help)       show_help; exit 0 ;;
    --) shift; break ;;
    -*) echo "unknown flag: $1 (try --help)" >&2; exit 2 ;;
    *)
      if [ -z "$GAME_SCRIPT" ]; then GAME_SCRIPT="$1"
      elif [ -z "$OUT_PNG" ];    then OUT_PNG="$1"
      else echo "unexpected extra arg: $1" >&2; exit 2; fi
      shift ;;
  esac
done

if [ -z "$GAME_SCRIPT" ] || [ -z "$OUT_PNG" ]; then
  echo "usage: bin/screenshot <game-script.rb> <output.png> [options]" >&2
  echo "  (run 'bin/screenshot --help' for details)" >&2
  exit 2
fi

case "$TARGET" in
  web|desktop) : ;;
  *) echo "error: --target must be web or desktop (got: $TARGET)" >&2; exit 2 ;;
esac

# Absolute output path.
case "$OUT_PNG" in
  /*) ABS_OUT="$OUT_PNG" ;;
  *)  ABS_OUT="$(pwd)/$OUT_PNG" ;;
esac
mkdir -p "$(dirname "$ABS_OUT")" 2>/dev/null || true
rm -f "$ABS_OUT"

valid_png() {
  [ -f "$1" ] || return 1
  case "$(file -b "$1" 2>/dev/null || true)" in
    *PNG*image*) return 0 ;;
    *) return 1 ;;
  esac
}

# ─── WEB target: headless browser of the relay-served game ──────────────────
capture_web() {
  [ "$WIN_W" -gt 0 ] 2>/dev/null || WIN_W=1280
  [ "$WIN_H" -gt 0 ] 2>/dev/null || WIN_H=720
  [ -n "$FRAMES" ] || FRAMES=3000   # ms to wait after load for shaders to settle
  echo "── web capture (relay) ─────────────────────────────────────────────"
  echo "  url:     $URL"
  echo "  output:  $ABS_OUT"
  echo "  size:    ${WIN_W}x${WIN_H}   settle: ${FRAMES}ms   timeout: ${TIMEOUT}s"

  if ! curl -s -o /dev/null -m 5 "$URL"; then
    echo "  error: relay not serving $URL (start it: node tools/agent-bridge/server.js)" >&2
    return 1
  fi

  # Pick backend: explicit --browser, else chrome if a system binary exists,
  # else puppeteer (downloads a headless Chromium into the project cache).
  use_chrome=0; use_puppeteer=0
  if [ -n "$BROWSER" ]; then
    case "$BROWSER" in chrome) use_chrome=1 ;; puppeteer) use_puppeteer=1 ;;
      *) echo "error: --browser must be chrome or puppeteer" >&2; return 1 ;; esac
  else
    for b in google-chrome google-chrome-stable chromium chromium-browser chrome; do
      if command -v "$b" >/dev/null 2>&1; then CHROME_BIN="$b"; use_chrome=1; break; fi
    done
    [ "$use_chrome" = 1 ] || use_puppeteer=1
  fi

  if [ "$use_chrome" = 1 ]; then
    echo "  backend: system chrome ($CHROME_BIN)"
    # --headless=new + --screenshot writes a PNG of the viewport after load.
    # --run-all-compositor-stages-before-draw + --virtual-time-budget let the
    # WebGL canvas render frames before the screenshot is taken.
    "$CHROME_BIN" --headless=new --disable-gpu --no-sandbox \
      --hide-scrollbars --force-device-scale-factor=1 \
      --window-size="${WIN_W},${WIN_H}" --virtual-time-budget="$FRAMES" \
      --run-all-compositor-stages-before-draw \
      --screenshot="$ABS_OUT" "$URL" >/tmp/ss.chrome.log 2>&1 || true
  fi

  if [ "$use_puppeteer" = 1 ]; then
    echo "  backend: puppeteer (project-local)"
    if [ ! -d node_modules/puppeteer ]; then
      echo "  error: puppeteer not installed. One-time setup (downloads ~180MB Chromium):" >&2
      echo "    npm install puppeteer   # then re-run" >&2
      return 1
    fi
    PUPPETEER_CACHE_DIR="$ROOT/.puppeteer-cache" \
      node tools/web_screenshot.js "$URL" "$ABS_OUT" "$FRAMES" "$WIN_W" "$WIN_H" \
      >/tmp/ss.puppeteer.log 2>&1 || { cat /tmp/ss.puppeteer.log >&2; return 1; }
  fi
}

# ─── DESKTOP target: raylib framebuffer capture (env hook) + ffmpeg fallback ─
capture_desktop() {
  [ "$WIN_W" -gt 0 ] 2>/dev/null || WIN_W=720
  [ "$WIN_H" -gt 0 ] 2>/dev/null || WIN_H=720
  [ -n "$FRAMES" ] || FRAMES=30
  if [ ! -f "$GAME_BIN" ]; then
    echo "error: game binary not found at $GAME_BIN (run ./rebuild.sh first)" >&2; return 1
  fi
  if [ ! -f "$GAME_SCRIPT" ]; then
    echo "error: game script not found: $GAME_SCRIPT" >&2; return 1
  fi
  # WSLg: prefer the Wayland backend (raylib is built Wayland-only — see
  # .agents/knowledge/environment.md). FALL BACK to X11 if the caller set DISPLAY.
  : "${XDG_RUNTIME_DIR:=/mnt/wslg/runtime-dir}"; export XDG_RUNTIME_DIR
  : "${WAYLAND_DISPLAY:=wayland-0}"; export WAYLAND_DISPLAY
  echo "── desktop capture (raylib framebuffer) ──────────────────────────────"
  echo "  game:    $GAME_SCRIPT   output: $ABS_OUT"
  echo "  frames:  $FRAMES  delay: ${DELAY}s  timeout: ${TIMEOUT}s"
  echo "  (WSLg note: this needs a driven display — see tools/SCREENSHOT.md)"

  raylib_capture() {
    export JAMSTACK_SCREENSHOT="$ABS_OUT"
    export JAMSTACK_SCREENSHOT_FRAMES="$FRAMES"
    export JAMSTACK_SCREENSHOT_DELAY="$DELAY"
    if [ "$KEEP_RUNNING" = 1 ]; then export JAMSTACK_SCREENSHOT_ONCE=0
    else unset JAMSTACK_SCREENSHOT_ONCE; fi
    if command -v timeout >/dev/null 2>&1; then
      timeout "${TIMEOUT}s" "$GAME_BIN" "$GAME_SCRIPT" >"$ABS_OUT.game.log" 2>&1 &
    else
      "$GAME_BIN" "$GAME_SCRIPT" >"$ABS_OUT.game.log" 2>&1 &
    fi
    GAME_PID=$!
    deadline=$(( $(date +%s) + TIMEOUT ))
    while [ "$(date +%s)" -lt "$deadline" ]; do
      valid_png "$ABS_OUT" && break
      if ! kill -0 "$GAME_PID" 2>/dev/null; then
        valid_png "$ABS_OUT" || { echo "  game exited w/o PNG; log tail:" >&2; tail -20 "$ABS_OUT.game.log" >&2 || true; }
        break
      fi
      sleep 0.25
    done
    if kill -0 "$GAME_PID" 2>/dev/null; then kill "$GAME_PID" 2>/dev/null || true; sleep 0.5; kill -9 "$GAME_PID" 2>/dev/null || true; fi
    wait "$GAME_PID" 2>/dev/null || true
    rm -f "$ABS_OUT.game.log"
  }

  ffmpeg_capture() {
    echo "── ffmpeg x11grab fallback ─────────────────────────────────────────"
    : "${DISPLAY:=:0}"; export DISPLAY
    "$GAME_BIN" "$GAME_SCRIPT" >"$ABS_OUT.game.log" 2>&1 & GAME_PID=$!
    sleep "$(awk -v f="$FRAMES" 'BEGIN{ printf "%.2f", f/60.0 }')"
    ffmpeg -y -f x11grab -video_size "${WIN_W}x${WIN_H}" -framedrop \
           -i "$DISPLAY" -frames:v 1 "$ABS_OUT" >"$ABS_OUT.ff.log" 2>&1 || true
    if kill -0 "$GAME_PID" 2>/dev/null; then kill "$GAME_PID" 2>/dev/null || true; sleep 0.3; kill -9 "$GAME_PID" 2>/dev/null || true; fi
    wait "$GAME_PID" 2>/dev/null || true
    rm -f "$ABS_OUT.game.log" "$ABS_OUT.ff.log"
  }

  [ "$FORCE_FFMPEG" != 1 ] && { raylib_capture || true; }
  if { [ "$FORCE_FFMPEG" = 1 ] || ! valid_png "$ABS_OUT"; }; then
    [ "$FORCE_FFMPEG" != 1 ] && echo "  raylib path produced no PNG — trying ffmpeg fallback" >&2
    ffmpeg_capture || true
  fi
}

case "$TARGET" in
  web)     capture_web || true ;;
  desktop) capture_desktop || true ;;
esac

if valid_png "$ABS_OUT"; then
  echo "✓ captured: $ABS_OUT"
  echo "  $(file -b "$ABS_OUT")"
  exit 0
else
  echo "✗ failed to capture a valid PNG at $ABS_OUT" >&2
  exit 1
fi