Files
musicland/functions/helpers/gemini.js
T

295 lines
9.7 KiB
JavaScript

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;
};