pi-simocracy 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,18 +1,28 @@
1
1
  # pi-simocracy
2
2
 
3
3
  Load a [Simocracy](https://simocracy.org) sim into your [`pi`](https://github.com/mariozechner/pi-coding-agent) chat — see its
4
- pixel-art sprite render in the terminal and chat with the agent **as that sim**.
4
+ sprite render in the terminal and chat with the agent **as that sim**.
5
5
 
6
6
  ```
7
7
  /sim mr meow
8
8
  ```
9
9
 
10
10
  …fetches Mr Meow from Simocracy's ATProto indexer, renders his 32×32
11
- sprite as colored ANSI half-blocks directly in the chat, and pushes
12
- his constitution + speaking style into pi's system prompt so pi
11
+ pixel-art sprite as colored ANSI half-blocks directly in the chat, and
12
+ pushes his constitution + speaking style into pi's system prompt so pi
13
13
  roleplays as Mr Meow until you `/sim unload`.
14
14
 
15
- ![Mr Meow loaded inline in pi](demo/sim-load.gif)
15
+ <p align="center">
16
+ <img src="https://raw.githubusercontent.com/GainForest/pi-simocracy/main/demo/sim-load.gif" alt="Mr Meow (a pipoya pixel-art cat) loaded inline in pi's chat" width="760">
17
+ </p>
18
+
19
+ As of v0.4.0, codex pet sims (OpenAI hatch-pet skill output) render too —
20
+ load **Einstein** with `/sim einstein` and the WebP atlas decodes, the
21
+ idle frame crops, and the half-block ANSI render fires inline:
22
+
23
+ <p align="center">
24
+ <img src="https://raw.githubusercontent.com/GainForest/pi-simocracy/main/demo/codex-pet-load.gif" alt="Einstein (a codex pet sim with a WebP petSheet) loaded inline in pi's chat" width="760">
25
+ </p>
16
26
 
17
27
  ---
18
28
 
@@ -109,11 +119,18 @@ The same actions are exposed to pi as tools, so the model can drive them itself:
109
119
  - `org.simocracy.sim` — display name + sprite + avatar blob refs
110
120
  - `org.simocracy.agents` — short description + full constitution
111
121
  - `org.simocracy.style` — speaking style / mannerisms
112
- 4. **Render.** Fetch the sprite-sheet blob (128×128 PNG, 4×4 of 32×32
113
- walking frames) via `com.atproto.sync.getBlob`, decode with `pngjs`,
114
- crop the front-facing walk-1 frame, emit as 24-bit ANSI using the
115
- upper-half-block character `▀` so each terminal cell paints two
116
- pixels. Transparent regions show pi's background through.
122
+ 4. **Render.** Fetch the sprite blob via `com.atproto.sync.getBlob`,
123
+ decode it, crop the front-facing idle frame, and emit as 24-bit ANSI
124
+ using the upper-half-block character `▀` so each terminal cell paints
125
+ two pixels. Two render paths depending on the sim's `spriteKind`:
126
+ - **`pipoya`** (legacy + default): 128×128 PNG, 4×4 of 32×32 walking
127
+ frames; decode with `pngjs`, take row 0 col 0 at native size.
128
+ - **`codexPet`** (OpenAI hatch-pet output): 1536×1872 atlas, 8×9 of
129
+ 192×208 cells. PNG sheets decode through `pngjs`; WebP sheets
130
+ decode through `@jsquash/webp` (wasm, lazy-init). The idle cell
131
+ (row 0 col 0) is box-downscaled to ~32 wide so the inline render
132
+ stays comparable in height to a pipoya sprite.
133
+ Transparent regions show pi's background through.
117
134
  5. **Inject.** A `before_agent_start` event handler appends the sim's
118
135
  identity + constitution + speaking style to pi's system prompt **every
119
136
  turn**. After `/sim unload`, a one-shot override fires on the next
@@ -129,15 +146,17 @@ keeps the terminal it's already running in.
129
146
 
130
147
  ```
131
148
  src/
132
- ├── index.ts # extension entry: slash command, tools, persona injection
133
- ├── persona.ts # buildSimPrompt(sim) — the system-prompt fragment
134
- ├── simocracy.ts # indexer + PDS client (read-only fetchers)
135
- ├── writes.ts # PDS writers + ownership / sign-in preconditions
136
- ├── png-to-ansi.ts # RGBA half-block ANSI renderer
137
- ├── openrouter.ts # minimal OpenRouter client (only used by simocracy_chat)
138
- └── auth/ # ATProto OAuth loopback flow + session storage
149
+ ├── index.ts # extension entry: slash command, tools, persona injection
150
+ ├── persona.ts # buildSimPrompt(sim) — the system-prompt fragment
151
+ ├── simocracy.ts # indexer + PDS client (read-only fetchers)
152
+ ├── writes.ts # PDS writers + ownership / sign-in preconditions
153
+ ├── png-to-ansi.ts # RGBA half-block ANSI renderer + downscalers
154
+ ├── webp-to-rgba.ts # @jsquash/webp wrapper for codex pet WebP sheets
155
+ ├── openrouter.ts # minimal OpenRouter client (only used by simocracy_chat)
156
+ └── auth/ # ATProto OAuth loopback flow + session storage
139
157
  demo/
140
- └── sim-load.tape # vhs tape — render with `vhs demo/sim-load.tape`
158
+ ├── sim-load.tape # vhs tape — Mr Meow (pipoya)
159
+ └── codex-pet-load.tape # vhs tape — Einstein (codex pet)
141
160
  ```
142
161
 
143
162
  ---
@@ -153,11 +172,12 @@ pi -e $(pwd)/src/index.ts -ne -ns # load the extension directly
153
172
 
154
173
  Then in `pi`: `/sim mr meow`.
155
174
 
156
- To rebuild the demo recording:
175
+ To rebuild the demo recordings:
157
176
 
158
177
  ```bash
159
178
  brew install vhs # one-time
160
- vhs demo/sim-load.tape # writes demo/sim-load.{webm,gif}
179
+ vhs demo/sim-load.tape # Mr Meow (pipoya) — demo/sim-load.{webm,gif}
180
+ vhs demo/codex-pet-load.tape # Einstein (codex pet) — demo/codex-pet-load.{webm,gif}
161
181
  ```
162
182
 
163
183
  ---
@@ -172,7 +192,11 @@ These come bundled with `pi` itself, so installing pi-simocracy via
172
192
 
173
193
  Direct npm dependencies (auto-installed):
174
194
 
175
- - `pngjs` — PNG decoder for sprite blobs
195
+ - `pngjs` — PNG decoder for pipoya sprite blobs and codex pet PNG sheets
196
+ - `@jsquash/webp` — wasm WebP decoder for codex pet WebP sheets
197
+ (lazy-init, no native bindings)
198
+ - `@atproto/api` + `@atproto/oauth-client-node` — ATProto loopback OAuth
199
+ for `/sim login` and PDS writes via `simocracy_update_sim`
176
200
  - `typebox` — tool parameter schemas
177
201
 
178
202
  ---
@@ -192,4 +216,4 @@ Direct npm dependencies (auto-installed):
192
216
 
193
217
  ## License
194
218
 
195
- MIT — see [LICENSE](./LICENSE).
219
+ MIT — see [LICENSE](https://github.com/GainForest/pi-simocracy/blob/main/LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-simocracy",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Pi extension: load a Simocracy sim into your chat — see its pixel-art sprite render inline in the terminal and roleplay with it.",
5
5
  "type": "module",
6
6
  "author": "David Dao <david@gainforest.earth> (https://github.com/daviddao)",
@@ -45,6 +45,7 @@
45
45
  "dependencies": {
46
46
  "@atproto/api": "^0.19.11",
47
47
  "@atproto/oauth-client-node": "^0.3.17",
48
+ "@jsquash/webp": "^1.5.0",
48
49
  "pngjs": "^7.0.0",
49
50
  "typebox": "^1.1.24"
50
51
  },
package/src/index.ts CHANGED
@@ -81,7 +81,9 @@ import {
81
81
  cropRgba,
82
82
  detectPixelArtScale,
83
83
  downscaleRgbaNearest,
84
+ boxDownscaleRgba,
84
85
  } from "./png-to-ansi.ts";
86
+ import { decodeWebp } from "./webp-to-rgba.ts";
85
87
  import { openRouterComplete, type ChatMessage } from "./openrouter.ts";
86
88
  import { buildSimPrompt, type LoadedSim } from "./persona.ts";
87
89
  import { runLogin, runLogout, runWhoami } from "./auth/commands.ts";
@@ -124,19 +126,43 @@ function blobLink(ref: unknown): string | null {
124
126
  }
125
127
 
126
128
  /**
127
- * Render a sim's sprite at its native 32×32 size as colored ANSI
128
- * half-block art 16 cells tall, 32 cells wide. Compact enough to fit
129
- * comfortably in a terminal alongside the loaded-sim message.
129
+ * Maximum long-edge of a non-pixel-art avatar before we box-downscale it.
130
+ * Picked so codex pet idle frames render at roughly the same on-screen
131
+ * height as a 32×32 pipoya sprite (~17 terminal lines) instead of
132
+ * ballooning to 60+ lines and pushing the rest of chat off-screen.
133
+ */
134
+ const NON_PIXEL_ART_TARGET_LONG_EDGE = 40;
135
+
136
+ /**
137
+ * Render a sim's avatar as colored ANSI half-block art, sized to fit
138
+ * comfortably alongside the loaded-sim message in a typical terminal.
139
+ *
140
+ * Branches on `spriteKind` (lexicon discriminator):
141
+ *
142
+ * pipoya — fetch the 4×4 walk-sheet PNG, crop the front-facing
143
+ * walk-1 frame (32×32, row 0 col 0), render at native
144
+ * resolution. ~16 lines tall.
130
145
  *
131
- * Pulls the front-facing walk1 frame (row 0, col 0) from the 128×128
132
- * sprite-sheet blob. Falls back to the static avatar PNG if no sheet
133
- * is published for this sim.
146
+ * codexPet prefer the 8×9 atlas (`petSheet`, 1536×1872, 192×208
147
+ * cells) when it's PNG: crop the idle cell (row 0 col 0)
148
+ * and box-downscale to ~32 wide. The atlas is usually WebP
149
+ * (the OpenAI hatch-pet skill emits WebP and `pngjs`
150
+ * doesn't speak WebP), so we fall through to the rendered
151
+ * `image` thumbnail — a PNG that the simocracy.org client
152
+ * generates at 128×128 from the idle frame. That gets
153
+ * box-downscaled to a comparable terminal size.
154
+ *
155
+ * absent — treated as legacy 'pipoya' for back-compat.
134
156
  */
135
157
  async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
158
+ const spriteKind = sim.sim.spriteKind ?? "pipoya";
136
159
  const spriteLink = blobLink(sim.sim.sprite?.ref);
160
+ const petSheetLink = blobLink(sim.sim.petSheet?.ref);
161
+ const petSheetMime = sim.sim.petSheet?.mimeType;
137
162
  const imageLink = blobLink(sim.sim.image?.ref);
138
163
 
139
- if (spriteLink) {
164
+ // Pipoya 4×4 walk sheet — the legacy/default path.
165
+ if (spriteKind !== "codexPet" && spriteLink) {
140
166
  try {
141
167
  const buf = await fetchBlob(sim.did, spriteLink);
142
168
  const { width, height, data } = decodePng(buf);
@@ -158,23 +184,74 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
158
184
  alphaThreshold: 16,
159
185
  });
160
186
  } catch {
161
- /* fall through to avatar */
187
+ /* fall through to image fallback */
162
188
  }
163
189
  }
164
190
 
191
+ // Codex pet atlas — the canonical asset for codex pets. Sheets are
192
+ // 1536×1872 with 192×208 cells laid out 8 cols × 9 rows; row 0 col 0
193
+ // is the idle frame. Both PNG and WebP are valid in the lexicon (the
194
+ // hatch-pet skill emits WebP, the dropzone preserves PNG when the
195
+ // user drops a PNG sheet) so we pick the right decoder by mimeType.
196
+ // We crop the idle cell first thing and box-downscale to ~32 wide,
197
+ // so the inline render is similar in height to a pipoya sprite
198
+ // (~17 lines).
199
+ if (spriteKind === "codexPet" && petSheetLink) {
200
+ try {
201
+ const buf = await fetchBlob(sim.did, petSheetLink);
202
+ const { width, height, data } =
203
+ petSheetMime === "image/webp" ? await decodeWebp(buf) : decodePng(buf);
204
+ const CELL_W = 192;
205
+ const CELL_H = 208;
206
+ if (width >= CELL_W && height >= CELL_H) {
207
+ const cell = cropRgba(data, width, height, 0, 0, CELL_W, CELL_H);
208
+ const targetW = 32;
209
+ const targetH = Math.round((CELL_H / CELL_W) * targetW); // ~35
210
+ const scaled = boxDownscaleRgba(cell, CELL_W, CELL_H, targetW, targetH);
211
+ return renderRgbaToAnsi(scaled.data, scaled.width, scaled.height, {
212
+ cropToContent: true,
213
+ cropPad: 1,
214
+ indent: 2,
215
+ alphaThreshold: 16,
216
+ });
217
+ }
218
+ } catch {
219
+ /* fall through to image fallback */
220
+ }
221
+ }
222
+
223
+ // Image fallback — used when a sim has no walk sheet (legacy pipoya
224
+ // sims that pre-date `org.simocracy.sim.sprite`) or when the codex
225
+ // pet atlas decode failed for any reason. The simocracy.org client
226
+ // always generates a 128×128 PNG idle thumbnail and uploads it as
227
+ // `image` for codex pets, so even when this path runs the right pose
228
+ // shows up.
165
229
  if (imageLink) {
166
230
  try {
167
231
  const buf = await fetchBlob(sim.did, imageLink);
168
232
  const { width, height, data } = decodePng(buf);
169
- // Old sims (pre-sprite-sheet) only have the avatar PNG, which
170
- // simocracy.org renders by 4×-upscaling a native 32×32 sprite into
171
- // a 128×128 image with nearest-neighbour. Detect that and downsample
172
- // back to the original size so the inline render is the same
173
- // ~13-line height as a sprite-sheet-equipped sim instead of
174
- // ballooning to ~22 lines and pushing chat off-screen.
233
+ // Old pipoya sims publish a 128×128 PNG that's a 4×-upscaled 32×32
234
+ // pixel-art sprite. Detect and undo that with nearest-neighbour
235
+ // downsampling.
175
236
  const scale = detectPixelArtScale(data, width, height, 8);
176
- const native =
177
- scale > 1 ? downscaleRgbaNearest(data, width, height, scale) : { data, width, height };
237
+ let native: { data: Buffer; width: number; height: number } =
238
+ scale > 1
239
+ ? downscaleRgbaNearest(data, width, height, scale)
240
+ : { data, width, height };
241
+ // Non-pixel-art images (codex pet thumbnails, avatars from other
242
+ // pipelines) come through at full resolution — box-downscale them
243
+ // so the ANSI render fits in a typical terminal row budget.
244
+ const longEdge = Math.max(native.width, native.height);
245
+ if (scale === 1 && longEdge > NON_PIXEL_ART_TARGET_LONG_EDGE) {
246
+ const k = NON_PIXEL_ART_TARGET_LONG_EDGE / longEdge;
247
+ native = boxDownscaleRgba(
248
+ native.data,
249
+ native.width,
250
+ native.height,
251
+ Math.max(1, Math.round(native.width * k)),
252
+ Math.max(1, Math.round(native.height * k)),
253
+ );
254
+ }
178
255
  return renderRgbaToAnsi(native.data, native.width, native.height, {
179
256
  cropToContent: true,
180
257
  cropPad: 1,
@@ -942,8 +1019,11 @@ async function tryLoadFromQuery(query: string): Promise<LoadedSim | null> {
942
1019
  const { getRecordFromPds } = await import("./simocracy.ts");
943
1020
  const sim = await getRecordFromPds<{
944
1021
  name: string;
945
- image?: { ref: unknown };
946
- sprite?: { ref: unknown };
1022
+ spriteKind?: "pipoya" | "codexPet";
1023
+ image?: { ref: unknown; mimeType: string; size: number };
1024
+ sprite?: { ref: unknown; mimeType: string; size: number };
1025
+ petSheet?: { ref: unknown; mimeType: string; size: number };
1026
+ petManifest?: { id?: string; displayName?: string; description?: string };
947
1027
  $type?: string;
948
1028
  }>(did, "org.simocracy.sim", rkey);
949
1029
  const match: SimMatch = {
@@ -954,9 +1034,12 @@ async function tryLoadFromQuery(query: string): Promise<LoadedSim | null> {
954
1034
  sim: {
955
1035
  $type: "org.simocracy.sim",
956
1036
  name: sim.name,
1037
+ spriteKind: sim.spriteKind,
957
1038
  settings: { selectedOptions: {} },
958
1039
  image: sim.image as never,
959
1040
  sprite: sim.sprite as never,
1041
+ petSheet: sim.petSheet as never,
1042
+ petManifest: sim.petManifest,
960
1043
  createdAt: "",
961
1044
  },
962
1045
  };
@@ -235,6 +235,85 @@ export function downscaleRgbaNearest(
235
235
  return { data: out, width: newW, height: newH };
236
236
  }
237
237
 
238
+ /**
239
+ * Box-average downscale an RGBA buffer to arbitrary target dimensions.
240
+ *
241
+ * Uses straight area-weighted averaging on premultiplied alpha so partly
242
+ * transparent edge pixels don't bleed black into the result. Designed for
243
+ * non-pixel-art inputs (codex pet idle thumbnails, codex pet sheet cells)
244
+ * where we want a smaller display size at non-integer ratios.
245
+ *
246
+ * If the target equals the source size, returns the input untouched.
247
+ */
248
+ export function boxDownscaleRgba(
249
+ data: Buffer,
250
+ width: number,
251
+ height: number,
252
+ targetW: number,
253
+ targetH: number,
254
+ ): { data: Buffer; width: number; height: number } {
255
+ if (targetW < 1 || targetH < 1) {
256
+ throw new Error(`boxDownscaleRgba target must be ≥1×1, got ${targetW}×${targetH}`);
257
+ }
258
+ if (targetW === width && targetH === height) {
259
+ return { data, width, height };
260
+ }
261
+ const out = Buffer.alloc(targetW * targetH * 4);
262
+ // For each output pixel, average the source rectangle that maps to it.
263
+ // Edge cells are clamped to the source extent.
264
+ for (let oy = 0; oy < targetH; oy++) {
265
+ const sy0 = (oy * height) / targetH;
266
+ const sy1 = ((oy + 1) * height) / targetH;
267
+ const y0 = Math.floor(sy0);
268
+ const y1 = Math.min(height, Math.ceil(sy1));
269
+ for (let ox = 0; ox < targetW; ox++) {
270
+ const sx0 = (ox * width) / targetW;
271
+ const sx1 = ((ox + 1) * width) / targetW;
272
+ const x0 = Math.floor(sx0);
273
+ const x1 = Math.min(width, Math.ceil(sx1));
274
+
275
+ // Premultiplied accumulation so transparent pixels don't smear
276
+ // black RGB values into mostly-opaque neighbours.
277
+ let rSum = 0,
278
+ gSum = 0,
279
+ bSum = 0,
280
+ aSum = 0,
281
+ wSum = 0;
282
+ for (let y = y0; y < y1; y++) {
283
+ // Vertical coverage of this source row inside the output cell.
284
+ const wy = Math.min(y + 1, sy1) - Math.max(y, sy0);
285
+ if (wy <= 0) continue;
286
+ for (let x = x0; x < x1; x++) {
287
+ const wx = Math.min(x + 1, sx1) - Math.max(x, sx0);
288
+ if (wx <= 0) continue;
289
+ const w = wx * wy;
290
+ const i = (y * width + x) * 4;
291
+ const a = data[i + 3];
292
+ const aw = a * w;
293
+ rSum += data[i] * aw;
294
+ gSum += data[i + 1] * aw;
295
+ bSum += data[i + 2] * aw;
296
+ aSum += aw;
297
+ wSum += w;
298
+ }
299
+ }
300
+ const di = (oy * targetW + ox) * 4;
301
+ if (aSum > 0) {
302
+ out[di] = Math.min(255, Math.round(rSum / aSum));
303
+ out[di + 1] = Math.min(255, Math.round(gSum / aSum));
304
+ out[di + 2] = Math.min(255, Math.round(bSum / aSum));
305
+ out[di + 3] = Math.min(255, Math.round(aSum / wSum));
306
+ } else {
307
+ out[di] = 0;
308
+ out[di + 1] = 0;
309
+ out[di + 2] = 0;
310
+ out[di + 3] = 0;
311
+ }
312
+ }
313
+ }
314
+ return { data: out, width: targetW, height: targetH };
315
+ }
316
+
238
317
  /**
239
318
  * Detect the integer upscale factor of a pixel-art image by scanning
240
319
  * for the largest factor F where every F×F block has uniform colour.
package/src/simocracy.ts CHANGED
@@ -24,13 +24,33 @@ export interface BlobRef {
24
24
  size: number;
25
25
  }
26
26
 
27
+ /** Which sprite system a sim uses. Absent = legacy 'pipoya'. */
28
+ export type SpriteKind = "pipoya" | "codexPet";
29
+
30
+ /** Subset of pet.json from the OpenAI hatch-pet skill output. */
31
+ export interface CodexPetManifest {
32
+ id?: string;
33
+ displayName?: string;
34
+ description?: string;
35
+ }
36
+
27
37
  export interface SimRecord {
28
38
  $type: "org.simocracy.sim";
29
39
  name: string;
30
- settings: SpriteSettings;
40
+ /** Discriminator for sprite system. Absent = 'pipoya'. */
41
+ spriteKind?: SpriteKind;
42
+ /** Pipoya appearance settings (only used when spriteKind = 'pipoya'). */
43
+ settings?: SpriteSettings;
44
+ /** Rendered avatar PNG/JPEG/WebP thumbnail (always present for codex pets, generated client-side as a 128×128 PNG). */
31
45
  image?: BlobRef;
46
+ /** Pipoya 4×4 walk sheet (128×128 PNG). Only present when spriteKind = 'pipoya'. */
32
47
  sprite?: BlobRef;
48
+ /** Codex pet 8×9 atlas (1536×1872, 192×208 cells, PNG or WebP). Only present when spriteKind = 'codexPet'. */
49
+ petSheet?: BlobRef;
50
+ /** Subset of pet.json from the hatch-pet skill output. Only present when spriteKind = 'codexPet'. */
51
+ petManifest?: CodexPetManifest;
33
52
  createdAt: string;
53
+ updatedAt?: string;
34
54
  }
35
55
 
36
56
  export interface AgentsRecord {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * WebP → RGBA decoder for Node.
3
+ *
4
+ * `pngjs` covers the PNG side of pi-simocracy, but the OpenAI hatch-pet
5
+ * skill (and therefore most `org.simocracy.sim` records with
6
+ * `spriteKind = "codexPet"`) ships its 1536×1872 atlas as WebP. We need
7
+ * a WebP decoder to render the idle frame.
8
+ *
9
+ * @jsquash/webp's `decode()` is browser-shaped: in Node its wasm glue
10
+ * tries to `fetch()` its own `.wasm` URL, which Undici rejects for
11
+ * `file://`. Workaround: read the wasm bytes from disk via
12
+ * `createRequire().resolve()`, compile to a `WebAssembly.Module`, and
13
+ * feed it to the package's documented `init()` escape hatch. Fully ESM,
14
+ * no native bindings.
15
+ *
16
+ * The wasm module is initialised lazily on the first `decodeWebp()`
17
+ * call so loading pi-simocracy stays cheap when no one ever loads a
18
+ * codex pet sim. Subsequent calls reuse the same module.
19
+ */
20
+
21
+ import { createRequire } from "node:module";
22
+ import { readFile } from "node:fs/promises";
23
+ import decode, { init as initWebpDecode } from "@jsquash/webp/decode.js";
24
+
25
+ let wasmInitPromise: Promise<void> | null = null;
26
+
27
+ async function ensureWasmInit(): Promise<void> {
28
+ if (wasmInitPromise) return wasmInitPromise;
29
+ wasmInitPromise = (async () => {
30
+ const require = createRequire(import.meta.url);
31
+ // @jsquash/webp ships the decoder wasm next to its JS glue; the
32
+ // package only re-exports JS files, so we have to resolve the wasm
33
+ // path manually. Resolving against the JS entry guarantees we hit
34
+ // the same install of the package the bundler/linker picked.
35
+ const decodeJs = require.resolve("@jsquash/webp/decode.js");
36
+ const wasmPath = decodeJs.replace(/decode\.js$/, "codec/dec/webp_dec.wasm");
37
+ const bytes = await readFile(wasmPath);
38
+ const mod = await WebAssembly.compile(bytes);
39
+ await initWebpDecode(mod);
40
+ })().catch((err) => {
41
+ // Reset so the next caller can retry; otherwise a transient FS
42
+ // error would permanently break codex-pet rendering for the session.
43
+ wasmInitPromise = null;
44
+ throw new Error(`Failed to init @jsquash/webp wasm: ${(err as Error).message}`);
45
+ });
46
+ return wasmInitPromise;
47
+ }
48
+
49
+ /**
50
+ * Decode a WebP buffer into the same flat-RGBA shape `decodePng()`
51
+ * returns, so call sites can treat the two interchangeably.
52
+ *
53
+ * Allocates a fresh `Buffer` rather than aliasing `Uint8ClampedArray`
54
+ * so downstream `Buffer`-only helpers (`pixelAt`, `cropRgba`,
55
+ * `boxDownscaleRgba`) keep working without coercion.
56
+ */
57
+ export async function decodeWebp(
58
+ buf: Buffer,
59
+ ): Promise<{ width: number; height: number; data: Buffer }> {
60
+ await ensureWasmInit();
61
+ // jSquash wants an ArrayBuffer view of just the relevant bytes —
62
+ // passing the whole underlying buffer mis-decodes if `buf` is a slice.
63
+ const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
64
+ const img = await decode(ab as ArrayBuffer);
65
+ return {
66
+ width: img.width,
67
+ height: img.height,
68
+ data: Buffer.from(img.data.buffer, img.data.byteOffset, img.data.byteLength),
69
+ };
70
+ }