diff --git a/README.md b/README.md index 64f038f..0d650b7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/generated/big-banana-logo-1.png b/generated/big-banana-logo-1.png index 0487296..d131b8a 100644 Binary files a/generated/big-banana-logo-1.png and b/generated/big-banana-logo-1.png differ diff --git a/src/google.ts b/src/google.ts index 3e96887..e364977 100644 --- a/src/google.ts +++ b/src/google.ts @@ -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; + cleanup?: () => Promise; + 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 = { + '.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> { + const bytes = await readFile(filePath); + return { + inlineData: { + data: bytes.toString('base64'), + mimeType, + }, + }; +} + +async function uploadFilePart(ai: GoogleGenAI, filePath: string, mimeType: string, displayName?: string): Promise { + 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 { + 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 { const model = request.model ?? config.defaultModel; const ai = new GoogleGenAI({ apiKey }); - - const parts: Array> = [{ 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> = [ + ...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 => 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, - }; } \ No newline at end of file diff --git a/src/httpConfigServer.ts b/src/httpConfigServer.ts index e45956f..4052cb0 100644 --- a/src/httpConfigServer.ts +++ b/src/httpConfigServer.ts @@ -83,13 +83,28 @@ export async function startConfigHttpServer(initialConfig: AppConfig): Promise((resolve, reject) => { - server.once('error', reject); - server.listen(initialConfig.configServerPort, initialConfig.configServerHost, () => { - server.off('error', reject); - resolve(); + try { + await new Promise((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}`, diff --git a/src/index.ts b/src/index.ts index 36a3021..ce11671 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,10 +102,21 @@ async function createServer(): Promise { 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 { 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), };