clear last tickets
This commit is contained in:
+188
-470
@@ -3,13 +3,33 @@ const { genkit, z } = require("genkit");
|
||||
const { GEMINI_API_KEY } = require("../config/keys");
|
||||
const admin = require("firebase-admin");
|
||||
const { Buffer } = require("buffer");
|
||||
const { setTimeout } = require("timers");
|
||||
const { setTimeout } = require("timers/promises");
|
||||
|
||||
// --- CONFIGURATION ---
|
||||
// Plus intelligente que le Flash original, ultra rapide, et stable sur l'API.
|
||||
const TEXT_MODEL_NAME = "gemini-3-pro-preview";
|
||||
const IMAGE_MODEL_NAME = "gemini-3-pro-image-preview";
|
||||
|
||||
// --- SINGLETON PATTERN (WARM START) ---
|
||||
// On stocke l'instance en dehors de la fonction pour la réutiliser
|
||||
// entre les invocations si le conteneur est "chaud".
|
||||
let aiInstance = null;
|
||||
|
||||
const getAiInstance = () => {
|
||||
if (!aiInstance) {
|
||||
console.log("⚡ [Gemini] Initialisation froide (Cold Start)");
|
||||
aiInstance = genkit({
|
||||
plugins: [googleAI({ apiKey: GEMINI_API_KEY })],
|
||||
});
|
||||
}
|
||||
return aiInstance;
|
||||
};
|
||||
|
||||
/**
|
||||
* Génération de texte générique
|
||||
*/
|
||||
exports.generateAI = async ({ system = "", prompt = "", schema }) => {
|
||||
const genkitInstance = genkit({
|
||||
plugins: [googleAI({ apiKey: GEMINI_API_KEY })],
|
||||
model: googleAI.model("gemini-2.5-flash"),
|
||||
});
|
||||
const ai = getAiInstance(); // Récupère l'instance singleton
|
||||
|
||||
if (prompt?.length < 1) {
|
||||
throw new Error(
|
||||
@@ -17,21 +37,37 @@ exports.generateAI = async ({ system = "", prompt = "", schema }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const { output } = await genkitInstance.generate({
|
||||
system,
|
||||
prompt,
|
||||
output: { schema },
|
||||
});
|
||||
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`);
|
||||
const startedAt = Date.now();
|
||||
|
||||
return output;
|
||||
try {
|
||||
const { output } = await ai.generate({
|
||||
model: googleAI.model(TEXT_MODEL_NAME),
|
||||
system,
|
||||
prompt,
|
||||
output: { schema },
|
||||
config: {
|
||||
temperature: 0.7, // Créativité équilibrée
|
||||
},
|
||||
});
|
||||
|
||||
return output;
|
||||
} catch (error) {
|
||||
console.error("❌ [generateAI] Error:", error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
console.log(`⏱️ [generateAI] Durée: ${Date.now() - startedAt}ms`);
|
||||
}
|
||||
};
|
||||
|
||||
// Analyse la toxicité des paroles d'une chanson pour modération.
|
||||
// Entrée: { title: string, lyrics: string | {couplet?, refrain?} | Array<{type, lyrics}> }
|
||||
// Sortie (schéma strict): indicateurs de toxicité, raisons, extraits et décision de blocage.
|
||||
// Cloud Function plus nuancée : bloque le réellement problématique, laisse passer le reste
|
||||
/**
|
||||
* Analyse la toxicité des paroles.
|
||||
* Utilise gemini-1.5-flash-002 avec des réglages permissifs pour l'analyse.
|
||||
*/
|
||||
exports.analyseLyrics = async ({ title = "", lyrics }) => {
|
||||
// --- Normalisation inchangée (robuste aux différents formats) ---
|
||||
const ai = getAiInstance();
|
||||
|
||||
// --- Normalisation ---
|
||||
const normalizeLyrics = (raw) => {
|
||||
if (!raw) return "";
|
||||
if (typeof raw === "string") return raw;
|
||||
@@ -55,486 +91,168 @@ exports.analyseLyrics = async ({ title = "", lyrics }) => {
|
||||
};
|
||||
|
||||
const lyricsText = normalizeLyrics(lyrics).trim();
|
||||
if (!lyricsText) throw new Error("analyseLyrics: paroles requises.");
|
||||
|
||||
if (!lyricsText) {
|
||||
throw new Error(
|
||||
"analyseLyrics: vous devez fournir des paroles (lyrics) non vides.",
|
||||
);
|
||||
}
|
||||
|
||||
// Détection heuristique d'insultes explicites pour durcir la modération avant l'IA
|
||||
const sanitizedLyrics = lyricsText
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9\s]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const vulgarTerms = [
|
||||
"trou du cul",
|
||||
"trouduc",
|
||||
"encule",
|
||||
"enculer",
|
||||
"connard",
|
||||
"connasse",
|
||||
"con",
|
||||
"fdp",
|
||||
"fils de pute",
|
||||
"pute",
|
||||
"putain",
|
||||
"salope",
|
||||
"salaud",
|
||||
"enfoire",
|
||||
"batard",
|
||||
"ordure",
|
||||
"ta gueule",
|
||||
"nique ta mere",
|
||||
"ntm",
|
||||
];
|
||||
const detectedVulgarities = vulgarTerms.filter((term) => {
|
||||
const pattern = new RegExp(`\\b${escapeRegex(term)}\\b`, "i");
|
||||
return pattern.test(sanitizedLyrics);
|
||||
});
|
||||
const vulgarityHints = detectedVulgarities.length
|
||||
? `<VULGARITES_DETECTEES>${detectedVulgarities.join(", ")}</VULGARITES_DETECTEES>`
|
||||
: "<VULGARITES_DETECTEES>Aucune détectée heuristiquement.</VULGARITES_DETECTEES>";
|
||||
|
||||
// --- Schéma de sortie (inchangé pour compatibilité) ---
|
||||
// --- Schéma ---
|
||||
const moderationSchema = z.object({
|
||||
title: z.string().describe("Titre analysé (copie du titre fourni)"),
|
||||
title: z.string().describe("Titre analysé"),
|
||||
flagged: z
|
||||
.boolean()
|
||||
.describe("Vrai si au moins un signalement de toxicité est détecté."),
|
||||
.describe("Vrai si le contenu nécessite un avertissement."),
|
||||
blocked: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Vrai si la publication doit être BLOQUÉE (cas graves et non ambigus).",
|
||||
),
|
||||
score: z.number().min(0).max(1).describe("Score global de toxicité (0-1)"),
|
||||
reasons: z.array(z.string()).describe("Raisons concises de la décision."),
|
||||
.describe("Vrai UNIQUEMENT si violation grave (Haine, Violence réelle)."),
|
||||
score: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(1)
|
||||
.describe("Score de risque (0=Sûr, 1=Dangereux)."),
|
||||
reasons: z.array(z.string()).describe("Liste concise des raisons."),
|
||||
excerpts: z
|
||||
.array(
|
||||
z.object({
|
||||
quote: z.string().describe("Extrait exact problématique"),
|
||||
category: z
|
||||
.string()
|
||||
.describe(
|
||||
"Catégorie principale (ex: racism, nazism, sexism, homophobia, harassment, self-harm incitement, terrorism, minors-sexual-content, etc.)",
|
||||
),
|
||||
severity: z
|
||||
.string()
|
||||
.describe("none|low|medium|high|critical pour l'extrait"),
|
||||
quote: z.string(),
|
||||
category: z.string(),
|
||||
severity: z.string(),
|
||||
}),
|
||||
)
|
||||
.max(10)
|
||||
.describe("Jusqu'à 10 extraits problématiques"),
|
||||
success: z.boolean().describe("Toujours true si l'analyse a abouti"),
|
||||
.max(10),
|
||||
success: z.boolean(),
|
||||
});
|
||||
|
||||
// --- Politique : davantage de place à la création tout en restant sûre ---
|
||||
const system = `
|
||||
Tu es un modérateur de contenu spécialisé dans l'analyse de paroles de chanson.
|
||||
Objectif: protéger les utilisateurs en BLOQUANT uniquement les cas réellement problématiques et non ambigus, tout en LAISSANT PASSER les œuvres qui utilisent la narration, la satire, la critique ou la représentation de personnages.
|
||||
// --- Prompt ---
|
||||
const system = `Tu es un Expert en Modération de Contenu Musical (Trust & Safety).
|
||||
TA MISSION : Distinguer l'expression artistique (même crue/vulgaire) du contenu réellement dangereux.
|
||||
|
||||
Sois STRICT sur les risques graves, mais NE SOIS PAS PUNITIF envers l'expression artistique.
|
||||
Évalue TOUJOURS: 1) l'intention, 2) le contexte (récit, citation, dénonciation, rôle/persona), 3) la cible, 4) la probabilité de tort réel.
|
||||
Ne laisse PAS passer des insultes vulgaires explicites (connard, trou du cul, enculé, fdp, salope, etc.): au minimum flagged=true avec un score significatif; blocked=true si c'est dirigé ou répété sans distance.
|
||||
1. "FLAGGED" (Avertissement) : Vulgarités, thèmes matures, drogue, sexe consensuel.
|
||||
2. "BLOCKED" (Interdit) : Discours de haine, harcèlement ciblé, pédopornographie, incitation explicite violence/suicide.
|
||||
|
||||
Retourne UNIQUEMENT un JSON conforme au schéma. Pas de texte hors JSON.
|
||||
`.trim();
|
||||
|
||||
// Règles détaillées injectées dans le prompt utilisateur pour guider la décision
|
||||
const decisionPolicy = `
|
||||
<POLITIQUE>
|
||||
<SECTION id="A">
|
||||
<TITRE>QUAND BLOQUER (blocked = true) — cas graves et clairs (pas ambigus)</TITRE>
|
||||
<CAS>Sexualité impliquant des mineurs (référence explicite, érotisation, incitation).</CAS>
|
||||
<CAS>Apologie explicite et sérieuse de terrorisme ou extrémisme réel, ou appel à rejoindre/soutenir.</CAS>
|
||||
<CAS>Appels explicites à la violence ou à un préjudice grave contre une personne ou un groupe réel.</CAS>
|
||||
<CAS>Déshumanisation évidente d'un groupe protégé accompagnée d'une menace ou d'une incitation.</CAS>
|
||||
<CAS>Instruction ou encouragement clair à commettre des crimes violents.</CAS>
|
||||
<CAS>Nazisme ou fascisme réel promu sans distance critique.</CAS>
|
||||
<CAS>Partage de contenu sexuel non consenti ou exploitation sexuelle réelle.</CAS>
|
||||
</SECTION>
|
||||
<SECTION id="B">
|
||||
<TITRE>QUAND NE PAS BLOQUER (blocked = false, flagged possible)</TITRE>
|
||||
<CAS>Œuvres montrant un personnage toxique pour le critiquer, le contextualiser ou le dénoncer.</CAS>
|
||||
<CAS>Citations, descriptions historiques, satire, ironie ou réappropriation par les communautés visées.</CAS>
|
||||
<CAS>Métaphores violentes sans cible réelle ni intention d'incitation.</CAS>
|
||||
<CAS>Références à des thèmes sombres à des fins narratives, éducatives ou critiques sans promotion réelle.</CAS>
|
||||
</SECTION>
|
||||
<SECTION id="C">
|
||||
<TITRE>CALIBRAGE DU SCORE ET DES SEVERITY</TITRE>
|
||||
<CAS>Score low ≈ 0.1-0.3, medium ≈ 0.3-0.6, high ≈ 0.6-0.85, critical > 0.85.</CAS>
|
||||
<CAS>Utilise la colonne severity pour refléter le passage le plus problématique.</CAS>
|
||||
</SECTION>
|
||||
<SECTION id="D">
|
||||
<TITRE>RELECTURE DU CONTEXTE</TITRE>
|
||||
<CAS>Si le passage relève de la narration, satire, critique ou roleplay: privilégie flagged=true et blocked=false.</CAS>
|
||||
<CAS>Si le passage constitue une injonction réelle ou un appel clair au tort: blocked=true.</CAS>
|
||||
</SECTION>
|
||||
<SECTION id="E">
|
||||
<TITRE>INSULTES ET VULGARITES</TITRE>
|
||||
<CAS>Insultes vulgaires explicites (trou du cul, connard, enculé, fdp, salope, ta gueule, nique ta mere, etc.) = flagged=true minimum et score ≥ 0.45.</CAS>
|
||||
<CAS>Si ces insultes visent quelqu'un de manière directe ou répétée, ou incitent à rabaisser/attaquer: blocked=true ou severity high/critical (au moins high si pas certain).</CAS>
|
||||
<CAS>Si c'est de l'auto-dérision ou du langage cru sans cible ni incitation: flagged=true mais blocked=false, score limité.</CAS>
|
||||
</SECTION>
|
||||
</POLITIQUE>
|
||||
`.trim();
|
||||
Analyse le CONTEXTE. Une insulte dans un clash de rap est différente d'un appel au meurtre.`;
|
||||
|
||||
const userPrompt = `
|
||||
<ANALYSE_PAROLES>
|
||||
<METADONNEES>
|
||||
<TITRE>${title || "Sans titre"}</TITRE>
|
||||
</METADONNEES>
|
||||
<PAROLES><![CDATA[
|
||||
ANALYSE CETTE CHANSON :
|
||||
Titre : ${title || "Inconnu"}
|
||||
|
||||
PAROLES :
|
||||
"""
|
||||
${lyricsText}
|
||||
]]></PAROLES>
|
||||
</ANALYSE_PAROLES>
|
||||
"""
|
||||
`;
|
||||
|
||||
<DIRECTIVES>
|
||||
<OBJECTIF>Autoriser l'expression artistique tout en bloquant les contenus réellement dangereux.</OBJECTIF>
|
||||
<REGLE>"blocked" = true UNIQUEMENT pour les cas graves listés dans la section A.</REGLE>
|
||||
<REGLE>Si le contenu relève du récit, de la critique, de la satire ou d'une mise en contexte artistique, laisse blocked=false (flagged=true si nécessaire).</REGLE>
|
||||
<REGLE>Fournis des "excerpts" courts en citant exactement les passages sensibles.</REGLE>
|
||||
<REGLE>Si des insultes vulgaires explicites sont présentes, flagged=true au minimum (score >=0.45) et blocked=true si elles visent clairement quelqu'un.</REGLE>
|
||||
</DIRECTIVES>
|
||||
|
||||
${decisionPolicy}
|
||||
|
||||
<INDICES_SUPPLEMENTAIRES>
|
||||
${vulgarityHints}
|
||||
<REGLE_VULGARITES>Si la liste ci-dessus n'est pas vide, considère ces termes comme signaux forts de harcèlement verbal: flagged=true au minimum et score ajusté en conséquence.</REGLE_VULGARITES>
|
||||
</INDICES_SUPPLEMENTAIRES>
|
||||
`.trim();
|
||||
|
||||
const { output } = await genkit({
|
||||
plugins: [googleAI({ apiKey: GEMINI_API_KEY })],
|
||||
model: googleAI.model("gemini-2.5-flash"),
|
||||
}).generate({
|
||||
system,
|
||||
prompt: userPrompt,
|
||||
output: { schema: moderationSchema },
|
||||
});
|
||||
|
||||
if (output && detectedVulgarities.length) {
|
||||
output.flagged = true;
|
||||
const bumpScore = detectedVulgarities.length >= 3 ? 0.65 : 0.5;
|
||||
const baseScore = Number.isFinite(output.score) ? output.score : 0;
|
||||
output.score = Math.min(1, Math.max(baseScore, bumpScore));
|
||||
|
||||
const reasons = Array.isArray(output.reasons) ? output.reasons : [];
|
||||
const reason = `Vulgarités détectées (${detectedVulgarities.join(", ")})`;
|
||||
if (!reasons.includes(reason)) reasons.push(reason);
|
||||
output.reasons = reasons;
|
||||
|
||||
const excerpts = Array.isArray(output.excerpts) ? output.excerpts : [];
|
||||
const slots = Math.max(0, 10 - excerpts.length);
|
||||
if (slots > 0) {
|
||||
const severity = detectedVulgarities.length > 2 ? "high" : "medium";
|
||||
const newExcerpts = detectedVulgarities.slice(0, slots).map((term) => ({
|
||||
quote: term,
|
||||
category: "harassment",
|
||||
severity,
|
||||
}));
|
||||
output.excerpts = [...excerpts, ...newExcerpts];
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
exports.generateImageV2 = async (prompt, size = 1024, path = "") => {
|
||||
if (typeof prompt !== "string" || prompt.trim().length < 1) {
|
||||
throw new Error("Vous devez spécifier un prompt (string) non vide.");
|
||||
}
|
||||
console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`);
|
||||
const startedAt = Date.now();
|
||||
|
||||
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 token = require("crypto").randomUUID();
|
||||
|
||||
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 { output } = await ai.generate({
|
||||
model: googleAI.model(TEXT_MODEL_NAME),
|
||||
system,
|
||||
prompt: userPrompt,
|
||||
output: { schema: moderationSchema },
|
||||
config: {
|
||||
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
|
||||
safetySettings: [
|
||||
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" },
|
||||
{
|
||||
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
threshold: "BLOCK_NONE",
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
threshold: "BLOCK_NONE",
|
||||
},
|
||||
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const publicUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
||||
path,
|
||||
)}?alt=media&token=${token}`;
|
||||
if (!output) throw new Error("Échec de l'analyse de modération.");
|
||||
|
||||
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 = {},
|
||||
path = "",
|
||||
) => {
|
||||
const size = Number(options.size || 1024);
|
||||
console.log("🧩 [CombineCoverAndPicture] Start", { size });
|
||||
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("toDataUrl: échec de conversion en data URL", e?.message);
|
||||
// Correction de cohérence
|
||||
if (output.blocked) {
|
||||
output.flagged = true;
|
||||
if (output.score < 0.7) output.score = 0.85;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
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)",
|
||||
);
|
||||
return output;
|
||||
} finally {
|
||||
console.log(`⏱️ [analyseLyrics] Durée: ${Date.now() - startedAt}ms`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Génération d'image via Imagen 3
|
||||
*/
|
||||
exports.generateImageV2 = async (prompt, size = 1024, path = "") => {
|
||||
const ai = getAiInstance();
|
||||
|
||||
if (typeof prompt !== "string" || prompt.trim().length < 1) {
|
||||
throw new Error("Prompt requis.");
|
||||
}
|
||||
|
||||
console.log(`🎨 [generateImageV2] Start (${IMAGE_MODEL_NAME})`);
|
||||
const startedAt = Date.now();
|
||||
|
||||
// Optimisation du prompt pour Imagen
|
||||
let enhancedPrompt = prompt.trim();
|
||||
if (!enhancedPrompt.toLowerCase().includes("high quality")) {
|
||||
enhancedPrompt += ", high quality, detailed, 4k";
|
||||
}
|
||||
// Aspect ratio 1:1 pour les pochettes
|
||||
enhancedPrompt = `${enhancedPrompt} --aspect-ratio 1:1`;
|
||||
|
||||
const maxAttempts = 3;
|
||||
let lastError = null;
|
||||
|
||||
try {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
console.log(`🔄 Tentative ${attempt}/${maxAttempts}`);
|
||||
|
||||
const response = await ai.generate({
|
||||
model: googleAI.model(IMAGE_MODEL_NAME),
|
||||
prompt: enhancedPrompt,
|
||||
});
|
||||
|
||||
const media = response.media;
|
||||
|
||||
if (media && media.url) {
|
||||
console.log("✅ Image générée.");
|
||||
|
||||
// --- Sauvegarde dans Firebase Storage ---
|
||||
const dataUrl = String(media.url);
|
||||
const commaIdx = dataUrl.indexOf(",");
|
||||
const b64 =
|
||||
commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : dataUrl;
|
||||
const buffer = Buffer.from(b64, "base64");
|
||||
|
||||
const bucket = admin.storage().bucket();
|
||||
const token = require("crypto").randomUUID();
|
||||
const file = bucket.file(path);
|
||||
|
||||
await file.save(buffer, {
|
||||
resumable: false,
|
||||
metadata: {
|
||||
contentType: media.contentType || "image/png",
|
||||
metadata: { firebaseStorageDownloadTokens: token },
|
||||
},
|
||||
});
|
||||
|
||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`;
|
||||
}
|
||||
|
||||
throw new Error("Pas de média dans la réponse IA.");
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ Erreur tentative ${attempt}:`, err.message);
|
||||
lastError = err;
|
||||
if (attempt < maxAttempts) await setTimeout(2000 * attempt);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Échec final après ${maxAttempts} tentatives: ${lastError?.message}`,
|
||||
);
|
||||
} finally {
|
||||
console.log(
|
||||
`⏱️ [generateImageV2] Durée totale: ${Date.now() - startedAt}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
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 = `
|
||||
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 } },
|
||||
];
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
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 token = require("crypto").randomUUID();
|
||||
|
||||
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("✅ [CombineCoverAndPicture] Uploaded", {
|
||||
path,
|
||||
mimeType,
|
||||
urlLen: publicUrl.length,
|
||||
});
|
||||
return publicUrl;
|
||||
};
|
||||
|
||||
+101
-86
@@ -7,6 +7,7 @@ exports.generatePicturePrompt = (project = {}) => {
|
||||
artistName: artistNameRaw = "",
|
||||
} = project || {};
|
||||
|
||||
// --- 1. Nettoyage et Normalisation ---
|
||||
const sanitizeInline = (value = "") => {
|
||||
if (typeof value !== "string") return "";
|
||||
return value
|
||||
@@ -14,13 +15,6 @@ exports.generatePicturePrompt = (project = {}) => {
|
||||
.replace(/[<>]/g, "")
|
||||
.trim();
|
||||
};
|
||||
const normalizeForMatching = (value = "") => {
|
||||
if (typeof value !== "string") return "";
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
const titleForPrompt = sanitizeInline(title) || "Sans titre";
|
||||
const artistName =
|
||||
@@ -30,93 +24,114 @@ exports.generatePicturePrompt = (project = {}) => {
|
||||
const {
|
||||
genres = [],
|
||||
tempo = "",
|
||||
voice = "",
|
||||
instruments = [],
|
||||
mood = "",
|
||||
instruments = [],
|
||||
} = musicConfig || {};
|
||||
const userStyle = sanitizeInline(coverStyleRaw);
|
||||
|
||||
// Normalise les paroles en tableau de sections { type, lyrics }
|
||||
const lyricsSections = Array.isArray(lyricsRaw)
|
||||
? lyricsRaw
|
||||
: [
|
||||
lyricsRaw?.couplet
|
||||
? { type: "couplet", lyrics: lyricsRaw.couplet }
|
||||
: null,
|
||||
lyricsRaw?.refrain
|
||||
? { type: "refrain", lyrics: lyricsRaw.refrain }
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
// --- 2. Intelligence Visuelle (Mapping) ---
|
||||
|
||||
// Échantillon pour l'ambiance (courtes bribes pour inspirer la direction créative)
|
||||
const lyricsSample = (lyricsSections || [])
|
||||
.map((s) => (s?.lyrics || "").split("\n").slice(0, 2).join(" "))
|
||||
.filter(Boolean)
|
||||
.slice(0, 6)
|
||||
.join(" | ");
|
||||
// Détermination de l'énergie visuelle
|
||||
const isEnergetic =
|
||||
tempo &&
|
||||
/\b(rapid|fast|vite|agité|upbeat|energ|dance|rock|metal)\b/i.test(
|
||||
String(tempo),
|
||||
);
|
||||
const isDark =
|
||||
mood &&
|
||||
/\b(sombre|triste|dark|sad|mélancoli|nuit|night|eerie)\b/i.test(
|
||||
String(mood),
|
||||
);
|
||||
|
||||
// Etiquettes d'ambiance
|
||||
const tags = [];
|
||||
if (Array.isArray(genres) && genres.length)
|
||||
tags.push(`Genres: ${genres.join(", ")}`);
|
||||
if (tempo) tags.push(`Tempo: ${tempo}`);
|
||||
if (Array.isArray(instruments) && instruments.length)
|
||||
tags.push(`Instruments: ${instruments.join(", ")}`);
|
||||
if (voice) tags.push(`Vocal: ${voice}`);
|
||||
if (mood) tags.push(`Mood: ${mood}`);
|
||||
|
||||
// Guidance palette selon tempo (simple heuristique)
|
||||
const paletteHint =
|
||||
tempo && /\b(rapid|fast|vite|agité|upbeat|energ)/i.test(String(tempo))
|
||||
? "Palette vive et contrastée (magenta, cyan, jaune, bleu électrique)"
|
||||
: "Palette harmonieuse et douce (bleu nuit, violet, corail, or pâle)";
|
||||
|
||||
const coverStyleInput = String(coverStyleRaw || "").trim();
|
||||
const coverStyle = sanitizeInline(coverStyleInput);
|
||||
const normalizedStyle = normalizeForMatching(coverStyle);
|
||||
let coverStyleGuidance = coverStyle;
|
||||
if (normalizedStyle.includes("realist")) {
|
||||
coverStyleGuidance =
|
||||
"Style réaliste photographique hyper détaillé, textures fidèles, lumière naturelle, profondeur de champ crédible, aucun effet cartoon, fantastique ou peint";
|
||||
// Construction de la Palette & Lumière
|
||||
let visualAtmosphere = "";
|
||||
if (isDark) {
|
||||
visualAtmosphere =
|
||||
"Atmosphere: Cinematic dark lighting, moody shadows, deep contrast. Palette: Midnight blue, obsidian, deep purple, metallic accents.";
|
||||
} else if (isEnergetic) {
|
||||
visualAtmosphere =
|
||||
"Atmosphere: Dynamic lighting, high energy, vibrant saturation. Palette: Neon colors, electric blue, magenta, bright yellow, high contrast.";
|
||||
} else {
|
||||
visualAtmosphere =
|
||||
"Atmosphere: Soft natural lighting, harmonious and ethereal. Palette: Pastel tones, warm gold, soft coral, balanced and elegant colors.";
|
||||
}
|
||||
const hasTags = tags.length > 0;
|
||||
const tagsLine = hasTags
|
||||
? `${tags.join(
|
||||
" ; ",
|
||||
)}. Utilise ces indications pour guider uniquement la palette, l'énergie et l'émotion, sans représenter littéralement les instruments, objets ou mots cités.`
|
||||
: "Ambiance sonore non précisée : crée une atmosphère abstraite sans instrument ni objet musical apparent.";
|
||||
const lyricsLine = lyricsSample || "Pas d'extraits fournis";
|
||||
const styleLine = coverStyleGuidance
|
||||
? `${coverStyleGuidance}. ${paletteHint}`
|
||||
: `${paletteHint}. Style libre, artistique et lumineux.`;
|
||||
const typographyLine = hasArtistName
|
||||
? `Reproduis strictement le titre de la chanson ${titleForPrompt} sans modification, sans traduction et sans ajout ou suppression de caractères. Ajoute également le nom de l'artiste ${artistName} de manière artistique, parfaitement lisible et hiérarchisée, en harmonie avec le style abstrait et lumineux de la pochette.`
|
||||
: `Reproduis strictement le titre de la chanson ${titleForPrompt} sans modification, sans traduction et sans ajout ou suppression de caractères. Intègre-le de manière artistique et parfaitement lisible, en harmonie avec le style abstrait et lumineux de la pochette.`;
|
||||
const artistLine = hasArtistName
|
||||
? ` <ARTISTE>${artistName}</ARTISTE>\n`
|
||||
: "";
|
||||
|
||||
const prompt = `<BRIEF>
|
||||
<OBJECTIF>Créer une pochette d'album en adéquation avec les paroles de la musique et qui respecte le style ${coverStyle}</OBJECTIF>
|
||||
<TITRE>${titleForPrompt}</TITRE>
|
||||
${artistLine} <STYLE_MUSICAL>${tagsLine}</STYLE_MUSICAL>
|
||||
<EXTRAITS_PAROLES>${lyricsLine}</EXTRAITS_PAROLES>
|
||||
</BRIEF>
|
||||
// Définition du Style de Rendu (Si l'utilisateur est vague, on renforce)
|
||||
let renderingStyle = userStyle
|
||||
? `Art Style: ${userStyle}`
|
||||
: "Art Style: Digital Art, Mixed Media";
|
||||
|
||||
<CONTEXTES_VISUELS>
|
||||
<FORMAT>Image carrée 1024x1024 pixels, résolution haute.</FORMAT>
|
||||
<STYLE>${styleLine}</STYLE>
|
||||
<COMPOSITION>La composition doit être dynamique et remplir toute la surface, sans laisser de bordures ni de zones vides.</COMPOSITION>
|
||||
<TYPOGRAPHIE>${typographyLine}</TYPOGRAPHIE>
|
||||
<ORTHOGRAPHE>Aucune faute d'orthographe n'est tolérée. Respecte la casse et l'orthographe exactes du titre fourni.</ORTHOGRAPHE>
|
||||
</CONTEXTES_VISUELS>
|
||||
if (
|
||||
userStyle.toLowerCase().includes("realist") ||
|
||||
userStyle.toLowerCase().includes("photo")
|
||||
) {
|
||||
renderingStyle +=
|
||||
", 8k resolution, highly detailed texture, photorealistic, cinematic depth of field, raytracing.";
|
||||
} else if (
|
||||
userStyle.toLowerCase().includes("illu") ||
|
||||
userStyle.toLowerCase().includes("dessin")
|
||||
) {
|
||||
renderingStyle +=
|
||||
", vector art, clean lines, professional illustration, flat design or detailed painting.";
|
||||
} else {
|
||||
// Style par défaut "Album Cover" qui marche bien
|
||||
renderingStyle +=
|
||||
", abstract surrealism, conceptual album art, high fidelity, masterpiece.";
|
||||
}
|
||||
|
||||
<CONTRAINTES>
|
||||
<INTERDIT>Personnes, visages ou silhouettes reconnaissables.</INTERDIT>
|
||||
<INTERDIT>Fonds blancs ou bordures délimitant l'image.</INTERDIT>
|
||||
<INTERDIT>Texte illisible ou trop petit.</INTERDIT>
|
||||
<INTERDIT>Représenter des instruments ou objets évoqués dans STYLE_MUSICAL : ces informations servent uniquement au contexte audio.</INTERDIT>
|
||||
</CONTRAINTES>
|
||||
`.trim();
|
||||
// --- 3. Extraction de l'Inspiration (Lyrics) ---
|
||||
|
||||
return prompt;
|
||||
// On cherche le REFRAIN en priorité pour l'image, car c'est le cœur visuel
|
||||
const sections = Array.isArray(lyricsRaw) ? lyricsRaw : [];
|
||||
const chorus = sections.find(
|
||||
(s) => s.type === "refrain" || s.type === "chorus",
|
||||
);
|
||||
const verse = sections.find(
|
||||
(s) => s.type === "couplet" || s.type === "verse",
|
||||
);
|
||||
|
||||
// On prend 2 lignes max du refrain, ou du premier couplet
|
||||
const visualHook = (chorus?.lyrics || verse?.lyrics || "")
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 10) // On évite les lignes trop courtes
|
||||
.slice(0, 2)
|
||||
.join(". ");
|
||||
|
||||
const imageryPrompt = visualHook
|
||||
? `Visual Inspiration: An artistic interpretation of these lyrics: "${visualHook}".`
|
||||
: "Visual Inspiration: Abstract visual representation of the song's mood.";
|
||||
|
||||
// --- 4. Construction du Prompt Final (Structure Optimisée Imagen 3) ---
|
||||
|
||||
const promptParts = [
|
||||
// Rôle
|
||||
"Design a professional, high-quality music album cover.",
|
||||
|
||||
// 1. Le Texte (Crucial pour Imagen 3 - Doit être au début ou très clair)
|
||||
`**Typography & Text:**`,
|
||||
`The song title "${titleForPrompt}" must be the CENTERPIECE. Write it in a distinct, artistic font that matches the mood.`,
|
||||
hasArtistName
|
||||
? `The artist name "${artistName}" must appear smaller, elegant, and legible near the bottom or top.`
|
||||
: "",
|
||||
"Ensure perfect spelling. The text should be integrated into the artwork (e.g., metallic texture, neon glow, or bold cut-out), not just pasted on top.",
|
||||
|
||||
// 2. Le Visuel
|
||||
`**Visuals:**`,
|
||||
renderingStyle,
|
||||
imageryPrompt,
|
||||
`Subject: A central visual element that represents the song. ${isEnergetic ? "Dynamic composition." : "Balanced, centered composition."}`,
|
||||
|
||||
// 3. L'Atmosphère (Context from music config)
|
||||
`**Mood & Color:**`,
|
||||
visualAtmosphere,
|
||||
genres.length > 0 ? `Musical Vibe Reference: ${genres.join(", ")}.` : "",
|
||||
|
||||
// 4. Contraintes Négatives (Phrasées positivement pour l'IA)
|
||||
"**Constraints:**",
|
||||
"Use a square 1:1 aspect ratio.",
|
||||
"Do NOT depict literal musical instruments (like guitars or microphones) unless they are part of a surreal abstract composition.",
|
||||
"No blurry text. No messy borders. No watermarks.",
|
||||
];
|
||||
|
||||
return promptParts.filter(Boolean).join("\n\n");
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user