SLOPSHOPPER

pumpt

Pixi works out above your prompt while Claude works: pixel-art exercises matched to how hard your prompt is, as rated by a locally hosted…

newbandguardpromptnetworktimer
v0.1.0MITupdated 2026-10-08Panoplos/Pumpt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pumpt
› fix the failing auth test and add an audit log call ● pumpt: pumpt: no verdict from torchcast-ai/torchcast-decision-12b at http://localhost:8080/v1/chat/completions (HTTP 0); keeping the guess ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ▣ Pixi doing arm circles Pixi · arm circles · sizing up the task… · 0/40 reps · 0:00 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ Pixi doing arm circles Pixi · arm circles · sizing up the task… · 0/40 reps · 0:00
README

<img src="docs/logo.png" alt="Pumpt!" width="240" />

You work out while Claude works.

A Claude Code mod that rates how hard your prompt is with a model on your own machine, then puts a pixel-art coach above your prompt doing the exercise to match. Do the reps with him until the turn is done.

Claude Code mod Node Local model Pixel art License

<!-- To get a real player here: open this README in GitHub's web editor, drag docs/pumpt-720p.mp4 into it, and paste the github.com/user-attachments/... URL it gives you on a line of its own in place of the image below. GitHub only renders a player for uploaded attachments. --> <a href="docs/pumpt-720p.mp4"><img src="docs/pumpt-preview.gif" alt="Pumpt! in 12 seconds: the title crashes in, you prompt Claude, and your coach starts squats above your prompt. Click for the full video." width="640" /></a>

Easy prompt → neck rolls. Big prompt → burpees. Finish the set → take a bow.

Watch the full 64-second video · the story of why Pixi built it.


The idea

Agents take minutes. You spend those minutes staring at a spinner, or scrolling. Meanwhile you have a perfectly good body sitting in that chair.

Pumpt! reads your prompt, estimates how long the turn will take, and gives you a set that fits: a typo fix gets you neck rolls, a service migration gets you burpees. Pixi, your pixel-art coach, does the reps above your prompt and counts them; you do them beside the desk. When Claude is done, so are you. Finish the set first and you take a bow.

Everything runs on your machine. The difficulty comes from torchcast-decision-12b, a System One decision model served locally, and every frame of the coach is computed by a small procedural rig, not generated by an image model.

The workouts

| | | | | | :-: | :-: | :-: | :-: | | <img src="docs/gifs/neck-roll.gif" width="192" /><br>1 · Neck rolls<br><sub>a set of 20</sub> | <img src="docs/gifs/arm-circles.gif" width="192" /><br>2 · Arm circles<br><sub>a set of 40</sub> | <img src="docs/gifs/jumping-jacks.gif" width="192" /><br>3 · Jumping jacks<br><sub>a set of 50</sub> | <img src="docs/gifs/sit-ups.gif" width="192" /><br>4 · Sit-ups<br><sub>a set of 30</sub> | | <img src="docs/gifs/squats.gif" width="192" /><br>5 · Squats<br><sub>a set of 25</sub> | <img src="docs/gifs/push-ups.gif" width="192" /><br>6 · Push-ups<br><sub>a set of 20</sub> | <img src="docs/gifs/burpees.gif" width="192" /><br>7 · Burpees<br><sub>a set of 12</sub> | <img src="docs/gifs/outro.gif" width="192" /><br>The bow<br><sub>after any set</sub> |

Hit Enter, and your exercise's name pops onto the screen with your coach already in position. Get up and match him, rep for rep. Finish the set before Claude finishes the turn and you get a GOOD JOB! and a bow; if Claude beats you to it, the band clears and you sit back down. Either way, you moved.

Quick start

You need Claude Code, Node 18+, and a terminal that can draw images (Ghostty, kitty, WezTerm or iTerm2; anything else gets a half-block fallback).

git clone https://github.com/Panoplos/Pumpt.git
cd Pumpt
sh tools/launch.sh              # starts the MLX model server if needed, then Claude Code with the mod

Already have a model server running? Point Claude Code at the mod directly:

claude --plugin-dir /path/to/Pumpt

Then type a prompt. Your coach appears in a strip just above it when the turn starts (the transcript shifts up to make room, and gets it back when he leaves), and leaves when Claude finishes, or when your set is done and the bow is taken. The line under him shows your exercise, the model's verdict (difficulty 4/7 · ~4 min), your reps (12/30 reps) and a clock. <kbd>Ctrl</kbd>+<kbd>X</kbd> <kbd>Ctrl</kbd>+<kbd>A</kbd> collapses the band if you need the rows back.

No model yet? node tools/mock-model.mjs stands in for it (it rates by prompt length), and node tools/player.mjs previews the animations without Claude Code at all (<kbd>←</kbd>/<kbd>→</kbd> switch exercise, <kbd>q</kbd> quits).

How it works

 your prompt ──► guess from length + wording ──► the title balloons in, pops: your set starts
   │
   └─────► System One scores 1–7 (~0.4 s) ──► the title changes its name mid-zoom if the verdict differs
                                                         │
 turn.complete ◄───────────────────────────────┬─────────┘ the coach leaves the band
 you finish the set ──► a leap, a landing, GOOD JOB! ┘

Pumpt! is a Claude Code mod: function hooks that run inside Claude Code.

| Event | What it does | | :- | :- | | prompt.submit | Guesses the difficulty, asks the local model, starts the intro in the band | | turn.complete | Clears the band (a subagent's turn ending doesn't count) | | ui.render (AbovePrompt) | Draws the current frame and the status line, sized to the band; yields to a survey | | $.clock.every | Repaints the sprite in place at 12 fps and runs the set: intro → reps → outro → an empty band |

The band is Claude Code's strip directly above the prompt: it takes real rows (14 by default, plus the status line), so the transcript shifts up by that much while you work out and comes back when the band empties. Inside it the sprite's transparent pixels show your terminal background, and the half-block fallback leaves empty cells in the terminal's own colour for the same effect.

The estimate is fire-and-forget. If the model is down, you still get a set from the guess, and a stale verdict for an earlier prompt never overrides a newer one.

The exercise chooser: a System One model

torchcast-ai/torchcast-decision-12b is not a chat model. It is a typed decision model (a Gemma 4 12B fine-tune): each decision is one forward pass that reads the probability of every option letter at the answer slot. Pumpt! asks it one score question over seven levels and reads a distribution, which gives your exercise (the likeliest level), a confidence and the expected minutes (difficulty 4/7 (72%) · ~4 min). The minutes are the geometric expectation over a table that runs from half a minute at level 1 to an hour at level 7, so a few percent of probability on the top level cannot swamp a trivial prompt's estimate.

Two ways to reach it, chosen by the URL (/config → LLM endpoint, or the env below):

  • …/v1/systemone: the authors' shim (vLLM on an NVIDIA box). The shim does the readout.
  • Any OpenAI-compatible chat endpoint that returns logprobs: mlx_lm.server, llama-server, vLLM. The mod renders the shim's prompt scaffolding, asks for one token with top_logprobs, and sums the mass per letter itself. This is the path for a Mac.
PUMPT_LLM_URL=http://localhost:8080/v1/chat/completions \
PUMPT_LLM_MODEL=torchcast-decision-12b \
claude --plugin-dir /path/to/Pumpt

Running it on a Mac with MLX

No MLX conversion is published, but mlx-lm (≥ 0.32) loads this gemma4_unified checkpoint. The download is 22 GB; the 8-bit weights come to ~12.5 GB and keep the readout's calibration closer than 4-bit would.

sh tools/mlx-convert.sh        # venv + mlx-lm, download, patch the config, convert
~/.pumpt-mlx/bin/mlx_lm.server --model ~/models/torchcast-decision-12b-8bit --port 8080

A plain mlx_lm.convert --hf-path torchcast-ai/torchcast-decision-12b fails with Expected shape (4096, 3840) but received shape (512, 3840): the checkpoint's config.json omits num_global_key_value_heads (1) and global_head_dim (512), which Gemma 4's full-attention layers need and which mlx-lm otherwise guesses as 8 heads. The script stages the files with a patched config and converts from there.

Alternative with no conversion: brew install llama.cpp, then llama-server -hf mradermacher/torchcast-decision-12b-GGUF:Q8_0 --port 8080 (11.8 GB).

Latency. A decision costs ~100 ms fixed plus ~1.1 ms per prompt token (~900 tokens/s of prefill). The mod warms the server at session start and keeps context short, so a fresh prompt is rated in about half a second. Verdicts measured on an M5 Pro with the 8-bit weights are monotonic across the scale.

Check it end to end:

node tools/ask.mjs "refactor the auth module across three services"

prints the distribution over the seven levels, the verdict and the response time.

It tunes itself

Every turn the mod records one JSON file under ~/.pumpt/decisions/ (switch it off with Record decisions in /config or PUMPT_LOG_DIR=off): the prompt, the recent context, the verdict, what the turn really cost (measured duration, tool calls, tokens), and which renderer drew the coach with any blit the terminal refused, so a fallback to cells can be diagnosed after the fact. A turn's real duration, bucketed at 0.75, 1.5, 3.5, 8, 20 and 45 minutes, is the target level. The minutes table itself is refit from the logged durations (the median turn at each level, shrunk toward the current table) and judged on the minutes log-error, since it cannot move the level distribution.

hooks/config.js holds every knob the model's protocol leaves open: the question's wording, the level descriptions, minutes per level, context size, the decision rule. tools/eval/hillclimb.mjs climbs them:

node tools/eval/hillclimb.mjs                      # dry run on the logged turns
node tools/eval/hillclimb.mjs --mutate 4 --apply   # let Claude propose wordings; write the winner

The fitness is the ranked probability score of the level distribution against the target, a proper scoring rule, so it rewards mass near the truth and penalises confidence that isn't earned. Candidates are accepted only when they win on the whole set and on three of four folds, and every attempt lands in ~/.pumpt/ledger.jsonl. Readouts are cached, so numeric knobs are free to try.

tools/eval/context.mjs scores context policies against hand-labelled prompts. On the author's own transcripts the policy the mod ships (the last spoken exchange, 300 characters each) agreed with the labels at ρ ≈ 0.8, with 75–81 % of verdicts within one level.

The animation

Every frame is computed, so position, size and timing are exact across frames and exercises.

  • The head is lifted pixel for pixel from the logo (tools/pixi-logo.png, an 8 px grid) into hooks/head.js. It only moves by transforms that keep it crisp: whole-row shears, a RotSprite-style rotation, and a one-pixel hair-cap bounce. Expressions (eyes, mouths, sweat, sparks, puffs) are overlays in the logo's own palette, keyed to each exercise's effort.
  • The body is a jointed rig (torso, two-bone arms and legs) rasterised at 128 × 128 with a 1 px outline, a shade band and a rim light. The tunic has a V-neck trim, sleeve hems, a buckled belt and a hip pouch; hands are fists, open hands or flat palms depending on the pose; boots have a shaft, toe cap and sole line. Planted hands and feet are IK targets, so floor contact is exact.
  • Clips are functions of time with eased keyframes, anticipation, squash on landings and holds. 10–36 frames per cycle at 12 fps. Each exercise carries the size of a set (12 burpees, 40 arm circles); when the reps are done the outro plays and the band empties.
  • The titles are a 5×7 bitmap font drawn the arcade way: scaled, lit along the top, extruded two pixels down-right and outlined, so "SQUATS" reads as a slab. The intro zooms it in with an overshoot, swells it for two frames and pops it: a white-silhouette flash, a shockwave ring and a burst of confetti on their own little physics (drag, gravity, a tumble), with the coach revealed in the exercise's first pose so the workout cuts in without a seam. The outro is a leap with sparks at the apex, a squashed landing into a wide "ta-da" squat, GOOD JOB! slamming in overhead and confetti raining through the hold.

Two renderers are picked at run time. True pixels (Image) draws the pre-rendered 1024 × 1024 PNGs in assets/frames, scaled by the terminal itself. If the terminal keeps refusing the picture for a second, the mod falls back to half-block cells (Raster), rendering the same frames into ▀/▄ cells, and the status line ends in · cells; the next prompt tries the PNGs again. Force one with PUMPT_RENDERER=image|raster.

node tools/render.mjs                       # frames → assets/frames (and the checks)
node tools/render.mjs --sheets /tmp/sheets  # plus a contact sheet per clip
node tools/make-gifs.mjs                    # the GIFs in this README → docs/gifs
node tools/make-gifs.mjs --set squats       # one GIF of a whole set: intro, two reps, the bow
node tools/extract-head.mjs                 # regenerate hooks/head.js from the logo

render.mjs fails if any IK target is out of reach or a drawing touches the canvas edge, so a clip edit cannot ship a frame where a foot floats or an ear is cut off.

Configuration

Set these in Claude Code's /config:

| Setting | Default | What it does | | :- | :- | :- | | LLM endpoint | http://localhost:8080/v1/chat/completions | Where the model lives | | LLM model | torchcast-ai/torchcast-decision-12b | Model name sent to it | | Sprite height | 14 | Rows the sprite takes above the prompt (capped at what the band can show whole) | | Status line | on | The line under the coach: exercise, verdict, reps, clock. Off gives the sprite that row | | Cell aspect ratio | 0.5 | Cell width ÷ height, so the sprite comes out square (0.43 for Iosevka) | | Record decisions | on | Keep verdicts and turn costs for tuning |

Project layout

hooks/            the mod: register.js (events + UI), pixi.js (rig + clips), head.js, estimate.js, config.js
assets/frames/    pre-rendered 1024 px frames per clip: each exercise, its intro, the outro
docs/gifs/        README animations
tools/            render, player, model checks, MLX conversion, eval + hill-climbing
tests/            the mod driven with stubbed network and UI

Verify

claude plugin validate .
claude plugin test

The tests drive the mod with stubbed network and UI: a prompt puts a sprite of the right size in the band, opening on the intro, and it never outgrows a short band; the image path names the right frame for a verdict; difficulty 7 is burpees; a whole set of push-ups runs through the intro, 20 reps, the bow and an empty band, frame-exact; the raster renderer packs half-block cells on the terminal's own background; a dead model leaves the guess in place; a late verdict for an older prompt is ignored; a survey gets the band; a subagent finishing doesn't end the workout; turn.complete clears the band.

License

MIT

