feat: refine creation and registration flows

This commit is contained in:
Sacha
2026-07-30 09:42:12 +02:00
parent 3fff09ba81
commit 761f3cc200
39 changed files with 1444 additions and 1239 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

+189 -261
View File
@@ -5,261 +5,201 @@ const logger = require('firebase-functions/logger')
const axios = require('axios')
const sharp = require('sharp')
const crypto = require('crypto')
const path = require('path')
// 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 COVER_TEMPLATE = 'MUSICLAND_PLAYBACKER'
const COVER_OPTION_ID = 'musicland-playbacker'
const COVER_SIZE = 1024
const PROFILE_SIZE = 228
const TEMPLATE_PATH = path.join(__dirname, '../assets/musiclandPlaybackCover.png')
const FONT_FAMILY = 'Poppins, Montserrat, Arial, sans-serif'
const escapeSvgText = (value = '') =>
String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
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))
const normalizeArtistName = (value) => {
if (typeof value !== 'string') {
return 'MusicLand'
}
const trimmedValue = value.trim()
return trimmedValue || 'MusicLand'
}
const getArtistFontSize = (artistName) => {
if (artistName.length <= 16) return 52
if (artistName.length <= 24) return 44
return 36
}
const buildTextOverlay = (artistName) => {
const safeArtistName = escapeSvgText(artistName)
const artistFontSize = getArtistFontSize(artistName)
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>
<rect
x="${rectX}"
y="${rectY}"
width="${rectWidth}"
height="${rectHeight}"
rx="${rectRadius}"
ry="${rectRadius}"
fill="${BRAND_BG_COLOR}"
/>
<svg width="${COVER_SIZE}" height="${COVER_SIZE}" viewBox="0 0 ${COVER_SIZE} ${COVER_SIZE}" xmlns="http://www.w3.org/2000/svg">
<text
x="${textX}"
y="${textY}"
x="512"
y="824"
text-anchor="middle"
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>
dominant-baseline="middle"
font-family="${FONT_FAMILY}"
font-size="${artistFontSize}"
font-weight="400"
letter-spacing="3"
fill="#FFFFFF"
>${safeArtistName}</text>
<text
x="512"
y="963"
text-anchor="middle"
dominant-baseline="middle"
font-family="${FONT_FAMILY}"
font-size="54"
font-weight="400"
letter-spacing="5"
fill="#FFFFFF"
>PLAYBACKER</text>
</svg>`)
}
/**
* Utilitaires Strings
*/
const pickFirstNonEmpty = (...values) => {
for (const value of values) {
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim()
const buildCircularMask = (size) => {
const mask = Buffer.alloc(size * size * 4)
const radius = size / 2
const center = radius - 0.5
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const offset = (y * size + x) * 4
const distance = Math.sqrt((x - center) ** 2 + (y - center) ** 2)
const alpha = distance <= radius ? 255 : 0
mask[offset] = 255
mask[offset + 1] = 255
mask[offset + 2] = 255
mask[offset + 3] = alpha
}
}
return ''
return mask
}
const combineNames = (...parts) =>
parts
.map((part) => (typeof part === 'string' ? part.trim() : ''))
.filter(Boolean)
.join(' ')
.trim()
async function loadCoverIdentity(project) {
const artistName = normalizeArtistName(project.userName)
const userId = typeof project.userId === 'string' ? project.userId.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 ''
if (!userId) {
return { artistName, profilePictureURL: null }
}
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)
) || ''
)
const userData = userSnapshot.exists ? userSnapshot.data() : null
return {
artistName: normalizeArtistName(project.userName || userData?.userName),
profilePictureURL: userData?.profilePictureURL || null,
}
} catch (error) {
logger.warn('⚠️ [Cover] Artist name resolution failed', {
projectId: project?.id,
logger.warn('[Cover] Unable to load profile picture', {
projectId: project.id,
error: error.message,
})
return ''
return { artistName, profilePictureURL: null }
}
}
/**
* 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...')
async function buildProfileOverlay(profilePictureURL) {
if (!profilePictureURL) {
return null
}
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
const response = await axios.get(profilePictureURL, { responseType: 'arraybuffer' })
const circularMask = buildCircularMask(PROFILE_SIZE)
return await sharp(response.data)
.resize(PROFILE_SIZE, PROFILE_SIZE, { fit: 'cover', position: 'centre' })
.ensureAlpha()
.composite([{ input: brandOverlay, left: 0, top: 0, blend: 'over' }])
.composite([
{
input: circularMask,
raw: {
width: PROFILE_SIZE,
height: PROFILE_SIZE,
channels: 4,
},
blend: 'dest-in',
},
])
.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
logger.warn('[Cover] Unable to load profile image, keeping default icon', {
error: error.message,
})
return null
}
}
/**
* Cœur de la logique de génération
*/
async function performCoverGeneration(project) {
const t0 = Date.now()
const artistName = await resolveArtistName(project)
async function uploadCover(buffer, targetPath) {
const token = crypto.randomUUID()
const file = bucket.file(targetPath)
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
}
await file.save(buffer, {
resumable: false,
metadata: {
contentType: 'image/png',
cacheControl: 'public, max-age=31536000',
metadata: { firebaseStorageDownloadTokens: token },
},
})
// Attente de la résolution de toutes les générations
const results = await Promise.all(generationPromises)
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`
}
// 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é.')
async function prepareMusiclandCover(project) {
const startedAt = Date.now()
const userId = typeof project.userId === 'string' ? project.userId.trim() : ''
if (!userId) {
throw new Error('Utilisateur du projet introuvable')
}
// Sauvegarde dans Firestore
const [firstOption] = options
const { artistName, profilePictureURL } = await loadCoverIdentity(project)
const profileOverlay = await buildProfileOverlay(profilePictureURL)
const overlays = [{ input: buildTextOverlay(artistName), left: 0, top: 0 }]
if (profileOverlay) {
overlays.unshift({ input: profileOverlay, left: 398, top: 454 })
}
const coverBuffer = await sharp(TEMPLATE_PATH)
.resize(COVER_SIZE, COVER_SIZE, { fit: 'cover', kernel: sharp.kernel.lanczos3 })
.ensureAlpha()
.composite(overlays)
.png()
.toBuffer()
const storagePath = `users/${userId}/projects/${project.id}/cover-musicland-playbacker.png`
const coverUrl = await uploadCover(coverBuffer, storagePath)
const option = {
id: COVER_OPTION_ID,
finalUrl: coverUrl,
generatedUrl: null,
}
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
template: COVER_TEMPLATE,
result: coverUrl,
generatedBackground: null,
selectedOptionId: COVER_OPTION_ID,
options: [option],
},
coverStatus: 'GENERATED',
updatedAt: FieldValue.serverTimestamp(),
@@ -267,82 +207,68 @@ async function performCoverGeneration(project) {
{ merge: true }
)
logger.info('🏁 [Cover] Process complete', {
logger.info('[Cover] MusicLand cover prepared', {
projectId: project.id,
successCount: options.length,
duration: Date.now() - t0,
duration: Date.now() - startedAt,
})
return firstOption.finalUrl
return coverUrl
}
/**
* 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)
timeoutSeconds: 120,
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
if (!projectId || !['cover', 'combine'].includes(type)) return
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId })
if (type === 'combine') {
await event.data.ref.update({
status: 'CANCELLED',
error: "La personnalisation photo n'est plus disponible.",
updatedAt: FieldValue.serverTimestamp(),
})
return
}
logger.info(`[Task ${taskId}] MusicLand cover started`, { 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
const projectSnapshot = await refList.projects.doc(projectId).get()
if (!projectSnapshot.exists) {
throw new Error('Projet introuvable')
}
// 3. Exécution Génération
const coverUrl = await performCoverGeneration(project)
const project = { id: projectId, ...projectSnapshot.data() }
const existingCover = project.cover || {}
let coverUrl = existingCover.template === COVER_TEMPLATE ? existingCover.result : null
if (!coverUrl) {
coverUrl = await prepareMusiclandCover(project)
} else {
await refList.projects.doc(projectId).update({
coverStatus: 'GENERATED',
updatedAt: FieldValue.serverTimestamp(),
})
}
// 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({
@@ -350,28 +276,30 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
receiver: project.userId,
receiverCollection: 'users',
title: 'Pochette prête !',
message: `La pochette pour "${projectTitle}" a été générée avec succès.`,
message: `La pochette pour "${projectTitle}" est prête.`,
data: {
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
projectId,
projectTitle,
coverUrl,
},
}).catch((err) => logger.warn('Notification failed', err))
}).catch((error) => logger.warn('Notification failed', error))
}
} 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
logger.error(`[Task ${taskId}] MusicLand cover failed`, error)
await event.data.ref.set(
{
status: 'ERROR',
error: error.message,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
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({
@@ -379,7 +307,7 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
receiver: projectData.userId,
receiverCollection: 'users',
title: 'Échec pochette',
message: `Impossible de générer la pochette pour "${projectData.title || 'ton projet'}".`,
message: `Impossible de préparer la pochette pour "${projectData.title || 'ton projet'}".`,
data: {
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
projectId,