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 { ALERT_TYPE, refList } = require("../index"); const { sendNotification } = require("./notifications"); const bucket = admin.storage().bucket(); const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandLogo.png"); const pickFirstNonEmpty = (...values) => { for (const value of values) { if (typeof value === "string") { const trimmed = value.trim(); if (trimmed.length > 0) { return trimmed; } } } return ""; }; const combineNames = (...parts) => parts .map((part) => (typeof part === "string" ? part.trim() : "")) .filter(Boolean) .join(" ") .trim(); async function resolveArtistName(project = {}) { const owner = project?.owner || {}; const direct = pickFirstNonEmpty( project?.artistName, project?.userName, owner?.artistName, owner?.userName, owner?.displayName ); if (direct) return direct; const userId = typeof project?.userId === "string" && project.userId.trim() ? 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 ) || "" ); } catch (error) { logger.warn("⚠️ [Cover] Unable to resolve artist name", { projectId: project?.id || null, userId, error: error?.message || String(error), }); return ""; } } 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), ]); const baseImage = sharp(backgroundBuffer); const { width = 1024, height = 1024 } = await baseImage.metadata(); const desiredWidth = Math.round(width * 0.32); const margin = Math.round(width * 0.04); const { data: resizedLogo, info: logoInfo } = await sharp(logoBuffer) .resize({ width: desiredWidth }) .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(); 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}`; } // Shared core for generating and saving the cover, and updating the project async function performCoverGeneration(project) { const t0 = Date.now(); const artistName = await resolveArtistName(project); const prompt = generatePicturePrompt({ ...project, 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) { 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 { 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, }); } const [firstOption] = options; await refList.projects.doc(project.id).set( { cover: { generatedBackground: firstOption?.generatedUrl || null, result: firstOption?.finalUrl || null, selectedOptionId: firstOption?.id || null, options, }, coverStatus: "GENERATED", updatedAt: FieldValue.serverTimestamp(), }, { merge: true } ); logger.info("✅ [Cover] Saved", { projectId: project.id, optionsCount: options.length, ms: Date.now() - t0, }); return firstOption?.finalUrl || null; } // Firestore trigger: création d'une tâche de génération de cover exports.onTaskCreateGenerateCover = onDocumentCreated( { timeoutSeconds: 540, 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, }); if (type === "combine") { logger.warn("⛔ [Task] Combine task ignored (feature disabled)", { projectId, taskId: event?.params?.taskId, }); await event.data.ref.update({ status: "CANCELLED", error: "La personnalisation de la pochette avec une photo n'est plus disponible.", updatedAt: FieldValue.serverTimestamp(), }); return; } 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; const existingOptionsCount = Array.isArray(project?.cover?.options) ? project.cover.options.length : 0; 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(), }); await event.data.ref.update({ status: "CANCELLED", error: "Cover already generated", updatedAt: FieldValue.serverTimestamp(), }); return; } if (type === "cover") { logger.info("🎨 [Task] Start cover generation", { projectId }); coverUrl = await performCoverGeneration(project); logger.info("✅ [Task] Cover generated", { projectId }); } 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", { projectId, error: notifError?.message || String(notifError), }); } } return; } catch (error) { logger.error("❌ [Task] Cover generation error", { taskId: event?.params?.taskId, projectId, type, error: error?.message || String(error), }); await event.data.ref.set( { status: "ERROR", error: error?.message || "Erreur" }, { merge: true } ); await refList.projects.doc(projectId).update({ coverStatus: "ERROR", }); 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", { projectId, error: notifError?.message || String(notifError), }); } } } } );