343 lines
9.8 KiB
JavaScript
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, '<')
|
|
.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(`
|
|
<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(() => {})
|
|
}
|
|
}
|
|
}
|
|
)
|