@pi-unipi/image 2.2.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/README.md +82 -0
- package/index.ts +1 -0
- package/package.json +57 -0
- package/skills/image/SKILL.md +73 -0
- package/src/commands.ts +17 -0
- package/src/generate.ts +201 -0
- package/src/image-source.ts +204 -0
- package/src/index.ts +99 -0
- package/src/models.ts +290 -0
- package/src/recognize.ts +223 -0
- package/src/settings.ts +149 -0
- package/src/tools.ts +296 -0
- package/src/tui/model-selector.ts +279 -0
- package/src/tui/settings-dialog.ts +236 -0
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @pi-unipi/image
|
|
2
|
+
|
|
3
|
+
Image generation and image recognition tools for the agent.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
| Tool | Description |
|
|
8
|
+
|------|-------------|
|
|
9
|
+
| `image_generate` | Generate an image from a text prompt. Returned inline and saved to disk. |
|
|
10
|
+
| `image_recognize` | Analyze an image with a vision model. Accepts a file path, `data:` URL, or base64. |
|
|
11
|
+
|
|
12
|
+
## Commands
|
|
13
|
+
|
|
14
|
+
| Command | Description |
|
|
15
|
+
|---------|-------------|
|
|
16
|
+
| `/unipi:image-settings` | Configure models, output directory and the recognition system prompt |
|
|
17
|
+
|
|
18
|
+
## image_generate
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
image_generate(prompt: "A cutaway diagram of a submarine, technical illustration")
|
|
22
|
+
image_generate(prompt: "...", model: "flux.2-pro")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Models come from pi-ai's image catalog — 34 models including FLUX.2,
|
|
26
|
+
Gemini 3 Pro Image, GPT-5 Image, Recraft and Riverflow — all served through
|
|
27
|
+
**OpenRouter**, so an OpenRouter key is required
|
|
28
|
+
([get one](https://openrouter.ai/keys)).
|
|
29
|
+
|
|
30
|
+
The `model` parameter is fuzzy-matched, so `flux`, `recraft` and
|
|
31
|
+
`gemini-3-pro` all work. Omit it to use the model chosen in
|
|
32
|
+
`/unipi:image-settings`.
|
|
33
|
+
|
|
34
|
+
Images are returned inline **and** written to `~/.unipi/images` by default;
|
|
35
|
+
the saved path is reported back to the agent. A failed write never discards a
|
|
36
|
+
successfully generated image.
|
|
37
|
+
|
|
38
|
+
## image_recognize
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
image_recognize(image: "./screenshot.png")
|
|
42
|
+
image_recognize(image: "./error.png", prompt: "What does the stack trace say?")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Uses any chat model whose input modality includes `image`. A model that cannot
|
|
46
|
+
accept images is rejected up front with a clear message rather than failing
|
|
47
|
+
inside the provider.
|
|
48
|
+
|
|
49
|
+
Input can be a local file path, a `data:` URL, or raw base64. The media type is
|
|
50
|
+
detected from the file's magic numbers, so a `.jpg` that is really a PNG still
|
|
51
|
+
works. Supported: PNG, JPEG, GIF, WebP. Remote URLs are not fetched.
|
|
52
|
+
|
|
53
|
+
Prefer file paths — inlining base64 into the conversation is far more
|
|
54
|
+
expensive in tokens.
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
58
|
+
`~/.unipi/config/image/config.json`:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"generate": {
|
|
63
|
+
"enabled": true,
|
|
64
|
+
"model": "openrouter/google/gemini-3-pro-image",
|
|
65
|
+
"outputDir": "~/.unipi/images",
|
|
66
|
+
"saveToDisk": true
|
|
67
|
+
},
|
|
68
|
+
"recognize": {
|
|
69
|
+
"enabled": true,
|
|
70
|
+
"model": "",
|
|
71
|
+
"systemPrompt": "You are a precise image analyst…"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- `recognize.model` empty means "use the session's current model".
|
|
77
|
+
- The system prompt is fully customizable, and can be overridden per call.
|
|
78
|
+
- Enabling or disabling a tool takes effect next session, since tools are
|
|
79
|
+
registered at startup.
|
|
80
|
+
- Every read falls back to defaults, so a corrupt config never breaks the tools.
|
|
81
|
+
|
|
82
|
+
Set `UNIPI_IMAGE_CONFIG_DIR` to relocate the config directory (used by tests).
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi-unipi/image",
|
|
3
|
+
"version": "2.2.0",
|
|
4
|
+
"description": "Image generation and image recognition tools for the Pi coding agent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Neuron Mr White",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Neuron-Mr-White/unipi.git",
|
|
12
|
+
"directory": "packages/image"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/Neuron-Mr-White/unipi#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/Neuron-Mr-White/unipi/issues"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"pi-package",
|
|
20
|
+
"pi-extension",
|
|
21
|
+
"pi-coding-agent",
|
|
22
|
+
"unipi",
|
|
23
|
+
"image",
|
|
24
|
+
"vision",
|
|
25
|
+
"image-generation"
|
|
26
|
+
],
|
|
27
|
+
"files": [
|
|
28
|
+
"index.ts",
|
|
29
|
+
"src/**/*.ts",
|
|
30
|
+
"skills/**/*",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@pi-unipi/core": "2.2.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@earendil-works/pi-ai": "^0.80.0",
|
|
41
|
+
"@earendil-works/pi-coding-agent": "^0.80.0",
|
|
42
|
+
"@earendil-works/pi-tui": "^0.80.0",
|
|
43
|
+
"typebox": "^1.1.38"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^25.6.0"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"test": "npx tsx --test tests/**/*.test.ts"
|
|
50
|
+
},
|
|
51
|
+
"pi": {
|
|
52
|
+
"extensions": [],
|
|
53
|
+
"skills": [],
|
|
54
|
+
"prompts": [],
|
|
55
|
+
"themes": []
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: image
|
|
3
|
+
description: Generate images from text prompts and analyze images with vision models
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Image Tools
|
|
7
|
+
|
|
8
|
+
Two agent tools: `image_generate` creates images from a text prompt, and
|
|
9
|
+
`image_recognize` analyzes an existing image.
|
|
10
|
+
|
|
11
|
+
## image_generate
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
image_generate(prompt: "A cutaway diagram of a submarine, technical illustration, muted blues")
|
|
15
|
+
image_generate(prompt: "...", model: "flux.2-pro")
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- `prompt` (required) — describe subject, style, composition and lighting.
|
|
19
|
+
Detail materially improves the result.
|
|
20
|
+
- `model` (optional) — fuzzy-matched against the image catalog
|
|
21
|
+
(`flux`, `gemini-3-pro-image`, `recraft-v4`, …). Omit to use the model
|
|
22
|
+
configured in `/unipi:image-settings`.
|
|
23
|
+
|
|
24
|
+
The image is returned inline and, when `saveToDisk` is on (the default),
|
|
25
|
+
written to the output directory (default `~/.unipi/images`). The saved path is
|
|
26
|
+
reported in the result.
|
|
27
|
+
|
|
28
|
+
**Image generation costs money per call.** Never regenerate an image
|
|
29
|
+
speculatively — only when the user asks for a change.
|
|
30
|
+
|
|
31
|
+
Models are served through OpenRouter, so an OpenRouter key is required:
|
|
32
|
+
https://openrouter.ai/keys
|
|
33
|
+
|
|
34
|
+
## image_recognize
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
image_recognize(image: "./screenshot.png")
|
|
38
|
+
image_recognize(image: "./error.png", prompt: "What does the stack trace say?")
|
|
39
|
+
image_recognize(image: "...", model: "claude-sonnet", systemPrompt: "Reply only with the visible text.")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- `image` (required) — a local file path, a `data:` URL, or raw base64.
|
|
43
|
+
**Prefer a file path**: inlining base64 into the conversation is far more
|
|
44
|
+
expensive. Remote URLs are not fetched — download first.
|
|
45
|
+
- `prompt` (optional) — the question to ask. Defaults to a general
|
|
46
|
+
description. A specific question gives a far more useful answer.
|
|
47
|
+
- `model` (optional) — must accept image input. Omit to use the configured
|
|
48
|
+
model, falling back to the session's current model.
|
|
49
|
+
- `systemPrompt` (optional) — override the configured system prompt for one
|
|
50
|
+
call.
|
|
51
|
+
|
|
52
|
+
Supported types: PNG, JPEG, GIF, WebP. The type is detected from the file's
|
|
53
|
+
magic numbers, so a misnamed extension still works.
|
|
54
|
+
|
|
55
|
+
### When to use it
|
|
56
|
+
|
|
57
|
+
- Reading a screenshot of an error, a stack trace, or failing UI
|
|
58
|
+
- Understanding a design mockup or wireframe before implementing it
|
|
59
|
+
- Extracting content from an architecture diagram or flowchart
|
|
60
|
+
- Checking what a rendered page or chart actually looks like
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
`/unipi:image-settings` configures both tools:
|
|
65
|
+
|
|
66
|
+
- Generation model (picker over the image catalog)
|
|
67
|
+
- Recognition model (picker over vision-capable models only)
|
|
68
|
+
- Enable/disable either tool
|
|
69
|
+
- Output directory and whether to save to disk
|
|
70
|
+
- The recognition system prompt
|
|
71
|
+
|
|
72
|
+
Config lives at `~/.unipi/config/image/config.json`. Toggling a tool on or off
|
|
73
|
+
takes effect on the next session, since tools are registered at startup.
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/image — Slash commands
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { IMAGE_COMMANDS, UNIPI_PREFIX } from "@pi-unipi/core";
|
|
7
|
+
|
|
8
|
+
import { showSettingsDialog } from "./tui/settings-dialog.js";
|
|
9
|
+
|
|
10
|
+
export function registerImageCommands(pi: ExtensionAPI): void {
|
|
11
|
+
pi.registerCommand(`${UNIPI_PREFIX}${IMAGE_COMMANDS.SETTINGS}`, {
|
|
12
|
+
description: "Configure image generation and recognition models",
|
|
13
|
+
handler: async (_args, ctx) => {
|
|
14
|
+
await showSettingsDialog(ctx);
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
}
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/image — Image generation
|
|
3
|
+
*
|
|
4
|
+
* Wraps pi-ai's image API. `generateImages` never rejects — failures come back
|
|
5
|
+
* as `stopReason: "error"` — so every call site must inspect the result rather
|
|
6
|
+
* than relying on try/catch.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
|
|
12
|
+
import { getImagesModels, type ImageGenModel, type ImagesModelsLike } from "./models.js";
|
|
13
|
+
|
|
14
|
+
export interface GeneratedImage {
|
|
15
|
+
/** Base64 image data. */
|
|
16
|
+
data: string;
|
|
17
|
+
mimeType: string;
|
|
18
|
+
/** Absolute path, when saved to disk. */
|
|
19
|
+
path?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GenerateResult {
|
|
23
|
+
images: GeneratedImage[];
|
|
24
|
+
/** Any accompanying commentary from the model. */
|
|
25
|
+
text: string;
|
|
26
|
+
model: string;
|
|
27
|
+
provider: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Structural view of pi-ai's AssistantImages. */
|
|
31
|
+
interface AssistantImagesLike {
|
|
32
|
+
output?: Array<{ type?: string; text?: string; data?: string; mimeType?: string }>;
|
|
33
|
+
stopReason?: string;
|
|
34
|
+
errorMessage?: string;
|
|
35
|
+
usage?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Injectable generation function, for tests. */
|
|
39
|
+
export type GenerateImagesFn = (
|
|
40
|
+
model: unknown,
|
|
41
|
+
context: { input: Array<{ type: string; text?: string }> },
|
|
42
|
+
options: { apiKey?: string; signal?: AbortSignal },
|
|
43
|
+
) => Promise<AssistantImagesLike>;
|
|
44
|
+
|
|
45
|
+
/** Extension for a media type, for naming saved files. */
|
|
46
|
+
function extensionFor(mimeType: string): string {
|
|
47
|
+
switch (mimeType) {
|
|
48
|
+
case "image/png":
|
|
49
|
+
return ".png";
|
|
50
|
+
case "image/jpeg":
|
|
51
|
+
return ".jpg";
|
|
52
|
+
case "image/webp":
|
|
53
|
+
return ".webp";
|
|
54
|
+
case "image/gif":
|
|
55
|
+
return ".gif";
|
|
56
|
+
case "image/svg+xml":
|
|
57
|
+
return ".svg";
|
|
58
|
+
default:
|
|
59
|
+
return ".img";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Filesystem-safe slug from a prompt, for a recognizable filename. */
|
|
64
|
+
export function slugify(prompt: string, maxLength = 40): string {
|
|
65
|
+
const slug = prompt
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
68
|
+
.replace(/^-+|-+$/g, "")
|
|
69
|
+
.slice(0, maxLength)
|
|
70
|
+
.replace(/-+$/g, "");
|
|
71
|
+
return slug || "image";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build a collision-free filename. */
|
|
75
|
+
export function buildFileName(
|
|
76
|
+
prompt: string,
|
|
77
|
+
mimeType: string,
|
|
78
|
+
index: number,
|
|
79
|
+
now: Date = new Date(),
|
|
80
|
+
): string {
|
|
81
|
+
const stamp = now.toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
|
|
82
|
+
const suffix = index > 0 ? `-${index + 1}` : "";
|
|
83
|
+
return `${stamp}-${slugify(prompt)}${suffix}${extensionFor(mimeType)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Write an image to disk.
|
|
88
|
+
* @returns the absolute path, or undefined if the write failed (never throws —
|
|
89
|
+
* a failed save must not discard a successfully generated image).
|
|
90
|
+
*/
|
|
91
|
+
export function saveImage(
|
|
92
|
+
outputDir: string,
|
|
93
|
+
fileName: string,
|
|
94
|
+
base64: string,
|
|
95
|
+
): string | undefined {
|
|
96
|
+
try {
|
|
97
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
98
|
+
const target = path.join(outputDir, fileName);
|
|
99
|
+
fs.writeFileSync(target, Buffer.from(base64, "base64"));
|
|
100
|
+
return target;
|
|
101
|
+
} catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface GenerateOptions {
|
|
107
|
+
prompt: string;
|
|
108
|
+
model: ImageGenModel;
|
|
109
|
+
/** Fallback key, used only when pi-ai's own auth resolution comes up empty. */
|
|
110
|
+
apiKey?: string;
|
|
111
|
+
signal?: AbortSignal;
|
|
112
|
+
/** Absolute directory for saved images; omit to skip saving. */
|
|
113
|
+
outputDir?: string;
|
|
114
|
+
now?: Date;
|
|
115
|
+
/** Injected images collection, for tests. */
|
|
116
|
+
images?: ImagesModelsLike;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Generate images and optionally save them.
|
|
121
|
+
* @throws {Error} with an actionable message when generation fails.
|
|
122
|
+
*/
|
|
123
|
+
export async function generateImage(options: GenerateOptions): Promise<GenerateResult> {
|
|
124
|
+
const { prompt, model, signal, outputDir, now } = options;
|
|
125
|
+
|
|
126
|
+
if (!prompt.trim()) {
|
|
127
|
+
throw new Error("A non-empty prompt is required.");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const imagesApi = options.images ?? (await getImagesModels());
|
|
131
|
+
if (!imagesApi) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
"Image generation is unavailable — this version of pi-ai does not expose an image API.",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Prefer pi-ai's own credential store, then the caller-supplied fallback so
|
|
138
|
+
// a bare OPENROUTER_API_KEY still works.
|
|
139
|
+
let apiKey: string | undefined;
|
|
140
|
+
try {
|
|
141
|
+
apiKey = (await imagesApi.getAuth(model))?.apiKey;
|
|
142
|
+
} catch {
|
|
143
|
+
// Reported as a missing key below.
|
|
144
|
+
}
|
|
145
|
+
apiKey ||= options.apiKey;
|
|
146
|
+
|
|
147
|
+
if (!apiKey) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`No API key for provider "${model.provider}".\n` +
|
|
150
|
+
`→ Add one with /login, or set the provider's API key environment variable.\n` +
|
|
151
|
+
`→ Image models are served through OpenRouter: https://openrouter.ai/keys`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const result = (await imagesApi.generateImages(
|
|
156
|
+
model,
|
|
157
|
+
{ input: [{ type: "text", text: prompt }] },
|
|
158
|
+
{ apiKey, ...(signal ? { signal } : {}) },
|
|
159
|
+
)) as AssistantImagesLike;
|
|
160
|
+
|
|
161
|
+
// pi-ai reports failures in-band rather than rejecting.
|
|
162
|
+
if (result.stopReason === "error") {
|
|
163
|
+
throw new Error(result.errorMessage || "Image generation failed.");
|
|
164
|
+
}
|
|
165
|
+
if (result.stopReason === "aborted") {
|
|
166
|
+
throw new Error("Image generation was cancelled.");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const images: GeneratedImage[] = [];
|
|
170
|
+
const textParts: string[] = [];
|
|
171
|
+
|
|
172
|
+
for (const part of result.output ?? []) {
|
|
173
|
+
if (part?.type === "image" && typeof part.data === "string" && part.data.length > 0) {
|
|
174
|
+
images.push({ data: part.data, mimeType: part.mimeType || "image/png" });
|
|
175
|
+
} else if (part?.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
|
176
|
+
textParts.push(part.text.trim());
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (images.length === 0) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
textParts.length > 0
|
|
183
|
+
? `The model returned no image. It said: ${textParts.join(" ")}`
|
|
184
|
+
: "The model returned no image.",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (outputDir) {
|
|
189
|
+
images.forEach((image, index) => {
|
|
190
|
+
const fileName = buildFileName(prompt, image.mimeType, index, now);
|
|
191
|
+
image.path = saveImage(outputDir, fileName, image.data);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
images,
|
|
197
|
+
text: textParts.join("\n"),
|
|
198
|
+
model: model.id,
|
|
199
|
+
provider: model.provider,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/image — Image input handling
|
|
3
|
+
*
|
|
4
|
+
* Accepts a local file path, a data: URL, or a raw base64 string and
|
|
5
|
+
* normalizes it to the `{ data, mimeType }` shape pi-ai expects.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
|
|
11
|
+
export interface LoadedImage {
|
|
12
|
+
/** Base64-encoded image data (no data: prefix). */
|
|
13
|
+
data: string;
|
|
14
|
+
/** IANA media type. */
|
|
15
|
+
mimeType: string;
|
|
16
|
+
/** Where it came from, for the tool's result message. */
|
|
17
|
+
source: "file" | "data-url" | "base64";
|
|
18
|
+
/** Absolute path when loaded from disk. */
|
|
19
|
+
path?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Media types accepted by the vision APIs. */
|
|
23
|
+
export const SUPPORTED_MIME_TYPES = [
|
|
24
|
+
"image/png",
|
|
25
|
+
"image/jpeg",
|
|
26
|
+
"image/gif",
|
|
27
|
+
"image/webp",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
const EXTENSION_MIME: Record<string, string> = {
|
|
31
|
+
".png": "image/png",
|
|
32
|
+
".jpg": "image/jpeg",
|
|
33
|
+
".jpeg": "image/jpeg",
|
|
34
|
+
".jfif": "image/jpeg",
|
|
35
|
+
".gif": "image/gif",
|
|
36
|
+
".webp": "image/webp",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Magic-number signatures, checked before trusting a file extension. */
|
|
40
|
+
const SIGNATURES: Array<{ mimeType: string; test: (b: Buffer) => boolean }> = [
|
|
41
|
+
{
|
|
42
|
+
mimeType: "image/png",
|
|
43
|
+
test: (b) =>
|
|
44
|
+
b.length >= 8 &&
|
|
45
|
+
b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 &&
|
|
46
|
+
b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
mimeType: "image/jpeg",
|
|
50
|
+
test: (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
mimeType: "image/gif",
|
|
54
|
+
test: (b) => b.length >= 6 && b.subarray(0, 6).toString("ascii").startsWith("GIF8"),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
mimeType: "image/webp",
|
|
58
|
+
test: (b) =>
|
|
59
|
+
b.length >= 12 &&
|
|
60
|
+
b.subarray(0, 4).toString("ascii") === "RIFF" &&
|
|
61
|
+
b.subarray(8, 12).toString("ascii") === "WEBP",
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** Infer a media type from a file extension. */
|
|
66
|
+
export function mimeTypeFromExtension(filePath: string): string | undefined {
|
|
67
|
+
return EXTENSION_MIME[path.extname(filePath).toLowerCase()];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Detect a media type from magic numbers. Authoritative over the extension. */
|
|
71
|
+
export function detectMimeType(buffer: Buffer): string | undefined {
|
|
72
|
+
return SIGNATURES.find((s) => s.test(buffer))?.mimeType;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Whether a media type is accepted by the vision APIs. */
|
|
76
|
+
export function isSupportedMimeType(mimeType: string): boolean {
|
|
77
|
+
return (SUPPORTED_MIME_TYPES as readonly string[]).includes(mimeType);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Parse a `data:image/png;base64,...` URL. */
|
|
81
|
+
export function parseDataUrl(
|
|
82
|
+
input: string,
|
|
83
|
+
): { data: string; mimeType: string } | null {
|
|
84
|
+
const match = input.match(/^data:([^;,]+)(;[^,]*)?,(.*)$/s);
|
|
85
|
+
if (!match) return null;
|
|
86
|
+
const [, mimeType, params, payload] = match;
|
|
87
|
+
if (!params?.includes("base64")) return null;
|
|
88
|
+
return { data: payload.trim(), mimeType: mimeType.trim().toLowerCase() };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Whether a string plausibly is raw base64 (and long enough to be an image). */
|
|
92
|
+
export function looksLikeBase64(input: string): boolean {
|
|
93
|
+
const compact = input.replace(/\s/g, "");
|
|
94
|
+
if (compact.length < 64) return false;
|
|
95
|
+
return /^[A-Za-z0-9+/]+={0,2}$/.test(compact);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function describeUnsupported(mimeType: string): string {
|
|
99
|
+
return (
|
|
100
|
+
`Unsupported image type "${mimeType}". ` +
|
|
101
|
+
`Supported types: ${SUPPORTED_MIME_TYPES.join(", ")}.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve an `image` parameter to base64 data plus a media type.
|
|
107
|
+
*
|
|
108
|
+
* Order: data: URL, then existing file path, then raw base64. A path that
|
|
109
|
+
* looks like a path but does not exist reports the missing file rather than
|
|
110
|
+
* being misread as base64.
|
|
111
|
+
*
|
|
112
|
+
* @throws {Error} with an actionable message on any unusable input.
|
|
113
|
+
*/
|
|
114
|
+
export function loadImage(input: string, cwd = process.cwd()): LoadedImage {
|
|
115
|
+
const trimmed = input.trim();
|
|
116
|
+
if (!trimmed) {
|
|
117
|
+
throw new Error("No image provided. Pass a file path, a data: URL, or base64 data.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 1. data: URL
|
|
121
|
+
if (trimmed.startsWith("data:")) {
|
|
122
|
+
const parsed = parseDataUrl(trimmed);
|
|
123
|
+
if (!parsed) {
|
|
124
|
+
throw new Error("Malformed data: URL — expected data:<mime>;base64,<data>.");
|
|
125
|
+
}
|
|
126
|
+
if (!isSupportedMimeType(parsed.mimeType)) {
|
|
127
|
+
throw new Error(describeUnsupported(parsed.mimeType));
|
|
128
|
+
}
|
|
129
|
+
return { data: parsed.data, mimeType: parsed.mimeType, source: "data-url" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2. Remote URLs are not fetched — be explicit rather than silently failing.
|
|
133
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
"Remote image URLs are not supported. " +
|
|
136
|
+
"Download the image first, then pass the local file path.",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 3. File path
|
|
141
|
+
const looksLikePath =
|
|
142
|
+
trimmed.startsWith("/") ||
|
|
143
|
+
trimmed.startsWith("~") ||
|
|
144
|
+
trimmed.startsWith(".") ||
|
|
145
|
+
/[\\/]/.test(trimmed) ||
|
|
146
|
+
Boolean(mimeTypeFromExtension(trimmed));
|
|
147
|
+
|
|
148
|
+
if (looksLikePath) {
|
|
149
|
+
const resolved = path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
|
150
|
+
|
|
151
|
+
if (!fs.existsSync(resolved)) {
|
|
152
|
+
throw new Error(`Image file not found: ${resolved}`);
|
|
153
|
+
}
|
|
154
|
+
if (!fs.statSync(resolved).isFile()) {
|
|
155
|
+
throw new Error(`Not a file: ${resolved}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const buffer = fs.readFileSync(resolved);
|
|
159
|
+
if (buffer.length === 0) {
|
|
160
|
+
throw new Error(`Image file is empty: ${resolved}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Trust the content over the extension.
|
|
164
|
+
const mimeType = detectMimeType(buffer) ?? mimeTypeFromExtension(resolved);
|
|
165
|
+
if (!mimeType) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Could not determine the image type of ${resolved}. ` +
|
|
168
|
+
`Supported types: ${SUPPORTED_MIME_TYPES.join(", ")}.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (!isSupportedMimeType(mimeType)) {
|
|
172
|
+
throw new Error(describeUnsupported(mimeType));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
data: buffer.toString("base64"),
|
|
177
|
+
mimeType,
|
|
178
|
+
source: "file",
|
|
179
|
+
path: resolved,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 4. Raw base64
|
|
184
|
+
if (looksLikeBase64(trimmed)) {
|
|
185
|
+
const compact = trimmed.replace(/\s/g, "");
|
|
186
|
+
const mimeType = detectMimeType(Buffer.from(compact, "base64"));
|
|
187
|
+
if (!mimeType) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
"Could not determine the image type of the supplied base64 data. " +
|
|
190
|
+
"Prefer a file path, or use a data: URL that declares the media type.",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return { data: compact, mimeType, source: "base64" };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Could not interpret "${truncate(trimmed)}" as an image. ` +
|
|
198
|
+
"Pass a local file path, a data: URL, or base64-encoded image data.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function truncate(value: string, max = 60): string {
|
|
203
|
+
return value.length <= max ? value : `${value.slice(0, max)}…`;
|
|
204
|
+
}
|