387 lines
11 KiB
JavaScript
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),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
);
|