pi-sprite 1.0.0
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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/NOTICE.md +7 -0
- package/README.md +353 -0
- package/examples/README.md +15 -0
- package/examples/custom-pets/wumpus-template/README.md +21 -0
- package/examples/custom-pets/wumpus-template/pet.json +14 -0
- package/examples/petdex-downloads/.gitkeep +0 -0
- package/extensions/index.ts +104 -0
- package/package.json +77 -0
- package/skills/pi-sprite-authoring/SKILL.md +330 -0
- package/skills/pi-sprite-authoring/assets/wumpus-template/pet.json +14 -0
- package/skills/pi-sprite-authoring/references/character-cohesion-review.md +65 -0
- package/skills/pi-sprite-authoring/references/gpt-image-sprite-workflow.md +181 -0
- package/skills/pi-sprite-authoring/references/petdex-reference-to-custom-pet.md +155 -0
- package/skills/pi-sprite-authoring/references/wumpus-sprite-prompts.md +54 -0
- package/skills/pi-sprite-authoring/scripts/assemble_sprite_strip.py +98 -0
- package/skills/pi-sprite-authoring/scripts/create-pet-template.mjs +51 -0
- package/skills/pi-sprite-authoring/scripts/create_motion_strip.py +110 -0
- package/skills/pi-sprite-authoring/scripts/download-petdex-examples.mjs +79 -0
- package/skills/pi-sprite-authoring/scripts/openai_sprite_image.py +223 -0
- package/skills/pi-sprite-authoring/scripts/remove_sprite_background.py +207 -0
- package/src/agent/session-entries.ts +28 -0
- package/src/agent/side-completion.ts +94 -0
- package/src/agent/side-session-text.ts +20 -0
- package/src/agent/side-session-types.ts +12 -0
- package/src/agent/side-session.ts +107 -0
- package/src/btw/completion.ts +15 -0
- package/src/btw/format.ts +30 -0
- package/src/btw/index.ts +306 -0
- package/src/btw/prompt.ts +35 -0
- package/src/btw/recap.ts +56 -0
- package/src/btw/session.ts +37 -0
- package/src/btw/thread-store.ts +37 -0
- package/src/context/index.ts +343 -0
- package/src/recap/conversation.ts +22 -0
- package/src/recap/direct.ts +15 -0
- package/src/recap/format.ts +25 -0
- package/src/recap/generation.ts +58 -0
- package/src/recap/index.ts +86 -0
- package/src/sprite/commands.ts +205 -0
- package/src/sprite/download.ts +75 -0
- package/src/sprite/kitty-placeholder.ts +441 -0
- package/src/sprite/live-status-format.ts +67 -0
- package/src/sprite/live-status.ts +48 -0
- package/src/sprite/loader.ts +134 -0
- package/src/sprite/manifest.ts +87 -0
- package/src/sprite/paths.ts +8 -0
- package/src/sprite/petdex.ts +133 -0
- package/src/sprite/renderer.ts +491 -0
- package/src/sprite/runtime.ts +524 -0
- package/src/sprite/turn-status-format.ts +112 -0
- package/src/sprite/turn-status.ts +88 -0
- package/src/ui/overlay.ts +308 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
type Component,
|
|
6
|
+
deleteAllKittyImages,
|
|
7
|
+
deleteKittyImage,
|
|
8
|
+
encodeKitty,
|
|
9
|
+
getCapabilities,
|
|
10
|
+
getCellDimensions,
|
|
11
|
+
Image,
|
|
12
|
+
visibleWidth,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
import sharp from "sharp";
|
|
15
|
+
import {
|
|
16
|
+
clearKittyPlaceholderCaches,
|
|
17
|
+
KittyPlaceholderSpriteWidget,
|
|
18
|
+
setTerminalGraphicsSinkForTests,
|
|
19
|
+
type TerminalGraphicsSink,
|
|
20
|
+
} from "./kitty-placeholder.ts";
|
|
21
|
+
import type { InstalledPet } from "./loader.ts";
|
|
22
|
+
import type { SpriteState } from "./manifest.ts";
|
|
23
|
+
|
|
24
|
+
const RESET = "\u001b[0m";
|
|
25
|
+
const SIZE_PRESETS = {
|
|
26
|
+
tiny: { columns: 12, rows: 3 },
|
|
27
|
+
small: { columns: 16, rows: 4 },
|
|
28
|
+
medium: { columns: 20, rows: 6 },
|
|
29
|
+
large: { columns: 24, rows: 8 },
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export type SpriteSize = keyof typeof SIZE_PRESETS;
|
|
33
|
+
export type SpriteAlign = "left" | "right";
|
|
34
|
+
|
|
35
|
+
export interface SpriteRenderOptions {
|
|
36
|
+
size?: SpriteSize;
|
|
37
|
+
label?: boolean;
|
|
38
|
+
align?: SpriteAlign;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function preset(options: SpriteRenderOptions = {}): { columns: number; rows: number } {
|
|
42
|
+
return SIZE_PRESETS[options.size ?? "small"];
|
|
43
|
+
}
|
|
44
|
+
const PETDEX_ATLAS_ROWS = 9;
|
|
45
|
+
const PETDEX_ATLAS_COLS = 8;
|
|
46
|
+
const PETDEX_FRAME_COUNTS = {
|
|
47
|
+
idle: 6,
|
|
48
|
+
runRight: 8,
|
|
49
|
+
runLeft: 8,
|
|
50
|
+
wave: 4,
|
|
51
|
+
jump: 5,
|
|
52
|
+
failed: 8,
|
|
53
|
+
waiting: 6,
|
|
54
|
+
running: 6,
|
|
55
|
+
review: 6,
|
|
56
|
+
} as const;
|
|
57
|
+
const STATE_TO_ATLAS_ROW: Record<SpriteState, { row: number; frames: number }> = {
|
|
58
|
+
idle: { row: 0, frames: PETDEX_FRAME_COUNTS.idle },
|
|
59
|
+
thinking: { row: 8, frames: PETDEX_FRAME_COUNTS.review },
|
|
60
|
+
working: { row: 7, frames: PETDEX_FRAME_COUNTS.running },
|
|
61
|
+
success: { row: 4, frames: PETDEX_FRAME_COUNTS.jump },
|
|
62
|
+
error: { row: 5, frames: PETDEX_FRAME_COUNTS.failed },
|
|
63
|
+
};
|
|
64
|
+
const DISABLE_NATIVE_IMAGE_VALUES = new Set(["0", "false", "off", "none", "ansi"]);
|
|
65
|
+
const TMUX_PASSTHROUGH_PREFIX = "\u001bPtmux;";
|
|
66
|
+
const TMUX_PASSTHROUGH_SUFFIX = "\u001b\\";
|
|
67
|
+
const TUI_IMAGE_CLEANUP_GUARD_SEQUENCE = "\u001b_Ga=d,d=I,i=0,q=2\u001b\\";
|
|
68
|
+
|
|
69
|
+
export interface NativeSpriteFrame {
|
|
70
|
+
base64: string;
|
|
71
|
+
filename: string;
|
|
72
|
+
width: number;
|
|
73
|
+
height: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface RenderedSpriteFrame {
|
|
77
|
+
lines: string[];
|
|
78
|
+
signature: string;
|
|
79
|
+
native?: NativeSpriteFrame;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface RenderedSpriteAnimation {
|
|
83
|
+
frames: RenderedSpriteFrame[];
|
|
84
|
+
signature: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const MAX_RENDER_CACHE_ENTRIES = 48;
|
|
88
|
+
const renderCache = new Map<string, RenderedSpriteAnimation>();
|
|
89
|
+
|
|
90
|
+
function color(r: number, g: number, b: number, target: "fg" | "bg"): string {
|
|
91
|
+
return `\u001b[${target === "fg" ? 38 : 48};2;${r};${g};${b}m`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function visiblePixel(data: Buffer, offset: number): [number, number, number] | undefined {
|
|
95
|
+
const alpha = data[offset + 3] ?? 255;
|
|
96
|
+
if (alpha < 32) return undefined;
|
|
97
|
+
return [data[offset] ?? 0, data[offset + 1] ?? 0, data[offset + 2] ?? 0];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function pixelsToHalfBlocks(data: Buffer, width: number, height: number): string[] {
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
for (let y = 0; y < height; y += 2) {
|
|
103
|
+
let line = "";
|
|
104
|
+
for (let x = 0; x < width; x++) {
|
|
105
|
+
const top = visiblePixel(data, (y * width + x) * 4);
|
|
106
|
+
const bottom = y + 1 < height ? visiblePixel(data, ((y + 1) * width + x) * 4) : undefined;
|
|
107
|
+
if (top && bottom) line += `${color(...top, "fg")}${color(...bottom, "bg")}▀${RESET}`;
|
|
108
|
+
else if (top) line += `${color(...top, "fg")}▀${RESET}`;
|
|
109
|
+
else if (bottom) line += `${color(...bottom, "fg")}▄${RESET}`;
|
|
110
|
+
else line += " ";
|
|
111
|
+
}
|
|
112
|
+
lines.push(line.replace(/\s+$/u, ""));
|
|
113
|
+
}
|
|
114
|
+
return lines.filter((line) => line.trim().length > 0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sourcePath(pet: InstalledPet, state: SpriteState): string | undefined {
|
|
118
|
+
return pet.manifest.sprites[state] ?? pet.manifest.sprites.idle;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function frameHash(parts: string[]): string {
|
|
122
|
+
return createHash("sha1").update(parts.join("\0")).digest("hex").slice(0, 12);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function frameRects(
|
|
126
|
+
state: SpriteState,
|
|
127
|
+
metadata: sharp.Metadata,
|
|
128
|
+
configuredFrame: { width?: number; height?: number } = {},
|
|
129
|
+
looksLikeSpritesheet = false,
|
|
130
|
+
): Array<{ left: number; top: number; width: number; height: number; name: string }> {
|
|
131
|
+
const imageWidth = metadata.width ?? 0;
|
|
132
|
+
const imageHeight = metadata.height ?? 0;
|
|
133
|
+
const inferredAtlas =
|
|
134
|
+
looksLikeSpritesheet &&
|
|
135
|
+
imageWidth >= PETDEX_ATLAS_COLS &&
|
|
136
|
+
imageHeight >= PETDEX_ATLAS_ROWS &&
|
|
137
|
+
imageWidth % PETDEX_ATLAS_COLS === 0 &&
|
|
138
|
+
imageHeight % PETDEX_ATLAS_ROWS === 0;
|
|
139
|
+
const frameWidth = Math.floor(configuredFrame.width ?? (inferredAtlas ? imageWidth / PETDEX_ATLAS_COLS : imageWidth));
|
|
140
|
+
const frameHeight = Math.floor(
|
|
141
|
+
configuredFrame.height ?? (inferredAtlas ? imageHeight / PETDEX_ATLAS_ROWS : imageHeight),
|
|
142
|
+
);
|
|
143
|
+
if (frameWidth > 0 && frameHeight > 0 && imageWidth >= frameWidth && imageHeight >= frameHeight) {
|
|
144
|
+
const cols = Math.floor(imageWidth / frameWidth);
|
|
145
|
+
const rows = Math.floor(imageHeight / frameHeight);
|
|
146
|
+
if (cols >= PETDEX_ATLAS_COLS && rows >= PETDEX_ATLAS_ROWS) {
|
|
147
|
+
const mapping = STATE_TO_ATLAS_ROW[state];
|
|
148
|
+
return Array.from({ length: Math.min(mapping.frames, cols) }, (_, col) => ({
|
|
149
|
+
left: col * frameWidth,
|
|
150
|
+
top: mapping.row * frameHeight,
|
|
151
|
+
width: frameWidth,
|
|
152
|
+
height: frameHeight,
|
|
153
|
+
name: `${state}-${col}`,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
return Array.from({ length: Math.max(1, cols) }, (_, col) => ({
|
|
157
|
+
left: col * frameWidth,
|
|
158
|
+
top: 0,
|
|
159
|
+
width: frameWidth,
|
|
160
|
+
height: frameHeight,
|
|
161
|
+
name: `${state}-${col}`,
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
return [{ left: 0, top: 0, width: imageWidth, height: imageHeight, name: state }];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function renderRect(
|
|
168
|
+
file: string,
|
|
169
|
+
rect: { left: number; top: number; width: number; height: number; name: string },
|
|
170
|
+
label: string,
|
|
171
|
+
signatureParts: string[],
|
|
172
|
+
options: SpriteRenderOptions = {},
|
|
173
|
+
): Promise<RenderedSpriteFrame> {
|
|
174
|
+
const size = preset(options);
|
|
175
|
+
const extracted = sharp(file, { animated: false, limitInputPixels: 32 * 1024 * 1024 })
|
|
176
|
+
.rotate()
|
|
177
|
+
.extract({ left: rect.left, top: rect.top, width: rect.width, height: rect.height });
|
|
178
|
+
const png = await extracted.png().toBuffer();
|
|
179
|
+
const { data, info } = await sharp(png)
|
|
180
|
+
.resize({
|
|
181
|
+
width: size.columns,
|
|
182
|
+
height: size.rows * 2,
|
|
183
|
+
fit: "inside",
|
|
184
|
+
withoutEnlargement: true,
|
|
185
|
+
kernel: sharp.kernel.nearest,
|
|
186
|
+
})
|
|
187
|
+
.ensureAlpha()
|
|
188
|
+
.raw()
|
|
189
|
+
.toBuffer({ resolveWithObject: true });
|
|
190
|
+
const art = pixelsToHalfBlocks(data, info.width, info.height);
|
|
191
|
+
if (!art.length) throw new Error("empty sprite frame");
|
|
192
|
+
return {
|
|
193
|
+
lines: options.label ? [...art, label] : art,
|
|
194
|
+
signature: frameHash([
|
|
195
|
+
...signatureParts,
|
|
196
|
+
rect.name,
|
|
197
|
+
`${info.width}x${info.height}`,
|
|
198
|
+
options.label ? "label" : "no-label",
|
|
199
|
+
]),
|
|
200
|
+
native: {
|
|
201
|
+
base64: png.toString("base64"),
|
|
202
|
+
filename: `${frameHash([...signatureParts, rect.name])}.png`,
|
|
203
|
+
width: rect.width,
|
|
204
|
+
height: rect.height,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function explicitNativeImageSetting(): string {
|
|
210
|
+
return process.env.PI_SPRITE_NATIVE_IMAGES?.trim().toLowerCase() ?? "";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isTmux(): boolean {
|
|
214
|
+
const term = process.env.TERM?.toLowerCase() ?? "";
|
|
215
|
+
return Boolean(process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen"));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function outerTerminalSupportsKittyImages(): boolean {
|
|
219
|
+
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() ?? "";
|
|
220
|
+
const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() ?? "";
|
|
221
|
+
return Boolean(
|
|
222
|
+
process.env.KITTY_WINDOW_ID ||
|
|
223
|
+
process.env.GHOSTTY_RESOURCES_DIR ||
|
|
224
|
+
process.env.WEZTERM_PANE ||
|
|
225
|
+
termProgram === "kitty" ||
|
|
226
|
+
termProgram === "ghostty" ||
|
|
227
|
+
termProgram === "wezterm" ||
|
|
228
|
+
terminalEmulator === "ghostty" ||
|
|
229
|
+
terminalEmulator === "wezterm",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function terminalSupportsKittyControl(): boolean {
|
|
234
|
+
return getCapabilities().images === "kitty" || outerTerminalSupportsKittyImages();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export type SpriteNativeImageMode = "ansi" | "direct" | "placeholder";
|
|
238
|
+
|
|
239
|
+
export function spriteNativeImageMode(): SpriteNativeImageMode {
|
|
240
|
+
const setting = explicitNativeImageSetting();
|
|
241
|
+
if (DISABLE_NATIVE_IMAGE_VALUES.has(setting)) return "ansi";
|
|
242
|
+
return terminalSupportsKittyControl() ? "placeholder" : "ansi";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function spriteImageProtocol(): "kitty" | "iterm2" | null {
|
|
246
|
+
const mode = spriteNativeImageMode();
|
|
247
|
+
if (mode === "ansi") return null;
|
|
248
|
+
if (mode === "placeholder") return "kitty";
|
|
249
|
+
const protocol = getCapabilities().images;
|
|
250
|
+
if (protocol === "iterm2") return protocol;
|
|
251
|
+
return "kitty";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function wrapTmuxPassthrough(sequence: string): string {
|
|
255
|
+
return `${TMUX_PASSTHROUGH_PREFIX}${sequence.replaceAll("\u001b", "\u001b\u001b")}${TMUX_PASSTHROUGH_SUFFIX}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function withKittyPlacementId(sequence: string, placementId: number): string {
|
|
259
|
+
const commandStart = sequence.indexOf("\u001b_G");
|
|
260
|
+
if (commandStart < 0) return sequence;
|
|
261
|
+
const paramsEnd = sequence.indexOf(";", commandStart);
|
|
262
|
+
if (paramsEnd < 0) return sequence;
|
|
263
|
+
return `${sequence.slice(0, paramsEnd)},p=${placementId}${sequence.slice(paramsEnd)}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function nativeImageCellSize(
|
|
267
|
+
frame: NativeSpriteFrame,
|
|
268
|
+
maxWidth: number,
|
|
269
|
+
maxRows: number,
|
|
270
|
+
): { columns: number; rows: number } {
|
|
271
|
+
const cell = getCellDimensions();
|
|
272
|
+
const widthScale = (maxWidth * cell.widthPx) / Math.max(1, frame.width);
|
|
273
|
+
const heightScale = (maxRows * cell.heightPx) / Math.max(1, frame.height);
|
|
274
|
+
const scale = Math.min(widthScale, heightScale);
|
|
275
|
+
const columns = Math.ceil((frame.width * scale) / cell.widthPx);
|
|
276
|
+
const rows = Math.ceil((frame.height * scale) / cell.heightPx);
|
|
277
|
+
return { columns: Math.max(1, Math.min(maxWidth, columns)), rows: Math.max(1, Math.min(maxRows, rows)) };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function alignLine(line: string, width: number, align: SpriteAlign): string {
|
|
281
|
+
if (align !== "right") return line;
|
|
282
|
+
return `${" ".repeat(Math.max(0, width - visibleWidth(line) - 1))}${line}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function clearNativeSpriteImage(imageId: number): string[] {
|
|
286
|
+
if (!terminalSupportsKittyControl()) return [];
|
|
287
|
+
const sequence = deleteKittyImage(imageId);
|
|
288
|
+
return [isTmux() ? wrapTmuxPassthrough(sequence) : sequence];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function clearNativeSpriteImages(imageIds: number[]): string[] {
|
|
292
|
+
return imageIds.flatMap((imageId) => clearNativeSpriteImage(imageId));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function clearAllNativeSpriteImages(): string[] {
|
|
296
|
+
if (!terminalSupportsKittyControl()) return [];
|
|
297
|
+
const sequence = deleteAllKittyImages();
|
|
298
|
+
return [isTmux() ? wrapTmuxPassthrough(sequence) : sequence];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
class KittySpriteImage implements Component {
|
|
302
|
+
constructor(
|
|
303
|
+
private readonly frame: NativeSpriteFrame,
|
|
304
|
+
private readonly imageId: number,
|
|
305
|
+
private readonly wrapForTmux: boolean,
|
|
306
|
+
private readonly options: SpriteRenderOptions,
|
|
307
|
+
private readonly previousImageId?: number,
|
|
308
|
+
) {}
|
|
309
|
+
|
|
310
|
+
invalidate(): void {}
|
|
311
|
+
|
|
312
|
+
render(width: number): string[] {
|
|
313
|
+
const configured = preset(this.options);
|
|
314
|
+
const maxWidth = Math.max(1, Math.min(width - 2, configured.columns));
|
|
315
|
+
const size = nativeImageCellSize(this.frame, maxWidth, configured.rows);
|
|
316
|
+
const drawSequence = withKittyPlacementId(
|
|
317
|
+
encodeKitty(this.frame.base64, {
|
|
318
|
+
columns: size.columns,
|
|
319
|
+
rows: size.rows,
|
|
320
|
+
imageId: this.imageId,
|
|
321
|
+
moveCursor: false,
|
|
322
|
+
}),
|
|
323
|
+
this.imageId,
|
|
324
|
+
);
|
|
325
|
+
const deletePreviousSequence =
|
|
326
|
+
this.previousImageId && this.previousImageId !== this.imageId ? deleteKittyImage(this.previousImageId) : "";
|
|
327
|
+
const sequence = `${TUI_IMAGE_CLEANUP_GUARD_SEQUENCE}${drawSequence}${deletePreviousSequence}`;
|
|
328
|
+
const lines = [this.wrapForTmux ? wrapTmuxPassthrough(sequence) : sequence];
|
|
329
|
+
for (let i = 0; i < size.rows - 1; i++) lines.push("");
|
|
330
|
+
return lines;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
class TextSpriteWidget implements Component {
|
|
335
|
+
constructor(
|
|
336
|
+
private readonly lines: string[],
|
|
337
|
+
private readonly options: SpriteRenderOptions,
|
|
338
|
+
) {}
|
|
339
|
+
|
|
340
|
+
invalidate(): void {}
|
|
341
|
+
|
|
342
|
+
render(width: number): string[] {
|
|
343
|
+
return this.lines.map((line) => alignLine(line, width, this.options.align ?? "left"));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
class NativeSpriteWidget implements Component {
|
|
348
|
+
constructor(
|
|
349
|
+
private readonly image: Component,
|
|
350
|
+
private readonly statusLine: string,
|
|
351
|
+
private readonly reservedColumns: number,
|
|
352
|
+
private readonly options: SpriteRenderOptions,
|
|
353
|
+
) {}
|
|
354
|
+
|
|
355
|
+
invalidate(): void {
|
|
356
|
+
this.image.invalidate();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
render(width: number): string[] {
|
|
360
|
+
const align = this.options.align ?? "left";
|
|
361
|
+
const pad = align === "right" ? " ".repeat(Math.max(0, width - this.reservedColumns - 1)) : "";
|
|
362
|
+
const imageLines = this.image.render(width).map((line, index) => (index === 0 ? `${pad}${line}` : line));
|
|
363
|
+
if (!this.options.label) return imageLines;
|
|
364
|
+
return [...imageLines, alignLine(this.statusLine, width, align)];
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function formatTextSpriteLines(
|
|
369
|
+
lines: string[],
|
|
370
|
+
options: SpriteRenderOptions = {},
|
|
371
|
+
width = process.stdout.columns || 80,
|
|
372
|
+
): string[] {
|
|
373
|
+
return lines.map((line) => alignLine(line, width, options.align ?? "left"));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function formatNativeSpritePlaceholderLines(
|
|
377
|
+
prefixLines: string[] = [],
|
|
378
|
+
options: SpriteRenderOptions = {},
|
|
379
|
+
width = process.stdout.columns || 80,
|
|
380
|
+
): string[] {
|
|
381
|
+
const rows = preset(options).rows + (options.label ? 1 : 0);
|
|
382
|
+
const blank = " ".repeat(Math.max(1, width - 1));
|
|
383
|
+
return [...prefixLines, ...Array.from({ length: rows }, () => blank)];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function buildTextSpriteWidget(lines: string[], options: SpriteRenderOptions = {}): Component {
|
|
387
|
+
return new TextSpriteWidget(lines, options);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function supportsNativeSpriteImages(): boolean {
|
|
391
|
+
return spriteNativeImageMode() !== "ansi";
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function usesKittyPlaceholderSpriteImages(): boolean {
|
|
395
|
+
return spriteNativeImageMode() === "placeholder";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function setSpriteTerminalGraphicsSinkForTests(sink: TerminalGraphicsSink | undefined): void {
|
|
399
|
+
setTerminalGraphicsSinkForTests(sink);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function clearSpriteTerminalGraphicsCaches(): void {
|
|
403
|
+
clearKittyPlaceholderCaches();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function buildKittyPlaceholderSpriteWidget(
|
|
407
|
+
frames: NativeSpriteFrame[],
|
|
408
|
+
activeFrameIndex: number,
|
|
409
|
+
statusLine: string,
|
|
410
|
+
imageIds: number[],
|
|
411
|
+
options: SpriteRenderOptions = {},
|
|
412
|
+
): Component {
|
|
413
|
+
return new KittyPlaceholderSpriteWidget(frames, activeFrameIndex, imageIds, statusLine, preset(options), options);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function buildNativeSpriteWidget(
|
|
417
|
+
frame: NativeSpriteFrame,
|
|
418
|
+
statusLine: string,
|
|
419
|
+
imageId: number,
|
|
420
|
+
options: SpriteRenderOptions = {},
|
|
421
|
+
previousImageId?: number,
|
|
422
|
+
): Component {
|
|
423
|
+
const protocol = spriteImageProtocol();
|
|
424
|
+
const size = preset(options);
|
|
425
|
+
const image =
|
|
426
|
+
protocol === "kitty"
|
|
427
|
+
? new KittySpriteImage(frame, imageId, isTmux(), options, previousImageId)
|
|
428
|
+
: new Image(
|
|
429
|
+
frame.base64,
|
|
430
|
+
"image/png",
|
|
431
|
+
{ fallbackColor: (text) => text },
|
|
432
|
+
{ maxWidthCells: size.columns, maxHeightCells: size.rows, filename: frame.filename, imageId },
|
|
433
|
+
{ widthPx: frame.width, heightPx: frame.height },
|
|
434
|
+
);
|
|
435
|
+
return new NativeSpriteWidget(image, statusLine, size.columns, options);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export async function renderSpriteAnimation(
|
|
439
|
+
pet: InstalledPet,
|
|
440
|
+
state: SpriteState,
|
|
441
|
+
options: SpriteRenderOptions = {},
|
|
442
|
+
): Promise<RenderedSpriteAnimation> {
|
|
443
|
+
const relative = sourcePath(pet, state) ?? "";
|
|
444
|
+
const file = relative ? join(pet.dir, relative) : "";
|
|
445
|
+
try {
|
|
446
|
+
const mtime = statSync(file).mtimeMs;
|
|
447
|
+
const cacheKey = [
|
|
448
|
+
pet.id,
|
|
449
|
+
pet.manifest.name,
|
|
450
|
+
pet.dir,
|
|
451
|
+
state,
|
|
452
|
+
relative,
|
|
453
|
+
String(mtime),
|
|
454
|
+
JSON.stringify(pet.manifest.frame ?? {}),
|
|
455
|
+
options.size ?? "small",
|
|
456
|
+
options.label ? "label" : "no-label",
|
|
457
|
+
options.align ?? "left",
|
|
458
|
+
].join("\0");
|
|
459
|
+
const cached = renderCache.get(cacheKey);
|
|
460
|
+
if (cached) return cached;
|
|
461
|
+
const image = sharp(file, { animated: false, limitInputPixels: 32 * 1024 * 1024 }).rotate();
|
|
462
|
+
const metadata = await image.metadata();
|
|
463
|
+
const rects = frameRects(state, metadata, pet.manifest.frame, /spritesheet/i.test(relative)).filter(
|
|
464
|
+
(rect) => rect.width > 0 && rect.height > 0,
|
|
465
|
+
);
|
|
466
|
+
const label = `pi-sprite · ${state} · ${pet.manifest.name}`;
|
|
467
|
+
const signatureParts = [pet.id, state, relative, String(mtime)];
|
|
468
|
+
const frames = await Promise.all(rects.map((rect) => renderRect(file, rect, label, signatureParts, options)));
|
|
469
|
+
if (!frames.length) throw new Error("empty sprite animation");
|
|
470
|
+
const animation = { frames, signature: frameHash(signatureParts) };
|
|
471
|
+
renderCache.set(cacheKey, animation);
|
|
472
|
+
if (renderCache.size > MAX_RENDER_CACHE_ENTRIES) renderCache.delete(renderCache.keys().next().value!);
|
|
473
|
+
return animation;
|
|
474
|
+
} catch {
|
|
475
|
+
const fallbackLines = [` ◕‿◕ ${pet.manifest.name}`];
|
|
476
|
+
if (options.label) fallbackLines.push(`pi-sprite · ${state}${relative ? ` · ${relative}` : ""}`);
|
|
477
|
+
const fallback = {
|
|
478
|
+
lines: fallbackLines,
|
|
479
|
+
signature: `${pet.id}:${state}:${relative}:fallback:${options.label ? "label" : "no-label"}`,
|
|
480
|
+
};
|
|
481
|
+
return { frames: [fallback], signature: fallback.signature };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export async function renderSpriteFrame(
|
|
486
|
+
pet: InstalledPet,
|
|
487
|
+
state: SpriteState,
|
|
488
|
+
options: SpriteRenderOptions = {},
|
|
489
|
+
): Promise<RenderedSpriteFrame> {
|
|
490
|
+
return (await renderSpriteAnimation(pet, state, options)).frames[0]!;
|
|
491
|
+
}
|