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') const path = require('path') const { ALERT_TYPE, refList } = require('../index') const { sendNotification } = require('./notifications') const COVER_TEMPLATE = 'MUSICLAND_PLAYBACKER' const COVER_OPTION_ID = 'musicland-playbacker' const COVER_SIZE = 1024 const PROFILE_SIZE = 320 const TEMPLATE_PATH = path.join(__dirname, '../assets/musiclandPlaybackCover.png') const FONT_FAMILY = 'Poppins, Montserrat, Arial, sans-serif' const escapeSvgText = (value = '') => String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') const normalizeArtistName = (value) => { if (typeof value !== 'string') { return 'MusicLand' } const trimmedValue = value.trim() return trimmedValue || 'MusicLand' } const normalizeSongTitle = (value) => { if (typeof value !== 'string') { return 'Sans titre' } const trimmedValue = value.trim() return trimmedValue || 'Sans titre' } const getTitleFontSize = (songTitle) => { if (songTitle.length <= 16) return 38 if (songTitle.length <= 24) return 32 return 26 } const getUsernameFontSize = (username) => { if (username.length <= 16) return 52 if (username.length <= 24) return 44 return 36 } const buildTextOverlay = ({ songTitle, username }) => { const safeSongTitle = escapeSvgText(songTitle) const safeUsername = escapeSvgText(username) const titleFontSize = getTitleFontSize(songTitle) const usernameFontSize = getUsernameFontSize(username) return Buffer.from(` ${safeSongTitle} ${safeUsername} `) } 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 mask } async function loadCoverIdentity(project) { const username = normalizeArtistName(project.userName) const songTitle = normalizeSongTitle(project.title) const userId = typeof project.userId === 'string' ? project.userId.trim() : '' if (!userId) { return { songTitle, username, profilePictureURL: project.coverCenterImageURL || null } } try { const userSnapshot = await refList.users.doc(userId).get() const userData = userSnapshot.exists ? userSnapshot.data() : null return { songTitle, username: normalizeArtistName(project.userName || userData?.userName), profilePictureURL: project.coverCenterImageURL || userData?.profilePictureURL || null, } } catch (error) { logger.warn('[Cover] Unable to load profile picture', { projectId: project.id, error: error.message, }) return { songTitle, username, profilePictureURL: project.coverCenterImageURL || null } } } async function buildProfileOverlay(profilePictureURL) { if (!profilePictureURL) { return null } try { 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: circularMask, raw: { width: PROFILE_SIZE, height: PROFILE_SIZE, channels: 4, }, blend: 'dest-in', }, ]) .png() .toBuffer() } catch (error) { logger.warn('[Cover] Unable to load profile image, keeping default icon', { error: error.message, }) return null } } async function uploadCover(buffer, targetPath) { const bucket = admin.storage().bucket() const token = crypto.randomUUID() const file = bucket.file(targetPath) await file.save(buffer, { 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}` } 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') } const { songTitle, username, profilePictureURL } = await loadCoverIdentity(project) const profileOverlay = await buildProfileOverlay(profilePictureURL) const overlays = [{ input: buildTextOverlay({ songTitle, username }), left: 0, top: 0 }] if (profileOverlay) { overlays.unshift({ input: profileOverlay, left: (COVER_SIZE - PROFILE_SIZE) / 2, top: 568 - PROFILE_SIZE / 2, }) } 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: { template: COVER_TEMPLATE, result: coverUrl, generatedBackground: null, selectedOptionId: COVER_OPTION_ID, options: [option], }, coverStatus: 'GENERATED', updatedAt: FieldValue.serverTimestamp(), }, { merge: true } ) logger.info('[Cover] MusicLand cover prepared', { projectId: project.id, duration: Date.now() - startedAt, }) return coverUrl } exports.onTaskCreateGenerateCover = onDocumentCreated( { timeoutSeconds: 120, memory: '1GiB', document: 'tasks/{taskId}', }, async (event) => { const data = event.data?.data() || {} const { type, projectId } = data const taskId = event.params.taskId if (!projectId || !['cover', 'combine'].includes(type)) return 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 { await refList.projects.doc(projectId).update({ coverStatus: 'GENERATING', updatedAt: FieldValue.serverTimestamp(), }) const projectSnapshot = await refList.projects.doc(projectId).get() if (!projectSnapshot.exists) { throw new Error('Projet introuvable') } 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(), }) } await event.data.ref.update({ status: 'DONE', coverUrl, updatedAt: FieldValue.serverTimestamp(), }) 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}" est prête.`, data: { type: ALERT_TYPE?.COVER_GENERATION_SUCCESS, projectId, projectTitle, coverUrl, }, }).catch((error) => logger.warn('Notification failed', error)) } } catch (error) { 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(), }) 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 préparer la pochette pour "${projectData.title || 'ton projet'}".`, data: { type: ALERT_TYPE?.COVER_GENERATION_FAILED, projectId, error: error.message, }, }).catch(() => {}) } } } )