Enhance image input handling: support local file paths and URIs in image processing
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user