clear last tickets

This commit is contained in:
Thomas Demirdjian
2025-11-26 15:33:22 +01:00
parent ee9cc5dccd
commit d7b14bd873
25 changed files with 1692 additions and 1967 deletions
+231 -241
View File
@@ -2,25 +2,46 @@ const { onDocumentCreated } = require("firebase-functions/v2/firestore");
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const logger = require("firebase-functions/logger");
const { generateImageV2 } = require("../helpers/gemini");
const { generatePicturePrompt } = require("../helpers/prompts");
const path = require("path");
const fs = require("fs");
const axios = require("axios");
const sharp = require("sharp");
const crypto = require("crypto");
// Imports internes
const { generateImageV2 } = require("../helpers/gemini");
const { generatePicturePrompt } = require("../helpers/prompts");
const { ALERT_TYPE, refList } = require("../index");
const { sendNotification } = require("./notifications");
// Configuration
const bucket = admin.storage().bucket();
const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandLogo.png");
// Cache mémoire pour le buffer du logo (évite les I/O disque à chaque appel sur instance chaude)
let _cachedLogoBuffer = null;
/**
* Récupère le buffer du logo depuis le cache ou le disque
*/
const getLogoBuffer = async () => {
if (_cachedLogoBuffer) return _cachedLogoBuffer;
try {
_cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH);
return _cachedLogoBuffer;
} catch (error) {
logger.error("❌ [Cover] Impossible de lire le fichier logo", error);
throw new Error("Asset Logo manquant sur le serveur");
}
};
/**
* Utilitaires Strings
*/
const pickFirstNonEmpty = (...values) => {
for (const value of values) {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return "";
@@ -33,354 +54,323 @@ const combineNames = (...parts) =>
.join(" ")
.trim();
/**
* Résolution intelligente du nom d'artiste
*/
async function resolveArtistName(project = {}) {
// 1. Vérification directe sur le projet ou le snapshot "owner"
const owner = project?.owner || {};
const direct = pickFirstNonEmpty(
project?.artistName,
project?.userName,
owner?.artistName,
owner?.userName,
owner?.displayName
owner?.displayName,
);
if (direct) return direct;
// 2. Fallback : Récupération depuis la collection Users
const userId =
typeof project?.userId === "string" && project.userId.trim()
? project.userId.trim()
: "";
typeof project?.userId === "string" ? project.userId.trim() : "";
if (!userId) return "";
try {
const userSnapshot = await refList.users.doc(userId).get();
if (!userSnapshot?.exists) return "";
const userData = userSnapshot.data() || {};
const fullName = combineNames(userData.firstName, userData.lastName);
return (
pickFirstNonEmpty(
userData.artistName,
userData.userName,
userData.displayName,
fullName
combineNames(userData.firstName, userData.lastName),
) || ""
);
} catch (error) {
logger.warn("⚠️ [Cover] Unable to resolve artist name", {
projectId: project?.id || null,
userId,
error: error?.message || String(error),
logger.warn("⚠️ [Cover] Artist name resolution failed", {
projectId: project?.id,
error: error.message,
});
return "";
}
}
/**
* Ajoute le logo en filigrane sur l'image générée
*/
async function buildCoverWithLogo(backgroundUrl, targetPath) {
logger.info("🖼️ [Cover] Adding logo to generated background");
const [{ data: backgroundBuffer }, logoBuffer] = await Promise.all([
axios.get(backgroundUrl, { responseType: "arraybuffer" }),
fs.promises.readFile(LOGO_PATH),
]);
logger.info("🖼️ [Cover] Compositing logo...");
const baseImage = sharp(backgroundBuffer);
const { width = 1024, height = 1024 } = await baseImage.metadata();
try {
// Téléchargement background + Lecture Logo (parallèle)
const [bgResponse, logoBuffer] = await Promise.all([
axios.get(backgroundUrl, { responseType: "arraybuffer" }),
getLogoBuffer(),
]);
const desiredWidth = Math.round(width * 0.32);
const margin = Math.round(width * 0.04);
const baseImage = sharp(bgResponse.data);
const metadata = await baseImage.metadata();
const width = metadata.width || 1024;
const height = metadata.height || 1024;
const { data: resizedLogo, info: logoInfo } = await sharp(logoBuffer)
.resize({ width: desiredWidth })
.png()
.toBuffer({ resolveWithObject: true });
// Calcul dynamique de la taille du logo (32% de la largeur)
const desiredWidth = Math.round(width * 0.32);
const margin = Math.round(width * 0.04);
const left = Math.max(width - logoInfo.width - margin, 0);
const top = Math.max(height - logoInfo.height - margin, 0);
// Redimensionnement du logo
const resizedLogo = await sharp(logoBuffer)
.resize({ width: desiredWidth })
.png()
.toBuffer();
const stampedBuffer = await baseImage
.ensureAlpha()
.composite([
{
input: resizedLogo,
left,
top,
blend: "over",
// Positionnement (Bas Droite)
const logoMetadata = await sharp(resizedLogo).metadata();
const left = Math.max(width - logoMetadata.width - margin, 0);
const top = Math.max(height - logoMetadata.height - margin, 0);
// Composition
const stampedBuffer = await baseImage
.ensureAlpha()
.composite([{ input: resizedLogo, left, top, blend: "over" }])
.png()
.toBuffer();
// Upload vers Storage
const token = crypto.randomUUID();
const file = bucket.file(targetPath);
await file.save(stampedBuffer, {
resumable: false,
metadata: {
contentType: "image/png",
cacheControl: "public, max-age=31536000",
metadata: { firebaseStorageDownloadTokens: token },
},
])
.png()
.toBuffer();
});
const token = require("crypto").randomUUID();
await bucket.file(targetPath).save(stampedBuffer, {
resumable: false,
metadata: {
contentType: "image/png",
cacheControl: "public, max-age=31536000",
metadata: { firebaseStorageDownloadTokens: token },
},
});
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
targetPath
)}?alt=media&token=${token}`;
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`;
} catch (error) {
logger.error("❌ [Cover] buildCoverWithLogo failed", error);
// En cas d'échec du logo, on renvoie l'URL originale pour ne pas tout perdre
return backgroundUrl;
}
}
// Shared core for generating and saving the cover, and updating the project
/**
* Cœur de la logique de génération
*/
async function performCoverGeneration(project) {
const t0 = Date.now();
const artistName = await resolveArtistName(project);
// Génération du Prompt optimisé
const prompt = generatePicturePrompt({
...project,
artistName,
});
// Utiliser generateImageV2 qui renvoie directement l'URL publique
logger.info("🎨 [Cover] Calling model V2", {
logger.info("🎨 [Cover] Prompt generated", {
projectId: project.id,
promptPreview: String(prompt).slice(0, 160),
artistName,
promptPreview: prompt.slice(0, 100) + "...",
});
const baseTimestamp = Date.now();
const options = [];
const GENERATION_COUNT = 2; // Nombre de variantes simultanées
for (let index = 0; index < 2; index += 1) {
const uniqueSuffix = `${baseTimestamp}-${index}`;
const storageBasePath = `users/${project.userId}/projects/${project.id}`;
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`;
let generatedUrl = "";
try {
generatedUrl = await generateImageV2(prompt, 1024, generatedPath);
logger.info("🎨 [Cover] Candidate generated", {
projectId: project.id,
candidateIndex: index,
});
} catch (e) {
logger.error("❌ [Cover] generateImageV2 failed", {
projectId: project.id,
candidateIndex: index,
error: e?.message || String(e),
});
throw e;
}
if (!generatedUrl) {
throw new Error("Génération d'image échouée (URL vide)");
}
let finalCoverUrl = generatedUrl;
try {
// Création d'un tableau de promesses pour exécuter les tâches en parallèle
const generationPromises = Array.from({ length: GENERATION_COUNT }).map(
async (_, index) => {
const uniqueSuffix = `${baseTimestamp}-${index}`;
const storageBasePath = `users/${project.userId}/projects/${project.id}`;
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`;
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`;
finalCoverUrl = await buildCoverWithLogo(generatedUrl, stampedPath);
} catch (e) {
logger.error("❌ [Cover] Logo overlay failed", {
projectId: project.id,
candidateIndex: index,
error: e?.message || String(e),
});
}
options.push({
id: uniqueSuffix,
generatedUrl,
finalUrl: finalCoverUrl,
});
try {
// 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres
const generatedUrl = await generateImageV2(prompt, 1024, generatedPath);
if (!generatedUrl) throw new Error("URL vide retournée par l'IA");
// 2. Ajout du Logo
const finalCoverUrl = await buildCoverWithLogo(
generatedUrl,
stampedPath,
);
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`);
return {
id: uniqueSuffix,
generatedUrl,
finalUrl: finalCoverUrl,
promptUsed: prompt,
};
} catch (e) {
// On catch l'erreur ICI pour ne pas faire échouer tout le Promise.all
logger.error(`❌ [Cover] Option ${index + 1} failed`, {
error: e.message,
});
return null; // On retourne null pour filtrer plus tard
}
},
);
// Attente de la résolution de toutes les générations
const results = await Promise.all(generationPromises);
// On garde uniquement les tentatives réussies (non null)
const options = results.filter(Boolean);
if (options.length === 0) {
throw new Error("Toutes les tentatives de génération ont échoué.");
}
// Sauvegarde dans Firestore
const [firstOption] = options;
await refList.projects.doc(project.id).set(
{
cover: {
generatedBackground: firstOption?.generatedUrl || null,
result: firstOption?.finalUrl || null,
selectedOptionId: firstOption?.id || null,
options,
generatedBackground: firstOption.generatedUrl,
result: firstOption.finalUrl,
selectedOptionId: firstOption.id,
options, // Sauvegarde de toutes les variantes réussies
},
coverStatus: "GENERATED",
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
{ merge: true },
);
logger.info(" [Cover] Saved", {
logger.info("🏁 [Cover] Process complete", {
projectId: project.id,
optionsCount: options.length,
ms: Date.now() - t0,
successCount: options.length,
duration: Date.now() - t0,
});
return firstOption?.finalUrl || null;
return firstOption.finalUrl;
}
// Firestore trigger: création d'une tâche de génération de cover
/**
* TRIGGER FIRESTORE
* Déclenché à la création d'un document dans 'tasks/{taskId}'
*/
exports.onTaskCreateGenerateCover = onDocumentCreated(
{
timeoutSeconds: 540,
timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent)
memory: "1GiB",
document: "tasks/{taskId}",
},
async (event) => {
const data = event?.data?.data() || {};
const type = data?.type || "";
const projectId = data?.projectId || null;
let coverUrl = null;
let project = null;
try {
if (!projectId) {
throw new Error("projectId manquant dans la task");
}
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,
});
const data = event.data?.data() || {};
const { type, projectId } = data;
const taskId = event.params.taskId;
if (!projectId) return; // Ignorer les tâches mal formées
if (!["cover", "combine"].includes(type)) return; // Ignorer les autres types de tâches
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId });
try {
// 1. Validation & Setup
if (type === "combine") {
logger.warn("⛔ [Task] Combine task ignored (feature disabled)", {
projectId,
taskId: event?.params?.taskId,
});
// Feature désactivée pour le moment
await event.data.ref.update({
status: "CANCELLED",
error:
"La personnalisation de la pochette avec une photo n'est plus disponible.",
error: "La personnalisation photo n'est plus disponible.",
updatedAt: FieldValue.serverTimestamp(),
});
return;
}
// Mise à jour statut projet
await refList.projects.doc(projectId).update({
coverStatus: "GENERATING",
updatedAt: FieldValue.serverTimestamp(),
});
project = (await refList.projects.doc(projectId).get())?.data() || null;
if (!project) {
throw new Error("Projet non trouvé");
}
project.id = projectId;
// 2. Chargement Projet
const projectSnap = await refList.projects.doc(projectId).get();
if (!projectSnap.exists) throw new Error("Projet introuvable");
const existingOptionsCount = Array.isArray(project?.cover?.options)
? project.cover.options.length
: 0;
const project = { id: projectId, ...projectSnap.data() };
logger.info("🔎 [Task] Project loaded", {
projectId,
hasGeneratedBackground: !!project?.cover?.generatedBackground,
hasUserForeground: !!project?.cover?.userForeground,
existingOptionsCount,
});
if (existingOptionsCount > 0) {
logger.warn("⛔ [Task] Cover already generated, skipping", {
projectId,
existingOptionsCount,
});
await refList.projects.doc(projectId).update({
coverStatus: "GENERATED",
updatedAt: FieldValue.serverTimestamp(),
});
// Idempotency check (si déjà généré, on ne refait pas)
if (
Array.isArray(project?.cover?.options) &&
project.cover.options.length > 0
) {
logger.warn("⚠️ [Task] Cover already exists. Skipping.");
await refList.projects
.doc(projectId)
.update({ coverStatus: "GENERATED" });
await event.data.ref.update({
status: "CANCELLED",
error: "Cover already generated",
updatedAt: FieldValue.serverTimestamp(),
status: "DONE",
info: "Already generated",
});
return;
}
if (type === "cover") {
logger.info("🎨 [Task] Start cover generation", { projectId });
coverUrl = await performCoverGeneration(project);
logger.info("✅ [Task] Cover generated", { projectId });
}
// 3. Exécution Génération
const coverUrl = await performCoverGeneration(project);
// 4. Finalisation Tâche
await event.data.ref.update({
status: "DONE",
coverUrl,
updatedAt: FieldValue.serverTimestamp(),
});
logger.info("📦 [Task] Marked DONE", {
taskId: event?.params?.taskId,
projectId,
});
if (
project?.userId &&
typeof project.userId === "string" &&
project.userId.trim()
) {
const receiverId = project.userId.trim();
const projectTitle =
typeof project?.title === "string" && project.title.trim()
? project.title.trim()
: "ton projet";
const message = `Ta nouvelle pochette pour "${projectTitle}" est prête.`;
try {
await sendNotification({
sender: "SYSTEM",
receiver: receiverId,
receiverCollection: "users",
title: "Pochette générée",
message,
data: {
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
projectId,
projectTitle,
coverUrl,
},
});
} catch (notifError) {
logger.error("❌ [Cover] Failed to send success notification", {
// 5. Notification
if (project.userId) {
const projectTitle = project.title || "ton projet";
await sendNotification({
sender: "SYSTEM",
receiver: project.userId,
receiverCollection: "users",
title: "Pochette prête !",
message: `La pochette pour "${projectTitle}" a été générée avec succès.`,
data: {
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
projectId,
error: notifError?.message || String(notifError),
});
}
projectTitle,
coverUrl,
},
}).catch((err) => logger.warn("Notification failed", err));
}
return;
} catch (error) {
logger.error("❌ [Task] Cover generation error", {
taskId: event?.params?.taskId,
projectId,
type,
error: error?.message || String(error),
});
logger.error(`🔥 [Task ${taskId}] Failed`, error);
// Mise à jour erreur Tâche
await event.data.ref.set(
{ status: "ERROR", error: error?.message || "Erreur" },
{ merge: true }
{ status: "ERROR", error: error.message },
{ merge: true },
);
// Mise à jour erreur Projet
await refList.projects.doc(projectId).update({
coverStatus: "ERROR",
updatedAt: FieldValue.serverTimestamp(),
});
if (
project?.userId &&
typeof project.userId === "string" &&
project.userId.trim()
) {
const receiverId = project.userId.trim();
const projectTitle =
typeof project?.title === "string" && project.title.trim()
? project.title.trim()
: "ton projet";
const message = `La génération de la pochette pour "${projectTitle}" a échoué.`;
try {
await sendNotification({
sender: "SYSTEM",
receiver: receiverId,
receiverCollection: "users",
title: "Pochette indisponible",
message,
data: {
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
projectId,
projectTitle,
error: error?.message || String(error),
},
});
} catch (notifError) {
logger.error("❌ [Cover] Failed to send error notification", {
// Notification Erreur
const projectData = (await refList.projects.doc(projectId).get()).data();
if (projectData?.userId) {
await sendNotification({
sender: "SYSTEM",
receiver: projectData.userId,
receiverCollection: "users",
title: "Échec pochette",
message: `Impossible de générer la pochette pour "${projectData.title || "ton projet"}".`,
data: {
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
projectId,
error: notifError?.message || String(notifError),
});
}
error: error.message,
},
}).catch(() => {});
}
}
}
},
);
+117 -210
View File
@@ -10,6 +10,8 @@ const axios = require("axios");
const { FieldValue } = require("firebase-admin/firestore");
const { refList } = require("../index");
// --- CONSTANTES DE STRUCTURE ---
// On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur
const STRUCTURE_PROMPT_LABELS = {
couplet: "couplet",
refrain: "refrain",
@@ -60,6 +62,8 @@ const STRUCTURE_ALIASES = {
soft_transition: "transition_douce",
};
// --- UTILITAIRES ---
const normalizeStructureValue = (value) => {
const raw = String(value || "")
.trim()
@@ -68,47 +72,16 @@ const normalizeStructureValue = (value) => {
if (STRUCTURE_PROMPT_LABELS[raw]) return raw;
if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw];
const sanitized = raw.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
if (STRUCTURE_PROMPT_LABELS[sanitized]) return sanitized;
if (STRUCTURE_ALIASES[sanitized]) return STRUCTURE_ALIASES[sanitized];
return sanitized;
return STRUCTURE_PROMPT_LABELS[sanitized]
? sanitized
: STRUCTURE_ALIASES[sanitized] || sanitized;
};
// Nettoyage simplifié : on laisse l'IA gérer la logique musicale plutôt que de supprimer brutalement.
// On s'assure juste que les clés sont propres.
const sanitizeStructureEntries = (structure = []) => {
if (!Array.isArray(structure)) return [];
const output = [];
structure.forEach((entry) => {
const normalized = normalizeStructureValue(entry);
if (!normalized) return;
if (normalized === "short_intro" || normalized === "long_intro") {
const existingIndex = output.findIndex(
(item) => item === "short_intro" || item === "long_intro",
);
if (existingIndex !== -1) output.splice(existingIndex, 1);
output.push(normalized);
return;
}
if (normalized === "pre_refrain_instrumental") {
output.push("pre_refrain_instrumental");
return;
}
if (
normalized === "final_apogee" ||
normalized === "arret_net" ||
normalized === "fade_out" ||
normalized === "transition_douce"
) {
const outroIndex = output.findIndex((item) =>
["final_apogee", "arret_net", "fade_out", "transition_douce"].includes(
item,
),
);
if (outroIndex !== -1) {
output.splice(outroIndex, 1);
}
}
output.push(normalized);
});
return output;
return structure.map(normalizeStructureValue).filter(Boolean);
};
const mapStructureToPrompt = (structure = []) =>
@@ -116,16 +89,7 @@ const mapStructureToPrompt = (structure = []) =>
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry,
);
const TOXIC_KEYWORDS = [
/\bnazis?\b/i,
/\bnazisme\b/i,
/\bnéo[- ]?nazis?\b/i,
/\banti[- ]?s[eé]mites?\b/i,
/\bantis[eé]mitisme\b/i,
/\bracis(tes?|me)\b/i,
/\bwhite\s+supremac(?:y|istes?|ism)\b/i,
/\bku\s*klux\s*klan\b/i,
];
// --- MODERATION ---
const buildModerationBrief = ({
objective,
@@ -144,34 +108,13 @@ const buildModerationBrief = ({
`<AUDIENCE>${audience || ""}</AUDIENCE>`,
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}</RIMES>`,
];
const promptStructure = mapStructureToPrompt(structure);
if (promptStructure.length) {
if (promptStructure.length)
sections.push(`<STRUCTURE>${promptStructure.join(" | ")}</STRUCTURE>`);
}
return `<BRIEF_UTILISATEUR>\n${sections.join("\n")}\n</BRIEF_UTILISATEUR>`;
};
const moderationIndicatesBlock = (moderation = {}, fallbackText = "") => {
if (!moderation || typeof moderation !== "object") return false;
if (moderation.blocked === true) return true;
const excerpts = Array.isArray(moderation.excerpts)
? moderation.excerpts
: [];
const reasons = Array.isArray(moderation.reasons) ? moderation.reasons : [];
const keywordDetected = (value) =>
typeof value === "string" && TOXIC_KEYWORDS.some((rx) => rx.test(value));
if (excerpts.some((item) => keywordDetected(item?.quote))) return true;
if (reasons.some((item) => keywordDetected(item))) return true;
if (keywordDetected(fallbackText)) return true;
return false;
};
// --- GENERATION DE PAROLES (MAIN) ---
exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
try {
@@ -185,6 +128,7 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
rhymes = "",
} = data;
// 1. Préparation Structure
const sanitizedStructure = sanitizeStructureEntries(rawStructure);
const promptStructure = sanitizedStructure.length
? sanitizedStructure.map(
@@ -192,6 +136,8 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
)
: [];
// 2. Modération "Pro" (Via Gemini 1.5 Pro)
// On supprime les regex manuelles obsolètes.
const moderationPayload = {
objective,
context,
@@ -201,59 +147,62 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
structure: promptStructure,
rhymes,
};
const moderationInput = buildModerationBrief(moderationPayload);
try {
const moderation = await analyseLyrics({
title: "Brief utilisateur",
title: "Brief utilisateur (Pré-génération)",
lyrics: moderationInput,
});
if (moderationIndicatesBlock(moderation, moderationInput)) {
console.warn("generateLyrics blocked by moderation", {
reasons: moderation?.reasons,
excerpts: moderation?.excerpts,
// Si Gemini dit "Blocked", on bloque. C'est la seule autorité.
if (moderation?.blocked === true) {
console.warn("⛔ generateLyrics blocked by Gemini Pro", {
reasons: moderation.reasons,
});
const summary = Array.isArray(moderation?.reasons)
const summary = Array.isArray(moderation.reasons)
? moderation.reasons.slice(0, 3).join(", ")
: "contenu sensible";
: "Contenu non conforme";
throw new HttpsError(
"invalid-argument",
`Certaines informations saisies contiennent des termes interdits (${summary}). Merci de reformuler pour poursuivre la création des paroles.`,
{ moderation },
`Demande refusée par la modération : ${summary}.`,
);
}
} catch (moderationError) {
if (moderationError instanceof HttpsError) {
throw moderationError;
}
console.error("generateLyrics moderation failure", moderationError);
if (moderationError instanceof HttpsError) throw moderationError;
console.error("⚠️ Moderation check error (fail open)", moderationError);
// On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique.
// Ici je fail closed par sécurité pour une app publique.
throw new HttpsError(
"internal",
"Impossible de vérifier le brief pour le moment. Merci de réessayer dans quelques instants.",
"Vérification de sécurité indisponible.",
);
}
console.log("Function generate lyrics start with data", data);
console.log("🎵 generateLyrics: Start generation", { style, emotion });
// 3. Le Prompt "Hit Maker"
const system = `
Tu es un parolier expert en chanson française moderne et populaire.
Analyse attentivement les informations placées entre balises XML-like.
Génère un titre accrocheur et mémorable en plus des paroles, tout en respectant strictement la structure demandée.
Retourne uniquement un JSON conforme au schéma fourni, sans ajouter d'autres textes.
Tu es le meilleur parolier musical actuel, expert en "Songwriting" pour les IA génératives audio (Suno/Udio).
Ta spécialité est la **Prosodie** (le rythme naturel des mots) et l'impact émotionnel.
TES RÈGLES D'OR :
1. **MÉTRIQUE & RYTHME** : Tes vers doivent être "chantables". Compte les syllabes pour qu'elles collent au style musical demandé. Évite les phrases trop longues ou imprononçables rapidement.
2. **RIMES RICHES** : Évite les rimes faciles (amour/toujours). Cherche des assonances et des rimes internes.
3. **SHOW, DON'T TELL** : N'écris pas "Je suis triste", écris "La pluie brouille mes carreaux". Utilise des détails sensoriels.
4. **STRUCTURE STRICTE** : Respecte exactement l'ordre des sections demandées.
- **Couplet** : Raconte l'histoire, pose le décor.
- **Refrain** : Le "Hook". Doit être simple, répétitif, accrocheur et résumer l'émotion principale.
- **Pont** : Change de rythme ou de perspective, crée une tension avant le dernier refrain.
5. **VOCABULAIRE** : Adapte le niveau de langue au style (Argot pour le Rap, Soutenu pour la Chanson Française, Simple pour la Pop).
Retourne UNIQUEMENT un JSON valide.
`.trim();
const structureTags = (
Array.isArray(promptStructure) ? promptStructure : []
)
.map((part, index) => {
const content = part || "";
return ` <SECTION ordre="${index + 1}">${content}</SECTION>`;
})
.map((part, index) => ` <SECTION ordre="${index + 1}">${part}</SECTION>`)
.join("\n");
const fallbackStructureTags = [
@@ -261,40 +210,29 @@ Retourne uniquement un JSON conforme au schéma fourni, sans ajouter d'autres te
' <SECTION ordre="2">refrain</SECTION>',
].join("\n");
// Construction du prompt utilisateur avec des balises explicites
const prompt = `
<BRIEF>
<OBJECTIF>${objective || ""}</OBJECTIF>
<CONTEXTE>${context || ""}</CONTEXTE>
<EMOTION>${emotion || ""}</EMOTION>
<STYLE_MUSICAL>${style || ""}</STYLE_MUSICAL>
<AUDIENCE>${audience || ""}</AUDIENCE>
<SCHEMA_DE_RIMES>${rhymes || ""}</SCHEMA_DE_RIMES>
</BRIEF>
<BRIEF_CREATIF>
<OBJECTIF>${objective || "Créer une chanson mémorable"}</OBJECTIF>
<CONTEXTE>${context || "Libre interprétation"}</CONTEXTE>
<EMOTION_DOMINANTE>${emotion || "Intense"}</EMOTION_DOMINANTE>
<STYLE_MUSICAL>${style || "Pop Moderne"}</STYLE_MUSICAL>
<CIBLE>${audience || "Tout public"}</CIBLE>
<TYPE_DE_RIMES>${rhymes || "Rimes croisées et riches"}</TYPE_DE_RIMES>
</BRIEF_CREATIF>
<STRUCTURE_DEMANDEE>
<STRUCTURE_IMPOSEE>
${structureTags || fallbackStructureTags}
</STRUCTURE_DEMANDEE>
</STRUCTURE_IMPOSEE>
<CONTRAINTES>
<TITRE>
Génère un titre court, mémorable et aligné avec l'esprit de la musique.
</TITRE>
<PAROLES>
Pour chaque section demandée, rédige entre 4 et 16 lignes expressives.
Respecte strictement l'ordre et le type de chaque section (couplet, refrain, etc.).
</PAROLES>
<STYLE>
Adapte le vocabulaire et le ton aux informations fournies dans le brief et la structure.
</STYLE>
<FORMAT>
Retourne uniquement un objet JSON valide selon le schéma.
Utilise les retours à la ligne (\n) pour séparer les phrases afin de conserver le découpage.
</FORMAT>
</CONTRAINTES>
<CONSIGNES_GENERATION>
1. Génère un **Titre** percutant (moins de 6 mots).
2. Pour chaque section <SECTION>, écris le contenu adapté :
- Si c'est "Instrumental" ou "Solo" : laisse le champ 'lyrics' vide ou mets une brève indication d'ambiance entre parenthèses ex: "(Solo de guitare déchirant)".
- Si c'est "Couplet/Refrain" : Écris 4 à 12 vers.
3. **IMPORTANT** : Le style est "${style}". Assure-toi que le vocabulaire et le rythme collent parfaitement à ce genre.
</CONSIGNES_GENERATION>
`.trim();
// Définition du schéma zod/genkit pour la validation du retour
const lyricsSchema = z.object({
title: z.string().describe("Titre de la chanson, court et accrocheur"),
lyrics: z
@@ -303,48 +241,51 @@ ${structureTags || fallbackStructureTags}
type: z
.string()
.describe(
"Type de section : respecter exactement le nom de la section demandé dans la structure",
"Type de section (copier exactement la demande structure)",
),
lyrics: z
.string()
.describe(
"Paroles de la section, chaque ligne séparée par un retour à la ligne",
"Les paroles. Pour les sections instrumentales, laisser vide ou décrire l'ambiance.",
),
}),
)
.describe(
"Paroles de la chanson sous forme de tableau de sections structurées.",
),
.describe("La structure complète de la chanson"),
lyricsDescription: z
.string()
.describe("Courte déscription qui résume les paroles de la musique"),
success: z.boolean().describe("Indique si la génération a réussi"),
.describe(
"Un pitch de 2 phrases décrivant l'ambiance et le thème de la chanson pour Suno.",
),
success: z.boolean(),
});
// Appel Gemini avec le nouveau modèle Pro configuré dans helpers/gemini
return await generateAI({
system,
prompt,
schema: lyricsSchema,
});
} catch (e) {
console.log(e);
console.error("❌ generateLyrics Error:", e);
// Remontée d'erreur propre
if (e instanceof HttpsError) throw e;
throw new HttpsError(
"internal",
"Erreur lors de la génération des paroles.",
);
}
});
// --- ANALYSE TOXICITÉ (EXISTANTE, NETTOYÉE) ---
// Cette fonction reste utile pour l'analyse POST-création ou pour l'interface UI
const severityScore = (severity = "") => {
const normalized = String(severity || "").toLowerCase();
switch (normalized) {
case "critical":
return 4;
case "high":
return 3;
case "medium":
return 2;
case "low":
return 1;
default:
return 0;
}
if (normalized === "critical") return 4;
if (normalized === "high") return 3;
if (normalized === "medium") return 2;
if (normalized === "low") return 1;
return 0;
};
const softenModerationDecision = (rawResult = {}) => {
@@ -361,6 +302,7 @@ const softenModerationDecision = (rawResult = {}) => {
? Math.min(Math.max(result.score, 0), 1)
: 0;
// Détection de contexte narratif pour être plus indulgent
const narrativeHint = reasons.some((reason) =>
/narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test(
reason || "",
@@ -369,9 +311,11 @@ const softenModerationDecision = (rawResult = {}) => {
const adjustments = [];
// Logique d'adoucissement : Si c'est "High" severity mais narratif, on peut parfois débloquer (selon ta politique).
// Ici on reste prudent sur le block, mais on adoucit le flag.
if (result.blocked) {
const shouldUnblockLowSeverity = highestSeverity <= 1 && score < 0.65;
if (shouldUnblockLowSeverity) {
// Débloque uniquement les erreurs manifestes (score bas mais blocked true par erreur)
if (highestSeverity <= 1 && score < 0.65) {
result.blocked = false;
adjustments.push("auto-unblock-low-severity");
}
@@ -380,6 +324,7 @@ const softenModerationDecision = (rawResult = {}) => {
if (!result.blocked && result.flagged) {
const lowSignal = highestSeverity <= 1 && score < 0.4;
const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55;
if (lowSignal) {
result.flagged = false;
adjustments.push("drop-flag-low-signal");
@@ -392,18 +337,12 @@ const softenModerationDecision = (rawResult = {}) => {
return {
...result,
moderationAdjustments: adjustments,
moderationCalibration: {
highestSeverity,
score,
},
moderationCalibration: { highestSeverity, score },
};
};
// Vérifie la toxicité des paroles via Gemini et retourne un résultat structuré
// Nom Cloud Function: lyrics-analyseLyricsToxicity
exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
try {
// Validation d'entrée minimale
const requestSchema = z.object({
title: z.string().optional(),
lyrics: z
@@ -420,7 +359,7 @@ exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
}),
),
])
.describe("Paroles à analyser (formats supportés)"),
.describe("Paroles à analyser"),
});
const parsed = requestSchema.parse(data || {});
@@ -430,8 +369,7 @@ exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
});
const result = softenModerationDecision(aiResult);
// Prépare un message lisible pour le front
const highCats = Object.entries(result.categories || {})
const highCats = Object.entries(result.categories || {}) // Note: categories n'est pas dans le schema Zod actuel de Gemini, à vérifier si besoin
.filter(([, v]) => (typeof v === "number" ? v : 0) >= 0.5)
.map(([k]) => k)
.slice(0, 5);
@@ -441,60 +379,41 @@ exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
if (result.blocked) {
errorCode = "TOXIC_CONTENT_BLOCKED";
const list = highCats.length ? ` (${highCats.join(", ")})` : "";
message = `Publication bloquée: contenu interdit détecté${list}.`;
message = `Contenu bloqué par sécurité.`;
} else if (result.flagged) {
errorCode = "TOXIC_CONTENT_FLAGGED";
const list = highCats.length ? ` (${highCats.join(", ")})` : "";
message = `Attention: contenu potentiellement sensible détecté${list}.`;
message = `Attention: contenu sensible détecté.`;
}
return {
success: !result.blocked,
errorCode,
message,
result,
};
return { success: !result.blocked, errorCode, message, result };
} catch (err) {
console.error("analyseLyricsToxicity failed", err?.message || err);
return {
success: false,
errorCode: "ANALYSE_FAILED",
message: "Erreur lors de l'analyse de la toxicité. Réessayez plus tard.",
message: "Erreur analyse toxicité.",
};
}
});
/**
* Récupère les timestamps (aligned words) pour une génération Suno
* Attend: { projectId: string }
*/
// --- SUNO TIMESTAMPS (EXISTANT) ---
async function getSunoTimestamps(projectId) {
try {
const { sunoTaskId, songIndex } = (
await refList.projects.doc(projectId).get()
).data();
if (!sunoTaskId) {
throw new Error("TaskId manquant");
}
if (!projectId || typeof projectId !== "string")
throw new Error("projectId invalide");
if (songIndex < 0) {
const docSnap = await refList.projects.doc(projectId).get();
if (!docSnap.exists) throw new Error("Projet introuvable");
const { sunoTaskId, songIndex } = docSnap.data();
if (!sunoTaskId) throw new Error("TaskId manquant");
if (songIndex === undefined || songIndex < 0)
throw new Error("musicIndex invalide");
}
if (!projectId || typeof projectId !== "string" || !projectId.trim()) {
throw new Error("projectId manquant ou invalide");
}
console.log("🔎 getSunoTimestamps", { sunoTaskId, songIndex });
console.log("🔎 Récupération des timestamps Suno", {
sunoTaskId,
songIndex,
});
let response;
let parsed;
response = await axios.post(
const response = await axios.post(
`${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`,
{ taskId: sunoTaskId, musicIndex: songIndex },
{
@@ -505,35 +424,23 @@ async function getSunoTimestamps(projectId) {
timeout: 30000,
},
);
parsed = response.data;
console.log("📊 Réponse timestamps Suno API:", parsed?.code, parsed?.msg);
const dataToReturn = parsed?.data || parsed || {};
const dataToReturn = response.data?.data || response.data || {};
await refList.projects.doc(projectId).set(
{
musicTimestamps: {
[songIndex]: dataToReturn,
},
musicTimestamps: { [songIndex]: dataToReturn },
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
console.log("💾 Timestamps sauvegardés dans Firestore", {
projectId,
songIndex,
});
return {
success: true,
};
return { success: true };
} catch (error) {
console.error("❌ Erreur interne getSunoTimestamps:", error);
console.error("❌ getSunoTimestamps Error:", error.message);
return {
success: false,
error: {
message: error.message,
type: "INTERNAL_ERROR",
},
error: { message: error.message, type: "INTERNAL_ERROR" },
};
}
}
+439 -633
View File
File diff suppressed because it is too large Load Diff