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…

<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.
<!-- 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.
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.
| | | | | | :-: | :-: | :-: | :-: | | <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.
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).
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.
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.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
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.
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.
Every frame is computed, so position, size and timing are exact across frames and exercises.
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.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.
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 |
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
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.
hooks/register.js 329 lines1// 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}
329hooks/pixi.js 1195 lines1// 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}
1195hooks/estimate.js 169 lines1// 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}
169hooks/head.js 50 lines1// 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]
50hooks/config.js 32 lines1// 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