Enhance image input handling: support local file paths and URIs in image processing

This commit is contained in:
enne2
2026-03-14 19:56:26 +01:00
parent 39f973df0a
commit adc105e600
5 changed files with 321 additions and 73 deletions
+38 -3
View File
@@ -7,6 +7,7 @@ Server MCP in TypeScript per usare i modelli Google Nano Banana tramite Gemini A
- generazione da testo
- editing da una o piu immagini di riferimento
- fusione di piu immagini
- immagini locali passate come file path, oltre a base64/data URL e URI del Files API
- grounding opzionale con Google Search
- controlli su aspect ratio, risoluzione e thinking
@@ -86,11 +87,44 @@ Tool principale per generazione, editing e multimodalita.
Supporta:
- solo prompt testo
- prompt + immagine base
- prompt + immagini multiple
- prompt + immagine esistente da `filePath`, `fileUri` o base64
- prompt + immagini multiple per fusion/context preservation
- grounding opzionale con Google Search
- salvataggio opzionale dei risultati su disco
Esempio con file locale esistente:
```json
{
"prompt": "Mantieni il logo originale, aggiungi la sigla BBMCP nella targhetta inferiore.",
"inputImages": [
{
"filePath": "/home/enne2/dev/bigbananamcp/generated/big-banana-logo-1.png",
"inputMethod": "auto"
}
],
"outputDirectory": "/home/enne2/dev/bigbananamcp/generated",
"outputPrefix": "big-banana-logo-edit"
}
```
Esempio con piu immagini da fondere:
```json
{
"prompt": "Combina queste reference in un unico badge vettoriale pulito, mantenendo la banana centrale.",
"inputImages": [
{
"filePath": "/abs/path/reference-1.png"
},
{
"fileUri": "https://example.com/reference-2.png",
"mimeType": "image/png"
}
]
}
```
### `nano_banana_models`
Elenca modelli consigliati e relative capacita operative.
@@ -103,7 +137,8 @@ Ritorna stato configurazione, path del file nascosto e URL del servizio HTTP loc
- Il modello predefinito e `gemini-3.1-flash-image-preview`, cioe Nano Banana 2.
- Sono supportati anche `gemini-2.5-flash-image` e `gemini-3-pro-image-preview`.
- Le immagini vengono inviate come `inlineData` in base64, in linea con l'SDK ufficiale `@google/genai`.
- Per immagini locali il server usa `inlineData` per file piccoli e il Gemini Files API per file grandi o riutilizzabili.
- Gli input `fileUri` permettono di riusare file gia caricati nel Files API o URI pubblici/signed quando supportati dal modello.
- Tutti i log runtime vanno su stderr per non interferire con il trasporto stdio MCP.
## Debug in VS Code
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

