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

Pixel art above your Claude Code prompt that reacts while Claude works: your own image, or an animated scene.
![]()
<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.
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.
/pixelband | open 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> --here | use 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 / off | show or hide it | |||
/pixelband clear [--here] | forget the image | |||
/pixelband demo <mood> | play working, done, error or intro on demand (good for screenshots) |
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.
Each scene is drawn from code, not a video, and reacts to what Claude is doing:
| scene | idle | while Claude works | when it finishes |
|---|---|---|---|
creation | Michelangelo's hands, a spark glowing in the gap between the fingertips | the room dims and the spark blazes and crackles | light floods out in a ring |
city | rain, windows switching on and off, flickering neon, a wet street | the rain gets heavier | lightning |
space | stars drifting past a ringed planet | warp speed | a hyperspace flash |
aurora | northern lights over mountains and pines | brighter, faster curtains | a bright pulse |
fire | low flames over your terminal's own background | the flames climb | a burst of embers |
matrix | green code raining down | it pours | a bright scan sweeps down |
aquarium | fish, bubbles and swaying weed | the fish dart about and the bubbles pick up | a 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.
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.
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.sips (or ImageMagick on Linux) to convert and shrink the image first, so a 20 MB iPhone photo never gets pulled through the mod./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.
sips or ImageMagick. Every Mac has sips; on Linux, install ImageMagick or use a PNG./pixelband colormode full|256|auto overrides the detection. Through tmux, enable true colour there too.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.
hooks/register.ts 867 lines1/**
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}
867hooks/anim.ts 100 lines1/**
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
100hooks/effects.ts 112 lines1/**
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}
112hooks/load.ts 180 lines1/**
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}
180hooks/pixelate.ts 172 lines1/**
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}
172hooks/png.ts 131 lines1/**
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}
131hooks/palette256.ts 89 lines1/**
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}
89hooks/raster.ts 109 lines1/**
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}
109hooks/svg.ts 66 lines1/**
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}
66hooks/scenes.ts 731 lines1/**
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}
731hooks/styles.ts 105 lines1/**
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}
105hooks/bmp.ts 71 lines1/**
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