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 axios = require('axios') const sharp = require('sharp') const crypto = require('crypto') // Imports internes const { GEMINI_API_KEY } = require('../config/secrets') 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 BRAND_TEXT = 'By Musicland.ai' const BRAND_FONT_FAMILY = 'Poppins, Montserrat, Arial, sans-serif' const BRAND_BG_COLOR = 'rgba(0,0,0,0.65)' const escapeSvgText = (value = '') => String(value).replace(/&/g, '&').replace(//g, '>') const buildBrandSvg = ({ width = 1024, height = 1024, text = BRAND_TEXT } = {}) => { const safeWidth = Math.max(320, Math.round(width)) const safeHeight = Math.max(320, Math.round(height)) const margin = Math.round(safeWidth * 0.04) const fontSize = Math.max(22, Math.round(safeWidth * 0.045)) const letterSpacing = Math.max(1, Math.round(fontSize * 0.06)) const strokeWidth = Math.max(2, Math.round(fontSize * 0.08)) const shadowDy = Math.max(2, Math.round(fontSize * 0.08)) const shadowBlur = Math.max(4, Math.round(fontSize * 0.18)) const paddingX = Math.max(10, Math.round(fontSize * 0.7)) const paddingY = Math.max(6, Math.round(fontSize * 0.4)) const approxCharWidth = fontSize * 0.62 const safeText = escapeSvgText(text) const textLength = safeText.length const textWidth = approxCharWidth * textLength + letterSpacing * Math.max(0, textLength - 1) const rectWidth = Math.max(0, Math.round(textWidth + paddingX * 2)) const rectHeight = Math.round(fontSize + paddingY * 2) const rectX = Math.max(0, Math.round(safeWidth - margin - rectWidth)) const rectY = Math.max(0, Math.round(safeHeight - margin - rectHeight)) const textX = rectX + rectWidth / 2 const baselineShift = Math.round(fontSize * 0.3) const textY = rectY + rectHeight / 2 + baselineShift const rectRadius = Math.max(6, Math.round(rectHeight * 0.35)) return Buffer.from(` ${safeText} `) } /** * Utilitaires Strings */ const pickFirstNonEmpty = (...values) => { for (const value of values) { if (typeof value === 'string' && value.trim().length > 0) { return value.trim() } } return '' } const combineNames = (...parts) => parts .map((part) => (typeof part === 'string' ? part.trim() : '')) .filter(Boolean) .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 ) 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 '' try { const userSnapshot = await refList.users.doc(userId).get() if (!userSnapshot?.exists) return '' const userData = userSnapshot.data() || {} return ( pickFirstNonEmpty( userData.artistName, userData.userName, userData.displayName, combineNames(userData.firstName, userData.lastName) ) || '' ) } catch (error) { logger.warn('⚠️ [Cover] Artist name resolution failed', { projectId: project?.id, error: error.message, }) return '' } } /** * Ajoute le texte de marque en filigrane sur l'image générée */ async function buildCoverWithBrandText(backgroundUrl, targetPath) { logger.info('🖼️ [Cover] Compositing brand text...') try { // Téléchargement background const bgResponse = await axios.get(backgroundUrl, { responseType: 'arraybuffer' }) const baseImage = sharp(bgResponse.data) const metadata = await baseImage.metadata() const width = metadata.width || 1024 const height = metadata.height || 1024 const brandOverlay = buildBrandSvg({ width, height, text: BRAND_TEXT }) // Composition const stampedBuffer = await baseImage .ensureAlpha() .composite([{ input: brandOverlay, left: 0, top: 0, 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 }, }, }) return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}` } catch (error) { logger.error('❌ [Cover] buildCoverWithBrandText failed', error) // En cas d'échec du texte, on renvoie l'URL originale pour ne pas tout perdre return backgroundUrl } } /** * Cœur de la logique de génération */ async function performCoverGeneration(project) { const t0 = Date.now() const artistName = await resolveArtistName(project) 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 prompt = generatePicturePrompt({ ...project, artistName, variantSeed: `${project.id || 'project'}-${uniqueSuffix}`, }) logger.info('🎨 [Cover] Prompt generated', { projectId: project.id, artistName, variant: uniqueSuffix, promptPreview: prompt.slice(0, 100) + '...', }) 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 buildCoverWithBrandText(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, result: firstOption.finalUrl, // selectedOptionId: firstOption.id, // disable default selection options, // Sauvegarde de toutes les variantes réussies }, coverStatus: 'GENERATED', updatedAt: FieldValue.serverTimestamp(), }, { merge: true } ) logger.info('🏁 [Cover] Process complete', { projectId: project.id, successCount: options.length, duration: Date.now() - t0, }) return firstOption.finalUrl } /** * TRIGGER FIRESTORE * Déclenché à la création d'un document dans 'tasks/{taskId}' */ exports.onTaskCreateGenerateCover = onDocumentCreated( { timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent) memory: '1GiB', document: 'tasks/{taskId}', secrets: [GEMINI_API_KEY], }, async (event) => { 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') { // Feature désactivée pour le moment await event.data.ref.update({ status: 'CANCELLED', 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(), }) // 2. Chargement Projet const projectSnap = await refList.projects.doc(projectId).get() if (!projectSnap.exists) throw new Error('Projet introuvable') 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' }) await event.data.ref.update({ status: 'DONE', info: 'Already generated', }) return } // 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(), }) // 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, projectTitle, coverUrl, }, }).catch((err) => logger.warn('Notification failed', err)) } } catch (error) { logger.error(`🔥 [Task ${taskId}] Failed`, error) // Mise à jour erreur Tâche await event.data.ref.set({ status: 'ERROR', error: error.message }, { merge: true }) // Mise à jour erreur Projet await refList.projects.doc(projectId).update({ coverStatus: 'ERROR', updatedAt: FieldValue.serverTimestamp(), }) // 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: error.message, }, }).catch(() => {}) } } } )