clear last tickets

This commit is contained in:
Thomas Demirdjian
2025-11-26 15:33:22 +01:00
parent ee9cc5dccd
commit d7b14bd873
25 changed files with 1692 additions and 1967 deletions
+231 -241
View File
@@ -2,25 +2,46 @@ const { onDocumentCreated } = require("firebase-functions/v2/firestore");
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const logger = require("firebase-functions/logger");
const { generateImageV2 } = require("../helpers/gemini");
const { generatePicturePrompt } = require("../helpers/prompts");
const path = require("path");
const fs = require("fs");
const axios = require("axios");
const sharp = require("sharp");
const crypto = require("crypto");
// Imports internes
const { generateImageV2 } = require("../helpers/gemini");
const { generatePicturePrompt } = require("../helpers/prompts");
const { ALERT_TYPE, refList } = require("../index");
const { sendNotification } = require("./notifications");
// Configuration
const bucket = admin.storage().bucket();
const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandLogo.png");
// Cache mémoire pour le buffer du logo (évite les I/O disque à chaque appel sur instance chaude)
let _cachedLogoBuffer = null;
/**
* Récupère le buffer du logo depuis le cache ou le disque
*/
const getLogoBuffer = async () => {
if (_cachedLogoBuffer) return _cachedLogoBuffer;
try {
_cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH);
return _cachedLogoBuffer;
} catch (error) {
logger.error("❌ [Cover] Impossible de lire le fichier logo", error);
throw new Error("Asset Logo manquant sur le serveur");
}
};
/**
* Utilitaires Strings
*/
const pickFirstNonEmpty = (...values) => {
for (const value of values) {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return "";
@@ -33,354 +54,323 @@ const combineNames = (...parts) =>
.join(" ")
.trim();
/**
* Résolution intelligente du nom d'artiste
*/
async function resolveArtistName(project = {}) {
// 1. Vérification directe sur le projet ou le snapshot "owner"
const owner = project?.owner || {};
const direct = pickFirstNonEmpty(
project?.artistName,
project?.userName,
owner?.artistName,
owner?.userName,
owner?.displayName
owner?.displayName,
);
if (direct) return direct;
// 2. Fallback : Récupération depuis la collection Users
const userId =
typeof project?.userId === "string" && project.userId.trim()
? project.userId.trim()
: "";
typeof project?.userId === "string" ? project.userId.trim() : "";
if (!userId) return "";
try {
const userSnapshot = await refList.users.doc(userId).get();
if (!userSnapshot?.exists) return "";
const userData = userSnapshot.data() || {};
const fullName = combineNames(userData.firstName, userData.lastName);
return (
pickFirstNonEmpty(
userData.artistName,
userData.userName,
userData.displayName,
fullName
combineNames(userData.firstName, userData.lastName),
) || ""
);
} catch (error) {
logger.warn("⚠️ [Cover] Unable to resolve artist name", {
projectId: project?.id || null,
userId,
error: error?.message || String(error),
logger.warn("⚠️ [Cover] Artist name resolution failed", {
projectId: project?.id,
error: error.message,
});
return "";
}
}
/**
* Ajoute le logo en filigrane sur l'image générée
*/
async function buildCoverWithLogo(backgroundUrl, targetPath) {
logger.info("🖼️ [Cover] Adding logo to generated background");
const [{ data: backgroundBuffer }, logoBuffer] = await Promise.all([
axios.get(backgroundUrl, { responseType: "arraybuffer" }),
fs.promises.readFile(LOGO_PATH),
]);
logger.info("🖼️ [Cover] Compositing logo...");
const baseImage = sharp(backgroundBuffer);
const { width = 1024, height = 1024 } = await baseImage.metadata();
try {
// Téléchargement background + Lecture Logo (parallèle)
const [bgResponse, logoBuffer] = await Promise.all([
axios.get(backgroundUrl, { responseType: "arraybuffer" }),
getLogoBuffer(),
]);
const desiredWidth = Math.round(width * 0.32);
const margin = Math.round(width * 0.04);
const baseImage = sharp(bgResponse.data);
const metadata = await baseImage.metadata();
const width = metadata.width || 1024;
const height = metadata.height || 1024;
const { data: resizedLogo, info: logoInfo } = await sharp(logoBuffer)
.resize({ width: desiredWidth })
.png()
.toBuffer({ resolveWithObject: true });
// Calcul dynamique de la taille du logo (32% de la largeur)
const desiredWidth = Math.round(width * 0.32);
const margin = Math.round(width * 0.04);
const left = Math.max(width - logoInfo.width - margin, 0);
const top = Math.max(height - logoInfo.height - margin, 0);
// Redimensionnement du logo
const resizedLogo = await sharp(logoBuffer)
.resize({ width: desiredWidth })
.png()
.toBuffer();
const stampedBuffer = await baseImage
.ensureAlpha()
.composite([
{
input: resizedLogo,
left,
top,
blend: "over",
// Positionnement (Bas Droite)
const logoMetadata = await sharp(resizedLogo).metadata();
const left = Math.max(width - logoMetadata.width - margin, 0);
const top = Math.max(height - logoMetadata.height - margin, 0);
// Composition
const stampedBuffer = await baseImage
.ensureAlpha()
.composite([{ input: resizedLogo, left, top, blend: "over" }])
.png()
.toBuffer();
// Upload vers Storage
const token = crypto.randomUUID();
const file = bucket.file(targetPath);
await file.save(stampedBuffer, {
resumable: false,
metadata: {
contentType: "image/png",
cacheControl: "public, max-age=31536000",
metadata: { firebaseStorageDownloadTokens: token },
},
])
.png()
.toBuffer();
});
const token = require("crypto").randomUUID();
await bucket.file(targetPath).save(stampedBuffer, {
resumable: false,
metadata: {
contentType: "image/png",
cacheControl: "public, max-age=31536000",
metadata: { firebaseStorageDownloadTokens: token },
},
});
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
targetPath
)}?alt=media&token=${token}`;
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`;
} catch (error) {
logger.error("❌ [Cover] buildCoverWithLogo failed", error);
// En cas d'échec du logo, on renvoie l'URL originale pour ne pas tout perdre
return backgroundUrl;
}
}
// Shared core for generating and saving the cover, and updating the project
/**
* Cœur de la logique de génération
*/
async function performCoverGeneration(project) {
const t0 = Date.now();
const artistName = await resolveArtistName(project);
// Génération du Prompt optimisé
const prompt = generatePicturePrompt({
...project,
artistName,
});
// Utiliser generateImageV2 qui renvoie directement l'URL publique
logger.info("🎨 [Cover] Calling model V2", {
logger.info("🎨 [Cover] Prompt generated", {
projectId: project.id,
promptPreview: String(prompt).slice(0, 160),
artistName,
promptPreview: prompt.slice(0, 100) + "...",
});
const baseTimestamp = Date.now();
const options = [];
const GENERATION_COUNT = 2; // Nombre de variantes simultanées
for (let index = 0; index < 2; index += 1) {
const uniqueSuffix = `${baseTimestamp}-${index}`;
const storageBasePath = `users/${project.userId}/projects/${project.id}`;
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`;
let generatedUrl = "";
try {
generatedUrl = await generateImageV2(prompt, 1024, generatedPath);
logger.info("🎨 [Cover] Candidate generated", {
projectId: project.id,
candidateIndex: index,
});
} catch (e) {
logger.error("❌ [Cover] generateImageV2 failed", {
projectId: project.id,
candidateIndex: index,
error: e?.message || String(e),
});
throw e;
}
if (!generatedUrl) {
throw new Error("Génération d'image échouée (URL vide)");
}
let finalCoverUrl = generatedUrl;
try {
// Création d'un tableau de promesses pour exécuter les tâches en parallèle
const generationPromises = Array.from({ length: GENERATION_COUNT }).map(
async (_, index) => {
const uniqueSuffix = `${baseTimestamp}-${index}`;
const storageBasePath = `users/${project.userId}/projects/${project.id}`;
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`;
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`;
finalCoverUrl = await buildCoverWithLogo(generatedUrl, stampedPath);
} catch (e) {
logger.error("❌ [Cover] Logo overlay failed", {
projectId: project.id,
candidateIndex: index,
error: e?.message || String(e),
});
}
options.push({
id: uniqueSuffix,
generatedUrl,
finalUrl: finalCoverUrl,
});
try {
// 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres
const generatedUrl = await generateImageV2(prompt, 1024, generatedPath);
if (!generatedUrl) throw new Error("URL vide retournée par l'IA");
// 2. Ajout du Logo
const finalCoverUrl = await buildCoverWithLogo(
generatedUrl,
stampedPath,
);
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`);
return {
id: uniqueSuffix,
generatedUrl,
finalUrl: finalCoverUrl,
promptUsed: prompt,
};
} catch (e) {
// On catch l'erreur ICI pour ne pas faire échouer tout le Promise.all
logger.error(`❌ [Cover] Option ${index + 1} failed`, {
error: e.message,
});
return null; // On retourne null pour filtrer plus tard
}
},
);
// Attente de la résolution de toutes les générations
const results = await Promise.all(generationPromises);
// On garde uniquement les tentatives réussies (non null)
const options = results.filter(Boolean);
if (options.length === 0) {
throw new Error("Toutes les tentatives de génération ont échoué.");
}
// Sauvegarde dans Firestore
const [firstOption] = options;
await refList.projects.doc(project.id).set(
{
cover: {
generatedBackground: firstOption?.generatedUrl || null,
result: firstOption?.finalUrl || null,
selectedOptionId: firstOption?.id || null,
options,
generatedBackground: firstOption.generatedUrl,
result: firstOption.finalUrl,
selectedOptionId: firstOption.id,
options, // Sauvegarde de toutes les variantes réussies
},
coverStatus: "GENERATED",
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
{ merge: true },
);
logger.info(" [Cover] Saved", {
logger.info("🏁 [Cover] Process complete", {
projectId: project.id,
optionsCount: options.length,
ms: Date.now() - t0,
successCount: options.length,
duration: Date.now() - t0,
});
return firstOption?.finalUrl || null;
return firstOption.finalUrl;
}
// Firestore trigger: création d'une tâche de génération de cover
/**
* TRIGGER FIRESTORE
* Déclenché à la création d'un document dans 'tasks/{taskId}'
*/
exports.onTaskCreateGenerateCover = onDocumentCreated(
{
timeoutSeconds: 540,
timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent)
memory: "1GiB",
document: "tasks/{taskId}",
},
async (event) => {
const data = event?.data?.data() || {};
const type = data?.type || "";
const projectId = data?.projectId || null;
let coverUrl = null;
let project = null;
try {
if (!projectId) {
throw new Error("projectId manquant dans la task");
}
if (!["cover", "combine"]?.includes(type)) {
throw new Error(`Type de tâche non supporté: ${type}`);
}
logger.info("🧵 [Task] Received", {
taskId: event?.params?.taskId,
type,
projectId,
});
const data = event.data?.data() || {};
const { type, projectId } = data;
const taskId = event.params.taskId;
if (!projectId) return; // Ignorer les tâches mal formées
if (!["cover", "combine"].includes(type)) return; // Ignorer les autres types de tâches
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId });
try {
// 1. Validation & Setup
if (type === "combine") {
logger.warn("⛔ [Task] Combine task ignored (feature disabled)", {
projectId,
taskId: event?.params?.taskId,
});
// Feature désactivée pour le moment
await event.data.ref.update({
status: "CANCELLED",
error:
"La personnalisation de la pochette avec une photo n'est plus disponible.",
error: "La personnalisation photo n'est plus disponible.",
updatedAt: FieldValue.serverTimestamp(),
});
return;
}
// Mise à jour statut projet
await refList.projects.doc(projectId).update({
coverStatus: "GENERATING",
updatedAt: FieldValue.serverTimestamp(),
});
project = (await refList.projects.doc(projectId).get())?.data() || null;
if (!project) {
throw new Error("Projet non trouvé");
}
project.id = projectId;
// 2. Chargement Projet
const projectSnap = await refList.projects.doc(projectId).get();
if (!projectSnap.exists) throw new Error("Projet introuvable");
const existingOptionsCount = Array.isArray(project?.cover?.options)
? project.cover.options.length
: 0;
const project = { id: projectId, ...projectSnap.data() };
logger.info("🔎 [Task] Project loaded", {
projectId,
hasGeneratedBackground: !!project?.cover?.generatedBackground,
hasUserForeground: !!project?.cover?.userForeground,
existingOptionsCount,
});
if (existingOptionsCount > 0) {
logger.warn("⛔ [Task] Cover already generated, skipping", {
projectId,
existingOptionsCount,
});
await refList.projects.doc(projectId).update({
coverStatus: "GENERATED",
updatedAt: FieldValue.serverTimestamp(),
});
// Idempotency check (si déjà généré, on ne refait pas)
if (
Array.isArray(project?.cover?.options) &&
project.cover.options.length > 0
) {
logger.warn("⚠️ [Task] Cover already exists. Skipping.");
await refList.projects
.doc(projectId)
.update({ coverStatus: "GENERATED" });
await event.data.ref.update({
status: "CANCELLED",
error: "Cover already generated",
updatedAt: FieldValue.serverTimestamp(),
status: "DONE",
info: "Already generated",
});
return;
}
if (type === "cover") {
logger.info("🎨 [Task] Start cover generation", { projectId });
coverUrl = await performCoverGeneration(project);
logger.info("✅ [Task] Cover generated", { projectId });
}
// 3. Exécution Génération
const coverUrl = await performCoverGeneration(project);
// 4. Finalisation Tâche
await event.data.ref.update({
status: "DONE",
coverUrl,
updatedAt: FieldValue.serverTimestamp(),
});
logger.info("📦 [Task] Marked DONE", {
taskId: event?.params?.taskId,
projectId,
});
if (
project?.userId &&
typeof project.userId === "string" &&
project.userId.trim()
) {
const receiverId = project.userId.trim();
const projectTitle =
typeof project?.title === "string" && project.title.trim()
? project.title.trim()
: "ton projet";
const message = `Ta nouvelle pochette pour "${projectTitle}" est prête.`;
try {
await sendNotification({
sender: "SYSTEM",
receiver: receiverId,
receiverCollection: "users",
title: "Pochette générée",
message,
data: {
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
projectId,
projectTitle,
coverUrl,
},
});
} catch (notifError) {
logger.error("❌ [Cover] Failed to send success notification", {
// 5. Notification
if (project.userId) {
const projectTitle = project.title || "ton projet";
await sendNotification({
sender: "SYSTEM",
receiver: project.userId,
receiverCollection: "users",
title: "Pochette prête !",
message: `La pochette pour "${projectTitle}" a été générée avec succès.`,
data: {
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
projectId,
error: notifError?.message || String(notifError),
});
}
projectTitle,
coverUrl,
},
}).catch((err) => logger.warn("Notification failed", err));
}
return;
} catch (error) {
logger.error("❌ [Task] Cover generation error", {
taskId: event?.params?.taskId,
projectId,
type,
error: error?.message || String(error),
});
logger.error(`🔥 [Task ${taskId}] Failed`, error);
// Mise à jour erreur Tâche
await event.data.ref.set(
{ status: "ERROR", error: error?.message || "Erreur" },
{ merge: true }
{ status: "ERROR", error: error.message },
{ merge: true },
);
// Mise à jour erreur Projet
await refList.projects.doc(projectId).update({
coverStatus: "ERROR",
updatedAt: FieldValue.serverTimestamp(),
});
if (
project?.userId &&
typeof project.userId === "string" &&
project.userId.trim()
) {
const receiverId = project.userId.trim();
const projectTitle =
typeof project?.title === "string" && project.title.trim()
? project.title.trim()
: "ton projet";
const message = `La génération de la pochette pour "${projectTitle}" a échoué.`;
try {
await sendNotification({
sender: "SYSTEM",
receiver: receiverId,
receiverCollection: "users",
title: "Pochette indisponible",
message,
data: {
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
projectId,
projectTitle,
error: error?.message || String(error),
},
});
} catch (notifError) {
logger.error("❌ [Cover] Failed to send error notification", {
// Notification Erreur
const projectData = (await refList.projects.doc(projectId).get()).data();
if (projectData?.userId) {
await sendNotification({
sender: "SYSTEM",
receiver: projectData.userId,
receiverCollection: "users",
title: "Échec pochette",
message: `Impossible de générer la pochette pour "${projectData.title || "ton projet"}".`,
data: {
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
projectId,
error: notifError?.message || String(notifError),
});
}
error: error.message,
},
}).catch(() => {});
}
}
}
},
);