diff --git a/functions/helpers/gemini.js b/functions/helpers/gemini.js index 08c47d3..e5e2c44 100644 --- a/functions/helpers/gemini.js +++ b/functions/helpers/gemini.js @@ -191,36 +191,56 @@ exports.CombineCoverAndPicture = async ( options = {}, ) => { const size = Number(options.size || 1024); + console.log("🧩 [CombineCoverAndPicture] Start", { size }); - const toDataUrl = (input, fallbackMime = "image/png") => { + // Convertit diverses entrées (URL http(s), data URL, {base64, mimeType}) en data URL + const toDataUrl = async (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}`; + try { + if (typeof input === "string") { + if (input.startsWith("data:")) return input; + if (/^https?:\/\//i.test(input)) { + const axios = require("axios"); + const resp = await axios.get(input, { responseType: "arraybuffer" }); + const mime = resp.headers["content-type"] || fallbackMime; + const b64 = Buffer.from(resp.data).toString("base64"); + return `data:${mime};base64,${b64}`; + } + // Base64 brut -> fallback + if (/^[A-Za-z0-9+/=]+$/.test(input) && input.length > 256) { + return `data:${fallbackMime};base64,${input}`; + } + return 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) + if (typeof input === "object") { + if (input.url) { + if (input.url.startsWith("data:")) return input.url; + if (/^https?:\/\//i.test(input.url)) { + const axios = require("axios"); + const resp = await axios.get(input.url, { + responseType: "arraybuffer", + }); + const mime = + input.mimeType || resp.headers["content-type"] || fallbackMime; + const b64 = Buffer.from(resp.data).toString("base64"); + return `data:${mime};base64,${b64}`; + } + 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 input.url; - } - if (input.base64) { - return `data:${input.mimeType || fallbackMime};base64,${input.base64}`; + } } + } catch (e) { + console.warn("toDataUrl: échec de conversion en data URL", e?.message); } return null; }; - const bgUrl = toDataUrl(backgroundImage, "image/png"); - const fgUrl = toDataUrl(foregroundImage, "image/png"); + const bgUrl = await toDataUrl(backgroundImage, "image/png"); + const fgUrl = await toDataUrl(foregroundImage, "image/png"); if (!bgUrl || !fgUrl) { throw new Error( "CombineCoverAndPicture: images d'entrée invalides (background/foreground requis)", @@ -234,23 +254,38 @@ exports.CombineCoverAndPicture = async ( // 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. +Tu reçois 2 images: la première est l'arrière-plan, la seconde est une photo à incruster. +1) Crée une toile carrée ${size}x${size} et place l'arrière-plan en plein cadre, sans bords vides (recadrer si nécessaire). +2) Détoure le sujet principal de la seconde image. +3) Place le sujet détouré par-dessus l'arrière-plan, et essaie de faire une jolie pochette. +4) Aucun texte, aucun logo, aucun filigrane. Export en PNG. `.trim(); // Construire le prompt avec parties media + // Utiliser des data URLs pour garantir la prise en compte des 2 images const parts = [ { text: instruction }, { media: { url: bgUrl } }, { media: { url: fgUrl } }, ]; - const res = await ai.generate({ model, prompt: parts }); + console.log("🧩 [CombineCoverAndPicture] Entrées prêtes", { + bgIsDataUrl: typeof bgUrl === "string" && bgUrl.startsWith("data:"), + fgIsDataUrl: typeof fgUrl === "string" && fgUrl.startsWith("data:"), + bgLen: (bgUrl || "").length, + fgLen: (fgUrl || "").length, + }); + + let res; + try { + res = await ai.generate({ model, prompt: parts }); + } catch (err) { + console.error( + "❌ [CombineCoverAndPicture] ai.generate failed", + err?.message || err, + ); + throw err; + } const media = res?.media; if (!media || !media.url) { throw new Error("La génération n'a pas retourné d'image composite."); @@ -289,6 +324,10 @@ Aucun texte, aucun logo, aucun filigrane. Export en PNG. const publicUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent( path, )}?alt=media&token=${token}`; - + console.log("✅ [CombineCoverAndPicture] Uploaded", { + path, + mimeType, + urlLen: publicUrl.length, + }); return publicUrl; }; diff --git a/functions/index.js b/functions/index.js index eaf0afa..27e93ed 100644 --- a/functions/index.js +++ b/functions/index.js @@ -1,12 +1,8 @@ const admin = require("firebase-admin"); -const serviceAccount = require("./serviceAccountKey.json"); -admin.initializeApp({ - credential: admin.credential.cert(serviceAccount), - storageBucket: "musicland-d33f9.firebasestorage.app", - databaseURL: "https://musicland-d33f9-default-rtdb.firebaseio.com", - projectId: "musicland-d33f9", -}); +// Use default credentials/environment provided by Cloud Functions. +// Avoid bundling a service account key and hardcoding project/bucket. +admin.initializeApp(); // Les fonctions IA ont été déplacées vers helpers/gemini diff --git a/functions/src/cover.js b/functions/src/cover.js index 8b81899..19ad034 100644 --- a/functions/src/cover.js +++ b/functions/src/cover.js @@ -14,14 +14,20 @@ async function performCoverGeneration(project) { const t0 = Date.now(); const prompt = generatePicturePrompt(project); // Utiliser generateImageV2 qui renvoie directement l'URL publique - logger.info("🎨 [Cover] Calling model V2", { projectId: project.id }); + logger.info("🎨 [Cover] Calling model V2", { + projectId: project.id, + promptPreview: String(prompt).slice(0, 160), + }); let coverUrl = ""; try { // Utiliser une taille fixe de 1024 pixels coverUrl = await generateImageV2(prompt, 1024); console.log("coverUrl", coverUrl); } catch (e) { - logger.error("❌ [Cover] generateImageV2 failed", e); + logger.error("❌ [Cover] generateImageV2 failed", { + projectId: project.id, + error: e?.message || String(e), + }); throw e; } if (!coverUrl) { @@ -62,17 +68,28 @@ async function performCombineGeneration(project) { ); } + logger.info("🧩 [Combine] Inputs detected", { + bgPreview: String(background).slice(0, 80), + fgPreview: String(foreground).slice(0, 80), + }); + const combinedUrl = await CombineCoverAndPicture(background, foreground); // Sauvegarder le résultat et marquer comme généré - await db.collection("projects").doc(project.id).set( - { - "cover.result": combinedUrl, - coverStatus: "GENERATED", - updatedAt: admin.firestore.FieldValue.serverTimestamp(), - }, - { merge: true }, - ); + await db + .collection("projects") + .doc(project.id) + .set( + { + cover: { + ...project.cover, + result: combinedUrl, + }, + coverStatus: "GENERATED", + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }, + { merge: true }, + ); logger.info("✅ [Cover] Combined image saved", { projectId: project.id, @@ -102,6 +119,11 @@ exports.onTaskCreateGenerateCover = onDocumentCreated( if (!["cover", "combine"]?.includes(type)) { throw new Error(`Type de tâche non supporté: ${type}`); } + logger.info("🧵 [Task] Received", { + taskId: event?.params?.taskId, + type, + projectId, + }); await db.collection("projects").doc(projectId).update({ coverStatus: "GENERATING", updatedAt: admin.firestore.FieldValue.serverTimestamp(), @@ -114,6 +136,12 @@ exports.onTaskCreateGenerateCover = onDocumentCreated( } project.id = projectId; + logger.info("🔎 [Task] Project loaded", { + projectId, + hasGeneratedBackground: !!project?.cover?.generatedBackground, + hasUserForeground: !!project?.cover?.userForeground, + }); + if (type === "cover") { logger.info("🎨 [Task] Start cover generation", { projectId }); coverUrl = await performCoverGeneration(project); @@ -129,9 +157,18 @@ exports.onTaskCreateGenerateCover = onDocumentCreated( coverUrl, updatedAt: admin.firestore.FieldValue.serverTimestamp(), }); + logger.info("📦 [Task] Marked DONE", { + taskId: event?.params?.taskId, + projectId, + }); return; } catch (error) { - logger.error("❌ [Task] Cover generation error", error); + logger.error("❌ [Task] Cover generation error", { + taskId: event?.params?.taskId, + projectId, + type, + error: error?.message || String(error), + }); await event.data.ref.set( { status: "ERROR", error: error?.message || "Erreur" }, { merge: true }, diff --git a/src/screens/cover/PhotoCover.js b/src/screens/cover/PhotoCover.js index b5b0b6d..b858f9c 100644 --- a/src/screens/cover/PhotoCover.js +++ b/src/screens/cover/PhotoCover.js @@ -116,11 +116,17 @@ const PhotoCover = () => { > navigate(Routes.ValidateCover)} - title="Non, valider la pochette" + title={ + isGenerating ? "Génération en cours..." : "Valider la pochette" + } + disabled={isGenerating} /> onChooseForegroundImage()} + disabled={isGenerating} />