feat: fixes and formatter

This commit is contained in:
2026-01-12 16:01:32 +01:00
parent 85c6084351
commit 11e632acff
353 changed files with 23315 additions and 27361 deletions
+154 -171
View File
@@ -1,98 +1,97 @@
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 path = require("path");
const fs = require("fs");
const axios = require("axios");
const sharp = require("sharp");
const crypto = require("crypto");
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 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");
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");
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;
let _cachedLogoBuffer = null
/**
* Récupère le buffer du logo depuis le cache ou le disque
*/
const getLogoBuffer = async () => {
if (_cachedLogoBuffer) return _cachedLogoBuffer;
if (_cachedLogoBuffer) return _cachedLogoBuffer
try {
_cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH);
return _cachedLogoBuffer;
_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");
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" && value.trim().length > 0) {
return value.trim();
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim()
}
}
return "";
};
return ''
}
const combineNames = (...parts) =>
parts
.map((part) => (typeof part === "string" ? part.trim() : ""))
.map((part) => (typeof part === 'string' ? part.trim() : ''))
.filter(Boolean)
.join(" ")
.trim();
.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 owner = project?.owner || {}
const direct = pickFirstNonEmpty(
project?.artistName,
project?.userName,
owner?.artistName,
owner?.userName,
owner?.displayName,
);
if (direct) return direct;
owner?.displayName
)
if (direct) return direct
// 2. Fallback : Récupération depuis la collection Users
const userId =
typeof project?.userId === "string" ? project.userId.trim() : "";
if (!userId) return "";
const userId = typeof project?.userId === 'string' ? project.userId.trim() : ''
if (!userId) return ''
try {
const userSnapshot = await refList.users.doc(userId).get();
if (!userSnapshot?.exists) return "";
const userSnapshot = await refList.users.doc(userId).get()
if (!userSnapshot?.exists) return ''
const userData = userSnapshot.data() || {};
const userData = userSnapshot.data() || {}
return (
pickFirstNonEmpty(
userData.artistName,
userData.userName,
userData.displayName,
combineNames(userData.firstName, userData.lastName),
) || ""
);
combineNames(userData.firstName, userData.lastName)
) || ''
)
} catch (error) {
logger.warn("⚠️ [Cover] Artist name resolution failed", {
logger.warn('⚠️ [Cover] Artist name resolution failed', {
projectId: project?.id,
error: error.message,
});
return "";
})
return ''
}
}
@@ -100,60 +99,57 @@ async function resolveArtistName(project = {}) {
* Ajoute le logo en filigrane sur l'image générée
*/
async function buildCoverWithLogo(backgroundUrl, targetPath) {
logger.info("🖼️ [Cover] Compositing logo...");
logger.info('🖼️ [Cover] Compositing logo...')
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' }),
getLogoBuffer(),
]);
])
const baseImage = sharp(bgResponse.data);
const metadata = await baseImage.metadata();
const width = metadata.width || 1024;
const height = metadata.height || 1024;
const baseImage = sharp(bgResponse.data)
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 margin = Math.round(width * 0.04);
const desiredWidth = Math.round(width * 0.32)
const margin = Math.round(width * 0.04)
// Redimensionnement du logo
const resizedLogo = await sharp(logoBuffer)
.resize({ width: desiredWidth })
.png()
.toBuffer();
const resizedLogo = await sharp(logoBuffer).resize({ width: desiredWidth }).png().toBuffer()
// 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);
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" }])
.composite([{ input: resizedLogo, left, top, blend: 'over' }])
.png()
.toBuffer();
.toBuffer()
// Upload vers Storage
const token = crypto.randomUUID();
const file = bucket.file(targetPath);
const token = crypto.randomUUID()
const file = bucket.file(targetPath)
await file.save(stampedBuffer, {
resumable: false,
metadata: {
contentType: "image/png",
cacheControl: "public, max-age=31536000",
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);
logger.error('❌ [Cover] buildCoverWithLogo failed', error)
// En cas d'échec du logo, on renvoie l'URL originale pour ne pas tout perdre
return backgroundUrl;
return backgroundUrl
}
}
@@ -161,74 +157,69 @@ async function buildCoverWithLogo(backgroundUrl, targetPath) {
* Cœur de la logique de génération
*/
async function performCoverGeneration(project) {
const t0 = Date.now();
const artistName = await resolveArtistName(project);
const t0 = Date.now()
const artistName = await resolveArtistName(project)
// Génération du Prompt optimisé
const prompt = generatePicturePrompt({
...project,
artistName,
});
})
logger.info("🎨 [Cover] Prompt generated", {
logger.info('🎨 [Cover] Prompt generated', {
projectId: project.id,
artistName,
promptPreview: prompt.slice(0, 100) + "...",
});
promptPreview: prompt.slice(0, 100) + '...',
})
const baseTimestamp = Date.now();
const GENERATION_COUNT = 2; // Nombre de variantes simultanées
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 storageBasePath = `users/${project.userId}/projects/${project.id}`;
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`;
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`;
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`
try {
// 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres
const generatedUrl = await generateImageV2(prompt, 1024, generatedPath);
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");
if (!generatedUrl) throw new Error("URL vide retournée par l'IA")
// 2. Ajout du Logo
const finalCoverUrl = await buildCoverWithLogo(
generatedUrl,
stampedPath,
);
// 2. Ajout du Logo
const finalCoverUrl = await buildCoverWithLogo(generatedUrl, stampedPath)
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`);
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
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);
const results = await Promise.all(generationPromises)
// On garde uniquement les tentatives réussies (non null)
const options = results.filter(Boolean);
const options = results.filter(Boolean)
if (options.length === 0) {
throw new Error("Toutes les tentatives de génération ont échoué.");
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(
{
@@ -238,19 +229,19 @@ async function performCoverGeneration(project) {
// selectedOptionId: firstOption.id, // disable default selection
options, // Sauvegarde de toutes les variantes réussies
},
coverStatus: "GENERATED",
coverStatus: 'GENERATED',
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
{ merge: true }
)
logger.info("🏁 [Cover] Process complete", {
logger.info('🏁 [Cover] Process complete', {
projectId: project.id,
successCount: options.length,
duration: Date.now() - t0,
});
})
return firstOption.finalUrl;
return firstOption.finalUrl
}
/**
@@ -260,77 +251,72 @@ async function performCoverGeneration(project) {
exports.onTaskCreateGenerateCover = onDocumentCreated(
{
timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent)
memory: "1GiB",
document: "tasks/{taskId}",
memory: '1GiB',
document: 'tasks/{taskId}',
},
async (event) => {
const data = event.data?.data() || {};
const { type, projectId } = data;
const taskId = event.params.taskId;
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
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 });
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId })
try {
// 1. Validation & Setup
if (type === "combine") {
if (type === 'combine') {
// Feature désactivée pour le moment
await event.data.ref.update({
status: "CANCELLED",
status: 'CANCELLED',
error: "La personnalisation photo n'est plus disponible.",
updatedAt: FieldValue.serverTimestamp(),
});
return;
})
return
}
// Mise à jour statut projet
await refList.projects.doc(projectId).update({
coverStatus: "GENERATING",
coverStatus: 'GENERATING',
updatedAt: FieldValue.serverTimestamp(),
});
})
// 2. Chargement Projet
const projectSnap = await refList.projects.doc(projectId).get();
if (!projectSnap.exists) throw new Error("Projet introuvable");
const projectSnap = await refList.projects.doc(projectId).get()
if (!projectSnap.exists) throw new Error('Projet introuvable')
const project = { id: projectId, ...projectSnap.data() };
const project = { id: projectId, ...projectSnap.data() }
// 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" });
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: "DONE",
info: "Already generated",
});
return;
status: 'DONE',
info: 'Already generated',
})
return
}
// 3. Exécution Génération
const coverUrl = await performCoverGeneration(project);
const coverUrl = await performCoverGeneration(project)
// 4. Finalisation Tâche
await event.data.ref.update({
status: "DONE",
status: 'DONE',
coverUrl,
updatedAt: FieldValue.serverTimestamp(),
});
})
// 5. Notification
if (project.userId) {
const projectTitle = project.title || "ton projet";
const projectTitle = project.title || 'ton projet'
await sendNotification({
sender: "SYSTEM",
sender: 'SYSTEM',
receiver: project.userId,
receiverCollection: "users",
title: "Pochette prête !",
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,
@@ -338,39 +324,36 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
projectTitle,
coverUrl,
},
}).catch((err) => logger.warn("Notification failed", err));
}).catch((err) => logger.warn('Notification failed', err))
}
} catch (error) {
logger.error(`🔥 [Task ${taskId}] Failed`, error);
logger.error(`🔥 [Task ${taskId}] Failed`, error)
// Mise à jour erreur Tâche
await event.data.ref.set(
{ status: "ERROR", error: error.message },
{ merge: true },
);
await event.data.ref.set({ status: 'ERROR', error: error.message }, { merge: true })
// Mise à jour erreur Projet
await refList.projects.doc(projectId).update({
coverStatus: "ERROR",
coverStatus: 'ERROR',
updatedAt: FieldValue.serverTimestamp(),
});
})
// Notification Erreur
const projectData = (await refList.projects.doc(projectId).get()).data();
const projectData = (await refList.projects.doc(projectId).get()).data()
if (projectData?.userId) {
await sendNotification({
sender: "SYSTEM",
sender: 'SYSTEM',
receiver: projectData.userId,
receiverCollection: "users",
title: "Échec pochette",
message: `Impossible de générer la pochette pour "${projectData.title || "ton projet"}".`,
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: error.message,
},
}).catch(() => { });
}).catch(() => {})
}
}
},
);
}
)