Source 5 files
hooks/register.js 329 lines
1// pumpt — Pixi works out while Claude works.
2//
3// prompt.submit → ask the local model how hard the task is → in the band above
4// the prompt the exercise's name balloons in and pops to reveal Pixi, who
5// works the set → turn.complete clears the band, or the set ends first and
6// Pixi takes a bow (the outro) before leaving.
7//
8// The band is drawn straight over the terminal's own background, no frame and
9// no dock: the sprite's transparent pixels show whatever is behind them.
10//
11// Every frame comes from hooks/pixi.js. Terminals that draw pictures get the
12// pre-rendered PNGs under assets/frames (true pixels, read by the terminal
13// itself); the rest get the same drawing rendered live into half-block cells.
14import { FPS, OUTRO, READY, byDifficulty, framePath, halfBlocks, introOf, renderFrame } from './pixi.js'
15import { contextOf, estimateWith, formatMinutes, resolveModel } from './estimate.js'
16
17const KEY = 'pixi'
18const FRAME_MS = Math.round(1000 / FPS)
19const WARM_UP = 'warm up: fix the typo in the readme'
20const LOG_KEEP = { exchanges: 2, chars: 1000 } // recent messages kept with a logged decision, for re-rendering other context policies
21
22let llmUrl = ''
23let llmModel = ''
24let renderer = 'auto' // 'auto' | 'image' | 'raster' (PUMPT_RENDERER)
25let imageDenied = false // the PNG frames stayed refused for a second: cells for the rest of this workout (the next prompt tries them again)
26let imageDenies = 0 // refusals of the picture in a row: the first ones are the terminal still putting it up
27let workout = null // see newWorkout
28let mounted = null // what the last render drew: { requestId, kind, columns, rows }
29let remountIn = 0 // ticks until a refused blit's site is drawn again (a second, not every frame)
30let ticks = 0 // frames since the session started: the status line redraws once a second
31let seq = 0 // prompts seen; a stale estimate must not overwrite a newer one
32let logDir = '' // where decisions and their outcomes are recorded ('' = off)
33let turnLog = null // the turn being recorded: { ts, cwd, prompt, messages, guess, verdict, latencyMs, toolCalls, render }
34let cwd = ''
35
36// Until the model answers: length, nudged by what the prompt asks for.
37function guessDifficulty(text) {
38  const n = text.trim().length
39  let d = n < 40 ? 1 : n < 120 ? 2 : n < 300 ? 3 : n < 700 ? 4 : 5
40  if (/\b(refactor|migrat|rewrite|redesign|architect|implement|build|integrat)/i.test(text)) d += 1
41  if (/\b(typo|rename|quick|small|minor|just)\b/i.test(text)) d -= 1
42  return Math.min(7, Math.max(1, d))
43}
44
45// The recent conversation, as $.session.messages() gives it; [] when unreadable.
46async function recentMessages($) {
47  try {
48    return await $.session.messages()
49  } catch {
50    return []
51  }
52}
53
54async function estimate($, text, messages = []) {
55  // Context is the last spoken exchange (hooks/config.js says how much): the
56  // person's previous prompt and the answer it got. Kept short: every prompt
57  // token costs about a millisecond of prefill.
58  const post = (url, body) => $.http.fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
59  return estimateWith(post, llmUrl, llmModel, text, contextOf(messages))
60}
61
62// Records a finished turn next to its decision, one file per turn, so the
63// estimator can be scored and tuned on what prompts really cost (tools/eval).
64async function recordTurn($, e) {
65  const t = turnLog
66  turnLog = null
67  if (!logDir || !t) return
68  const record = {
69    version: 1,
70    ts: t.ts,
71    cwd: t.cwd,
72    prompt: t.prompt,
73    messages: t.messages,
74    guess: t.guess,
75    verdict: t.verdict,
76    latencyMs: t.latencyMs,
77    model: llmModel,
78    url: llmUrl,
79    render: t.render,
80    actual: {
81      seconds: Math.round((e.durationMs ?? 0) / 100) / 10,
82      reason: e.reason,
83      toolCalls: t.toolCalls,
84      inputTokens: e.usage?.input_tokens ?? e.usage?.inputTokens ?? null,
85      outputTokens: e.usage?.output_tokens ?? e.usage?.outputTokens ?? null,
86    },
87  }
88  try {
89    await $.fs.write(`${logDir}/${t.ts.replace(/[:.]/g, '-')}.json`, JSON.stringify(record))
90  } catch (err) {
91    $.ui.log(`pumpt: could not record the turn (${err?.message ?? err})`, { to: 'debug' })
92  }
93}
94
95// --- the workout ------------------------------------------------------------
96// Phases: 'ready' ("READY?" while the verdict is on its way, so the title
97// that follows names the right exercise), 'intro' (the name balloons in and
98// pops), 'work' (the exercise cycles, counting reps), 'outro' (the set is
99// done: a bow), 'done' (the band is empty until the turn ends). `clip` and
100// `frame` are what is on screen.
101
102const READY_MIN = 8 // frames: long enough to read, even when the verdict is instant
103const READY_MAX = 3 * FPS // frames: a slow model gets this long, then the guess goes ahead
104
105function newWorkout(ex, mine, guess) {
106  return { ex, phase: 'ready', clip: READY, frame: 0, readyFrames: 0, workFrames: 0, reps: 0, startedAt: Date.now(), seq: mine, pending: true, result: null, guess }
107}
108
109/** The verdict picked another exercise: swap it in without breaking the flow. */
110function switchExercise(w, ex) {
111  if (w.phase === 'ready') w.ex = ex // the title has not started: it will carry the right name
112  else if (w.phase === 'intro') Object.assign(w, { ex, clip: introOf(ex) }) // the title changes its name mid-zoom
113  else if (w.phase === 'work') Object.assign(w, { ex, clip: ex, frame: 0, workFrames: 0, reps: 0, startedAt: Date.now() })
114  // in the outro, or done: the set Pixi did stands
115}
116
117/** One frame on. Returns false once there is nothing more to show. */
118function advance(w) {
119  w.frame++
120  if (w.phase === 'ready') {
121    w.readyFrames++
122    w.frame %= READY.frames
123    if (w.readyFrames >= READY_MIN && (!w.pending || w.readyFrames >= READY_MAX)) Object.assign(w, { phase: 'intro', clip: introOf(w.ex), frame: 0 })
124  } else if (w.phase === 'intro' && w.frame >= w.clip.frames) Object.assign(w, { phase: 'work', clip: w.ex, frame: 0, workFrames: 0, reps: 0 })
125  else if (w.phase === 'work') {
126    w.workFrames++
127    w.reps = Math.floor(w.workFrames / w.ex.frames)
128    w.frame %= w.ex.frames
129    if (w.reps >= w.ex.reps) Object.assign(w, { phase: 'outro', clip: OUTRO, frame: 0 })
130  } else if (w.phase === 'outro' && w.frame >= OUTRO.frames) w.phase = 'done'
131  return w.phase !== 'done'
132}
133
134function statusLine(kind) {
135  const w = workout
136  const seconds = Math.max(0, (Date.now() - w.startedAt) / 1000)
137  const clock = `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, '0')}`
138  const verdict = w.pending
139    ? 'sizing up the task…'
140    : w.result
141      ? `difficulty ${w.result.difficulty}/7 (${Math.round(w.result.confidence * 100)}%) · ${formatMinutes(w.result.minutes)}`
142      : `difficulty ~${w.guess}/7`
143  const reps = w.phase === 'outro' ? `${w.ex.reps}/${w.ex.reps} reps · good job!` : `${w.reps}/${w.ex.reps} reps`
144  // `cells` flags the half-block fallback: the terminal refused the PNG frames
145  return `Pixi · ${w.ex.name} · ${verdict} · ${reps} · ${clock}${kind === 'raster' ? ' · cells' : ''}`
146}
147
148const DEFAULT = 0x01000000 // the terminal's own color
149function rasterCells(clip, frame, columns, rows) {
150  const cells = halfBlocks(renderFrame(clip, frame).canvas, columns, rows)
151  const words = new Uint32Array(columns * rows * 3)
152  cells.forEach(([ch, fg, bg], i) => words.set([ch, fg < 0 ? DEFAULT : fg, bg < 0 ? DEFAULT : bg], i * 3))
153  return new Uint8Array(words.buffer).toBase64()
154}
155
156const useImage = () => renderer === 'image' || (renderer === 'auto' && !imageDenied)
157
158// A refusal that is about the picture itself ("an Image drawing its alt there:
159// no placeholder images, or a file this terminal cannot read"), not about the
160// site: "no Image ... is mounted" after a re-render, the band collapsed, a
161// survey holding it say nothing about what the terminal can draw.
162const cannotDrawImages = (deny) => /its alt|placeholder|cannot read/i.test(deny)
163
164export function register(on, options) {
165  llmUrl = options.llm_url
166  llmModel = options.llm_model
167
168  on('session.start', async ($, e, next) => {
169    cwd = e.cwd ?? ''
170    llmUrl = (await $.env.get('PUMPT_LLM_URL')) || llmUrl
171    llmModel = (await $.env.get('PUMPT_LLM_MODEL')) || llmModel
172    const r = (await $.env.get('PUMPT_RENDERER')) || 'auto'
173    renderer = r === 'image' || r === 'raster' ? r : 'auto'
174    const home = (await $.env.get('HOME')) || ''
175    const logEnv = await $.env.get('PUMPT_LOG_DIR')
176    logDir = options.log_decisions === false || logEnv === 'off' ? '' : logEnv || (home ? `${home}/.pumpt/decisions` : '')
177    // Ask the server what it serves, so the model name matches whatever it loaded.
178    resolveModel((url) => $.http.fetch(url, { method: 'GET' }), llmUrl, llmModel).then((name) => {
179      if (name !== llmModel) $.ui.log(`pumpt: the server at ${llmUrl} serves ${name}; using that instead of ${llmModel}`, { to: 'debug' })
180      llmModel = name
181      // Warm the server up: its first decision compiles kernels and takes
182      // seconds, and the system prompt's prefix then sits in its cache.
183      estimate($, WARM_UP).catch(() => {})
184    })
185
186    // The animation: the mounted sprite is repainted in place at the frame
187    // rate; the status line (reps, clock) redraws once a second.
188    $.clock.every(FRAME_MS, async () => {
189      if (!workout || workout.phase === 'done') return
190      ticks++
191      if (!mounted) {
192        if (remountIn > 0 && --remountIn === 0) $.ui.invalidate('ui.render')
193        return
194      }
195      if (!advance(workout)) {
196        // the set is done and the bow taken: the band empties until the turn ends
197        mounted = null
198        $.ui.invalidate('ui.render')
199        return
200      }
201      const { clip, frame } = workout
202      const { requestId, kind, columns, rows } = mounted
203      const res =
204        kind === 'image'
205          ? await $.ui.blit({ requestId, key: KEY, source: { file: `${$.plugin.root}/${framePath(clip, frame)}`, format: 'png' } })
206          : await $.ui.blit({ requestId, key: KEY, cells: rasterCells(clip, frame, columns, rows) })
207      if (res?.deny) {
208        if (turnLog?.render) turnLog.render.denies[`${kind}: ${res.deny}`] = (turnLog.render.denies[`${kind}: ${res.deny}`] ?? 0) + 1
209        if (kind === 'image' && cannotDrawImages(res.deny)) {
210          // The picture is not up yet: right after a render the terminal is
211          // still placing it, and the blit lands at the next frame. Only a
212          // second of this means it never will.
213          if (++imageDenies < FPS) return
214          imageDenied = true
215          $.ui.log(`pumpt: this terminal cannot show the PNG frames (${res.deny}); drawing cells instead`, { to: 'debug' })
216          mounted = null
217          remountIn = 1
218          return
219        }
220        // The site itself refused (the band collapsed, a survey holding it):
221        // stop blitting, and try drawing again in a second.
222        mounted = null
223        remountIn = FPS
224      } else {
225        imageDenies = 0
226        if (turnLog?.render) turnLog.render.ok++
227        if (ticks % FPS === 0 && options.show_status !== false) $.ui.invalidate('ui.render')
228      }
229    })
230    return next(e)
231  })
232
233  on('prompt.submit', async ($, e, next) => {
234    const mine = ++seq
235    const guess = guessDifficulty(e.text)
236    imageDenied = false
237    imageDenies = 0
238    workout = newWorkout(byDifficulty(guess), mine, guess)
239    const messages = await recentMessages($)
240    const kept = messages.filter((m) => m?.text?.trim() && (m.role === 'user' || m.role === 'assistant'))
241    turnLog = {
242      ts: new Date().toISOString(),
243      cwd,
244      prompt: e.text,
245      messages: kept.slice(-2 * LOG_KEEP.exchanges).map((m) => ({ role: m.role, text: m.text.trim().slice(0, LOG_KEEP.chars) })),
246      guess,
247      verdict: null,
248      latencyMs: null,
249      toolCalls: 0,
250      render: { kinds: [], ok: 0, denies: {} }, // which renderer drew, and what the terminal refused: diagnosing a fallback after the fact
251    }
252    const asked = Date.now()
253    // Fire and forget: the hook must not wait on the model.
254    estimate($, e.text, messages)
255      .then((result) => {
256        if (turnLog?.ts && workout?.seq === mine) Object.assign(turnLog, { verdict: result, latencyMs: Date.now() - asked })
257        if (workout?.seq !== mine) return // a newer prompt took over
258        workout.result = result
259        const ex = byDifficulty(result.difficulty)
260        if (ex !== workout.ex) switchExercise(workout, ex)
261      })
262      .catch((err) => {
263        if (workout?.seq !== mine) return
264        $.ui.log(`pumpt: no verdict from ${llmModel} at ${llmUrl} (${err?.message ?? err}); keeping the guess`, { to: 'debug' })
265      })
266      .finally(() => {
267        if (workout?.seq !== mine) return
268        workout.pending = false
269        $.ui.invalidate('ui.render')
270      })
271
272    // Pixi steps into the band above the prompt.
273    $.ui.invalidate('ui.render')
274    return next(e)
275  })
276
277  // Tool calls of the main turn, one of the measures of what a prompt cost.
278  on('tool.call', async ($, e, next) => {
279    if (turnLog && !e.agentId) turnLog.toolCalls++
280    return next(e)
281  })
282
283  // The main turn is done: Pixi leaves the band. A subagent's turn ending
284  // mid-task is not the end of the workout.
285  on('turn.complete', async ($, e, next) => {
286    if (e.agentId) return next(e)
287    if (workout) {
288      workout = null
289      mounted = null
290      $.ui.invalidate('ui.render')
291    }
292    await recordTurn($, e)
293    return next(e)
294  })
295
296  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
297    // The band is one shared instance: with nothing to show, or a survey
298    // holding it, pass it on untouched.
299    if (!workout || workout.phase === 'done' || e.props.hasSurvey) {
300      mounted = null
301      if (workout && workout.phase !== 'done') remountIn = FPS // come back in a second, in case the band is not redrawn on its own
302      return next(e)
303    }
304    if (e.surface !== 'terminal') return next(e)
305    const { Box, Text, Raster, Image } = $.ui.resolve(e)
306    const w = workout
307    // A square box for the sprite (square in pixels given the cell's aspect
308    // ratio) over one row for the status line if it is on, never taller than
309    // the band shows whole: a sprite that scrolls is no sprite.
310    const showStatus = options.show_status !== false
311    const bodyColumns = e.props.bodyColumns ?? e.viewport?.columns ?? 40
312    const maxRows = e.props.maxRows ?? 20
313    const aspect = options.cell_aspect > 0 ? options.cell_aspect : 0.5
314    const wanted = Math.round(options.sprite_rows ?? 14)
315    const rows = Math.max(2, Math.min(maxRows - (showStatus ? 1 : 0), wanted, Math.floor(bodyColumns * aspect)))
316    const columns = Math.min(bodyColumns, Math.round(rows / aspect))
317    const kind = useImage() ? 'image' : 'raster'
318    mounted = { requestId: e.requestId, kind, columns, rows }
319    if (turnLog?.render && turnLog.render.kinds.at(-1) !== kind) turnLog.render.kinds.push(kind)
320    const sprite =
321      kind === 'image'
322        ? Image({ key: KEY, source: { file: `${$.plugin.root}/${framePath(w.clip, w.frame)}`, format: 'png' }, columns, rows, alt: `Pixi doing ${w.ex.name}` })
323        : Raster({ key: KEY, columns, rows, cells: rasterCells(w.clip, w.frame, columns, rows) })
324    // At the right end, by the prompt, out of the way of what Claude writes.
325    const children = showStatus ? [sprite, Text({ children: [statusLine(kind)], dimColor: true })] : [sprite]
326    return Box({ flexDirection: 'column', alignItems: 'flex-end', children })
327  })
328}
329
hooks/pixi.js 1195 lines
1// Pixi's rig, the seven exercise clips and the pixel-art rasterizer.
2//
3// Pure ES module (no Node, no DOM): shared by the mod (register.js), the frame
4// builder (tools/render.mjs) and the preview player (tools/player.mjs). Every
5// frame is drawn from a pose, so position, size and timing never drift: the
6// head is the logo's own pixels (hooks/head.js), moved by transforms that keep
7// them crisp, and the body is a jointed rig rasterized with a 1 px outline,
8// a shade band, a rim light and the logo's palette at the logical resolution.
9import { HEAD_ROWS, HEAD_PALETTE } from './head.js'
10
11export const SIZE = 128 // logical canvas edge, px
12export const FPS = 12
13export const FLOOR = 118 // the ground: soles rest on this row
14
15// --- palette -----------------------------------------------------------------
16
17const hex = (s) => parseInt(s.slice(1), 16)
18const OUT = hex('#230438') // the logo's outline color, used on every part
19const C = {
20  skin: hex('#945276'),
21  tunic: hex('#3b2d7a'),
22  tunicLight: hex('#5a47a3'),
23  pants: hex('#241a44'),
24  boot: hex('#7a4a33'),
25  bootLight: hex('#a3684a'),
26  gold: hex('#f1be58'),
27  goldShade: hex('#b8842a'),
28  drop: hex('#69a8bd'),
29  light: hex('#fdfacc'),
30  spark: hex('#fed363'),
31}
32const mul = (rgb, k) => {
33  const c = (v) => Math.min(255, Math.round(v * k))
34  return (c((rgb >> 16) & 255) << 16) | (c((rgb >> 8) & 255) << 8) | c(rgb & 255)
35}
36const dim = (rgb, k) => mul(rgb, k)
37// the rim light: lighter and a touch warmer than the base
38const lighten = (rgb) => {
39  const c = (v, k) => Math.min(255, Math.round(v * k + 16))
40  return (c((rgb >> 16) & 255, 1.25) << 16) | (c((rgb >> 8) & 255, 1.2) << 8) | c(rgb & 255, 1.15)
41}
42
43// --- the head bitmap ---------------------------------------------------------
44
45const HW = HEAD_ROWS[0].length // 50
46const HH = HEAD_ROWS.length // 44
47const NECK = [25, 40] // the pivot: where the head meets the torso
48const CAP_ROWS = 13 // rows 0..12 are hair only; they bounce on their own
49const HEAD_CHARS = Object.keys(HEAD_PALETTE)
50const HEAD_RGB = HEAD_CHARS.map((k) => hex(HEAD_PALETTE[k]))
51const charIndex = (ch) => (ch === '.' ? 0 : HEAD_CHARS.indexOf(ch) + 1)
52const BASE_HEAD = new Uint8Array(HW * HH)
53HEAD_ROWS.forEach((row, y) => {
54  for (let x = 0; x < HW; x++) BASE_HEAD[y * HW + x] = charIndex(row[x])
55})
56
57// Expression overlays: patches in the head's own palette letters, '.' = keep.
58// Coordinates are head-local; 'S' is skin, 'O' outline, 'J' highlight.
59const EYES = {
60  open: null,
61  // a relaxed blink: lids down, one dark line per eye
62  closed: { x: 17, y: 28, rows: ['SSSS........SSSS', 'OOOO........OOOO', 'SSSS........SSSS'] },
63  // happy ^ ^
64  happy: { x: 17, y: 28, rows: ['.OO..........OO.', 'O..O........O..O', 'SSSS........SSSS'] },
65  // effort: upper lid pushed down, eye two rows tall
66  squint: { x: 17, y: 28, rows: ['SSSS........SSSS'] },
67  // surprise / joy: eye a row taller
68  wide: { x: 17, y: 27, rows: ['GJUV........VUJG'] },
69  // the pupils look down (a chin tuck, the floor of a plank)
70  down: { x: 17, y: 28, rows: ['SSSS........SSSS', 'GJUV........VUJG', 'GJGQ........QGJG'] },
71}
72const MOUTHS = {
73  smirk: null,
74  // a small o: an exhale
75  o: { x: 20, y: 35, rows: ['SSSSSSSSSSS', 'SSSSOOOSSSS', 'SSSOPPPOSSS', 'SSSSOOOSSSS'] },
76  // a big breath, a shout at the apex
77  // the tongue sits at the bottom of the mouth, never against the top lip
78  open: { x: 20, y: 34, rows: ['SSSSSSSSSS.', 'SSSOOOOOSSS', 'SSOPPPPPOSS', 'SSOPKKKPOSS', 'SSSOOOOOSSS'] },
79  // gritted teeth: effort
80  // gritted teeth two rows tall: one-row teeth vanish under a few degrees of head rotation
81  // the row above each big mouth clears the smirk's two dark pixels, which would read as a fat upper lip
82  grit: { x: 20, y: 34, rows: ['SSSSSSSSSS.', 'SOOOOOOOOOS', 'SOJOJOJOJOS', 'SOJOJOJOJOS', 'SOOOOOOOOOS'] },
83  // a wide grin with teeth
84  grin: { x: 19, y: 34, rows: ['.SSSSSSSSSS..', 'OSSSSSSSSSSSO', 'SOJJJJJJJJJOS', 'SSOJJJJJJJOSS', 'SSSOOOOOOOSSS'] },
85}
86
87const headCache = new Map()
88/** The head grid with an expression applied; cached per (eyes, mouth). */
89function headGrid(eyes, mouth) {
90  const key = `${eyes}|${mouth}`
91  let g = headCache.get(key)
92  if (g) return g
93  g = Uint8Array.from(BASE_HEAD)
94  for (const patch of [EYES[eyes], MOUTHS[mouth]]) {
95    if (!patch) continue
96    patch.rows.forEach((row, dy) => {
97      for (let dx = 0; dx < row.length; dx++) if (row[dx] !== '.') g[(patch.y + dy) * HW + patch.x + dx] = charIndex(row[dx])
98    })
99  }
100  headCache.set(key, g)
101  return g
102}
103
104// --- canvas and rasterizer ---------------------------------------------------
105
106export class Canvas {
107  constructor(w = SIZE, h = SIZE) {
108    this.w = w
109    this.h = h
110    this.rgba = new Uint8Array(w * h * 4)
111  }
112  set(x, y, rgb, a = 255) {
113    if (x < 0 || y < 0 || x >= this.w || y >= this.h) return
114    const i = (y * this.w + x) * 4
115    this.rgba[i] = (rgb >> 16) & 255
116    this.rgba[i + 1] = (rgb >> 8) & 255
117    this.rgba[i + 2] = rgb & 255
118    this.rgba[i + 3] = a
119  }
120}
121
122// Primitives answer "is this pixel centre inside?" over a bounding box.
123const capsule = (ax, ay, bx, by, r, color) => {
124  const dx = bx - ax, dy = by - ay
125  const len2 = dx * dx + dy * dy || 1
126  return {
127    color,
128    bbox: [Math.min(ax, bx) - r, Math.min(ay, by) - r, Math.max(ax, bx) + r, Math.max(ay, by) + r],
129    inside(x, y) {
130      let t = ((x - ax) * dx + (y - ay) * dy) / len2
131      t = t < 0 ? 0 : t > 1 ? 1 : t
132      const px = ax + dx * t - x, py = ay + dy * t - y
133      return px * px + py * py <= r * r
134    },
135  }
136}
137const ellipse = (cx, cy, rx, ry, color) => ({
138  color,
139  bbox: [cx - rx, cy - ry, cx + rx, cy + ry],
140  inside(x, y) {
141    const u = (x - cx) / rx, v = (y - cy) / ry
142    return u * u + v * v <= 1
143  },
144})
145const polygon = (pts, color) => ({
146  color,
147  bbox: [Math.min(...pts.map((p) => p[0])), Math.min(...pts.map((p) => p[1])), Math.max(...pts.map((p) => p[0])), Math.max(...pts.map((p) => p[1]))],
148  inside(x, y) {
149    let inside = false
150    for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
151      const [xi, yi] = pts[i], [xj, yj] = pts[j]
152      if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside
153    }
154    return inside
155  },
156})
157// trim, soles, seams: painted as they are, with no shade band or rim light
158const flat = (prim) => ({ ...prim, flat: true })
159
160/**
161 * Paints one part: the union of its primitives (later ones on top), cel-shaded
162 * along the bottom-right rim, rim-lit along the top-left (light from the
163 * top-left) and outlined in the logo's outline color. `shade` maps a fill
164 * color to its shadow tone; a `flat` primitive takes neither band.
165 */
166function paintPart(cv, prims, { shade = (c) => dim(c, 0.72), depth = 2, outline = true, alpha = 255, highlight = true } = {}) {
167  if (!prims.length) return
168  const x0 = Math.max(0, Math.floor(Math.min(...prims.map((p) => p.bbox[0]))) - 1)
169  const y0 = Math.max(0, Math.floor(Math.min(...prims.map((p) => p.bbox[1]))) - 1)
170  const x1 = Math.min(cv.w - 1, Math.ceil(Math.max(...prims.map((p) => p.bbox[2]))) + 1)
171  const y1 = Math.min(cv.h - 1, Math.ceil(Math.max(...prims.map((p) => p.bbox[3]))) + 1)
172  const W = x1 - x0 + 1, H = y1 - y0 + 1
173  if (W <= 0 || H <= 0) return
174  const mask = new Uint8Array(W * H) // 0 outside, else 1 + index of the top primitive
175  for (let y = 0; y < H; y++)
176    for (let x = 0; x < W; x++)
177      for (let i = prims.length - 1; i >= 0; i--)
178        if (prims[i].inside(x0 + x + 0.5, y0 + y + 0.5)) {
179          mask[y * W + x] = i + 1
180          break
181        }
182  const at = (x, y) => (x < 0 || y < 0 || x >= W || y >= H ? 0 : mask[y * W + x])
183  for (let y = 0; y < H; y++)
184    for (let x = 0; x < W; x++) {
185      const m = at(x, y)
186      if (!m) continue
187      const edge = !at(x - 1, y) || !at(x + 1, y) || !at(x, y - 1) || !at(x, y + 1)
188      const prim = prims[m - 1]
189      let rgb = prim.color
190      if (edge && outline) rgb = OUT
191      else if (!prim.flat) {
192        for (let d = 1; d <= depth; d++) if (!at(x + d, y + d)) rgb = shade(prim.color)
193        if (rgb === prim.color && highlight && !at(x - 2, y - 2)) rgb = lighten(prim.color)
194      }
195      cv.set(x0 + x, y0 + y, rgb, alpha)
196    }
197}
198
199// --- head transforms ---------------------------------------------------------
200// Everything stays on the pixel grid: the hair cap and the lean slide whole
201// rows; a rotation goes the RotSprite way (EPX upscale, rotate, sample the
202// centres), which keeps 0° and 90° exact and diagonals clean.
203
204/** The head as a small indexed image with the hair cap shifted by `capDy`. */
205function deformedHead(grid, capDy) {
206  const h = HH + 2
207  const g = new Uint8Array(HW * h)
208  // the face first, the cap over it: a shift stretches or squashes the hair
209  for (let y = CAP_ROWS - 1; y < HH; y++) for (let x = 0; x < HW; x++) if (grid[y * HW + x]) g[(y + 1) * HW + x] = grid[y * HW + x]
210  for (let y = 0; y < CAP_ROWS; y++) for (let x = 0; x < HW; x++) if (grid[y * HW + x]) g[(y + 1 + capDy) * HW + x] = grid[y * HW + x]
211  return { g, w: HW, h, px: NECK[0], py: NECK[1] + 1 }
212}
213
214/** A sideways lean: rows slide, nothing else changes. */
215function shearHead(img, deg) {
216  if (!deg) return img
217  const k = Math.tan((deg * Math.PI) / 180)
218  const pad = Math.ceil(Math.abs(k) * img.h) + 1
219  const w = img.w + 2 * pad
220  const g = new Uint8Array(w * img.h)
221  for (let y = 0; y < img.h; y++) {
222    const s = Math.round(k * (y - img.py))
223    for (let x = 0; x < img.w; x++) if (img.g[y * img.w + x]) g[y * w + x + pad + s] = img.g[y * img.w + x]
224  }
225  return { g, w, h: img.h, px: img.px + pad, py: img.py }
226}
227
228/** EPX / Scale2x: doubles an indexed image, smoothing staircase edges. */
229function scale2x(g, w, h) {
230  const W = w * 2
231  const out = new Uint8Array(W * h * 2)
232  for (let y = 0; y < h; y++)
233    for (let x = 0; x < w; x++) {
234      const P = g[y * w + x]
235      const A = y > 0 ? g[(y - 1) * w + x] : 0
236      const B = x < w - 1 ? g[y * w + x + 1] : 0
237      const Cc = x > 0 ? g[y * w + x - 1] : 0
238      const D = y < h - 1 ? g[(y + 1) * w + x] : 0
239      let p1 = P, p2 = P, p3 = P, p4 = P
240      if (Cc === A && Cc !== D && A !== B) p1 = A
241      if (A === B && A !== Cc && B !== D) p2 = B
242      if (D === Cc && D !== B && Cc !== A) p3 = Cc
243      if (B === D && B !== A && D !== Cc) p4 = D
244      const o = y * 2 * W + x * 2
245      out[o] = p1
246      out[o + 1] = p2
247      out[o + W] = p3
248      out[o + W + 1] = p4
249    }
250  return out
251}
252
253/** Pixels of the head image rotated `deg` clockwise about its pivot, as [x, y, index]. */
254function rotateHead(img, deg) {
255  const out = []
256  if (!deg) {
257    for (let y = 0; y < img.h; y++) for (let x = 0; x < img.w; x++) if (img.g[y * img.w + x]) out.push([x - img.px, y - img.py, img.g[y * img.w + x]])
258    return out
259  }
260  let g = img.g, w = img.w, h = img.h
261  for (let k = 0; k < 3; k++) {
262    g = scale2x(g, w, h)
263    w *= 2
264    h *= 2
265  }
266  const th = (deg * Math.PI) / 180
267  const cos = Math.cos(th), sin = Math.sin(th)
268  const r = Math.ceil(Math.hypot(img.w, img.h)) + 1
269  for (let y = -r; y <= r; y++)
270    for (let x = -r; x <= r; x++) {
271      const cx = x + 0.5, cy = y + 0.5
272      const sx = cos * cx + sin * cy, sy = -sin * cx + cos * cy
273      const ix = Math.floor((sx + img.px) * 8), iy = Math.floor((sy + img.py) * 8)
274      if (ix < 0 || iy < 0 || ix >= w || iy >= h) continue
275      const v = g[iy * w + ix]
276      if (v) out.push([x, y, v])
277    }
278  return out
279}
280
281function drawHead(cv, h) {
282  let img = deformedHead(headGrid(h.eyes || 'open', h.mouth || 'smirk'), h.cap || 0)
283  img = shearHead(img, h.tilt || 0)
284  const px = rotateHead(img, h.rot || 0)
285  const ox = Math.round(h.x), oy = Math.round(h.y)
286  for (const [x, y, i] of px) cv.set(ox + x, oy + y, HEAD_RGB[i - 1])
287}
288
289// --- small bitmaps: effects --------------------------------------------------
290
291const FX = {
292  drop: ['..O..', '.OWO.', 'OWJWO', 'OWWWO', '.OWO.', '..O..'],
293  spark: ['..G..', '.GJG.', 'GJJJG', '.GJG.', '..G..'],
294  sparkSmall: ['.G.', 'GJG', '.G.'],
295  puff: ['.JJJ..', 'JJJJJJ', '.JJJJ.'],
296}
297const FX_RGB = { O: OUT, W: C.drop, J: C.light, G: C.spark }
298function drawFx(cv, fx) {
299  if (fx.type === 'line') {
300    const n = Math.max(Math.abs(fx.x2 - fx.x), Math.abs(fx.y2 - fx.y)) || 1
301    for (let i = 0; i <= n; i++) cv.set(Math.round(fx.x + ((fx.x2 - fx.x) * i) / n), Math.round(fx.y + ((fx.y2 - fx.y) * i) / n), fx.color ?? OUT, fx.alpha ?? 255)
302    return
303  }
304  if (fx.type === 'ghost') {
305    // a translucent motion-trail ghost of a hand
306    paintPart(cv, [ellipse(fx.x, fx.y, fx.r, fx.r, C.skin)], { outline: false, alpha: fx.alpha ?? 110, highlight: false })
307    return
308  }
309  const rows = FX[fx.type]
310  const ox = Math.round(fx.x), oy = Math.round(fx.y)
311  rows.forEach((row, dy) => {
312    for (let dx = 0; dx < row.length; dx++) if (row[dx] !== '.') cv.set(ox + dx, oy + dy, FX_RGB[row[dx]])
313  })
314}
315
316// --- the rig -----------------------------------------------------------------
317
318export const RIG = {
319  torso: 24,
320  shoulderHalf: 12,
321  hipHalf: 6,
322  upperArm: 13,
323  foreArm: 12,
324  hand: 3.5,
325  thigh: 14,
326  shin: 13,
327}
328const REACH_ARM = RIG.upperArm + RIG.foreArm
329const REACH_LEG = RIG.thigh + RIG.shin
330
331const rad = (d) => (d * Math.PI) / 180
332// A direction from "down", turning toward `facing` (+x): dir(90) is forward, dir(180) up.
333const dir = (deg, facing = 1) => [facing * Math.sin(rad(deg)), Math.cos(rad(deg))]
334/** Where the neck is for a hip and torso lean (clips place effects by it). */
335export const neckOf = (hip, torso = 0, facing = 1, len = RIG.torso) => {
336  const u = dir(180 - torso, facing)
337  return [hip[0] + u[0] * len, hip[1] + u[1] * len]
338}
339
340/**
341 * Two-bone IK: the middle joint for root `a`, target `t` and bone lengths.
342 * `bend` +1 puts the joint on the left of a→t in screen space, -1 on the right
343 * (for a limb hanging down, +1 is the viewer's left). Facing right in side
344 * view, a knee's natural bend is always -1 and an elbow's +1; in front view
345 * knees point out (L +1, R -1). Returns { mid, end, reached }: `end` is `t`
346 * when it is reachable.
347 */
348function ik(a, t, l1, l2, bend) {
349  let dx = t[0] - a[0], dy = t[1] - a[1]
350  let d = Math.hypot(dx, dy)
351  const max = l1 + l2 - 0.01, min = Math.abs(l1 - l2) + 0.01
352  let reached = true
353  if (d > max || d < min) {
354    const k = (d > max ? max : min) / (d || 1)
355    dx *= k
356    dy *= k
357    d = Math.hypot(dx, dy)
358    reached = false
359  }
360  const end = [a[0] + dx, a[1] + dy]
361  const along = (l1 * l1 - l2 * l2 + d * d) / (2 * d)
362  const h = Math.sqrt(Math.max(0, l1 * l1 - along * along))
363  const ux = dx / d, uy = dy / d
364  const mid = [a[0] + ux * along - uy * h * bend, a[1] + uy * along + ux * h * bend]
365  return { mid, end, reached }
366}
367
368function limb(root, spec, l1, l2, facing, torsoDeg) {
369  if (spec.target && spec.straight) {
370    // a straight limb to a projected point: shorter than its length when it
371    // points toward or away from the viewer, the joint on the line
372    const k = l1 / (l1 + l2)
373    const mid = [root[0] + (spec.target[0] - root[0]) * k, root[1] + (spec.target[1] - root[1]) * k]
374    return { mid, end: spec.target, reached: true }
375  }
376  if (spec.target) return ik(root, spec.target, l1, l2, spec.bend ?? 1)
377  // forward kinematics: `a` from the torso's own "down", `b` the elbow / knee
378  const a = (spec.a ?? 0) - torsoDeg
379  const d1 = dir(a, facing)
380  const mid = [root[0] + d1[0] * l1, root[1] + d1[1] * l1]
381  const d2 = dir(a + (spec.b ?? 0), facing)
382  return { mid, end: [mid[0] + d2[0] * l2, mid[1] + d2[1] * l2], reached: true }
383}
384
385/**
386 * A hand at the wrist: `d` runs along the forearm, `n` is the thumb's side.
387 * `grip`: 'fist' (a stubby palm, thumb folded, two knuckles), 'open' (fingers
388 * out, for a reach) or 'flat' (palm down on the floor, fingers forward).
389 */
390function handPrims(wrist, d, n, grip, r, facing, k) {
391  const [wx, wy] = wrist
392  const at = (a, b = 0) => [wx + d[0] * a + n[0] * b, wy + d[1] * a + n[1] * b]
393  const skin = k(C.skin), crease = k(dim(C.skin, 0.7))
394  const thumb = (a, b) => ellipse(...at(a, b), 1.7, 1.7, skin)
395  if (grip === 'flat') return [ellipse(wx + facing * 2.5, wy + 0.5, 4.6, 2.4, skin), ellipse(wx - facing * 1.5, wy - 0.8, 1.8, 1.6, skin)]
396  if (grip === 'open') {
397    return [capsule(wx, wy, ...at(4.5), r - 0.4, skin), thumb(1.5, r - 0.6), flat(capsule(...at(5, -1.4), ...at(5, 1.4), 0.5, crease))]
398  }
399  return [capsule(wx, wy, ...at(2.2), r, skin), thumb(1.2, r - 0.1), flat(capsule(...at(r + 0.9, -1.6), ...at(r + 0.9, 1.6), 0.55, crease))]
400}
401
402/**
403 * A boot below the ankle: from the front a rounded block with a toe cap and a
404 * sole; from the side a shaft and a foot pointing `toe` degrees off the floor
405 * (0 flat, 90 on the toes), with a rounded toe cap and a sole line. Returns
406 * the primitives and the lowest point, for the shadow.
407 */
408function bootPrims(ankle, facing, toe, side, k) {
409  const [ax, ay] = ankle
410  const sole = k(dim(C.boot, 0.55))
411  if (!side) {
412    return {
413      prims: [
414        polygon([[ax - 4.5, ay - 1], [ax + 4.5, ay - 1], [ax + 5.5, ay + 4.5], [ax - 5.5, ay + 4.5]], k(C.boot)),
415        ellipse(ax, ay + 2.4, 3.2, 1.5, k(C.bootLight)),
416        flat(capsule(ax - 4.6, ay + 3.6, ax + 4.6, ay + 3.6, 0.6, sole)),
417      ],
418      bottom: ay + 4.5,
419    }
420  }
421  const t = rad(toe)
422  const f = [facing * Math.cos(t), Math.sin(t)] // where the toes point
423  const nrm = [-f[1] * facing, f[0] * facing] // the sole's side
424  const r = 2.8
425  const heel = [ax - f[0] * 2, ay + 1.5 - f[1] * 2], tip = [ax + f[0] * 7.5, ay + 1.5 + f[1] * 7.5]
426  return {
427    prims: [
428      capsule(ax, ay - 1.5, ax, ay + 1.5, 3.6, k(C.boot)), // the shaft
429      capsule(heel[0], heel[1], tip[0], tip[1], r, k(C.boot)), // the foot
430      ellipse(tip[0] + f[0] * 0.5, tip[1] + f[1] * 0.5, 2, 1.8, k(C.bootLight)), // the toe cap
431      flat(capsule(heel[0] + nrm[0] * (r - 1.2), heel[1] + nrm[1] * (r - 1.2), tip[0] + nrm[0] * (r - 1.2), tip[1] + nrm[1] * (r - 1.2), 0.6, sole)),
432    ],
433    bottom: Math.max(heel[1], tip[1]) + r,
434  }
435}
436
437/**
438 * Draws a pose. Returns the resolved joints and whether every IK target was
439 * reached, which the frame builder checks.
440 *
441 * A pose: { view: 'front' | 'side', facing, hip: [x, y], torso (degrees of
442 * lean toward facing; -90 lies on the back, 90 on the front), torsoLen,
443 * head: { rot, tilt, dx, dy, eyes, mouth, z }, arms / legs: { L, R } each
444 * { a, b } (angles) or { target, bend, toe, straight, grip, hand }, fx: [...] }.
445 * In side view L is the far limb and R the near one.
446 */
447export function drawPose(cv, pose) {
448  const side = pose.view === 'side'
449  const facing = pose.facing ?? 1
450  const [hx, hy] = pose.hip
451  const torsoDeg = pose.torso ?? 0
452  const torsoLen = pose.torsoLen ?? RIG.torso
453  const up = dir(180 - torsoDeg, facing) // the spine, hip → neck
454  const neck = [hx + up[0] * torsoLen, hy + up[1] * torsoLen]
455  const right = [-up[1] * facing, up[0] * facing] // across the chest, toward facing
456  const sh = side ? 0 : RIG.shoulderHalf
457  const hh = side ? 0 : RIG.hipHalf
458  const shoulder = (s) => [neck[0] + right[0] * sh * s, neck[1] + right[1] * sh * s]
459  const hipJoint = (s) => [hx + right[0] * hh * s, hy + right[1] * hh * s]
460  const joints = { neck, hip: [hx, hy], reached: true }
461  const parts = []
462  let footBottom = -Infinity
463
464  // the far limb sits a step back and draws darker
465  const farOff = side ? [-2 * facing, 0] : [0, 0]
466  const limbRoot = (base, isFar) => (isFar ? [base[0] + farOff[0], base[1] + farOff[1]] : base)
467  const tone = (isFar) => (isFar ? (c) => dim(c, 0.78) : (c) => c)
468
469  for (const key of ['L', 'R']) {
470    const isFar = side && key === 'L'
471    const s = key === 'L' ? -1 : 1
472    const k = tone(isFar)
473    const spec = pose.legs?.[key] ?? { a: 0, b: 0 }
474    const root = limbRoot(hipJoint(s), isFar)
475    const leg = limb(root, spec, RIG.thigh, RIG.shin, facing, 0)
476    joints.reached &&= leg.reached
477    joints[`ankle${key}`] = leg.end
478    const boot = bootPrims(leg.end, facing, spec.toe ?? 0, side, k)
479    footBottom = Math.max(footBottom, boot.bottom)
480    const prims = [
481      capsule(root[0], root[1], leg.mid[0], leg.mid[1], 4.5, k(C.pants)),
482      capsule(leg.mid[0], leg.mid[1], leg.end[0], leg.end[1], 4, k(C.pants)),
483      ...boot.prims,
484    ]
485    parts.push({ z: isFar ? 1 : 3, prims, shade: (c) => dim(c, 0.7) })
486  }
487
488  // torso: a tunic from the hips to the neck, narrow at the waist, belted,
489  // with a V-neck trim (and a pouch on the belt from the side); `bulk` (0..1)
490  // puts a belly on it, forward in side view, and lets the belt out
491  {
492    const bulk = pose.bulk ?? 0
493    const w0 = (side ? 5 : 8) + 3 * bulk, w1 = (side ? 7 : 12) + 2 * bulk // half-widths at the hip and the chest
494    const pt = (u, v) => [hx + right[0] * u + up[0] * v, hy + right[1] * u + up[1] * v]
495    const body = polygon(
496      [pt(-w0, -2), pt(w0, -2), pt(w0 + 0.5, torsoLen * 0.35), pt(w1, torsoLen * 0.8), pt(w1, torsoLen + 1), pt(-w1, torsoLen + 1), pt(-w1, torsoLen * 0.8), pt(-w0 - 0.5, torsoLen * 0.35)],
497      C.tunic,
498    )
499    const prims = [body]
500    if (bulk > 0) {
501      // the belly: a round front, low on the tunic, that the belt goes over
502      const [bx, by] = pt(side ? w0 - 2 + 3 * bulk : 0, 6 + 3 * bulk)
503      prims.push(ellipse(bx, by, (side ? 6 : w0 + 1) + 5 * bulk, 6 + 5 * bulk, C.tunic))
504    }
505    const bw = w0 + 1 + (side ? 10 * bulk : 2 * bulk) // out to the belly's front, which the ellipse puts at w0 + 1 + 8 * bulk
506    const belt = polygon([pt(-w0 - 1, 3), pt(bw, 3), pt(bw, 6), pt(-w0 - 1, 6)], C.gold)
507    prims.push(belt)
508    if (!side) {
509      prims.push(polygon([pt(-2, 2), pt(2, 2), pt(2, 7), pt(-2, 7)], C.goldShade))
510      prims.push(flat(capsule(...pt(-5.5, torsoLen), ...pt(0, torsoLen - 5.5), 0.7, C.gold)), flat(capsule(...pt(5.5, torsoLen), ...pt(0, torsoLen - 5.5), 0.7, C.gold)))
511    } else {
512      prims.push(flat(capsule(...pt(w1 - 1, torsoLen), ...pt(1.5, torsoLen - 4.5), 0.7, C.gold)))
513      prims.push(ellipse(...pt(w0 + 1.5, 0.5), 2.8, 2.5, C.boot))
514    }
515    parts.push({ z: 2, prims, shade: (c) => dim(c, 0.72), depth: 3 })
516  }
517
518  for (const key of ['L', 'R']) {
519    const isFar = side && key === 'L'
520    const s = key === 'L' ? -1 : 1
521    const k = tone(isFar)
522    const spec = pose.arms?.[key] ?? { a: 8, b: 6 }
523    const root = limbRoot(shoulder(s), isFar)
524    const arm = limb(root, spec, RIG.upperArm, RIG.foreArm, side ? facing : s, torsoDeg)
525    joints.reached &&= arm.reached
526    joints[`hand${key}`] = arm.end
527    const [mx, my] = arm.mid, [wx, wy] = arm.end
528    const len = Math.hypot(wx - mx, wy - my) || 1
529    const d = [(wx - mx) / len, (wy - my) / len] // along the forearm
530    // the thumb's side: toward the body from the front, up from the side
531    let n = [-d[1], d[0]]
532    if (side ? n[1] > 0 : n[0] * (hx - wx) < 0) n = [-n[0], -n[1]]
533    const prims = [
534      capsule(root[0], root[1], mx, my, 3.5, k(C.tunic)),
535      flat(capsule(mx - d[0] * 1.5, my - d[1] * 1.5, mx + d[0] * 0.5, my + d[1] * 0.5, 3.6, k(C.tunicLight))), // the sleeve's hem
536      capsule(mx, my, wx, wy, 3, k(C.skin)),
537      flat(capsule(wx - d[0] * 2.5, wy - d[1] * 2.5, wx - d[0] * 1, wy - d[1] * 1, 3.3, k(C.gold))), // a wristband
538      ...handPrims([wx, wy], d, n, spec.grip ?? 'fist', spec.hand ?? RIG.hand, facing, k),
539    ]
540    parts.push({ z: isFar ? 0 : 5, prims, shade: (c) => dim(c, 0.72) })
541  }
542
543  // the head, pivoting at the neck
544  const h = pose.head ?? {}
545  parts.push({ z: h.z ?? 4, head: { ...h, x: neck[0] + up[0] + (h.dx ?? 0), y: neck[1] + up[1] + (h.dy ?? 0) } })
546
547  // the ground shadow: the footprint, fading as the feet leave the floor
548  {
549    const floor = pose.floor ?? FLOOR
550    const lift = Math.max(0, floor - footBottom)
551    const k = Math.max(0.25, 1 - lift / 30)
552    const cx = (joints.ankleL[0] + joints.ankleR[0]) / 2 + (side ? 3 * facing : 0)
553    const rx = (side ? 14 : Math.abs(joints.ankleL[0] - joints.ankleR[0]) / 2 + 9) * k
554    parts.push({ z: -1, shadow: [cx, floor + 2, rx, 2.2 * k] })
555  }
556
557  parts.sort((a, b) => a.z - b.z)
558  for (const p of parts) {
559    if (p.head) drawHead(cv, p.head)
560    else if (p.shadow) {
561      const [cx, cy, rx, ry] = p.shadow
562      const e = ellipse(cx, cy, rx, ry)
563      for (let y = Math.floor(cy - ry); y <= cy + ry; y++) for (let x = Math.floor(cx - rx); x <= cx + rx; x++) if (e.inside(x + 0.5, y + 0.5)) cv.set(x, y, OUT, 96)
564    } else paintPart(cv, p.prims, { shade: p.shade, depth: p.depth })
565  }
566  for (const fx of pose.fx ?? []) drawFx(cv, fx)
567  return joints
568}
569
570// --- clips -------------------------------------------------------------------
571
572const clamp01 = (t) => (t < 0 ? 0 : t > 1 ? 1 : t)
573const mix = (a, b, t) => a + (b - a) * t
574const smooth = (t) => ((t = clamp01(t)), t * t * (3 - 2 * t))
575const easeOut = (t) => ((t = clamp01(t)), 1 - (1 - t) * (1 - t))
576const easeIn = (t) => ((t = clamp01(t)), t * t)
577const seg = (t, a, b) => clamp01((t - a) / (b - a))
578const STAND_HIP_Y = FLOOR - 3 - REACH_LEG + 1 // feet planted, knees a touch soft
579const CX = 64
580
581/**
582 * Keyframed values: `keys` holds values at times in [0, 1); numbers (and
583 * arrays and objects of them) interpolate with the key's `ease` (smooth by
584 * default), anything else holds the earlier key's value. Cyclic.
585 */
586function keyed(keys) {
587  const EASE = { smooth, in: easeIn, out: easeOut, linear: clamp01, hold: () => 0 }
588  const lerp = (a, b, u) => {
589    if (typeof a === 'number' && typeof b === 'number') return mix(a, b, u)
590    if (Array.isArray(a) && Array.isArray(b)) return a.map((v, i) => lerp(v, b[i], u))
591    if (a && b && typeof a === 'object' && typeof b === 'object') {
592      const o = {}
593      for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) o[k] = k in a && k in b ? lerp(a[k], b[k], u) : a[k] ?? b[k]
594      return o
595    }
596    return a
597  }
598  return (t) => {
599    t -= Math.floor(t)
600    let i = keys.length - 1
601    while (i > 0 && keys[i].t > t) i--
602    const a = keys[i], b = keys[(i + 1) % keys.length]
603    const span = ((((b.t - a.t) % 1) + 1) % 1) || 1
604    const u = EASE[a.ease ?? 'smooth']((((t - a.t) % 1) + 1) % 1 / span)
605    return lerp(a.v, b.v, u)
606  }
607}
608
609// 1. Neck rolls: the head rolls along a crescent, ear to shoulder on the left,
610//    chin to the chest, ear to shoulder on the right, and back the same way;
611//    eyes close through the bottom of the roll.
612function neckRoll(t) {
613  const phi = (Math.PI * (1 - Math.cos(2 * Math.PI * t))) / 2 // 0 → π → 0: left, down, right, down, left
614  const side = -Math.cos(phi) // -1 at the left, +1 at the right
615  const low = Math.sin(phi) // 1 at the bottom of the crescent
616  const tilt = 18 * side
617  const dx = 4 * side
618  const dy = 6 * low
619  const eyes = low > 0.85 ? 'closed' : low > 0.35 ? 'down' : 'open'
620  const mouth = low > 0.7 ? 'o' : 'smirk'
621  return {
622    view: 'front',
623    hip: [CX, STAND_HIP_Y],
624    torso: 1.5 * side,
625    torsoLen: RIG.torso - Math.round(1.5 * low), // the shoulders ride up as the chin drops
626    head: { tilt, dx, dy: Math.round(dy), eyes, mouth },
627    arms: { L: { a: 10 - 3 * side, b: 6 }, R: { a: 10 + 3 * side, b: 6 } },
628    legs: { L: { target: [CX - 8, FLOOR - 3], bend: 1 }, R: { target: [CX + 8, FLOOR - 3], bend: -1 } },
629  }
630}
631
632// 2. Arm circles: arms straight out, each hand sweeping a big circle around
633//    the shoulder line; seen from the front the arm rises and dips, shortening
634//    as the hand comes toward the viewer, and the body sways on the beat.
635function armCircles(t) {
636  const th = 2 * Math.PI * t
637  const sway = 1.5 * Math.sin(th)
638  const neckY = STAND_HIP_Y - RIG.torso
639  const CONE = rad(40) // the circle's half-angle at the shoulder
640  const reach = REACH_ARM + 1
641  const hand = (s) => [
642    CX + sway + s * (RIG.shoulderHalf + reach * Math.cos(CONE) + 4 * Math.cos(th)), // nearer the viewer: a touch wider
643    neckY - reach * Math.sin(CONE) * Math.sin(th),
644  ]
645  return {
646    view: 'front',
647    hip: [CX + sway, STAND_HIP_Y],
648    torso: -1.5 * Math.sin(th),
649    head: { tilt: 4 * Math.sin(th), dy: Math.round(Math.cos(th)), eyes: 'open', mouth: t > 0.5 ? 'grin' : 'smirk' },
650    arms: { L: { target: hand(-1), straight: true, grip: 'open' }, R: { target: hand(1), straight: true, grip: 'open' } },
651    legs: { L: { target: [CX - 9, FLOOR - 3], bend: 1 }, R: { target: [CX + 9, FLOOR - 3], bend: -1 } },
652  }
653}
654
655// 3. Jumping jacks: two hops a cycle, arms leading the legs, a squash on
656//    each landing and sparks at the top.
657function jumpingJacks(t) {
658  const opening = t < 0.5
659  const h = opening ? t * 2 : (t - 0.5) * 2
660  const air = Math.sin(Math.PI * h)
661  const spread = opening ? smooth(h) : 1 - smooth(h)
662  const armRaw = opening ? smooth(seg(h, 0, 0.85)) : 1 - smooth(seg(h, 0, 0.85))
663  const landing = h > 0.9 || h < 0.08
664  // straight legs: the hips sit where the stance puts them, and in the air
665  // the feet rise with them; only the landing dips into a bend
666  const feet = 7 + 13 * spread
667  const legLen = REACH_LEG - 0.3
668  const legDrop = Math.sqrt(legLen * legLen - (feet - RIG.hipHalf) ** 2)
669  const hipY = FLOOR - 3 - legDrop + (landing ? 2 : 0) - 9 * air
670  const ankleY = landing ? FLOOR - 3 : hipY + legDrop
671  const a = 12 + 118 * armRaw
672  const b = 12 - 8 * armRaw
673  const fx = []
674  if (opening && h > 0.8) fx.push({ type: 'spark', x: 14, y: 30 }, { type: 'sparkSmall', x: 110, y: 26 })
675  if (!opening && h > 0.9) fx.push({ type: 'line', x: CX - 30, y: FLOOR - 1, x2: CX - 36, y2: FLOOR - 1 }, { type: 'line', x: CX + 30, y: FLOOR - 1, x2: CX + 36, y2: FLOOR - 1 })
676  return {
677    view: 'front',
678    hip: [CX, hipY],
679    torsoLen: RIG.torso - (landing ? 2 : 0),
680    head: { dy: landing ? 2 : air > 0.8 ? -1 : 0, eyes: air > 0.7 ? 'wide' : landing ? 'closed' : 'open', mouth: opening && h > 0.55 ? 'grin' : 'smirk' },
681    arms: { L: { a, b }, R: { a, b } },
682    legs: { L: { target: [CX - feet, ankleY], bend: 1 }, R: { target: [CX + feet, ankleY], bend: -1 } },
683    fx,
684  }
685}
686
687// 4. Sit-ups (side view): the torso rolls up from the floor, slow and
688//    straining, holds, and lowers; the arms stay straight, reaching for the
689//    knees and sweeping down to the shins at the top.
690function sitUps(t) {
691  const up = t < 0.5 ? smooth(seg(t, 0.02, 0.46)) : 1 - smooth(seg(t, 0.56, 0.98))
692  const torso = mix(-88, 14, up)
693  const hip = [74, FLOOR - 7]
694  const neck = neckOf(hip, torso)
695  const reach = [92 + 2 * up, 96 + 12 * up] // where the hands point: past the knees, then the shins
696  const arm = (dx) => {
697    const root = [neck[0] + dx, neck[1]]
698    const v = [reach[0] - root[0], reach[1] - root[1]]
699    const n = Math.hypot(v[0], v[1]) || 1
700    return { target: [root[0] + (v[0] / n) * REACH_ARM, root[1] + (v[1] / n) * REACH_ARM], straight: true, grip: 'open' }
701  }
702  const effort = up > 0.25 && up < 0.95
703  const top = up >= 0.95
704  const fx = []
705  if (top && t < 0.5) fx.push({ type: 'drop', x: neck[0] + 22, y: neck[1] - 30 })
706  if (effort && t < 0.5) fx.push({ type: 'line', x: neck[0] + 26, y: neck[1] - 20, x2: neck[0] + 30, y2: neck[1] - 24 }, { type: 'line', x: neck[0] + 28, y: neck[1] - 12, x2: neck[0] + 33, y2: neck[1] - 13 })
707  return {
708    view: 'side',
709    hip,
710    torso,
711    // the head stays off the floor (chin tucked); flat on the back her ear would sink into it
712    head: { rot: mix(-56, 22, up), eyes: top ? 'happy' : effort ? 'squint' : up < 0.05 ? 'closed' : 'open', mouth: top ? 'o' : effort ? 'grit' : 'smirk' },
713    arms: { L: arm(-2), R: arm(0) },
714    legs: { L: { target: [hip[0] + 20, FLOOR - 3], bend: -1 }, R: { target: [hip[0] + 22, FLOOR - 3], bend: -1 } },
715    fx,
716  }
717}
718
719// 5. Squats (facing the viewer): a wide stance, knees out as the hips drop,
720//    arms reaching toward the viewer, a trembling hold at the bottom, then up.
721function squats(t) {
722  const down = t < 0.42 ? smooth(seg(t, 0.02, 0.42)) : t < 0.56 ? 1 : 1 - easeOut(seg(t, 0.56, 0.96))
723  const hold = t >= 0.42 && t < 0.56
724  const tremble = hold ? Math.floor(t * 60) % 2 : 0
725  const stance = 15
726  const legLen = REACH_LEG - 0.3
727  const topHipY = FLOOR - 3 - Math.sqrt(legLen * legLen - (stance - RIG.hipHalf) ** 2)
728  const hip = [CX, topHipY + 15 * down + tremble]
729  const torsoLen = RIG.torso - 4 * down // leaning forward foreshortens the torso
730  const neckY = hip[1] - torsoLen
731  const bottom = down > 0.8
732  // arms straight toward the viewer: short on screen, the hands big and close
733  const hand = (s) => ({ target: [CX + s * (RIG.shoulderHalf - 2 + 2 * down), neckY + 11 + 2 * down], straight: true, hand: 4, grip: 'open' })
734  return {
735    view: 'front',
736    hip,
737    torsoLen,
738    head: { dy: bottom ? 1 : 0, eyes: bottom ? 'squint' : down > 0.3 ? 'down' : 'open', mouth: bottom ? 'grit' : t > 0.85 ? 'o' : 'smirk' },
739    arms: { L: hand(-1), R: hand(1) },
740    legs: { L: { target: [CX - stance, FLOOR - 3], bend: 1 }, R: { target: [CX + stance, FLOOR - 3], bend: -1 } },
741    fx: hold ? [{ type: 'drop', x: CX + 26, y: neckY - 30 + Math.round(seg(t, 0.42, 0.56) * 8) }] : [],
742  }
743}
744
745// A plank on the toes: the body a straight line from the ankle, `phi` degrees
746// above horizontal; the knee a touch soft so IK always reaches. On the toes
747// the boot reaches 12 px below the ankle, so the ankle sits that far up.
748const PLANK_ANKLE = [30, FLOOR - 12]
749const PLANK_TOP = 18
750const PLANK_HAND = [PLANK_ANKLE[0] + (REACH_LEG - 0.6 + RIG.torso) * Math.cos(rad(PLANK_TOP)) + 1, FLOOR - 3]
751function plank(phi) {
752  const [ax, ay] = PLANK_ANKLE
753  const leg = REACH_LEG - 0.6
754  const hip = [ax + leg * Math.cos(rad(phi)), ay - leg * Math.sin(rad(phi))]
755  return { hip, torso: 90 - phi }
756}
757
758// 6. Push-ups (side view): the body a straight line pivoting on the toes,
759//    hands planted; strain at the bottom, a push back up.
760function pushUps(t) {
761  const down = t < 0.4 ? smooth(seg(t, 0.02, 0.4)) : t < 0.5 ? 1 : 1 - easeOut(seg(t, 0.5, 0.86))
762  const { hip, torso } = plank(mix(PLANK_TOP, 8, down))
763  const neck = neckOf(hip, torso)
764  const bottom = down > 0.85
765  const fx = []
766  if (bottom) fx.push({ type: 'drop', x: neck[0] + 22, y: neck[1] - 26 })
767  if (t > 0.5 && t < 0.7) fx.push({ type: 'puff', x: neck[0] + 30, y: neck[1] - 4 })
768  return {
769    view: 'side',
770    hip,
771    torso,
772    head: { rot: 24 + 8 * down, eyes: bottom ? 'squint' : down > 0.4 ? 'down' : 'open', mouth: bottom ? 'grit' : t > 0.5 && t < 0.75 ? 'open' : 'smirk', z: 3.5 },
773    arms: { L: { target: [PLANK_HAND[0] - 3, PLANK_HAND[1]], bend: 1, grip: 'flat' }, R: { target: PLANK_HAND, bend: 1, grip: 'flat' } },
774    legs: { L: { target: [PLANK_ANKLE[0] - 2, PLANK_ANKLE[1]], bend: -1, toe: 90 }, R: { target: PLANK_ANKLE, bend: -1, toe: 90 } },
775    fx,
776  }
777}
778
779// 7. Burpees (side view): squat, kick back, push-up, hop in, leap, land.
780const CROUCH = { hip: [48, FLOOR - 14], torso: 64, ankle: [54, FLOOR - 3], hand: [76, FLOOR - 3] }
781const STAND = { hip: [52, STAND_HIP_Y], torso: 0, ankle: [54, FLOOR - 3] }
782const burpeeKeys = keyed([
783  { t: 0.0, v: { ...STAND, headRot: 0, toe: 0, arm: { a: 10, b: 6 }, hand: null, lift: 0 } },
784  { t: 0.12, v: { hip: CROUCH.hip, torso: CROUCH.torso, headRot: 12, ankle: CROUCH.ankle, toe: 0, arm: { a: 0, b: 0 }, hand: CROUCH.hand, lift: 0 }, ease: 'in' },
785  { t: 0.24, v: { ...plank(PLANK_TOP), headRot: 24, ankle: PLANK_ANKLE, toe: 90, arm: { a: 0, b: 0 }, hand: PLANK_HAND, lift: 10 } },
786  { t: 0.34, v: { ...plank(8), headRot: 32, ankle: PLANK_ANKLE, toe: 90, arm: { a: 0, b: 0 }, hand: PLANK_HAND, lift: 0 }, ease: 'out' },
787  { t: 0.44, v: { ...plank(PLANK_TOP), headRot: 24, ankle: PLANK_ANKLE, toe: 90, arm: { a: 0, b: 0 }, hand: PLANK_HAND, lift: 0 } },
788  // the hands leave the floor here: the arms swing back for the leap
789  { t: 0.56, v: { hip: CROUCH.hip, torso: CROUCH.torso, headRot: 12, ankle: CROUCH.ankle, toe: 0, arm: { a: 30, b: 10 }, hand: null, lift: 10 }, ease: 'out' },
790  { t: 0.62, v: { hip: [50, FLOOR - 36], torso: 14, headRot: 2, ankle: [54, FLOOR - 10], toe: 50, arm: { a: -50, b: -10 }, hand: null, lift: 0 }, ease: 'out' },
791  { t: 0.7, v: { hip: [52, FLOOR - 50], torso: -4, headRot: -4, ankle: [54, FLOOR - 26], toe: 70, arm: { a: 95, b: -10 }, hand: null, lift: 0 }, ease: 'in' },
792  { t: 0.82, v: { hip: [50, FLOOR - 22], torso: 26, headRot: 8, ankle: [54, FLOOR - 3], toe: 0, arm: { a: 60, b: 10 }, hand: null, lift: 0 }, ease: 'out' },
793  { t: 0.92, v: { ...STAND, headRot: 0, toe: 0, arm: { a: 10, b: 6 }, hand: null, lift: 0 } },
794])
795function burpees(t) {
796  const k = burpeeKeys(t)
797  const arms = k.hand ? { L: { target: [k.hand[0] - 3, k.hand[1]], bend: 1, grip: 'flat' }, R: { target: k.hand, bend: 1, grip: 'flat' } } : { L: k.arm, R: k.arm }
798  const ankle = [k.ankle[0], k.ankle[1] - k.lift]
799  const neck = neckOf(k.hip, k.torso)
800  const airborne = t > 0.6 && t < 0.8
801  const apex = t > 0.66 && t < 0.76
802  const pushing = t > 0.3 && t < 0.42
803  const fx = []
804  if (apex) fx.push({ type: 'spark', x: neck[0] - 34, y: neck[1] - 30 }, { type: 'sparkSmall', x: neck[0] + 30, y: neck[1] - 36 }, { type: 'sparkSmall', x: neck[0] - 26, y: neck[1] + 4 })
805  if (t > 0.84 && t < 0.98) fx.push({ type: 'drop', x: neck[0] + 22, y: neck[1] - 30 + Math.round((t - 0.84) * 40) })
806  if (pushing) fx.push({ type: 'line', x: neck[0] + 26, y: neck[1] - 22, x2: neck[0] + 30, y2: neck[1] - 26 }, { type: 'line', x: neck[0] + 29, y: neck[1] - 14, x2: neck[0] + 34, y2: neck[1] - 15 })
807  return {
808    view: 'side',
809    hip: k.hip,
810    torso: k.torso,
811    head: { rot: k.headRot, eyes: apex ? 'wide' : pushing ? 'squint' : t > 0.8 && t < 0.92 ? 'closed' : 'open', mouth: apex ? 'grin' : pushing ? 'grit' : t > 0.84 ? 'open' : 'smirk', z: airborne ? 4 : 3.5 },
812    arms,
813    legs: { L: { target: [ankle[0] - 2, ankle[1]], bend: -1, toe: k.toe }, R: { target: ankle, bend: -1, toe: k.toe } },
814    fx,
815  }
816}
817
818// `reps` is where a workout stops on its own: a set that is a lot for the
819// easy moves and honest for the hard ones, after which Pixi takes a bow.
820export const EXERCISES = [
821  { id: 'neck-roll', name: 'neck rolls', difficulty: 1, seconds: 3.0, reps: 20, pose: neckRoll },
822  { id: 'arm-circles', name: 'arm circles', difficulty: 2, seconds: 1.2, reps: 40, pose: armCircles },
823  { id: 'jumping-jacks', name: 'jumping jacks', difficulty: 3, seconds: 0.85, reps: 50, pose: jumpingJacks },
824  { id: 'sit-ups', name: 'sit-ups', difficulty: 4, seconds: 1.7, reps: 30, pose: sitUps },
825  { id: 'squats', name: 'squats', difficulty: 5, seconds: 1.8, reps: 25, pose: squats },
826  { id: 'push-ups', name: 'push-ups', difficulty: 6, seconds: 1.5, reps: 20, pose: pushUps },
827  { id: 'burpees', name: 'burpees', difficulty: 7, seconds: 2.5, reps: 12, pose: burpees },
828].map((ex) => ({ ...ex, frames: Math.round(ex.seconds * FPS) }))
829
830export const byDifficulty = (d) => EXERCISES.find((x) => x.difficulty === Math.min(7, Math.max(1, d | 0))) ?? EXERCISES[0]
831
832/** The path of a pre-rendered frame of a clip (an exercise, its intro, the outro), relative to the plugin root. */
833export const framePath = (clip, i) => `assets/frames/${clip.id}/f${i}.png`
834
835// --- arcade lettering --------------------------------------------------------
836// A 5x7 bitmap font drawn the arcade way: scaled, lit along the top, extruded
837// down-right in a darker tone and outlined, so a title reads as a slab.
838
839const FONT_W = 5, FONT_H = 7
840const FONT = {
841  A: ['.###.', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
842  B: ['####.', '#...#', '#...#', '####.', '#...#', '#...#', '####.'],
843  C: ['.###.', '#...#', '#....', '#....', '#....', '#...#', '.###.'],
844  D: ['####.', '#...#', '#...#', '#...#', '#...#', '#...#', '####.'],
845  E: ['#####', '#....', '#....', '####.', '#....', '#....', '#####'],
846  F: ['#####', '#....', '#....', '####.', '#....', '#....', '#....'],
847  G: ['.###.', '#...#', '#....', '#.###', '#...#', '#...#', '.####'],
848  H: ['#...#', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
849  I: ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '#####'],
850  J: ['..###', '...#.', '...#.', '...#.', '...#.', '#..#.', '.##..'],
851  K: ['#...#', '#..#.', '#.#..', '##...', '#.#..', '#..#.', '#...#'],
852  L: ['#....', '#....', '#....', '#....', '#....', '#....', '#####'],
853  M: ['#...#', '##.##', '#.#.#', '#.#.#', '#...#', '#...#', '#...#'],
854  N: ['#...#', '#...#', '##..#', '#.#.#', '#..##', '#...#', '#...#'],
855  O: ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
856  P: ['####.', '#...#', '#...#', '####.', '#....', '#....', '#....'],
857  Q: ['.###.', '#...#', '#...#', '#...#', '#.#.#', '#..#.', '.##.#'],
858  R: ['####.', '#...#', '#...#', '####.', '#.#..', '#..#.', '#...#'],
859  S: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'],
860  T: ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'],
861  U: ['#...#', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
862  V: ['#...#', '#...#', '#...#', '#...#', '#...#', '.#.#.', '..#..'],
863  W: ['#...#', '#...#', '#...#', '#.#.#', '#.#.#', '##.##', '#...#'],
864  X: ['#...#', '#...#', '.#.#.', '..#..', '.#.#.', '#...#', '#...#'],
865  Y: ['#...#', '#...#', '.#.#.', '..#..', '..#..', '..#..', '..#..'],
866  Z: ['#####', '....#', '...#.', '..#..', '.#...', '#....', '#####'],
867  '0': ['.###.', '#...#', '#..##', '#.#.#', '##..#', '#...#', '.###.'],
868  '1': ['..#..', '.##..', '..#..', '..#..', '..#..', '..#..', '.###.'],
869  '2': ['.###.', '#...#', '....#', '...#.', '..#..', '.#...', '#####'],
870  '3': ['#####', '...#.', '..#..', '...#.', '....#', '#...#', '.###.'],
871  '4': ['...#.', '..##.', '.#.#.', '#..#.', '#####', '...#.', '...#.'],
872  '5': ['#####', '#....', '####.', '....#', '....#', '#...#', '.###.'],
873  '6': ['..##.', '.#...', '#....', '####.', '#...#', '#...#', '.###.'],
874  '7': ['#####', '....#', '...#.', '..#..', '.#...', '.#...', '.#...'],
875  '8': ['.###.', '#...#', '#...#', '.###.', '#...#', '#...#', '.###.'],
876  '9': ['.###.', '#...#', '#...#', '.####', '....#', '...#.', '.##..'],
877  'X': ['##..#', '##..#', '.###.', '..#..', '.###.', '#..##', '#..##'], // X's mark: a thick \\ over a thin /
878  '-': ['.....', '.....', '.....', '#####', '.....', '.....', '.....'],
879  '.': ['.....', '.....', '.....', '.....', '.....', '.##..', '.##..'],
880  ',': ['.....', '.....', '.....', '.....', '..##.', '..##.', '.##..'],
881  ':': ['.....', '.##..', '.##..', '.....', '.##..', '.##..', '.....'],
882  '…': ['.....', '.....', '.....', '.....', '.....', '#.#.#', '#.#.#'],
883  '·': ['.....', '.....', '.....', '.##..', '.##..', '.....', '.....'],
884  '%': ['##..#', '##.#.', '...#.', '..#..', '.#...', '.#.##', '#..##'],
885  '/': ['....#', '...#.', '...#.', '..#..', '.#...', '.#...', '#....'],
886  "'": ['..#..', '..#..', '.....', '.....', '.....', '.....', '.....'],
887  '(': ['...#.', '..#..', '.#...', '.#...', '.#...', '..#..', '...#.'],
888  ')': ['.#...', '..#..', '...#.', '...#.', '...#.', '..#..', '.#...'],
889  '!': ['..#..', '..#..', '..#..', '..#..', '..#..', '.....', '..#..'],
890  '?': ['.###.', '#...#', '....#', '...#.', '..#..', '.....', '..#..'],
891  ' ': ['.....', '.....', '.....', '.....', '.....', '.....', '.....'],
892}
893
894/**
895 * Lines of text laid out in the font, centred on each other: a grid of 0
896 * (clear), 1 (the face) and 2 (the lit band along the top of each glyph).
897 */
898function textMask(lines) {
899  const GAP = 1, LINE_GAP = 2
900  const width = (line) => line.length * (FONT_W + GAP) - GAP
901  const w = Math.max(...lines.map(width))
902  const h = lines.length * (FONT_H + LINE_GAP) - LINE_GAP
903  const g = new Uint8Array(w * h)
904  lines.forEach((line, li) => {
905    const ox = Math.floor((w - width(line)) / 2), oy = li * (FONT_H + LINE_GAP)
906    ;[...line.toUpperCase()].forEach((ch, ci) => {
907      const rows = FONT[ch] ?? FONT['?']
908      rows.forEach((row, y) => {
909        for (let x = 0; x < FONT_W; x++) if (row[x] === '#') g[(oy + y) * w + ox + ci * (FONT_W + GAP) + x] = y < 3 ? 2 : 1
910      })
911    })
912  })
913  return { w, h, g }
914}
915
916/**
917 * Draws a text mask scaled by `s` about (cx, cy) as a slab: the face in
918 * `face` with `lit` along the top, extruded `depth` px (at scale 2) down-right
919 * in `side`, and a 1 px outline around the lot. Nearest-neighbour scaling, so
920 * it is crisp at whole and half scales and reads as motion at the rest.
921 */
922function drawText(cv, mask, cx, cy, s, { depth = 2, face = C.gold, lit = lighten(C.gold), side = C.goldShade, alpha = 255 } = {}) {
923  const W = Math.round(mask.w * s), H = Math.round(mask.h * s)
924  if (W < 1 || H < 1) return
925  const x0 = Math.round(cx - W / 2), y0 = Math.round(cy - H / 2)
926  const M = new Uint8Array(W * H)
927  for (let y = 0; y < H; y++)
928    for (let x = 0; x < W; x++) M[y * W + x] = mask.g[Math.min(mask.h - 1, Math.floor(y / s)) * mask.w + Math.min(mask.w - 1, Math.floor(x / s))]
929  const d = Math.max(1, Math.round((depth * s) / 2))
930  const at = (x, y) => (x < 0 || y < 0 || x >= W || y >= H ? 0 : M[y * W + x])
931  const solid = (x, y) => {
932    for (let k = 0; k <= d; k++) if (at(x - k, y - k)) return true
933    return false
934  }
935  for (let y = -1; y <= H + d; y++)
936    for (let x = -1; x <= W + d; x++) {
937      if (solid(x, y)) continue
938      if (solid(x - 1, y) || solid(x + 1, y) || solid(x, y - 1) || solid(x, y + 1)) cv.set(x0 + x, y0 + y, OUT, alpha)
939    }
940  for (let k = d; k >= 1; k--) for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (M[y * W + x]) cv.set(x0 + x + k, y0 + y + k, side, alpha)
941  for (let y = 0; y < H; y++)
942    for (let x = 0; x < W; x++) {
943      const m = M[y * W + x]
944      if (m) cv.set(x0 + x, y0 + y, m === 2 ? lit : face, alpha)
945    }
946}
947
948/** Arcade lettering for anyone: `lines` of text, scaled `s`, centred on (cx, cy). */
949export const drawTitle = (cv, lines, cx, cy, s = 2, opts) => drawText(cv, textMask(lines), cx, cy, s, opts)
950
951// --- party effects -----------------------------------------------------------
952
953const CONFETTI = [C.gold, C.light, C.drop, C.tunicLight, C.spark, hex('#e86a8a'), hex('#7bd389')]
954// a tiny deterministic generator, so every frame of a burst agrees with the last
955const lcg = (seed) => {
956  let s = seed
957  return () => (s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff
958}
959function piece(cv, x, y, color, wide, tumble, alpha) {
960  const [pw, ph] = tumble ? [wide ? 3 : 2, 2] : [2, wide ? 3 : 2]
961  const px = Math.round(x), py = Math.round(y)
962  for (let dy = 0; dy < ph; dy++) for (let dx = 0; dx < pw; dx++) if (px + dx > 0 && py + dy > 0 && px + dx < SIZE - 1 && py + dy < SIZE - 1) cv.set(px + dx, py + dy, color, alpha)
963}
964
965/** `n` pieces burst from (cx, cy); `k` frames on, each has flown its own arc, slowing and falling, and fades after `fade` frames. */
966function confettiBurst(cv, cx, cy, k, { n = 28, seed = 7, fade = 7 } = {}) {
967  const rnd = lcg(seed)
968  for (let i = 0; i < n; i++) {
969    const ang = rnd() * Math.PI * 2, speed = 5 + rnd() * 8
970    let x = cx, y = cy, vx = Math.cos(ang) * speed, vy = Math.sin(ang) * speed - 3
971    const color = CONFETTI[Math.floor(rnd() * CONFETTI.length)], wide = rnd() < 0.5, spin = Math.floor(rnd() * 4)
972    for (let f = 0; f < k; f++) {
973      x += vx
974      y += vy
975      vx *= 0.8
976      vy = vy * 0.8 + 1
977    }
978    const alpha = k < fade ? 255 : Math.max(0, 255 - (k - fade) * 70)
979    if (alpha > 0) piece(cv, x, y, color, wide, (k + spin) % 4 < 2, alpha)
980  }
981}
982
983/** Confetti raining from the top: `n` pieces let go over the first dozen frames, each drifting down on its own sway. */
984function confettiRain(cv, k, { n = 30, seed = 11 } = {}) {
985  const rnd = lcg(seed)
986  for (let i = 0; i < n; i++) {
987    const x0 = 4 + rnd() * (SIZE - 8), start = Math.floor(rnd() * 12), speed = 1.6 + rnd() * 2, sway = 2 + rnd() * 3, phase = rnd() * 6.28
988    const color = CONFETTI[Math.floor(rnd() * CONFETTI.length)], wide = rnd() < 0.5, spin = Math.floor(rnd() * 4)
989    const age = k - start
990    if (age < 0) continue
991    const y = -3 + age * speed
992    if (y > SIZE - 2) continue
993    piece(cv, x0 + Math.sin(phase + age * 0.45) * sway, y, color, wide, (age + spin) % 4 < 2, 255)
994  }
995}
996
997/** A one-pixel ring: the pop's shockwave. */
998function ring(cv, cx, cy, r, color, alpha = 255) {
999  for (let y = Math.floor(cy - r - 1); y <= cy + r + 1; y++)
1000    for (let x = Math.floor(cx - r - 1); x <= cx + r + 1; x++) if (Math.abs(Math.hypot(x + 0.5 - cx, y + 0.5 - cy) - r) < 0.75) cv.set(x, y, color, alpha)
1001}
1002
1003/** Every drawn pixel in one colour: the flash of a reveal. */
1004function silhouette(cv, color) {
1005  for (let i = 0; i < cv.rgba.length; i += 4)
1006    if (cv.rgba[i + 3]) {
1007      cv.rgba[i] = (color >> 16) & 255
1008      cv.rgba[i + 1] = (color >> 8) & 255
1009      cv.rgba[i + 2] = color & 255
1010    }
1011}
1012
1013// --- ready: a beat before the title, while the verdict is on its way ---------
1014
1015export const READY_FRAMES = 12
1016const READY_TITLE = textMask(['READY?'])
1017function drawReady(cv, i) {
1018  // a gentle bob, so the card is alive while it waits
1019  drawText(cv, READY_TITLE, CX, 60 + Math.round(2 * Math.sin((2 * Math.PI * i) / READY_FRAMES)), 2)
1020  return { reached: true }
1021}
1022
1023// --- intro: the title balloons in and pops to reveal Pixi --------------------
1024
1025const backOut = (u) => 1 + 2.70158 * (u - 1) ** 3 + 1.70158 * (u - 1) ** 2 // eases past 1 and settles
1026const ZOOM_FRAMES = 10, BULGE_FRAMES = 2, POP_FRAMES = 6
1027export const INTRO_FRAMES = ZOOM_FRAMES + BULGE_FRAMES + POP_FRAMES
1028const TITLE_Y = 60
1029
1030function drawIntro(cv, ex, i) {
1031  const mask = textMask(ex.name.split(' '))
1032  if (i < ZOOM_FRAMES) {
1033    // the title rushes in from small, overshoots and settles at 2x
1034    const s = 0.3 + 1.7 * backOut(i / (ZOOM_FRAMES - 1))
1035    drawText(cv, mask, CX, TITLE_Y, Math.max(0.3, Math.round(s * 4) / 4))
1036    return { reached: true }
1037  }
1038  if (i < ZOOM_FRAMES + BULGE_FRAMES) {
1039    // the balloon swells before it goes
1040    drawText(cv, mask, CX, TITLE_Y, i === ZOOM_FRAMES ? 2.25 : 2.5)
1041    return { reached: true }
1042  }
1043  // the pop: Pixi stands revealed in the exercise's opening pose, startled for
1044  // a beat, under a shockwave and a burst of confetti
1045  const k = i - ZOOM_FRAMES - BULGE_FRAMES
1046  const pose = ex.pose(0)
1047  const head = { ...(pose.head ?? {}), cap: 0 }
1048  if (k <= 2) Object.assign(head, { eyes: 'wide', mouth: 'o' })
1049  const joints = drawPose(cv, { ...pose, head })
1050  if (k === 0) silhouette(cv, C.light)
1051  if (k < 3) ring(cv, CX, TITLE_Y, 16 + k * 16, C.light, 255 - k * 80)
1052  confettiBurst(cv, CX, TITLE_Y, k + 1)
1053  return joints
1054}
1055
1056// --- outro: a leap, a landing, "GOOD JOB!", confetti -------------------------
1057
1058export const OUTRO_FRAMES = 36
1059const GOOD_JOB = textMask(['GOOD JOB!'])
1060const TADA_HIP_Y = FLOOR - 3 - 14 // a deep, wide squat: the pose Pixi lands in and holds
1061const TADA_STANCE = 15
1062
1063function drawOutro(cv, i) {
1064  let pose
1065  const stand = (hipY, stance, torsoLen = RIG.torso) => ({
1066    view: 'front',
1067    hip: [CX, hipY],
1068    torsoLen,
1069    legs: { L: { target: [CX - stance, FLOOR - 3], bend: 1 }, R: { target: [CX + stance, FLOOR - 3], bend: -1 } },
1070  })
1071  const armsUp = { L: { a: 160, b: -8 }, R: { a: 160, b: -8 } }
1072  const fx = []
1073  if (i < 4) {
1074    // anticipation: a crouch, arms swung back
1075    const u = smooth(i / 3)
1076    pose = { ...stand(STAND_HIP_Y + 7 * u, 9, RIG.torso - 2 * u), head: { dy: Math.round(2 * u), eyes: 'closed', mouth: 'o' }, arms: { L: { a: 35, b: 10 }, R: { a: 35, b: 10 } } }
1077  } else if (i < 10) {
1078    // the leap: straight up, fists in the air, sparks at the apex
1079    const h = (i - 4) / 5
1080    const air = Math.sin(Math.PI * h)
1081    const hipY = STAND_HIP_Y - 22 * air // any higher and the hair leaves the canvas
1082    const apex = i === 6 || i === 7
1083    pose = {
1084      view: 'front',
1085      hip: [CX, hipY],
1086      head: { dy: air > 0.8 ? -1 : 0, eyes: apex ? 'wide' : 'open', mouth: h > 0.3 ? 'grin' : 'open' },
1087      arms: armsUp,
1088      legs: { L: { target: [CX - 7, hipY + REACH_LEG - 3], bend: 1 }, R: { target: [CX + 7, hipY + REACH_LEG - 3], bend: -1 } },
1089    }
1090    if (apex) fx.push({ type: 'spark', x: CX - 42, y: hipY - 50 }, { type: 'sparkSmall', x: CX + 36, y: hipY - 56 }, { type: 'sparkSmall', x: CX - 30, y: hipY - 10 })
1091    if (i === 9) fx.push({ type: 'line', x: CX - 30, y: FLOOR - 1, x2: CX - 36, y2: FLOOR - 1 }, { type: 'line', x: CX + 30, y: FLOOR - 1, x2: CX + 36, y2: FLOOR - 1 })
1092  } else {
1093    // the landing, squashed, then the hold: arms in a V, a bounce on the beat, a blink
1094    const landing = i < 12
1095    const k = i - 12
1096    const bounce = landing ? 0 : [0, -1, -1, 0, 0, 0][k % 6]
1097    const blink = k >= 16 && k <= 17
1098    pose = {
1099      ...stand(TADA_HIP_Y + (landing ? 3 : 0) + bounce, TADA_STANCE, RIG.torso - (landing ? 3 : 0)),
1100      head: { dy: landing ? 2 : 0, eyes: landing || blink ? 'closed' : 'happy', mouth: landing ? 'open' : 'grin' },
1101      arms: armsUp,
1102    }
1103    if (!landing && k % 8 < 3) fx.push({ type: k % 2 ? 'spark' : 'sparkSmall', x: [14, 104, 22, 100][Math.floor(k / 8) % 4], y: [70, 56, 46, 78][Math.floor(k / 8) % 4] })
1104  }
1105  const joints = drawPose(cv, { ...pose, head: { ...(pose.head ?? {}), cap: 0 }, fx })
1106  if (i >= 12) {
1107    const k = i - 12
1108    confettiRain(cv, k)
1109    // "GOOD JOB!" slams in over the head, its lit band glinting now and then
1110    const s = k < 6 ? 0.3 + 1.7 * backOut(k / 5) : 2
1111    drawText(cv, GOOD_JOB, CX, 18, Math.max(0.3, Math.round(s * 4) / 4), { lit: k >= 6 && k % 12 >= 9 ? C.light : lighten(C.gold) })
1112  }
1113  return joints
1114}
1115
1116// --- the clips ---------------------------------------------------------------
1117// An exercise cycles; its intro and the outro play once. `renderFrame` draws
1118// any of them; `framePath` names their pre-rendered frames.
1119
1120const intros = new Map()
1121/** The intro clip for an exercise: its name balloons in and pops to reveal Pixi in the opening pose. */
1122export const introOf = (ex) => {
1123  if (!intros.has(ex)) intros.set(ex, { id: `${ex.id}-intro`, frames: INTRO_FRAMES, draw: (cv, i) => drawIntro(cv, ex, i) })
1124  return intros.get(ex)
1125}
1126/** The ready clip: "READY?" bobbing while the model decides which exercise it is; loops. */
1127export const READY = { id: 'ready', frames: READY_FRAMES, draw: drawReady, loops: true }
1128/** The outro clip: a set well done. */
1129export const OUTRO = { id: 'outro', frames: OUTRO_FRAMES, draw: drawOutro }
1130/** Everything the frame builder renders. */
1131export const CLIPS = [...EXERCISES, READY, ...EXERCISES.map(introOf), OUTRO]
1132
1133// --- for drawing scenes around Pixi (the showcase) ---------------------------
1134/** The rasterizer's primitives and painter, the palette and the effects, so a scene can be drawn in Pixi's own style. */
1135export const shapes = { capsule, ellipse, polygon, flat, paintPart }
1136export const PALETTE = C
1137export const OUTLINE = OUT
1138export const ease = { clamp01, mix, smooth, easeOut, easeIn, seg, keyed }
1139export { drawFx, confettiBurst, lighten, dim }
1140
1141/**
1142 * The canvas as terminal cells, `columns` wide and `rows` tall, two pixels a
1143 * cell (▀ over a background color). Each pixel takes the majority color of
1144 * its source box, so the palette stays exact. Returns [codePoint, fg, bg] per
1145 * cell, row-major; -1 is the terminal's own color.
1146 */
1147export function halfBlocks(cv, columns, rows) {
1148  const W = columns, H = rows * 2
1149  const pick = (px, py) => {
1150    const x0 = Math.floor((px * cv.w) / W), x1 = Math.max(x0 + 1, Math.floor(((px + 1) * cv.w) / W))
1151    const y0 = Math.floor((py * cv.h) / H), y1 = Math.max(y0 + 1, Math.floor(((py + 1) * cv.h) / H))
1152    const votes = new Map()
1153    let n = 0, opaque = 0
1154    for (let y = y0; y < y1; y++)
1155      for (let x = x0; x < x1; x++) {
1156        n++
1157        const i = (y * cv.w + x) * 4
1158        if (cv.rgba[i + 3] < 128) continue
1159        opaque++
1160        const c = (cv.rgba[i] << 16) | (cv.rgba[i + 1] << 8) | cv.rgba[i + 2]
1161        votes.set(c, (votes.get(c) ?? 0) + 1)
1162      }
1163    if (opaque * 2 < n) return -1
1164    let best = -1, most = 0
1165    for (const [c, k] of votes) if (k > most) (most = k), (best = c)
1166    return best
1167  }
1168  const cells = []
1169  for (let cy = 0; cy < rows; cy++)
1170    for (let cx = 0; cx < columns; cx++) {
1171      const top = pick(cx, cy * 2), bottom = pick(cx, cy * 2 + 1)
1172      if (top >= 0 && bottom >= 0) cells.push([0x2580, top, bottom])
1173      else if (top >= 0) cells.push([0x2580, top, -1])
1174      else if (bottom >= 0) cells.push([0x2584, bottom, -1])
1175      else cells.push([32, -1, -1])
1176    }
1177  return cells
1178}
1179
1180/**
1181 * Renders frame `i` of a clip into a fresh canvas: an intro or the outro draws
1182 * itself; an exercise is posed, with the hair cap trailing the head's
1183 * vertical motion by a pixel, read off the previous frame.
1184 */
1185export function renderFrame(ex, i, cv = new Canvas()) {
1186  if (ex.draw) return { canvas: cv, joints: ex.draw(cv, i % ex.frames), pose: null }
1187  const t = (i % ex.frames) / ex.frames
1188  const pose = ex.pose(t)
1189  const prev = ex.pose(t - 1 / ex.frames)
1190  const vy = neckOf(pose.hip, pose.torso)[1] + (pose.head?.dy ?? 0) - neckOf(prev.hip, prev.torso)[1] - (prev.head?.dy ?? 0)
1191  const head = { ...(pose.head ?? {}), cap: vy > 2.5 ? -1 : vy < -2.5 ? 1 : 0 }
1192  const joints = drawPose(cv, { ...pose, head })
1193  return { canvas: cv, joints, pose }
1194}
1195
hooks/estimate.js 169 lines
1// The difficulty question, asked the way torchcast-decision-12b is meant to be
2// asked: a typed decision — one forward pass that reads the probability of
3// each option letter at the answer slot — not free-form JSON.
4//
5// Two transports, picked by the URL:
6//  - the official shim, `…/v1/systemone`: a `score` question over the seven
7//    levels; the shim does the readout on its vLLM.
8//  - any OpenAI-compatible chat endpoint that returns logprobs (mlx_lm.server,
9//    llama-server, vLLM): the mod renders the shim's own prompt, asks for one
10//    token with top_logprobs, and reads the letters itself.
11//
12// Every function takes the knobs (hooks/config.js) as an optional last
13// argument, so tools/eval can score alternatives without touching the file.
14// Shared with tools/ask.mjs and tools/eval so a live check runs this code.
15import config from './config.js'
16
17export { config }
18export const LEVELS = config.levels
19export const MINUTES = config.minutes
20export const INSTRUCTIONS = config.instructions
21// The shim's scaffolding (torchcast_shim.py), kept verbatim: without it the model answers in prose.
22export const SYSTEM =
23  'You are a calibration engine. You never answer in prose. You are given a state, a question and ' +
24  'a numbered set of options, and you choose exactly one option. You reply with that option\'s ' +
25  'LETTER and nothing else — a single character, no words, no punctuation, no explanation.'
26const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
27
28export const isDecisionEndpoint = (url) => /\/v1\/systemone\b/.test(url)
29
30/**
31 * The recent conversation to send: the last `exchanges` spoken prompt/answer
32 * pairs, `chars` each. Messages come as `$.session.messages()` gives them;
33 * tool results are user messages with no text and are skipped.
34 */
35export function contextOf(messages, cfg = config) {
36  const said = messages.filter((m) => m?.text?.trim() && (m.role === 'user' || m.role === 'assistant'))
37  const n = cfg.context?.exchanges ?? 1
38  const users = said.filter((m) => m.role === 'user').slice(-n)
39  const assistants = said.filter((m) => m.role === 'assistant').slice(-n)
40  return [...users, ...assistants].map((m) => `${m.role}: ${m.text.trim().replace(/\s+/g, ' ').slice(0, cfg.context?.chars ?? 300)}`).join('\n')
41}
42
43/** The decision's state: the prompt, after a little recent conversation. */
44export const stateOf = (text, context = '') => (context ? `Recent conversation:\n${context}\n\n` : '') + `New task:\n${text.slice(0, 4000)}`
45
46/** The body for POST /v1/systemone: a score question over the levels. */
47export function decisionRequest(text, context = '', cfg = config) {
48  return { state: stateOf(text, context), questions: { decision: { type: 'score', instructions: cfg.instructions, criteria: cfg.levels } } }
49}
50
51/** The shim's prompt rendering (build_prompt), for a chat endpoint with logprobs. */
52export function renderPrompt(state, cfg = config) {
53  const lines = [state.trimEnd(), '', cfg.instructions, '', 'Options:']
54  cfg.levels.forEach((desc, i) => lines.push(`${LETTERS[i]}. ${desc}`))
55  lines.push('', 'Answer with the letter of exactly one option, and nothing else:')
56  return lines.join('\n')
57}
58
59/** The chat-completions body for the readout: one token, its top logprobs. */
60export function readoutRequest(model, text, context = '', cfg = config) {
61  return {
62    model,
63    messages: [
64      { role: 'system', content: SYSTEM },
65      { role: 'user', content: renderPrompt(stateOf(text, context), cfg) },
66    ],
67    max_tokens: 1,
68    temperature: 1,
69    logprobs: true,
70    top_logprobs: 11, // mlx_lm.server's cap; plenty for seven letters
71    chat_template_kwargs: { enable_thinking: false },
72  }
73}
74
75const TOKEN_LETTER = /^[\s(\[{'"]*([A-Za-z])[\s.,:)\]}'"]*$/
76/**
77 * Probability per level from the answer slot's top_logprobs. A letter can
78 * surface as several tokens ("A", " A", "A."), so mass is summed per letter.
79 */
80export function letterProbs(top, n = LEVELS.length) {
81  const acc = new Array(n).fill(0)
82  let any = false
83  for (const { token, logprob } of top) {
84    const m = TOKEN_LETTER.exec(token ?? '')
85    if (!m) continue
86    const i = LETTERS.indexOf(m[1].toUpperCase())
87    if (i < 0 || i >= n) continue
88    acc[i] += Math.exp(logprob)
89    any = true
90  }
91  if (!any) throw new Error('no option letter at the answer slot')
92  const total = acc.reduce((a, b) => a + b, 0)
93  return acc.map((p) => p / total)
94}
95
96/** The distribution at a readout temperature: p^(1/T), renormalised. */
97export function tempered(probabilities, T = 1) {
98  if (!T || T === 1) return probabilities
99  const z = probabilities.map((p) => Math.max(p, 1e-12) ** (1 / T))
100  const s = z.reduce((a, b) => a + b, 0)
101  return z.map((v) => v / s)
102}
103
104/** A verdict from a distribution over the levels: the level, how sure, the expected minutes. */
105export function verdictOf(raw, cfg = config) {
106  const probabilities = tempered(raw, cfg.temperature)
107  let level = 0
108  if (cfg.decision === 'median') {
109    let c = 0
110    while (level < probabilities.length - 1 && (c += probabilities[level]) < 0.5) level++
111  } else probabilities.forEach((p, i) => p > probabilities[level] && (level = i))
112  // the geometric expectation: the table spans two orders of magnitude, and an
113  // arithmetic mean would let a few percent on the top level dominate
114  const minutes = Math.round(100 * Math.exp(probabilities.reduce((sum, p, i) => sum + p * Math.log(cfg.minutes[i]), 0))) / 100
115  const expected = probabilities.reduce((sum, p, i) => sum + p * (i + 1), 0)
116  return { difficulty: level + 1, confidence: probabilities[level], minutes, expected, probabilities }
117}
118
119/** Minutes for people: `~40 s`, `~2.5 min`, `~20 min`. */
120export function formatMinutes(minutes) {
121  if (minutes < 1) return `~${Math.max(5, Math.round((minutes * 60) / 5) * 5)} s`
122  if (minutes < 10) return `~${Math.round(minutes * 10) / 10} min`
123  return `~${Math.round(minutes)} min`
124}
125
126/** The verdict in a /v1/systemone reply. */
127export function parseDecision(json, cfg = config) {
128  const d = json?.answers?.decision
129  if (!d?.probabilities) throw new Error(json?.error ?? 'no decision in the reply')
130  return verdictOf(cfg.levels.map((_, i) => Number(d.probabilities[String(i)]) || 0), cfg)
131}
132
133/** The verdict in a chat-completions reply that carries logprobs. */
134export function parseReadout(json, cfg = config) {
135  const top = json?.choices?.[0]?.logprobs?.content?.[0]?.top_logprobs
136  if (!Array.isArray(top)) throw new Error(json?.error?.message ?? json?.error ?? 'no top_logprobs at the answer slot: the server must return logprobs')
137  return verdictOf(letterProbs(top, cfg.levels.length), cfg)
138}
139
140/**
141 * The model name to send: the configured one if the server lists it, else
142 * the server's first model (mlx_lm.server reports the loaded path and would
143 * try to download anything else; llama-server reports the GGUF's name).
144 * `get(url) → { ok, text }`; any failure keeps the configured name.
145 */
146export async function resolveModel(get, url, configured) {
147  if (isDecisionEndpoint(url)) return configured
148  try {
149    const res = await get(url.replace(/\/v1\/.*$/, '/v1/models'))
150    const ids = (JSON.parse(res.text || '{}')?.data ?? []).map((m) => m?.id).filter((id) => typeof id === 'string')
151    if (!ids.length || ids.includes(configured)) return configured
152    return ids[0]
153  } catch {
154    return configured
155  }
156}
157
158/** One estimate over `fetch`-like `post(url, body) → { ok, status, text }`. */
159export async function estimateWith(post, url, model, text, context = '', cfg = config) {
160  const decision = isDecisionEndpoint(url)
161  const res = await post(url, decision ? decisionRequest(text, context, cfg) : readoutRequest(model, text, context, cfg))
162  let json = {}
163  try {
164    json = JSON.parse(res.text || '{}')
165  } catch {}
166  if (!res.ok) throw new Error(`HTTP ${res.status}${json?.error ? `: ${json.error?.message ?? json.error}` : ''}`)
167  return decision ? parseDecision(json, cfg) : parseReadout(json, cfg)
168}
169
hooks/head.js 50 lines
1// Generated by tools/extract-head.mjs from tools/pixi-logo.png — do not edit.
2// Pixi's head at the logo's native pixel grid, 50x44; '.' is transparent.
3export const HEAD_PALETTE = {"O":"#230438","T":"#48c1c5","S":"#945276","B":"#1a8393","P":"#5b1e56","Y":"#367890","D":"#072141","K":"#d56d6d","G":"#fed363","W":"#69a8bd","L":"#dfa881","N":"#ca948b","M":"#7b3a67","E":"#373046","F":"#4f90a4","H":"#87464e","I":"#daa062","J":"#fdfacc","Q":"#a46d4e","R":"#1a6a80","U":"#f9b94c","V":"#4b1d0b","X":"#1f385b","Z":"#677598","a":"#2b9da8"}
4export const HEAD_ROWS = [
5  '..................OOOOO...........................',
6  '..................OOOOOO..OOOOOOOO................',
7  '...................OWTTOOOOOOOOOOOO...............',
8  '...................OZFTDOODDDDDDOOO....OO.........',
9  '...................OOOTTOOBTTTTBOO.....OO.........',
10  '................OOOOOOBTOBTTTWOOOO...OOOO.........',
11  '..............OOOOBBBBTTOTTTBBBBBOOOOOOTO.........',
12  '............OOOOBTTTTTTBBTTTBBTTTBOOBTTTO.........',
13  '...........OOOBTTTTTTTBTTTTTTTTTTTTTTTTOO.........',
14  '..........OOOOTTTTTTTTTTTTTTTTTTTTTBOOOO..........',
15  '.........OOOOFTTTTTBTTTTTTTTTTTTTTTTFOOOO.........',
16  '.........OOODTTTTTTBTTTTTTTTTTTTTTTTTDOOO.........',
17  '.........OOTTTTBTTBTTTTTTTTTTTTTBBTTTTTOO.........',
18  'OOOO....OOBTTTBBTTRTTTTTTTTTTTTTTTBTTTTBOO....OOOO',
19  'OOOOO...OOTTTBBTTTOTTTTTTTBTTTTTTTTBTTTTOO...OOOOO',
20  'OOSOOO.OOBTTWOTTTBOTTTTTTTOBTTTTBTTTOWTTBOO.OOOSOO',
21  'OONSSOOOOBBODTTBBOOBTTTTTTOOBBTTTBBTBDOBBOOOOSSNOO',
22  'OOPNLSOOOBBODTTBOOPOWTTTTTFOODBTTTBBTDOBBOOOSLNPOO',
23  'OOPNLHOOOBYDDTTYOOPOWTTTTTBOODYWTTBBTDDYBOOOHLNPOO',
24  '.OOPPLSOOYOWTTYOPPPOYRTTTTBOOOOOYYYBBBYOYOOSLPPOO.',
25  '.OOPPOLSOOOYBTOOPSSPOOBTTTBOPPPOOOOBBBYOOOSLOPPOO.',
26  '..OOOSOISOOYBBOPSSSSPPOBTTBOPPPPPPOOBBYOOSIOSOOO..',
27  '..OOOSPOISOYYOOSSSSSSSPOOBTOPPSSSPPOOYYOSIOPSOOO..',
28  '...OOOSPOSOYYOPOOOOSSSSPOOOOMMSOOOPPOYYOSOPSOOO...',
29  '...OOOSPOSOYYOPPPPOOSSSSSPPSSSOOPPPPOYYOSOPSOOO...',
30  '....OOOSPOOYYOPPPPPOOOOSSSSOOOOPPPPPOYYOOPSOOO....',
31  '.....OOSPOOYYOPPPPPOOOOSSSSOOOOPPPPPOYYOOPSOO.....',
32  '.....OOSSOOYYOOOOOOOPPPSSSSPPPOOOOOOOYYOOSSOO.....',
33  '......OOSSOOOOSOSGJUVOPSSSSPOVUJGSOSOOOOSSOO......',
34  '.......OOSPOOOSPSGJGQSPSSSSPSQGJGSPSOOOPSOO.......',
35  '.......OOOOOOOSSSGGGQSSSSSSSSQGGGSSSOOOOOOO.......',
36  '.......OBOOOOOSSSSSSSSSSSSSSSSSSSSSSOOOOOBO.......',
37  '......OOBTBOOOSKKKSSSSSPKKPSSSSSKKKSOOOBTBOO......',
38  '.....EOXBTBOOOMKKKSSSSSMHHPSSSSSKKKMOOOBTaXOE.....',
39  '.....OOBTTBDOOPKKKSSSSSSOOSSSSMSKKKPOODBTTBOOE....',
40  '...OOOTTTBBBYOOSSSSSSSSSSSSSOOOSSSSOOYBBBTTTOOO...',
41  '.....OOOOOOYYOOPSSSSOOSSSSSOPPSSSSPOOYYOOOOOO.....',
42  '......OOOOOYBBOOPSSSSSPOOOOSSSSSSPOOBBYOOOOO......',
43  '.........OBBBBBOOPSSSSSSSSSSSSSSPOOBBBBBO.........',
44  '.........OBBYOOOOOOPSSSSPPSSSSPOOOOOOYBBO.........',
45  '.........ODDOOOOEOOOOOPSSSSPOOOOOEOOOODDO.........',
46  '.........OOOOOOO.OOOOOPSSSSPOOOOO.OOOOOOO.........',
47  '.........OOOO.....OOOOOOOOOOOOOO.....OOOO.........',
48  '......................OOOOOO......................',
49]
50
hooks/config.js 32 lines
1// The estimator's tunable knobs. tools/eval/hillclimb.mjs rewrites this file
2// when a candidate beats the incumbent on logged outcomes; edit by hand freely.
3// The shim's prompt layout (state, instructions, lettered options, answer
4// line) is fixed; everything inside the question is tunable.
5export default {
6  version: 1,
7  // the question put to the model
8  instructions:
9    'Rate how hard this task is for an AI coding agent working in the repository: how much focused work it takes to finish well. ' +
10    'A short follow-up ("continue", "same again", "fix that") takes the work it refers to in the recent conversation.',
11  // the seven options, difficulty 1..7
12  levels: [
13    'trivial: a typo, a one-line answer',
14    'small: a quick lookup or a tiny edit',
15    'routine: a focused change in one place',
16    'moderate: a feature touching a few files',
17    'substantial: design work across modules',
18    'large: a refactor or migration across a codebase',
19    'huge: a multi-day build or a deep investigation',
20  ],
21  // minutes an agent's turn takes per level (geometric: each level about 2.5x
22  // the last); the verdict's minutes is the geometric expectation over the
23  // distribution, so a little mass on the top levels does not swamp it
24  minutes: [0.5, 1, 2, 5, 12, 30, 60],
25  // how much recent conversation goes in: the last `exchanges` spoken prompt/answer pairs, `chars` each
26  context: { exchanges: 1, chars: 300 },
27  // how a level is read off the distribution: 'argmax' (the shim's choice) or 'median'
28  decision: 'argmax',
29  // readout temperature on the score distribution (the shim's is 1)
30  temperature: 1,
31}
32