const { googleAI } = require("@genkit-ai/googleai"); const { genkit } = require("genkit"); const { GEMINI_API_KEY } = require("../config/keys"); const admin = require("firebase-admin"); const { Buffer } = require("buffer"); const { setTimeout } = require("timers"); exports.generateAI = async ({ system = "", prompt = "", schema }) => { const genkitInstance = genkit({ plugins: [googleAI({ apiKey: GEMINI_API_KEY })], model: googleAI.model("gemini-2.5-flash"), }); if (prompt?.length < 1) { throw new Error( "Vous devez spécifier un prompt pour effectuer cette action.", ); } const { output } = await genkitInstance.generate({ system, prompt, output: { schema }, }); return output; }; exports.generateImageV2 = async (prompt, size = 1024) => { if (typeof prompt !== "string" || prompt.trim().length < 1) { throw new Error("Vous devez spécifier un prompt (string) non vide."); } try { console.log("🚀 [generateImageV2] Démarrage de la génération d'image", { promptLength: prompt.trim().length, size, }); // Ajouter explicitement la taille au prompt si elle n'est pas mentionnée if (!prompt.includes("1024x1024") && !prompt.includes(size + "x" + size)) { prompt = `Image carrée ${size}x${size} pixels. ${prompt}`; } const ai = genkit({ plugins: [googleAI({ apiKey: GEMINI_API_KEY })], }); const imageModel = googleAI.model("gemini-2.5-flash-image-preview", { responseModalities: ["IMAGE"], // L'API n'accepte pas generationConfig comme paramètre direct // Les paramètres de génération doivent être passés lors de l'appel generate() }); console.log("🔄 [generateImageV2] Envoi du prompt au modèle"); // Ajouter un timeout et des tentatives let attempts = 0; const maxAttempts = 3; let res; while (attempts < maxAttempts) { try { attempts++; console.log( `🔁 [generateImageV2] Tentative ${attempts}/${maxAttempts}`, ); // Utiliser Promise.race pour ajouter un timeout const timeout = new Promise((_, reject) => { setTimeout(() => reject(new Error("Timeout dépassé (30s)")), 30000); }); res = await Promise.race([ ai.generate({ model: imageModel, prompt: prompt.trim(), // Paramètres de génération passés dans un objet generation_config generation_config: { temperature: 0.7, top_k: 40, top_p: 0.95, max_output_tokens: 8192, }, }), timeout, ]); if (res?.media?.url) { console.log( "✅ [generateImageV2] Image générée avec succès à la tentative", attempts, ); break; // Sortir de la boucle si on a une image } else { console.warn( `⚠️ [generateImageV2] Tentative ${attempts} sans média, nouvelle tentative...`, ); // Attendre un peu avant de réessayer await new Promise((resolve) => setTimeout(resolve, 2000)); } } catch (err) { console.error( `❌ [generateImageV2] Erreur à la tentative ${attempts}:`, err.message, ); if (attempts >= maxAttempts) throw err; // Attendre un peu plus longtemps après une erreur await new Promise((resolve) => setTimeout(resolve, 3000)); } } console.log("📊 [generateImageV2] Réponse finale", { hasMedia: !!res?.media, hasUrl: !!res?.media?.url, responseKeys: Object.keys(res || {}).join(", "), }); const media = res?.media; if (!media || !media.url) { console.error( "❌ [generateImageV2] Pas de média dans la réponse après toutes les tentatives", { response: JSON.stringify(res || {}).substring(0, 500), prompt: prompt.substring(0, 200) + "...", }, ); throw new Error( "La génération n'a pas retourné de média image après plusieurs tentatives.", ); } const dataUrl = String(media.url); const commaIdx = dataUrl.indexOf(","); if (commaIdx === -1) throw new Error("FORMAT_DATA_URL_INVALIDE"); const header = dataUrl.substring(0, commaIdx); const b64 = dataUrl.substring(commaIdx + 1); let mimeType = media.contentType || "image/png"; const headerMatch = header.match(/^data:([^;]+);base64$/i); if (headerMatch && headerMatch[1]) mimeType = headerMatch[1]; const buffer = Buffer.from(b64, "base64"); const bucket = admin.storage().bucket(); const ext = mimeType === "image/jpeg" ? "jpg" : mimeType === "image/webp" ? "webp" : "png"; const token = require("crypto").randomUUID(); const path = `generated/images/${Date.now()}-${token}.${ext}`; console.log( "💾 [generateImageV2] Sauvegarde de l'image dans Firebase Storage", ); await bucket.file(path).save(buffer, { resumable: false, metadata: { contentType: mimeType, cacheControl: "public, max-age=31536000", metadata: { firebaseStorageDownloadTokens: token }, }, }); const publicUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent( path, )}?alt=media&token=${token}`; console.log("✅ [generateImageV2] Image générée avec succès"); return publicUrl; } catch (error) { console.error( "❌ [generateImageV2] Erreur lors de la génération d'image:", error, ); throw new Error(`Erreur lors de la génération d'image: ${error.message}`); } }; // Combine deux images avec Gemini (background = cover abstraite, foreground = photo utilisateur détourée) // Entrées possibles pour chaque image: // - string data URL ("data:image/...;base64,....") // - string URL http(s) // - { base64: string, mimeType: string } // - { url: string, mimeType?: string } // Sortie: URL publique de l'image composite (PNG 1024x1024) exports.CombineCoverAndPicture = async ( backgroundImage, foregroundImage, options = {}, ) => { const size = Number(options.size || 1024); const toDataUrl = (input, fallbackMime = "image/png") => { if (!input) return null; if (typeof input === "string") { if (input.startsWith("data:")) return input; // http(s) URL - laissez le middleware télécharger if (/^https?:\/\//i.test(input)) return input; // Base64 brut (non recommandé sans mimeType) -> tenter fallback if (/^[A-Za-z0-9+/=]+$/.test(input) && input.length > 256) { return `data:${fallbackMime};base64,${input}`; } return input; // dernier recours } if (typeof input === "object") { if (input.url) { if (input.url.startsWith("data:")) return input.url; if (/^https?:\/\//i.test(input.url)) return input.url; // middleware download if (input.base64) return `data:${input.mimeType || fallbackMime};base64,${input.base64}`; return input.url; } if (input.base64) { return `data:${input.mimeType || fallbackMime};base64,${input.base64}`; } } return null; }; const bgUrl = toDataUrl(backgroundImage, "image/png"); const fgUrl = toDataUrl(foregroundImage, "image/png"); if (!bgUrl || !fgUrl) { throw new Error( "CombineCoverAndPicture: images d'entrée invalides (background/foreground requis)", ); } const ai = genkit({ plugins: [googleAI({ apiKey: GEMINI_API_KEY })] }); const model = googleAI.model("gemini-2.5-flash-image-preview", { responseModalities: ["IMAGE"], }); // Instruction en FR pour un compositing propre const instruction = ` Combine les deux images fournies sur une toile carrée ${size}x${size}. Utilise la première image comme arrière-plan en plein cadre (recadrer au besoin sans bords vides). Détoure précisément le sujet principal de la seconde image (suppression d'arrière-plan), puis place-le par-dessus, centré, avec une légère ombre portée douce pour le détacher. Préserve les détails/contours, couleurs naturelles, et une intégration réaliste (échelle, lumière). L'image générer sera une pochette d'album de musique. Aucun texte, aucun logo, aucun filigrane. Export en PNG. `.trim(); // Construire le prompt avec parties media const parts = [ { text: instruction }, { media: { url: bgUrl } }, { media: { url: fgUrl } }, ]; const res = await ai.generate({ model, prompt: parts }); const media = res?.media; if (!media || !media.url) { throw new Error("La génération n'a pas retourné d'image composite."); } const dataUrl = String(media.url); const commaIdx = dataUrl.indexOf(","); if (commaIdx === -1) throw new Error("FORMAT_DATA_URL_INVALIDE"); const header = dataUrl.substring(0, commaIdx); const b64 = dataUrl.substring(commaIdx + 1); let mimeType = media.contentType || "image/png"; const headerMatch = header.match(/^data:([^;]+);base64$/i); if (headerMatch && headerMatch[1]) mimeType = headerMatch[1]; const buffer = Buffer.from(b64, "base64"); const bucket = admin.storage().bucket(); const ext = mimeType === "image/jpeg" ? "jpg" : mimeType === "image/webp" ? "webp" : "png"; const token = require("crypto").randomUUID(); const path = `generated/combined/${Date.now()}-${token}.${ext}`; await bucket.file(path).save(buffer, { resumable: false, metadata: { contentType: mimeType, cacheControl: "public, max-age=31536000", metadata: { firebaseStorageDownloadTokens: token }, }, }); const publicUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent( path, )}?alt=media&token=${token}`; return publicUrl; };