368 lines
12 KiB
JavaScript
368 lines
12 KiB
JavaScript
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 { 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 escapeSvgText = (value = '') =>
|
|
String(value).replace(/&/g, '&').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 safeText = escapeSvgText(text)
|
|
|
|
return Buffer.from(`
|
|
<svg width="${safeWidth}" height="${safeHeight}" viewBox="0 0 ${safeWidth} ${safeHeight}" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<filter id="brandShadow" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feDropShadow dx="0" dy="${shadowDy}" stdDeviation="${shadowBlur}" flood-color="rgba(0,0,0,0.55)" />
|
|
</filter>
|
|
</defs>
|
|
<text
|
|
x="${safeWidth - margin}"
|
|
y="${safeHeight - margin}"
|
|
text-anchor="end"
|
|
dominant-baseline="alphabetic"
|
|
font-family="${BRAND_FONT_FAMILY}"
|
|
font-size="${fontSize}"
|
|
font-style="italic"
|
|
font-weight="700"
|
|
letter-spacing="${letterSpacing}"
|
|
fill="rgba(255,255,255,0.95)"
|
|
stroke="rgba(0,0,0,0.45)"
|
|
stroke-width="${strokeWidth}"
|
|
paint-order="stroke"
|
|
filter="url(#brandShadow)"
|
|
>${safeText}</text>
|
|
</svg>`)
|
|
}
|
|
|
|
/**
|
|
* 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}',
|
|
},
|
|
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(() => {})
|
|
}
|
|
}
|
|
}
|
|
)
|