364 lines
11 KiB
JavaScript
364 lines
11 KiB
JavaScript
const { onCall, HttpsError } = require("firebase-functions/v2/https");
|
|
const { z } = require("genkit");
|
|
const { generateAI, analyseLyrics } = require("../helpers/gemini");
|
|
const {
|
|
SUNO_API_BASE,
|
|
SUNO_TIMESTAMPED_LYRICS_PATH,
|
|
} = require("../config/suno");
|
|
const { SUNO_API_KEY } = require("../config/keys");
|
|
const axios = require("axios");
|
|
const admin = require("firebase-admin");
|
|
const { refList } = require("../index");
|
|
|
|
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,
|
|
];
|
|
|
|
const buildModerationBrief = ({
|
|
objective,
|
|
context,
|
|
style,
|
|
audience,
|
|
emotion,
|
|
structure,
|
|
rhymes,
|
|
}) => {
|
|
const sections = [
|
|
`<OBJECTIF>${objective || ""}</OBJECTIF>`,
|
|
`<CONTEXTE>${context || ""}</CONTEXTE>`,
|
|
`<EMOTION>${emotion || ""}</EMOTION>`,
|
|
`<STYLE>${style || ""}</STYLE>`,
|
|
`<AUDIENCE>${audience || ""}</AUDIENCE>`,
|
|
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}</RIMES>`,
|
|
];
|
|
|
|
if (Array.isArray(structure) && structure.length) {
|
|
sections.push(`<STRUCTURE>${structure.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;
|
|
};
|
|
|
|
exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
|
|
try {
|
|
const {
|
|
objective = "",
|
|
context = "",
|
|
style = "",
|
|
audience = "",
|
|
emotion = "",
|
|
structure = ["couplet", "refrain", "couplet", "refrain"],
|
|
rhymes = "",
|
|
} = data;
|
|
|
|
const moderationPayload = {
|
|
objective,
|
|
context,
|
|
style,
|
|
audience,
|
|
emotion,
|
|
structure,
|
|
rhymes,
|
|
};
|
|
|
|
const moderationInput = buildModerationBrief(moderationPayload);
|
|
|
|
try {
|
|
const moderation = await analyseLyrics({
|
|
title: "Brief utilisateur",
|
|
lyrics: moderationInput,
|
|
});
|
|
|
|
if (moderationIndicatesBlock(moderation, moderationInput)) {
|
|
console.warn("generateLyrics blocked by moderation", {
|
|
reasons: moderation?.reasons,
|
|
excerpts: moderation?.excerpts,
|
|
});
|
|
|
|
const summary = Array.isArray(moderation?.reasons)
|
|
? moderation.reasons.slice(0, 3).join(", ")
|
|
: "contenu sensible";
|
|
|
|
throw new HttpsError(
|
|
"invalid-argument",
|
|
`Certaines informations saisies contiennent des termes interdits (${summary}). Merci de reformuler pour poursuivre la création des paroles.`,
|
|
{ moderation },
|
|
);
|
|
}
|
|
} catch (moderationError) {
|
|
if (moderationError instanceof HttpsError) {
|
|
throw moderationError;
|
|
}
|
|
|
|
console.error("generateLyrics moderation failure", moderationError);
|
|
throw new HttpsError(
|
|
"internal",
|
|
"Impossible de vérifier le brief pour le moment. Merci de réessayer dans quelques instants.",
|
|
);
|
|
}
|
|
|
|
console.log("Function generate lyrics start with data", data);
|
|
|
|
const system = `
|
|
Tu es un parolier expert en chanson française moderne et populaire.
|
|
Tu écris des chansons marquantes, accessibles, sur des thématiques d'innovation, d'esprit d'équipe et de créativité digitale.
|
|
Ton écriture doit toucher à la fois l'humain, le collectif et la passion d'entreprendre.
|
|
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.
|
|
`.trim();
|
|
|
|
const structureTags = (Array.isArray(structure) ? structure : [])
|
|
.map((part, index) => {
|
|
const content = part || "";
|
|
return ` <SECTION ordre="${index + 1}">${content}</SECTION>`;
|
|
})
|
|
.join("\n");
|
|
|
|
const fallbackStructureTags = [
|
|
' <SECTION ordre="1">couplet</SECTION>',
|
|
' <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>
|
|
|
|
<STRUCTURE_DEMANDEE>
|
|
${structureTags || fallbackStructureTags}
|
|
</STRUCTURE_DEMANDEE>
|
|
|
|
<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>
|
|
`.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
|
|
.array(
|
|
z.object({
|
|
type: z
|
|
.string()
|
|
.describe(
|
|
'Type de section : "couplet" ou "refrain" ' +
|
|
"selon la structure",
|
|
),
|
|
lyrics: z
|
|
.string()
|
|
.describe(
|
|
"Paroles de la section, chaque ligne séparée " +
|
|
"par un retour à la ligne",
|
|
),
|
|
}),
|
|
)
|
|
.describe(
|
|
"Paroles de la chanson sous forme de tableau de sections " +
|
|
"structurées.",
|
|
),
|
|
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"),
|
|
});
|
|
|
|
return await generateAI({
|
|
system,
|
|
prompt,
|
|
schema: lyricsSchema,
|
|
});
|
|
} catch (e) {
|
|
console.log(e);
|
|
}
|
|
});
|
|
|
|
// 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
|
|
.union([
|
|
z.string(),
|
|
z.object({
|
|
couplet: z.string().optional(),
|
|
refrain: z.string().optional(),
|
|
}),
|
|
z.array(
|
|
z.object({
|
|
type: z.string().optional(),
|
|
lyrics: z.string().optional(),
|
|
}),
|
|
),
|
|
])
|
|
.describe("Paroles à analyser (formats supportés)"),
|
|
});
|
|
|
|
const parsed = requestSchema.parse(data || {});
|
|
const result = await analyseLyrics({
|
|
title: parsed.title || "",
|
|
lyrics: parsed.lyrics,
|
|
});
|
|
|
|
// Prépare un message lisible pour le front
|
|
const highCats = Object.entries(result.categories || {})
|
|
.filter(([, v]) => (typeof v === "number" ? v : 0) >= 0.5)
|
|
.map(([k]) => k)
|
|
.slice(0, 5);
|
|
|
|
let errorCode = "OK";
|
|
let message = "Analyse effectuée: aucun blocage.";
|
|
|
|
if (result.blocked) {
|
|
errorCode = "TOXIC_CONTENT_BLOCKED";
|
|
const list = highCats.length ? ` (${highCats.join(", ")})` : "";
|
|
message = `Publication bloquée: contenu interdit détecté${list}.`;
|
|
} else if (result.flagged) {
|
|
errorCode = "TOXIC_CONTENT_FLAGGED";
|
|
const list = highCats.length ? ` (${highCats.join(", ")})` : "";
|
|
message = `Attention: contenu potentiellement sensible détecté${list}.`;
|
|
}
|
|
|
|
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.",
|
|
};
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Récupère les timestamps (aligned words) pour une génération Suno
|
|
* Attend: { projectId: string }
|
|
*/
|
|
async function getSunoTimestamps(projectId) {
|
|
try {
|
|
const { sunoTaskId, songIndex } = (
|
|
await refList.projects.doc(projectId).get()
|
|
).data();
|
|
if (!sunoTaskId) {
|
|
throw new Error("TaskId manquant");
|
|
}
|
|
|
|
if (songIndex < 0) {
|
|
throw new Error("musicIndex invalide");
|
|
}
|
|
|
|
if (!projectId || typeof projectId !== "string" || !projectId.trim()) {
|
|
throw new Error("projectId manquant ou invalide");
|
|
}
|
|
|
|
console.log("🔎 Récupération des timestamps Suno", {
|
|
sunoTaskId,
|
|
songIndex,
|
|
});
|
|
|
|
let response;
|
|
let parsed;
|
|
|
|
response = await axios.post(
|
|
`${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`,
|
|
{ taskId: sunoTaskId, musicIndex: songIndex },
|
|
{
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${SUNO_API_KEY}`,
|
|
},
|
|
timeout: 30000,
|
|
},
|
|
);
|
|
parsed = response.data;
|
|
console.log("📊 Réponse timestamps Suno API:", parsed?.code, parsed?.msg);
|
|
|
|
const dataToReturn = parsed?.data || parsed || {};
|
|
|
|
await refList.projects.doc(projectId).set(
|
|
{
|
|
musicTimestamps: {
|
|
[songIndex]: dataToReturn,
|
|
},
|
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
|
},
|
|
{ merge: true },
|
|
);
|
|
console.log("💾 Timestamps sauvegardés dans Firestore", {
|
|
projectId,
|
|
songIndex,
|
|
});
|
|
return {
|
|
success: true,
|
|
};
|
|
} catch (error) {
|
|
console.error("❌ Erreur interne getSunoTimestamps:", error);
|
|
return {
|
|
success: false,
|
|
error: {
|
|
message: error.message,
|
|
type: "INTERNAL_ERROR",
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
exports.getSunoTimestamps = getSunoTimestamps;
|