+246 -60
View File
@@ -1,4 +1,4 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { extname, isAbsolute, join, resolve } from 'node:path';
import { GoogleGenAI } from '@google/genai';
@@ -24,8 +24,12 @@ export const SUPPORTED_MODELS = [
] as const;
export type InputImage = {
data: string;
data?: string;
filePath?: string;
fileUri?: string;
mimeType?: string;
inputMethod?: 'auto' | 'inline' | 'file-api';
displayName?: string;
};
export type GenerateRequest = {
@@ -46,6 +50,18 @@ export type GenerateRequest = {
outputPrefix?: string;
};
type ResolvedInputImage = {
part: Record<string, unknown>;
cleanup?: () => Promise<void>;
summary: {
source: 'data' | 'filePath' | 'fileUri';
method: 'inline' | 'file-api' | 'uri';
mimeType: string;
filePath?: string;
fileUri?: string;
};
};
export type GeneratedImage = {
mimeType: string;
data: string;
@@ -58,9 +74,27 @@ export type GenerateResult = {
textParts: string[];
images: GeneratedImage[];
usedGoogleSearch: boolean;
inputImageSources: Array<{
source: 'data' | 'filePath' | 'fileUri';
method: 'inline' | 'file-api' | 'uri';
mimeType: string;
filePath?: string;
fileUri?: string;
}>;
usageMetadata?: unknown;
};
const INLINE_IMAGE_LIMIT_BYTES = 20 * 1024 * 1024;
const MIME_TYPE_BY_EXTENSION: Record<string, string> = {
'.heic': 'image/heic',
'.heif': 'image/heif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
};
function cleanBase64(data: string): { data: string; mimeType?: string } {
const trimmed = data.trim();
const match = /^data:([^;]+);base64,(.+)$/s.exec(trimmed);
@@ -75,6 +109,157 @@ function cleanBase64(data: string): { data: string; mimeType?: string } {
};
}
function inferMimeTypeFromPath(pathLike: string): string | undefined {
const extension = extname(pathLike).toLowerCase();
return MIME_TYPE_BY_EXTENSION[extension];
}
function detectInputImageSource(image: InputImage): 'data' | 'filePath' | 'fileUri' {
if (image.data) {
return 'data';
}
if (image.filePath) {
return 'filePath';
}
if (image.fileUri) {
return 'fileUri';
}
throw new Error('Each input image must include one of: data, filePath, or fileUri.');
}
async function buildInlinePartFromFile(filePath: string, mimeType: string): Promise<Record<string, unknown>> {
const bytes = await readFile(filePath);
return {
inlineData: {
data: bytes.toString('base64'),
mimeType,
},
};
}
async function uploadFilePart(ai: GoogleGenAI, filePath: string, mimeType: string, displayName?: string): Promise<ResolvedInputImage> {
const uploadedFile = await ai.files.upload({
file: filePath,
config: {
...(displayName ? { displayName } : {}),
mimeType,
},
});
let currentFile = uploadedFile;
while (currentFile.state === 'PROCESSING') {
await new Promise(resolveDelay => setTimeout(resolveDelay, 500));
currentFile = await ai.files.get({ name: currentFile.name ?? '' });
}
if (currentFile.state === 'FAILED') {
throw new Error(`Gemini File API processing failed for ${filePath}.`);
}
const fileUri = currentFile.uri;
const resolvedMimeType = currentFile.mimeType ?? mimeType;
if (!fileUri) {
throw new Error(`Gemini File API did not return a file URI for ${filePath}.`);
}
return {
part: {
fileData: {
fileUri,
mimeType: resolvedMimeType,
},
},
cleanup: currentFile.name
? async () => {
await ai.files.delete({ name: currentFile.name! });
}
: undefined,
summary: {
source: 'filePath',
method: 'file-api',
mimeType: resolvedMimeType,
filePath,
fileUri,
},
};
}
async function resolveInputImage(ai: GoogleGenAI, image: InputImage): Promise<ResolvedInputImage> {
const source = detectInputImageSource(image);
if (source === 'data') {
const normalized = cleanBase64(image.data!);
const mimeType = image.mimeType ?? normalized.mimeType ?? 'image/png';
return {
part: {
inlineData: {
data: normalized.data,
mimeType,
},
},
summary: {
source,
method: 'inline',
mimeType,
},
};
}
if (source === 'fileUri') {
const mimeType = image.mimeType ?? inferMimeTypeFromPath(image.fileUri!);
if (!mimeType) {
throw new Error(`Unable to infer mimeType for fileUri ${image.fileUri}. Provide mimeType explicitly.`);
}
return {
part: {
fileData: {
fileUri: image.fileUri,
mimeType,
},
},
summary: {
source,
method: 'uri',
mimeType,
fileUri: image.fileUri,
},
};
}
const filePath = resolve(image.filePath!);
const mimeType = image.mimeType ?? inferMimeTypeFromPath(filePath);
if (!mimeType) {
throw new Error(`Unable to infer mimeType for ${filePath}. Supported extensions: ${Object.keys(MIME_TYPE_BY_EXTENSION).join(', ')}.`);
}
const fileStats = await stat(filePath);
const requestedMethod = image.inputMethod ?? 'auto';
const effectiveMethod = requestedMethod === 'auto'
? fileStats.size > INLINE_IMAGE_LIMIT_BYTES
? 'file-api'
: 'inline'
: requestedMethod;
if (effectiveMethod === 'file-api') {
return uploadFilePart(ai, filePath, mimeType, image.displayName);
}
return {
part: await buildInlinePartFromFile(filePath, mimeType),
summary: {
source,
method: 'inline',
mimeType,
filePath,
},
};
}
function buildThinkingConfig(level: ThinkingLevel | undefined, budget: number | undefined, includeThoughts: boolean | undefined) {
const thinkingBudget = budget ?? (level === 'high' ? 24576 : level === 'minimal' ? 0 : undefined);
@@ -125,70 +310,71 @@ async function saveImages(images: GeneratedImage[], outputDirectory: string, out
export async function generateWithNanoBanana(apiKey: string, config: AppConfig, request: GenerateRequest): Promise<GenerateResult> {
const model = request.model ?? config.defaultModel;
const ai = new GoogleGenAI({ apiKey });
const parts: Array<Record<string, unknown>> = [{ text: request.prompt }];
for (const image of request.inputImages ?? []) {
const normalized = cleanBase64(image.data);
parts.push({
inlineData: {
data: normalized.data,
mimeType: image.mimeType ?? normalized.mimeType ?? 'image/png',
},
});
}
const resolvedInputImages = await Promise.all((request.inputImages ?? []).map(image => resolveInputImage(ai, image)));
const parts: Array<Record<string, unknown>> = [
...resolvedInputImages.map(image => image.part),
{ text: request.prompt },
];
const usedGoogleSearch = request.useGoogleSearch ?? config.enableGoogleSearchByDefault;
const cleanupTasks = resolvedInputImages
.map(image => image.cleanup)
.filter((cleanup): cleanup is () => Promise<void> => Boolean(cleanup));
const response = await ai.models.generateContent({
model,
contents: [
{
role: 'user',
parts,
try {
const response = await ai.models.generateContent({
model,
contents: [
{
role: 'user',
parts,
},
],
config: {
...(request.systemInstruction ? { systemInstruction: request.systemInstruction } : {}),
...(request.temperature !== undefined ? { temperature: request.temperature } : {}),
...(request.candidateCount !== undefined ? { candidateCount: request.candidateCount } : {}),
responseModalities: ['TEXT', 'IMAGE'],
imageConfig: {
aspectRatio: request.aspectRatio ?? config.defaultAspectRatio,
imageSize: request.imageSize ?? config.defaultImageSize,
},
...(buildThinkingConfig(request.thinkingLevel, request.thinkingBudget, request.includeThoughts)
? { thinkingConfig: buildThinkingConfig(request.thinkingLevel, request.thinkingBudget, request.includeThoughts) }
: {}),
...(usedGoogleSearch ? { tools: [{ googleSearch: {} }] } : {}),
},
],
config: {
...(request.systemInstruction ? { systemInstruction: request.systemInstruction } : {}),
...(request.temperature !== undefined ? { temperature: request.temperature } : {}),
...(request.candidateCount !== undefined ? { candidateCount: request.candidateCount } : {}),
responseModalities: ['TEXT', 'IMAGE'],
imageConfig: {
aspectRatio: request.aspectRatio ?? config.defaultAspectRatio,
imageSize: request.imageSize ?? config.defaultImageSize,
},
...(buildThinkingConfig(request.thinkingLevel, request.thinkingBudget, request.includeThoughts)
? { thinkingConfig: buildThinkingConfig(request.thinkingLevel, request.thinkingBudget, request.includeThoughts) }
: {}),
...(usedGoogleSearch ? { tools: [{ googleSearch: {} }] } : {}),
},
});
});
const firstCandidate = response.candidates?.[0];
const responseParts = firstCandidate?.content?.parts ?? [];
const firstCandidate = response.candidates?.[0];
const responseParts = firstCandidate?.content?.parts ?? [];
let images: GeneratedImage[] = responseParts
.filter((part): part is { inlineData: { mimeType?: string; data?: string } } => Boolean((part as { inlineData?: unknown }).inlineData))
.map(part => ({
mimeType: part.inlineData.mimeType ?? 'image/png',
data: part.inlineData.data ?? '',
}))
.filter(image => image.data.length > 0);
let images: GeneratedImage[] = responseParts
.filter((part): part is { inlineData: { mimeType?: string; data?: string } } => Boolean((part as { inlineData?: unknown }).inlineData))
.map(part => ({
mimeType: part.inlineData.mimeType ?? 'image/png',
data: part.inlineData.data ?? '',
}))
.filter(image => image.data.length > 0);
if (request.outputDirectory) {
images = await saveImages(images, request.outputDirectory, request.outputPrefix ?? 'nano-banana-output');
if (request.outputDirectory) {
images = await saveImages(images, request.outputDirectory, request.outputPrefix ?? 'nano-banana-output');
}
const textParts = responseParts
.filter((part): part is { text: string; thought?: boolean } => typeof (part as { text?: unknown }).text === 'string')
.map(part => (part.thought ? `[thought] ${part.text}` : part.text));
return {
model,
prompt: request.prompt,
textParts,
images,
usedGoogleSearch,
inputImageSources: resolvedInputImages.map(image => image.summary),
usageMetadata: response.usageMetadata,
};
} finally {
await Promise.allSettled(cleanupTasks.map(cleanup => cleanup()));
}
const textParts = responseParts
.filter((part): part is { text: string; thought?: boolean } => typeof (part as { text?: unknown }).text === 'string')
.map(part => (part.thought ? `[thought] ${part.text}` : part.text));
return {
model,
prompt: request.prompt,
textParts,
images,
usedGoogleSearch,
usageMetadata: response.usageMetadata,
};
}
+21 -6
View File
@@ -83,13 +83,28 @@ export async function startConfigHttpServer(initialConfig: AppConfig): Promise<C
}
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(initialConfig.configServerPort, initialConfig.configServerHost, () => {
server.off('error', reject);
resolve();
try {
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(initialConfig.configServerPort, initialConfig.configServerHost, () => {
server.off('error', reject);
resolve();
});
});
});
} catch (error) {
const listenError = error as NodeJS.ErrnoException;
if (listenError.code === 'EADDRINUSE') {
console.error(
`Configuration endpoint already active on http://${initialConfig.configServerHost}:${initialConfig.configServerPort}; continuing without starting a duplicate listener.`,
);
return {
close: async () => {},
};
}
throw error;
}
console.error(
`Configuration endpoint listening on http://${initialConfig.configServerHost}:${initialConfig.configServerPort}`,
+16 -4
View File
@@ -102,10 +102,21 @@ async function createServer(): Promise<McpServer> {
systemInstruction: z.string().optional().describe('Optional system instruction sent to Gemini.'),
inputImages: z
.array(
z.object({
data: z.string().describe('Base64 image data or a data URL.'),
mimeType: z.string().optional().describe('Explicit image MIME type if not embedded in the data URL.'),
}),
z
.object({
data: z.string().optional().describe('Base64 image data or a data URL.'),
filePath: z.string().optional().describe('Absolute or workspace-relative path to a local image file.'),
fileUri: z.string().optional().describe('Gemini File API URI or a public/signed image URI.'),
mimeType: z.string().optional().describe('Explicit image MIME type. Required when it cannot be inferred from data, filePath, or fileUri.'),
inputMethod: z
.enum(['auto', 'inline', 'file-api'])
.optional()
.describe('For local files only: auto chooses inline for smaller files and Gemini Files API for larger ones.'),
displayName: z.string().optional().describe('Optional Gemini Files API display name used when uploading a local file.'),
})
.refine(image => Boolean(image.data || image.filePath || image.fileUri), {
message: 'Each input image must include one of: data, filePath, or fileUri.',
}),
)
.max(14)
.optional()
@@ -138,6 +149,7 @@ async function createServer(): Promise<McpServer> {
model: result.model,
prompt: result.prompt,
usedGoogleSearch: result.usedGoogleSearch,
inputImageSources: result.inputImageSources,
imageCount: result.images.length,
savedPaths: result.images.map(image => image.savedPath).filter(Boolean),
};