Files
musicland/functions/src/cover.js
T
2026-09-09 12:06:17 +02:00

343 lines
9.8 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')
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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
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(`
<svg width="${COVER_SIZE}" height="${COVER_SIZE}" viewBox="0 0 ${COVER_SIZE} ${COVER_SIZE}" xmlns="http://www.w3.org/2000/svg">
<text
x="512"
y="170"
text-anchor="middle"
dominant-baseline="middle"
font-family="${FONT_FAMILY}"
font-size="${titleFontSize}"
font-weight="400"
letter-spacing="2"
fill="#FFFFFF"
>${safeSongTitle}</text>
<text
x="512"
y="963"
text-anchor="middle"
dominant-baseline="middle"
font-family="${FONT_FAMILY}"
font-size="${usernameFontSize}"
font-weight="400"
letter-spacing="4"
fill="#FFFFFF"
>${safeUsername}</text>
</svg>`)
}
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(() => {})
}
}
}
)