295 lines
9.7 KiB
JavaScript
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;
|
|
};
|