pixmap-engine 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/.claude/settings.local.json +15 -0
- package/README.md +103 -0
- package/dist/embedder.js +75 -0
- package/dist/engine.js +66 -0
- package/dist/index.js +259 -0
- package/dist/indexer.js +10 -0
- package/dist/metadataDb.js +69 -0
- package/dist/preview.js +216 -0
- package/dist/searcher.js +17 -0
- package/dist/types.js +3 -0
- package/dist/vectorStore.js +47 -0
- package/docs/HOW.md +177 -0
- package/package.json +33 -0
- package/src/embedder.ts +100 -0
- package/src/engine.ts +106 -0
- package/src/index.ts +310 -0
- package/src/indexer.ts +21 -0
- package/src/metadataDb.ts +87 -0
- package/src/preview.ts +292 -0
- package/src/searcher.ts +30 -0
- package/src/types.ts +19 -0
- package/src/vectorStore.ts +59 -0
- package/tsconfig.json +16 -0
package/src/preview.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import sharp from 'sharp';
|
|
3
|
+
|
|
4
|
+
// ── Terminal detection ──────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
type Protocol = 'iterm' | 'kitty' | 'sixel' | 'halfblock';
|
|
7
|
+
|
|
8
|
+
function detectProtocol(): Protocol {
|
|
9
|
+
const term = process.env.TERM_PROGRAM ?? '';
|
|
10
|
+
const termEnv = process.env.TERM ?? '';
|
|
11
|
+
|
|
12
|
+
// iTerm2, WezTerm, Hyper, Tabby, mintty all support iTerm2 inline images
|
|
13
|
+
if (
|
|
14
|
+
term === 'iTerm.app' ||
|
|
15
|
+
term === 'WezTerm' ||
|
|
16
|
+
term === 'Hyper' ||
|
|
17
|
+
term === 'Tabby' ||
|
|
18
|
+
process.env.WEZTERM_PANE != null ||
|
|
19
|
+
process.env.MINTTY_SHORTCUT != null
|
|
20
|
+
) {
|
|
21
|
+
return 'iterm';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Kitty
|
|
25
|
+
if (term === 'kitty' || process.env.KITTY_PID != null) {
|
|
26
|
+
return 'kitty';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// VS Code terminal supports iTerm2 protocol
|
|
30
|
+
if (term === 'vscode') {
|
|
31
|
+
return 'iterm';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Sixel support (xterm with sixel, foot, mlterm)
|
|
35
|
+
if (termEnv.includes('xterm') && process.env.SIXEL_SUPPORT === '1') {
|
|
36
|
+
return 'sixel';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return 'halfblock';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Get usable terminal width */
|
|
43
|
+
function getTermCols(): number {
|
|
44
|
+
return (process.stdout.columns || 120) - 4;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── iTerm2 inline image protocol ────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
async function renderIterm(imagePath: string, widthCols: number, heightRows?: number): Promise<string> {
|
|
50
|
+
// Resize with sharp first to control dimensions
|
|
51
|
+
const meta = await sharp(imagePath).metadata();
|
|
52
|
+
const origW = meta.width ?? 400;
|
|
53
|
+
const origH = meta.height ?? 400;
|
|
54
|
+
|
|
55
|
+
// Each terminal column ≈ 8px, each row ≈ 16px (common defaults)
|
|
56
|
+
// We target pixel dimensions for good quality
|
|
57
|
+
const targetPxW = widthCols * 8;
|
|
58
|
+
let targetPxH = Math.round(targetPxW * (origH / origW));
|
|
59
|
+
|
|
60
|
+
if (heightRows) {
|
|
61
|
+
const maxPxH = heightRows * 16;
|
|
62
|
+
if (targetPxH > maxPxH) {
|
|
63
|
+
targetPxH = maxPxH;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const buf = await sharp(imagePath)
|
|
68
|
+
.resize(targetPxW, targetPxH, { fit: 'inside', kernel: 'lanczos3' })
|
|
69
|
+
.png()
|
|
70
|
+
.toBuffer();
|
|
71
|
+
|
|
72
|
+
const b64 = buf.toString('base64');
|
|
73
|
+
const args = `inline=1;width=${widthCols};preserveAspectRatio=1`;
|
|
74
|
+
// OSC 1337 ; File=[args]:[base64 data] ST
|
|
75
|
+
return ` \x1b]1337;File=${args}:${b64}\x07`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Kitty graphics protocol ─────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
async function renderKitty(imagePath: string, widthCols: number, heightRows?: number): Promise<string> {
|
|
81
|
+
const meta = await sharp(imagePath).metadata();
|
|
82
|
+
const origW = meta.width ?? 400;
|
|
83
|
+
const origH = meta.height ?? 400;
|
|
84
|
+
|
|
85
|
+
const targetPxW = widthCols * 8;
|
|
86
|
+
let targetPxH = Math.round(targetPxW * (origH / origW));
|
|
87
|
+
|
|
88
|
+
if (heightRows) {
|
|
89
|
+
const maxPxH = heightRows * 16;
|
|
90
|
+
if (targetPxH > maxPxH) targetPxH = maxPxH;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const buf = await sharp(imagePath)
|
|
94
|
+
.resize(targetPxW, targetPxH, { fit: 'inside', kernel: 'lanczos3' })
|
|
95
|
+
.png()
|
|
96
|
+
.toBuffer();
|
|
97
|
+
|
|
98
|
+
const b64 = buf.toString('base64');
|
|
99
|
+
const chunks: string[] = [];
|
|
100
|
+
const CHUNK_SIZE = 4096;
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < b64.length; i += CHUNK_SIZE) {
|
|
103
|
+
const chunk = b64.slice(i, i + CHUNK_SIZE);
|
|
104
|
+
const more = i + CHUNK_SIZE < b64.length ? 1 : 0;
|
|
105
|
+
if (i === 0) {
|
|
106
|
+
chunks.push(`\x1b_Ga=T,f=100,c=${widthCols},m=${more};${chunk}\x1b\\`);
|
|
107
|
+
} else {
|
|
108
|
+
chunks.push(`\x1b_Gm=${more};${chunk}\x1b\\`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return ' ' + chunks.join('');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Half-block fallback (true-color ANSI) ───────────────────────────
|
|
116
|
+
|
|
117
|
+
const UPPER_HALF = '\u2580';
|
|
118
|
+
const RESET = '\x1b[0m';
|
|
119
|
+
|
|
120
|
+
function fg(r: number, g: number, b: number): string {
|
|
121
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function bgc(r: number, g: number, b: number): string {
|
|
125
|
+
return `\x1b[48;2;${r};${g};${b}m`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
type Pixel = [number, number, number]; // r, g, b
|
|
129
|
+
|
|
130
|
+
async function loadPixels(
|
|
131
|
+
imagePath: string,
|
|
132
|
+
cols: number,
|
|
133
|
+
maxTermRows?: number,
|
|
134
|
+
): Promise<{ pixels: Pixel[][]; width: number; height: number }> {
|
|
135
|
+
const meta = await sharp(imagePath).metadata();
|
|
136
|
+
const origW = meta.width ?? cols;
|
|
137
|
+
const origH = meta.height ?? cols;
|
|
138
|
+
const aspect = origH / origW;
|
|
139
|
+
|
|
140
|
+
let pxW = cols;
|
|
141
|
+
let pxH = Math.round(cols * aspect);
|
|
142
|
+
|
|
143
|
+
if (maxTermRows && pxH > maxTermRows * 2) {
|
|
144
|
+
pxH = maxTermRows * 2;
|
|
145
|
+
pxW = Math.round(pxH / aspect);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
pxW = Math.max(pxW, 2);
|
|
149
|
+
pxH = Math.max(pxH, 2);
|
|
150
|
+
// Ensure even height for half-block pairing
|
|
151
|
+
if (pxH % 2 !== 0) pxH += 1;
|
|
152
|
+
|
|
153
|
+
const { data, info } = await sharp(imagePath)
|
|
154
|
+
.resize(pxW, pxH, { fit: 'fill', kernel: 'lanczos3' })
|
|
155
|
+
.removeAlpha()
|
|
156
|
+
.raw()
|
|
157
|
+
.toBuffer({ resolveWithObject: true });
|
|
158
|
+
|
|
159
|
+
const pixels: Pixel[][] = [];
|
|
160
|
+
for (let y = 0; y < info.height; y++) {
|
|
161
|
+
const row: Pixel[] = [];
|
|
162
|
+
for (let x = 0; x < info.width; x++) {
|
|
163
|
+
const i = (y * info.width + x) * 3;
|
|
164
|
+
row.push([data[i], data[i + 1], data[i + 2]]);
|
|
165
|
+
}
|
|
166
|
+
pixels.push(row);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { pixels, width: info.width, height: info.height };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function pixelsToAnsi(pixels: Pixel[][], indent = 2): string {
|
|
173
|
+
const pad = ' '.repeat(indent);
|
|
174
|
+
const lines: string[] = [];
|
|
175
|
+
|
|
176
|
+
for (let y = 0; y < pixels.length; y += 2) {
|
|
177
|
+
let line = pad;
|
|
178
|
+
const topRow = pixels[y];
|
|
179
|
+
const botRow = y + 1 < pixels.length ? pixels[y + 1] : null;
|
|
180
|
+
|
|
181
|
+
for (let x = 0; x < topRow.length; x++) {
|
|
182
|
+
const [tr, tg, tb] = topRow[x];
|
|
183
|
+
const [br, bg, bb] = botRow ? botRow[x] : [0, 0, 0];
|
|
184
|
+
line += `${fg(tr, tg, tb)}${bgc(br, bg, bb)}${UPPER_HALF}`;
|
|
185
|
+
}
|
|
186
|
+
line += RESET;
|
|
187
|
+
lines.push(line);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return lines.join('\n');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function renderHalfblock(imagePath: string, cols: number, maxTermRows?: number): Promise<string> {
|
|
194
|
+
const { pixels } = await loadPixels(imagePath, cols, maxTermRows);
|
|
195
|
+
return pixelsToAnsi(pixels);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
const protocol = detectProtocol();
|
|
201
|
+
|
|
202
|
+
/** Render a single image to terminal string */
|
|
203
|
+
export async function renderImage(
|
|
204
|
+
imagePath: string,
|
|
205
|
+
cols?: number,
|
|
206
|
+
maxTermRows?: number,
|
|
207
|
+
): Promise<string> {
|
|
208
|
+
const w = cols ?? getTermCols();
|
|
209
|
+
|
|
210
|
+
switch (protocol) {
|
|
211
|
+
case 'iterm':
|
|
212
|
+
return renderIterm(imagePath, w, maxTermRows);
|
|
213
|
+
case 'kitty':
|
|
214
|
+
return renderKitty(imagePath, w, maxTermRows);
|
|
215
|
+
default:
|
|
216
|
+
return renderHalfblock(imagePath, w, maxTermRows);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
type ImagePanel = {
|
|
221
|
+
label: string;
|
|
222
|
+
imagePath: string;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/** Render multiple images side-by-side in a row with labels on top */
|
|
226
|
+
export async function renderImageRow(
|
|
227
|
+
panels: ImagePanel[],
|
|
228
|
+
panelCols?: number,
|
|
229
|
+
maxPanelRows = 24,
|
|
230
|
+
gap = 2,
|
|
231
|
+
): Promise<string> {
|
|
232
|
+
const termW = getTermCols();
|
|
233
|
+
const cols = panelCols ?? Math.floor((termW - gap * (panels.length - 1)) / panels.length);
|
|
234
|
+
|
|
235
|
+
// For inline-image protocols, render each image stacked (side-by-side isn't
|
|
236
|
+
// possible with protocol images). Label + image + gap, repeated.
|
|
237
|
+
if (protocol === 'iterm' || protocol === 'kitty') {
|
|
238
|
+
const parts: string[] = [];
|
|
239
|
+
for (const panel of panels) {
|
|
240
|
+
parts.push(` ${panel.label}`);
|
|
241
|
+
const img = await renderImage(panel.imagePath, cols, maxPanelRows);
|
|
242
|
+
parts.push(img);
|
|
243
|
+
parts.push('');
|
|
244
|
+
}
|
|
245
|
+
return parts.join('\n');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Half-block: true side-by-side rendering
|
|
249
|
+
const allData = await Promise.all(
|
|
250
|
+
panels.map((p) => loadPixels(p.imagePath, cols, maxPanelRows)),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const lines: string[] = [];
|
|
254
|
+
const indent = ' ';
|
|
255
|
+
const gapStr = ' '.repeat(gap);
|
|
256
|
+
|
|
257
|
+
// Labels
|
|
258
|
+
let labelLine = indent;
|
|
259
|
+
for (let i = 0; i < panels.length; i++) {
|
|
260
|
+
const label = panels[i].label;
|
|
261
|
+
const padded = label.length > cols ? label.slice(0, cols - 1) + '\u2026' : label.padEnd(cols);
|
|
262
|
+
labelLine += padded;
|
|
263
|
+
if (i < panels.length - 1) labelLine += gapStr;
|
|
264
|
+
}
|
|
265
|
+
lines.push(labelLine);
|
|
266
|
+
|
|
267
|
+
const maxPxRows = Math.max(...allData.map((d) => d.height));
|
|
268
|
+
|
|
269
|
+
for (let y = 0; y < maxPxRows; y += 2) {
|
|
270
|
+
let line = indent;
|
|
271
|
+
|
|
272
|
+
for (let gi = 0; gi < allData.length; gi++) {
|
|
273
|
+
const { pixels, width } = allData[gi];
|
|
274
|
+
|
|
275
|
+
for (let x = 0; x < width; x++) {
|
|
276
|
+
const [tr, tg, tb] = y < pixels.length ? pixels[y][x] : [0, 0, 0];
|
|
277
|
+
const [br, bg, bb] = y + 1 < pixels.length ? pixels[y + 1][x] : [0, 0, 0];
|
|
278
|
+
line += `${fg(tr, tg, tb)}${bgc(br, bg, bb)}${UPPER_HALF}`;
|
|
279
|
+
}
|
|
280
|
+
line += RESET;
|
|
281
|
+
|
|
282
|
+
// Pad shorter panels
|
|
283
|
+
const padN = cols - width;
|
|
284
|
+
if (padN > 0) line += ' '.repeat(padN);
|
|
285
|
+
if (gi < allData.length - 1) line += gapStr;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
lines.push(line);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return lines.join('\n');
|
|
292
|
+
}
|
package/src/searcher.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ImageEmbedder } from './embedder.js';
|
|
2
|
+
import { MetadataDb } from './metadataDb.js';
|
|
3
|
+
import { DEFAULT_TOP_K, SearchResult } from './types.js';
|
|
4
|
+
import { VectorStore } from './vectorStore.js';
|
|
5
|
+
|
|
6
|
+
export async function findSimilar(
|
|
7
|
+
queryImagePath: string,
|
|
8
|
+
embedder: ImageEmbedder,
|
|
9
|
+
store: VectorStore,
|
|
10
|
+
db: MetadataDb,
|
|
11
|
+
topK = DEFAULT_TOP_K
|
|
12
|
+
): Promise<SearchResult[]> {
|
|
13
|
+
const queryVec = await embedder.embed(queryImagePath);
|
|
14
|
+
const hits = store.search(queryVec, topK);
|
|
15
|
+
|
|
16
|
+
const results: SearchResult[] = [];
|
|
17
|
+
for (const hit of hits) {
|
|
18
|
+
const metadata = db.get(hit.id);
|
|
19
|
+
if (!metadata) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
results.push({
|
|
24
|
+
...metadata,
|
|
25
|
+
score: hit.score,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return results;
|
|
30
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const VECTOR_DIMENSIONS = 512;
|
|
2
|
+
export const DEFAULT_TOP_K = 5;
|
|
3
|
+
export const DEFAULT_MAX_ELEMENTS = 100_000;
|
|
4
|
+
|
|
5
|
+
export type SimilarityHit = {
|
|
6
|
+
id: number;
|
|
7
|
+
score: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ImageRecord = {
|
|
11
|
+
id: number;
|
|
12
|
+
path: string;
|
|
13
|
+
created: number;
|
|
14
|
+
indexed: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type SearchResult = ImageRecord & {
|
|
18
|
+
score: number;
|
|
19
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import hnswlib from 'hnswlib-node';
|
|
3
|
+
import { DEFAULT_MAX_ELEMENTS, SimilarityHit, VECTOR_DIMENSIONS } from './types.js';
|
|
4
|
+
|
|
5
|
+
const { HierarchicalNSW } = hnswlib;
|
|
6
|
+
|
|
7
|
+
export class VectorStore {
|
|
8
|
+
private index: any;
|
|
9
|
+
private readonly dim: number;
|
|
10
|
+
private maxElements: number;
|
|
11
|
+
|
|
12
|
+
constructor(dim = VECTOR_DIMENSIONS, maxElements = DEFAULT_MAX_ELEMENTS) {
|
|
13
|
+
this.dim = dim;
|
|
14
|
+
this.maxElements = maxElements;
|
|
15
|
+
this.index = new HierarchicalNSW('cosine', dim);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
initOrLoad(indexPath: string): void {
|
|
19
|
+
if (fs.existsSync(indexPath)) {
|
|
20
|
+
this.index.readIndexSync(indexPath);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
this.index.initIndex(this.maxElements);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
add(id: number, vector: Float32Array): void {
|
|
28
|
+
this.ensureCapacity();
|
|
29
|
+
this.index.addPoint(Array.from(vector), id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
search(queryVector: Float32Array, topK: number): SimilarityHit[] {
|
|
33
|
+
const result = this.index.searchKnn(Array.from(queryVector), topK);
|
|
34
|
+
|
|
35
|
+
return result.neighbors.map((id: number, i: number) => ({
|
|
36
|
+
id,
|
|
37
|
+
score: 1 - result.distances[i],
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
save(path: string): void {
|
|
42
|
+
this.index.writeIndex(path);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getCount(): number {
|
|
46
|
+
return this.index.getCurrentCount();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private ensureCapacity(): void {
|
|
50
|
+
const count = this.index.getCurrentCount();
|
|
51
|
+
if (count < this.maxElements) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const next = Math.ceil(this.maxElements * 1.5);
|
|
56
|
+
this.index.resizeIndex(next);
|
|
57
|
+
this.maxElements = next;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"resolveJsonModule": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts"],
|
|
15
|
+
"exclude": ["dist", "node_modules"]
|
|
16
|
+
}
|