diff --git a/.claude/skills/timelapse/skill.md b/.claude/skills/timelapse/skill.md new file mode 100644 index 00000000..3bc4c375 --- /dev/null +++ b/.claude/skills/timelapse/skill.md @@ -0,0 +1,79 @@ +--- +name: timelapse +description: Build a build-progression timelapse video of Tensies — walks the whole git history oldest→newest, launches just the app on each commit, drives a real 2-player round with headless Chromium (loading → lobby → first roll → win → opponent's loss), screenshots every step, and stitches it all into an MP4. Use when asked to make/refresh the progression timelapse or a per-commit gameplay video. +user_invocable: true +--- + +# Tensies progression timelapse + +Produce a video that walks the entire commit history of this repo, playing a +round of the game on every commit. The whole pipeline lives in `Timelapse/bin/` +and is driven by one orchestrator. **Do not ask for confirmation — run it and +report the result.** + +## Run it + +```bash +Timelapse/bin/make_timelapse.sh arc 4 +``` + +- arg 1: `arc` (default, 8 frames/commit — loading, lobby, first roll, dice + locking, WIN, and the opponent's LOSS) or `board` (1 frame/commit, fast). +- arg 2: number of parallel workers (default 4). Each worker gets its own + shared-object clone under `Timelapse/.work/` and its own port (8200+id), so + workers never collide on git state. + +Output: `Timelapse/tensies-gameplay-timelapse.mp4`. Deliver it with the file +tool when done. + +## Prerequisites (install once, before the first run) + +```bash +pip install playwright imageio-ffmpeg +playwright install chromium +``` + +If `playwright install chromium` can't download but a Chromium build already +exists under `PLAYWRIGHT_BROWSERS_PATH`, pin the pip package to match instead +(check the build number in that dir; e.g. build `1194` ⇒ `pip install +playwright==1.56`). `imageio-ffmpeg` provides a static libx264 ffmpeg; a system +`ffmpeg` on `PATH` is used if present. + +## How it works (for debugging / extending) + +- **`launch.py`** boots the checked-out commit's `main:app` and monkey-patches + `server.telemetry` `start`/`stop`/`emit` to no-ops, so telemetry-era commits + run with no Postgres/Grafana. Older commits have no telemetry — the patch is + guarded. +- **`play.py`** drives two browser contexts (host = winner, guest = loser) + against one server. Contexts emulate an **iPhone 17 Pro Max** (the `DEVICE` + dict: 440×956 @ DPR 3 → native 1320×2868, mobile + touch + iOS UA) so the page + renders its real phone layout; change `DEVICE` to target another device. It + clicks by **button text** (`Create`/`Start`/`Roll`) and reads **stable ids** + (`#lobby-code` for the join code, `#code-input` to join, `#winner-overlay` to + detect the win). Rolls are paced above `MIN_ROLL_INTERVAL` and the host rolls + until the winner overlay appears; the guest's screen at that instant is the + loss frame. Every step screenshots best-effort, so a commit never produces a + missing frame. +- **`shoot.py`** is the lightweight `board`-mode driver (create → start → + screenshot the board). +- **`build_video.py`** stitches `frames/frame_NNN_S.png` via ffmpeg's concat + demuxer at the frames' native resolution, holding the win/loss beats (steps 6 + & 7) longer so each round reads as a story. Mobile-friendly encode: H.264 + Main + yuv420p, CRF 26 / veryslow / `-tune stillimage`, silent AAC, faststart. + Raise CRF for a smaller file. + +## Gotchas + +- Drive by text/stable-id, **not** by brittle per-version selectors — the + markup and protocol changed a lot across history (inline-JS era → ES modules, + server-authoritative rolls → client-side → back, dialog vs div overlay). +- A solo game can't show a loss (there's no "you lost" screen); the 2-player + setup is what makes both win and loss frames possible. +- Parallel git worktrees on one repo can deadlock on lock files — that's why + each worker uses a separate `git clone --shared` instead. +- At native iPhone resolution (3× DPR) each worker drives two ~1320×2868 + Chromium contexts, which is memory-hungry; 5 workers can OOM (a worker may die + with signal 143). Use ~4 workers at 3× DPR, then re-run any missing indices. +- If a commit comes back `PARTIAL` (no win captured), just re-run that index; + it's almost always a timing fluke, not a real failure. diff --git a/Timelapse/.gitignore b/Timelapse/.gitignore new file mode 100644 index 00000000..b94daa0f --- /dev/null +++ b/Timelapse/.gitignore @@ -0,0 +1,2 @@ +.work/ +frames/ diff --git a/Timelapse/README.md b/Timelapse/README.md new file mode 100644 index 00000000..c03d980e --- /dev/null +++ b/Timelapse/README.md @@ -0,0 +1,84 @@ +# Tensies build-progression timelapse + +A video that walks the entire git history of this app, oldest commit to newest, +playing a real round on each one so you can watch the game grow up — from the +first dark-themed board to the polished wood-table, 3D-dice, telemetry-era app. + +![tensies-gameplay-timelapse.mp4](tensies-gameplay-timelapse.mp4) + +`tensies-gameplay-timelapse.mp4` is the rendered result. + +## What it does + +For **every commit** on `HEAD` (oldest → newest) the pipeline: + +1. checks the commit out into a throwaway clone, +2. launches **just the app** — telemetry (Postgres/Prometheus/Grafana) is + monkey-patched to no-ops so even the telemetry-era commits boot with no + external services, +3. drives it with headless Chromium, and +4. screenshots, then stitches all the frames into one MP4. + +Two capture modes: + +| Mode | Frames/commit | What you see | +|---------|---------------|--------------| +| `arc` (default) | 8 | A full **2-player round**: loading → landing → lobby (both players) → fresh board → first roll → dice locking onto the target → **WIN** (winner's overlay), then the **opponent's losing view** of the same moment. | +| `board` | 1 | A single fresh game board per commit (fast, minimal). | + +The `arc` driver runs two browser contexts against one server: the host plays to +a win while the guest is left behind, so the same instant is captured as both a +win (10/10 locked) and a loss (e.g. 7/10). Everything is driven by **stable +selectors** (`#lobby-code`, `#code-input`, `#winner-overlay`) and **button text** +(`Create` / `Start` / `Roll`), which survive the markup and protocol drift across +the project's history. + +### Capture device & output + +Frames are captured in a real **mobile** browser context emulating an +**iPhone 17 Pro Max** — viewport 440×956 CSS pts at DPR 3 → native **1320×2868** +pixels, `is_mobile`/`has_touch`, iOS Safari UA — so the page renders its true +phone layout (not a desktop window scaled down). To target a different device, +edit the `DEVICE` dict at the top of `bin/play.py`. + +The video keeps that native resolution and is encoded for phones: H.264 **Main** ++ `yuv420p`, **CRF 26 / `veryslow` / `-tune stillimage`** (the frames are +stills), a silent AAC track, and `+faststart`. At native 3× the result is large +(~80 MB for the full history); raise CRF for a smaller file. + +## Usage + +```bash +# from anywhere inside the repo +Timelapse/bin/make_timelapse.sh # arc mode, 4 parallel workers +Timelapse/bin/make_timelapse.sh board 6 # quick board-only, 6 workers +Timelapse/bin/make_timelapse.sh arc 4 /path/to/repo +``` + +Output is written to `Timelapse/tensies-gameplay-timelapse.mp4`. Scratch clones +and raw frames live in `Timelapse/.work/` and `Timelapse/frames/` (gitignored). + +## Prerequisites + +```bash +pip install playwright imageio-ffmpeg +playwright install chromium +``` + +Notes: +- `imageio-ffmpeg` ships a static ffmpeg with libx264; the build script falls + back to a system `ffmpeg` on `PATH` if one exists. +- Playwright's pip package and its browser build must match. If + `playwright install chromium` can't download (offline/firewalled) but a + Chromium build already exists under `PLAYWRIGHT_BROWSERS_PATH`, pin the pip + package to the matching version instead (e.g. build 1194 ⇒ `playwright==1.56`). + +## Files + +| File | Role | +|------|------| +| `bin/make_timelapse.sh` | orchestrator — clones, parallel workers, then builds the video | +| `bin/launch.py` | boots a commit's `main:app`, telemetry neutralized | +| `bin/play.py` | 2-player gameplay arc → 8 frames (`arc` mode) | +| `bin/shoot.py` | single fresh-board screenshot (`board` mode) | +| `bin/build_video.py` | frames → MP4 (native res, mobile compression), holding the win/loss beats longer | diff --git a/Timelapse/bin/build_video.py b/Timelapse/bin/build_video.py new file mode 100755 index 00000000..948a94f1 --- /dev/null +++ b/Timelapse/bin/build_video.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Stitch frame_NNN_S.png files into the timelapse MP4. + +Usage: build_video.py [arc|board] + +In "arc" mode the win/loss beats (steps 6 & 7) are held longer so each round +reads as a little story. In "board" mode every frame gets an equal hold. + +ffmpeg is taken from PATH if present, otherwise from the imageio-ffmpeg +bundled static build (pip install imageio-ffmpeg). +""" +import glob +import os +import shutil +import subprocess +import sys + +FRAMES = sys.argv[1] if len(sys.argv) > 1 else "frames" +OUT = sys.argv[2] if len(sys.argv) > 2 else "tensies-timelapse.mp4" +MODE = sys.argv[3] if len(sys.argv) > 3 else "arc" + +# seconds each step (the trailing _S) is held on screen, in "arc" mode +ARC_DUR = {0: 0.16, 1: 0.16, 2: 0.28, 3: 0.22, 4: 0.24, 5: 0.24, 6: 0.55, 7: 0.55} +BOARD_DUR = 0.25 + + +def find_ffmpeg(): + exe = shutil.which("ffmpeg") + if exe: + return exe + try: + import imageio_ffmpeg + return imageio_ffmpeg.get_ffmpeg_exe() + except Exception: + sys.exit("ffmpeg not found: install it or `pip install imageio-ffmpeg`") + + +def main(): + frames = sorted(glob.glob(os.path.join(FRAMES, "frame_*.png"))) + if not frames: + sys.exit(f"no frames found in {FRAMES}") + + listfile = os.path.join(FRAMES, "_concat.txt") + with open(listfile, "w") as f: + f.write("ffconcat version 1.0\n") + for fr in frames: + if MODE == "arc": + step = int(fr.rsplit("_", 1)[1].split(".")[0]) + dur = ARC_DUR.get(step, 0.2) + else: + dur = BOARD_DUR + f.write(f"file '{os.path.abspath(fr)}'\n") + f.write(f"duration {dur}\n") + f.write(f"file '{os.path.abspath(frames[-1])}'\n") # honor last duration + + ff = find_ffmpeg() + print(f"{len(frames)} frames -> {OUT}") + # Keep the frames' native resolution (the capture already targets the device + # — e.g. iPhone 17 Pro Max at 1320x2868). Mobile-friendly compression: + # H.264 Main + yuv420p, CRF 26 / veryslow / stillimage (frames are stills), + # a silent AAC track (some mobile/social players reject audioless files), + # and +faststart for progressive streaming. + subprocess.run( + [ + ff, "-y", + "-f", "concat", "-safe", "0", "-i", listfile, + "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", + "-vf", "format=yuv420p,fps=30", + "-c:v", "libx264", "-profile:v", "main", "-crf", "26", + "-preset", "veryslow", "-tune", "stillimage", + "-c:a", "aac", "-b:a", "64k", "-shortest", + "-movflags", "+faststart", OUT, + ], + check=True, + ) + print("done") + + +if __name__ == "__main__": + main() diff --git a/Timelapse/bin/launch.py b/Timelapse/bin/launch.py new file mode 100755 index 00000000..7f1533d1 --- /dev/null +++ b/Timelapse/bin/launch.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Boot a Tensies commit's app as "just the app" on a given port. + +Telemetry (Postgres/Prometheus/Grafana) is monkey-patched to no-ops so that +*any* commit — including the telemetry-era ones — boots standalone with no +external services. The game board never needs telemetry. + +Run with the target commit's working tree as the current directory: + cd && python3 launch.py +""" +import os +import sys + +sys.path.insert(0, os.getcwd()) # import the checkout's main.py, not this dir + +# Neutralize telemetry if the commit has it (older commits don't — that's fine). +try: + import server.telemetry as tel + + async def _noop(*a, **k): + pass + + tel.start = _noop + tel.stop = _noop + tel.emit = lambda *a, **k: None +except Exception: + pass + +import uvicorn # noqa: E402 +import main # noqa: E402 + +port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000 +uvicorn.run(main.app, host="127.0.0.1", port=port, log_level="warning") diff --git a/Timelapse/bin/make_timelapse.sh b/Timelapse/bin/make_timelapse.sh new file mode 100755 index 00000000..c1dfb624 --- /dev/null +++ b/Timelapse/bin/make_timelapse.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Build the Tensies build-progression timelapse. +# +# For every commit (oldest -> newest) this checks out the commit into a private +# clone, launches just the app (telemetry neutralized), drives it to gameplay +# with headless Chromium, screenshots, and finally stitches every frame into an +# MP4. Work is split across N parallel workers, each with its own clone + port. +# +# Usage: +# bin/make_timelapse.sh [arc|board] [jobs] [repo_dir] +# +# arc (default) 2-player round per commit: loading -> lobby -> first roll +# -> dice locking -> WIN, then the opponent's losing view +# (8 frames/commit) +# board single fresh-board screenshot per commit (1 frame/commit) +# jobs parallel workers (default 4) +# repo_dir git repo to walk (default: repo containing this script) +# +# Prerequisites (see ../README.md): +# pip install playwright imageio-ffmpeg && playwright install chromium +set -uo pipefail + +MODE="${1:-arc}" +JOBS="${2:-4}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="${3:-$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)}" + +WORK="$SCRIPT_DIR/../.work" +FRAMES="$SCRIPT_DIR/../frames" +SHAS="$WORK/shas.txt" +OUT="$SCRIPT_DIR/../tensies-gameplay-timelapse.mp4" +BASEPORT=8200 + +rm -rf "$FRAMES"; mkdir -p "$FRAMES" "$WORK" + +# ordered oldest -> newest with zero-padded index +git -C "$REPO" log --reverse --format='%H' HEAD | awk '{printf "%03d %s\n", NR-1, $0}' > "$SHAS" +TOTAL=$(wc -l < "$SHAS") +echo "[timelapse] $TOTAL commits | mode=$MODE | jobs=$JOBS | repo=$REPO" + +# one shared-object clone per worker (cheap; isolates git state for parallelism) +for k in $(seq 0 $((JOBS - 1))); do + rm -rf "$WORK/clone$k" + git clone --quiet --shared "$REPO" "$WORK/clone$k" +done + +capture_one() { # clone port idx sha + local clone="$1" port="$2" idx="$3" sha="$4" pid ok=0 i + ( cd "$clone" && git checkout -q --force "$sha" 2>/dev/null && git clean -fdxq 2>/dev/null ) || return + ( cd "$clone" && exec python3 "$SCRIPT_DIR/launch.py" "$port" >"$WORK/srv_$port.log" 2>&1 ) & + pid=$! + for i in $(seq 1 60); do + [ "$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$port/" 2>/dev/null)" = "200" ] && { ok=1; break; } + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 + done + if [ "$ok" = 1 ]; then + if [ "$MODE" = board ]; then + python3 "$SCRIPT_DIR/shoot.py" "http://127.0.0.1:$port/" "$FRAMES/frame_${idx}_0.png" >/dev/null 2>&1 + else + python3 "$SCRIPT_DIR/play.py" "http://127.0.0.1:$port/" "$FRAMES" "$((10#$idx))" >/dev/null 2>&1 + fi + echo "[timelapse] $idx ${sha:0:7} captured" + else + echo "[timelapse] $idx ${sha:0:7} LAUNCH-FAIL ($(tail -1 "$WORK/srv_$port.log" 2>/dev/null | cut -c1-50))" + fi + kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null +} + +worker() { # worker_id (handles every commit where idx % JOBS == id) + local w="$1" port=$((BASEPORT + w)) clone="$WORK/clone$w" idx sha + while read -r idx sha; do + [ $((10#$idx % JOBS)) -eq "$w" ] && capture_one "$clone" "$port" "$idx" "$sha" + done < "$SHAS" +} + +for k in $(seq 0 $((JOBS - 1))); do worker "$k" & done +wait + +echo "[timelapse] stitching video..." +python3 "$SCRIPT_DIR/build_video.py" "$FRAMES" "$OUT" "$MODE" +echo "[timelapse] done -> $OUT" diff --git a/Timelapse/bin/play.py b/Timelapse/bin/play.py new file mode 100755 index 00000000..134f86c7 --- /dev/null +++ b/Timelapse/bin/play.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Drive a full 2-player round and capture a gameplay arc for one commit. + +Usage: play.py + +One Playwright process drives TWO browser contexts (host = winner, +guest = loser) against a single server, then writes 8 frames (always — +best-effort, never missing): + + frame__0.png loading / first paint + frame__1.png landing + frame__2.png lobby (2 players) + frame__3.png fresh board 0/10 + frame__4.png after first roll + frame__5.png mid progression + frame__6.png WIN (host's winner overlay) + frame__7.png LOSS (guest's view of the same moment) + +Everything is driven by stable selectors (#lobby-code, #code-input, +#winner-overlay) and button text, which survive the markup/protocol drift +across the project's history. +""" +import sys +import time +from playwright.sync_api import sync_playwright + +URL = sys.argv[1] +OUTDIR = sys.argv[2] +IDX = int(sys.argv[3]) +PREFIX = f"{OUTDIR}/frame_{IDX:03d}_" + +# iPhone 17 Pro Max: 440x956 CSS pts @ DPR 3 -> 1320x2868 native px, 6.9". +# Captured as a real mobile context so the page renders its true phone layout. +DEVICE = dict( + viewport={"width": 440, "height": 956}, + device_scale_factor=3, + is_mobile=True, + has_touch=True, + user_agent=( + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1" + ), +) + +JS_FIND_BTN = """(re) => { + const els=[...document.querySelectorAll('button, a, .btn, [role=button], [onclick]')]; + return els.find(e => e.offsetParent!==null && new RegExp(re,'i').test((e.textContent||'').trim())); +}""" + +JS_CODE = """() => { + const el=document.querySelector('#lobby-code'); + const t=el ? (el.textContent||'').trim() : ''; + return /^[A-Z]{5}$/.test(t) ? t : ''; +}""" + +JS_GAME_VISIBLE = """() => { + const g=document.querySelector('#game'); + return !!(g && getComputedStyle(g).display!=='none' && g.offsetParent!==null); +}""" + +JS_DO_ROLL = """() => { + const b=[...document.querySelectorAll('button, .btn, [onclick]')] + .find(x => x.offsetParent!==null && /roll/i.test((x.textContent||'').trim()) && !x.disabled); + if(!b) return false; + b.click(); + return true; +}""" + +JS_OVERLAY = """() => { + const el=document.querySelector('#winner-overlay'); + if(!el) return false; + const cs=getComputedStyle(el); + return el.open===true || (cs.display!=='none' && cs.visibility!=='hidden' && el.offsetWidth>0); +}""" + + +# Suppress focus rings / tap highlights — driving the game by clicks leaves a +# full-viewport .screen element focused, which paints an amber WebKit focus ring +# around the whole frame under the mobile/iOS-UA context. +NO_OUTLINE = """ +(() => { + const css = '*{outline:none !important;-webkit-tap-highlight-color:transparent !important}'; + const add = () => { + const s = document.createElement('style'); + s.textContent = css; + (document.head || document.documentElement).appendChild(s); + }; + if (document.head || document.documentElement) add(); + document.addEventListener('DOMContentLoaded', add); +})(); +""" + + +def snap(page, step): + try: + page.evaluate("() => { const a=document.activeElement; if (a && a.blur) a.blur(); }") + except Exception: + pass + try: + page.screenshot(path=f"{PREFIX}{step}.png") + except Exception: + pass + + +def waitfor(page, js, ms=6000, arg=None): + t0 = time.time() + while (time.time() - t0) * 1000 < ms: + try: + r = page.evaluate(js, arg) if arg is not None else page.evaluate(js) + if r: + return r + except Exception: + pass + time.sleep(0.12) + return None + + +def click_re(page, re, ms=5000): + if not waitfor(page, JS_FIND_BTN, ms, re): + return False + try: + page.evaluate( + "(re)=>{const els=[...document.querySelectorAll('button,a,.btn,[role=button],[onclick]')];" + "const e=els.find(x=>x.offsetParent!==null && new RegExp(re,'i').test((x.textContent||'').trim()));" + "if(e)e.click();}", + re, + ) + return True + except Exception: + return False + + +def main(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) + host_ctx = browser.new_context(**DEVICE) + guest_ctx = browser.new_context(**DEVICE) + host_ctx.add_init_script(NO_OUTLINE) + guest_ctx.add_init_script(NO_OUTLINE) + host = host_ctx.new_page() + guest = guest_ctx.new_page() + status = [] + + # 0. loading / first paint + try: + host.goto(URL, wait_until="commit", timeout=20000) + except Exception as e: + status.append(f"goto:{e}") + time.sleep(0.25) + snap(host, 0) + + # 1. landing + waitfor(host, JS_FIND_BTN, 6000, "create") + snap(host, 1) + + # create game + click_re(host, "create") + code = waitfor(host, JS_CODE, 8000) + status.append(f"code={code}") + + # guest joins + if code: + try: + guest.goto(URL, wait_until="commit", timeout=20000) + except Exception: + pass + waitfor(guest, JS_FIND_BTN, 6000, "create") + click_re(guest, "join") # reveal join form / screen + time.sleep(0.4) + try: + guest.fill("#code-input", code, timeout=4000) + except Exception: + pass + for nsel in ("#join-name-input", "#name-input"): + try: + if guest.locator(nsel).count() and guest.locator(nsel).first.is_visible(): + guest.fill(nsel, "Rival", timeout=1500) + break + except Exception: + pass + try: + guest.press("#code-input", "Enter") + except Exception: + pass + click_re(guest, "^join$|join game", 1500) # fallback submit + + # 2. lobby (host) — wait until 2nd player shows, then snap + time.sleep(1.2) + snap(host, 2) + + # start + click_re(host, "start") + waitfor(host, JS_GAME_VISIBLE, 8000) + waitfor(guest, JS_GAME_VISIBLE, 8000) + time.sleep(0.8) + snap(host, 3) # 3. fresh board + + # guest rolls a couple times so its board looks played (will still lose) + for _ in range(2): + try: + guest.evaluate(JS_DO_ROLL) + except Exception: + pass + time.sleep(0.4) + + # 4..6 host rolls until win + won = False + snapped4 = snapped5 = False + for i in range(70): + try: + host.evaluate(JS_DO_ROLL) + except Exception: + pass + time.sleep(0.55) # > MIN_ROLL_INTERVAL, lets the reveal play + if not snapped4: + snap(host, 4) + snapped4 = True # after first roll + if host.evaluate(JS_OVERLAY): + won = True + break + if not snapped5 and i >= 6: + snap(host, 5) + snapped5 = True # mid progression + if not snapped4: + snap(host, 4) + if not snapped5: + snap(host, 5) + + # 6. WIN overlay (host) + if not won: + won = bool(waitfor(host, JS_OVERLAY, 4000)) + snap(host, 6) + status.append("won" if won else "no-win") + + # 7. LOSS (guest) — same moment, loser's screen + waitfor(guest, JS_OVERLAY, 3500) + snap(guest, 7) + + browser.close() + print(f"{IDX:03d}\t{'OK' if won else 'PARTIAL'}\t{'; '.join(status)}") + sys.exit(0 if won else 2) + + +if __name__ == "__main__": + main() diff --git a/Timelapse/bin/shoot.py b/Timelapse/bin/shoot.py new file mode 100755 index 00000000..f02444a3 --- /dev/null +++ b/Timelapse/bin/shoot.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Load the app, drive to the game board, screenshot (simple "board" mode). + +Usage: shoot.py [width height] + +Drives by button *text* (Create / Start) because those labels are stable +across all 80 commits even as ids/markup changed. Always writes a screenshot +so a frame is never missing; exits 0 only if the board was actually reached. +""" +import sys +from playwright.sync_api import sync_playwright + +url = sys.argv[1] +out = sys.argv[2] +W = int(sys.argv[3]) if len(sys.argv) > 3 else 760 +H = int(sys.argv[4]) if len(sys.argv) > 4 else 1180 + +DRIVE = r""" +async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const vis = el => el && el.offsetParent !== null; + const findBtn = re => [...document.querySelectorAll('button, .btn, a, [onclick]')] + .find(e => vis(e) && re.test((e.textContent || '').trim())); + const wait = async (fn, ms=6000) => { + const t0 = Date.now(); + while (Date.now() - t0 < ms) { const r = fn(); if (r) return r; await sleep(120); } + return null; + }; + const create = await wait(() => findBtn(/create/i)); + if (!create) return 'no-create'; + create.click(); + const start = await wait(() => findBtn(/^start|start game/i)); + if (!start) return 'no-start'; + start.click(); + const board = await wait(() => { + const g = document.querySelector('#game'); + return g && getComputedStyle(g).display !== 'none' ? g : null; + }); + if (!board) return 'no-board'; + await sleep(900); + return 'board'; +} +""" + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) + ctx = browser.new_context(viewport={"width": W, "height": H}, device_scale_factor=2) + page = ctx.new_page() + status = "nav-fail" + try: + page.goto(url, wait_until="domcontentloaded", timeout=20000) + status = page.evaluate(DRIVE) + except Exception as e: + status = f"exc:{e}" + page.screenshot(path=out) + browser.close() + print(f"{status}\t{out}") + sys.exit(0 if status == "board" else 2) diff --git a/Timelapse/tensies-gameplay-timelapse.mp4 b/Timelapse/tensies-gameplay-timelapse.mp4 new file mode 100644 index 00000000..5394e4a3 Binary files /dev/null and b/Timelapse/tensies-gameplay-timelapse.mp4 differ