clear last tickets
This commit is contained in:
+147
-429
@@ -3,13 +3,33 @@ const { genkit, z } = require("genkit");
|
|||||||
const { GEMINI_API_KEY } = require("../config/keys");
|
const { GEMINI_API_KEY } = require("../config/keys");
|
||||||
const admin = require("firebase-admin");
|
const admin = require("firebase-admin");
|
||||||
const { Buffer } = require("buffer");
|
const { Buffer } = require("buffer");
|
||||||
const { setTimeout } = require("timers");
|
const { setTimeout } = require("timers/promises");
|
||||||
|
|
||||||
exports.generateAI = async ({ system = "", prompt = "", schema }) => {
|
// --- CONFIGURATION ---
|
||||||
const genkitInstance = genkit({
|
// 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 })],
|
plugins: [googleAI({ apiKey: GEMINI_API_KEY })],
|
||||||
model: googleAI.model("gemini-2.5-flash"),
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
return aiInstance;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génération de texte générique
|
||||||
|
*/
|
||||||
|
exports.generateAI = async ({ system = "", prompt = "", schema }) => {
|
||||||
|
const ai = getAiInstance(); // Récupère l'instance singleton
|
||||||
|
|
||||||
if (prompt?.length < 1) {
|
if (prompt?.length < 1) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -17,21 +37,37 @@ exports.generateAI = async ({ system = "", prompt = "", schema }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { output } = await genkitInstance.generate({
|
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { output } = await ai.generate({
|
||||||
|
model: googleAI.model(TEXT_MODEL_NAME),
|
||||||
system,
|
system,
|
||||||
prompt,
|
prompt,
|
||||||
output: { schema },
|
output: { schema },
|
||||||
|
config: {
|
||||||
|
temperature: 0.7, // Créativité équilibrée
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return output;
|
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}> }
|
* Analyse la toxicité des paroles.
|
||||||
// Sortie (schéma strict): indicateurs de toxicité, raisons, extraits et décision de blocage.
|
* Utilise gemini-1.5-flash-002 avec des réglages permissifs pour l'analyse.
|
||||||
// Cloud Function plus nuancée : bloque le réellement problématique, laisse passer le reste
|
*/
|
||||||
exports.analyseLyrics = async ({ title = "", lyrics }) => {
|
exports.analyseLyrics = async ({ title = "", lyrics }) => {
|
||||||
// --- Normalisation inchangée (robuste aux différents formats) ---
|
const ai = getAiInstance();
|
||||||
|
|
||||||
|
// --- Normalisation ---
|
||||||
const normalizeLyrics = (raw) => {
|
const normalizeLyrics = (raw) => {
|
||||||
if (!raw) return "";
|
if (!raw) return "";
|
||||||
if (typeof raw === "string") return raw;
|
if (typeof raw === "string") return raw;
|
||||||
@@ -55,486 +91,168 @@ exports.analyseLyrics = async ({ title = "", lyrics }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const lyricsText = normalizeLyrics(lyrics).trim();
|
const lyricsText = normalizeLyrics(lyrics).trim();
|
||||||
|
if (!lyricsText) throw new Error("analyseLyrics: paroles requises.");
|
||||||
|
|
||||||
if (!lyricsText) {
|
// --- Schéma ---
|
||||||
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é) ---
|
|
||||||
const moderationSchema = z.object({
|
const moderationSchema = z.object({
|
||||||
title: z.string().describe("Titre analysé (copie du titre fourni)"),
|
title: z.string().describe("Titre analysé"),
|
||||||
flagged: z
|
flagged: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.describe("Vrai si au moins un signalement de toxicité est détecté."),
|
.describe("Vrai si le contenu nécessite un avertissement."),
|
||||||
blocked: z
|
blocked: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.describe(
|
.describe("Vrai UNIQUEMENT si violation grave (Haine, Violence réelle)."),
|
||||||
"Vrai si la publication doit être BLOQUÉE (cas graves et non ambigus).",
|
score: z
|
||||||
),
|
.number()
|
||||||
score: z.number().min(0).max(1).describe("Score global de toxicité (0-1)"),
|
.min(0)
|
||||||
reasons: z.array(z.string()).describe("Raisons concises de la décision."),
|
.max(1)
|
||||||
|
.describe("Score de risque (0=Sûr, 1=Dangereux)."),
|
||||||
|
reasons: z.array(z.string()).describe("Liste concise des raisons."),
|
||||||
excerpts: z
|
excerpts: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
quote: z.string().describe("Extrait exact problématique"),
|
quote: z.string(),
|
||||||
category: z
|
category: z.string(),
|
||||||
.string()
|
severity: 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"),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.max(10)
|
.max(10),
|
||||||
.describe("Jusqu'à 10 extraits problématiques"),
|
success: z.boolean(),
|
||||||
success: z.boolean().describe("Toujours true si l'analyse a abouti"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Politique : davantage de place à la création tout en restant sûre ---
|
// --- Prompt ---
|
||||||
const system = `
|
const system = `Tu es un Expert en Modération de Contenu Musical (Trust & Safety).
|
||||||
Tu es un modérateur de contenu spécialisé dans l'analyse de paroles de chanson.
|
TA MISSION : Distinguer l'expression artistique (même crue/vulgaire) du contenu réellement dangereux.
|
||||||
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.
|
|
||||||
|
|
||||||
Sois STRICT sur les risques graves, mais NE SOIS PAS PUNITIF envers l'expression artistique.
|
1. "FLAGGED" (Avertissement) : Vulgarités, thèmes matures, drogue, sexe consensuel.
|
||||||
É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.
|
2. "BLOCKED" (Interdit) : Discours de haine, harcèlement ciblé, pédopornographie, incitation explicite violence/suicide.
|
||||||
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.
|
|
||||||
|
|
||||||
Retourne UNIQUEMENT un JSON conforme au schéma. Pas de texte hors JSON.
|
Analyse le CONTEXTE. Une insulte dans un clash de rap est différente d'un appel au meurtre.`;
|
||||||
`.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();
|
|
||||||
|
|
||||||
const userPrompt = `
|
const userPrompt = `
|
||||||
<ANALYSE_PAROLES>
|
ANALYSE CETTE CHANSON :
|
||||||
<METADONNEES>
|
Titre : ${title || "Inconnu"}
|
||||||
<TITRE>${title || "Sans titre"}</TITRE>
|
|
||||||
</METADONNEES>
|
PAROLES :
|
||||||
<PAROLES><![CDATA[
|
"""
|
||||||
${lyricsText}
|
${lyricsText}
|
||||||
]]></PAROLES>
|
"""
|
||||||
</ANALYSE_PAROLES>
|
`;
|
||||||
|
|
||||||
<DIRECTIVES>
|
console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`);
|
||||||
<OBJECTIF>Autoriser l'expression artistique tout en bloquant les contenus réellement dangereux.</OBJECTIF>
|
const startedAt = Date.now();
|
||||||
<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}
|
try {
|
||||||
|
const { output } = await ai.generate({
|
||||||
<INDICES_SUPPLEMENTAIRES>
|
model: googleAI.model(TEXT_MODEL_NAME),
|
||||||
${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,
|
system,
|
||||||
prompt: userPrompt,
|
prompt: userPrompt,
|
||||||
output: { schema: moderationSchema },
|
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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (output && detectedVulgarities.length) {
|
if (!output) throw new Error("Échec de l'analyse de modération.");
|
||||||
|
|
||||||
|
// Correction de cohérence
|
||||||
|
if (output.blocked) {
|
||||||
output.flagged = true;
|
output.flagged = true;
|
||||||
const bumpScore = detectedVulgarities.length >= 3 ? 0.65 : 0.5;
|
if (output.score < 0.7) output.score = 0.85;
|
||||||
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;
|
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 = "") => {
|
exports.generateImageV2 = async (prompt, size = 1024, path = "") => {
|
||||||
|
const ai = getAiInstance();
|
||||||
|
|
||||||
if (typeof prompt !== "string" || prompt.trim().length < 1) {
|
if (typeof prompt !== "string" || prompt.trim().length < 1) {
|
||||||
throw new Error("Vous devez spécifier un prompt (string) non vide.");
|
throw new Error("Prompt requis.");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
console.log(`🎨 [generateImageV2] Start (${IMAGE_MODEL_NAME})`);
|
||||||
console.log("🚀 [generateImageV2] Démarrage de la génération d'image", {
|
const startedAt = Date.now();
|
||||||
promptLength: prompt.trim().length,
|
|
||||||
size,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ajouter explicitement la taille au prompt si elle n'est pas mentionnée
|
// Optimisation du prompt pour Imagen
|
||||||
if (!prompt.includes("1024x1024") && !prompt.includes(size + "x" + size)) {
|
let enhancedPrompt = prompt.trim();
|
||||||
prompt = `Image carrée ${size}x${size} pixels. ${prompt}`;
|
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 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;
|
const maxAttempts = 3;
|
||||||
let res;
|
let lastError = null;
|
||||||
|
|
||||||
while (attempts < maxAttempts) {
|
|
||||||
try {
|
try {
|
||||||
attempts++;
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
console.log(
|
try {
|
||||||
`🔁 [generateImageV2] Tentative ${attempts}/${maxAttempts}`,
|
console.log(`🔄 Tentative ${attempt}/${maxAttempts}`);
|
||||||
);
|
|
||||||
|
|
||||||
// Utiliser Promise.race pour ajouter un timeout
|
const response = await ai.generate({
|
||||||
const timeout = new Promise((_, reject) => {
|
model: googleAI.model(IMAGE_MODEL_NAME),
|
||||||
setTimeout(() => reject(new Error("Timeout dépassé (30s)")), 30000);
|
prompt: enhancedPrompt,
|
||||||
});
|
});
|
||||||
|
|
||||||
res = await Promise.race([
|
const media = response.media;
|
||||||
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) {
|
if (media && media.url) {
|
||||||
console.log(
|
console.log("✅ Image générée.");
|
||||||
"✅ [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.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// --- Sauvegarde dans Firebase Storage ---
|
||||||
const dataUrl = String(media.url);
|
const dataUrl = String(media.url);
|
||||||
const commaIdx = dataUrl.indexOf(",");
|
const commaIdx = dataUrl.indexOf(",");
|
||||||
if (commaIdx === -1) throw new Error("FORMAT_DATA_URL_INVALIDE");
|
const b64 =
|
||||||
const header = dataUrl.substring(0, commaIdx);
|
commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : dataUrl;
|
||||||
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 buffer = Buffer.from(b64, "base64");
|
||||||
|
|
||||||
const bucket = admin.storage().bucket();
|
const bucket = admin.storage().bucket();
|
||||||
const token = require("crypto").randomUUID();
|
const token = require("crypto").randomUUID();
|
||||||
|
const file = bucket.file(path);
|
||||||
|
|
||||||
console.log(
|
await file.save(buffer, {
|
||||||
"💾 [generateImageV2] Sauvegarde de l'image dans Firebase Storage",
|
|
||||||
);
|
|
||||||
await bucket.file(path).save(buffer, {
|
|
||||||
resumable: false,
|
resumable: false,
|
||||||
metadata: {
|
metadata: {
|
||||||
contentType: mimeType,
|
contentType: media.contentType || "image/png",
|
||||||
cacheControl: "public, max-age=31536000",
|
|
||||||
metadata: { firebaseStorageDownloadTokens: token },
|
metadata: { firebaseStorageDownloadTokens: token },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const publicUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`;
|
||||||
path,
|
|
||||||
)}?alt=media&token=${token}`;
|
|
||||||
|
|
||||||
console.log("✅ [generateImageV2] Image générée avec succès");
|
|
||||||
return publicUrl;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"❌ [generateImageV2] Erreur lors de la génération d'image:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
throw new Error(`Erreur lors de la génération d'image: ${error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Combine deux images avec Gemini (background = cover abstraite, foreground = photo utilisateur détourée)
|
|
||||||
// Entrées possibles pour chaque image:
|
|
||||||
// - string data URL ("data:image/...;base64,....")
|
|
||||||
// - string URL http(s)
|
|
||||||
// - { base64: string, mimeType: string }
|
|
||||||
// - { url: string, mimeType?: string }
|
|
||||||
// Sortie: URL publique de l'image composite (PNG 1024x1024)
|
|
||||||
exports.CombineCoverAndPicture = async (
|
|
||||||
backgroundImage,
|
|
||||||
foregroundImage,
|
|
||||||
options = {},
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
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)",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ai = genkit({ plugins: [googleAI({ apiKey: GEMINI_API_KEY })] });
|
throw new Error("Pas de média dans la réponse IA.");
|
||||||
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) {
|
} catch (err) {
|
||||||
console.error(
|
console.warn(`⚠️ Erreur tentative ${attempt}:`, err.message);
|
||||||
"❌ [CombineCoverAndPicture] ai.generate failed",
|
lastError = err;
|
||||||
err?.message || 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`,
|
||||||
);
|
);
|
||||||
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 = "",
|
artistName: artistNameRaw = "",
|
||||||
} = project || {};
|
} = project || {};
|
||||||
|
|
||||||
|
// --- 1. Nettoyage et Normalisation ---
|
||||||
const sanitizeInline = (value = "") => {
|
const sanitizeInline = (value = "") => {
|
||||||
if (typeof value !== "string") return "";
|
if (typeof value !== "string") return "";
|
||||||
return value
|
return value
|
||||||
@@ -14,13 +15,6 @@ exports.generatePicturePrompt = (project = {}) => {
|
|||||||
.replace(/[<>]/g, "")
|
.replace(/[<>]/g, "")
|
||||||
.trim();
|
.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 titleForPrompt = sanitizeInline(title) || "Sans titre";
|
||||||
const artistName =
|
const artistName =
|
||||||
@@ -30,93 +24,114 @@ exports.generatePicturePrompt = (project = {}) => {
|
|||||||
const {
|
const {
|
||||||
genres = [],
|
genres = [],
|
||||||
tempo = "",
|
tempo = "",
|
||||||
voice = "",
|
|
||||||
instruments = [],
|
|
||||||
mood = "",
|
mood = "",
|
||||||
|
instruments = [],
|
||||||
} = musicConfig || {};
|
} = musicConfig || {};
|
||||||
|
const userStyle = sanitizeInline(coverStyleRaw);
|
||||||
|
|
||||||
// Normalise les paroles en tableau de sections { type, lyrics }
|
// --- 2. Intelligence Visuelle (Mapping) ---
|
||||||
const lyricsSections = Array.isArray(lyricsRaw)
|
|
||||||
? lyricsRaw
|
|
||||||
: [
|
|
||||||
lyricsRaw?.couplet
|
|
||||||
? { type: "couplet", lyrics: lyricsRaw.couplet }
|
|
||||||
: null,
|
|
||||||
lyricsRaw?.refrain
|
|
||||||
? { type: "refrain", lyrics: lyricsRaw.refrain }
|
|
||||||
: null,
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
// Échantillon pour l'ambiance (courtes bribes pour inspirer la direction créative)
|
// Détermination de l'énergie visuelle
|
||||||
const lyricsSample = (lyricsSections || [])
|
const isEnergetic =
|
||||||
.map((s) => (s?.lyrics || "").split("\n").slice(0, 2).join(" "))
|
tempo &&
|
||||||
.filter(Boolean)
|
/\b(rapid|fast|vite|agité|upbeat|energ|dance|rock|metal)\b/i.test(
|
||||||
.slice(0, 6)
|
String(tempo),
|
||||||
.join(" | ");
|
);
|
||||||
|
const isDark =
|
||||||
|
mood &&
|
||||||
|
/\b(sombre|triste|dark|sad|mélancoli|nuit|night|eerie)\b/i.test(
|
||||||
|
String(mood),
|
||||||
|
);
|
||||||
|
|
||||||
// Etiquettes d'ambiance
|
// Construction de la Palette & Lumière
|
||||||
const tags = [];
|
let visualAtmosphere = "";
|
||||||
if (Array.isArray(genres) && genres.length)
|
if (isDark) {
|
||||||
tags.push(`Genres: ${genres.join(", ")}`);
|
visualAtmosphere =
|
||||||
if (tempo) tags.push(`Tempo: ${tempo}`);
|
"Atmosphere: Cinematic dark lighting, moody shadows, deep contrast. Palette: Midnight blue, obsidian, deep purple, metallic accents.";
|
||||||
if (Array.isArray(instruments) && instruments.length)
|
} else if (isEnergetic) {
|
||||||
tags.push(`Instruments: ${instruments.join(", ")}`);
|
visualAtmosphere =
|
||||||
if (voice) tags.push(`Vocal: ${voice}`);
|
"Atmosphere: Dynamic lighting, high energy, vibrant saturation. Palette: Neon colors, electric blue, magenta, bright yellow, high contrast.";
|
||||||
if (mood) tags.push(`Mood: ${mood}`);
|
} else {
|
||||||
|
visualAtmosphere =
|
||||||
// Guidance palette selon tempo (simple heuristique)
|
"Atmosphere: Soft natural lighting, harmonious and ethereal. Palette: Pastel tones, warm gold, soft coral, balanced and elegant colors.";
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
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>
|
// Définition du Style de Rendu (Si l'utilisateur est vague, on renforce)
|
||||||
<OBJECTIF>Créer une pochette d'album en adéquation avec les paroles de la musique et qui respecte le style ${coverStyle}</OBJECTIF>
|
let renderingStyle = userStyle
|
||||||
<TITRE>${titleForPrompt}</TITRE>
|
? `Art Style: ${userStyle}`
|
||||||
${artistLine} <STYLE_MUSICAL>${tagsLine}</STYLE_MUSICAL>
|
: "Art Style: Digital Art, Mixed Media";
|
||||||
<EXTRAITS_PAROLES>${lyricsLine}</EXTRAITS_PAROLES>
|
|
||||||
</BRIEF>
|
|
||||||
|
|
||||||
<CONTEXTES_VISUELS>
|
if (
|
||||||
<FORMAT>Image carrée 1024x1024 pixels, résolution haute.</FORMAT>
|
userStyle.toLowerCase().includes("realist") ||
|
||||||
<STYLE>${styleLine}</STYLE>
|
userStyle.toLowerCase().includes("photo")
|
||||||
<COMPOSITION>La composition doit être dynamique et remplir toute la surface, sans laisser de bordures ni de zones vides.</COMPOSITION>
|
) {
|
||||||
<TYPOGRAPHIE>${typographyLine}</TYPOGRAPHIE>
|
renderingStyle +=
|
||||||
<ORTHOGRAPHE>Aucune faute d'orthographe n'est tolérée. Respecte la casse et l'orthographe exactes du titre fourni.</ORTHOGRAPHE>
|
", 8k resolution, highly detailed texture, photorealistic, cinematic depth of field, raytracing.";
|
||||||
</CONTEXTES_VISUELS>
|
} 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>
|
// --- 3. Extraction de l'Inspiration (Lyrics) ---
|
||||||
<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();
|
|
||||||
|
|
||||||
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");
|
||||||
};
|
};
|
||||||
|
|||||||
+201
-211
@@ -2,25 +2,46 @@ const { onDocumentCreated } = require("firebase-functions/v2/firestore");
|
|||||||
const admin = require("firebase-admin");
|
const admin = require("firebase-admin");
|
||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { FieldValue } = require("firebase-admin/firestore");
|
||||||
const logger = require("firebase-functions/logger");
|
const logger = require("firebase-functions/logger");
|
||||||
const { generateImageV2 } = require("../helpers/gemini");
|
|
||||||
const { generatePicturePrompt } = require("../helpers/prompts");
|
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const sharp = require("sharp");
|
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 { ALERT_TYPE, refList } = require("../index");
|
||||||
const { sendNotification } = require("./notifications");
|
const { sendNotification } = require("./notifications");
|
||||||
|
|
||||||
|
// Configuration
|
||||||
const bucket = admin.storage().bucket();
|
const bucket = admin.storage().bucket();
|
||||||
const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandLogo.png");
|
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) => {
|
const pickFirstNonEmpty = (...values) => {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string" && value.trim().length > 0) {
|
||||||
const trimmed = value.trim();
|
return value.trim();
|
||||||
if (trimmed.length > 0) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
@@ -33,82 +54,93 @@ const combineNames = (...parts) =>
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résolution intelligente du nom d'artiste
|
||||||
|
*/
|
||||||
async function resolveArtistName(project = {}) {
|
async function resolveArtistName(project = {}) {
|
||||||
|
// 1. Vérification directe sur le projet ou le snapshot "owner"
|
||||||
const owner = project?.owner || {};
|
const owner = project?.owner || {};
|
||||||
const direct = pickFirstNonEmpty(
|
const direct = pickFirstNonEmpty(
|
||||||
project?.artistName,
|
project?.artistName,
|
||||||
project?.userName,
|
project?.userName,
|
||||||
owner?.artistName,
|
owner?.artistName,
|
||||||
owner?.userName,
|
owner?.userName,
|
||||||
owner?.displayName
|
owner?.displayName,
|
||||||
);
|
);
|
||||||
if (direct) return direct;
|
if (direct) return direct;
|
||||||
|
|
||||||
|
// 2. Fallback : Récupération depuis la collection Users
|
||||||
const userId =
|
const userId =
|
||||||
typeof project?.userId === "string" && project.userId.trim()
|
typeof project?.userId === "string" ? project.userId.trim() : "";
|
||||||
? project.userId.trim()
|
|
||||||
: "";
|
|
||||||
if (!userId) return "";
|
if (!userId) return "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userSnapshot = await refList.users.doc(userId).get();
|
const userSnapshot = await refList.users.doc(userId).get();
|
||||||
if (!userSnapshot?.exists) return "";
|
if (!userSnapshot?.exists) return "";
|
||||||
|
|
||||||
const userData = userSnapshot.data() || {};
|
const userData = userSnapshot.data() || {};
|
||||||
const fullName = combineNames(userData.firstName, userData.lastName);
|
|
||||||
return (
|
return (
|
||||||
pickFirstNonEmpty(
|
pickFirstNonEmpty(
|
||||||
userData.artistName,
|
userData.artistName,
|
||||||
userData.userName,
|
userData.userName,
|
||||||
userData.displayName,
|
userData.displayName,
|
||||||
fullName
|
combineNames(userData.firstName, userData.lastName),
|
||||||
) || ""
|
) || ""
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn("⚠️ [Cover] Unable to resolve artist name", {
|
logger.warn("⚠️ [Cover] Artist name resolution failed", {
|
||||||
projectId: project?.id || null,
|
projectId: project?.id,
|
||||||
userId,
|
error: error.message,
|
||||||
error: error?.message || String(error),
|
|
||||||
});
|
});
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute le logo en filigrane sur l'image générée
|
||||||
|
*/
|
||||||
async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
||||||
logger.info("🖼️ [Cover] Adding logo to generated background");
|
logger.info("🖼️ [Cover] Compositing logo...");
|
||||||
const [{ data: backgroundBuffer }, logoBuffer] = await Promise.all([
|
|
||||||
|
try {
|
||||||
|
// Téléchargement background + Lecture Logo (parallèle)
|
||||||
|
const [bgResponse, logoBuffer] = await Promise.all([
|
||||||
axios.get(backgroundUrl, { responseType: "arraybuffer" }),
|
axios.get(backgroundUrl, { responseType: "arraybuffer" }),
|
||||||
fs.promises.readFile(LOGO_PATH),
|
getLogoBuffer(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const baseImage = sharp(backgroundBuffer);
|
const baseImage = sharp(bgResponse.data);
|
||||||
const { width = 1024, height = 1024 } = await baseImage.metadata();
|
const metadata = await baseImage.metadata();
|
||||||
|
const width = metadata.width || 1024;
|
||||||
|
const height = metadata.height || 1024;
|
||||||
|
|
||||||
|
// Calcul dynamique de la taille du logo (32% de la largeur)
|
||||||
const desiredWidth = Math.round(width * 0.32);
|
const desiredWidth = Math.round(width * 0.32);
|
||||||
const margin = Math.round(width * 0.04);
|
const margin = Math.round(width * 0.04);
|
||||||
|
|
||||||
const { data: resizedLogo, info: logoInfo } = await sharp(logoBuffer)
|
// Redimensionnement du logo
|
||||||
|
const resizedLogo = await sharp(logoBuffer)
|
||||||
.resize({ width: desiredWidth })
|
.resize({ width: desiredWidth })
|
||||||
.png()
|
.png()
|
||||||
.toBuffer({ resolveWithObject: true });
|
|
||||||
|
|
||||||
const left = Math.max(width - logoInfo.width - margin, 0);
|
|
||||||
const top = Math.max(height - logoInfo.height - margin, 0);
|
|
||||||
|
|
||||||
const stampedBuffer = await baseImage
|
|
||||||
.ensureAlpha()
|
|
||||||
.composite([
|
|
||||||
{
|
|
||||||
input: resizedLogo,
|
|
||||||
left,
|
|
||||||
top,
|
|
||||||
blend: "over",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
.png()
|
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
const token = require("crypto").randomUUID();
|
// Positionnement (Bas Droite)
|
||||||
await bucket.file(targetPath).save(stampedBuffer, {
|
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,
|
resumable: false,
|
||||||
metadata: {
|
metadata: {
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
@@ -117,270 +149,228 @@ async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`;
|
||||||
targetPath
|
} catch (error) {
|
||||||
)}?alt=media&token=${token}`;
|
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) {
|
async function performCoverGeneration(project) {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const artistName = await resolveArtistName(project);
|
const artistName = await resolveArtistName(project);
|
||||||
|
|
||||||
|
// Génération du Prompt optimisé
|
||||||
const prompt = generatePicturePrompt({
|
const prompt = generatePicturePrompt({
|
||||||
...project,
|
...project,
|
||||||
artistName,
|
artistName,
|
||||||
});
|
});
|
||||||
// Utiliser generateImageV2 qui renvoie directement l'URL publique
|
|
||||||
logger.info("🎨 [Cover] Calling model V2", {
|
|
||||||
projectId: project.id,
|
|
||||||
promptPreview: String(prompt).slice(0, 160),
|
|
||||||
});
|
|
||||||
const baseTimestamp = Date.now();
|
|
||||||
const options = [];
|
|
||||||
|
|
||||||
for (let index = 0; index < 2; index += 1) {
|
logger.info("🎨 [Cover] Prompt generated", {
|
||||||
|
projectId: project.id,
|
||||||
|
artistName,
|
||||||
|
promptPreview: prompt.slice(0, 100) + "...",
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseTimestamp = Date.now();
|
||||||
|
const GENERATION_COUNT = 2; // Nombre de variantes simultanées
|
||||||
|
|
||||||
|
// 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 uniqueSuffix = `${baseTimestamp}-${index}`;
|
||||||
const storageBasePath = `users/${project.userId}/projects/${project.id}`;
|
const storageBasePath = `users/${project.userId}/projects/${project.id}`;
|
||||||
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`;
|
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 {
|
|
||||||
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.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({
|
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,
|
id: uniqueSuffix,
|
||||||
generatedUrl,
|
generatedUrl,
|
||||||
finalUrl: finalCoverUrl,
|
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;
|
const [firstOption] = options;
|
||||||
|
|
||||||
await refList.projects.doc(project.id).set(
|
await refList.projects.doc(project.id).set(
|
||||||
{
|
{
|
||||||
cover: {
|
cover: {
|
||||||
generatedBackground: firstOption?.generatedUrl || null,
|
generatedBackground: firstOption.generatedUrl,
|
||||||
result: firstOption?.finalUrl || null,
|
result: firstOption.finalUrl,
|
||||||
selectedOptionId: firstOption?.id || null,
|
selectedOptionId: firstOption.id,
|
||||||
options,
|
options, // Sauvegarde de toutes les variantes réussies
|
||||||
},
|
},
|
||||||
coverStatus: "GENERATED",
|
coverStatus: "GENERATED",
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true }
|
{ merge: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info("✅ [Cover] Saved", {
|
logger.info("🏁 [Cover] Process complete", {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
optionsCount: options.length,
|
successCount: options.length,
|
||||||
ms: Date.now() - t0,
|
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(
|
exports.onTaskCreateGenerateCover = onDocumentCreated(
|
||||||
{
|
{
|
||||||
timeoutSeconds: 540,
|
timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent)
|
||||||
memory: "1GiB",
|
memory: "1GiB",
|
||||||
document: "tasks/{taskId}",
|
document: "tasks/{taskId}",
|
||||||
},
|
},
|
||||||
async (event) => {
|
async (event) => {
|
||||||
const data = event?.data?.data() || {};
|
const data = event.data?.data() || {};
|
||||||
const type = data?.type || "";
|
const { type, projectId } = data;
|
||||||
const projectId = data?.projectId || null;
|
const taskId = event.params.taskId;
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
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") {
|
if (type === "combine") {
|
||||||
logger.warn("⛔ [Task] Combine task ignored (feature disabled)", {
|
// Feature désactivée pour le moment
|
||||||
projectId,
|
|
||||||
taskId: event?.params?.taskId,
|
|
||||||
});
|
|
||||||
await event.data.ref.update({
|
await event.data.ref.update({
|
||||||
status: "CANCELLED",
|
status: "CANCELLED",
|
||||||
error:
|
error: "La personnalisation photo n'est plus disponible.",
|
||||||
"La personnalisation de la pochette avec une photo n'est plus disponible.",
|
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mise à jour statut projet
|
||||||
await refList.projects.doc(projectId).update({
|
await refList.projects.doc(projectId).update({
|
||||||
coverStatus: "GENERATING",
|
coverStatus: "GENERATING",
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
|
|
||||||
project = (await refList.projects.doc(projectId).get())?.data() || null;
|
// 2. Chargement Projet
|
||||||
if (!project) {
|
const projectSnap = await refList.projects.doc(projectId).get();
|
||||||
throw new Error("Projet non trouvé");
|
if (!projectSnap.exists) throw new Error("Projet introuvable");
|
||||||
}
|
|
||||||
project.id = projectId;
|
|
||||||
|
|
||||||
const existingOptionsCount = Array.isArray(project?.cover?.options)
|
const project = { id: projectId, ...projectSnap.data() };
|
||||||
? project.cover.options.length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
logger.info("🔎 [Task] Project loaded", {
|
// Idempotency check (si déjà généré, on ne refait pas)
|
||||||
projectId,
|
if (
|
||||||
hasGeneratedBackground: !!project?.cover?.generatedBackground,
|
Array.isArray(project?.cover?.options) &&
|
||||||
hasUserForeground: !!project?.cover?.userForeground,
|
project.cover.options.length > 0
|
||||||
existingOptionsCount,
|
) {
|
||||||
});
|
logger.warn("⚠️ [Task] Cover already exists. Skipping.");
|
||||||
|
await refList.projects
|
||||||
if (existingOptionsCount > 0) {
|
.doc(projectId)
|
||||||
logger.warn("⛔ [Task] Cover already generated, skipping", {
|
.update({ coverStatus: "GENERATED" });
|
||||||
projectId,
|
|
||||||
existingOptionsCount,
|
|
||||||
});
|
|
||||||
await refList.projects.doc(projectId).update({
|
|
||||||
coverStatus: "GENERATED",
|
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
|
||||||
});
|
|
||||||
await event.data.ref.update({
|
await event.data.ref.update({
|
||||||
status: "CANCELLED",
|
status: "DONE",
|
||||||
error: "Cover already generated",
|
info: "Already generated",
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "cover") {
|
// 3. Exécution Génération
|
||||||
logger.info("🎨 [Task] Start cover generation", { projectId });
|
const coverUrl = await performCoverGeneration(project);
|
||||||
coverUrl = await performCoverGeneration(project);
|
|
||||||
logger.info("✅ [Task] Cover generated", { projectId });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 4. Finalisation Tâche
|
||||||
await event.data.ref.update({
|
await event.data.ref.update({
|
||||||
status: "DONE",
|
status: "DONE",
|
||||||
coverUrl,
|
coverUrl,
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
logger.info("📦 [Task] Marked DONE", {
|
|
||||||
taskId: event?.params?.taskId,
|
|
||||||
projectId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
// 5. Notification
|
||||||
project?.userId &&
|
if (project.userId) {
|
||||||
typeof project.userId === "string" &&
|
const projectTitle = project.title || "ton projet";
|
||||||
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({
|
await sendNotification({
|
||||||
sender: "SYSTEM",
|
sender: "SYSTEM",
|
||||||
receiver: receiverId,
|
receiver: project.userId,
|
||||||
receiverCollection: "users",
|
receiverCollection: "users",
|
||||||
title: "Pochette générée",
|
title: "Pochette prête !",
|
||||||
message,
|
message: `La pochette pour "${projectTitle}" a été générée avec succès.`,
|
||||||
data: {
|
data: {
|
||||||
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
|
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
|
||||||
projectId,
|
projectId,
|
||||||
projectTitle,
|
projectTitle,
|
||||||
coverUrl,
|
coverUrl,
|
||||||
},
|
},
|
||||||
});
|
}).catch((err) => logger.warn("Notification failed", err));
|
||||||
} catch (notifError) {
|
|
||||||
logger.error("❌ [Cover] Failed to send success notification", {
|
|
||||||
projectId,
|
|
||||||
error: notifError?.message || String(notifError),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("❌ [Task] Cover generation error", {
|
logger.error(`🔥 [Task ${taskId}] Failed`, error);
|
||||||
taskId: event?.params?.taskId,
|
|
||||||
projectId,
|
// Mise à jour erreur Tâche
|
||||||
type,
|
|
||||||
error: error?.message || String(error),
|
|
||||||
});
|
|
||||||
await event.data.ref.set(
|
await event.data.ref.set(
|
||||||
{ status: "ERROR", error: error?.message || "Erreur" },
|
{ status: "ERROR", error: error.message },
|
||||||
{ merge: true }
|
{ merge: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mise à jour erreur Projet
|
||||||
await refList.projects.doc(projectId).update({
|
await refList.projects.doc(projectId).update({
|
||||||
coverStatus: "ERROR",
|
coverStatus: "ERROR",
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
if (
|
|
||||||
project?.userId &&
|
// Notification Erreur
|
||||||
typeof project.userId === "string" &&
|
const projectData = (await refList.projects.doc(projectId).get()).data();
|
||||||
project.userId.trim()
|
if (projectData?.userId) {
|
||||||
) {
|
|
||||||
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({
|
await sendNotification({
|
||||||
sender: "SYSTEM",
|
sender: "SYSTEM",
|
||||||
receiver: receiverId,
|
receiver: projectData.userId,
|
||||||
receiverCollection: "users",
|
receiverCollection: "users",
|
||||||
title: "Pochette indisponible",
|
title: "Échec pochette",
|
||||||
message,
|
message: `Impossible de générer la pochette pour "${projectData.title || "ton projet"}".`,
|
||||||
data: {
|
data: {
|
||||||
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
|
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
|
||||||
projectId,
|
projectId,
|
||||||
projectTitle,
|
error: error.message,
|
||||||
error: error?.message || String(error),
|
|
||||||
},
|
},
|
||||||
});
|
}).catch(() => {});
|
||||||
} catch (notifError) {
|
|
||||||
logger.error("❌ [Cover] Failed to send error notification", {
|
|
||||||
projectId,
|
|
||||||
error: notifError?.message || String(notifError),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+116
-209
@@ -10,6 +10,8 @@ const axios = require("axios");
|
|||||||
const { FieldValue } = require("firebase-admin/firestore");
|
const { FieldValue } = require("firebase-admin/firestore");
|
||||||
const { refList } = require("../index");
|
const { refList } = require("../index");
|
||||||
|
|
||||||
|
// --- CONSTANTES DE STRUCTURE ---
|
||||||
|
// On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur
|
||||||
const STRUCTURE_PROMPT_LABELS = {
|
const STRUCTURE_PROMPT_LABELS = {
|
||||||
couplet: "couplet",
|
couplet: "couplet",
|
||||||
refrain: "refrain",
|
refrain: "refrain",
|
||||||
@@ -60,6 +62,8 @@ const STRUCTURE_ALIASES = {
|
|||||||
soft_transition: "transition_douce",
|
soft_transition: "transition_douce",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- UTILITAIRES ---
|
||||||
|
|
||||||
const normalizeStructureValue = (value) => {
|
const normalizeStructureValue = (value) => {
|
||||||
const raw = String(value || "")
|
const raw = String(value || "")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -68,47 +72,16 @@ const normalizeStructureValue = (value) => {
|
|||||||
if (STRUCTURE_PROMPT_LABELS[raw]) return raw;
|
if (STRUCTURE_PROMPT_LABELS[raw]) return raw;
|
||||||
if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw];
|
if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw];
|
||||||
const sanitized = raw.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
const sanitized = raw.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
||||||
if (STRUCTURE_PROMPT_LABELS[sanitized]) return sanitized;
|
return STRUCTURE_PROMPT_LABELS[sanitized]
|
||||||
if (STRUCTURE_ALIASES[sanitized]) return STRUCTURE_ALIASES[sanitized];
|
? sanitized
|
||||||
return 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 = []) => {
|
const sanitizeStructureEntries = (structure = []) => {
|
||||||
if (!Array.isArray(structure)) return [];
|
if (!Array.isArray(structure)) return [];
|
||||||
const output = [];
|
return structure.map(normalizeStructureValue).filter(Boolean);
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStructureToPrompt = (structure = []) =>
|
const mapStructureToPrompt = (structure = []) =>
|
||||||
@@ -116,16 +89,7 @@ const mapStructureToPrompt = (structure = []) =>
|
|||||||
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry,
|
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry,
|
||||||
);
|
);
|
||||||
|
|
||||||
const TOXIC_KEYWORDS = [
|
// --- MODERATION ---
|
||||||
/\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 = ({
|
const buildModerationBrief = ({
|
||||||
objective,
|
objective,
|
||||||
@@ -144,34 +108,13 @@ const buildModerationBrief = ({
|
|||||||
`<AUDIENCE>${audience || ""}</AUDIENCE>`,
|
`<AUDIENCE>${audience || ""}</AUDIENCE>`,
|
||||||
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}</RIMES>`,
|
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}</RIMES>`,
|
||||||
];
|
];
|
||||||
|
|
||||||
const promptStructure = mapStructureToPrompt(structure);
|
const promptStructure = mapStructureToPrompt(structure);
|
||||||
if (promptStructure.length) {
|
if (promptStructure.length)
|
||||||
sections.push(`<STRUCTURE>${promptStructure.join(" | ")}</STRUCTURE>`);
|
sections.push(`<STRUCTURE>${promptStructure.join(" | ")}</STRUCTURE>`);
|
||||||
}
|
|
||||||
|
|
||||||
return `<BRIEF_UTILISATEUR>\n${sections.join("\n")}\n</BRIEF_UTILISATEUR>`;
|
return `<BRIEF_UTILISATEUR>\n${sections.join("\n")}\n</BRIEF_UTILISATEUR>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const moderationIndicatesBlock = (moderation = {}, fallbackText = "") => {
|
// --- GENERATION DE PAROLES (MAIN) ---
|
||||||
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 = {} }) => {
|
exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
|
||||||
try {
|
try {
|
||||||
@@ -185,6 +128,7 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
|
|||||||
rhymes = "",
|
rhymes = "",
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
|
// 1. Préparation Structure
|
||||||
const sanitizedStructure = sanitizeStructureEntries(rawStructure);
|
const sanitizedStructure = sanitizeStructureEntries(rawStructure);
|
||||||
const promptStructure = sanitizedStructure.length
|
const promptStructure = sanitizedStructure.length
|
||||||
? sanitizedStructure.map(
|
? 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 = {
|
const moderationPayload = {
|
||||||
objective,
|
objective,
|
||||||
context,
|
context,
|
||||||
@@ -201,59 +147,62 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
|
|||||||
structure: promptStructure,
|
structure: promptStructure,
|
||||||
rhymes,
|
rhymes,
|
||||||
};
|
};
|
||||||
|
|
||||||
const moderationInput = buildModerationBrief(moderationPayload);
|
const moderationInput = buildModerationBrief(moderationPayload);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const moderation = await analyseLyrics({
|
const moderation = await analyseLyrics({
|
||||||
title: "Brief utilisateur",
|
title: "Brief utilisateur (Pré-génération)",
|
||||||
lyrics: moderationInput,
|
lyrics: moderationInput,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (moderationIndicatesBlock(moderation, moderationInput)) {
|
// Si Gemini dit "Blocked", on bloque. C'est la seule autorité.
|
||||||
console.warn("generateLyrics blocked by moderation", {
|
if (moderation?.blocked === true) {
|
||||||
reasons: moderation?.reasons,
|
console.warn("⛔ generateLyrics blocked by Gemini Pro", {
|
||||||
excerpts: moderation?.excerpts,
|
reasons: moderation.reasons,
|
||||||
});
|
});
|
||||||
|
const summary = Array.isArray(moderation.reasons)
|
||||||
const summary = Array.isArray(moderation?.reasons)
|
|
||||||
? moderation.reasons.slice(0, 3).join(", ")
|
? moderation.reasons.slice(0, 3).join(", ")
|
||||||
: "contenu sensible";
|
: "Contenu non conforme";
|
||||||
|
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"invalid-argument",
|
"invalid-argument",
|
||||||
`Certaines informations saisies contiennent des termes interdits (${summary}). Merci de reformuler pour poursuivre la création des paroles.`,
|
`Demande refusée par la modération : ${summary}.`,
|
||||||
{ moderation },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (moderationError) {
|
} catch (moderationError) {
|
||||||
if (moderationError instanceof HttpsError) {
|
if (moderationError instanceof HttpsError) throw moderationError;
|
||||||
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.
|
||||||
console.error("generateLyrics moderation failure", moderationError);
|
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
"internal",
|
"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 = `
|
const system = `
|
||||||
Tu es un parolier expert en chanson française moderne et populaire.
|
Tu es le meilleur parolier musical actuel, expert en "Songwriting" pour les IA génératives audio (Suno/Udio).
|
||||||
Analyse attentivement les informations placées entre balises XML-like.
|
Ta spécialité est la **Prosodie** (le rythme naturel des mots) et l'impact émotionnel.
|
||||||
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.
|
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();
|
`.trim();
|
||||||
|
|
||||||
const structureTags = (
|
const structureTags = (
|
||||||
Array.isArray(promptStructure) ? promptStructure : []
|
Array.isArray(promptStructure) ? promptStructure : []
|
||||||
)
|
)
|
||||||
.map((part, index) => {
|
.map((part, index) => ` <SECTION ordre="${index + 1}">${part}</SECTION>`)
|
||||||
const content = part || "";
|
|
||||||
return ` <SECTION ordre="${index + 1}">${content}</SECTION>`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
const fallbackStructureTags = [
|
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>',
|
' <SECTION ordre="2">refrain</SECTION>',
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
// Construction du prompt utilisateur avec des balises explicites
|
|
||||||
const prompt = `
|
const prompt = `
|
||||||
<BRIEF>
|
<BRIEF_CREATIF>
|
||||||
<OBJECTIF>${objective || ""}</OBJECTIF>
|
<OBJECTIF>${objective || "Créer une chanson mémorable"}</OBJECTIF>
|
||||||
<CONTEXTE>${context || ""}</CONTEXTE>
|
<CONTEXTE>${context || "Libre interprétation"}</CONTEXTE>
|
||||||
<EMOTION>${emotion || ""}</EMOTION>
|
<EMOTION_DOMINANTE>${emotion || "Intense"}</EMOTION_DOMINANTE>
|
||||||
<STYLE_MUSICAL>${style || ""}</STYLE_MUSICAL>
|
<STYLE_MUSICAL>${style || "Pop Moderne"}</STYLE_MUSICAL>
|
||||||
<AUDIENCE>${audience || ""}</AUDIENCE>
|
<CIBLE>${audience || "Tout public"}</CIBLE>
|
||||||
<SCHEMA_DE_RIMES>${rhymes || ""}</SCHEMA_DE_RIMES>
|
<TYPE_DE_RIMES>${rhymes || "Rimes croisées et riches"}</TYPE_DE_RIMES>
|
||||||
</BRIEF>
|
</BRIEF_CREATIF>
|
||||||
|
|
||||||
<STRUCTURE_DEMANDEE>
|
<STRUCTURE_IMPOSEE>
|
||||||
${structureTags || fallbackStructureTags}
|
${structureTags || fallbackStructureTags}
|
||||||
</STRUCTURE_DEMANDEE>
|
</STRUCTURE_IMPOSEE>
|
||||||
|
|
||||||
<CONTRAINTES>
|
<CONSIGNES_GENERATION>
|
||||||
<TITRE>
|
1. Génère un **Titre** percutant (moins de 6 mots).
|
||||||
Génère un titre court, mémorable et aligné avec l'esprit de la musique.
|
2. Pour chaque section <SECTION>, écris le contenu adapté :
|
||||||
</TITRE>
|
- 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)".
|
||||||
<PAROLES>
|
- Si c'est "Couplet/Refrain" : Écris 4 à 12 vers.
|
||||||
Pour chaque section demandée, rédige entre 4 et 16 lignes expressives.
|
3. **IMPORTANT** : Le style est "${style}". Assure-toi que le vocabulaire et le rythme collent parfaitement à ce genre.
|
||||||
Respecte strictement l'ordre et le type de chaque section (couplet, refrain, etc.).
|
</CONSIGNES_GENERATION>
|
||||||
</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();
|
`.trim();
|
||||||
|
|
||||||
// Définition du schéma zod/genkit pour la validation du retour
|
|
||||||
const lyricsSchema = z.object({
|
const lyricsSchema = z.object({
|
||||||
title: z.string().describe("Titre de la chanson, court et accrocheur"),
|
title: z.string().describe("Titre de la chanson, court et accrocheur"),
|
||||||
lyrics: z
|
lyrics: z
|
||||||
@@ -303,48 +241,51 @@ ${structureTags || fallbackStructureTags}
|
|||||||
type: z
|
type: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.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
|
lyrics: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.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(
|
.describe("La structure complète de la chanson"),
|
||||||
"Paroles de la chanson sous forme de tableau de sections structurées.",
|
|
||||||
),
|
|
||||||
lyricsDescription: z
|
lyricsDescription: z
|
||||||
.string()
|
.string()
|
||||||
.describe("Courte déscription qui résume les paroles de la musique"),
|
.describe(
|
||||||
success: z.boolean().describe("Indique si la génération a réussi"),
|
"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({
|
return await generateAI({
|
||||||
system,
|
system,
|
||||||
prompt,
|
prompt,
|
||||||
schema: lyricsSchema,
|
schema: lyricsSchema,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} 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 severityScore = (severity = "") => {
|
||||||
const normalized = String(severity || "").toLowerCase();
|
const normalized = String(severity || "").toLowerCase();
|
||||||
switch (normalized) {
|
if (normalized === "critical") return 4;
|
||||||
case "critical":
|
if (normalized === "high") return 3;
|
||||||
return 4;
|
if (normalized === "medium") return 2;
|
||||||
case "high":
|
if (normalized === "low") return 1;
|
||||||
return 3;
|
|
||||||
case "medium":
|
|
||||||
return 2;
|
|
||||||
case "low":
|
|
||||||
return 1;
|
|
||||||
default:
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const softenModerationDecision = (rawResult = {}) => {
|
const softenModerationDecision = (rawResult = {}) => {
|
||||||
@@ -361,6 +302,7 @@ const softenModerationDecision = (rawResult = {}) => {
|
|||||||
? Math.min(Math.max(result.score, 0), 1)
|
? Math.min(Math.max(result.score, 0), 1)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
// Détection de contexte narratif pour être plus indulgent
|
||||||
const narrativeHint = reasons.some((reason) =>
|
const narrativeHint = reasons.some((reason) =>
|
||||||
/narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test(
|
/narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test(
|
||||||
reason || "",
|
reason || "",
|
||||||
@@ -369,9 +311,11 @@ const softenModerationDecision = (rawResult = {}) => {
|
|||||||
|
|
||||||
const adjustments = [];
|
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) {
|
if (result.blocked) {
|
||||||
const shouldUnblockLowSeverity = highestSeverity <= 1 && score < 0.65;
|
// Débloque uniquement les erreurs manifestes (score bas mais blocked true par erreur)
|
||||||
if (shouldUnblockLowSeverity) {
|
if (highestSeverity <= 1 && score < 0.65) {
|
||||||
result.blocked = false;
|
result.blocked = false;
|
||||||
adjustments.push("auto-unblock-low-severity");
|
adjustments.push("auto-unblock-low-severity");
|
||||||
}
|
}
|
||||||
@@ -380,6 +324,7 @@ const softenModerationDecision = (rawResult = {}) => {
|
|||||||
if (!result.blocked && result.flagged) {
|
if (!result.blocked && result.flagged) {
|
||||||
const lowSignal = highestSeverity <= 1 && score < 0.4;
|
const lowSignal = highestSeverity <= 1 && score < 0.4;
|
||||||
const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55;
|
const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55;
|
||||||
|
|
||||||
if (lowSignal) {
|
if (lowSignal) {
|
||||||
result.flagged = false;
|
result.flagged = false;
|
||||||
adjustments.push("drop-flag-low-signal");
|
adjustments.push("drop-flag-low-signal");
|
||||||
@@ -392,18 +337,12 @@ const softenModerationDecision = (rawResult = {}) => {
|
|||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
moderationAdjustments: adjustments,
|
moderationAdjustments: adjustments,
|
||||||
moderationCalibration: {
|
moderationCalibration: { highestSeverity, score },
|
||||||
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 = {} }) => {
|
exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
|
||||||
try {
|
try {
|
||||||
// Validation d'entrée minimale
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
lyrics: z
|
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 || {});
|
const parsed = requestSchema.parse(data || {});
|
||||||
@@ -430,8 +369,7 @@ exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
|
|||||||
});
|
});
|
||||||
const result = softenModerationDecision(aiResult);
|
const result = softenModerationDecision(aiResult);
|
||||||
|
|
||||||
// Prépare un message lisible pour le front
|
const highCats = Object.entries(result.categories || {}) // Note: categories n'est pas dans le schema Zod actuel de Gemini, à vérifier si besoin
|
||||||
const highCats = Object.entries(result.categories || {})
|
|
||||||
.filter(([, v]) => (typeof v === "number" ? v : 0) >= 0.5)
|
.filter(([, v]) => (typeof v === "number" ? v : 0) >= 0.5)
|
||||||
.map(([k]) => k)
|
.map(([k]) => k)
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
@@ -441,60 +379,41 @@ exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
|
|||||||
|
|
||||||
if (result.blocked) {
|
if (result.blocked) {
|
||||||
errorCode = "TOXIC_CONTENT_BLOCKED";
|
errorCode = "TOXIC_CONTENT_BLOCKED";
|
||||||
const list = highCats.length ? ` (${highCats.join(", ")})` : "";
|
message = `Contenu bloqué par sécurité.`;
|
||||||
message = `Publication bloquée: contenu interdit détecté${list}.`;
|
|
||||||
} else if (result.flagged) {
|
} else if (result.flagged) {
|
||||||
errorCode = "TOXIC_CONTENT_FLAGGED";
|
errorCode = "TOXIC_CONTENT_FLAGGED";
|
||||||
const list = highCats.length ? ` (${highCats.join(", ")})` : "";
|
message = `Attention: contenu sensible détecté.`;
|
||||||
message = `Attention: contenu potentiellement sensible détecté${list}.`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { success: !result.blocked, errorCode, message, result };
|
||||||
success: !result.blocked,
|
|
||||||
errorCode,
|
|
||||||
message,
|
|
||||||
result,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("analyseLyricsToxicity failed", err?.message || err);
|
console.error("analyseLyricsToxicity failed", err?.message || err);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
errorCode: "ANALYSE_FAILED",
|
errorCode: "ANALYSE_FAILED",
|
||||||
message: "Erreur lors de l'analyse de la toxicité. Réessayez plus tard.",
|
message: "Erreur analyse toxicité.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// --- SUNO TIMESTAMPS (EXISTANT) ---
|
||||||
* Récupère les timestamps (aligned words) pour une génération Suno
|
|
||||||
* Attend: { projectId: string }
|
|
||||||
*/
|
|
||||||
async function getSunoTimestamps(projectId) {
|
async function getSunoTimestamps(projectId) {
|
||||||
try {
|
try {
|
||||||
const { sunoTaskId, songIndex } = (
|
if (!projectId || typeof projectId !== "string")
|
||||||
await refList.projects.doc(projectId).get()
|
throw new Error("projectId invalide");
|
||||||
).data();
|
|
||||||
if (!sunoTaskId) {
|
|
||||||
throw new Error("TaskId manquant");
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
throw new Error("musicIndex invalide");
|
||||||
}
|
|
||||||
|
|
||||||
if (!projectId || typeof projectId !== "string" || !projectId.trim()) {
|
console.log("🔎 getSunoTimestamps", { sunoTaskId, songIndex });
|
||||||
throw new Error("projectId manquant ou invalide");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("🔎 Récupération des timestamps Suno", {
|
const response = await axios.post(
|
||||||
sunoTaskId,
|
|
||||||
songIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
let response;
|
|
||||||
let parsed;
|
|
||||||
|
|
||||||
response = await axios.post(
|
|
||||||
`${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`,
|
`${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`,
|
||||||
{ taskId: sunoTaskId, musicIndex: songIndex },
|
{ taskId: sunoTaskId, musicIndex: songIndex },
|
||||||
{
|
{
|
||||||
@@ -505,35 +424,23 @@ async function getSunoTimestamps(projectId) {
|
|||||||
timeout: 30000,
|
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(
|
await refList.projects.doc(projectId).set(
|
||||||
{
|
{
|
||||||
musicTimestamps: {
|
musicTimestamps: { [songIndex]: dataToReturn },
|
||||||
[songIndex]: dataToReturn,
|
|
||||||
},
|
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true },
|
||||||
);
|
);
|
||||||
console.log("💾 Timestamps sauvegardés dans Firestore", {
|
|
||||||
projectId,
|
return { success: true };
|
||||||
songIndex,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Erreur interne getSunoTimestamps:", error);
|
console.error("❌ getSunoTimestamps Error:", error.message);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: { message: error.message, type: "INTERNAL_ERROR" },
|
||||||
message: error.message,
|
|
||||||
type: "INTERNAL_ERROR",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+428
-622
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 26 MiB After Width: | Height: | Size: 4.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 528 KiB After Width: | Height: | Size: 2.7 MiB |
@@ -227,6 +227,7 @@ export const videos = {
|
|||||||
Platform.OS === "web"
|
Platform.OS === "web"
|
||||||
? require("./video/testVideoWeb.mp4")
|
? require("./video/testVideoWeb.mp4")
|
||||||
: require("./video/testVideo.mp4"),
|
: require("./video/testVideo.mp4"),
|
||||||
|
club: require("./video/club.mp4"),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const img = {
|
export const img = {
|
||||||
|
|||||||
Binary file not shown.
+19
-32
@@ -29,37 +29,30 @@ export default ({
|
|||||||
const SLIDER_WIDTH = layout?.width;
|
const SLIDER_WIDTH = layout?.width;
|
||||||
const MAX_VALUE = SLIDER_WIDTH - INITIAL_BOX_SIZE;
|
const MAX_VALUE = SLIDER_WIDTH - INITIAL_BOX_SIZE;
|
||||||
|
|
||||||
|
const handleSeekStart = () => {
|
||||||
|
if (seekEnabled && typeof onSeekStart === "function" && !seekingRef.current) {
|
||||||
|
seekingRef.current = true;
|
||||||
|
onSeekStart();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeekEnd = () => {
|
||||||
|
if (seekingRef.current && typeof onSeekEnd === "function") {
|
||||||
|
seekingRef.current = false;
|
||||||
|
onSeekEnd();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const pan = Gesture.Pan()
|
const pan = Gesture.Pan()
|
||||||
.enabled(seekEnabled)
|
.enabled(seekEnabled)
|
||||||
.onBegin(() => {
|
.onBegin(() => {
|
||||||
if (
|
runOnJS(handleSeekStart)();
|
||||||
seekEnabled &&
|
|
||||||
typeof onSeekStart === "function" &&
|
|
||||||
!seekingRef.current
|
|
||||||
) {
|
|
||||||
seekingRef.current = true;
|
|
||||||
runOnJS(onSeekStart)();
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
if (
|
runOnJS(handleSeekStart)();
|
||||||
seekEnabled &&
|
|
||||||
typeof onSeekStart === "function" &&
|
|
||||||
!seekingRef.current
|
|
||||||
) {
|
|
||||||
seekingRef.current = true;
|
|
||||||
runOnJS(onSeekStart)();
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.onChange((event) => {
|
.onChange((event) => {
|
||||||
if (
|
runOnJS(handleSeekStart)();
|
||||||
seekEnabled &&
|
|
||||||
typeof onSeekStart === "function" &&
|
|
||||||
!seekingRef.current
|
|
||||||
) {
|
|
||||||
seekingRef.current = true;
|
|
||||||
runOnJS(onSeekStart)();
|
|
||||||
}
|
|
||||||
offset.value =
|
offset.value =
|
||||||
Math.abs(offset.value) <= MAX_VALUE
|
Math.abs(offset.value) <= MAX_VALUE
|
||||||
? offset.value + event.changeX <= 0
|
? offset.value + event.changeX <= 0
|
||||||
@@ -81,16 +74,10 @@ export default ({
|
|||||||
// Reanimated -> JS thread bridge
|
// Reanimated -> JS thread bridge
|
||||||
runOnJS(onSeek)(ratio);
|
runOnJS(onSeek)(ratio);
|
||||||
}
|
}
|
||||||
if (seekingRef.current && typeof onSeekEnd === "function") {
|
runOnJS(handleSeekEnd)();
|
||||||
seekingRef.current = false;
|
|
||||||
runOnJS(onSeekEnd)();
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.onFinalize(() => {
|
.onFinalize(() => {
|
||||||
if (seekingRef.current && typeof onSeekEnd === "function") {
|
runOnJS(handleSeekEnd)();
|
||||||
seekingRef.current = false;
|
|
||||||
runOnJS(onSeekEnd)();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reflect external progress into the slider UI
|
// Reflect external progress into the slider UI
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const configureFunctionsEmulator = (instance, regionKey = "us-central1") => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[firebase] Unable to set functions emulator for region ${regionKey}`,
|
`[firebase] Unable to set functions emulator for region ${regionKey}`,
|
||||||
error?.message
|
error?.message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -50,9 +50,9 @@ if (!firebase?.apps?.filter(({ name_ }) => name_ === "[DEFAULT]").length) {
|
|||||||
persistence: getReactNativePersistence(AsyncStorage),
|
persistence: getReactNativePersistence(AsyncStorage),
|
||||||
});
|
});
|
||||||
|
|
||||||
// if (__DEV__) {
|
if (__DEV__) {
|
||||||
// firebase.functions().useEmulator("localhost", 5001);
|
firebase.functions().useEmulator("localhost", 5001);
|
||||||
// }
|
}
|
||||||
const defaultFunctions = firebase.functions();
|
const defaultFunctions = firebase.functions();
|
||||||
functionsInstances["us-central1"] = defaultFunctions;
|
functionsInstances["us-central1"] = defaultFunctions;
|
||||||
configureFunctionsEmulator(defaultFunctions, "us-central1");
|
configureFunctionsEmulator(defaultFunctions, "us-central1");
|
||||||
|
|||||||
+102
-27
@@ -451,6 +451,102 @@ const PlayerProvider = ({ children }) => {
|
|||||||
} catch (_err) { }
|
} catch (_err) { }
|
||||||
}, [player, isLooping]);
|
}, [player, isLooping]);
|
||||||
|
|
||||||
|
|
||||||
|
const seekDebounceRef = useRef(null);
|
||||||
|
const pendingSeekPosRef = useRef(null);
|
||||||
|
const isSeekingRef = useRef(false);
|
||||||
|
|
||||||
|
const waitForActiveSeek = useCallback(async () => {
|
||||||
|
if (!isSeekingRef.current) return;
|
||||||
|
// Poll every 50ms until seek is done
|
||||||
|
while (isSeekingRef.current) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seekTo = useCallback(
|
||||||
|
async (positionMs) => {
|
||||||
|
if (!player) return;
|
||||||
|
const bounded = Math.max(0, Number(positionMs) || 0);
|
||||||
|
const seekValue = toPlayerSeekValue(bounded);
|
||||||
|
|
||||||
|
// Cancel any pending debounce
|
||||||
|
if (seekDebounceRef.current) {
|
||||||
|
clearTimeout(seekDebounceRef.current);
|
||||||
|
seekDebounceRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store pending seek position
|
||||||
|
pendingSeekPosRef.current = seekValue;
|
||||||
|
|
||||||
|
// Update local state immediately for UI responsiveness
|
||||||
|
setPlayback((prev) => ({
|
||||||
|
...prev,
|
||||||
|
positionMs: bounded,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
seekDebounceRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
isSeekingRef.current = true;
|
||||||
|
if (status?.isLoaded) {
|
||||||
|
await player.seekTo?.(seekValue);
|
||||||
|
} else {
|
||||||
|
pendingSeekValueRef.current = seekValue;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
isSeekingRef.current = false;
|
||||||
|
pendingSeekPosRef.current = null;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 100); // 100ms debounce
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[player, status?.isLoaded, toPlayerSeekValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const seekBy = useCallback(
|
||||||
|
async (deltaMs) => {
|
||||||
|
const currentPos = pendingSeekPosRef.current !== null
|
||||||
|
? (Platform.OS === "web" ? pendingSeekPosRef.current : pendingSeekPosRef.current * 1000)
|
||||||
|
: playback.positionMs;
|
||||||
|
const next = Math.max(0, currentPos + Number(deltaMs || 0));
|
||||||
|
await seekTo(next);
|
||||||
|
},
|
||||||
|
[playback.positionMs, seekTo]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper to flush pending seek before playing
|
||||||
|
const flushPendingSeek = useCallback(async () => {
|
||||||
|
// 1. Cancel pending debounce and execute immediately
|
||||||
|
if (seekDebounceRef.current) {
|
||||||
|
clearTimeout(seekDebounceRef.current);
|
||||||
|
seekDebounceRef.current = null;
|
||||||
|
}
|
||||||
|
if (pendingSeekPosRef.current !== null) {
|
||||||
|
const seekValue = pendingSeekPosRef.current;
|
||||||
|
pendingSeekPosRef.current = null;
|
||||||
|
try {
|
||||||
|
isSeekingRef.current = true;
|
||||||
|
if (status?.isLoaded) {
|
||||||
|
await player.seekTo?.(seekValue);
|
||||||
|
} else {
|
||||||
|
pendingSeekValueRef.current = seekValue;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
isSeekingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Wait for any active seek to complete
|
||||||
|
await waitForActiveSeek();
|
||||||
|
}, [player, status?.isLoaded, waitForActiveSeek]);
|
||||||
|
|
||||||
|
|
||||||
const play = useCallback(
|
const play = useCallback(
|
||||||
async (trackInput, options = {}) => {
|
async (trackInput, options = {}) => {
|
||||||
const normalized = normalizeTrack(trackInput, options);
|
const normalized = normalizeTrack(trackInput, options);
|
||||||
@@ -489,6 +585,9 @@ const PlayerProvider = ({ children }) => {
|
|||||||
if (sameTrack) {
|
if (sameTrack) {
|
||||||
setCurrentTrack((prev) => ({ ...prev, ...normalized }));
|
setCurrentTrack((prev) => ({ ...prev, ...normalized }));
|
||||||
try {
|
try {
|
||||||
|
// Flush any pending seek first
|
||||||
|
await flushPendingSeek();
|
||||||
|
|
||||||
if (targetPositionMs > 0) {
|
if (targetPositionMs > 0) {
|
||||||
await player?.seekTo?.(targetSeekValue);
|
await player?.seekTo?.(targetSeekValue);
|
||||||
}
|
}
|
||||||
@@ -529,17 +628,18 @@ const PlayerProvider = ({ children }) => {
|
|||||||
}));
|
}));
|
||||||
setCurrentTrack(normalized);
|
setCurrentTrack(normalized);
|
||||||
},
|
},
|
||||||
[currentTrack?.id, player, toPlayerSeekValue, updateQueue]
|
[currentTrack?.id, player, toPlayerSeekValue, updateQueue, flushPendingSeek]
|
||||||
);
|
);
|
||||||
|
|
||||||
const resume = useCallback(async () => {
|
const resume = useCallback(async () => {
|
||||||
if (!currentTrack) return;
|
if (!currentTrack) return;
|
||||||
try {
|
try {
|
||||||
|
await flushPendingSeek();
|
||||||
await player?.play?.();
|
await player?.play?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err);
|
setError(err);
|
||||||
}
|
}
|
||||||
}, [player, currentTrack]);
|
}, [player, currentTrack, flushPendingSeek]);
|
||||||
|
|
||||||
const pause = useCallback(async () => {
|
const pause = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -578,32 +678,7 @@ const PlayerProvider = ({ children }) => {
|
|||||||
[currentTrack, playback.isPlaying, pause, play, resume]
|
[currentTrack, playback.isPlaying, pause, play, resume]
|
||||||
);
|
);
|
||||||
|
|
||||||
const seekTo = useCallback(
|
|
||||||
async (positionMs) => {
|
|
||||||
if (!player) return;
|
|
||||||
const bounded = Math.max(0, Number(positionMs) || 0);
|
|
||||||
const seekValue = toPlayerSeekValue(bounded);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (status?.isLoaded) {
|
|
||||||
await player.seekTo?.(seekValue);
|
|
||||||
} else {
|
|
||||||
pendingSeekValueRef.current = seekValue;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[player, status?.isLoaded, toPlayerSeekValue]
|
|
||||||
);
|
|
||||||
|
|
||||||
const seekBy = useCallback(
|
|
||||||
async (deltaMs) => {
|
|
||||||
const next = Math.max(0, playback.positionMs + Number(deltaMs || 0));
|
|
||||||
await seekTo(next);
|
|
||||||
},
|
|
||||||
[playback.positionMs, seekTo]
|
|
||||||
);
|
|
||||||
|
|
||||||
const stop = useCallback(async () => {
|
const stop = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import React, { memo } from "react";
|
import React, { memo, useEffect } from "react";
|
||||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
import { Pressable, StyleSheet, View } from "react-native";
|
||||||
import { Image as ExpoImage } from "expo-image";
|
import { useVideoPlayer, VideoView } from "expo-video";
|
||||||
import { FONT_FAMILY } from "../../../styles/Fonts";
|
import { videos } from "../../../assets";
|
||||||
import { Palette } from "../../../styles";
|
|
||||||
import { icons } from "../../../assets";
|
|
||||||
import { isWeb } from "../../../hooks/useLayoutType.js";
|
import { isWeb } from "../../../hooks/useLayoutType.js";
|
||||||
|
|
||||||
const ClubCard = ({ image, onPress, hasActiveSubscription = false }) => {
|
const ClubCard = ({ onPress }) => {
|
||||||
const subtitle = hasActiveSubscription
|
const player = useVideoPlayer(videos.club, (player) => {
|
||||||
? "Tu fais déjà partie du club !"
|
player.loop = true;
|
||||||
: "Rejoins le club !";
|
player.muted = true;
|
||||||
|
player.play();
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (player) {
|
||||||
|
player.muted = true;
|
||||||
|
player.loop = true;
|
||||||
|
player.play();
|
||||||
|
}
|
||||||
|
}, [player]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -17,13 +25,12 @@ const ClubCard = ({ image, onPress, hasActiveSubscription = false }) => {
|
|||||||
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
|
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
|
||||||
>
|
>
|
||||||
<View style={styles.inner}>
|
<View style={styles.inner}>
|
||||||
<ExpoImage
|
<VideoView
|
||||||
source={icons.club}
|
player={player}
|
||||||
contentFit="contain"
|
style={styles.video}
|
||||||
style={styles.clubLogo}
|
contentFit="cover"
|
||||||
|
nativeControls={false}
|
||||||
/>
|
/>
|
||||||
<ExpoImage source={image} contentFit="contain" style={styles.image} />
|
|
||||||
<Text style={styles.subtitle}>{subtitle}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -34,32 +41,20 @@ export default memo(ClubCard);
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
marginTop: isWeb ? 16 : 28,
|
marginTop: isWeb ? 16 : 28,
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor: "#252438",
|
backgroundColor: "#252438",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
width: 200,
|
width: 200,
|
||||||
|
height: 120, // Added fixed height to ensure video visibility, adjusting based on previous content size estimation
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
},
|
},
|
||||||
cardPressed: {
|
cardPressed: {
|
||||||
opacity: 0.85,
|
opacity: 0.85,
|
||||||
},
|
},
|
||||||
inner: {
|
inner: {
|
||||||
paddingVertical: 12,
|
flex: 1,
|
||||||
paddingHorizontal: 14,
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
},
|
},
|
||||||
clubLogo: {
|
video: {
|
||||||
width: 160,
|
width: "100%",
|
||||||
height: 36,
|
height: "100%",
|
||||||
},
|
|
||||||
image: {
|
|
||||||
width: 52,
|
|
||||||
height: 52,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontFamily: FONT_FAMILY.InterMedium,
|
|
||||||
fontSize: 12,
|
|
||||||
color: Palette.white,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,8 +24,14 @@ import {
|
|||||||
import { useGlobal } from "reactn";
|
import { useGlobal } from "reactn";
|
||||||
import { background, icons } from "../../assets";
|
import { background, icons } from "../../assets";
|
||||||
import PressableScale from "../../components/PressableScale";
|
import PressableScale from "../../components/PressableScale";
|
||||||
import ProgressSlider from "../../components/player/ProgressSlider";
|
import Slider from "../../components/Slider";
|
||||||
import { increment, projectsRef, usersRef } from "../../config/firebase";
|
import {
|
||||||
|
arrayRemove,
|
||||||
|
arrayUnion,
|
||||||
|
increment,
|
||||||
|
projectsRef,
|
||||||
|
usersRef,
|
||||||
|
} from "../../config/firebase";
|
||||||
import useDataFromRef from "../../hooks/useDataFromRef";
|
import useDataFromRef from "../../hooks/useDataFromRef";
|
||||||
import usePlayer from "../../hooks/usePlayer";
|
import usePlayer from "../../hooks/usePlayer";
|
||||||
import useTrackController from "../../hooks/useTrackController";
|
import useTrackController from "../../hooks/useTrackController";
|
||||||
@@ -39,11 +45,6 @@ import {
|
|||||||
createMusicSharePayload,
|
createMusicSharePayload,
|
||||||
openShareSheet,
|
openShareSheet,
|
||||||
} from "../../utils/shareSheet";
|
} from "../../utils/shareSheet";
|
||||||
import {
|
|
||||||
getProjectLikes,
|
|
||||||
LIKE_TARGET,
|
|
||||||
toggleProjectLike,
|
|
||||||
} from "../../utils/likes";
|
|
||||||
import {
|
import {
|
||||||
formatStructureLabel,
|
formatStructureLabel,
|
||||||
getPromptLabelForStructure,
|
getPromptLabelForStructure,
|
||||||
@@ -61,6 +62,9 @@ const MusicDetails = ({ route }) => {
|
|||||||
const projectId = params?.projectId || null;
|
const projectId = params?.projectId || null;
|
||||||
const [fav, setFav] = useState(false);
|
const [fav, setFav] = useState(false);
|
||||||
const [currentUID] = useGlobal("currentUID");
|
const [currentUID] = useGlobal("currentUID");
|
||||||
|
const wasPlayingBeforeSeek = useRef(false);
|
||||||
|
const hasCapturedSeekStateRef = useRef(false);
|
||||||
|
const lastSeekTargetMsRef = useRef(null);
|
||||||
const listenedMsRef = useRef(0);
|
const listenedMsRef = useRef(0);
|
||||||
const incrementDoneRef = useRef(false);
|
const incrementDoneRef = useRef(false);
|
||||||
const timerRef = useRef(null);
|
const timerRef = useRef(null);
|
||||||
@@ -82,10 +86,13 @@ const MusicDetails = ({ route }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const likes = getProjectLikes(project, LIKE_TARGET.SONG);
|
if (project && currentUID) {
|
||||||
const liked = currentUID ? likes.includes(currentUID) : false;
|
const liked = Array.isArray(project?.likedBy)
|
||||||
|
? project.likedBy.includes(currentUID)
|
||||||
|
: false;
|
||||||
setFav(liked);
|
setFav(liked);
|
||||||
}, [currentUID, project]);
|
}
|
||||||
|
}, [project?.likedBy, currentUID]);
|
||||||
|
|
||||||
const title = project?.title || "Sans titre";
|
const title = project?.title || "Sans titre";
|
||||||
const artist = useMemo(() => {
|
const artist = useMemo(() => {
|
||||||
@@ -289,6 +296,15 @@ const MusicDetails = ({ route }) => {
|
|||||||
return clearTimer;
|
return clearTimer;
|
||||||
}, [isTrackPlaying, projectId]);
|
}, [isTrackPlaying, projectId]);
|
||||||
|
|
||||||
|
const fmt = (ms) => {
|
||||||
|
const total = Math.max(0, Math.floor((ms || 0) / 1000));
|
||||||
|
const m = Math.floor(total / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(1, "0");
|
||||||
|
const s = (total % 60).toString().padStart(2, "0");
|
||||||
|
return `${m}:${s}`;
|
||||||
|
};
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
const togglePlay = useCallback(async () => {
|
||||||
if (!trackDescriptor) return;
|
if (!trackDescriptor) return;
|
||||||
try {
|
try {
|
||||||
@@ -316,30 +332,41 @@ const MusicDetails = ({ route }) => {
|
|||||||
|
|
||||||
const handleSliderSeekStart = useCallback(async () => {
|
const handleSliderSeekStart = useCallback(async () => {
|
||||||
if (!trackDescriptor) return;
|
if (!trackDescriptor) return;
|
||||||
|
lastSeekTargetMsRef.current = null;
|
||||||
|
if (!hasCapturedSeekStateRef.current) {
|
||||||
|
hasCapturedSeekStateRef.current = true;
|
||||||
|
wasPlayingBeforeSeek.current = isTrackPlaying;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (!isCurrentTrack) {
|
if (!isCurrentTrack) {
|
||||||
await ensureLoaded({ startPositionMs: positionMs, autoPlay: false });
|
await ensureLoaded({ startPositionMs: positionMs, autoPlay: false });
|
||||||
}
|
}
|
||||||
|
if (isTrackPlaying) {
|
||||||
|
await pauseTrack();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("MusicDetails seek start error", e?.message);
|
console.log("MusicDetails seek start error", e?.message);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
trackDescriptor,
|
trackDescriptor,
|
||||||
|
isTrackPlaying,
|
||||||
isCurrentTrack,
|
isCurrentTrack,
|
||||||
ensureLoaded,
|
ensureLoaded,
|
||||||
positionMs,
|
positionMs,
|
||||||
|
pauseTrack,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSliderSeek = useCallback(
|
const handleSliderSeek = useCallback(
|
||||||
async (targetMs) => {
|
async (ratio) => {
|
||||||
const dur = sliderDurationMs || 0;
|
const dur = sliderDurationMs || 0;
|
||||||
if (!trackDescriptor || dur <= 0) return;
|
if (!trackDescriptor || dur <= 0) return;
|
||||||
const bounded = Math.max(0, Math.min(dur, Math.floor(targetMs)));
|
const targetMs = Math.max(0, Math.floor(dur * ratio));
|
||||||
|
lastSeekTargetMsRef.current = targetMs;
|
||||||
try {
|
try {
|
||||||
if (!isCurrentTrack) {
|
if (!isCurrentTrack) {
|
||||||
await ensureLoaded({ startPositionMs: bounded, autoPlay: false });
|
await ensureLoaded({ startPositionMs: targetMs, autoPlay: false });
|
||||||
} else {
|
} else {
|
||||||
await seekTrackTo(bounded);
|
await seekTrackTo(targetMs);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("MusicDetails seek error", e?.message);
|
console.log("MusicDetails seek error", e?.message);
|
||||||
@@ -401,6 +428,37 @@ const MusicDetails = ({ route }) => {
|
|||||||
resumeTrack,
|
resumeTrack,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleSliderSeekEnd = useCallback(async () => {
|
||||||
|
const targetMs =
|
||||||
|
typeof lastSeekTargetMsRef.current === "number"
|
||||||
|
? Math.max(0, lastSeekTargetMsRef.current)
|
||||||
|
: null;
|
||||||
|
try {
|
||||||
|
if (wasPlayingBeforeSeek.current) {
|
||||||
|
if (!isCurrentTrack) {
|
||||||
|
await ensureLoaded({
|
||||||
|
startPositionMs:
|
||||||
|
targetMs !== null && Number.isFinite(targetMs)
|
||||||
|
? targetMs
|
||||||
|
: positionMs,
|
||||||
|
autoPlay: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (targetMs !== null && Number.isFinite(targetMs)) {
|
||||||
|
await seekTrackTo(targetMs);
|
||||||
|
}
|
||||||
|
await resumeTrack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log("MusicDetails seek end error", e?.message);
|
||||||
|
} finally {
|
||||||
|
wasPlayingBeforeSeek.current = false;
|
||||||
|
hasCapturedSeekStateRef.current = false;
|
||||||
|
lastSeekTargetMsRef.current = null;
|
||||||
|
}
|
||||||
|
}, [ensureLoaded, isCurrentTrack, positionMs, resumeTrack, seekTrackTo]);
|
||||||
|
|
||||||
const handleSeekBySeconds = useCallback(
|
const handleSeekBySeconds = useCallback(
|
||||||
async (deltaSeconds) => {
|
async (deltaSeconds) => {
|
||||||
if (!trackDescriptor) return;
|
if (!trackDescriptor) return;
|
||||||
@@ -455,6 +513,10 @@ const MusicDetails = ({ route }) => {
|
|||||||
// Build a readable text from lyrics with section labels
|
// Build a readable text from lyrics with section labels
|
||||||
if (Array.isArray(project?.lyrics)) {
|
if (Array.isArray(project?.lyrics)) {
|
||||||
return project.lyrics
|
return project.lyrics
|
||||||
|
.filter((s) => {
|
||||||
|
const t = (s?.type || "").toLowerCase();
|
||||||
|
return ["couplet", "refrain"].includes(t);
|
||||||
|
})
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
const body = (s?.lyrics || "").trim();
|
const body = (s?.lyrics || "").trim();
|
||||||
if (!body) return null;
|
if (!body) return null;
|
||||||
@@ -696,7 +758,9 @@ const MusicDetails = ({ route }) => {
|
|||||||
pushCurrent();
|
pushCurrent();
|
||||||
|
|
||||||
let lineIdx = 0;
|
let lineIdx = 0;
|
||||||
return grouped.map((section) => ({
|
return grouped
|
||||||
|
.filter((s) => ["couplet", "refrain"].includes(s.type))
|
||||||
|
.map((section) => ({
|
||||||
...section,
|
...section,
|
||||||
lines: section.lines.map((line) => ({
|
lines: section.lines.map((line) => ({
|
||||||
...line,
|
...line,
|
||||||
@@ -804,11 +868,10 @@ const MusicDetails = ({ route }) => {
|
|||||||
const next = !fav;
|
const next = !fav;
|
||||||
setFav(next);
|
setFav(next);
|
||||||
try {
|
try {
|
||||||
await toggleProjectLike({
|
await projectsRef.doc(projectId).update({
|
||||||
projectId,
|
likedBy: next
|
||||||
target: LIKE_TARGET.SONG,
|
? arrayUnion(currentUID)
|
||||||
currentUID,
|
: arrayRemove(currentUID),
|
||||||
next,
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setFav(!next);
|
setFav(!next);
|
||||||
@@ -834,15 +897,18 @@ const MusicDetails = ({ route }) => {
|
|||||||
</View>
|
</View>
|
||||||
{songUrl && (
|
{songUrl && (
|
||||||
<View style={{ paddingTop: 22 }}>
|
<View style={{ paddingTop: 22 }}>
|
||||||
<ProgressSlider
|
<Slider
|
||||||
positionMs={positionMs}
|
value={fmt(positionMs)}
|
||||||
durationMs={sliderDurationMs}
|
maxValue={fmt(sliderDurationMs)}
|
||||||
isPlaying={isTrackPlaying}
|
progress={
|
||||||
|
sliderDurationMs
|
||||||
|
? Math.min(1, Math.max(0, (positionMs || 0) / sliderDurationMs))
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
seekEnabled={!!songUrl}
|
||||||
onSeekStart={handleSliderSeekStart}
|
onSeekStart={handleSliderSeekStart}
|
||||||
onSeek={handleSliderSeek}
|
onSeek={handleSliderSeek}
|
||||||
onPause={pauseTrack}
|
onSeekEnd={handleSliderSeekEnd}
|
||||||
onPlay={resumeTrack}
|
|
||||||
disabled={!songUrl}
|
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={{ ...Style.containerRow, gap: 24, alignSelf: "center" }}
|
style={{ ...Style.containerRow, gap: 24, alignSelf: "center" }}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import { Image, StyleSheet, View } from "react-native";
|
import { Image, StyleSheet, View } from "react-native";
|
||||||
import { ai, background } from "../../assets";
|
import { ai, background } from "../../assets";
|
||||||
import BorderGradientButton from "../../components/BorderGradientButton";
|
import BorderGradientButton from "../../components/BorderGradientButton";
|
||||||
@@ -8,40 +8,23 @@ import MusicLandHeader from "../../components/MusicLandHeader";
|
|||||||
import Page from "../../layouts/Page";
|
import Page from "../../layouts/Page";
|
||||||
import { isWeb } from "../../hooks/useLayoutType";
|
import { isWeb } from "../../hooks/useLayoutType";
|
||||||
import { Routes } from "../../navigation";
|
import { Routes } from "../../navigation";
|
||||||
import { goBack, navigate } from "../../navigation/NavigationService";
|
import { navigate } from "../../navigation/NavigationService";
|
||||||
import { useUser } from "../../providers/UserDataProvider";
|
import { useUser } from "../../providers/UserDataProvider";
|
||||||
import { gutters } from "../../styles";
|
import { gutters } from "../../styles";
|
||||||
|
|
||||||
const Playback = ({ route, navigation }) => {
|
const Playback = ({ route, navigation }) => {
|
||||||
const { project } = route.params || {};
|
const { project } = route.params || {};
|
||||||
const { videos } = useUser();
|
const { videos } = useUser();
|
||||||
const [showIntro, setShowIntro] = useState(false);
|
const benhaiUrl = isWeb ? videos?.benhaiWeb || null : videos?.benhai;
|
||||||
|
const [showIntro, setShowIntro] = useState(benhaiUrl);
|
||||||
|
|
||||||
const introVideoUrl = useMemo(() => {
|
const handleCloseIntro = () => {
|
||||||
if (!videos) {
|
if (showIntro === benhaiUrl) {
|
||||||
return null;
|
setShowIntro(videos.theo);
|
||||||
|
} else {
|
||||||
|
setShowIntro(null);
|
||||||
}
|
}
|
||||||
return isWeb ? videos?.benhaiWeb || null : videos?.benhai || null;
|
};
|
||||||
}, [videos]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!introVideoUrl) {
|
|
||||||
setShowIntro(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setShowIntro(true);
|
|
||||||
}, [introVideoUrl]);
|
|
||||||
|
|
||||||
const handleCloseIntro = useCallback(() => {
|
|
||||||
setShowIntro(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleShowGuide = useCallback(() => {
|
|
||||||
if (!introVideoUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setShowIntro(true);
|
|
||||||
}, [introVideoUrl]);
|
|
||||||
|
|
||||||
const onPressRecord = useCallback(() => {
|
const onPressRecord = useCallback(() => {
|
||||||
navigate(Routes.RecordPlayback, { project });
|
navigate(Routes.RecordPlayback, { project });
|
||||||
@@ -68,14 +51,14 @@ const Playback = ({ route, navigation }) => {
|
|||||||
/>
|
/>
|
||||||
<BorderGradientButton
|
<BorderGradientButton
|
||||||
title="Guide du Playbacker"
|
title="Guide du Playbacker"
|
||||||
onPress={handleShowGuide}
|
onPress={() => setShowIntro(videos.theo)}
|
||||||
/>
|
/>
|
||||||
{/* <BorderGradientButton title="Importer une vidéo" /> */}
|
{/* <BorderGradientButton title="Importer une vidéo" /> */}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<FullscreenIntroVideo
|
<FullscreenIntroVideo
|
||||||
url={introVideoUrl}
|
url={showIntro}
|
||||||
visible={showIntro && !!introVideoUrl}
|
visible={showIntro}
|
||||||
onClose={handleCloseIntro}
|
onClose={handleCloseIntro}
|
||||||
/>
|
/>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useIsFocused, useRoute } from "@react-navigation/native";
|
import { useIsFocused, useRoute } from "@react-navigation/native";
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import Carousel from "react-native-reanimated-carousel";
|
import Carousel from "react-native-reanimated-carousel";
|
||||||
import { responsiveHeight } from "react-native-responsive-dimensions";
|
import { responsiveHeight } from "react-native-responsive-dimensions";
|
||||||
@@ -9,16 +9,30 @@ import PlaybackItem from "./components/PlaybackItem";
|
|||||||
|
|
||||||
const Playbacks = () => {
|
const Playbacks = () => {
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const carouselRef = useRef(null);
|
|
||||||
const userCache = useRef(new Map());
|
const userCache = useRef(new Map());
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
const { data: playbacks = [], loadMore } = useDataFromRef({
|
const route = useRoute();
|
||||||
|
const focusProjectId =
|
||||||
|
route?.params?.projectId || route?.params?.focusId || null;
|
||||||
|
const { data: rawPlaybacks = [], loadMore } = useDataFromRef({
|
||||||
ref: projectsRef.where("playbackUrl", "!=", null),
|
ref: projectsRef.where("playbackUrl", "!=", null),
|
||||||
simpleRef: false,
|
simpleRef: false,
|
||||||
listener: false,
|
listener: false,
|
||||||
usePagination: true,
|
usePagination: true,
|
||||||
batchSize: 6,
|
batchSize: 6,
|
||||||
});
|
});
|
||||||
|
const focusIndex = useMemo(() => {
|
||||||
|
if (!focusProjectId) return -1;
|
||||||
|
return rawPlaybacks.findIndex((p) => p?.id === focusProjectId);
|
||||||
|
}, [focusProjectId, rawPlaybacks]);
|
||||||
|
|
||||||
|
const playbacks = useMemo(() => {
|
||||||
|
if (focusIndex < 0) return rawPlaybacks;
|
||||||
|
const target = rawPlaybacks[focusIndex];
|
||||||
|
const before = rawPlaybacks.slice(0, focusIndex);
|
||||||
|
const after = rawPlaybacks.slice(focusIndex + 1);
|
||||||
|
return [target, ...after, ...before];
|
||||||
|
}, [focusIndex, rawPlaybacks]);
|
||||||
|
|
||||||
const onSnap = useCallback(
|
const onSnap = useCallback(
|
||||||
(index) => {
|
(index) => {
|
||||||
@@ -31,6 +45,17 @@ const Playbacks = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const triedLoadMoreRef = useRef(0);
|
const triedLoadMoreRef = useRef(0);
|
||||||
|
const focusLoadAttemptsRef = useRef(0);
|
||||||
|
const hasAppliedFocusRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
focusLoadAttemptsRef.current = 0;
|
||||||
|
hasAppliedFocusRef.current = false;
|
||||||
|
if (!focusProjectId) {
|
||||||
|
setActiveIndex(0);
|
||||||
|
}
|
||||||
|
}, [focusProjectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadMore && triedLoadMoreRef.current < 6) {
|
if (loadMore && triedLoadMoreRef.current < 6) {
|
||||||
triedLoadMoreRef.current += 1;
|
triedLoadMoreRef.current += 1;
|
||||||
@@ -38,10 +63,22 @@ const Playbacks = () => {
|
|||||||
}
|
}
|
||||||
}, [loadMore, playbacks]);
|
}, [loadMore, playbacks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusProjectId) return;
|
||||||
|
if (focusIndex >= 0 && !hasAppliedFocusRef.current) {
|
||||||
|
hasAppliedFocusRef.current = true;
|
||||||
|
setActiveIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loadMore && focusLoadAttemptsRef.current < 6) {
|
||||||
|
focusLoadAttemptsRef.current += 1;
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
}, [focusIndex, focusProjectId, loadMore, playbacks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||||
<Carousel
|
<Carousel
|
||||||
ref={carouselRef}
|
|
||||||
data={playbacks}
|
data={playbacks}
|
||||||
vertical
|
vertical
|
||||||
height={responsiveHeight(100)}
|
height={responsiveHeight(100)}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const Playbacks = () => {
|
|||||||
const { getUserByUid } = useUser() || {};
|
const { getUserByUid } = useUser() || {};
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
const {
|
const {
|
||||||
data: playbacks = [],
|
data: rawPlaybacks = [],
|
||||||
loadMore,
|
loadMore,
|
||||||
hasMore,
|
hasMore,
|
||||||
loading,
|
loading,
|
||||||
@@ -43,6 +43,18 @@ const Playbacks = () => {
|
|||||||
usePagination: true,
|
usePagination: true,
|
||||||
batchSize: 6,
|
batchSize: 6,
|
||||||
});
|
});
|
||||||
|
const focusIndex = useMemo(() => {
|
||||||
|
if (!focusProjectId) return -1;
|
||||||
|
return rawPlaybacks.findIndex((p) => p?.id === focusProjectId);
|
||||||
|
}, [focusProjectId, rawPlaybacks]);
|
||||||
|
|
||||||
|
const playbacks = useMemo(() => {
|
||||||
|
if (focusIndex < 0) return rawPlaybacks;
|
||||||
|
const target = rawPlaybacks[focusIndex];
|
||||||
|
const before = rawPlaybacks.slice(0, focusIndex);
|
||||||
|
const after = rawPlaybacks.slice(focusIndex + 1);
|
||||||
|
return [target, ...after, ...before];
|
||||||
|
}, [focusIndex, rawPlaybacks]);
|
||||||
|
|
||||||
const activePlayback = playbacks?.[activeIndex] || null;
|
const activePlayback = playbacks?.[activeIndex] || null;
|
||||||
const activeVideoUrl = activePlayback?.playbackUrl || null;
|
const activeVideoUrl = activePlayback?.playbackUrl || null;
|
||||||
@@ -167,22 +179,32 @@ const Playbacks = () => {
|
|||||||
}, [activeVideoUrl]);
|
}, [activeVideoUrl]);
|
||||||
|
|
||||||
const triedLoadMoreRef = useRef(0);
|
const triedLoadMoreRef = useRef(0);
|
||||||
|
const hasAppliedFocusRef = useRef(false);
|
||||||
|
const focusLoadAttemptsRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hasAppliedFocusRef.current = false;
|
||||||
|
focusLoadAttemptsRef.current = 0;
|
||||||
|
}, [focusProjectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusProjectId) return;
|
if (!focusProjectId) return;
|
||||||
const idx = playbacks.findIndex((p) => p?.id === focusProjectId);
|
if (focusIndex >= 0 && !hasAppliedFocusRef.current) {
|
||||||
if (idx >= 0) {
|
hasAppliedFocusRef.current = true;
|
||||||
lastActiveIndexRef.current = idx;
|
lastActiveIndexRef.current = 0;
|
||||||
setActiveIndex(idx);
|
setActiveIndex(0);
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
try {
|
try {
|
||||||
listRef.current?.scrollToIndex?.({ index: idx, animated: false });
|
listRef.current?.scrollToOffset?.({ offset: 0, animated: false });
|
||||||
} catch (_e) {}
|
} catch (_e) {}
|
||||||
}, 50);
|
});
|
||||||
} else if (loadMore && triedLoadMoreRef.current < 6) {
|
return;
|
||||||
triedLoadMoreRef.current += 1;
|
}
|
||||||
|
if (loadMore && focusLoadAttemptsRef.current < 6) {
|
||||||
|
focusLoadAttemptsRef.current += 1;
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
}, [focusProjectId, playbacks, loadMore]);
|
}, [focusIndex, focusProjectId, loadMore, playbacks]);
|
||||||
|
|
||||||
// === FlatList (one real page per item) ===
|
// === FlatList (one real page per item) ===
|
||||||
// keep active index in sync with scroll
|
// keep active index in sync with scroll
|
||||||
@@ -208,21 +230,6 @@ const Playbacks = () => {
|
|||||||
[windowHeight, handleActiveIndexChange],
|
[windowHeight, handleActiveIndexChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
// programmatic jump to focus item
|
|
||||||
useEffect(() => {
|
|
||||||
if (!focusProjectId) return;
|
|
||||||
const idx = playbacks.findIndex((p) => p?.id === focusProjectId);
|
|
||||||
if (idx >= 0) {
|
|
||||||
lastActiveIndexRef.current = idx;
|
|
||||||
setActiveIndex(idx);
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
listRef.current?.scrollToIndex?.({ index: idx, animated: false });
|
|
||||||
} catch (_e) {}
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
}, [focusProjectId, playbacks]);
|
|
||||||
|
|
||||||
const getItemLayout = useCallback(
|
const getItemLayout = useCallback(
|
||||||
(_data, index) => ({
|
(_data, index) => ({
|
||||||
length: windowHeight,
|
length: windowHeight,
|
||||||
|
|||||||
@@ -380,8 +380,7 @@ const ManageSubscription = ({ navigation }) => {
|
|||||||
? currentUserData.subscriptionGrantStrategy
|
? currentUserData.subscriptionGrantStrategy
|
||||||
: null;
|
: null;
|
||||||
const isUpfrontGrant = grantStrategy === "upfront";
|
const isUpfrontGrant = grantStrategy === "upfront";
|
||||||
const nextGrantTimestamp =
|
const nextGrantTimestamp = isUpfrontGrant
|
||||||
isUpfrontGrant
|
|
||||||
? null
|
? null
|
||||||
: currentUserData?.subscriptionNextGrantAt ||
|
: currentUserData?.subscriptionNextGrantAt ||
|
||||||
currentUserData?.subscriptionGrantNextAt ||
|
currentUserData?.subscriptionGrantNextAt ||
|
||||||
@@ -727,7 +726,7 @@ const ManageSubscription = ({ navigation }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<ClubAdvantagesCard isDev style={styles.clubCardSpacing} />
|
{/*<ClubAdvantagesCard isDev style={styles.clubCardSpacing} />*/}
|
||||||
</View>
|
</View>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const GeneratingSong = () => {
|
|||||||
|
|
||||||
// project loaded from provider
|
// project loaded from provider
|
||||||
|
|
||||||
// Progress based on 8 minutes cap or until status becomes GENERATED
|
// Progress based on an 8 minute cap or until status becomes GENERATED
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const totalMs = 8 * 60 * 1000;
|
const totalMs = 8 * 60 * 1000;
|
||||||
const maxGeneratingProgress = 90;
|
const maxGeneratingProgress = 90;
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const SongReady = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("SongReady pause error", error?.message);
|
console.log("SongReady pause error", error?.message);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -95,7 +95,7 @@ const SongReady = () => {
|
|||||||
console.log("SongReady play error", error?.message);
|
console.log("SongReady play error", error?.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[pauseAllExcept]
|
[pauseAllExcept],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sync URLs from provider's selectedProject
|
// Sync URLs from provider's selectedProject
|
||||||
@@ -150,7 +150,7 @@ const SongReady = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
pauseAllPlayers();
|
pauseAllPlayers();
|
||||||
};
|
};
|
||||||
}, [pauseAllPlayers])
|
}, [pauseAllPlayers]),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -177,7 +177,7 @@ const SongReady = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{ cancelable: false }
|
{ cancelable: false },
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,6 @@ const SongReady = () => {
|
|||||||
|
|
||||||
alert(
|
alert(
|
||||||
"Re-générer le morceau",
|
"Re-générer le morceau",
|
||||||
(
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -237,8 +236,7 @@ const SongReady = () => {
|
|||||||
<Text style={descriptionTextStyle}>
|
<Text style={descriptionTextStyle}>
|
||||||
Les crédits seront utilisés lors de l'étape de génération.
|
Les crédits seront utilisés lors de l'étape de génération.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>,
|
||||||
),
|
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: "Annuler",
|
text: "Annuler",
|
||||||
@@ -250,7 +248,7 @@ const SongReady = () => {
|
|||||||
onPress: () => handleConfirmRegenerate(),
|
onPress: () => handleConfirmRegenerate(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{ cancelable: true }
|
{ cancelable: true },
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -360,6 +358,7 @@ const SongOptionCard = ({
|
|||||||
|
|
||||||
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 });
|
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 });
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const shouldResumeAfterSeekRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerPlayer(index, player);
|
registerPlayer(index, player);
|
||||||
@@ -387,11 +386,11 @@ const SongOptionCard = ({
|
|||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
const durationMs = Math.max(
|
const durationMs = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.round((Number(player.duration) || 0) * 1000)
|
Math.round((Number(player.duration) || 0) * 1000),
|
||||||
);
|
);
|
||||||
const positionMs = Math.max(
|
const positionMs = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.round((Number(player.currentTime) || 0) * 1000)
|
Math.round((Number(player.currentTime) || 0) * 1000),
|
||||||
);
|
);
|
||||||
setProgressInfo((prev) => {
|
setProgressInfo((prev) => {
|
||||||
if (
|
if (
|
||||||
@@ -413,35 +412,16 @@ const SongOptionCard = ({
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [player]);
|
}, [player]);
|
||||||
|
|
||||||
const handleSeek = async (targetMs) => {
|
const hasCapturedSeekStateRef = useRef(false);
|
||||||
if (!player || !progressInfo?.dur) return;
|
|
||||||
const dur = progressInfo.dur || 0;
|
|
||||||
const pos = Math.max(0, Math.min(dur, Math.floor(targetMs)));
|
|
||||||
try {
|
|
||||||
await player.seekTo?.(Math.floor(pos / 1000));
|
|
||||||
setProgressInfo((prev) => ({ ...prev, pos }));
|
|
||||||
} catch (error) {
|
|
||||||
console.log("SongReady seek error", error?.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSeekStart = () => {
|
const resumePlaybackIfNeeded = useCallback(async () => {
|
||||||
onSelect(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pauseDuringSeek = async () => {
|
|
||||||
if (!player) return;
|
if (!player) return;
|
||||||
try {
|
const shouldResume = shouldResumeAfterSeekRef.current;
|
||||||
if (player.playing) {
|
shouldResumeAfterSeekRef.current = false;
|
||||||
await player.pause?.();
|
hasCapturedSeekStateRef.current = false; // Reset capture flag
|
||||||
}
|
|
||||||
} catch (error) {
|
if (!shouldResume) return;
|
||||||
console.log("SongReady pause on seek start", error?.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resumeAfterSeek = async () => {
|
|
||||||
if (!player) return;
|
|
||||||
try {
|
try {
|
||||||
if (player.resume) {
|
if (player.resume) {
|
||||||
await player.resume?.();
|
await player.resume?.();
|
||||||
@@ -451,7 +431,48 @@ const SongOptionCard = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("SongReady resume after seek", error?.message);
|
console.log("SongReady resume after seek", error?.message);
|
||||||
}
|
}
|
||||||
};
|
}, [player]);
|
||||||
|
|
||||||
|
const handleSeek = useCallback(
|
||||||
|
async (targetMs) => {
|
||||||
|
if (!player || !progressInfo?.dur) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dur = progressInfo.dur || 0;
|
||||||
|
const pos = Math.max(0, Math.min(dur, Math.floor(targetMs)));
|
||||||
|
|
||||||
|
// Mise à jour visuelle immédiate
|
||||||
|
setProgressInfo((prev) => ({ ...prev, pos }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await player.seekTo?.(Math.floor(pos / 1000));
|
||||||
|
} catch (error) {
|
||||||
|
console.log("SongReady seek error", error?.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[player, progressInfo?.dur],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSeekStart = useCallback(async () => {
|
||||||
|
onSelect(index);
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
|
const isCurrentlyPlaying = !!player.playing || isPlaying;
|
||||||
|
|
||||||
|
// Only capture state if we haven't already for this drag interaction
|
||||||
|
if (!hasCapturedSeekStateRef.current) {
|
||||||
|
hasCapturedSeekStateRef.current = true;
|
||||||
|
shouldResumeAfterSeekRef.current = isCurrentlyPlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isCurrentlyPlaying) {
|
||||||
|
await player.pause?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("SongReady pause on seek start", error?.message);
|
||||||
|
}
|
||||||
|
}, [index, isPlaying, onSelect, player]);
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
onSelect(index);
|
onSelect(index);
|
||||||
@@ -510,8 +531,7 @@ const SongOptionCard = ({
|
|||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
onSeekStart={handleSeekStart}
|
onSeekStart={handleSeekStart}
|
||||||
onSeek={handleSeek}
|
onSeek={handleSeek}
|
||||||
onPause={pauseDuringSeek}
|
onSeekEnd={resumePlaybackIfNeeded}
|
||||||
onPlay={resumeAfterSeek}
|
|
||||||
disabled={!url}
|
disabled={!url}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -557,7 +577,7 @@ const RegenerateModal = ({ visible, onClose, onConfirm }) => {
|
|||||||
const horizontalPadding = Math.max(isCompactWidth ? 16 : gutters, 12);
|
const horizontalPadding = Math.max(isCompactWidth ? 16 : gutters, 12);
|
||||||
const verticalPadding = Math.max(
|
const verticalPadding = Math.max(
|
||||||
isCompactWidth ? gutters : gutters * 1.5,
|
isCompactWidth ? gutters : gutters * 1.5,
|
||||||
12
|
12,
|
||||||
);
|
);
|
||||||
const availableWidth = Math.max(width - horizontalPadding * 2, 0);
|
const availableWidth = Math.max(width - horizontalPadding * 2, 0);
|
||||||
const contentWidth =
|
const contentWidth =
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const CreateLyricsWithAi = () => {
|
|||||||
const [progress, setProgress] = useState(16);
|
const [progress, setProgress] = useState(16);
|
||||||
const [parentLayout, setparentLayout] = useState(null);
|
const [parentLayout, setparentLayout] = useState(null);
|
||||||
const containerWidth = windowWidth;
|
const containerWidth = windowWidth;
|
||||||
|
const handleLyricsError = React.useCallback(() => {
|
||||||
|
setSelectedIndex(7);
|
||||||
|
}, [setSelectedIndex]);
|
||||||
|
|
||||||
// Collected state across steps
|
// Collected state across steps
|
||||||
const [objective, setObjective] = useState(null); // from Goals list
|
const [objective, setObjective] = useState(null); // from Goals list
|
||||||
@@ -496,6 +499,7 @@ const CreateLyricsWithAi = () => {
|
|||||||
customStructure,
|
customStructure,
|
||||||
rhymes,
|
rhymes,
|
||||||
}}
|
}}
|
||||||
|
onErrorRedirect={handleLyricsError}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</SwiperFlatList>
|
</SwiperFlatList>
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false };
|
|||||||
const CreateLyricsWithAi = () => {
|
const CreateLyricsWithAi = () => {
|
||||||
const { selectedProject } = useUser();
|
const { selectedProject } = useUser();
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const handleLyricsError = React.useCallback(() => {
|
||||||
|
setSelectedIndex(7);
|
||||||
|
}, [setSelectedIndex]);
|
||||||
|
|
||||||
// Collected state across steps
|
// Collected state across steps
|
||||||
const [objective, setObjective] = useState(null); // from Goals list
|
const [objective, setObjective] = useState(null); // from Goals list
|
||||||
@@ -403,6 +406,7 @@ const CreateLyricsWithAi = () => {
|
|||||||
customStructure,
|
customStructure,
|
||||||
rhymes,
|
rhymes,
|
||||||
}}
|
}}
|
||||||
|
onErrorRedirect={handleLyricsError}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const FAKE_PROGRESS_MAX = 96;
|
|||||||
const PROGRESS_INTERVAL_MS = 250;
|
const PROGRESS_INTERVAL_MS = 250;
|
||||||
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false };
|
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false };
|
||||||
|
|
||||||
const CreatingLyrics = ({ active, config, selections }) => {
|
const CreatingLyrics = ({ active, config, selections, onErrorRedirect }) => {
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [called, setCalled] = useState(false);
|
const [called, setCalled] = useState(false);
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
@@ -237,6 +237,13 @@ const CreatingLyrics = ({ active, config, selections }) => {
|
|||||||
if (error && !hasShownErrorAlert.current) {
|
if (error && !hasShownErrorAlert.current) {
|
||||||
hasShownErrorAlert.current = true;
|
hasShownErrorAlert.current = true;
|
||||||
const redirect = () => {
|
const redirect = () => {
|
||||||
|
if (typeof onErrorRedirect === "function") {
|
||||||
|
console.log(
|
||||||
|
"↩️ [CreatingLyrics] Retour à l'étape Rimes après erreur",
|
||||||
|
);
|
||||||
|
onErrorRedirect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log("↩️ [CreatingLyrics] Retour à WritingLyrics après erreur");
|
console.log("↩️ [CreatingLyrics] Retour à WritingLyrics après erreur");
|
||||||
navigate(Routes.WritingLyrics);
|
navigate(Routes.WritingLyrics);
|
||||||
};
|
};
|
||||||
@@ -257,7 +264,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
|
|||||||
redirect();
|
redirect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [error]);
|
}, [error, onErrorRedirect]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Image as ExpoImage } from "expo-image";
|
import { Image as ExpoImage } from "expo-image";
|
||||||
import React from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { ActivityIndicator, Text, View } from "react-native";
|
import { ActivityIndicator, Text, View } from "react-native";
|
||||||
import { background } from "../../assets";
|
import { background } from "../../assets";
|
||||||
import GradientButton from "../../components/GradientButton";
|
import GradientButton from "../../components/GradientButton";
|
||||||
@@ -11,9 +11,17 @@ import { Routes } from "../../navigation";
|
|||||||
import { goBack, navigate } from "../../navigation/NavigationService";
|
import { goBack, navigate } from "../../navigation/NavigationService";
|
||||||
import { useUserData } from "../../providers/UserDataProvider";
|
import { useUserData } from "../../providers/UserDataProvider";
|
||||||
import { gutters, Palette, Style } from "../../styles";
|
import { gutters, Palette, Style } from "../../styles";
|
||||||
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
||||||
|
import ProgressBar from "../../components/ProgressBar";
|
||||||
|
const COVER_PROGRESS_MAX = 98;
|
||||||
|
const COVER_PROGRESS_INTERVAL_MS = 500;
|
||||||
|
const COVER_FAKE_DURATION_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
const PhotoCover = () => {
|
const PhotoCover = () => {
|
||||||
|
const [coverProgress, setCoverProgress] = useState(0);
|
||||||
|
const progressIntervalRef = useRef(null);
|
||||||
|
const progressStartRef = useRef(null);
|
||||||
const { selectedProject } = useUserData();
|
const { selectedProject } = useUserData();
|
||||||
const isGenerating = selectedProject?.coverStatus === "GENERATING";
|
const isGenerating = selectedProject?.coverStatus === "GENERATING";
|
||||||
const coverGenerationMessage = isWeb
|
const coverGenerationMessage = isWeb
|
||||||
@@ -29,6 +37,46 @@ const PhotoCover = () => {
|
|||||||
coverOptions?.[0]?.finalUrl ||
|
coverOptions?.[0]?.finalUrl ||
|
||||||
coverOptions?.[0]?.generatedUrl ||
|
coverOptions?.[0]?.generatedUrl ||
|
||||||
null;
|
null;
|
||||||
|
const coverProgressValue = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, Math.round(coverProgress)),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const clearProgressInterval = () => {
|
||||||
|
if (progressIntervalRef.current) {
|
||||||
|
global.clearInterval(progressIntervalRef.current);
|
||||||
|
progressIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isGenerating || coverUrl) {
|
||||||
|
clearProgressInterval();
|
||||||
|
progressStartRef.current = null;
|
||||||
|
setCoverProgress(coverUrl ? 100 : 0);
|
||||||
|
return clearProgressInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
progressStartRef.current = new Date();
|
||||||
|
setCoverProgress(0);
|
||||||
|
clearProgressInterval();
|
||||||
|
progressIntervalRef.current = global.setInterval(() => {
|
||||||
|
const start = progressStartRef.current;
|
||||||
|
if (!start) return;
|
||||||
|
const elapsed = Date.now() - start.getTime();
|
||||||
|
const ratio = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(1, elapsed / COVER_FAKE_DURATION_MS),
|
||||||
|
);
|
||||||
|
const next = COVER_PROGRESS_MAX * ratio;
|
||||||
|
setCoverProgress((prev) => {
|
||||||
|
if (prev >= COVER_PROGRESS_MAX) return COVER_PROGRESS_MAX;
|
||||||
|
return next >= COVER_PROGRESS_MAX ? COVER_PROGRESS_MAX : next;
|
||||||
|
});
|
||||||
|
}, COVER_PROGRESS_INTERVAL_MS);
|
||||||
|
|
||||||
|
return clearProgressInterval;
|
||||||
|
}, [coverUrl, isGenerating]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page backgroundImg={background.studioBG2} headerType="NONE">
|
<Page backgroundImg={background.studioBG2} headerType="NONE">
|
||||||
@@ -91,6 +139,18 @@ const PhotoCover = () => {
|
|||||||
Génération en cours.
|
Génération en cours.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
<View style={{ alignItems: "center", gap: 10 }}>
|
||||||
|
<ProgressBar gradient progress={coverProgressValue} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{coverProgressValue}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { Image as ExpoImage } from "expo-image";
|
import { Image as ExpoImage } from "expo-image";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image,
|
Image,
|
||||||
@@ -28,6 +28,7 @@ import { gutters, Palette, Style } from "../../styles";
|
|||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
import { getStageAction } from "../../utils/projectStages";
|
import { getStageAction } from "../../utils/projectStages";
|
||||||
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
||||||
|
import ProgressBar from "../../components/ProgressBar";
|
||||||
|
|
||||||
const COVER_STYLE_PRESETS = [
|
const COVER_STYLE_PRESETS = [
|
||||||
"Cyberpunk",
|
"Cyberpunk",
|
||||||
@@ -37,6 +38,9 @@ const COVER_STYLE_PRESETS = [
|
|||||||
"Livre de coloriage",
|
"Livre de coloriage",
|
||||||
"Shooting",
|
"Shooting",
|
||||||
];
|
];
|
||||||
|
const COVER_PROGRESS_MAX = 98;
|
||||||
|
const COVER_PROGRESS_INTERVAL_MS = 500;
|
||||||
|
const COVER_FAKE_DURATION_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
const PouchReady = () => {
|
const PouchReady = () => {
|
||||||
const { selectedProjectId, selectedProject, updateProjectData } = useUser();
|
const { selectedProjectId, selectedProject, updateProjectData } = useUser();
|
||||||
@@ -81,6 +85,9 @@ const PouchReady = () => {
|
|||||||
const [isSelecting, setIsSelecting] = useState(false);
|
const [isSelecting, setIsSelecting] = useState(false);
|
||||||
const [isAwaitingGenerationStart, setIsAwaitingGenerationStart] =
|
const [isAwaitingGenerationStart, setIsAwaitingGenerationStart] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [coverProgress, setCoverProgress] = useState(0);
|
||||||
|
const coverProgressIntervalRef = useRef(null);
|
||||||
|
const coverProgressStartRef = useRef(null);
|
||||||
|
|
||||||
const showGenerationLoading = useCallback(async () => {
|
const showGenerationLoading = useCallback(async () => {
|
||||||
setIsAwaitingGenerationStart(true);
|
setIsAwaitingGenerationStart(true);
|
||||||
@@ -184,6 +191,48 @@ const PouchReady = () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
const isCoverLoading =
|
||||||
|
isGenerating && !hasGeneratedOptions && !coverPreviewUrl;
|
||||||
|
const coverProgressValue = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, Math.round(coverProgress)),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const clearProgressInterval = () => {
|
||||||
|
if (coverProgressIntervalRef.current) {
|
||||||
|
global.clearInterval(coverProgressIntervalRef.current);
|
||||||
|
coverProgressIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isCoverLoading) {
|
||||||
|
clearProgressInterval();
|
||||||
|
coverProgressStartRef.current = null;
|
||||||
|
setCoverProgress(hasGeneratedOptions || coverPreviewUrl ? 100 : 0);
|
||||||
|
return clearProgressInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
coverProgressStartRef.current = new Date();
|
||||||
|
setCoverProgress(0);
|
||||||
|
clearProgressInterval();
|
||||||
|
coverProgressIntervalRef.current = global.setInterval(() => {
|
||||||
|
const start = coverProgressStartRef.current;
|
||||||
|
if (!start) return;
|
||||||
|
const elapsed = Date.now() - start.getTime();
|
||||||
|
const ratio = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(1, elapsed / COVER_FAKE_DURATION_MS),
|
||||||
|
);
|
||||||
|
const next = COVER_PROGRESS_MAX * ratio;
|
||||||
|
setCoverProgress((prev) => {
|
||||||
|
if (prev >= COVER_PROGRESS_MAX) return COVER_PROGRESS_MAX;
|
||||||
|
return next >= COVER_PROGRESS_MAX ? COVER_PROGRESS_MAX : next;
|
||||||
|
});
|
||||||
|
}, COVER_PROGRESS_INTERVAL_MS);
|
||||||
|
|
||||||
|
return clearProgressInterval;
|
||||||
|
}, [isCoverLoading, hasGeneratedOptions, coverPreviewUrl]);
|
||||||
|
|
||||||
const handleSelectOption = useCallback(
|
const handleSelectOption = useCallback(
|
||||||
async (option) => {
|
async (option) => {
|
||||||
@@ -234,12 +283,6 @@ const PouchReady = () => {
|
|||||||
});
|
});
|
||||||
}, [coverOptions, navigate, selectedOption, selectedProject]);
|
}, [coverOptions, navigate, selectedOption, selectedProject]);
|
||||||
|
|
||||||
const primaryActionTitle = hasGeneratedOptions
|
|
||||||
? "Valider la pochette"
|
|
||||||
: isGenerating
|
|
||||||
? "Veuillez patienter..."
|
|
||||||
: "En attente de la génération";
|
|
||||||
|
|
||||||
const isPrimaryActionDisabled =
|
const isPrimaryActionDisabled =
|
||||||
isGenerating ||
|
isGenerating ||
|
||||||
(hasGeneratedOptions ? !selectedOption || isSelecting : true);
|
(hasGeneratedOptions ? !selectedOption || isSelecting : true);
|
||||||
@@ -274,10 +317,9 @@ const PouchReady = () => {
|
|||||||
? "Ta pochette est prête!"
|
? "Ta pochette est prête!"
|
||||||
: "Génération de la pochette"
|
: "Génération de la pochette"
|
||||||
}
|
}
|
||||||
// subTitle="Qu’en penses-tu ?"
|
|
||||||
/>
|
/>
|
||||||
<View style={{ flex: 1, ...Style.containerCenter }}>
|
<View style={{ flex: 1, ...Style.containerCenter }}>
|
||||||
<View style={{ width: "80%", gap: 24 }}>
|
<View style={{ width: isWeb ? "80%" : "100%", gap: 24 }}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -354,16 +396,6 @@ const PouchReady = () => {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
// <View
|
|
||||||
// style={{
|
|
||||||
// width: isWeb ? 300 : "100%",
|
|
||||||
// height: 300,
|
|
||||||
// borderRadius: 20,
|
|
||||||
// backgroundColor: "#00000040",
|
|
||||||
// alignSelf: "center",
|
|
||||||
// ...Style.containerCenter,
|
|
||||||
// }}
|
|
||||||
// >
|
|
||||||
<>
|
<>
|
||||||
{isGenerating && (
|
{isGenerating && (
|
||||||
<View
|
<View
|
||||||
@@ -404,6 +436,18 @@ const PouchReady = () => {
|
|||||||
Génération en cours.
|
Génération en cours.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
<View style={{ alignItems: "center", gap: 10 }}>
|
||||||
|
<ProgressBar gradient progress={coverProgressValue} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{coverProgressValue}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -550,7 +594,7 @@ const PouchReady = () => {
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingBottom: gutters * 2,
|
paddingBottom: gutters * 2,
|
||||||
width: "80%",
|
width: isWeb ? "80%" : "100%",
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
gap: 12,
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
@@ -565,11 +609,12 @@ const PouchReady = () => {
|
|||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{hasGeneratedOptions && (
|
||||||
<GradientButton
|
<GradientButton
|
||||||
title={primaryActionTitle}
|
title={"Valider la pochette"}
|
||||||
disabled={isPrimaryActionDisabled}
|
|
||||||
onPress={onValidatePicture}
|
onPress={onValidatePicture}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
@@ -760,7 +805,6 @@ const styles = StyleSheet.create({
|
|||||||
marginLeft: 12,
|
marginLeft: 12,
|
||||||
},
|
},
|
||||||
modeColumn: {
|
modeColumn: {
|
||||||
flex: 1,
|
|
||||||
gap: 12,
|
gap: 12,
|
||||||
minWidth: 240,
|
minWidth: 240,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user