re choose cover and new prompt
This commit is contained in:
+61
-53
@@ -2,8 +2,6 @@ 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')
|
||||
@@ -16,23 +14,47 @@ const { sendNotification } = require('./notifications')
|
||||
|
||||
// Configuration
|
||||
const bucket = admin.storage().bucket()
|
||||
const LOGO_PATH = path.resolve(__dirname, '../assets/musicLandLogo.png')
|
||||
const BRAND_TEXT = 'By Musicland.ai'
|
||||
const BRAND_FONT_FAMILY = 'Poppins, Montserrat, Arial, sans-serif'
|
||||
|
||||
// Cache mémoire pour le buffer du logo (évite les I/O disque à chaque appel sur instance chaude)
|
||||
let _cachedLogoBuffer = null
|
||||
const escapeSvgText = (value = '') =>
|
||||
String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
/**
|
||||
* Récupère le buffer du logo depuis le cache ou le disque
|
||||
*/
|
||||
const getLogoBuffer = async () => {
|
||||
if (_cachedLogoBuffer) return _cachedLogoBuffer
|
||||
try {
|
||||
_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')
|
||||
}
|
||||
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>`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,39 +118,25 @@ async function resolveArtistName(project = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute le logo en filigrane sur l'image générée
|
||||
* Ajoute le texte de marque en filigrane sur l'image générée
|
||||
*/
|
||||
async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
||||
logger.info('🖼️ [Cover] Compositing logo...')
|
||||
async function buildCoverWithBrandText(backgroundUrl, targetPath) {
|
||||
logger.info('🖼️ [Cover] Compositing brand text...')
|
||||
|
||||
try {
|
||||
// Téléchargement background + Lecture Logo (parallèle)
|
||||
const [bgResponse, logoBuffer] = await Promise.all([
|
||||
axios.get(backgroundUrl, { responseType: 'arraybuffer' }),
|
||||
getLogoBuffer(),
|
||||
])
|
||||
// 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
|
||||
|
||||
// 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)
|
||||
|
||||
// Redimensionnement du logo
|
||||
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 brandOverlay = buildBrandSvg({ width, height, text: BRAND_TEXT })
|
||||
|
||||
// Composition
|
||||
const stampedBuffer = await baseImage
|
||||
.ensureAlpha()
|
||||
.composite([{ input: resizedLogo, left, top, blend: 'over' }])
|
||||
.composite([{ input: brandOverlay, left: 0, top: 0, blend: 'over' }])
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
@@ -147,8 +155,8 @@ async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
||||
|
||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`
|
||||
} catch (error) {
|
||||
logger.error('❌ [Cover] buildCoverWithLogo failed', error)
|
||||
// En cas d'échec du logo, on renvoie l'URL originale pour ne pas tout perdre
|
||||
logger.error('❌ [Cover] buildCoverWithBrandText failed', error)
|
||||
// En cas d'échec du texte, on renvoie l'URL originale pour ne pas tout perdre
|
||||
return backgroundUrl
|
||||
}
|
||||
}
|
||||
@@ -160,18 +168,6 @@ async function performCoverGeneration(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', {
|
||||
projectId: project.id,
|
||||
artistName,
|
||||
promptPreview: prompt.slice(0, 100) + '...',
|
||||
})
|
||||
|
||||
const baseTimestamp = Date.now()
|
||||
const GENERATION_COUNT = 2 // Nombre de variantes simultanées
|
||||
|
||||
@@ -181,6 +177,18 @@ async function performCoverGeneration(project) {
|
||||
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
|
||||
@@ -189,7 +197,7 @@ async function performCoverGeneration(project) {
|
||||
if (!generatedUrl) throw new Error("URL vide retournée par l'IA")
|
||||
|
||||
// 2. Ajout du Logo
|
||||
const finalCoverUrl = await buildCoverWithLogo(generatedUrl, stampedPath)
|
||||
const finalCoverUrl = await buildCoverWithBrandText(generatedUrl, stampedPath)
|
||||
|
||||
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user