Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .claude/skills/timelapse/skill.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions Timelapse/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.work/
frames/
84 changes: 84 additions & 0 deletions Timelapse/README.md
Original file line number Diff line number Diff line change
@@ -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 |
80 changes: 80 additions & 0 deletions Timelapse/bin/build_video.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Stitch frame_NNN_S.png files into the timelapse MP4.

Usage: build_video.py <frames_dir> <out.mp4> [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()
33 changes: 33 additions & 0 deletions Timelapse/bin/launch.py
Original file line number Diff line number Diff line change
@@ -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 <checkout> && python3 launch.py <port>
"""
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")
82 changes: 82 additions & 0 deletions Timelapse/bin/make_timelapse.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading