Files
musicland/functions/src/cover.js
T
Thomas Demirdjian 5457030d4a more tickets
2025-11-13 18:31:38 +01:00

387 lines
11 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 { generateImageV2 } = require("../helpers/gemini");
const { generatePicturePrompt } = require("../helpers/prompts");
const path = require("path");
const fs = require("fs");
const axios = require("axios");
const sharp = require("sharp");
const { ALERT_TYPE, refList } = require("../index");
const { sendNotification } = require("./notifications");
const bucket = admin.storage().bucket();
const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandLogo.png");
const pickFirstNonEmpty = (...values) => {
for (const value of values) {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
}
return "";
};
const combineNames = (...parts) =>
parts
.map((part) => (typeof part === "string" ? part.trim() : ""))
.filter(Boolean)
.join(" ")
.trim();
async function resolveArtistName(project = {}) {
const owner = project?.owner || {};
const direct = pickFirstNonEmpty(
project?.artistName,
project?.userName,
owner?.artistName,
owner?.userName,
owner?.displayName
);
if (direct) return direct;
const userId =
typeof project?.userId === "string" && project.userId.trim()
? project.userId.trim()
: "";
if (!userId) return "";
try {
const userSnapshot = await refList.users.doc(userId).get();
if (!userSnapshot?.exists) return "";
const userData = userSnapshot.data() || {};
const fullName = combineNames(userData.firstName, userData.lastName);
return (
pickFirstNonEmpty(
userData.artistName,
userData.userName,
userData.displayName,
fullName
) || ""
);
} catch (error) {
logger.warn("⚠️ [Cover] Unable to resolve artist name", {
projectId: project?.id || null,
userId,
error: error?.message || String(error),
});
return "";
}
}
async function buildCoverWithLogo(backgroundUrl, targetPath) {
logger.info("🖼️ [Cover] Adding logo to generated background");
const [{ data: backgroundBuffer }, logoBuffer] = await Promise.all([
axios.get(backgroundUrl, { responseType: "arraybuffer" }),
fs.promises.readFile(LOGO_PATH),
]);
const baseImage = sharp(backgroundBuffer);
const { width = 1024, height = 1024 } = await baseImage.metadata();
const desiredWidth = Math.round(width * 0.32);
const margin = Math.round(width * 0.04);
const { data: resizedLogo, info: logoInfo } = await sharp(logoBuffer)
.resize({ width: desiredWidth })
.png()
.toBuffer({ resolveWithObject: true });
const left = Math.max(width - logoInfo.width - margin, 0);
const top = Math.max(height - logoInfo.height - margin, 0);
const stampedBuffer = await baseImage
.ensureAlpha()
.composite([
{
input: resizedLogo,
left,
top,
blend: "over",
},
])
.png()
.toBuffer();
const token = require("crypto").randomUUID();
await bucket.file(targetPath).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}`;
}
// Shared core for generating and saving the cover, and updating the project
async function performCoverGeneration(project) {
const t0 = Date.now();
const artistName = await resolveArtistName(project);
const prompt = generatePicturePrompt({
...project,
artistName,
});
// Utiliser generateImageV2 qui renvoie directement l'URL publique
logger.info("🎨 [Cover] Calling model V2", {
projectId: project.id,
promptPreview: String(prompt).slice(0, 160),
});
const baseTimestamp = Date.now();
const options = [];
for (let index = 0; index < 2; index += 1) {
const uniqueSuffix = `${baseTimestamp}-${index}`;
const storageBasePath = `users/${project.userId}/projects/${project.id}`;
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`;
let generatedUrl = "";
try {
generatedUrl = await generateImageV2(prompt, 1024, generatedPath);
logger.info("🎨 [Cover] Candidate generated", {
projectId: project.id,
candidateIndex: index,
});
} catch (e) {
logger.error("❌ [Cover] generateImageV2 failed", {
projectId: project.id,
candidateIndex: index,
error: e?.message || String(e),
});
throw e;
}
if (!generatedUrl) {
throw new Error("Génération d'image échouée (URL vide)");
}
let finalCoverUrl = generatedUrl;
try {
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`;
finalCoverUrl = await buildCoverWithLogo(generatedUrl, stampedPath);
} catch (e) {
logger.error("❌ [Cover] Logo overlay failed", {
projectId: project.id,
candidateIndex: index,
error: e?.message || String(e),
});
}
options.push({
id: uniqueSuffix,
generatedUrl,
finalUrl: finalCoverUrl,
});
}
const [firstOption] = options;
await refList.projects.doc(project.id).set(
{
cover: {
generatedBackground: firstOption?.generatedUrl || null,
result: firstOption?.finalUrl || null,
selectedOptionId: firstOption?.id || null,
options,
},
coverStatus: "GENERATED",
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
);
logger.info("✅ [Cover] Saved", {
projectId: project.id,
optionsCount: options.length,
ms: Date.now() - t0,
});
return firstOption?.finalUrl || null;
}
// Firestore trigger: création d'une tâche de génération de cover
exports.onTaskCreateGenerateCover = onDocumentCreated(
{
timeoutSeconds: 540,
memory: "1GiB",
document: "tasks/{taskId}",
},
async (event) => {
const data = event?.data?.data() || {};
const type = data?.type || "";
const projectId = data?.projectId || null;
let coverUrl = null;
let project = null;
try {
if (!projectId) {
throw new Error("projectId manquant dans la task");
}
if (!["cover", "combine"]?.includes(type)) {
throw new Error(`Type de tâche non supporté: ${type}`);
}
logger.info("🧵 [Task] Received", {
taskId: event?.params?.taskId,
type,
projectId,
});
if (type === "combine") {
logger.warn("⛔ [Task] Combine task ignored (feature disabled)", {
projectId,
taskId: event?.params?.taskId,
});
await event.data.ref.update({
status: "CANCELLED",
error:
"La personnalisation de la pochette avec une photo n'est plus disponible.",
updatedAt: FieldValue.serverTimestamp(),
});
return;
}
await refList.projects.doc(projectId).update({
coverStatus: "GENERATING",
updatedAt: FieldValue.serverTimestamp(),
});
project = (await refList.projects.doc(projectId).get())?.data() || null;
if (!project) {
throw new Error("Projet non trouvé");
}
project.id = projectId;
const existingOptionsCount = Array.isArray(project?.cover?.options)
? project.cover.options.length
: 0;
logger.info("🔎 [Task] Project loaded", {
projectId,
hasGeneratedBackground: !!project?.cover?.generatedBackground,
hasUserForeground: !!project?.cover?.userForeground,
existingOptionsCount,
});
if (existingOptionsCount > 0) {
logger.warn("⛔ [Task] Cover already generated, skipping", {
projectId,
existingOptionsCount,
});
await refList.projects.doc(projectId).update({
coverStatus: "GENERATED",
updatedAt: FieldValue.serverTimestamp(),
});
await event.data.ref.update({
status: "CANCELLED",
error: "Cover already generated",
updatedAt: FieldValue.serverTimestamp(),
});
return;
}
if (type === "cover") {
logger.info("🎨 [Task] Start cover generation", { projectId });
coverUrl = await performCoverGeneration(project);
logger.info("✅ [Task] Cover generated", { projectId });
}
await event.data.ref.update({
status: "DONE",
coverUrl,
updatedAt: FieldValue.serverTimestamp(),
});
logger.info("📦 [Task] Marked DONE", {
taskId: event?.params?.taskId,
projectId,
});
if (
project?.userId &&
typeof project.userId === "string" &&
project.userId.trim()
) {
const receiverId = project.userId.trim();
const projectTitle =
typeof project?.title === "string" && project.title.trim()
? project.title.trim()
: "ton projet";
const message = `Ta nouvelle pochette pour "${projectTitle}" est prête.`;
try {
await sendNotification({
sender: "SYSTEM",
receiver: receiverId,
receiverCollection: "users",
title: "Pochette générée",
message,
data: {
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
projectId,
projectTitle,
coverUrl,
},
});
} catch (notifError) {
logger.error("❌ [Cover] Failed to send success notification", {
projectId,
error: notifError?.message || String(notifError),
});
}
}
return;
} catch (error) {
logger.error("❌ [Task] Cover generation error", {
taskId: event?.params?.taskId,
projectId,
type,
error: error?.message || String(error),
});
await event.data.ref.set(
{ status: "ERROR", error: error?.message || "Erreur" },
{ merge: true }
);
await refList.projects.doc(projectId).update({
coverStatus: "ERROR",
});
if (
project?.userId &&
typeof project.userId === "string" &&
project.userId.trim()
) {
const receiverId = project.userId.trim();
const projectTitle =
typeof project?.title === "string" && project.title.trim()
? project.title.trim()
: "ton projet";
const message = `La génération de la pochette pour "${projectTitle}" a échoué.`;
try {
await sendNotification({
sender: "SYSTEM",
receiver: receiverId,
receiverCollection: "users",
title: "Pochette indisponible",
message,
data: {
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
projectId,
projectTitle,
error: error?.message || String(error),
},
});
} catch (notifError) {
logger.error("❌ [Cover] Failed to send error notification", {
projectId,
error: notifError?.message || String(notifError),
});
}
}
}
}
);