SLOPSHOPPER

pixelband

Animated pixel art above your Claude Code prompt that reacts while Claude works: seven scenes, or your own image or GIF. Zero tokens.

newpanebandcommandprocesstimer
★ 7v0.3.2MITupdated 2026-10-04furqan-khan07/pixelband
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixelband
│ ┃ pixelband ✕ › fix the failing auth test and add an audit log call │ ┃ press ctrl+x then tab to use this menu · Es… │ ┃ Show : nothing yet: pick a scene or an i ⏺ Read(src/auth.ts) │ ┃ Image : drag an image here, or type its p ⎿ Read 6 lines │ ┃ Style : original ▾ ⏺ Update(src/auth.ts) │ ┃ Height [ - ] auto (8) [ + ] ⎿ Added 2 lines, removed 1 line │ ┃ Working : shrink to 3 rows while Claude wor ⏺ Bash(bun test) │ ┃ Pixels : standard: 2 per character (most t ⎿ 3 pass, 1 fail │ ┃ Colours : auto (full colour) ▾ │ ┃ [ Done ] [ Hide band ] [ Undo changes ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ like it? a star helps: github.com/furqan-kha │ ✻ Worked for 42s · done 4:20 PM │ │ › /pixelband │ │ pixelband · /pixelband to pick an image or scene ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
pixelband · /pixelband to pick an image or scene
Pane · pixelband
press ctrl+x then tab to use this menu · Esc closes Show : nothing yet: pick a scene or an image ▾ Image : drag an image here, or type its path ⏎ use it Style : original ▾ Height [ - ] auto (8) [ + ] Working : shrink to 3 rows while Claude works ▾ Pixels : standard: 2 per character (most terminals) ▾ Colours : auto (full colour) ▾ [ Done ] [ Hide band ] [ Undo changes ] like it? a star helps: github.com/furqan-khan07/pixelband ht
README

pixelband

Pixel art above your Claude Code prompt that reacts while Claude works: your own image, or an animated scene.

pixelband: animated pixel-art scenes above the prompt. A spark between Michelangelo's two hands blazes while Claude works and floods out in a ring of light when it finishes; rain on a city gets heavier and lightning strikes; stars go to warp speed; the aurora brightens; the fire climbs

<sub>The scenes are pixelband's own output, rendered frame by frame. The prompt box and labels around it are a mock-up, and a real terminal will look slightly different depending on your font.</sub>

I spend a lot of hours in Claude Code, and it looks the same for everyone. So I wanted to make mine mine: a picture of my choosing sitting right above the prompt, one that actually knows what's going on. It shimmers while Claude is working, sparkles when a turn finishes, and glitches when something errors. It's a small thing, but it makes the terminal feel like your own space.

Try it

pixelband is a Claude Code mod. Mods need Claude Code 2.1.287 or later (claude update). Install it from your shell:

claude plugin marketplace add furqan-khan07/pixelband
claude plugin install pixelband@pixelband

or from inside a Claude Code session:

/plugin marketplace add furqan-khan07/pixelband
/plugin install pixelband@pixelband

Run /reload-plugins if a session was already open. It works in the claude command in a terminal (including VS Code's, Cursor's and JetBrains' built-in terminals) and in the Code tab of the Claude desktop app.

Then, inside Claude Code, type /pixelband to open the menu. Pick a scene, or drag an image into the menu's image field (your newest downloads and screenshots are listed there too), and choose a style, size and crop. Every change shows in the band straight away, and Undo changes puts it all back.

If the menu doesn't respond to keys, press ctrl+x then tab to give it the keyboard. Tab and the arrow keys move around it; Enter picks.

Or skip the menu:

/pixelband scene city
/pixelband set ~/Pictures/cat.jpg

Once mods ship properly, the flag won't be needed.

Commands

/pixelbandopen the menu
/pixelband scene <name>an animated scene: creation, city (rain on a city at night), space, aurora, fire, matrix or aquarium
/pixelband set <image>use an image: PNG, JPEG, HEIC (iPhone photos), WebP and more. Animated GIFs play, looping, with their own timing
/pixelband set <image> --hereuse it for this project only, so each repo gets its own banner
/pixelband style <name>original, gameboy, pico8, mono or sepia
`/pixelband move <up\down\left\right> [steps]`aim the crop at the part of the picture you want
`/pixelband zoom <in\out\reset>`zoom the crop in, up to 4x
`/pixelband layout <auto\banner\fit>`banner fills the whole width with a crop; fit shows the whole image, centred. auto picks fit for logos and sprites with see-through backgrounds
`/pixelband size <rows\auto>`how tall the band is, 2 to 24 rows (two pixels per row). auto, the default, is about a quarter of the terminal
`/pixelband working <slim\hide\full>`what the band does while Claude works: shrink to a 3-row strip (the default), hide, or stay full size
/pixelband colors <n>palette size for the original style, 2 to 32. Fewer colours reads more like pixel art
`/pixelband pixels <standard\fine>`fine puts four pixels in each character instead of two: sharper, and the right shape if pixels look wide (fonts with tight line spacing, like some macOS Terminal profiles). Saved per terminal app, since it depends on the font
`/pixelband colormode <auto\full\256>`full colour, or the 256 colours older terminals show; auto detects macOS Terminal
`/pixelband animate on\off`pause a scene or GIF (it still reacts to Claude)
/pixelband on / offshow or hide it
/pixelband clear [--here]forget the image
/pixelband demo <mood>play working, done, error or intro on demand (good for screenshots)

Staying out of the way

While Claude is working, the band slides down to a slim 3-row strip so the output gets the room, and it grows back when the turn finishes. The effects still play in the strip. /pixelband working hide hides it during turns instead, and /pixelband working full keeps it full size. Claude Code also lets you collapse the band any time with its [-] mark or ctrl+x ctrl+a.

Scenes

Each scene is drawn from code, not a video, and reacts to what Claude is doing:

sceneidlewhile Claude workswhen it finishes
creationMichelangelo's hands, a spark glowing in the gap between the fingertipsthe room dims and the spark blazes and crackleslight floods out in a ring
cityrain, windows switching on and off, flickering neon, a wet streetthe rain gets heavierlightning
spacestars drifting past a ringed planetwarp speeda hyperspace flash
auroranorthern lights over mountains and pinesbrighter, faster curtainsa bright pulse
firelow flames over your terminal's own backgroundthe flames climba burst of embers
matrixgreen code raining downit poursa bright scan sweeps down
aquariumfish, bubbles and swaying weedthe fish dart about and the bubbles pick upa burst of bubbles

creation is built from Michelangelo's The Creation of Adam (c. 1511), which is in the public domain, via Wikimedia Commons.jpg). The plugin ships only a small 256-colour strip of the arms, made by tools/make_creation.py.

The styles work on scenes too, so /pixelband scene city plus /pixelband style gameboy is a four-green rainy city.

Why pixel art and not the actual photo?

Because terminals are bad at photos. Real pixels only show up in a couple of terminals (Kitty and Ghostty); everywhere else, even a full-width band is only about 100×24 pixels, and a photo shrunk that far just looks like a smudge. So pixelband leans into it on purpose: it crops the image to the band's shape (you choose which part with move and zoom), gives the colours a bit of punch, and cuts it down to a small palette, so every block looks deliberate. The retro styles go further and map the image onto a fixed palette with ordered dithering, the way a Game Boy or PICO-8 game would.

Each terminal cell shows two stacked pixels (the ▀ character in one colour over a background in another), or four with /pixelband pixels fine (quarter-block characters, two colours per cell). See-through parts of a PNG or GIF let your terminal's background show through, so logos and sprites blend right in.

How it works

  • Everything runs locally. Nothing is uploaded anywhere.
  • No dependencies. Mods run in a sandbox with no image decoders, no compression APIs and no WebAssembly, so pixelband decodes PNG itself (including the zlib decompression) in plain TypeScript.
  • GIFs are decoded in the mod too, frames and timing and all, since macOS's sips only returns a GIF's first frame. Frames are shrunk as they're read, long GIFs keep every second or third frame, and the loop is stored as one shared palette so it fits in Claude Code's store.
  • Photos go through your OS. For JPEG, HEIC, WebP and friends it asks macOS's built-in sips (or ImageMagick on Linux) to convert and shrink the image first, so a 20 MB iPhone photo never gets pulled through the mod.
  • The animation is cheap. A timer swaps just the pixel grid 12 times a second, so nothing else in Claude Code redraws. With a still image, the timer only runs while an effect plays; a scene or GIF keeps it running (/pixelband animate off stops it).

claude plugin validate lists everything a mod touches, and for pixelband that's:

hooks: session.start, turn.start, turn.complete, ui.render{component=AbovePrompt},
       ui.render{component=Pane, requestId=pixelband}, command.run{command=pixelband}
calls: $.clock.after, $.clock.every, $.command.register, $.env.get, $.fs.list, $.fs.read, $.fs.stat,
       $.process.run, $.session.root, $.store.delete, $.store.get, $.store.set, $.ui.blit, $.ui.close,
       $.ui.invalidate, $.ui.open, $.ui.panes, $.ui.resolve
env reads: COLORTERM, HOME, TERM_PROGRAM, TMPDIR

$.process.run is only ever sips, magick/convert (to convert a photo), split (to read a GIF over 4 MB in pieces, since Claude Code reads at most 4 MB at once) and rm (to delete the temporary files those make). $.fs.list and $.fs.stat only look at Downloads, Desktop and Pictures, to list your newest images in the menu.

Limitations

  • Terminal and desktop app. In the terminal pixelband draws a grid of coloured cells; the desktop app has no such grid, so there it draws the same pixels as an SVG image and redraws it for each frame. The VS Code extension's chat panel draws no mods, so it shows nothing there.
  • Non-PNG images need sips or ImageMagick. Every Mac has sips; on Linux, install ImageMagick or use a PNG.
  • Colours are best in a true-colour terminal (iTerm2, Ghostty, kitty, WezTerm, VS Code, and macOS Terminal from macOS 26). Older macOS Terminal only shows 256 colours; pixelband spots it and picks from those 256 itself, keeping hues that plain rounding would turn grey, but dark night skies still come out greyer than they should. /pixelband colormode full|256|auto overrides the detection. Through tmux, enable true colour there too.
  • Only your own turns animate it. Subagents working in the background don't.

Development

To hack on it, clone the repo and load it straight from the folder:

git clone https://github.com/furqan-khan07/pixelband
claude --plugin-dir ./pixelband
tools/validate.sh                             # what the engine will load and refuse
claude plugin test .                          # the test suite
python tests/fixtures/make_fixtures.py        # rebuild the image fixtures (needs Pillow)
python tests/fixtures/make_gif_fixtures.py    # rebuild the GIF fixtures

The PNG, BMP and GIF decoders are checked pixel for pixel against Pillow, and against real sips output for the photo path. For editor types, run /plugin-types inside Claude Code once. It writes the declarations to .claude/types, which tsconfig.json picks up.

If pixelband makes your terminal a bit nicer, a star on the repo helps other people find it.

MIT licensed, see LICENSE.

Source 16 files
hooks/register.ts 867 lines
1/**
2 * pixelband: pixel art above the Claude Code prompt that reacts while Claude works.
3 *
4 *   /pixelband                        open the menu: pick an image or scene, style, size, crop
5 *   /pixelband set <image> [--here]   use an image (drag a file into the terminal for the path);
6 *                                     --here makes it this project's banner only
7 *   /pixelband scene <name> [--here]  an animated scene instead: city, space, aurora or fire
8 *   /pixelband style <name>           original, gameboy, pico8, mono or sepia
9 *   /pixelband layout <mode>          banner (full width, cropped), fit (whole image), or auto
10 *   /pixelband move <dir> [n]         aim the banner's crop: up, down, left, right
11 *   /pixelband zoom <in|out|reset>    crop tighter or wider
12 *   /pixelband size <rows>            how tall the band is (2-24 rows, two pixels per row)
13 *   /pixelband colors <n>             palette size (2-32) for the original and sepia styles
14 *   /pixelband on | off               show or hide it
15 *   /pixelband clear [--here]         forget the image
16 *   /pixelband demo <mood>            play working, done, error or intro (handy for screenshots)
17 *
18 * How it hangs together: the band is drawn by hooking `ui.render` for `AbovePrompt` with one Raster
19 * element. Turn events set a mood; while something is animating (a mood, or a scene), a clock timer
20 * builds the next frame and swaps it into the Raster with `$.ui.blit`, so nothing else redraws. The
21 * desktop app has no Raster, so there the band is an `Svg` of the same pixels, and each frame is a
22 * redraw. The menu is a pane (`ui.render` for `Pane`) whose controls call the same actions as the
23 * commands.
24 */
25import type { Register, RenderElement } from 'claude-code'
26import { decodeAnim, encodeAnim, frameAt, spread, stack, type Anim, type StoredAnim } from './anim'
27import { DURATION, FRAME_MS, frame, isOneShot, type Mood } from './effects'
28import { cleanPath, loadImage, type Io } from './load'
29import { cropRect, DEFAULT_VIEW, downscale, downscaleRegion, fit, shrinkToFit, transparency, TRANSPARENT, type Art, type View } from './pixelate'
30import type { Rgba } from './png'
31import { brighten, to256 } from './palette256'
32import { cellsFor, quadCellsFor, rowsFor } from './raster'
33import { svgWithin } from './svg'
34import { isScene, makeScene, SCENES, type Renderer, type SceneName } from './scenes'
35import { STYLES, stylize, type Style } from './styles'
36
37type Layout = 'auto' | 'banner' | 'fit'
38type Scope = 'project' | 'global'
39type WhileWorking = 'slim' | 'full' | 'hide'
40type ColorMode = 'auto' | 'full' | '256'
41/** standard: two pixels per cell (half blocks). fine: four (quadrants), for nearly square cells. */
42type Pixels = 'standard' | 'fine'
43/** `rows` 0 means auto: about a quarter of the terminal. */
44interface Config { rows: number; colors: number; enabled: boolean; style: Style; layout: Layout; animate: boolean; whileWorking: WhileWorking; colorMode: ColorMode
45  /** Pixels per character, per terminal app: the right choice depends on that terminal's font. */
46  pixelsFor: Record<string, Pixels> }
47/**
48 * What a scope's slot in the store holds: the image (RGB when fully opaque, a quarter smaller;
49 * RGBA otherwise) and/or a scene. A scene wins while set, and keeps the image for switching back.
50 */
51interface Stored { w?: number; h?: number; rgb?: string; rgba?: string; anim?: StoredAnim; name?: string; scene?: string }
52
53type Source =
54  | { kind: 'image'; image: Rgba; name: string; clear: number; anim?: Anim }
55  | { kind: 'scene'; scene: SceneName }
56
57const DEFAULTS: Config = { rows: 0, colors: 16, enabled: true, style: 'original', layout: 'auto', animate: true, whileWorking: 'slim', colorMode: 'auto', pixelsFor: {} }
58/** How tall the band gets while Claude works, in 'slim' mode: out of the way, still animating. */
59const SLIM_ROWS = 3
60/** Rows the band grows or shrinks by per frame when it changes height. */
61const ROW_STEP = 2
62/** Longest side of the copy we keep; wide enough for a full-width banner. The store caps at 4 MiB total. */
63const STORED_MAX_SIDE = 320
64const MAX_COLS = 250
65/** Upper bound on cells per drawing, so a huge terminal can't produce an unreasonably big frame. */
66const MAX_CELLS = 6000
67/** Images at least this see-through (logos, sprites) default to showing whole instead of cropped. */
68const FIT_IF_TRANSPARENT = 0.15
69const MOVE_STEP = 0.08
70const KEY = 'art'
71const MENU = 'pixelband'
72const REPO_URL = 'https://github.com/furqan-khan07/pixelband'
73const IMAGE_FILE = /\.(png|jpe?g|heic|heif|webp|gif|bmp|tiff?|avif)$/i
74const RECENT_DIRS = ['Downloads', 'Desktop', 'Pictures']
75/** How much the creation scene is lightened in 256-colour terminals (see palette256's brighten). */
76const CREATION_LIFT_256 = 1.25
77
78/** Where each scene's slim strip looks, as a fraction of its height: windows over the street, the planet... */
79const SLIM_FOCUS: Record<SceneName, number> = { city: 0.66, space: 0.58, aurora: 0.5, fire: 0.7, creation: 0.5, matrix: 0.5, aquarium: 0.55 }
80
81export const SCENE_LABELS: Record<SceneName, string> = {
82  city: 'rain on a city at night',
83  space: 'stars and a ringed planet',
84  aurora: 'northern lights',
85  fire: 'a wall of fire',
86  creation: "Michelangelo's hands and a spark",
87  matrix: 'green code rain',
88  aquarium: 'fish, bubbles and weed',
89}
90
91/** Pixels across for a band `cols` cells wide. */
92const pixelCols = (cols: number, pixels: Pixels) => (pixels === 'fine' ? cols * 2 : cols)
93
94const clampInt = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, Math.round(v)))
95/** Auto height: about a quarter of what the band may take, 4 to 8 rows. */
96const autoRows = (maxRows: number) => clampInt(maxRows * 0.28, 4, 8)
97
98/**
99 * The engine calls needed outside a hook's own `$`: the animation timer and the menu's buttons run
100 * later. A mod may not keep `$` itself, so session.start captures each call as a small function,
101 * the way Anthropic's own diff mod does.
102 */
103interface Host {
104  every: (ms: number, fn: () => void) => { cancel: () => void }
105  after: (ms: number, fn: () => void) => unknown
106  blit: (args: { requestId: string; key: string; cells: string; columns: number; rows: number }) => unknown
107  invalidate: () => unknown
108  /** Redraws only the sites that read the frame counter (the band), for apps without blit. */
109  bump: () => unknown
110  get: (key: string) => Promise<unknown>
111  set: (key: string, value: unknown) => Promise<unknown>
112  del: (key: string) => Promise<unknown>
113  home: () => Promise<unknown>
114  list: (path: string) => Promise<unknown>
115  stat: (path: string) => Promise<unknown>
116  open: () => Promise<unknown>
117  close: () => Promise<unknown>
118  panes: () => Promise<unknown>
119  io: Io
120}
121
122export const register: Register = (on) => {
123  let host: Host | null = null
124  let root = ''
125  let config: Config = { ...DEFAULTS }
126  let source: Source | null = null
127  let scope: Scope | null = null
128  let imageName = '' // the image kept in the slot, even while a scene shows
129  let view: View = { ...DEFAULT_VIEW }
130  /** Whether the saved state has been looked up yet, so the "no image" hint doesn't flash at startup. */
131  let ready = false
132
133  /** The image's art per frame (one entry for a still), for the band size and look in `artKey`. */
134  let frameArts = new Map<number, Art>()
135  let artKey = ''
136  /** A few of an animation's frames stacked, for the palette every frame shares. */
137  let animSample: Rgba | null = null
138  let animT = 0
139  let renderer: Renderer | null = null
140  let rendererKey = ''
141  let sceneCache: { key: string; art: Art } | null = null
142  /** Where the Raster is mounted, so the timer can blit frames into it. `want` is the size asked for. */
143  let band: { requestId: string; columns: number; rows: number; want: { rows: number; cols: number } } | null = null
144
145  let mood: Mood = 'idle'
146  let ticks = 0
147  let working = false
148  let timer: { cancel: () => void } | null = null
149  let sceneT = 0
150  let energy = 0
151  /** The band's size limits from its last render, and how many rows it shows right now. */
152  let limits: { cols: number; maxRows: number } | null = null
153  /** Whether this terminal looks like it only shows 256 colours (macOS Terminal before macOS 26). */
154  let only256 = false
155  /** Which terminal app this session runs in (TERM_PROGRAM), for settings that depend on it. */
156  let terminal = 'unknown'
157  /** The app drawing the band: `terminal` draws a Raster; the others (desktop) an Svg. */
158  let surface = 'terminal'
159  const inTerminal = () => surface === 'terminal'
160  // Fine pixels and the 256-colour fallback are about terminal fonts and colours; an Svg has neither.
161  const pixelsNow = (): Pixels => (inTerminal() ? config.pixelsFor?.[terminal] ?? 'standard' : 'standard')
162  const use256 = () => inTerminal() && (config.colorMode === '256' || (config.colorMode === 'auto' && only256))
163  let shownRows = -1
164  /** The frame counter the desktop band subscribes to (see Host.bump). */
165  let frameNo = 0
166
167  // The menu's own state.
168  let note = ''
169  let recent: { name: string; path: string }[] = []
170  let snapshot: { config: Config; slots: [string, unknown][] } | null = null
171
172  const projectKey = () => `image:${root}`
173  const slotKey = (s: Scope) => (s === 'project' ? projectKey() : 'image:global')
174  const viewKeyFor = (s: Scope) => `view:${s === 'project' ? root : 'global'}`
175  const here = (): Scope => scope ?? 'global'
176
177  function encodeImage(img: Rgba, name: string): Stored {
178    if (transparency(img) > 0) return { w: img.width, h: img.height, rgba: img.data.toBase64(), name }
179    const rgb = new Uint8Array(img.width * img.height * 3)
180    for (let i = 0, j = 0; i < img.data.length; i += 4, j += 3) { rgb[j] = img.data[i]; rgb[j + 1] = img.data[i + 1]; rgb[j + 2] = img.data[i + 2] }
181    return { w: img.width, h: img.height, rgb: rgb.toBase64(), name }
182  }
183
184  function decodeImage(s: Stored | undefined): Rgba | null {
185    if (!s || typeof s.w !== 'number' || typeof s.h !== 'number') return null
186    if (s.anim) return decodeAnim(s.w, s.h, s.anim).frames[0]
187    if (typeof s.rgba === 'string') return { width: s.w, height: s.h, data: Uint8Array.fromBase64(s.rgba) }
188    if (typeof s.rgb !== 'string') return null
189    const rgb = Uint8Array.fromBase64(s.rgb)
190    const data = new Uint8Array(s.w * s.h * 4)
191    for (let i = 0, j = 0; j < rgb.length; i += 4, j += 3) { data[i] = rgb[j]; data[i + 1] = rgb[j + 1]; data[i + 2] = rgb[j + 2]; data[i + 3] = 255 }
192    return { width: s.w, height: s.h, data }
193  }
194
195  async function loadState() {
196    if (!host) return
197    const c = (await host.get('config')) as Partial<Config> | undefined
198    config = { ...DEFAULTS, ...(c ?? {}) }
199    const project = (await host.get(projectKey())) as Stored | undefined
200    const global = (await host.get('image:global')) as Stored | undefined
201    const pick = project ?? global
202    scope = project ? 'project' : global ? 'global' : null
203    const anim = pick?.anim && pick.w && pick.h ? decodeAnim(pick.w, pick.h, pick.anim) : undefined
204    const image = anim ? anim.frames[0] : decodeImage(pick)
205    imageName = image ? pick?.name ?? 'image' : ''
206    if (pick?.scene && isScene(pick.scene)) source = { kind: 'scene', scene: pick.scene }
207    else if (image) source = { kind: 'image', image, name: imageName, clear: transparency(image), anim }
208    else source = null
209    const v = scope ? ((await host.get(viewKeyFor(scope))) as Partial<View> | undefined) : undefined
210    view = { ...DEFAULT_VIEW, ...(v ?? {}) }
211    artKey = ''
212    sceneCache = null
213  }
214
215  const redraw = () => { try { host?.invalidate() } catch { /* not mounted yet */ } }
216
217  const layoutOf = (): 'banner' | 'fit' => {
218    if (config.layout !== 'auto') return config.layout
219    return source?.kind === 'image' && source.clear >= FIT_IF_TRANSPARENT ? 'fit' : 'banner'
220  }
221
222  /**
223   * The art for an image in a band `cols` wide and `rows` tall, re-made only when something that
224   * shapes it changes. A banner fills the whole width with a crop; fit shows all of it, centred.
225   */
226  function imageArt(rows: number, cols: number): Art | null {
227    if (source?.kind !== 'image') return null
228    const layout = layoutOf()
229    const key = `${layout}|${cols}x${rows}|${pixelsNow()}|${config.style}|${config.colors}|${view.focusX},${view.focusY},${view.zoom}|${source.name}`
230    if (artKey !== key) { artKey = key; frameArts = new Map(); animSample = null }
231    const anim = source.anim
232    const index = anim ? frameAt(anim, animT) : 0
233    const hit = frameArts.get(index)
234    if (hit) return hit
235    const w = pixelCols(cols, pixelsNow()), h = rows * 2
236    const shrink = (img: Rgba): Rgba => {
237      if (layout === 'banner') return downscaleRegion(img, cropRect(img.width, img.height, w / h, view), w, h)
238      const f = fit(img.width, img.height, rows, w)
239      return downscale(img, f.w, f.h)
240    }
241    if (anim && !animSample) animSample = stack(spread(anim.frames, 6).map(shrink))
242    const small = shrink(anim ? anim.frames[index] : source.image)
243    const inner = stylize(small, config.style, config.colors, animSample ?? small)
244    let made: Art = inner
245    if (layout === 'fit') {
246      const px = new Uint32Array(w * inner.h).fill(TRANSPARENT)
247      const left = Math.floor((w - inner.w) / 2)
248      for (let y = 0; y < inner.h; y++) px.set(inner.px.subarray(y * inner.w, (y + 1) * inner.w), y * w + left)
249      made = { w, h: inner.h, px }
250    }
251    frameArts.set(index, made)
252    return made
253  }
254
255  /**
256   * This moment's frame of a scene, run through the style if one is picked. A scene always renders
257   * at the band's full height; a shorter band (slim while Claude works) shows the most telling rows.
258   */
259  function sceneArt(rows: number, cols: number): Art | null {
260    if (source?.kind !== 'scene') return null
261    const w = pixelCols(cols, pixelsNow()), h = rows * 2
262    const fullH = Math.max(h, fullRows() * 2)
263    const flash = mood === 'done' ? ticks * FRAME_MS : null
264    const key = `${source.scene}|${w}x${h}/${fullH}|${sceneT}|${flash}|${config.style}|${config.colors}`
265    if (sceneCache?.key === key) return sceneCache.art
266    const rkey = `${source.scene}|${w}x${fullH}`
267    if (!renderer || rendererKey !== rkey) { renderer = makeScene(source.scene, w, fullH); rendererKey = rkey }
268    const whole = renderer({ t: sceneT, energy, flash })
269    const top = Math.min(fullH - h, Math.max(0, Math.round(SLIM_FOCUS[source.scene] * fullH - h / 2)))
270    const px = top === 0 && h === fullH ? whole : whole.slice(top * w, (top + h) * w)
271    let a: Art = { w, h, px }
272    if (config.style !== 'original') {
273      const data = new Uint8Array(w * h * 4)
274      for (let i = 0; i < px.length; i++) {
275        const c = px[i]
276        if (c === TRANSPARENT) continue
277        data[i * 4] = (c >> 16) & 255; data[i * 4 + 1] = (c >> 8) & 255; data[i * 4 + 2] = c & 255; data[i * 4 + 3] = 255
278      }
279      a = stylize({ width: w, height: h, data }, config.style, config.colors)
280    }
281    sceneCache = { key, art: a }
282    return a
283  }
284
285  /** This moment's art and pixels, with the mood's effect applied. */
286  function currentFrame(): { a: Art; px: Uint32Array } | null {
287    if (!band || !source) return null
288    const { rows, cols } = band.want
289    const a = source.kind === 'image' ? imageArt(rows, cols) : sceneArt(rows, cols)
290    if (!a) return null
291    // Scenes show working and done themselves (heavier rain, lightning); intro and error apply to both.
292    const m = source.kind === 'scene' && (mood === 'working' || mood === 'done') ? 'idle' : mood
293    return { a, px: frame(a, m, ticks * FRAME_MS) }
294  }
295
296  function currentCells(): string | null {
297    const f = currentFrame()
298    if (!f) return null
299    // The creation scene is lit low for its spark; in 256 colours that lands where the palette has
300    // no browns, so it's lifted first (see brighten).
301    const lifted = source?.kind === 'scene' && source.scene === 'creation' ? brighten(f.px, CREATION_LIFT_256) : f.px
302    const shown = use256() ? to256(lifted) : f.px
303    return pixelsNow() === 'fine' ? quadCellsFor(f.a, shown) : cellsFor(f.a, shown)
304  }
305
306  function blit() {
307    if (!band || !host) return
308    // An Svg can't be patched in place: the next frame is a redraw, of the band alone. A plain
309    // invalidate would redraw the menu too, rebuilding its buttons under the pointer every frame.
310    if (!inTerminal()) { Promise.resolve(host.bump()).catch(() => redraw()); return }
311    const cells = currentCells()
312    if (!cells) return
313    Promise.resolve(host.blit({ requestId: band.requestId, key: KEY, cells, columns: band.columns, rows: band.rows }))
314      .catch(() => { /* band gone or resized: the next render fixes it */ })
315  }
316
317  /** The band's full height for the current terminal. */
318  const fullRows = () => (limits ? Math.min(config.rows || autoRows(limits.maxRows), limits.maxRows, Math.floor(MAX_CELLS / limits.cols)) : 0)
319
320  /** Rows the band wants: its full height, or slim/hidden while Claude works. */
321  function targetRows(): number {
322    if (!limits) return 0
323    const full = fullRows()
324    if (!working || config.whileWorking === 'full') return full
325    return config.whileWorking === 'hide' ? 0 : Math.min(SLIM_ROWS, full)
326  }
327
328  const resizing = () => limits !== null && config.enabled && shownRows !== targetRows()
329
330  /** A scene or an animated image: something that moves by itself. */
331  const moving = () => source?.kind === 'scene' || (source?.kind === 'image' && !!source.anim)
332
333  /** Whether the band is on screen: nothing to animate otherwise (hidden, off, or slid away). */
334  const visible = () => band !== null && config.enabled
335
336  // One-shot effects (at most 1.4 s) always run out, so they end right; the ongoing ones (the
337  // working sweep, scenes, GIFs) only run while there's a band to show them.
338  const animating = () =>
339    isOneShot(mood) || (mood === 'working' && visible()) || resizing() || (moving() && config.animate && visible())
340
341  function stopTimer() { timer?.cancel(); timer = null }
342
343  function syncTimer() {
344    if (!animating()) stopTimer()
345    else if (!timer && host) timer = host.every(FRAME_MS, tick)
346  }
347
348  function tick() {
349    ticks++
350    if (resizing()) {
351      const target = targetRows()
352      shownRows = shownRows < target ? Math.min(target, shownRows + ROW_STEP) : Math.max(target, shownRows - ROW_STEP)
353      redraw() // a new height needs a real render, not a blit
354    }
355    if (source?.kind === 'scene' && config.animate) {
356      sceneT += FRAME_MS
357      energy += ((working ? 1 : 0) - energy) * 0.1
358    }
359    if (source?.kind === 'image' && source.anim && config.animate) animT += FRAME_MS
360    if (isOneShot(mood) && ticks * FRAME_MS >= DURATION[mood]) setMood(working ? 'working' : 'idle')
361    else blit()
362  }
363
364  function setMood(next: Mood) {
365    mood = next
366    ticks = 0
367    syncTimer()
368    blit()
369  }
370
371  // ---------------------------------------------------------------------------------------------
372  // Actions, shared by the commands and the menu. Each returns what to tell the person.
373
374  async function save() {
375    await host?.set('config', config)
376    artKey = ''; sceneCache = null
377    if (limits) shownRows = targetRows() // a size picked by hand applies at once; only turns glide
378    redraw(); syncTimer()
379  }
380  async function saveView() { if (scope) await host?.set(viewKeyFor(scope), view); artKey = ''; redraw() }
381
382  async function writeSlot(s: Scope, value: Stored): Promise<string | null> {
383    const put = (await host?.set(slotKey(s), value)) as { deny?: string } | undefined
384    return put && put.deny ? put.deny : null
385  }
386
387  async function setImage(input: string, s: Scope): Promise<string> {
388    if (!host) return 'not ready yet.'
389    if (!input.trim()) return 'Usage: /pixelband set <path to an image> [--here]. Tip: drag the file into the terminal.'
390    const path = cleanPath(input, (await host.home()) as string | undefined)
391    let loaded
392    try { loaded = await loadImage(host.io, path) } catch (err: any) { return `${err?.message ?? err}` }
393    const name = path.split('/').pop() || path
394    const frames = loaded.frames
395    const stored: Stored = frames && loaded.delays
396      ? { w: frames[0].width, h: frames[0].height, anim: encodeAnim(frames, loaded.delays), name }
397      : encodeImage(shrinkToFit(loaded.image, STORED_MAX_SIDE), name)
398    const deny = await writeSlot(s, stored)
399    if (deny) return `couldn't save it (${deny}). Try /pixelband clear on banners you no longer use.`
400    view = { ...DEFAULT_VIEW }
401    await host.set(viewKeyFor(s), view)
402    await loadState()
403    if (!config.enabled) { config.enabled = true; await host.set('config', config) }
404    redraw()
405    setMood('intro')
406    animT = 0
407    const what = frames ? `${frames.length} frames, ${loaded.image.width}x${loaded.image.height}` : `${loaded.image.width}x${loaded.image.height}`
408    return `${name} is now ${s === 'project' ? "this project's" : 'your'} banner (${what}, read via ${loaded.via}).`
409  }
410
411  async function setScene(name: string, s: Scope): Promise<string> {
412    if (!host) return 'not ready yet.'
413    if (!isScene(name)) return `Scenes: ${SCENES.map((n) => `${n} (${SCENE_LABELS[n]})`).join(', ')}. Use /pixelband scene <name>.`
414    const current = ((await host.get(slotKey(s))) as Stored | undefined) ?? {}
415    const deny = await writeSlot(s, { ...current, scene: name })
416    if (deny) return `couldn't save it (${deny}).`
417    await loadState()
418    if (!config.enabled) { config.enabled = true; await host.set('config', config) }
419    renderer = null
420    redraw()
421    setMood('intro')
422    return `showing ${name}: ${SCENE_LABELS[name]}${s === 'project' ? ' (this project)' : ''}.`
423  }
424
425  /** Back from a scene to the image kept in the same slot. */
426  async function showImage(): Promise<string> {
427    if (!host || !scope) return 'set an image first.'
428    const current = ((await host.get(slotKey(scope))) as Stored | undefined) ?? {}
429    if (!decodeImage(current)) return 'set an image first.'
430    const { scene: _, ...rest } = current
431    await writeSlot(scope, rest)
432    await loadState()
433    redraw()
434    setMood('intro')
435    return `showing ${imageName}.`
436  }
437
438  async function setScope(to: Scope): Promise<string> {
439    if (!host || !scope) return 'set an image or scene first.'
440    if (to === scope) return to === 'project' ? 'already just for this project.' : 'already shown everywhere.'
441    const current = await host.get(slotKey(scope))
442    const deny = await writeSlot(to, current as Stored)
443    if (deny) return `couldn't save it (${deny}).`
444    await host.set(viewKeyFor(to), view)
445    // A move, not a copy: "only this project" means other projects stop showing it.
446    const from = scope
447    await host.del(slotKey(from)); await host.del(viewKeyFor(from))
448    await loadState()
449    redraw()
450    return to === 'project' ? 'now shown only in this project.' : 'now shown in every project.'
451  }
452
453  async function setStyle(name: string): Promise<string> {
454    if (!(STYLES as readonly string[]).includes(name)) return `Usage: /pixelband style ${STYLES.join('|')}`
455    config.style = name as Style; await save()
456    return `style: ${name}.`
457  }
458
459  async function setLayout(mode: string): Promise<string> {
460    if (!['auto', 'banner', 'fit'].includes(mode)) return 'Usage: /pixelband layout banner|fit|auto'
461    config.layout = mode as Layout; await save()
462    return `layout: ${mode}${mode === 'auto' ? ` (${layoutOf()} for this image)` : ''}.`
463  }
464
465  async function move(dx: number, dy: number): Promise<string> {
466    view = { ...view, focusX: Math.min(1, Math.max(0, view.focusX + dx)), focusY: Math.min(1, Math.max(0, view.focusY + dy)) }
467    await saveView()
468    return `crop centred at ${Math.round(view.focusX * 100)}% across, ${Math.round(view.focusY * 100)}% down.`
469  }
470
471  async function zoom(how: string): Promise<string> {
472    if (!['in', 'out', 'reset'].includes(how)) return 'Usage: /pixelband zoom in|out|reset'
473    const z = how === 'reset' ? 1 : Math.min(4, Math.max(1, view.zoom * (how === 'in' ? 1.25 : 0.8)))
474    view = how === 'reset' ? { ...DEFAULT_VIEW } : { ...view, zoom: Math.round(z * 100) / 100 }
475    await saveView()
476    return `zoom ${view.zoom}x.`
477  }
478
479  async function setRows(n: number): Promise<string> {
480    config.rows = n <= 0 ? 0 : clampInt(n, 2, 24); await save()
481    return config.rows ? `${config.rows} rows tall (${config.rows * 2} pixels), as much as the terminal allows.` : 'auto height: about a quarter of the terminal.'
482  }
483
484  const rowsNow = () => config.rows || autoRows(limits?.maxRows ?? 29)
485
486  async function setWhileWorking(mode: string): Promise<string> {
487    if (!['slim', 'full', 'hide'].includes(mode)) return 'Usage: /pixelband working slim|full|hide'
488    config.whileWorking = mode as WhileWorking; await save()
489    return mode === 'slim' ? `while Claude works the band shrinks to ${SLIM_ROWS} rows, then grows back.`
490      : mode === 'hide' ? 'while Claude works the band hides, then comes back.' : 'the band stays full size while Claude works.'
491  }
492
493  async function setColors(n: number): Promise<string> {
494    config.colors = clampInt(n, 2, 32); await save()
495    return `${config.colors}-colour palette.`
496  }
497
498  async function setEnabled(show: boolean): Promise<string> {
499    config.enabled = show; await save()
500    return show ? 'on.' : 'off.'
501  }
502
503  async function setColorMode(mode: string): Promise<string> {
504    if (!['auto', 'full', '256'].includes(mode)) return 'Usage: /pixelband colormode auto|full|256'
505    config.colorMode = mode as ColorMode; await save()
506    const now = use256() ? '256 colours' : 'full colour'
507    return mode === 'auto' ? `colours: auto (${now} in this terminal).` : `colours: ${now}.`
508  }
509
510  async function setPixels(mode: string): Promise<string> {
511    if (!['standard', 'fine'].includes(mode)) return 'Usage: /pixelband pixels standard|fine'
512    config.pixelsFor = { ...(config.pixelsFor ?? {}), [terminal]: mode as Pixels }; await save()
513    const where = terminal === 'unknown' ? 'this terminal' : terminal.replace(/\.app$/, '').replace('Apple_Terminal', 'macOS Terminal')
514    return mode === 'fine' ? `fine pixels in ${where}: four per character, for fonts with tight line spacing.` : `standard pixels in ${where}: two per character.`
515  }
516
517  async function setAnimate(move: boolean): Promise<string> {
518    config.animate = move; await save()
519    return move ? 'scenes and GIFs animate.' : 'scenes and GIFs hold still.'
520  }
521
522  // ---------------------------------------------------------------------------------------------
523  // The menu
524
525  /** The few newest images in Downloads, Desktop and Pictures, for one-press picking. */
526  async function findRecent() {
527    if (!host) return
528    const home = String((await host.home()) ?? '').replace(/\/$/, '')
529    if (!home) return
530    const found: { name: string; path: string; mtime: number }[] = []
531    for (const dir of RECENT_DIRS) {
532      let entries: { name: string; kind: string }[] = []
533      try { entries = ((await host.list(`${home}/${dir}`)) as typeof entries) ?? [] } catch { continue }
534      const images = entries.filter((f) => f.kind === 'file' && IMAGE_FILE.test(f.name)).slice(-40)
535      for (const f of images) {
536        const path = `${home}/${dir}/${f.name}`
537        try { found.push({ name: f.name, path, mtime: Number(((await host.stat(path)) as { mtimeMs?: number })?.mtimeMs) || 0 }) } catch { /* gone */ }
538      }
539    }
540    recent = found.sort((a, b) => b.mtime - a.mtime).slice(0, 4).map(({ name, path }) => ({ name, path }))
541    redraw()
542  }
543
544  async function openMenu() {
545    if (!host) return
546    const keys = ['config', 'image:global', projectKey(), viewKeyFor('global'), viewKeyFor('project')]
547    const slots: [string, unknown][] = []
548    for (const k of keys) slots.push([k, await host.get(k)])
549    snapshot = { config: { ...config }, slots }
550    note = ''
551    await host.open()
552    findRecent().catch(() => { /* recent images are a nicety */ })
553    // The keyboard only goes to a pane when Claude Code can spare it; ask once more a moment later.
554    host.after(250, () => {
555      Promise.resolve(host?.panes()).then((list) => {
556        const pane = ((list as { id: string; isFocused: boolean }[]) ?? []).find((p) => p.id === MENU)
557        if (pane && !pane.isFocused) return host?.open()
558      }).catch(() => { /* the hint in the menu covers it */ })
559    })
560  }
561
562  async function revert(): Promise<string> {
563    if (!host || !snapshot) return 'nothing to undo.'
564    for (const [k, v] of snapshot.slots) {
565      if (v === undefined) await host.del(k)
566      else await host.set(k, v)
567    }
568    await loadState()
569    renderer = null
570    redraw()
571    syncTimer()
572    return 'back to how it was when you opened the menu.'
573  }
574
575  /** Run a menu action, then show its message in the menu. */
576  const act = (fn: () => Promise<string>) => () => {
577    fn().then((msg) => { note = msg; redraw() }, (err) => { note = `${err?.message ?? err}`; redraw() })
578  }
579
580  // ---------------------------------------------------------------------------------------------
581  // Hooks
582
583  on('session.start', async ($, e, next) => {
584    host = {
585      every: (ms, fn) => $.clock.every(ms, fn),
586      after: (ms, fn) => $.clock.after(ms, fn),
587      blit: (args) => $.ui.blit(args),
588      invalidate: () => $.ui.invalidate('ui.render'),
589      bump: () => $.state.set({ plugin: 'pixelband', key: 'frame' }, ++frameNo),
590      get: (key) => $.store.get(key),
591      set: (key, value) => $.store.set(key, value as any),
592      del: (key) => $.store.delete(key),
593      home: () => $.env.get('HOME'),
594      list: (path) => $.fs.list(path),
595      stat: (path) => $.fs.stat(path),
596      open: () => $.ui.open({ id: MENU, title: 'pixelband', focus: true, closeOnEscape: true, rows: 18 }),
597      close: () => $.ui.close({ id: MENU }),
598      panes: () => $.ui.panes(),
599      io: {
600        readBase64: (path) => $.fs.read(path, { as: 'bytes' }),
601        tmpdir: () => $.env.get('TMPDIR'),
602        run: (argv) => $.process.run(argv),
603        size: async (path) => ((await $.fs.stat(path)) as { size?: number } | undefined)?.size,
604      },
605    }
606    const program = String((await $.env.get('TERM_PROGRAM')) ?? '')
607    const colorterm = String((await $.env.get('COLORTERM')) ?? '')
608    only256 = program === 'Apple_Terminal' && !/truecolor|24bit/i.test(colorterm)
609    terminal = program || 'unknown'
610    root = (e as any).cwd ?? ''
611    try { root = (await $.session.root()) || root } catch { /* not in a repo: cwd will do */ }
612    await loadState()
613    ready = true
614    redraw()
615    await $.command.register({
616      name: 'pixelband',
617      description: 'Pixel art above your prompt. No arguments opens the menu; or set <image>, scene <name>, style, size, on, off',
618      argumentHint: '[set <image> | scene <name> | style <name> | move <dir> | zoom <in|out> | size <rows> | on | off]',
619    })
620    return next(e)
621  })
622
623  on('turn.start', ($, e, next) => {
624    if (!(e as any).agentId) { working = true; if (mood !== 'working') setMood('working'); else syncTimer() }
625    return next(e)
626  })
627
628  on('turn.complete', ($, e, next) => {
629    if (!(e as any).agentId) {
630      working = false
631      const reason = (e as any).reason
632      setMood(reason === 'answer' ? 'done' : reason === 'error' || reason === 'refusal' ? 'error' : 'idle')
633    }
634    return next(e)
635  })
636
637  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
638    const props = (e as any).props
639    if (!config.enabled || props.hasSurvey) { band = null; syncTimer(); return next(e) }
640    const { Box, Text, Raster, Svg } = $.ui.resolve(e) as any
641    surface = (e as any).surface ?? 'terminal'
642    if (!inTerminal() && !Svg) { band = null; syncTimer(); return next(e) }
643    if (!source) {
644      band = null
645      if (!ready) return next(e)
646      return h(Text, { dimColor: true }, 'pixelband · /pixelband to pick an image or scene') as RenderElement
647    }
648    const cols = Math.max(1, Math.min(props.bodyColumns ?? 80, MAX_COLS))
649    limits = { cols, maxRows: Math.max(1, props.maxRows ?? 24) }
650    // Catch up if a turn event was missed.
651    if (props.isWorking && !working) { working = true; if (mood === 'idle') setMood('working') }
652    if (!props.isWorking && working) { working = false; if (mood === 'working') setMood('idle') }
653    if (shownRows < 0) shownRows = targetRows()
654    const rows = Math.min(shownRows, limits.maxRows, Math.floor(MAX_CELLS / cols))
655    if (rows < 1) { band = null; syncTimer(); return next(e) }
656
657    // A fit image may be shorter than the band; a banner or scene fills it.
658    const drawnRows = source.kind === 'image' ? rowsFor(imageArt(rows, cols)?.h ?? rows * 2) : rows
659    band = { requestId: (e as any).requestId, columns: cols, rows: drawnRows, want: { rows, cols } }
660
661    syncTimer()
662
663    if (!inTerminal()) {
664      // Reading the counter subscribes the band to it, so each bump redraws this hook alone.
665      await $.state.get({ plugin: 'pixelband', key: 'frame' })
666      const f = currentFrame()
667      if (!f) return next(e)
668      const alt = source.kind === 'scene' ? `pixelband: ${SCENE_LABELS[source.scene]}` : `pixelband: ${source.name}`
669      return h(Box, { flexDirection: 'row' }, h(Svg, { source: svgWithin(f.a, f.px), alt })) as RenderElement
670    }
671    const cells = currentCells()
672    if (!cells) return next(e)
673    return h(Box, { flexDirection: 'row' }, h(Raster, { key: KEY, columns: band.columns, rows: band.rows, cells })) as RenderElement
674  })
675
676  on('ui.render', { component: 'Pane', requestId: MENU }, ($, e, next) => {
677    const { Box, Text, Button, Input, Link } = $.ui.resolve(e) as any
678    const ui = $.ui.resolve(e) as any
679    const props = (e as any).props
680    const width = Math.max(30, props.bodyColumns ?? 80)
681    const fitText = (t: string, n: number) => (t.length > n ? `${t.slice(0, Math.max(1, n - 1))}…` : t)
682    // Every dropdown option fits the pane after its label: a wrapped row garbles the terminal.
683    const Select = ui.Select
684    const select = (p: { options: { value: string; label?: string }[] } & Record<string, unknown>) =>
685      h(Select, { ...p, options: p.options.map((o) => ({ value: o.value, label: fitText(o.label ?? o.value, width - 12) })) })
686    // Every row stays on one line: a row that wraps makes the terminal redraw leave stale copies behind.
687    const row = (...kids: unknown[]) => h(Box, { flexDirection: 'row', columnGap: 1 }, ...kids)
688    const line = (t: string, style: object = {}) => h(Text, { wrap: 'truncate-end', ...style }, fitText(t, width))
689    const s = here()
690    const showing = source?.kind === 'scene' ? `scene:${source.scene}` : source ? 'image' : 'none'
691    const options = [
692      ...(imageName ? [{ value: 'image', label: fitText(`your image (${imageName})`, width - 12) }] : []),
693      ...SCENES.map((n) => ({ value: `scene:${n}`, label: fitText(`${n}: ${SCENE_LABELS[n]}`, width - 12) })),
694    ]
695    if (showing === 'none') options.unshift({ value: 'none', label: 'nothing yet: pick a scene or an image' })
696
697    return h(Box, { flexDirection: 'column' },
698      props.isFocused
699        ? line('Tab / ↑↓ move · Enter picks · Esc closes · changes show in the band live', { dimColor: true })
700        : line('press ctrl+x then tab to use this menu · Esc closes', { color: 'yellow' }),
701      select({
702        key: 'source', label: 'Show     ', options, value: showing, autoFocus: true,
703        onSelect: (v: string) => act(() => (v === 'image' ? showImage() : v.startsWith('scene:') ? setScene(v.slice(6), s) : Promise.resolve('')))(),
704      }),
705      h(Input, {
706        key: 'path', label: 'Image    ', placeholder: 'drag an image here, or type its path', submitLabel: 'use it',
707        onSubmit: (v: string) => act(() => setImage(v, s))(),
708      }),
709      recent.length
710        ? select({
711          key: 'recent', label: 'Recent   ', value: '',
712          options: [{ value: '', label: `${recent.length} newest in Downloads, Desktop, Pictures` }, ...recent.map((f) => ({ value: f.path, label: fitText(f.name, width - 14) }))],
713          onSelect: (v: string) => { if (v) act(() => setImage(v, s))() },
714        })
715        : null,
716      select({
717        key: 'style', label: 'Style    ', value: config.style,
718        options: STYLES.map((v) => ({ value: v })),
719        onSelect: (v: string) => act(() => setStyle(v))(),
720      }),
721      row(
722        h(Text, {}, 'Height   '),
723        h(Button, { key: 'rows-', label: '-', onPress: act(() => setRows(rowsNow() - 1)) }),
724        h(Text, {}, config.rows ? `${config.rows} rows` : `auto (${rowsNow()})`),
725        h(Button, { key: 'rows+', label: '+', onPress: act(() => setRows(rowsNow() + 1)) }),
726        config.rows ? h(Button, { key: 'rows-auto', label: 'auto', plain: true, dimColor: true, onPress: act(() => setRows(0)) }) : null,
727      ),
728      select({
729        key: 'working', label: 'Working  ', value: config.whileWorking,
730        options: [
731          { value: 'slim', label: `shrink to ${SLIM_ROWS} rows while Claude works` },
732          { value: 'hide', label: 'hide while Claude works' },
733          { value: 'full', label: 'stay full size' },
734        ],
735        onSelect: (v: string) => act(() => setWhileWorking(v))(),
736      }),
737      select({
738        key: 'pixels', label: 'Pixels   ', value: pixelsNow(),
739        options: [
740          { value: 'standard', label: 'standard: 2 per character (most terminals)' },
741          { value: 'fine', label: 'fine: 4 per character, if pixels look wide (this terminal)' },
742        ],
743        onSelect: (v: string) => act(() => setPixels(v))(),
744      }),
745      select({
746        key: 'colormode', label: 'Colours  ', value: config.colorMode,
747        options: [
748          { value: 'auto', label: `auto (${only256 ? '256 colours: this looks like macOS Terminal' : 'full colour'})` },
749          { value: 'full', label: 'full colour' },
750          { value: '256', label: '256 colours (older terminals)' },
751        ],
752        onSelect: (v: string) => act(() => setColorMode(v))(),
753      }),
754      source?.kind === 'image'
755        ? row(
756          h(Text, {}, 'Crop     '),
757          h(Button, { key: 'up', hotkey: 'w', plain: true, label: '↑', onPress: act(() => move(0, -MOVE_STEP)) }),
758          h(Button, { key: 'left', hotkey: 'a', plain: true, label: '←', onPress: act(() => move(-MOVE_STEP, 0)) }),
759          h(Button, { key: 'down', hotkey: 's', plain: true, label: '↓', onPress: act(() => move(0, MOVE_STEP)) }),
760          h(Button, { key: 'right', hotkey: 'd', plain: true, label: '→', onPress: act(() => move(MOVE_STEP, 0)) }),
761          h(Button, { key: 'zoom-in', hotkey: 'z', plain: true, label: 'in', onPress: act(() => zoom('in')) }),
762          h(Button, { key: 'zoom-out', hotkey: 'x', plain: true, label: 'out', onPress: act(() => zoom('out')) }),
763          h(Button, { key: 'reset', hotkey: 'r', plain: true, label: 'reset', onPress: act(() => zoom('reset')) }),
764        )
765        : null,
766      moving()
767        ? row(h(Text, {}, 'Motion   '), h(Button, { key: 'animate', label: config.animate ? 'pause' : 'animate', onPress: act(() => setAnimate(!config.animate)) }), h(Text, { dimColor: true }, config.animate ? 'moving' : 'paused'))
768        : null,
769      source
770        ? select({
771          key: 'scope', label: 'Where    ', value: s,
772          options: [{ value: 'global', label: 'every project' }, { value: 'project', label: 'only this project' }],
773          onSelect: (v: string) => act(() => setScope(v as Scope))(),
774        })
775        : null,
776      row(
777        h(Button, { key: 'done', variant: 'primary', role: 'dismiss', label: 'Done', onPress: () => { Promise.resolve(host?.close()).catch(() => {}) } }),
778        h(Button, { key: 'toggle', label: config.enabled ? 'Hide band' : 'Show band', onPress: act(() => setEnabled(!config.enabled)) }),
779        h(Button, { key: 'revert', label: 'Undo changes', onPress: act(revert) }),
780      ),
781      note ? line(note, { dimColor: true }) : null,
782      row(h(Text, { dimColor: true }, 'like it? a star helps:'), h(Link, { href: REPO_URL, label: 'github.com/furqan-khan07/pixelband' })),
783    ) as RenderElement
784  })
785
786  on('command.run', { command: 'pixelband' }, async ($, e, next) => {
787    const args = String((e as any).args ?? '').trim()
788    const [sub] = args.split(/\s+/)
789    const tail = args.slice(sub.length).trim()
790    const isHere = /(^|\s)--here$/.test(tail)
791    const arg = tail.replace(/(^|\s)--here$/, '').trim()
792    const reply = (text: string) => ({ text })
793
794    switch ((sub || '').toLowerCase()) {
795      case '':
796      case 'menu':
797        await openMenu()
798        return {}
799      case 'set':
800        return reply(await setImage(arg, isHere ? 'project' : here()))
801      case 'scene':
802        return reply(await setScene(arg.toLowerCase(), isHere ? 'project' : here()))
803      case 'size': {
804        if (arg.toLowerCase() === 'auto') return reply(await setRows(0))
805        const n = Number(arg)
806        return reply(Number.isFinite(n) && arg ? await setRows(n) : 'Usage: /pixelband size <rows> (2 to 24) or size auto.')
807      }
808      case 'working':
809        return reply(await setWhileWorking(arg.toLowerCase()))
810      case 'pixels':
811        return reply(await setPixels(arg.toLowerCase()))
812      case 'colormode':
813      case 'colourmode':
814        return reply(await setColorMode(arg.toLowerCase()))
815      case 'style':
816        return reply(await setStyle(arg.toLowerCase()))
817      case 'layout':
818        return reply(await setLayout(arg.toLowerCase()))
819      case 'move': {
820        const [dir, count] = arg.toLowerCase().split(/\s+/)
821        const n = Math.max(1, Math.min(10, Number(count) || 1)) * MOVE_STEP
822        const d = ({ up: [0, -n], down: [0, n], left: [-n, 0], right: [n, 0] } as Record<string, number[]>)[dir]
823        return reply(d ? await move(d[0], d[1]) : 'Usage: /pixelband move up|down|left|right [steps]')
824      }
825      case 'zoom':
826        return reply(await zoom(arg.toLowerCase()))
827      case 'colors':
828      case 'colours': {
829        const n = Number(arg)
830        return reply(Number.isFinite(n) && arg ? await setColors(n) : 'Usage: /pixelband colors <n>, from 2 to 32.')
831      }
832      case 'on':
833      case 'off':
834        return reply(await setEnabled(sub.toLowerCase() === 'on'))
835      case 'animate':
836        return reply(['on', 'off'].includes(arg) ? await setAnimate(arg === 'on') : 'Usage: /pixelband animate on|off')
837      case 'clear': {
838        // Clear what's showing here (this project's banner if it has one), or this project's with --here.
839        const which: Scope = isHere ? 'project' : here()
840        await $.store.delete(slotKey(which))
841        await $.store.delete(viewKeyFor(which))
842        await loadState(); redraw(); syncTimer()
843        return reply(`cleared ${which === 'project' ? "this project's" : 'the global'} banner.`)
844      }
845      case 'demo': {
846        const m = arg.toLowerCase()
847        if (!['working', 'done', 'error', 'intro'].includes(m)) return reply('Usage: /pixelband demo working|done|error|intro')
848        if (!source) return reply('set an image or scene first.')
849        // A demo "working" has no turn to end it, so stop it after a few seconds.
850        working = m === 'working'
851        setMood(m as Mood)
852        if (m === 'working') $.clock.after(4000, () => { if (mood === 'working') { working = false; setMood('idle') } })
853        return reply(`playing ${m}.`)
854      }
855      case 'status':
856      case 'help': {
857        const where = scope === 'project' ? 'this project' : 'global'
858        const status = !source ? 'nothing showing yet'
859          : `showing ${source.kind === 'scene' ? `the ${source.scene} scene` : source.name} (${where})${source.kind === 'image' ? `, ${layoutOf()}${source.anim ? `, ${source.anim.frames.length} frames` : ''}` : ''}, ${config.style}, ${config.rows ? `${config.rows} rows` : 'auto height'}${use256() ? ', 256 colours' : ''}, ${config.enabled ? 'on' : 'off'}`
860        return reply(`${status}.\n/pixelband opens the menu. Or: set <image> [--here] · scene <${SCENES.join('|')}> · style <${STYLES.join('|')}> · layout <banner|fit|auto> · move <up|down|left|right> · zoom <in|out|reset> · size <rows|auto> · working <slim|full|hide> · colormode <auto|full|256> · pixels <standard|fine> · colors <n> · animate <on|off> · on · off · clear [--here] · demo <working|done|error|intro>`)
861      }
862      default:
863        return reply(`unknown command "${sub}". /pixelband help lists them, or /pixelband alone opens the menu.`)
864    }
865  })
866}
867
hooks/anim.ts 100 lines
1/**
2 * Animations (from GIFs): the frames, their timing, and how they're kept in the store.
3 *
4 * The store holds 4 MiB across everything, so frames are kept as one shared palette of up to 255
5 * colours plus a byte per pixel, rather than RGBA: a 120-frame loop fits in about 1.6 MB.
6 */
7import { medianCut, nearest } from './pixelate'
8import type { Rgba } from './png'
9
10export interface Anim { frames: Rgba[]; delays: number[]; total: number }
11
12/** An animation as the store keeps it. Index 0 is see-through when `see` is set. */
13export interface StoredAnim { palette: string; see: boolean; frames: string; delays: number[] }
14
15type Rgb = [number, number, number]
16
17export function makeAnim(frames: Rgba[], delays: number[]): Anim {
18  return { frames, delays, total: delays.reduce((a, b) => a + b, 0) || 1 }
19}
20
21/** Which frame shows `t` ms into the loop. */
22export function frameAt(anim: Anim, t: number): number {
23  let x = ((t % anim.total) + anim.total) % anim.total
24  for (let i = 0; i < anim.delays.length; i++) {
25    if (x < anim.delays[i]) return i
26    x -= anim.delays[i]
27  }
28  return anim.delays.length - 1
29}
30
31export function encodeAnim(frames: Rgba[], delays: number[]): StoredAnim {
32  let see = false
33  const sample: Rgb[] = []
34  const total = frames.reduce((n, f) => n + f.width * f.height, 0)
35  const every = Math.max(1, Math.floor(total / 60_000))
36  let k = 0
37  for (const f of frames) {
38    for (let i = 0; i < f.width * f.height; i++) {
39      if (f.data[i * 4 + 3] < 128) { see = true; continue }
40      if (k++ % every === 0) sample.push([f.data[i * 4], f.data[i * 4 + 1], f.data[i * 4 + 2]])
41    }
42  }
43  const colors = medianCut(sample, see ? 255 : 256)
44  const offset = see ? 1 : 0
45  const palette = new Uint8Array((colors.length + offset) * 3)
46  colors.forEach((c, i) => palette.set(c, (i + offset) * 3))
47  // Nearest palette entry per colour, remembered at 5 bits a channel: frames share most colours.
48  const memo = new Map<number, number>()
49  const indexOf = (r: number, g: number, b: number) => {
50    const key = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3)
51    let v = memo.get(key)
52    if (v === undefined) {
53      const c = nearest([r, g, b], colors)
54      v = colors.indexOf(c) + offset
55      memo.set(key, v)
56    }
57    return v
58  }
59  const out = new Uint8Array(total)
60  let o = 0
61  for (const f of frames) {
62    for (let i = 0; i < f.width * f.height; i++) {
63      out[o++] = f.data[i * 4 + 3] < 128 ? 0 : indexOf(f.data[i * 4], f.data[i * 4 + 1], f.data[i * 4 + 2])
64    }
65  }
66  return { palette: palette.toBase64(), see, frames: out.toBase64(), delays }
67}
68
69export function decodeAnim(w: number, h: number, s: StoredAnim): Anim {
70  const pal = Uint8Array.fromBase64(s.palette), idx = Uint8Array.fromBase64(s.frames)
71  const frames: Rgba[] = []
72  for (let f = 0; f < s.delays.length; f++) {
73    const data = new Uint8Array(w * h * 4)
74    for (let i = 0; i < w * h; i++) {
75      const v = idx[f * w * h + i]
76      if (s.see && v === 0) continue
77      data[i * 4] = pal[v * 3]; data[i * 4 + 1] = pal[v * 3 + 1]; data[i * 4 + 2] = pal[v * 3 + 2]; data[i * 4 + 3] = 255
78    }
79    frames.push({ width: w, height: h, data })
80  }
81  return makeAnim(frames, s.delays)
82}
83
84/** Frames of one width stacked into one tall image: a sample for a palette every frame shares. */
85export function stack(frames: Rgba[]): Rgba {
86  const w = frames[0].width
87  const h = frames.reduce((n, f) => n + f.height, 0)
88  const data = new Uint8Array(w * h * 4)
89  let o = 0
90  for (const f of frames) { data.set(f.data, o); o += f.data.length }
91  return { width: w, height: h, data }
92}
93
94/** Up to `n` frames spread evenly through the loop. */
95export function spread<T>(items: T[], n: number): T[] {
96  if (items.length <= n) return items
97  return Array.from({ length: n }, (_, i) => items[Math.floor((i * items.length) / n)])
98}
99
100
hooks/effects.ts 112 lines
1/**
2 * The band's moods. Each is a pure function of the pixel art and the time since the mood started,
3 * so frames are reproducible (and testable) and a timer just asks for the next one.
4 *
5 *   intro    the art dissolves in, pixel by pixel, after /pixelband set
6 *   working  a band of light sweeps across while Claude is working
7 *   done     a bright flash with a few sparkles when a turn finishes
8 *   error    a glitch: rows jump, colour channels split, a red tint that fades out
9 */
10import { TRANSPARENT, type Art } from './pixelate'
11
12export type Mood = 'idle' | 'intro' | 'working' | 'done' | 'error'
13
14/** How long the one-shot moods last before the band settles, in ms. */
15export const DURATION: Record<'intro' | 'done' | 'error', number> = { intro: 700, done: 900, error: 1400 }
16
17/** The frame interval the animation runs at while a mood is playing. */
18export const FRAME_MS = 80
19
20const WHITE = 0xffffff
21const RED = 0xff2a4a
22
23/** Deterministic pseudo-random in [0, 1) from integers (a small integer hash). */
24export function hash(a: number, b: number, c: number): number {
25  let h = (a * 374761393 + b * 668265263 + c * 2147483647) | 0
26  h = Math.imul(h ^ (h >>> 13), 1274126177)
27  return ((h ^ (h >>> 16)) >>> 0) / 4294967296
28}
29
30const R = (c: number) => (c >> 16) & 255
31const G = (c: number) => (c >> 8) & 255
32const B = (c: number) => c & 255
33const rgb = (r: number, g: number, b: number) => ((r << 16) | (g << 8) | b) >>> 0
34
35/** Blend `c` toward `to` by `k` (0..1), leaving transparent pixels alone. */
36export function mix(c: number, to: number, k: number): number {
37  if (c === TRANSPARENT || k <= 0) return c
38  const m = (a: number, b: number) => Math.round(a + (b - a) * Math.min(1, k))
39  return rgb(m(R(c), R(to)), m(G(c), G(to)), m(B(c), B(to)))
40}
41
42/** Whether a mood plays once and then settles back. */
43export function isOneShot(m: Mood): m is 'intro' | 'done' | 'error' {
44  return m === 'intro' || m === 'done' || m === 'error'
45}
46
47/** The frame for `mood`, `t` ms after it started. */
48export function frame(art: Art, mood: Mood, t: number): Uint32Array {
49  const { w, h, px } = art
50  const out = new Uint32Array(px)
51  if (mood === 'idle') return out
52
53  if (mood === 'intro') {
54    const p = Math.min(1, t / DURATION.intro)
55    for (let y = 0; y < h; y++) {
56      for (let x = 0; x < w; x++) {
57        const i = y * w + x
58        const at = hash(x, y, 7)
59        if (at > p) out[i] = TRANSPARENT                 // not revealed yet
60        else if (p - at < 0.1) out[i] = mix(px[i], WHITE, 0.7) // just revealed: a brief glint
61      }
62    }
63    return out
64  }
65
66  if (mood === 'working') {
67    // A diagonal band of light, one sweep every 1.1 s, looping while Claude works.
68    const period = w + h + 6
69    const pos = ((t / 1100) % 1) * period - 3
70    for (let y = 0; y < h; y++) {
71      for (let x = 0; x < w; x++) {
72        const d = Math.abs(x + y - pos)
73        if (d < 2.5) out[y * w + x] = mix(px[y * w + x], WHITE, (1 - d / 2.5) * 0.55)
74      }
75    }
76    return out
77  }
78
79  if (mood === 'done') {
80    const p = Math.min(1, t / DURATION.done)
81    const flash = 0.85 * (1 - p) ** 2
82    const tick = Math.floor(t / FRAME_MS)
83    for (let i = 0; i < out.length; i++) {
84      if (px[i] === TRANSPARENT) continue
85      out[i] = hash(i, tick, 11) < 0.06 * (1 - p) ? WHITE : mix(px[i], WHITE, flash)
86    }
87    return out
88  }
89
90  // error: glitch
91  const p = Math.min(1, t / DURATION.error)
92  const k = 1 - p
93  const tick = Math.floor(t / FRAME_MS)
94  for (let y = 0; y < h; y++) {
95    const jump = hash(y, tick, 3) < 0.35 * k ? Math.round((hash(y, tick, 5) - 0.5) * 6 * k) : 0
96    const dim = y % 2 === tick % 2 ? 0.15 * k : 0
97    for (let x = 0; x < w; x++) {
98      const at = (dx: number) => px[y * w + Math.min(w - 1, Math.max(0, x - jump + dx))]
99      const mid = at(0)
100      if (mid === TRANSPARENT) { out[y * w + x] = TRANSPARENT; continue }
101      // channel split: red from the right neighbour, blue from the left
102      const rs = at(1), bs = at(-1)
103      const split = rgb(rs === TRANSPARENT ? R(mid) : R(rs), G(mid), bs === TRANSPARENT ? B(mid) : B(bs))
104      let c = mix(mid, split, k) // the split fades out with the glitch, so the art settles back exactly
105      c = mix(c, RED, 0.35 * k)
106      if (dim) c = mix(c, 0x000000, dim)
107      out[y * w + x] = c
108    }
109  }
110  return out
111}
112
hooks/load.ts 180 lines
1/**
2 * Getting an image off disk and into RGBA.
3 *
4 * PNG, BMP and GIF (animated too) decode right here in the mod. Everything else people actually have
5 * (JPEG, HEIC from an iPhone, WebP) goes through the OS: `sips` ships with every Mac, ImageMagick is common on
6 * Linux. They also shrink the photo on the way, so we never pull a 12-megapixel image into the mod.
7 */
8import { decodeBmp, isBmp } from './bmp'
9import { countGifFrames, decodeGif, isGif } from './gif'
10import { downscale } from './pixelate'
11import { decodePng, isPng, type Rgba } from './png'
12
13/** How big an image we decode ourselves before handing it to the OS tool to shrink first. */
14const MAX_DIRECT_PIXELS = 1024 * 1024
15/** Longest side we ask the OS tool for. The band is tiny; this is plenty. */
16const TOOL_MAX_SIDE = 256
17
18/** An image, plus its frames and their delays (ms) when it's animated. */
19export interface Loaded { image: Rgba; via: string; frames?: Rgba[]; delays?: number[] }
20
21/** Most frames we keep: longer GIFs keep every 2nd, 3rd... frame, with the timing added up. */
22export const MAX_FRAMES = 120
23/** Pixels across all kept frames, so an animation fits the store with room to spare. */
24const ANIM_BUDGET = 1_200_000
25const ANIM_MAX_SIDE = 240
26const STILL_MAX_SIDE = 320
27const MAX_GIF_BYTES = 30 * 1024 * 1024
28
29async function sizeOf(io: Io, path: string): Promise<number | null> {
30  if (!io.size) return null
31  try { const n = Number(await io.size(path)); return Number.isFinite(n) && n >= 0 ? n : null } catch { return null }
32}
33
34/**
35 * Read a file bigger than one read allows: `split` cuts it into temporary pieces in the temp folder,
36 * each is read, and the pieces are deleted again.
37 */
38async function readBig(io: Io, path: string, size: number): Promise<Uint8Array> {
39  const tmp = String((await io.tmpdir()) || '/tmp').replace(/\/$/, '')
40  const prefix = `${tmp}/pixelband-${Math.random().toString(36).slice(2)}-`
41  const count = Math.ceil(size / CHUNK)
42  const names = Array.from({ length: count }, (_, i) => prefix + String.fromCharCode(97 + Math.floor(i / 26), 97 + (i % 26)))
43  try {
44    const res = (await io.run(['split', '-b', String(CHUNK), path, prefix])) as { exitCode?: number } | undefined
45    if (!res || res.exitCode !== 0) throw new Error(`couldn't read ${path} in pieces (it's over 4 MB)`)
46    const out = new Uint8Array(size)
47    let o = 0
48    for (const name of names) { const part = await readBytes(io, name); out.set(part.subarray(0, size - o), o); o += part.length }
49    return out.subarray(0, Math.min(o, size))
50  } finally {
51    try { await io.run(['rm', '-f', ...names]) } catch { /* best effort */ }
52  }
53}
54
55/** Decode a GIF, shrinking each frame as it comes so a big one never sits in memory whole. */
56export function loadGif(bytes: Uint8Array): Loaded {
57  if (bytes.length > MAX_GIF_BYTES) throw new Error(`that GIF is ${Math.round(bytes.length / 1048576)} MB; up to 30 MB works`)
58  const count = countGifFrames(bytes)
59  const step = Math.max(1, Math.ceil(count / MAX_FRAMES))
60  const kept = Math.ceil(count / step)
61  const w = bytes[6] | (bytes[7] << 8), h = bytes[8] | (bytes[9] << 8)
62  const scale = Math.min(1, (count > 1 ? ANIM_MAX_SIDE : STILL_MAX_SIDE) / Math.max(w, h), count > 1 ? Math.sqrt(ANIM_BUDGET / (kept * w * h)) : 1)
63  const tw = Math.max(1, Math.round(w * scale)), th = Math.max(1, Math.round(h * scale))
64  const frames: Rgba[] = [], delays: number[] = []
65  decodeGif(bytes, (canvas, delay, i) => {
66    if (i % step === 0) { frames.push(scale === 1 ? { width: w, height: h, data: canvas.data.slice() } : downscale(canvas, tw, th)); delays.push(delay) }
67    else delays[delays.length - 1] += delay
68  })
69  return frames.length > 1 ? { image: frames[0], via: 'gif', frames, delays } : { image: frames[0], via: 'gif' }
70}
71
72/**
73 * What loading needs from Claude Code. Passed as plain functions because a mod may not hand `$`
74 * around: every engine call is written out as `$.noun.event(...)` where it happens (register.ts).
75 */
76export interface Io {
77  readBase64: (path: string) => Promise<unknown>
78  tmpdir: () => Promise<unknown>
79  run: (argv: string[]) => Promise<unknown>
80  /** The file's size in bytes, when it can be told without reading it. */
81  size?: (path: string) => Promise<unknown>
82}
83
84/** Claude Code reads at most 4 MiB of a file at once. */
85const READ_LIMIT = 4 * 1024 * 1024
86/** Bigger GIFs are read in pieces this size. */
87const CHUNK = 3 * 1024 * 1024
88
89/**
90 * Tidy up a path the way people actually give it: quoted, with backslash-escaped spaces from
91 * dragging a file into the terminal, or starting with `~`.
92 */
93export function cleanPath(input: string, home?: string): string {
94  let p = input.trim()
95  if (p.length > 1 && ((p[0] === '"' && p.endsWith('"')) || (p[0] === "'" && p.endsWith("'")))) p = p.slice(1, -1)
96  p = p.replace(/\\(.)/g, '$1')
97  if (home && (p === '~' || p.startsWith('~/'))) p = home.replace(/\/$/, '') + p.slice(1)
98  return p
99}
100
101async function readBytes(io: Io, path: string): Promise<Uint8Array> {
102  const r = (await io.readBase64(path)) as { base64?: unknown } | undefined
103  if (r && typeof r === 'object' && typeof r.base64 === 'string') return Uint8Array.fromBase64(r.base64)
104  throw new Error(`unexpected read result: ${JSON.stringify(r)?.slice(0, 120)}`)
105}
106
107function pngPixels(b: Uint8Array): number {
108  const u32 = (o: number) => ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0
109  return u32(16) * u32(20)
110}
111
112/** Ask sips, then ImageMagick, to turn `path` into a small BMP we can read. */
113async function viaTool(io: Io, path: string): Promise<Loaded> {
114  const tmp = String((await io.tmpdir()) || '/tmp').replace(/\/$/, '')
115  const out = `${tmp}/pixelband-${Math.random().toString(36).slice(2)}.bmp`
116  const attempts: [string, string[]][] = [
117    ['sips', ['sips', '-s', 'format', 'bmp', '--resampleHeightWidthMax', String(TOOL_MAX_SIDE), path, '--out', out]],
118    ['magick', ['magick', path, '-resize', `${TOOL_MAX_SIDE}x${TOOL_MAX_SIDE}>`, `BMP3:${out}`]],
119    ['convert', ['convert', path, '-resize', `${TOOL_MAX_SIDE}x${TOOL_MAX_SIDE}>`, `BMP3:${out}`]],
120  ]
121  for (const [name, argv] of attempts) {
122    try {
123      const res = (await io.run(argv)) as { exitCode?: number } | undefined
124      if (!res || res.exitCode !== 0) continue
125      return { image: decodeBmp(await readBytes(io, out)), via: name }
126    } catch {
127      continue
128    } finally {
129      try { await io.run(['rm', '-f', out]) } catch { /* best effort */ }
130    }
131  }
132  throw new Error("I can read PNG and BMP myself; for other formats I need macOS's sips or ImageMagick, and neither worked. Try a PNG?")
133}
134
135/** Photo formats go straight to the OS tool: no point pulling a 20 MB HEIC through the mod. */
136const TOOL_FIRST = /\.(jpe?g|heic|heif|webp|tiff?|avif|icns|psd)$/i
137
138/** Load any image we can into RGBA. */
139export async function loadImage(io: Io, path: string): Promise<Loaded> {
140  let toolError: unknown = null
141  const size = await sizeOf(io, path)
142  if (size !== null && size > READ_LIMIT) {
143    // Too big for one read. A GIF is read in pieces so its frames survive; anything else goes to the
144    // OS tool, which reads the file itself and hands back a small copy.
145    if (/\.gif$/i.test(path)) {
146      if (size > MAX_GIF_BYTES) throw new Error(`that GIF is ${Math.round(size / 1048576)} MB; up to 30 MB works`)
147      return loadGif(await readBig(io, path, size))
148    }
149    return viaTool(io, path)
150  }
151  if (TOOL_FIRST.test(path)) {
152    try { return await viaTool(io, path) } catch (err) { toolError = err } // maybe it's a mislabelled PNG
153  }
154  let bytes: Uint8Array
155  try {
156    bytes = await readBytes(io, path)
157  } catch (err: any) {
158    const why = err?.message ?? String(err)
159    throw new Error(`couldn't read ${path}${why ? ` (${why})` : ''}`)
160  }
161  if (isPng(bytes)) {
162    // Small PNGs decode here; huge ones get shrunk by the OS tool first when there is one.
163    if (pngPixels(bytes) <= MAX_DIRECT_PIXELS) {
164      try { return { image: decodePng(bytes), via: 'png' } } catch { /* e.g. interlaced: try the tool */ }
165    }
166    try { return await viaTool(io, path) } catch { return { image: decodePng(bytes), via: 'png' } }
167  }
168  if (isGif(bytes)) {
169    try { return loadGif(bytes) } catch (err: any) {
170      if (/MB; up to/.test(err?.message ?? '')) throw err
171      // an odd GIF: let the OS tool have a go at its first frame
172    }
173  }
174  if (isBmp(bytes)) {
175    try { return { image: decodeBmp(bytes), via: 'bmp' } } catch { /* odd BMP: try the tool */ }
176  }
177  if (toolError) throw toolError
178  return viaTool(io, path)
179}
180
hooks/pixelate.ts 172 lines
1/**
2 * Image -> pixel art.
3 *
4 * A terminal band is tiny (think 40x12 pixels), and a photo shrunk that far just looks blurry. So
5 * instead of pretending to be a photo, we lean into pixel art: area-average down to the grid, give
6 * the colours a little punch, then cut them down to a small palette so every pixel reads as a
7 * deliberate block of colour.
8 */
9import type { Rgba } from './png'
10
11/** Pixel art: `px` holds 0x00RRGGBB per pixel, or TRANSPARENT where the image was see-through. */
12export interface Art { w: number; h: number; px: Uint32Array }
13
14export const TRANSPARENT = 0x01000000
15
16/**
17 * Pixel grid for a band `rows` terminal rows tall (two pixels per row), keeping the image's shape
18 * and never wider than `maxCols`. Half-block pixels are roughly square, so aspect carries over.
19 */
20export function fit(srcW: number, srcH: number, rows: number, maxCols: number): { w: number; h: number } {
21  let h = Math.max(2, rows * 2)
22  let w = Math.max(1, Math.round((h * srcW) / srcH))
23  if (w > maxCols) {
24    w = Math.max(1, maxCols)
25    h = Math.max(2, Math.round((w * srcH) / srcW))
26  }
27  return { w, h }
28}
29
30/** A rectangle of the source image, in source pixels (fractional is fine). */
31export interface Rect { x: number; y: number; w: number; h: number }
32
33/** Where the banner looks: the crop's centre as a fraction of the image, and how far it's zoomed in. */
34export interface View { focusX: number; focusY: number; zoom: number }
35
36export const DEFAULT_VIEW: View = { focusX: 0.5, focusY: 0.5, zoom: 1 }
37
38/**
39 * The part of the image a banner of shape `aspect` (width / height) shows: the biggest rectangle of
40 * that shape that fits, shrunk by `zoom`, centred on the focus and kept inside the image.
41 */
42export function cropRect(srcW: number, srcH: number, aspect: number, view: View): Rect {
43  let w = srcW, h = srcW / aspect
44  if (h > srcH) { h = srcH; w = srcH * aspect }
45  const z = Math.max(1, view.zoom)
46  w /= z; h /= z
47  const x = Math.min(srcW - w, Math.max(0, view.focusX * srcW - w / 2))
48  const y = Math.min(srcH - h, Math.max(0, view.focusY * srcH - h / 2))
49  return { x, y, w, h }
50}
51
52/** Area-average resize. Colour is weighted by alpha so transparent edges don't turn grey. */
53export function downscale(src: Rgba, w: number, h: number): Rgba {
54  return downscaleRegion(src, { x: 0, y: 0, w: src.width, h: src.height }, w, h)
55}
56
57/** Area-average the `rect` part of `src` down (or up) to `w x h`. */
58export function downscaleRegion(src: Rgba, rect: Rect, w: number, h: number): Rgba {
59  const out = new Uint8Array(w * h * 4)
60  const sx = rect.w / w, sy = rect.h / h
61  for (let ty = 0; ty < h; ty++) {
62    const y0 = rect.y + ty * sy, y1 = y0 + sy
63    for (let tx = 0; tx < w; tx++) {
64      const x0 = rect.x + tx * sx, x1 = x0 + sx
65      let r = 0, g = 0, b = 0, a = 0, total = 0
66      for (let y = Math.max(0, Math.floor(y0)); y < Math.min(src.height, Math.ceil(y1)); y++) {
67        const wy = Math.min(y + 1, y1) - Math.max(y, y0)
68        for (let x = Math.max(0, Math.floor(x0)); x < Math.min(src.width, Math.ceil(x1)); x++) {
69          const wgt = wy * (Math.min(x + 1, x1) - Math.max(x, x0))
70          const o = (y * src.width + x) * 4
71          const al = src.data[o + 3] * wgt
72          r += src.data[o] * al; g += src.data[o + 1] * al; b += src.data[o + 2] * al
73          a += al; total += wgt
74        }
75      }
76      const o = (ty * w + tx) * 4
77      if (a > 0) { out[o] = r / a; out[o + 1] = g / a; out[o + 2] = b / a }
78      out[o + 3] = total > 0 ? a / total : 0
79    }
80  }
81  return { width: w, height: h, data: out }
82}
83
84/** Shrink so the longest side is at most `max` (what gets stored, so resizing later stays cheap). */
85export function shrinkToFit(src: Rgba, max: number): Rgba {
86  const scale = Math.min(1, max / Math.max(src.width, src.height))
87  if (scale === 1) return src
88  return downscale(src, Math.max(1, Math.round(src.width * scale)), Math.max(1, Math.round(src.height * scale)))
89}
90
91const clamp = (v: number) => (v < 0 ? 0 : v > 255 ? 255 : v)
92
93/** A bit more saturation and contrast: shrunk images go muddy, pixel art shouldn't. */
94export function punch(r: number, g: number, b: number): [number, number, number] {
95  const lum = 0.299 * r + 0.587 * g + 0.114 * b
96  const s = (c: number) => clamp(((lum + (c - lum) * 1.12) - 128) * 1.05 + 128)
97  return [s(r), s(g), s(b)]
98}
99
100/** Median-cut palette: split the colour box with the widest spread until we have `k` boxes. */
101export function medianCut(colors: [number, number, number][], k: number): [number, number, number][] {
102  if (!colors.length) return []
103  let boxes: [number, number, number][][] = [colors]
104  const spread = (box: [number, number, number][]) => {
105    let best = 0, ch = 0
106    for (let c = 0; c < 3; c++) {
107      let lo = 255, hi = 0
108      for (const p of box) { if (p[c] < lo) lo = p[c]; if (p[c] > hi) hi = p[c] }
109      if (hi - lo > best) { best = hi - lo; ch = c }
110    }
111    return { best, ch }
112  }
113  while (boxes.length < k) {
114    let pick = -1, pickScore = 0, pickCh = 0
115    boxes.forEach((box, i) => {
116      if (box.length < 2) return
117      const { best, ch } = spread(box)
118      const score = best * Math.sqrt(box.length)
119      if (score > pickScore) { pickScore = score; pick = i; pickCh = ch }
120    })
121    if (pick < 0) break
122    const box = boxes[pick].slice().sort((a, b) => a[pickCh] - b[pickCh])
123    const mid = box.length >> 1
124    boxes = [...boxes.slice(0, pick), box.slice(0, mid), box.slice(mid), ...boxes.slice(pick + 1)]
125  }
126  return boxes.map((box) => {
127    const s = [0, 0, 0]
128    for (const p of box) { s[0] += p[0]; s[1] += p[1]; s[2] += p[2] }
129    return [Math.round(s[0] / box.length), Math.round(s[1] / box.length), Math.round(s[2] / box.length)]
130  })
131}
132
133/** Nearest palette entry, weighted roughly the way eyes weigh red, green and blue. */
134export function nearest(p: [number, number, number], palette: [number, number, number][]): [number, number, number] {
135  let best = palette[0], bestD = Infinity
136  for (const c of palette) {
137    const d = 2 * (p[0] - c[0]) ** 2 + 4 * (p[1] - c[1]) ** 2 + 3 * (p[2] - c[2]) ** 2
138    if (d < bestD) { bestD = d; best = c }
139  }
140  return best
141}
142
143/** The whole pipeline: resize to `w x h`, punch up the colours, reduce to `colors` colours. */
144export function pixelate(src: Rgba, w: number, h: number, colors: number): Art {
145  const small = downscale(src, w, h)
146  const opaque: [number, number, number][] = []
147  const punched: ([number, number, number] | null)[] = []
148  for (let i = 0; i < w * h; i++) {
149    const o = i * 4
150    if (small.data[o + 3] < 128) { punched.push(null); continue }
151    const p = punch(small.data[o], small.data[o + 1], small.data[o + 2])
152    punched.push(p)
153    opaque.push(p)
154  }
155  const palette = medianCut(opaque, Math.max(2, colors))
156  const px = new Uint32Array(w * h)
157  for (let i = 0; i < w * h; i++) {
158    const p = punched[i]
159    if (!p) { px[i] = TRANSPARENT; continue }
160    const [r, g, b] = nearest(p, palette)
161    px[i] = ((r << 16) | (g << 8) | b) >>> 0
162  }
163  return { w, h, px }
164}
165
166/** Share of pixels that are mostly see-through: logos and sprites have lots, photos have none. */
167export function transparency(src: Rgba): number {
168  let n = 0
169  for (let i = 3; i < src.data.length; i += 4) if (src.data[i] < 128) n++
170  return n / (src.width * src.height)
171}
172
hooks/png.ts 131 lines
1/**
2 * PNG decoder: bytes in, RGBA out. Handles every non-interlaced PNG a person is likely to have:
3 * grayscale, RGB, palette, gray+alpha and RGBA, at 1/2/4/8/16 bits, with tRNS transparency.
4 * Interlaced (Adam7) PNGs throw, and the loader falls back to the OS image tool for those.
5 */
6import { inflateZlib } from './inflate'
7
8export interface Rgba { width: number; height: number; data: Uint8Array }
9
10const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]
11
12export function isPng(b: Uint8Array): boolean {
13  return b.length > 8 && SIGNATURE.every((v, i) => b[i] === v)
14}
15
16const u32 = (b: Uint8Array, o: number) => ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0
17
18function paeth(a: number, b: number, c: number): number {
19  const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c)
20  return pa <= pb && pa <= pc ? a : pb <= pc ? b : c
21}
22
23export function decodePng(bytes: Uint8Array): Rgba {
24  if (!isPng(bytes)) throw new Error('not a PNG')
25  let width = 0, height = 0, depth = 0, colorType = 0, interlace = 0
26  let palette: Uint8Array | null = null
27  let trns: Uint8Array | null = null
28  const idat: Uint8Array[] = []
29  for (let o = 8; o + 8 <= bytes.length;) {
30    const len = u32(bytes, o)
31    const type = String.fromCharCode(bytes[o + 4], bytes[o + 5], bytes[o + 6], bytes[o + 7])
32    const body = bytes.subarray(o + 8, o + 8 + len)
33    if (type === 'IHDR') {
34      width = u32(body, 0); height = u32(body, 4); depth = body[8]; colorType = body[9]; interlace = body[12]
35    } else if (type === 'PLTE') palette = body
36    else if (type === 'tRNS') trns = body
37    else if (type === 'IDAT') idat.push(body)
38    else if (type === 'IEND') break
39    o += 12 + len
40  }
41  if (!width || !height) throw new Error('PNG has no image header')
42  if (interlace) throw new Error('interlaced PNGs are not supported')
43  const channels = ({ 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 } as Record<number, number>)[colorType]
44  if (!channels) throw new Error(`unsupported PNG color type ${colorType}`)
45  if (colorType === 3 && !palette) throw new Error('palette PNG without a palette')
46
47  const total = idat.reduce((n, c) => n + c.length, 0)
48  const joined = new Uint8Array(total)
49  let at = 0
50  for (const c of idat) { joined.set(c, at); at += c.length }
51  const rowBytes = Math.ceil((width * channels * depth) / 8)
52  // The pixels need exactly this much once inflated; anything bigger is a malformed or hostile file.
53  const expected = height * (rowBytes + 1)
54  const raw = inflateZlib(joined, expected, expected * 2 + 1024)
55  if (raw.length < height * (rowBytes + 1)) throw new Error('PNG image data is truncated')
56
57  // Undo the per-row filters in place, into `rows`.
58  const bpp = Math.max(1, Math.ceil((channels * depth) / 8))
59  const rows = new Uint8Array(height * rowBytes)
60  for (let y = 0; y < height; y++) {
61    const filter = raw[y * (rowBytes + 1)]
62    const src = y * (rowBytes + 1) + 1
63    const dst = y * rowBytes
64    for (let x = 0; x < rowBytes; x++) {
65      const v = raw[src + x]
66      const a = x >= bpp ? rows[dst + x - bpp] : 0
67      const b = y > 0 ? rows[dst - rowBytes + x] : 0
68      const c = x >= bpp && y > 0 ? rows[dst - rowBytes + x - bpp] : 0
69      let out: number
70      switch (filter) {
71        case 0: out = v; break
72        case 1: out = v + a; break
73        case 2: out = v + b; break
74        case 3: out = v + ((a + b) >> 1); break
75        case 4: out = v + paeth(a, b, c); break
76        default: throw new Error(`bad PNG filter ${filter}`)
77      }
78      rows[dst + x] = out & 255
79    }
80  }
81
82  // Read sample i of row y, scaled to 0..255 whatever the bit depth.
83  const sample = (y: number, i: number): number => {
84    const base = y * rowBytes
85    if (depth === 8) return rows[base + i]
86    if (depth === 16) return rows[base + i * 2]
87    const perByte = 8 / depth
88    const byte = rows[base + Math.floor(i / perByte)]
89    const shift = 8 - depth * ((i % perByte) + 1)
90    const v = (byte >> shift) & ((1 << depth) - 1)
91    return colorType === 3 ? v : Math.round((v * 255) / ((1 << depth) - 1))
92  }
93  const rawSample = (y: number, i: number): number => { // unscaled, for tRNS comparisons
94    const base = y * rowBytes
95    if (depth === 16) return (rows[base + i * 2] << 8) | rows[base + i * 2 + 1]
96    if (depth === 8) return rows[base + i]
97    const perByte = 8 / depth
98    return (rows[base + Math.floor(i / perByte)] >> (8 - depth * ((i % perByte) + 1))) & ((1 << depth) - 1)
99  }
100  const trnsGray = trns && colorType === 0 ? (trns[0] << 8) | trns[1] : -1
101  const trnsRgb = trns && colorType === 2 ? [(trns[0] << 8) | trns[1], (trns[2] << 8) | trns[3], (trns[4] << 8) | trns[5]] : null
102
103  const data = new Uint8Array(width * height * 4)
104  for (let y = 0; y < height; y++) {
105    for (let x = 0; x < width; x++) {
106      const o = (y * width + x) * 4
107      if (colorType === 0) {
108        const g = sample(y, x)
109        data[o] = data[o + 1] = data[o + 2] = g
110        data[o + 3] = rawSample(y, x) === trnsGray ? 0 : 255
111      } else if (colorType === 2) {
112        data[o] = sample(y, x * 3); data[o + 1] = sample(y, x * 3 + 1); data[o + 2] = sample(y, x * 3 + 2)
113        const hit = trnsRgb && rawSample(y, x * 3) === trnsRgb[0] && rawSample(y, x * 3 + 1) === trnsRgb[1] && rawSample(y, x * 3 + 2) === trnsRgb[2]
114        data[o + 3] = hit ? 0 : 255
115      } else if (colorType === 3) {
116        const idx = sample(y, x)
117        data[o] = palette![idx * 3]; data[o + 1] = palette![idx * 3 + 1]; data[o + 2] = palette![idx * 3 + 2]
118        data[o + 3] = trns && idx < trns.length ? trns[idx] : 255
119      } else if (colorType === 4) {
120        const g = sample(y, x * 2)
121        data[o] = data[o + 1] = data[o + 2] = g
122        data[o + 3] = sample(y, x * 2 + 1)
123      } else {
124        data[o] = sample(y, x * 4); data[o + 1] = sample(y, x * 4 + 1); data[o + 2] = sample(y, x * 4 + 2)
125        data[o + 3] = sample(y, x * 4 + 3)
126      }
127    }
128  }
129  return { width, height, data }
130}
131
hooks/palette256.ts 89 lines
1/**
2 * For terminals that only show 256 colours (macOS Terminal before macOS 26 is the big one).
3 *
4 * Claude Code rounds full colours down for these itself, by plain nearest colour, which sends dark
5 * saturated colours to grey: a Game Boy's dark green comes out charcoal. So in 256-colour mode
6 * pixelband picks from the terminal's palette first: the 6x6x6 colour cube and the 24 greys (the
7 * first 16 are left out, since every theme redefines them), comparing colours in OKLab with extra
8 * weight on hue, so greens stay green and oranges stay orange. The exact palette colours we hand
9 * over pass through Claude Code unchanged.
10 *
11 * Dithering between palette colours was tried and dropped: at band resolution it reads as speckle,
12 * not as a blend.
13 */
14import { TRANSPARENT } from './pixelate'
15
16const LEVELS = [0, 95, 135, 175, 215, 255]
17
18/** The 240 palette colours we use: cube (16-231), then greys (232-255). */
19export const XTERM: number[] = []
20for (const r of LEVELS) for (const g of LEVELS) for (const b of LEVELS) XTERM.push((r << 16) | (g << 8) | b)
21for (let k = 0; k < 24; k++) { const v = 8 + 10 * k; XTERM.push((v << 16) | (v << 8) | v) }
22
23/** Weight on OKLab's a/b (hue and saturation) relative to lightness. */
24const CHROMA_WEIGHT = 3
25
26const lin = (c: number) => { const v = c / 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 }
27
28function oklab(r: number, g: number, b: number): [number, number, number] {
29  const R = lin(r), G = lin(g), B = lin(b)
30  const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B)
31  const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B)
32  const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B)
33  return [
34    0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
35    1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
36    0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
37  ]
38}
39
40const PAL_LAB = XTERM.map((c) => oklab((c >> 16) & 255, (c >> 8) & 255, c & 255))
41
42/** The palette colour closest to (r, g, b) the way it looks, keeping hue where it can. */
43export function nearest256(r: number, g: number, b: number): number {
44  const [L, A, B] = oklab(r, g, b)
45  let best = 0, bestD = Infinity
46  for (let i = 0; i < PAL_LAB.length; i++) {
47    const [l, a, bb] = PAL_LAB[i]
48    const d = (L - l) ** 2 + CHROMA_WEIGHT * ((A - a) ** 2 + (B - bb) ** 2)
49    if (d < bestD) { bestD = d; best = i }
50  }
51  return XTERM[best]
52}
53
54/** Answers remembered per colour: a frame only has a few hundred. */
55const memo = new Map<number, number>()
56
57/** Map a frame onto the 256-colour palette; see-through pixels stay see-through. */
58export function to256(px: Uint32Array): Uint32Array {
59  const out = new Uint32Array(px.length)
60  for (let i = 0; i < px.length; i++) {
61    const c = px[i]
62    if (c === TRANSPARENT) { out[i] = c; continue }
63    let m = memo.get(c)
64    if (m === undefined) {
65      m = nearest256((c >> 16) & 255, (c >> 8) & 255, c & 255)
66      if (memo.size > 50_000) memo.clear()
67      memo.set(c, m)
68    }
69    out[i] = m
70  }
71  return out
72}
73
74/**
75 * Lighten a frame by `f` before it goes to the palette. The cube has hardly any dark browns (its
76 * steps are 0, 95, 135), so a dim, warm picture such as the creation scene's fresco comes out as
77 * grey shadows and mustard skin; lifted into the 135-215 range, where the cube has tans and
78 * peaches, it reads as skin again.
79 */
80export function brighten(px: Uint32Array, f: number): Uint32Array {
81  const out = new Uint32Array(px.length)
82  const ch = (v: number) => Math.min(255, Math.round(v * f))
83  for (let i = 0; i < px.length; i++) {
84    const c = px[i]
85    out[i] = c === TRANSPARENT ? c : (ch((c >> 16) & 255) << 16) | (ch((c >> 8) & 255) << 8) | ch(c & 255)
86  }
87  return out
88}
89
hooks/raster.ts 109 lines
1/**
2 * Pixel art -> the cells of a `Raster` element.
3 *
4 * Each terminal cell shows two stacked pixels with the half-block trick: '▀' painted in the top
5 * pixel's colour over a background of the bottom pixel's colour. Transparent pixels fall back to
6 * the terminal's own default colours, so a logo with a see-through background blends in.
7 */
8import { TRANSPARENT, type Art } from './pixelate'
9
10const UPPER = 0x2580 // ▀
11const LOWER = 0x2584 // ▄
12const SPACE = 0x20
13/** The terminal's default colour, in the Raster cell encoding. */
14export const DEFAULT = 0x01000000
15
16export function rowsFor(h: number): number {
17  return Math.ceil(h / 2)
18}
19
20/** Encode `px` (w x h pixels) as base64 Raster cells, `w` columns by `rowsFor(h)` rows. */
21export function cellsFor(art: Art, px: Uint32Array = art.px): string {
22  const { w, h } = art
23  const rows = rowsFor(h)
24  const words = new Uint32Array(w * rows * 3)
25  for (let r = 0; r < rows; r++) {
26    for (let x = 0; x < w; x++) {
27      const top = px[2 * r * w + x]
28      const bottom = 2 * r + 1 < h ? px[(2 * r + 1) * w + x] : TRANSPARENT
29      const i = (r * w + x) * 3
30      if (top === TRANSPARENT && bottom === TRANSPARENT) {
31        words[i] = SPACE; words[i + 1] = DEFAULT; words[i + 2] = DEFAULT
32      } else if (top === TRANSPARENT) {
33        words[i] = LOWER; words[i + 1] = bottom; words[i + 2] = DEFAULT
34      } else if (bottom === TRANSPARENT) {
35        words[i] = UPPER; words[i + 1] = top; words[i + 2] = DEFAULT
36      } else {
37        words[i] = UPPER; words[i + 1] = top; words[i + 2] = bottom
38      }
39    }
40  }
41  return new Uint8Array(words.buffer).toBase64()
42}
43
44/** Quadrant glyphs by which corners are painted in the foreground: TL=1, TR=2, BL=4, BR=8. */
45const QUAD = [SPACE, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588]
46
47const dist = (a: number, b: number) => {
48  const dr = ((a >> 16) & 255) - ((b >> 16) & 255), dg = ((a >> 8) & 255) - ((b >> 8) & 255), db = (a & 255) - (b & 255)
49  return 2 * dr * dr + 4 * dg * dg + 3 * db * db
50}
51
52/** Of `colors`, the one closest to all the others: a cell's colours stay ones the art really has. */
53function medoid(colors: number[]): number {
54  let best = colors[0], bestD = Infinity
55  for (const c of colors) {
56    let d = 0
57    for (const o of colors) d += dist(c, o)
58    if (d < bestD) { bestD = d; best = c }
59  }
60  return best
61}
62
63/**
64 * Encode `px` (w x h pixels) as quadrant cells: four pixels per cell, `ceil(w/2)` columns by
65 * `rowsFor(h)` rows. For terminals whose cells are nearly square (a font with tight line spacing),
66 * where half-block pixels come out twice as wide as they are tall.
67 *
68 * A cell still has only two colours, so each cell splits its four pixels into the two groups that
69 * lose the least. See-through pixels always go to the background, which shows the terminal's own.
70 */
71export function quadCellsFor(art: Art, px: Uint32Array = art.px): string {
72  const { w, h } = art
73  const cols = Math.ceil(w / 2), rows = rowsFor(h)
74  const words = new Uint32Array(cols * rows * 3)
75  const at = (x: number, y: number) => (x < w && y < h ? px[y * w + x] : TRANSPARENT)
76  const quad = [0, 0, 0, 0]
77  for (let r = 0; r < rows; r++) {
78    for (let c = 0; c < cols; c++) {
79      quad[0] = at(2 * c, 2 * r); quad[1] = at(2 * c + 1, 2 * r)
80      quad[2] = at(2 * c, 2 * r + 1); quad[3] = at(2 * c + 1, 2 * r + 1)
81      const i = (r * cols + c) * 3
82      let mask = 0, fg = DEFAULT, bg = DEFAULT
83      const solid = quad.filter((p) => p !== TRANSPARENT)
84      if (solid.length === 0) {
85        // all see-through: leave the terminal showing
86      } else if (solid.length < 4) {
87        for (let k = 0; k < 4; k++) if (quad[k] !== TRANSPARENT) mask |= 1 << k
88        fg = medoid(solid)
89      } else if (quad[0] === quad[1] && quad[1] === quad[2] && quad[2] === quad[3]) {
90        mask = 15; fg = bg = quad[0]
91      } else {
92        // The seven ways to split four pixels in two (the group holding the top-left is the mask).
93        let bestErr = Infinity
94        for (const m of [1, 3, 5, 7, 9, 11, 13]) {
95          const a: number[] = [], b: number[] = []
96          for (let k = 0; k < 4; k++) ((m >> k) & 1 ? a : b).push(quad[k])
97          const ca = medoid(a), cb = medoid(b)
98          let err = 0
99          for (const p of a) err += dist(p, ca)
100          for (const p of b) err += dist(p, cb)
101          if (err < bestErr) { bestErr = err; mask = m; fg = ca; bg = cb }
102        }
103      }
104      words[i] = QUAD[mask]; words[i + 1] = fg; words[i + 2] = bg
105    }
106  }
107  return new Uint8Array(words.buffer).toBase64()
108}
109
hooks/svg.ts 66 lines
1/**
2 * Pixel art -> an SVG document, for apps that draw `Svg` but not `Raster` (the desktop app).
3 *
4 * Each colour becomes one path made of horizontal runs, so a flat sky costs a few characters per
5 * row instead of one rectangle per pixel. Transparent pixels are left out and show the app's own
6 * background, as the terminal's default colour does in a Raster.
7 *
8 * Each run reaches 0.05 of a pixel past its right and bottom edges. Scaled to fit the app, runs that
9 * end exactly where the next begins leave hairline gaps the background shows through; the overlap
10 * covers them and is far too small to see.
11 */
12import { TRANSPARENT, type Art } from './pixelate'
13
14/** The most characters an `Svg` element's document may have. */
15export const SVG_LIMIT = 131072
16/** CSS pixels per art pixel in the markup; the app scales the drawing down to fit its slot. */
17const SCALE = 12
18
19const hex = (c: number) => '#' + (c & 0xffffff).toString(16).padStart(6, '0')
20
21/** Encode `px` (`art.w` x `art.h` pixels) as an SVG document with square pixels. */
22export function svgFor(art: Art, px: Uint32Array = art.px): string {
23  const { w, h } = art
24  const runs = new Map<number, string[]>()
25  for (let y = 0; y < h; y++) {
26    let x = 0
27    while (x < w) {
28      const c = px[y * w + x]
29      let n = 1
30      while (x + n < w && px[y * w + x + n] === c) n++
31      if (c !== TRANSPARENT) {
32        let d = runs.get(c)
33        if (!d) runs.set(c, (d = []))
34        d.push(`M${x} ${y}h${n}.05v1.05H${x}z`)
35      }
36      x += n
37    }
38  }
39  let paths = ''
40  for (const [c, d] of runs) paths += `<path fill="${hex(c)}" d="${d.join('')}"/>`
41  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * SCALE}" height="${h * SCALE}" viewBox="0 0 ${w} ${h}" shape-rendering="crispEdges">${paths}</svg>`
42}
43
44/** Halve the art's resolution (each output pixel is the top-left of a 2x2 block). */
45function half(art: Art, px: Uint32Array): { art: Art; px: Uint32Array } {
46  const w = Math.max(1, art.w >> 1), h = Math.max(1, art.h >> 1)
47  const out = new Uint32Array(w * h)
48  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) out[y * w + x] = px[2 * y * art.w + 2 * x]
49  return { art: { w, h, px: out }, px: out }
50}
51
52/**
53 * `svgFor`, coarser until it fits the element's limit. A busy, full-width frame (lots of single
54 * pixels, as in rain or stars) can pass it at full resolution; half the pixels each way is a
55 * quarter of the runs, and the drawing keeps its size because the viewBox shrinks with it.
56 */
57export function svgWithin(art: Art, px: Uint32Array = art.px, limit = SVG_LIMIT): string {
58  let cur = { art, px }
59  let doc = svgFor(cur.art, cur.px)
60  while (doc.length > limit && cur.art.w > 1 && cur.art.h > 1) {
61    cur = half(cur.art, cur.px)
62    doc = svgFor(cur.art, cur.px).replace(/width="\d+" height="\d+"/, `width="${art.w * SCALE}" height="${art.h * SCALE}"`)
63  }
64  return doc
65}
66
hooks/scenes.ts 731 lines
1/**
2 * Built-in animated scenes, drawn from code instead of an image:
3 *
4 *   city     rain on a city at night: lit windows, a wet street, the odd flash of lightning
5 *   space    stars drifting past a ringed planet; warp speed while Claude works
6 *   aurora   northern lights over mountains and pines
7 *   fire     a wall of fire (the classic Doom fire), flames climb while Claude works
8 *   creation Michelangelo's hands, and a spark jumping the gap between the fingertips
9 *   matrix   green code raining down; it pours while Claude works
10 *   aquarium fish, bubbles and swaying weed; the fish dart about while Claude works
11 *
12 * A scene is made for one band size and then asked for frames. Each frame gets the time, an
13 * `energy` from 0 (idle) to 1 (Claude working) that eases between the two, and `flash`: how long
14 * ago a turn finished, which each scene celebrates its own way.
15 */
16import { CREATION } from './creation-data'
17import { hash } from './effects'
18import { downscaleRegion, TRANSPARENT } from './pixelate'
19import type { Rgba } from './png'
20import { stylize } from './styles'
21
22export const SCENES = ['city', 'space', 'aurora', 'fire', 'creation', 'matrix', 'aquarium'] as const
23export type SceneName = (typeof SCENES)[number]
24
25export interface SceneInput {
26  /** ms since the scene started. */
27  t: number
28  /** 0 idle .. 1 working. */
29  energy: number
30  /** ms since a turn finished, or null. */
31  flash: number | null
32}
33
34export type Renderer = (input: SceneInput) => Uint32Array
35
36/** How long a scene's finish flourish lasts, in ms. */
37export const FLASH_MS = 900
38
39export function isScene(name: string): name is SceneName {
40  return (SCENES as readonly string[]).includes(name)
41}
42
43export function makeScene(name: SceneName, w: number, h: number): Renderer {
44  switch (name) {
45    case 'city': return city(w, h)
46    case 'space': return space(w, h)
47    case 'aurora': return aurora(w, h)
48    case 'fire': return fire(w, h)
49    case 'creation': return creation(w, h)
50    case 'matrix': return matrix(w, h)
51    case 'aquarium': return aquarium(w, h)
52  }
53}
54
55// ---------------------------------------------------------------------------------------------
56// Drawing helpers: a float RGB canvas, packed to 0xRRGGBB at the end.
57
58type Rgb = readonly [number, number, number]
59
60class Canvas {
61  readonly buf: Float32Array
62  constructor(readonly w: number, readonly h: number) { this.buf = new Float32Array(w * h * 3) }
63  set(x: number, y: number, c: Rgb) {
64    if (x < 0 || y < 0 || x >= this.w || y >= this.h) return
65    const o = (y * this.w + x) * 3
66    this.buf[o] = c[0]; this.buf[o + 1] = c[1]; this.buf[o + 2] = c[2]
67  }
68  /** Blend toward `c` by `k`. */
69  mix(x: number, y: number, c: Rgb, k: number) {
70    if (x < 0 || y < 0 || x >= this.w || y >= this.h || k <= 0) return
71    if (k > 1) k = 1
72    const o = (y * this.w + x) * 3
73    this.buf[o] += (c[0] - this.buf[o]) * k
74    this.buf[o + 1] += (c[1] - this.buf[o + 1]) * k
75    this.buf[o + 2] += (c[2] - this.buf[o + 2]) * k
76  }
77  get(x: number, y: number): Rgb {
78    x = Math.min(this.w - 1, Math.max(0, x)); y = Math.min(this.h - 1, Math.max(0, y))
79    const o = (y * this.w + x) * 3
80    return [this.buf[o], this.buf[o + 1], this.buf[o + 2]]
81  }
82  pack(): Uint32Array {
83    const out = new Uint32Array(this.w * this.h)
84    const c = (v: number) => (v <= 0 ? 0 : v >= 255 ? 255 : Math.round(v))
85    for (let i = 0; i < out.length; i++) {
86      out[i] = ((c(this.buf[i * 3]) << 16) | (c(this.buf[i * 3 + 1]) << 8) | c(this.buf[i * 3 + 2])) >>> 0
87    }
88    return out
89  }
90}
91
92const lerp = (a: Rgb, b: Rgb, k: number): Rgb => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]
93const smooth = (k: number) => k * k * (3 - 2 * k)
94const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v)
95
96/** Smooth value noise in [0, 1). */
97function noise(x: number, y: number, seed: number): number {
98  const ix = Math.floor(x), iy = Math.floor(y)
99  const fx = smooth(x - ix), fy = smooth(y - iy)
100  const a = hash(ix, iy, seed), b = hash(ix + 1, iy, seed)
101  const c = hash(ix, iy + 1, seed), d = hash(ix + 1, iy + 1, seed)
102  return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy
103}
104
105/** A finished-turn pulse: 1 right at the finish, fading to 0 over FLASH_MS. */
106const pulse = (flash: number | null) => (flash === null || flash >= FLASH_MS ? 0 : (1 - flash / FLASH_MS) ** 2)
107
108// ---------------------------------------------------------------------------------------------
109// city: rain on a city at night
110
111function city(w: number, h: number): Renderer {
112  const NEON: Rgb[] = [[255, 64, 170], [60, 232, 255], [180, 110, 255]]
113  const ground = h >= 10 ? Math.max(2, Math.round(h * 0.17)) : h >= 6 ? 1 : 0
114  const y0 = h - ground // first row of street
115
116  interface Building { x: number; w: number; top: number; seed: number; antenna: boolean; setback: number; neon: number }
117  const far: Building[] = [], near: Building[] = []
118  for (let x = -1, i = 0; x < w; i++) {
119    const bw = 3 + Math.floor(hash(i, 1, 11) * 5)
120    far.push({ x, w: bw, top: Math.round(y0 * (0.3 + hash(i, 2, 11) * 0.4)), seed: i, antenna: false, setback: 0, neon: -1 })
121    x += bw
122  }
123  let signs = 0
124  for (let x = -2, i = 0; x < w; i++) {
125    const bw = 4 + Math.floor(hash(i, 1, 13) * 8)
126    const tall = hash(i, 5, 13) < 0.25
127    const top = Math.round(y0 * (tall ? 0.08 + hash(i, 2, 13) * 0.2 : 0.35 + hash(i, 2, 13) * 0.35))
128    const setback = bw >= 6 && hash(i, 6, 13) < 0.5 ? 2 + Math.floor(hash(i, 7, 13) * 2) : 0
129    const neon = y0 - top >= 8 && hash(i, 8, 13) < 0.3 ? signs++ % NEON.length : -1
130    near.push({ x, w: bw, top, seed: 100 + i, antenna: tall && top >= 4 && hash(i, 3, 13) < 0.7, setback, neon })
131    x += bw + (hash(i, 4, 13) < 0.3 ? 1 : 0)
132  }
133  const nearAt = new Int32Array(w).fill(-1)
134  near.forEach((b, i) => { for (let x = Math.max(0, b.x); x < Math.min(w, b.x + b.w); x++) nearAt[x] = i })
135  const farAt = new Int32Array(w).fill(-1)
136  far.forEach((b, i) => { for (let x = Math.max(0, b.x); x < Math.min(w, b.x + b.w); x++) farAt[x] = i })
137
138  const SKY_TOP: Rgb = [5, 7, 22], SKY_MID: Rgb = [22, 18, 50], GLOW: Rgb = [92, 44, 74]
139  const CLOUD: Rgb = [52, 48, 84], LIGHTNING: Rgb = [196, 198, 240]
140  const FAR: Rgb = [38, 32, 70], NEAR: Rgb = [9, 9, 18], STREET: Rgb = [10, 10, 20]
141  const RAIN: Rgb = [178, 194, 236]
142  const WARM: Rgb = [255, 196, 108], COOL: Rgb = [164, 204, 255], AMBER: Rgb = [206, 122, 58], DIM: Rgb = [128, 104, 84]
143
144  /** Lightning brightness at time t: random strikes every so often, plus one on a finish. */
145  function lightning(t: number, energy: number, flash: number | null): { k: number; slot: number } {
146    const SLOT = 9000
147    let best = { k: 0, slot: -1 }
148    const shape = (dt: number) => (dt < 0 ? 0 : dt < 70 ? 1 : dt < 140 ? 0.25 : dt < 220 ? 0.85 : Math.exp(-(dt - 220) / 180) * 0.6)
149    for (const s of [Math.floor(t / SLOT), Math.floor(t / SLOT) - 1]) {
150      if (s < 0 || hash(s, 0, 31) > 0.3 + 0.3 * energy) continue
151      const k = shape(t - (s * SLOT + hash(s, 1, 31) * (SLOT - 1500)))
152      if (k > best.k) best = { k, slot: s }
153    }
154    if (flash !== null) {
155      const k = shape(flash)
156      if (k > best.k) best = { k, slot: 1_000_000 + Math.floor((t - flash) / 100) }
157    }
158    return best
159  }
160
161  return ({ t, energy, flash }) => {
162    const c = new Canvas(w, h)
163    const L = lightning(t, energy, flash)
164    const sky = new Uint8Array(w * h)
165
166    // sky, with slow clouds
167    for (let y = 0; y < y0; y++) {
168      const v = y0 > 1 ? y / (y0 - 1) : 1
169      const base = v < 0.55 ? lerp(SKY_TOP, SKY_MID, v / 0.55) : lerp(SKY_MID, GLOW, ((v - 0.55) / 0.45) ** 1.4)
170      for (let x = 0; x < w; x++) {
171        c.set(x, y, base)
172        const n = noise(x * 0.06 + t * 0.0004, y * 0.3, 41) * 0.7 + noise(x * 0.15 + t * 0.0007, y * 0.6, 43) * 0.3
173        c.mix(x, y, CLOUD, smooth(clamp01((n - 0.45) / 0.35)) * (0.45 - v * 0.25))
174        if (L.k > 0) c.mix(x, y, LIGHTNING, L.k * (0.55 + n * 0.3) * (1 - v * 0.4))
175        sky[y * w + x] = 1
176      }
177    }
178
179    // the lightning bolt itself, behind the buildings
180    if (L.k > 0.6 && y0 > 4) {
181      let bx = Math.floor(hash(L.slot, 2, 31) * w)
182      const end = Math.floor(y0 * (0.55 + hash(L.slot, 3, 31) * 0.35))
183      for (let y = 0; y < end; y++) {
184        bx += Math.round((hash(L.slot, y, 33) - 0.5) * 2.6)
185        if (sky[y * w + bx]) c.set(bx, y, [250, 250, 255])
186        if (hash(L.slot, y, 35) < 0.12) { // a small fork
187          const fx = bx + (hash(L.slot, y, 37) < 0.5 ? -1 : 1)
188          c.mix(fx, y + 1, [220, 220, 255], 0.8)
189        }
190      }
191    }
192
193    // far skyline: flat silhouettes against the glow, a few dim windows
194    const lit = (x: number, y: number, seed: number, p: number) => {
195      const period = 5 + hash(x, y, seed + 7) * 12
196      const epoch = Math.floor((t / 1000 + hash(x, y, seed + 9) * 40) / period)
197      return hash(x, y, seed + epoch * 131) < p
198    }
199    for (let x = 0; x < w; x++) {
200      const b = far[farAt[x]]
201      if (!b) continue
202      for (let y = b.top; y < y0; y++) {
203        c.set(x, y, L.k > 0 ? lerp(FAR, [70, 66, 110], L.k * 0.5) : FAR)
204        sky[y * w + x] = 0
205        if ((x - b.x) % 2 === 1 && (y - b.top) % 3 === 2 && x < b.x + b.w - 1 && lit(x, y, 5, 0.14)) c.set(x, y, DIM)
206      }
207    }
208
209    // near skyline: dark towers full of windows that switch on and off, antennas blinking red
210    for (let x = 0; x < w; x++) {
211      const b = near[nearAt[x]]
212      if (!b) continue
213      const col = x - b.x
214      const inset = col < 1 || col >= b.w - 1
215      const top = b.setback && inset ? b.top + b.setback : b.top
216      for (let y = top; y < y0; y++) {
217        c.set(x, y, NEAR)
218        sky[y * w + x] = 0
219        const row = y - b.top
220        if (col % 2 === 1 && col < b.w - 1 && row % 2 === 1 && y < y0 - 1 && lit(x, y, b.seed, 0.4)) {
221          const tone = hash(x, y, b.seed + 3)
222          c.set(x, y, tone < 0.68 ? WARM : tone < 0.88 ? COOL : AMBER)
223        }
224      }
225      // a neon sign down one edge, flickering now and then
226      if (b.neon >= 0 && col === (b.seed % 2 ? 1 : b.w - 2)) {
227        const flick = hash(b.seed, Math.floor(t / 90), 29) < 0.04
228        for (let y = b.top + 2; y < Math.min(y0 - 1, b.top + 7); y++) c.set(x, y, flick ? [40, 20, 40] : NEON[b.neon])
229      }
230      if (b.antenna && x === b.x + (b.w >> 1)) {
231        for (let y = b.top - 3; y < b.top; y++) c.set(x, y, NEAR)
232        const blink = (t + b.seed * 377) % 1600 < 500
233        c.set(x, b.top - 3, blink ? [255, 48, 60] : [70, 16, 24])
234      }
235    }
236
237    // wet street: a rippling reflection of everything above it
238    for (let gy = 0; gy < ground; gy++) {
239      const y = y0 + gy
240      for (let x = 0; x < w; x++) {
241        const dx = Math.round((noise(x * 0.25, gy * 1.3 + t * 0.004, 51) - 0.5) * 3)
242        const src = c.get(x + dx, y0 - 1 - Math.floor(gy * 0.6))
243        const lum = (src[0] + src[1] + src[2]) / 3
244        c.set(x, y, lerp(STREET, src, lum > 90 ? 0.75 - gy * 0.08 : 0.4 - gy * 0.06))
245        if (gy === 0) c.mix(x, y, [40, 36, 60], 0.35)
246      }
247    }
248
249    // rain, heavier while Claude works
250    const drops = Math.floor(w * h * (0.012 + 0.03 * energy))
251    const fall = h + 4
252    const SLANT = 0.3
253    for (let i = 0; i < drops; i++) {
254      const front = hash(i, 4, 21) < 0.25
255      const speed = h * (1.8 + hash(i, 1, 21) * 0.8) * (front ? 1.25 : 1) * (1 + energy * 0.35) / 1000 // px per ms
256      const p = ((t * speed + hash(i, 2, 21) * fall) % fall) - 3
257      const x0 = hash(i, 3, 21) * (w + h * SLANT)
258      const len = front ? 4 : 2
259      for (let s = 0; s < len; s++) {
260        const y = Math.floor(p) - s
261        c.mix(Math.round(x0 - (p - s) * SLANT), y, RAIN, (front ? 0.8 : 0.5) * (1 - s / (len + 0.5)))
262      }
263    }
264    // splashes on the street
265    const tick = Math.floor(t / 80)
266    for (let y = y0; y < h; y++) {
267      for (let x = 0; x < w; x++) if (hash(x, y * 7 + tick, 23) < 0.012 + 0.03 * energy) c.mix(x, y, RAIN, 0.55)
268    }
269    return c.pack()
270  }
271}
272
273// ---------------------------------------------------------------------------------------------
274// space: parallax stars and a ringed planet; warp speed while Claude works
275
276function space(w: number, h: number): Renderer {
277  const layers = [
278    { density: 0.03, speed: 1.2, bright: 0.45 },
279    { density: 0.014, speed: 3.5, bright: 0.75 },
280    { density: 0.006, speed: 9, bright: 1 },
281  ]
282  const TINTS: Rgb[] = [[255, 255, 255], [190, 210, 255], [255, 236, 200], [255, 200, 200]]
283  const R = Math.max(2, h * 0.42)
284  const px0 = w * 0.76, py0 = h * 0.58
285  const BANDS: Rgb[] = [[214, 160, 104], [178, 112, 72], [232, 196, 140], [150, 86, 62], [204, 142, 92]]
286  let warp = 0 // eased separately so stars stretch smoothly
287
288  return ({ t, energy, flash }) => {
289    const c = new Canvas(w, h)
290    warp += (energy - warp) * 0.15
291    // deep space with a faint nebula
292    for (let y = 0; y < h; y++) {
293      for (let x = 0; x < w; x++) {
294        c.set(x, y, lerp([3, 3, 12], [9, 6, 22], y / h))
295        const n = noise(x * 0.05 + t * 0.00005, y * 0.12, 61)
296        const m = noise(x * 0.09, y * 0.2 + t * 0.00003, 63)
297        c.mix(x, y, [70, 30, 100], smooth(clamp01((n - 0.55) / 0.4)) * 0.45)
298        c.mix(x, y, [20, 70, 110], smooth(clamp01((m - 0.6) / 0.4)) * 0.35)
299      }
300    }
301    // stars: each layer drifts left at its own speed; at warp they streak
302    const mult = 1 + warp * 30
303    layers.forEach((L, li) => {
304      const n = Math.max(1, Math.floor(w * h * L.density))
305      const len = Math.min(w / 2, L.speed * mult * 0.12)
306      for (let i = 0; i < n; i++) {
307        const span = w + len + 2
308        const x = ((hash(i, li, 71) * span - (t / 1000) * L.speed * mult) % span + span) % span - 1
309        const y = Math.floor(hash(i, li, 73) * h)
310        const tw = li === 0 ? 0.55 + 0.45 * Math.sin(t / 400 + i * 1.7) : 1
311        const tint = TINTS[Math.floor(hash(i, li, 75) * TINTS.length)]
312        const k = L.bright * tw
313        c.mix(Math.floor(x), y, tint, k)
314        for (let s = 1; s <= len; s++) c.mix(Math.floor(x) + s, y, tint, k * (1 - s / (len + 1)) * 0.8)
315      }
316    })
317    // a shooting star every so often
318    const SLOT = 7000, s = Math.floor(t / SLOT)
319    if (hash(s, 0, 77) < 0.5) {
320      const dt = t - s * SLOT - hash(s, 1, 77) * 5000
321      if (dt > 0 && dt < 500) {
322        const sx = hash(s, 2, 77) * w * 0.7 + (dt / 500) * w * 0.3, sy = hash(s, 3, 77) * h * 0.4 + (dt / 500) * h * 0.3
323        for (let k = 0; k < 5; k++) c.mix(Math.floor(sx - k), Math.floor(sy - k * 0.5), [255, 255, 255], 0.9 - k * 0.18)
324      }
325    }
326    // ringed planet, lit from the upper left; bands drift as it turns
327    const ring = (x: number, y: number, front: boolean) => {
328      const dx = (x + 0.5 - px0) / (R * 2.1), dy = (y + 0.5 - py0) / (R * 0.42)
329      const d = dx * dx + dy * dy
330      if (d < 0.55 || d > 1) return
331      if (front !== (y + 0.5 >= py0)) return
332      c.mix(x, y, d < 0.75 ? [226, 204, 160] : [178, 150, 116], 0.9)
333    }
334    const x0 = Math.max(0, Math.floor(px0 - R * 2.2)), x1 = Math.min(w, Math.ceil(px0 + R * 2.2))
335    for (let y = 0; y < h; y++) for (let x = x0; x < x1; x++) ring(x, y, false)
336    for (let y = Math.floor(py0 - R); y <= Math.ceil(py0 + R); y++) {
337      for (let x = Math.floor(px0 - R); x <= Math.ceil(px0 + R); x++) {
338        const nx = (x + 0.5 - px0) / R, ny = (y + 0.5 - py0) / R
339        const r2 = nx * nx + ny * ny
340        if (r2 > 1) continue
341        const nz = Math.sqrt(1 - r2)
342        const light = clamp01(-0.55 * nx - 0.45 * ny + 0.7 * nz)
343        const band = BANDS[Math.floor(noise(ny * 3.2, nx * 0.6 + t * 0.00015, 81) * 2.99 + (ny + 1) * 1.6) % BANDS.length]
344        const shade = light < 0.18 ? 0.22 : light < 0.45 ? 0.55 : light < 0.75 ? 0.85 : 1.05
345        c.set(x, y, [band[0] * shade, band[1] * shade, band[2] * shade])
346      }
347    }
348    for (let y = 0; y < h; y++) for (let x = x0; x < x1; x++) ring(x, y, true)
349    // finish: a hyperspace flash
350    const f = pulse(flash)
351    if (f > 0) for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) c.mix(x, y, [210, 225, 255], f * 0.4)
352    return c.pack()
353  }
354}
355
356// ---------------------------------------------------------------------------------------------
357// aurora: northern lights over mountains and pines
358
359function aurora(w: number, h: number): Renderer {
360  const ridge = new Float32Array(w).fill(h)
361  const lit = new Uint8Array(w) // which side of its peak a column is on
362  for (let px = -8, i = 0; px < w + 8; i++) {
363    const top = h * (0.34 + hash(i, 0, 91) * 0.26), slope = 0.55 + hash(i, 1, 91) * 0.35
364    for (let x = 0; x < w; x++) {
365      const y = top + Math.abs(x - px) * slope + (noise(x * 0.5, i, 93) - 0.5) * 1.2
366      if (y < ridge[x]) { ridge[x] = y; lit[x] = x < px ? 1 : 0 }
367    }
368    px += 10 + Math.floor(hash(i, 2, 91) * 14)
369  }
370  const snowline = h * 0.5
371  const trees: { x: number; top: number }[] = []
372  for (let x = 0; x < w; x += 2 + Math.floor(hash(x, 0, 95) * 3)) {
373    trees.push({ x, top: Math.round(h * (0.74 + hash(x, 1, 95) * 0.14)) })
374  }
375  const GREEN: Rgb = [70, 255, 158], TEAL: Rgb = [60, 200, 210], VIOLET: Rgb = [168, 90, 236]
376  let glow = 0
377
378  return ({ t, energy, flash }) => {
379    const c = new Canvas(w, h)
380    glow += (energy - glow) * 0.08
381    const speed = 1 + glow * 1.6
382    const bright = 0.7 + glow * 0.3 + pulse(flash) * 0.6
383    for (let y = 0; y < h; y++) {
384      for (let x = 0; x < w; x++) {
385        c.set(x, y, lerp([2, 5, 16], [8, 24, 38], y / h))
386        if (hash(x, y, 97) < 0.025) c.mix(x, y, [230, 240, 255], 0.35 + 0.35 * Math.sin(t / 500 + x * 3.1 + y))
387      }
388    }
389    // the curtains: a wavy lower edge with rays rising from it
390    for (let x = 0; x < w; x++) {
391      const edge = h * 0.48 + Math.sin(x * 0.08 + t * 0.00035 * speed) * h * 0.13 + Math.sin(x * 0.21 - t * 0.0006 * speed) * h * 0.06
392      const rays = 0.35 + 0.65 * noise(x * 0.3 + t * 0.0009 * speed, t * 0.0002, 99)
393      for (let y = 0; y < h; y++) {
394        const above = edge - y
395        const k = above >= 0 ? Math.exp(-above / (h * 0.3)) * rays : Math.exp(above * 1.4) * rays
396        if (k < 0.02) continue
397        const col = above < h * 0.12 ? GREEN : above < h * 0.28 ? lerp(GREEN, TEAL, (above - h * 0.12) / (h * 0.16)) : lerp(TEAL, VIOLET, clamp01((above - h * 0.28) / (h * 0.2)))
398        c.mix(x, y, col, k * 0.8 * bright)
399      }
400    }
401    // mountains, snow catching the aurora's light
402    for (let x = 0; x < w; x++) {
403      const top = Math.round(ridge[x])
404      for (let y = top; y < h; y++) {
405        const snow = y < snowline + (noise(x * 0.4, 5, 94) - 0.5) * 2
406        const rock: Rgb = lit[x] ? [30, 42, 64] : [18, 26, 44]
407        const cap: Rgb = lit[x] ? [196, 214, 230] : [120, 142, 170]
408        c.set(x, y, snow ? lerp(cap, GREEN, 0.15 * bright) : rock)
409      }
410    }
411    // pines along the bottom
412    for (const tr of trees) {
413      for (let y = tr.top; y < h; y++) {
414        const half = Math.floor((y - tr.top) / 2)
415        for (let dx = -half; dx <= half; dx++) c.set(tr.x + dx, y, [3, 8, 12])
416      }
417    }
418    return c.pack()
419  }
420}
421
422// ---------------------------------------------------------------------------------------------
423// fire: the Doom fire, over your terminal's own background
424
425function fire(w: number, h: number): Renderer {
426  const PALETTE: number[] = [
427    0x070707, 0x1f0707, 0x2f0f07, 0x470f07, 0x571707, 0x671f07, 0x771f07, 0x8f2707, 0x9f2f07,
428    0xaf3f07, 0xbf4707, 0xc74707, 0xdf4f07, 0xdf5707, 0xdf5707, 0xd75f07, 0xd7670f, 0xcf6f0f,
429    0xcf770f, 0xcf7f0f, 0xcf8717, 0xc78717, 0xc78f17, 0xc7971f, 0xbf9f1f, 0xbf9f1f, 0xbfa727,
430    0xbfa727, 0xbfaf2f, 0xb7af2f, 0xb7b72f, 0xb7b737, 0xcfcf6f, 0xdfdf9f, 0xefefc7, 0xffffff, 0xffffff,
431  ]
432  const MAX = PALETTE.length - 1
433  const heat = new Float32Array(w * h)
434  const STEP_MS = 40
435  let step = -1
436  let reach = 0.45
437
438  function advance(s: number, energy: number, boost: number) {
439    const target = 0.45 + energy * 0.5 + boost * 0.3
440    reach += (target - reach) * 0.05
441    const decayPerRow = MAX / (Math.max(2, h) * reach)
442    const wind = (noise(s * 0.01, 0, 104) - 0.5) * 0.6
443    for (let x = 0; x < w; x++) {
444      const n = noise(x * 0.09, s * 0.02, 101) * 0.65 + noise(x * 0.3, s * 0.05, 102) * 0.35
445      heat[(h - 1) * w + x] = MAX * (0.5 + 0.5 * n)
446    }
447    for (let y = 0; y < h - 1; y++) {
448      for (let x = 0; x < w; x++) {
449        const r = hash(x, y, s * 7 + 103)
450        const src = heat[(y + 1) * w + x]
451        const tongue = 0.55 + noise(x * 0.18, (y + s * 0.6) * 0.15, 106) * 0.9
452        const decay = decayPerRow * (0.3 + r * 1.4) * tongue
453        const dx = r < 0.33 + wind ? -1 : r < 0.66 + wind ? 0 : 1
454        const tx = Math.min(w - 1, Math.max(0, x + dx))
455        heat[y * w + tx] = Math.max(0, src - decay)
456      }
457    }
458  }
459
460  return ({ t, energy, flash }) => {
461    const target = Math.floor(t / STEP_MS)
462    if (step < 0 || target < step) step = target - 30 // fresh start: warm it up
463    const boost = pulse(flash)
464    for (let s = Math.max(step + 1, target - 30); s <= target; s++) advance(s, energy, boost)
465    step = target
466    const out = new Uint32Array(w * h)
467    for (let i = 0; i < out.length; i++) {
468      const v = Math.round(heat[i])
469      out[i] = v <= 1 ? TRANSPARENT : PALETTE[Math.min(MAX, v)]
470    }
471    // embers drifting up, more of them on a finish
472    const embers = Math.floor(w * (0.04 + 0.05 * energy + boost * 0.25))
473    for (let i = 0; i < embers; i++) {
474      const life = 1400 + hash(i, 1, 105) * 1200
475      const p = ((t + hash(i, 2, 105) * life) % life) / life
476      const x = Math.floor(hash(i, 3, 105) * w + Math.sin(p * 6 + i) * 1.5)
477      const y = Math.floor((1 - p) * h * 0.9)
478      if (x >= 0 && x < w && y >= 0 && y < h && heat[y * w + x] < MAX * 0.4) out[y * w + x] = p < 0.6 ? 0xffb347 : 0xc05020
479    }
480    return out
481  }
482}
483
484// ---------------------------------------------------------------------------------------------
485// creation: Michelangelo's hands (public domain), with a spark in the gap
486
487let painting: Rgba | null = null
488
489/** The painting strip, unpacked from its palette the first time it's needed. */
490function paintingStrip(): Rgba {
491  if (painting) return painting
492  const pal = Uint8Array.fromBase64(CREATION.palette), idx = Uint8Array.fromBase64(CREATION.indices)
493  const data = new Uint8Array(idx.length * 4)
494  for (let i = 0; i < idx.length; i++) {
495    const p = idx[i] * 3
496    data[i * 4] = pal[p]; data[i * 4 + 1] = pal[p + 1]; data[i * 4 + 2] = pal[p + 2]; data[i * 4 + 3] = 255
497  }
498  painting = { width: CREATION.width, height: CREATION.height, data }
499  return painting
500}
501
502function creation(w: number, h: number): Renderer {
503  const src = paintingStrip()
504  // Zoomed in on the hands for a small band, showing more of the arms as it gets wider.
505  const frac = Math.min(0.5, Math.max(0.22, 0.12 + w / 600))
506  let cw = frac * CREATION.fullWidth, ch = (cw * h) / w
507  if (ch > src.height) { ch = src.height; cw = (ch * w) / h }
508  if (cw > src.width) { cw = src.width; ch = (cw * h) / w }
509  const x0 = Math.min(src.width - cw, Math.max(0, CREATION.gap.x - cw / 2))
510  const y0 = Math.min(src.height - ch, Math.max(0, CREATION.gap.y - ch / 2))
511  const base = stylize(downscaleRegion(src, { x: x0, y: y0, w: cw, h: ch }, w, h), 'original', 16).px
512  const sx = ((CREATION.gap.x - x0) / cw) * w, sy = ((CREATION.gap.y - y0) / ch) * h
513  const SPARK: Rgb = [255, 122, 36], HOT: Rgb = [255, 214, 120], WHITE: Rgb = [255, 255, 255]
514  const scale = Math.max(1, h / 16)
515  let glow = 0
516
517  return ({ t, energy, flash }) => {
518    const c = new Canvas(w, h)
519    glow += (energy - glow) * 0.12
520    const f = pulse(flash)
521    const beat = 0.5 + 0.5 * Math.sin(t / 520)
522    // The fresco is pale, so a spark painted on it barely shows. Instead the scene is lit low and
523    // the spark lights the fingertips around it; while Claude works the room dims further.
524    const dim = 0.74 - glow * 0.2 + f * 0.26
525    const sigma = (5 + beat * 1.5 + glow * 5) * scale
526    for (let y = 0; y < h; y++) {
527      for (let x = 0; x < w; x++) {
528        const d2 = (x + 0.5 - sx) ** 2 + ((y + 0.5 - sy) * 1.3) ** 2
529        const light = Math.exp(-d2 / (2 * sigma * sigma))
530        const b = base[y * w + x], lit = dim + (1.08 - dim) * light
531        c.set(x, y, [((b >> 16) & 255) * lit, ((b >> 8) & 255) * lit, (b & 255) * lit])
532        c.mix(x, y, SPARK, light * (0.22 + glow * 0.18))
533      }
534    }
535    const radius = (2 + beat * 0.8 + glow * 1.8 + f * 6) * scale
536    const strength = 0.55 + beat * 0.2 + glow * 0.25 + f * 0.4
537
538    // the glow around the gap: orange falling off, a white-hot core
539    const reach = Math.ceil(radius * 1.6)
540    for (let y = Math.floor(sy - reach); y <= Math.ceil(sy + reach); y++) {
541      for (let x = Math.floor(sx - reach); x <= Math.ceil(sx + reach); x++) {
542        const d = Math.hypot(x + 0.5 - sx, y + 0.5 - sy)
543        if (d > radius * 1.6) continue
544        const k = Math.max(0, 1 - d / (radius * 1.6)) ** 2 * strength
545        c.mix(x, y, SPARK, k)
546        if (d < radius * 0.35) c.mix(x, y, d < radius * 0.18 ? WHITE : HOT, 0.9)
547      }
548    }
549    // a four-point twinkle every so often, all the time while Claude works
550    const tick = Math.floor(t / 80)
551    if (glow > 0.3 || hash(tick >> 2, 0, 111) < 0.3) {
552      const arm = Math.round((1.5 + glow * 2.5 + beat) * scale)
553      for (let k = 1; k <= arm; k++) {
554        const a = 0.8 * (1 - k / (arm + 1))
555        c.mix(Math.floor(sx) + k, Math.floor(sy), WHITE, a); c.mix(Math.floor(sx) - k, Math.floor(sy), WHITE, a)
556        c.mix(Math.floor(sx), Math.floor(sy) + k, WHITE, a * 0.8); c.mix(Math.floor(sx), Math.floor(sy) - k, WHITE, a * 0.8)
557      }
558    }
559    // while Claude works: little arcs crackling out from the spark, and embers drifting off
560    if (glow > 0.15) {
561      const arcs = 1 + Math.round(glow * 2)
562      for (let a = 0; a < arcs; a++) {
563        const ang = hash(tick, a, 113) * Math.PI * 2, len = (3 + hash(tick, a, 115) * 5) * scale
564        let x = sx, y = sy
565        for (let k = 0; k < len; k++) {
566          x += Math.cos(ang) + (hash(tick, a * 50 + k, 117) - 0.5) * 1.4
567          y += Math.sin(ang) * 0.6 + (hash(tick, a * 50 + k, 119) - 0.5) * 1.4
568          c.mix(Math.floor(x), Math.floor(y), HOT, glow * (1 - k / len))
569        }
570      }
571      for (let i = 0; i < 6; i++) {
572        const life = 900 + hash(i, 1, 121) * 600
573        const p = ((t + hash(i, 2, 121) * life) % life) / life
574        const ang = hash(i, 3, 121) * Math.PI * 2
575        c.mix(Math.floor(sx + Math.cos(ang) * p * 10 * scale), Math.floor(sy + Math.sin(ang) * p * 5 * scale), SPARK, glow * (1 - p))
576      }
577    }
578    // a finished turn: the light floods out from the gap in a ring
579    if (f > 0) {
580      const ring = (1 - f ** 0.5) * Math.max(w, h * 2) * 0.6
581      for (let y = 0; y < h; y++) {
582        for (let x = 0; x < w; x++) {
583          const d = Math.abs(Math.hypot(x + 0.5 - sx, (y + 0.5 - sy) * 1.5) - ring)
584          if (d < 2.5) c.mix(x, y, HOT, (1 - d / 2.5) * f * 0.8)
585          c.mix(x, y, [255, 214, 160], f * 0.25)
586        }
587      }
588    }
589    return c.pack()
590  }
591}
592
593// ---------------------------------------------------------------------------------------------
594// matrix: green code rain
595
596function matrix(w: number, h: number): Renderer {
597  const HEAD: Rgb = [210, 255, 215], BRIGHT: Rgb = [40, 255, 90], DARK: Rgb = [0, 70, 25], BG: Rgb = [2, 8, 4]
598  // Each column runs its own streams: a speed, a trail length, a pause between drops.
599  const cols = Array.from({ length: w }, (_, x) => ({
600    speed: h * (0.55 + hash(x, 1, 131) * 0.9), // px per second
601    trail: Math.max(3, Math.round(h * (0.35 + hash(x, 2, 131) * 0.6))),
602    phase: hash(x, 3, 131),
603    active: hash(x, 4, 131),
604  }))
605  let pour = 0
606
607  return ({ t, energy, flash }) => {
608    const c = new Canvas(w, h)
609    pour += (energy - pour) * 0.1
610    for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) c.set(x, y, BG)
611    const tick = Math.floor(t / 90)
612    for (let x = 0; x < w; x++) {
613      const col = cols[x]
614      if (col.active > 0.45 + pour * 0.4) continue // fewer columns at rest; nearly all of them while Claude works
615      const speed = col.speed * (1 + pour * 1.6)
616      const span = h + col.trail + h * 0.6 * (1 - pour) // the gap before the next drop
617      const head = ((t / 1000) * speed + col.phase * span) % span
618      for (let k = 0; k < col.trail; k++) {
619        const y = Math.floor(head) - k
620        if (y < 0 || y >= h) continue
621        const fade = 1 - k / col.trail
622        // The glyphs in a trail keep changing: some pixels flicker brighter or drop out.
623        const flick = hash(x, y * 31 + tick, 133)
624        if (flick < 0.12) continue
625        const tone = k === 0 ? HEAD : lerp(DARK, BRIGHT, fade ** 1.4 * (flick > 0.85 ? 1 : 0.8))
626        c.set(x, y, tone)
627      }
628    }
629    // a finished turn: a bright scan sweeps down the screen
630    const f = pulse(flash)
631    if (f > 0) {
632      const sy = (1 - f) * (h + 4) - 2
633      for (let y = 0; y < h; y++) {
634        const d = Math.abs(y - sy)
635        if (d < 3) for (let x = 0; x < w; x++) c.mix(x, y, HEAD, (1 - d / 3) * 0.8)
636      }
637      for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) c.mix(x, y, BRIGHT, f * 0.15)
638    }
639    return c.pack()
640  }
641}
642
643// ---------------------------------------------------------------------------------------------
644// aquarium: fish, bubbles and weed
645
646function aquarium(w: number, h: number): Renderer {
647  const sand = Math.max(1, Math.round(h * 0.12))
648  const floor = h - sand
649  const TOP: Rgb = [24, 104, 168], DEEP: Rgb = [6, 32, 78], SAND: Rgb = [196, 170, 112], SAND2: Rgb = [168, 142, 92]
650  const WEED: Rgb = [40, 150, 70], WEED2: Rgb = [24, 108, 52], BUBBLE: Rgb = [200, 232, 255]
651  const KINDS: { body: Rgb; stripe?: Rgb; fin: Rgb }[] = [
652    { body: [255, 128, 32], stripe: [255, 255, 255], fin: [230, 90, 20] },  // clownfish
653    { body: [60, 110, 230], stripe: [250, 220, 40], fin: [30, 60, 170] },   // blue tang
654    { body: [250, 220, 50], fin: [220, 170, 20] },                          // yellow tang
655    { body: [230, 70, 110], fin: [180, 40, 80] },                           // a pink one
656  ]
657  const weeds = Array.from({ length: Math.max(2, Math.round(w / 11)) }, (_, i) => ({
658    x: Math.floor(hash(i, 1, 141) * w), tall: Math.round(floor * (0.3 + hash(i, 2, 141) * 0.45)), phase: hash(i, 3, 141) * 6,
659  }))
660  const fish = Array.from({ length: Math.max(2, Math.round(w / 16)) }, (_, i) => ({
661    kind: KINDS[i % KINDS.length],
662    len: 4 + Math.floor(hash(i, 1, 143) * 3) + (h >= 24 ? 1 : 0),
663    y: 2 + hash(i, 2, 143) * Math.max(1, floor - 6),
664    speed: 3 + hash(i, 3, 143) * 5, // px per second
665    dir: hash(i, 4, 143) < 0.5 ? 1 : -1,
666    offset: hash(i, 5, 143) * (w + 20),
667    bob: hash(i, 6, 143) * 6,
668  }))
669  const vents = Array.from({ length: Math.max(1, Math.round(w / 30)) }, (_, i) => Math.floor(hash(i, 7, 143) * w))
670  let dart = 0, travelled = 0, lastT = 0
671
672  return ({ t, energy, flash }) => {
673    const c = new Canvas(w, h)
674    dart += (energy - dart) * 0.08
675    const dt = Math.max(0, Math.min(200, t - lastT)); lastT = t
676    travelled += (dt / 1000) * (1 + dart * 2.2)
677    // water, with slow shafts of light from the surface
678    for (let y = 0; y < floor; y++) {
679      for (let x = 0; x < w; x++) {
680        c.set(x, y, lerp(TOP, DEEP, y / Math.max(1, floor)))
681        const ray = Math.sin((x + y * 0.6) * 0.18 + t * 0.0006) * Math.sin((x - y * 0.3) * 0.07 - t * 0.0004)
682        if (ray > 0.55) c.mix(x, y, [120, 200, 240], (ray - 0.55) * 0.5 * (1 - y / floor))
683      }
684    }
685    // sand
686    for (let y = floor; y < h; y++) for (let x = 0; x < w; x++) c.set(x, y, hash(x, y, 145) < 0.3 ? SAND2 : SAND)
687    // weed, swaying from the root
688    for (const wd of weeds) {
689      for (let k = 0; k < wd.tall; k++) {
690        const sway = Math.sin(t * 0.0015 + wd.phase + k * 0.35) * (k / wd.tall) * 2.2
691        const x = Math.round(wd.x + sway), y = floor - 1 - k
692        c.set(x, y, k % 3 === 0 ? WEED2 : WEED)
693        if (k % 4 === 2) c.set(x + (k % 8 < 4 ? 1 : -1), y, WEED2)
694      }
695    }
696    // bubbles from the vents, more while Claude works and a burst when it finishes
697    const f = pulse(flash)
698    const per = 3 + Math.round(dart * 5 + f * 12)
699    for (const vx of vents) {
700      for (let i = 0; i < per; i++) {
701        const life = 2600 + hash(vx, i, 147) * 1600
702        const p = ((t + hash(vx, i, 149) * life) % life) / life
703        const y = Math.round(floor - 1 - p * floor)
704        const x = Math.round(vx + Math.sin(p * 9 + i) * 1.2 + (hash(vx, i, 151) - 0.5) * 4)
705        c.mix(x, y, BUBBLE, 0.75)
706      }
707    }
708    // fish
709    for (const fi of fish) {
710      const span = w + fi.len * 2 + 8
711      const pos = (((fi.offset + travelled * fi.speed * fi.dir) % span) + span) % span - fi.len - 4
712      const x0 = Math.round(pos)
713      const y0 = Math.round(fi.y + Math.sin(t * 0.0012 + fi.bob) * 1.2)
714      const flap = Math.floor(t / (180 - dart * 90)) % 2
715      const body = fi.kind.body, fin = fi.kind.fin
716      for (let k = 0; k < fi.len; k++) {
717        const x = fi.dir > 0 ? x0 + k : x0 + fi.len - 1 - k // k = 0 at the tail
718        const thick = k === 0 ? 0 : k === fi.len - 1 ? 0 : k < fi.len / 2 ? 1 : 1
719        for (let dy = -thick; dy <= thick; dy++) c.set(x, y0 + dy, k === 0 ? fin : body)
720        if (fi.kind.stripe && k === Math.floor(fi.len / 2)) for (let dy = -thick; dy <= thick; dy++) c.set(x, y0 + dy, fi.kind.stripe)
721      }
722      // the tail flaps; the eye looks where it's going
723      const tailX = fi.dir > 0 ? x0 - 1 : x0 + fi.len
724      c.set(tailX, y0 + (flap ? -1 : 1), fin)
725      const eyeX = fi.dir > 0 ? x0 + fi.len - 2 : x0 + 1
726      c.set(eyeX, y0 - 1 >= 0 && fi.len > 4 ? y0 : y0, [16, 16, 24])
727    }
728    return c.pack()
729  }
730}
731
hooks/styles.ts 105 lines
1/**
2 * Looks. Every style takes the already-resized image and decides the final colours:
3 *
4 *   original   the image's own colours, cut to a small palette (the default)
5 *   gameboy    four greens, dithered, like a 1989 handheld
6 *   pico8      the PICO-8 fantasy console's 16 colours, lightly dithered
7 *   mono       four greys, dithered
8 *   sepia      warm browns, like an old photo
9 *
10 * Fixed retro palettes make any picture look deliberate, which helps with busy or dull images.
11 */
12import { medianCut, nearest, punch, TRANSPARENT, type Art } from './pixelate'
13import type { Rgba } from './png'
14
15export const STYLES = ['original', 'gameboy', 'pico8', 'mono', 'sepia'] as const
16export type Style = (typeof STYLES)[number]
17
18type Rgb = [number, number, number]
19
20const PALETTES: Record<'gameboy' | 'pico8' | 'mono', Rgb[]> = {
21  gameboy: [[15, 56, 15], [48, 98, 48], [139, 172, 15], [155, 188, 15]],
22  pico8: [[0, 0, 0], [29, 43, 83], [126, 37, 83], [0, 135, 81], [171, 82, 54], [95, 87, 79], [194, 195, 199],
23    [255, 241, 232], [255, 0, 77], [255, 163, 0], [255, 236, 39], [0, 228, 54], [41, 173, 255], [131, 118, 156],
24    [255, 119, 168], [255, 204, 170]],
25  mono: [[20, 20, 22], [90, 90, 96], [168, 168, 172], [240, 240, 236]],
26}
27
28/** How strongly each palette style dithers (0 = flat colour). */
29const DITHER: Record<'gameboy' | 'pico8' | 'mono', number> = { gameboy: 0.5, pico8: 0.3, mono: 0.5 }
30
31/** 4x4 ordered (Bayer) dither thresholds, centred on zero. */
32const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => v / 16 - 0.5)
33
34const clamp = (v: number) => (v < 0 ? 0 : v > 255 ? 255 : v)
35const pack = ([r, g, b]: Rgb) => ((r << 16) | (g << 8) | b) >>> 0
36
37/**
38 * A gentle auto-levels on brightness: stretch the darkest and brightest 1% out to the full range,
39 * at half strength. Lifts flat, grey images without blowing out ones that were fine.
40 */
41function levels(src: Rgba): (c: Rgb) => Rgb {
42  const lum: number[] = []
43  for (let i = 0; i < src.data.length; i += 4) {
44    if (src.data[i + 3] >= 128) lum.push(0.299 * src.data[i] + 0.587 * src.data[i + 1] + 0.114 * src.data[i + 2])
45  }
46  if (lum.length < 4) return (c) => c
47  lum.sort((a, b) => a - b)
48  const lo = lum[Math.floor(lum.length * 0.01)], hi = lum[Math.floor(lum.length * 0.99)]
49  if (hi - lo < 8) return (c) => c
50  const scale = 255 / (hi - lo)
51  return ([r, g, b]) => {
52    const s = (v: number) => clamp(v + ((v - lo) * scale - v) * 0.5)
53    return [s(r), s(g), s(b)]
54  }
55}
56
57/**
58 * Turn a resized image into pixel art in `style`. `colors` is the palette size for original/sepia.
59 * `from` is what the brightness levels and palette are taken from: the image itself by default, or
60 * a sample of an animation's frames, so every frame shares one palette and colours don't flicker.
61 */
62export function stylize(small: Rgba, style: Style, colors: number, from: Rgba = small): Art {
63  const { width: w, height: h, data } = small
64  const px = new Uint32Array(w * h)
65  const lift = levels(from)
66  const opaque = (i: number) => data[i * 4 + 3] >= 128
67  const rgbAt = (i: number): Rgb => lift([data[i * 4], data[i * 4 + 1], data[i * 4 + 2]])
68  const tone = (c: Rgb): Rgb => {
69    if (style !== 'sepia') return punch(c[0], c[1], c[2])
70    const l = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
71    return [clamp(l * 1.07 + 20), clamp(l * 0.9 + 8), clamp(l * 0.66)]
72  }
73
74  if (style === 'original' || style === 'sepia') {
75    const toned: (Rgb | null)[] = []
76    for (let i = 0; i < w * h; i++) toned.push(opaque(i) ? tone(rgbAt(i)) : null)
77    let sample = toned.filter((c): c is Rgb => !!c)
78    if (from !== small) {
79      sample = []
80      for (let i = 0; i < from.width * from.height; i++) {
81        if (from.data[i * 4 + 3] >= 128) sample.push(tone(lift([from.data[i * 4], from.data[i * 4 + 1], from.data[i * 4 + 2]])))
82      }
83    }
84    const palette = medianCut(sample, style === 'sepia' ? Math.min(colors, 8) : Math.max(2, colors))
85    for (let i = 0; i < w * h; i++) {
86      const c = toned[i]
87      px[i] = c ? pack(nearest(c, palette)) : TRANSPARENT
88    }
89    return { w, h, px }
90  }
91
92  const palette = PALETTES[style]
93  const amount = DITHER[style] * 64
94  for (let y = 0; y < h; y++) {
95    for (let x = 0; x < w; x++) {
96      const i = y * w + x
97      if (!opaque(i)) { px[i] = TRANSPARENT; continue }
98      const d = BAYER[(y & 3) * 4 + (x & 3)] * amount
99      const [r, g, b] = rgbAt(i)
100      px[i] = pack(nearest([clamp(r + d), clamp(g + d), clamp(b + d)], palette))
101    }
102  }
103  return { w, h, px }
104}
105
hooks/bmp.ts 71 lines
1/**
2 * BMP decoder for uncompressed 24- and 32-bit bitmaps.
3 *
4 * This isn't for people's own BMPs so much as for the OS image tool's output: for JPEG, HEIC, WebP
5 * and friends, the loader asks `sips` (macOS) or ImageMagick to convert and shrink the photo into a
6 * BMP, which is trivial to read without a decompressor.
7 */
8import type { Rgba } from './png'
9
10const u16 = (b: Uint8Array, o: number) => b[o] | (b[o + 1] << 8)
11const u32 = (b: Uint8Array, o: number) => (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0
12const i32 = (b: Uint8Array, o: number) => b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)
13
14export function isBmp(b: Uint8Array): boolean {
15  return b.length > 54 && b[0] === 0x42 && b[1] === 0x4d
16}
17
18/** Shift and scale a channel out of a pixel with a bit mask (BI_BITFIELDS). */
19function fromMask(px: number, mask: number): number {
20  if (!mask) return 0
21  let shift = 0
22  while (((mask >>> shift) & 1) === 0) shift++
23  const max = mask >>> shift
24  return Math.round((((px & mask) >>> shift) * 255) / max)
25}
26
27export function decodeBmp(b: Uint8Array): Rgba {
28  if (!isBmp(b)) throw new Error('not a BMP')
29  const dataOffset = u32(b, 10)
30  const headerSize = u32(b, 14)
31  const width = i32(b, 18)
32  const rawHeight = i32(b, 22)
33  const bpp = u16(b, 28)
34  const compression = u32(b, 30)
35  if (width <= 0 || rawHeight === 0) throw new Error('BMP has no size')
36  if (bpp !== 24 && bpp !== 32) throw new Error(`unsupported BMP depth ${bpp}`)
37  if (compression !== 0 && compression !== 3) throw new Error('compressed BMPs are not supported')
38  const height = Math.abs(rawHeight)
39  const topDown = rawHeight < 0
40
41  let masks: [number, number, number, number] | null = null
42  if (compression === 3) {
43    // Masks follow a 40-byte header, or sit inside V4/V5 headers at the same place.
44    const m = 14 + 40
45    masks = [u32(b, m), u32(b, m + 4), u32(b, m + 8), headerSize >= 56 ? u32(b, m + 12) : 0]
46  }
47
48  const stride = Math.floor((bpp * width + 31) / 32) * 4
49  const data = new Uint8Array(width * height * 4)
50  let anyAlpha = false
51  for (let y = 0; y < height; y++) {
52    const row = dataOffset + (topDown ? y : height - 1 - y) * stride
53    for (let x = 0; x < width; x++) {
54      const o = (y * width + x) * 4
55      const p = row + x * (bpp / 8)
56      if (masks) {
57        const px = u32(b, p)
58        data[o] = fromMask(px, masks[0]); data[o + 1] = fromMask(px, masks[1]); data[o + 2] = fromMask(px, masks[2])
59        data[o + 3] = masks[3] ? fromMask(px, masks[3]) : 255
60      } else {
61        data[o] = b[p + 2]; data[o + 1] = b[p + 1]; data[o + 2] = b[p]
62        data[o + 3] = bpp === 32 ? b[p + 3] : 255
63      }
64      if (data[o + 3]) anyAlpha = true
65    }
66  }
67  // Plenty of 32-bit BMPs leave the alpha byte at 0 meaning "unused": treat those as opaque.
68  if (bpp === 32 && !anyAlpha) for (let i = 3; i < data.length; i += 4) data[i] = 255
69  return { width, height, data }
70}
71