@sylphx/image-reader-mcp 0.1.1 → 0.1.2
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 +7 -0
- package/package.json +1 -1
- package/src/handlers/readImage.ts +4 -1
- package/src/schemas/readImage.ts +60 -0
- package/src/utils/agentMap.ts +93 -0
- package/src/utils/applyImageIntelligence.ts +68 -0
- package/src/utils/layout.ts +114 -0
- package/src/utils/optionalLlm.ts +67 -0
- package/src/utils/palette.ts +36 -0
package/README.md
CHANGED
|
@@ -36,6 +36,13 @@ Each instrument is an independent repository (marketplace + stars).
|
|
|
36
36
|
---
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
|
|
40
|
+
## Read images (not vague vision)
|
|
41
|
+
|
|
42
|
+
Iris is **local-first**: geometry + OCR + **layout blocks** + **agent_map** so a text-only agent can understand picture architecture without a vision model.
|
|
43
|
+
|
|
44
|
+
Spec: [docs/specs/agent-image-read-contract.md](docs/specs/agent-image-read-contract.md)
|
|
45
|
+
|
|
39
46
|
## Product docs
|
|
40
47
|
|
|
41
48
|
| Doc | Purpose |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/image-reader-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"mcpName": "io.github.SylphxAI/image-reader-mcp",
|
|
5
5
|
"description": "Iris \u2014 Evidence-first image reading for AI agents \u2014 metadata, OCR text, regions, and citeable evidence without generative LLM.",
|
|
6
6
|
"type": "module",
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from '../engine/rust-decode.js';
|
|
10
10
|
import { text, tool, toolError } from '../mcp.js';
|
|
11
11
|
import { type AgentMediaTwin, readImageArgsSchema } from '../schemas/readImage.js';
|
|
12
|
+
import { applyImageIntelligence } from '../utils/applyImageIntelligence.js';
|
|
12
13
|
import { ErrorCode, ImageError } from '../utils/errors.js';
|
|
13
14
|
import { collectTrustWarnings, redactGpsFields } from '../utils/metadata.js';
|
|
14
15
|
import { runTesseractOcr } from '../utils/ocr.js';
|
|
@@ -74,7 +75,7 @@ const readMetadata = async (
|
|
|
74
75
|
|
|
75
76
|
export const readImage = tool()
|
|
76
77
|
.description(
|
|
77
|
-
'Evidence-first image reader. Returns
|
|
78
|
+
'Evidence-first image reader for agents (read, not vague vision). Returns Agent Media Twin: geometry, metadata, OCR lines/words, layout blocks, text agent_map, optional palette, optional non-authority LLM caption. Local-first; generative path off by default.'
|
|
78
79
|
)
|
|
79
80
|
.input(readImageArgsSchema)
|
|
80
81
|
.handler(async ({ input }) => {
|
|
@@ -204,6 +205,8 @@ export const readImage = tool()
|
|
|
204
205
|
};
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
twin = await applyImageIntelligence(twin, resolvedPath, input, includeOcr);
|
|
209
|
+
|
|
207
210
|
if (input.region !== undefined) {
|
|
208
211
|
if (!useRustDecode) {
|
|
209
212
|
throw new ImageError(
|
package/src/schemas/readImage.ts
CHANGED
|
@@ -59,6 +59,28 @@ export const readImageArgsSchema = z.object({
|
|
|
59
59
|
.positive()
|
|
60
60
|
.optional()
|
|
61
61
|
.describe('Maximum width or height when resizing the cropped region for evidence.'),
|
|
62
|
+
include_layout: z
|
|
63
|
+
.boolean()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe(
|
|
66
|
+
'When OCR lines exist, cluster them into reading-order text blocks (image architecture). Defaults to true when include_ocr is true.'
|
|
67
|
+
),
|
|
68
|
+
include_agent_map: z
|
|
69
|
+
.boolean()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe(
|
|
72
|
+
'Return a text-only agent_map so non-vision models can read image structure. Defaults to true.'
|
|
73
|
+
),
|
|
74
|
+
include_palette: z
|
|
75
|
+
.boolean()
|
|
76
|
+
.optional()
|
|
77
|
+
.describe('Sample an approximate local color palette via sharp (not ML). Defaults to false.'),
|
|
78
|
+
include_optional_llm: z
|
|
79
|
+
.boolean()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe(
|
|
82
|
+
'Optional caption via IRIS_OPTIONAL_LLM_URL. Off by default; never authority over OCR/layout evidence.'
|
|
83
|
+
),
|
|
62
84
|
});
|
|
63
85
|
|
|
64
86
|
export const agentMediaTwinSchema = z.object({
|
|
@@ -101,6 +123,44 @@ export const agentMediaTwinSchema = z.object({
|
|
|
101
123
|
image_base64: z.string().optional(),
|
|
102
124
|
})
|
|
103
125
|
.optional(),
|
|
126
|
+
layout: z
|
|
127
|
+
.object({
|
|
128
|
+
policy: z.string(),
|
|
129
|
+
block_count: z.number().int().nonnegative(),
|
|
130
|
+
blocks: z.array(
|
|
131
|
+
z.object({
|
|
132
|
+
id: z.string(),
|
|
133
|
+
kind: z.literal('text_block'),
|
|
134
|
+
text: z.string(),
|
|
135
|
+
bbox: boundingBoxSchema,
|
|
136
|
+
line_count: z.number().int().nonnegative(),
|
|
137
|
+
reading_order: z.number().int().positive(),
|
|
138
|
+
})
|
|
139
|
+
),
|
|
140
|
+
full_text: z.string(),
|
|
141
|
+
warnings: z.array(z.string()),
|
|
142
|
+
})
|
|
143
|
+
.optional(),
|
|
144
|
+
agent_map: z
|
|
145
|
+
.object({
|
|
146
|
+
policy: z.string(),
|
|
147
|
+
filename: z.string(),
|
|
148
|
+
mime: z.string(),
|
|
149
|
+
dimensions: imageDimensionsSchema,
|
|
150
|
+
outline: z.string(),
|
|
151
|
+
text_present: z.boolean(),
|
|
152
|
+
block_count: z.number().int().nonnegative(),
|
|
153
|
+
palette: z.array(z.object({ hex: z.string(), approx_share: z.number() })).optional(),
|
|
154
|
+
optional_llm: z
|
|
155
|
+
.object({
|
|
156
|
+
available: z.boolean(),
|
|
157
|
+
skipped_reason: z.string().optional(),
|
|
158
|
+
route: z.string().optional(),
|
|
159
|
+
caption: z.string().optional(),
|
|
160
|
+
})
|
|
161
|
+
.optional(),
|
|
162
|
+
})
|
|
163
|
+
.optional(),
|
|
104
164
|
trust_warnings: z.array(z.string()),
|
|
105
165
|
});
|
|
106
166
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-only Agent Map: lets a non-vision model "read" the image architecture.
|
|
3
|
+
* Deterministic, local-first. Optional LLM captions are layered elsewhere.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ImageLayout } from './layout.js';
|
|
7
|
+
|
|
8
|
+
export type AgentImageMap = {
|
|
9
|
+
policy: 'agent_image_map_v1';
|
|
10
|
+
filename: string;
|
|
11
|
+
mime: string;
|
|
12
|
+
dimensions: { width: number; height: number };
|
|
13
|
+
/** Human/agent readable outline (markdown-ish, not prose hallucination). */
|
|
14
|
+
outline: string;
|
|
15
|
+
text_present: boolean;
|
|
16
|
+
block_count: number;
|
|
17
|
+
palette?: Array<{ hex: string; approx_share: number }>;
|
|
18
|
+
optional_llm?: {
|
|
19
|
+
available: boolean;
|
|
20
|
+
skipped_reason?: string;
|
|
21
|
+
route?: string;
|
|
22
|
+
caption?: string;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function buildAgentImageMap(input: {
|
|
27
|
+
filename: string;
|
|
28
|
+
mime: string;
|
|
29
|
+
dimensions: { width: number; height: number };
|
|
30
|
+
layout?: ImageLayout;
|
|
31
|
+
ocrLineCount?: number;
|
|
32
|
+
palette?: Array<{ hex: string; approx_share: number }>;
|
|
33
|
+
optionalLlm?: AgentImageMap['optional_llm'];
|
|
34
|
+
}): AgentImageMap {
|
|
35
|
+
const { width, height } = input.dimensions;
|
|
36
|
+
const blocks = input.layout?.blocks ?? [];
|
|
37
|
+
const textPresent = (input.ocrLineCount ?? 0) > 0 || blocks.length > 0;
|
|
38
|
+
|
|
39
|
+
const lines: string[] = [
|
|
40
|
+
`# Image map: ${input.filename}`,
|
|
41
|
+
`- mime: ${input.mime}`,
|
|
42
|
+
`- size: ${width}×${height}px`,
|
|
43
|
+
`- text_present: ${textPresent}`,
|
|
44
|
+
`- layout_blocks: ${blocks.length}`,
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
if (input.palette && input.palette.length > 0) {
|
|
48
|
+
lines.push(
|
|
49
|
+
`- palette: ${input.palette.map((p) => `${p.hex}~${Math.round(p.approx_share * 100)}%`).join(', ')}`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (blocks.length > 0) {
|
|
54
|
+
lines.push('', '## Reading-order text blocks');
|
|
55
|
+
for (const b of blocks) {
|
|
56
|
+
const preview = b.text.replace(/\s+/g, ' ').slice(0, 160);
|
|
57
|
+
lines.push(
|
|
58
|
+
`### ${b.id} (order ${b.reading_order}, ${b.line_count} lines)`,
|
|
59
|
+
`- bbox: x=${b.bbox.x} y=${b.bbox.y} w=${b.bbox.width} h=${b.bbox.height}`,
|
|
60
|
+
`- text: ${preview}${b.text.length > 160 ? '…' : ''}`,
|
|
61
|
+
''
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
} else if (!textPresent) {
|
|
65
|
+
lines.push(
|
|
66
|
+
'',
|
|
67
|
+
'## Notes',
|
|
68
|
+
'- No OCR text recovered. Enable include_ocr for text architecture.',
|
|
69
|
+
'- Use crop_region / image_probe for geometry; optional LLM caption only if configured.'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (input.optionalLlm?.available && input.optionalLlm.caption) {
|
|
74
|
+
lines.push('', '## Optional LLM caption (non-authority)', input.optionalLlm.caption);
|
|
75
|
+
} else if (input.optionalLlm && !input.optionalLlm.available) {
|
|
76
|
+
lines.push(
|
|
77
|
+
'',
|
|
78
|
+
`## Optional LLM: skipped (${input.optionalLlm.skipped_reason ?? 'not configured'})`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
policy: 'agent_image_map_v1',
|
|
84
|
+
filename: input.filename,
|
|
85
|
+
mime: input.mime,
|
|
86
|
+
dimensions: input.dimensions,
|
|
87
|
+
outline: lines.join('\n'),
|
|
88
|
+
text_present: textPresent,
|
|
89
|
+
block_count: blocks.length,
|
|
90
|
+
...(input.palette ? { palette: input.palette } : {}),
|
|
91
|
+
...(input.optionalLlm ? { optional_llm: input.optionalLlm } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { AgentMediaTwin, ReadImageArgs } from '../schemas/readImage.js';
|
|
2
|
+
import { buildAgentImageMap } from './agentMap.js';
|
|
3
|
+
import { buildLayoutFromOcrLines } from './layout.js';
|
|
4
|
+
import { maybeOptionalImageCaption } from './optionalLlm.js';
|
|
5
|
+
import { samplePalette } from './palette.js';
|
|
6
|
+
|
|
7
|
+
/** Attach layout, palette, optional LLM, and agent_map to an Agent Media Twin. */
|
|
8
|
+
export async function applyImageIntelligence(
|
|
9
|
+
twin: AgentMediaTwin,
|
|
10
|
+
resolvedPath: string,
|
|
11
|
+
input: ReadImageArgs,
|
|
12
|
+
includeOcr: boolean
|
|
13
|
+
): Promise<AgentMediaTwin> {
|
|
14
|
+
const next: AgentMediaTwin = {
|
|
15
|
+
...twin,
|
|
16
|
+
trust_warnings: [...twin.trust_warnings],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const includeLayout = input.include_layout ?? includeOcr;
|
|
20
|
+
if (includeLayout && next.ocr?.lines && next.ocr.lines.length > 0) {
|
|
21
|
+
const layout = buildLayoutFromOcrLines(
|
|
22
|
+
next.ocr.lines.map((line) => ({
|
|
23
|
+
text: line.text,
|
|
24
|
+
bbox: line.bbox,
|
|
25
|
+
...(line.confidence !== undefined ? { confidence: line.confidence } : {}),
|
|
26
|
+
}))
|
|
27
|
+
);
|
|
28
|
+
next.layout = layout;
|
|
29
|
+
for (const warning of layout.warnings) {
|
|
30
|
+
next.trust_warnings.push(`layout: ${warning}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let palette: Array<{ hex: string; approx_share: number }> | undefined;
|
|
35
|
+
if (input.include_palette) {
|
|
36
|
+
try {
|
|
37
|
+
palette = await samplePalette(resolvedPath);
|
|
38
|
+
} catch {
|
|
39
|
+
next.trust_warnings.push('palette: sampling failed; continuing without palette.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const optionalLlm = await maybeOptionalImageCaption({
|
|
44
|
+
path: resolvedPath,
|
|
45
|
+
mime: next.mime,
|
|
46
|
+
enabled: input.include_optional_llm ?? false,
|
|
47
|
+
});
|
|
48
|
+
if (optionalLlm.available) {
|
|
49
|
+
next.trust_warnings.push(
|
|
50
|
+
'optional_llm caption is non-authority; prefer OCR/layout locators for claims.'
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const includeAgentMap = input.include_agent_map ?? true;
|
|
55
|
+
if (includeAgentMap) {
|
|
56
|
+
next.agent_map = buildAgentImageMap({
|
|
57
|
+
filename: next.filename,
|
|
58
|
+
mime: next.mime,
|
|
59
|
+
dimensions: next.dimensions,
|
|
60
|
+
...(next.layout ? { layout: next.layout } : {}),
|
|
61
|
+
ocrLineCount: next.ocr?.line_count ?? next.ocr?.lines?.length ?? 0,
|
|
62
|
+
...(palette ? { palette } : {}),
|
|
63
|
+
optionalLlm,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic layout structure from OCR line boxes (no generative model).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type BBox = { x: number; y: number; width: number; height: number };
|
|
6
|
+
|
|
7
|
+
export type LayoutLine = {
|
|
8
|
+
text: string;
|
|
9
|
+
bbox: BBox;
|
|
10
|
+
confidence?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type LayoutBlock = {
|
|
14
|
+
id: string;
|
|
15
|
+
kind: 'text_block';
|
|
16
|
+
text: string;
|
|
17
|
+
bbox: BBox;
|
|
18
|
+
line_count: number;
|
|
19
|
+
reading_order: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ImageLayout = {
|
|
23
|
+
policy: string;
|
|
24
|
+
block_count: number;
|
|
25
|
+
blocks: LayoutBlock[];
|
|
26
|
+
full_text: string;
|
|
27
|
+
warnings: string[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const unionBBox = (boxes: BBox[]): BBox => {
|
|
31
|
+
const minX = Math.min(...boxes.map((b) => b.x));
|
|
32
|
+
const minY = Math.min(...boxes.map((b) => b.y));
|
|
33
|
+
const maxX = Math.max(...boxes.map((b) => b.x + b.width));
|
|
34
|
+
const maxY = Math.max(...boxes.map((b) => b.y + b.height));
|
|
35
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const median = (values: number[], fallback: number): number => {
|
|
39
|
+
if (values.length === 0) return fallback;
|
|
40
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
41
|
+
return sorted[Math.floor(sorted.length / 2)] ?? fallback;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const shouldMerge = (prev: LayoutLine, next: LayoutLine, yGap: number, xSlop: number): boolean => {
|
|
45
|
+
const verticalClose = next.bbox.y - (prev.bbox.y + prev.bbox.height) <= yGap;
|
|
46
|
+
const prevRight = prev.bbox.x + prev.bbox.width;
|
|
47
|
+
const nextRight = next.bbox.x + next.bbox.width;
|
|
48
|
+
const horizontalOverlap =
|
|
49
|
+
Math.min(prevRight, nextRight) - Math.max(prev.bbox.x, next.bbox.x) > -xSlop;
|
|
50
|
+
return verticalClose && horizontalOverlap;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const toBlock = (lines: LayoutLine[], index: number): LayoutBlock => {
|
|
54
|
+
const ordered = [...lines].sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x);
|
|
55
|
+
const text = ordered
|
|
56
|
+
.map((l) => l.text.trim())
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join('\n');
|
|
59
|
+
return {
|
|
60
|
+
id: `block-${index + 1}`,
|
|
61
|
+
kind: 'text_block',
|
|
62
|
+
text,
|
|
63
|
+
bbox: unionBBox(ordered.map((l) => l.bbox)),
|
|
64
|
+
line_count: ordered.length,
|
|
65
|
+
reading_order: index + 1,
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function buildLayoutFromOcrLines(lines: LayoutLine[]): ImageLayout {
|
|
70
|
+
if (lines.length === 0) {
|
|
71
|
+
return {
|
|
72
|
+
policy: 'ocr_line_cluster_v1',
|
|
73
|
+
block_count: 0,
|
|
74
|
+
blocks: [],
|
|
75
|
+
full_text: '',
|
|
76
|
+
warnings: ['No OCR lines available for layout clustering.'],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sorted = [...lines].sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x);
|
|
81
|
+
const medianHeight = median(
|
|
82
|
+
sorted.map((l) => l.bbox.height).filter((h) => h > 0),
|
|
83
|
+
16
|
|
84
|
+
);
|
|
85
|
+
const yGap = Math.max(8, medianHeight * 1.4);
|
|
86
|
+
const xSlop = medianHeight * 2;
|
|
87
|
+
|
|
88
|
+
const clusters: LayoutLine[][] = [];
|
|
89
|
+
for (const line of sorted) {
|
|
90
|
+
const last = clusters[clusters.length - 1];
|
|
91
|
+
const prev = last?.[last.length - 1];
|
|
92
|
+
if (last && prev && shouldMerge(prev, line, yGap, xSlop)) {
|
|
93
|
+
last.push(line);
|
|
94
|
+
} else {
|
|
95
|
+
clusters.push([line]);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const blocks = clusters.map((cluster, index) => toBlock(cluster, index));
|
|
100
|
+
const warnings: string[] = [];
|
|
101
|
+
if (blocks.length === 1 && lines.length > 8) {
|
|
102
|
+
warnings.push(
|
|
103
|
+
'Layout collapsed to a single block; image may be dense continuous text or OCR line geometry is coarse.'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
policy: 'ocr_line_cluster_v1',
|
|
109
|
+
block_count: blocks.length,
|
|
110
|
+
blocks,
|
|
111
|
+
full_text: blocks.map((b) => b.text).join('\n\n'),
|
|
112
|
+
warnings,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional local/remote vision caption — OFF by default (local-first).
|
|
3
|
+
* Set IRIS_OPTIONAL_LLM_URL to a POST endpoint that accepts JSON
|
|
4
|
+
* { path, mime, purpose: "image_caption" } and returns { caption: string }.
|
|
5
|
+
* Never treated as authority over OCR/layout evidence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type OptionalLlmResult = {
|
|
9
|
+
available: boolean;
|
|
10
|
+
skipped_reason?: string;
|
|
11
|
+
route?: string;
|
|
12
|
+
caption?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function maybeOptionalImageCaption(input: {
|
|
16
|
+
path: string;
|
|
17
|
+
mime: string;
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
}): Promise<OptionalLlmResult> {
|
|
20
|
+
if (!input.enabled) {
|
|
21
|
+
return { available: false, skipped_reason: 'include_optional_llm is false (default).' };
|
|
22
|
+
}
|
|
23
|
+
const url = process.env['IRIS_OPTIONAL_LLM_URL'];
|
|
24
|
+
if (!url) {
|
|
25
|
+
return {
|
|
26
|
+
available: false,
|
|
27
|
+
skipped_reason:
|
|
28
|
+
'IRIS_OPTIONAL_LLM_URL is not set. Local OCR/layout remain the evidence authority.',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(url, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
path: input.path,
|
|
38
|
+
mime: input.mime,
|
|
39
|
+
purpose: 'image_caption',
|
|
40
|
+
}),
|
|
41
|
+
signal: AbortSignal.timeout(15_000),
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
return {
|
|
45
|
+
available: false,
|
|
46
|
+
skipped_reason: `optional LLM HTTP ${res.status}`,
|
|
47
|
+
route: url,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const body = (await res.json()) as { caption?: string };
|
|
51
|
+
if (!body.caption || typeof body.caption !== 'string') {
|
|
52
|
+
return {
|
|
53
|
+
available: false,
|
|
54
|
+
skipped_reason: 'optional LLM response missing caption string',
|
|
55
|
+
route: url,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
available: true,
|
|
60
|
+
route: `optional-llm:${url}`,
|
|
61
|
+
caption: body.caption.slice(0, 4000),
|
|
62
|
+
};
|
|
63
|
+
} catch (error: unknown) {
|
|
64
|
+
const message = error instanceof Error ? error.message : 'optional LLM failed';
|
|
65
|
+
return { available: false, skipped_reason: message, route: url };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
|
|
3
|
+
/** Cheap local palette sample via sharp stats (not ML segmentation). */
|
|
4
|
+
export async function samplePalette(
|
|
5
|
+
filePath: string,
|
|
6
|
+
maxColors = 5
|
|
7
|
+
): Promise<Array<{ hex: string; approx_share: number }>> {
|
|
8
|
+
const image = sharp(filePath, { failOn: 'none' }).resize(64, 64, { fit: 'inside' });
|
|
9
|
+
const stats = await image.stats();
|
|
10
|
+
const dominant = stats.dominant;
|
|
11
|
+
if (!dominant) return [];
|
|
12
|
+
|
|
13
|
+
const hex = (r: number, g: number, b: number) =>
|
|
14
|
+
`#${[r, g, b]
|
|
15
|
+
.map((v) =>
|
|
16
|
+
Math.max(0, Math.min(255, Math.round(v)))
|
|
17
|
+
.toString(16)
|
|
18
|
+
.padStart(2, '0')
|
|
19
|
+
)
|
|
20
|
+
.join('')}`;
|
|
21
|
+
|
|
22
|
+
const colors: Array<{ hex: string; approx_share: number }> = [
|
|
23
|
+
{ hex: hex(dominant.r, dominant.g, dominant.b), approx_share: 0.55 },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// Channel means as a second honest swatch (not true multi-color clustering).
|
|
27
|
+
const ch = stats.channels;
|
|
28
|
+
if (ch && ch.length >= 3) {
|
|
29
|
+
colors.push({
|
|
30
|
+
hex: hex(ch[0]?.mean ?? dominant.r, ch[1]?.mean ?? dominant.g, ch[2]?.mean ?? dominant.b),
|
|
31
|
+
approx_share: 0.45,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return colors.slice(0, maxColors);
|
|
36
|
+
}
|