pi-simocracy 0.1.0 → 0.1.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/package.json +1 -1
- package/src/index.ts +17 -2
- package/src/png-to-ansi.ts +100 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-simocracy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.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)",
|
package/src/index.ts
CHANGED
|
@@ -35,7 +35,13 @@ import {
|
|
|
35
35
|
type SimMatch,
|
|
36
36
|
type StyleRecord,
|
|
37
37
|
} from "./simocracy.ts";
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
decodePng,
|
|
40
|
+
renderRgbaToAnsi,
|
|
41
|
+
cropRgba,
|
|
42
|
+
detectPixelArtScale,
|
|
43
|
+
downscaleRgbaNearest,
|
|
44
|
+
} from "./png-to-ansi.ts";
|
|
39
45
|
import { openRouterComplete, type ChatMessage } from "./openrouter.ts";
|
|
40
46
|
|
|
41
47
|
// ---------------------------------------------------------------------------
|
|
@@ -119,7 +125,16 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
|
|
|
119
125
|
try {
|
|
120
126
|
const buf = await fetchBlob(sim.did, imageLink);
|
|
121
127
|
const { width, height, data } = decodePng(buf);
|
|
122
|
-
|
|
128
|
+
// Old sims (pre-sprite-sheet) only have the avatar PNG, which
|
|
129
|
+
// simocracy.org renders by 4×-upscaling a native 32×32 sprite into
|
|
130
|
+
// a 128×128 image with nearest-neighbour. Detect that and downsample
|
|
131
|
+
// back to the original size so the inline render is the same
|
|
132
|
+
// ~13-line height as a sprite-sheet-equipped sim instead of
|
|
133
|
+
// ballooning to ~22 lines and pushing chat off-screen.
|
|
134
|
+
const scale = detectPixelArtScale(data, width, height, 8);
|
|
135
|
+
const native =
|
|
136
|
+
scale > 1 ? downscaleRgbaNearest(data, width, height, scale) : { data, width, height };
|
|
137
|
+
return renderRgbaToAnsi(native.data, native.width, native.height, {
|
|
123
138
|
cropToContent: true,
|
|
124
139
|
cropPad: 1,
|
|
125
140
|
indent: 2,
|
package/src/png-to-ansi.ts
CHANGED
|
@@ -190,3 +190,103 @@ export function cropRgba(
|
|
|
190
190
|
}
|
|
191
191
|
return out;
|
|
192
192
|
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Downscale an RGBA buffer by an integer factor using nearest-neighbour
|
|
196
|
+
* sampling. Designed for pixel-art images that were upscaled with no
|
|
197
|
+
* filtering — sampling the centre pixel of each source block recovers
|
|
198
|
+
* the original art losslessly.
|
|
199
|
+
*
|
|
200
|
+
* Returns the new buffer plus its dimensions. Throws if `factor < 1` or
|
|
201
|
+
* source dimensions aren't divisible by it.
|
|
202
|
+
*/
|
|
203
|
+
export function downscaleRgbaNearest(
|
|
204
|
+
data: Buffer,
|
|
205
|
+
width: number,
|
|
206
|
+
height: number,
|
|
207
|
+
factor: number,
|
|
208
|
+
): { data: Buffer; width: number; height: number } {
|
|
209
|
+
if (factor < 1 || !Number.isInteger(factor)) {
|
|
210
|
+
throw new Error(`downscale factor must be a positive integer, got ${factor}`);
|
|
211
|
+
}
|
|
212
|
+
if (factor === 1) return { data, width, height };
|
|
213
|
+
if (width % factor !== 0 || height % factor !== 0) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`downscale ${factor}× needs ${width}×${height} divisible by ${factor}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const newW = width / factor;
|
|
219
|
+
const newH = height / factor;
|
|
220
|
+
const out = Buffer.alloc(newW * newH * 4);
|
|
221
|
+
// Sample the centre pixel of each factor×factor block.
|
|
222
|
+
const offset = Math.floor(factor / 2);
|
|
223
|
+
for (let y = 0; y < newH; y++) {
|
|
224
|
+
const srcY = y * factor + offset;
|
|
225
|
+
for (let x = 0; x < newW; x++) {
|
|
226
|
+
const srcX = x * factor + offset;
|
|
227
|
+
const srcIdx = (srcY * width + srcX) * 4;
|
|
228
|
+
const dstIdx = (y * newW + x) * 4;
|
|
229
|
+
out[dstIdx] = data[srcIdx];
|
|
230
|
+
out[dstIdx + 1] = data[srcIdx + 1];
|
|
231
|
+
out[dstIdx + 2] = data[srcIdx + 2];
|
|
232
|
+
out[dstIdx + 3] = data[srcIdx + 3];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { data: out, width: newW, height: newH };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Detect the integer upscale factor of a pixel-art image by scanning
|
|
240
|
+
* for the largest factor F where every F×F block has uniform colour.
|
|
241
|
+
*
|
|
242
|
+
* For Simocracy avatars this returns 4 (a 32×32 sprite displayed as a
|
|
243
|
+
* 128×128 PNG). For native-resolution images it returns 1. Falls back
|
|
244
|
+
* to 1 if no consistent factor fits.
|
|
245
|
+
*
|
|
246
|
+
* Tests up to maxFactor (default 8) and only checks factors that
|
|
247
|
+
* cleanly divide both dimensions.
|
|
248
|
+
*/
|
|
249
|
+
export function detectPixelArtScale(
|
|
250
|
+
data: Buffer,
|
|
251
|
+
width: number,
|
|
252
|
+
height: number,
|
|
253
|
+
maxFactor = 8,
|
|
254
|
+
): number {
|
|
255
|
+
for (let f = Math.min(maxFactor, width, height); f >= 2; f--) {
|
|
256
|
+
if (width % f !== 0 || height % f !== 0) continue;
|
|
257
|
+
if (isUniformAtFactor(data, width, height, f)) return f;
|
|
258
|
+
}
|
|
259
|
+
return 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isUniformAtFactor(
|
|
263
|
+
data: Buffer,
|
|
264
|
+
width: number,
|
|
265
|
+
height: number,
|
|
266
|
+
factor: number,
|
|
267
|
+
): boolean {
|
|
268
|
+
for (let by = 0; by < height; by += factor) {
|
|
269
|
+
for (let bx = 0; bx < width; bx += factor) {
|
|
270
|
+
const baseIdx = (by * width + bx) * 4;
|
|
271
|
+
const r = data[baseIdx];
|
|
272
|
+
const g = data[baseIdx + 1];
|
|
273
|
+
const b = data[baseIdx + 2];
|
|
274
|
+
const a = data[baseIdx + 3];
|
|
275
|
+
for (let dy = 0; dy < factor; dy++) {
|
|
276
|
+
for (let dx = 0; dx < factor; dx++) {
|
|
277
|
+
if (dx === 0 && dy === 0) continue;
|
|
278
|
+
const idx = ((by + dy) * width + (bx + dx)) * 4;
|
|
279
|
+
if (
|
|
280
|
+
data[idx] !== r ||
|
|
281
|
+
data[idx + 1] !== g ||
|
|
282
|
+
data[idx + 2] !== b ||
|
|
283
|
+
data[idx + 3] !== a
|
|
284
|
+
) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
292
|
+
}
|