diff --git a/functions/helpers/gemini.js b/functions/helpers/gemini.js index 4e596a9..4dcf6a1 100644 --- a/functions/helpers/gemini.js +++ b/functions/helpers/gemini.js @@ -106,50 +106,56 @@ 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 policy = ` -RUBRIQUE DE DÉCISION (résumée): - -A. QUAND BLOQUER (blocked = true) — cas graves et clairs (pas ambigus): - 1) Sexualité impliquant des mineurs (référence explicite, érotisation, incitation). - 2) Apologie explicite et sérieuse de terrorisme/extrémisme réel, ou appel à rejoindre/soutenir. - 3) Appels explicites à la violence ou à un préjudice grave contre une personne/groupe réel. - 4) Déshumanisation évidente et sérieuse de groupes protégés couplée à menace/incitation. - 5) Instruction ou encouragement clair à commettre des crimes violents. - 6) Nazisme/fascisme réel promu (praise/advocacy) ou symboles glorifiés sans critique. - 7) Partage de contenu sexuel non consenti réel (ex: doxxing sexuel, vengeance) ou exploitation. - -B. QUAND NE PAS BLOQUER (blocked = false, mais flagged peut être true): - - Œuvres qui mettent en scène un personnage toxique pour le critiquer, le montrer ou le contextualiser. - - Citations, descriptions historiques, satire, ironie, mises en garde, ou re-appropriation par les communautés visées. - - Métaphores violentes ou langage dur SANS cible réelle ni intention d'incitation. - - Références à des thèmes sombres dans un but narratif/éducatif/critique, sans promotion réelle. - -C. SCORE ET SEVERITY: - - Calibre "score" selon l'impact potentiel et la clarté du tort. - - Indice: low≈0.1–0.3, medium≈0.3–0.6, high≈0.6–0.85, critical>0.85. - - Utilise severity par extrait pour refléter le pire passage. - -D. RELECTURE DE CONTEXTE: - - Demande-toi si le passage est: (i) narration/roleplay/critique —> plutôt allow; (ii) injonction réelle —> plutôt block. - - Si ambigu OU probablement artistique, NE PAS bloquer: flagged=true si nécessaire, blocked=false. + const decisionPolicy = ` + +
+ QUAND BLOQUER (blocked = true) — cas graves et clairs (pas ambigus) + Sexualité impliquant des mineurs (référence explicite, érotisation, incitation). + Apologie explicite et sérieuse de terrorisme ou extrémisme réel, ou appel à rejoindre/soutenir. + Appels explicites à la violence ou à un préjudice grave contre une personne ou un groupe réel. + Déshumanisation évidente d'un groupe protégé accompagnée d'une menace ou d'une incitation. + Instruction ou encouragement clair à commettre des crimes violents. + Nazisme ou fascisme réel promu sans distance critique. + Partage de contenu sexuel non consenti ou exploitation sexuelle réelle. +
+
+ QUAND NE PAS BLOQUER (blocked = false, flagged possible) + Œuvres montrant un personnage toxique pour le critiquer, le contextualiser ou le dénoncer. + Citations, descriptions historiques, satire, ironie ou réappropriation par les communautés visées. + Métaphores violentes sans cible réelle ni intention d'incitation. + Références à des thèmes sombres à des fins narratives, éducatives ou critiques sans promotion réelle. +
+
+ CALIBRAGE DU SCORE ET DES SEVERITY + Score low ≈ 0.1-0.3, medium ≈ 0.3-0.6, high ≈ 0.6-0.85, critical > 0.85. + Utilise la colonne severity pour refléter le passage le plus problématique. +
+
+ RELECTURE DU CONTEXTE + Si le passage relève de la narration, satire, critique ou roleplay: privilégie flagged=true et blocked=false. + Si le passage constitue une injonction réelle ou un appel clair au tort: blocked=true. +
+
`.trim(); const userPrompt = ` -Analyse de paroles de chanson -Titre: ${title || "Sans titre"} - -Paroles: -""" + + + ${title || "Sans titre"} + + + -Consigne: -- Applique la RUBRIQUE DE DÉCISION ci-dessous pour autoriser la création artistique tout en bloquant les cas réellement dangereux. -- "blocked" = true UNIQUEMENT pour les cas A.1 à A.7 (non ambigus). -- Si le contenu semble artistique (récit, personnage, satire, citation, dénonciation) et ne fait pas d'apologie/incitation réelle: "blocked" = false (tu peux mettre "flagged" = true + "score" adapté). -- Donne des "excerpts" courts (citations EXACTES) si possible. + + Autoriser l'expression artistique tout en bloquant les contenus réellement dangereux. + "blocked" = true UNIQUEMENT pour les cas graves listés dans la section A. + 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). + Fournis des "excerpts" courts en citant exactement les passages sensibles. + -${policy} +${decisionPolicy} `.trim(); const { output } = await genkit({ diff --git a/functions/helpers/prompts.js b/functions/helpers/prompts.js index 013e451..120acfe 100644 --- a/functions/helpers/prompts.js +++ b/functions/helpers/prompts.js @@ -44,25 +44,30 @@ exports.generatePicturePrompt = (project = {}) => { ? "Palette vive et contrastée (magenta, cyan, jaune, bleu électrique)" : "Palette harmonieuse et douce (bleu nuit, violet, corail, or pâle)"; - // Prompt simplifié et optimisé pour Gemini + const tagsLine = tags.join(" ; ") || "Non spécifié"; + const lyricsLine = lyricsSample || "Pas d'extraits fournis"; + const prompt = ` -Crée une pochette d'album musicale abstraite et colorée au format carré 1024x1024. + + Créer une pochette d'album abstraite et lumineuse. + ${title || "Sans titre"} + ${tagsLine} + ${lyricsLine} + -Contexte: -- Titre: ${title || "Sans titre"} -- Style musical: ${tags.join(" ; ")} + + Image carrée 1024x1024 pixels. + + ${paletteHint} + Remplis toute la surface sans laisser de bordures ou zones vides. + -Directives essentielles: -- Style: Abstrait, graphique, avec formes géométriques et dégradés de couleurs -- Couleurs: ${paletteHint} -- IMPORTANT: Image plein cadre sans AUCUN bord blanc ou espace vide -- Composition: Éléments visuels répartis sur TOUTE la surface de l'image - -Contraintes: -- PAS de texte ou typographie -- PAS de personnes ou visages -- PAS de fond blanc ou bordures -- PAS de filigrane + + Texte ou typographie. + Personnes ou visages. + Fond blanc ou bordures. + Filigrane ou logo. + `.trim(); return prompt; diff --git a/functions/src/lyrics.js b/functions/src/lyrics.js index a268958..aedb356 100644 --- a/functions/src/lyrics.js +++ b/functions/src/lyrics.js @@ -1,4 +1,4 @@ -const { onCall } = require("firebase-functions/v2/https"); +const { onCall, HttpsError } = require("firebase-functions/v2/https"); const { z } = require("genkit"); const { generateAI, analyseLyrics } = require("../helpers/gemini"); const { @@ -10,6 +10,64 @@ 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 = [ + `${objective || ""}`, + `${context || ""}`, + `${emotion || ""}`, + ``, + `${audience || ""}`, + `${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}`, + ]; + + if (Array.isArray(structure) && structure.length) { + sections.push(`${structure.join(" | ")}`); + } + + return `\n${sections.join("\n")}\n`; +}; + +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 { @@ -22,6 +80,52 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => { 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 = ` diff --git a/src/assets/UI/writingBgWeb.png b/src/assets/UI/writingBgWeb.png new file mode 100644 index 0000000..170a638 Binary files /dev/null and b/src/assets/UI/writingBgWeb.png differ diff --git a/src/assets/index.js b/src/assets/index.js index bebb459..0f554ce 100644 --- a/src/assets/index.js +++ b/src/assets/index.js @@ -211,6 +211,7 @@ export const tutorial = { export const background = { writingBG, + writingBgWeb: require("./UI/writingBgWeb.png"), studioBG, studioBG2, productionBG, diff --git a/src/components/BorderGradientButton.js b/src/components/BorderGradientButton.js index cf30682..291de65 100644 --- a/src/components/BorderGradientButton.js +++ b/src/components/BorderGradientButton.js @@ -14,12 +14,17 @@ const BorderGradientButton = ({ containerStyle = {}, tint = "dark", disabled = false, + maxWidth = null, }) => { return ( toggleSelect(item)} gradientProps={ useEmotionColors && !sel ? { locations: [0.24, 1] } : undefined } - showBorder={!sel} // mobile: bordure d’origine + showBorder={shouldShowBorder} disableBlur={sel && isWeb} // web: pas de blur si gradient blurViewStyle={ sel && isWeb @@ -323,7 +329,7 @@ const ListSelection = ({ {withWebBlueGradientIfSelected(baseContent, sel)} - {showHoverOutline && ( + {(showHoverOutline || showSelectionOutline) && ( { headerType="NONE" behavior={Platform.OS === "ios" ? "padding" : "height"} keyboardVerticalOffset={Platform.OS === "ios" ? 64 : 100} - backgroundImg={background.homeBG} + backgroundImg={background.writingBG} > { headerType="NONE" behavior={Platform.OS === "ios" ? "padding" : "height"} keyboardVerticalOffset={Platform.OS === "ios" ? 64 : 100} - backgroundImg={background.homeBG} + backgroundImg={background.writingBgWeb} > { const [called, setCalled] = useState(false); const [result, setResult] = useState(null); const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + const hasShownErrorAlert = useRef(false); const { setIsLoading } = useMinuit(); const { updateProjectData } = useUser(); @@ -28,6 +29,8 @@ const CreatingLyrics = ({ active, config, selections }) => { setResult(null); setProgress(0); setSaved(false); + setError(null); + hasShownErrorAlert.current = false; } }, [active]); @@ -52,6 +55,9 @@ const CreatingLyrics = ({ active, config, selections }) => { try { setCalled(true); await setIsLoading(true); + console.log("🚀 [CreatingLyrics] Lancement de la génération", { + platform: Platform.OS, + }); const callable = firebase .functions() .httpsCallable("lyrics-generateLyrics"); @@ -66,8 +72,22 @@ const CreatingLyrics = ({ active, config, selections }) => { }); setResult(data); setProgress(100); + console.log("✨ [CreatingLyrics] Génération réussie"); } catch (e) { - console.log(e); + console.error("🔥 [CreatingLyrics] Erreur de génération", e); + const rawMessage = typeof e?.message === "string" ? e.message : ""; + const cleanedMessage = rawMessage.replace( + /^functions error: \w+-\w+:\s*/i, + "", + ); + setError({ + message: + cleanedMessage?.length > 0 + ? cleanedMessage + : "Une erreur est survenue lors de la génération des paroles.", + code: e?.code, + details: e?.details, + }); } finally { await setIsLoading(false); } @@ -85,6 +105,7 @@ const CreatingLyrics = ({ active, config, selections }) => { if (saved) return; setSaved(true); await setIsLoading(true); + console.log("💾 [CreatingLyrics] Sauvegarde automatique des paroles"); await updateProjectData({ title: result?.title || "", lyrics: Array.isArray(result?.lyrics) ? result.lyrics : [], @@ -95,13 +116,13 @@ const CreatingLyrics = ({ active, config, selections }) => { }); navigate(Routes.Lyrics); } catch (e) { - console.log("Auto save generated lyrics error", e?.message); + console.error("⚠️ [CreatingLyrics] Erreur lors de la sauvegarde", e); } finally { await setIsLoading(false); } }; - if (active && result && progress >= 100 && !saved) { + if (active && result && progress >= 100 && !saved && !error) { autoSaveAndGo(); } }, [ @@ -109,12 +130,39 @@ const CreatingLyrics = ({ active, config, selections }) => { result, progress, saved, + error, setIsLoading, updateProjectData, config, selections, ]); + useEffect(() => { + if (error && !hasShownErrorAlert.current) { + hasShownErrorAlert.current = true; + const redirect = () => { + console.log("↩️ [CreatingLyrics] Retour à WritingLyrics après erreur"); + navigate(Routes.WritingLyrics); + }; + + Alert.alert( + "Brief à reformuler", + error.message, + [ + { + text: "OK", + onPress: redirect, + }, + ], + { cancelable: false }, + ); + + if (Platform.OS === "web") { + redirect(); + } + } + }, [error]); + return ( { textAlign: "center", }} > - Ton texte est{"\n"}en cours de création + {error + ? "Impossible de générer les paroles" + : "Ton texte est\nen cours de création"} @@ -183,7 +233,7 @@ const CreatingLyrics = ({ active, config, selections }) => { {progress}% - {!saved && ( + {!saved && result && !error && ( { if (saved) return; setSaved(true); await setIsLoading(true); + console.log( + "📄 [CreatingLyrics] Consultation manuelle du texte", + ); await updateProjectData({ title: result?.title || "", lyrics: Array.isArray(result?.lyrics) @@ -206,7 +259,10 @@ const CreatingLyrics = ({ active, config, selections }) => { }); navigate(Routes.Lyrics); } catch (e) { - console.log("Save generated lyrics error", e?.message); + console.error( + "⚠️ [CreatingLyrics] Erreur lors de l'ouverture manuelle", + e, + ); } finally { await setIsLoading(false); } diff --git a/src/screens/Writing/Lyrics.js b/src/screens/Writing/Lyrics.js index ba57b28..4d65490 100644 --- a/src/screens/Writing/Lyrics.js +++ b/src/screens/Writing/Lyrics.js @@ -12,6 +12,8 @@ import { navigate } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { gutters } from "../../styles"; import CustomInput from "./components/CustomInput"; +import { background } from "../../assets"; +import { isWeb } from "../../hooks/useLayoutType"; const Lyrics = ({ navigation }) => { const scrollRef = useRef(null); @@ -239,7 +241,10 @@ const Lyrics = ({ navigation }) => { ]); return ( - + navigate(Routes.Home)} progress={95} diff --git a/src/screens/Writing/WritingLyrics.js b/src/screens/Writing/WritingLyrics.js index dfa563b..32e3717 100644 --- a/src/screens/Writing/WritingLyrics.js +++ b/src/screens/Writing/WritingLyrics.js @@ -6,11 +6,13 @@ import BorderGradientButton from "../../components/BorderGradientButton"; import FullscreenIntroVideo from "../../components/FullscreenIntroVideo"; import GradientButton from "../../components/GradientButton"; import MusicLandHeader from "../../components/MusicLandHeader"; -import Page from "../../layouts/Page"; import { Routes } from "../../navigation"; import { goBack, navigate } from "../../navigation/NavigationService"; import { useUserData } from "../../providers/UserDataProvider"; import { gutters } from "../../styles"; +import { background } from "../../assets"; +import { isWeb } from "../../hooks/useLayoutType"; +import Page from "../../layouts/Page"; const WritingLyrics = () => { const { createNewProject, selectedProject, updateProjectData } = @@ -42,7 +44,10 @@ const WritingLyrics = () => { } return ( - + { }} > createAndNavigate({ hasLyrics: true })} + containerStyle={{ alignSelf: "center", width: "100%" }} /> createAndNavigate({ hasLyrics: false })} />