end of cover flow. Todo: fix combine pictures and save path in storage

This commit is contained in:
Thomas Demirdjian
2025-09-04 14:53:19 +02:00
parent c30b87b565
commit ce786d4750
17 changed files with 1104 additions and 850 deletions
+102 -134
View File
@@ -1,123 +1,86 @@
const { onDocumentCreated } = require("firebase-functions/v2/firestore");
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const { generateImage } = require("../index");
const {
generateImageV2,
CombineCoverAndPicture,
} = require("../helpers/gemini");
const { generatePicturePrompt } = require("../helpers/prompts");
const db = admin.firestore();
// Shared core for generating and saving the cover, and updating the project
async function performCoverGeneration(projectId) {
async function performCoverGeneration(project) {
const t0 = Date.now();
const snap = await db.collection("projects").doc(projectId).get();
if (!snap.exists) {
throw new Error("Projet introuvable");
}
const project = snap.data() || {};
const title = project?.title || "";
const lyricsSections = Array.isArray(project?.lyrics)
? project.lyrics
: [
project?.lyrics?.couplet
? { type: "couplet", lyrics: project.lyrics.couplet }
: null,
project?.lyrics?.refrain
? { type: "refrain", lyrics: project.lyrics.refrain }
: null,
].filter(Boolean);
const cfg = project?.musicConfig || {};
const genres = Array.isArray(cfg?.genres) ? cfg.genres : [];
const tempo = cfg?.tempo || "";
const voice = cfg?.voice || "";
const instruments = Array.isArray(cfg?.instruments) ? cfg.instruments : [];
// Construire un bref contexte visuel à partir des paroles
const sampleLyrics = (lyricsSections || [])
.map((s) => (s?.lyrics || "").split("\n").slice(0, 2).join(" "))
.filter(Boolean)
.slice(0, 6)
.join(" | ");
const ambianceParts = [];
if (genres.length) ambianceParts.push(`Genres: ${genres.join(", ")}`);
if (tempo) ambianceParts.push(`Tempo: ${tempo}`);
if (instruments.length)
ambianceParts.push(`Instruments: ${instruments.join(", ")}`);
if (voice) ambianceParts.push(`Vocal: ${voice}`);
const ambiance = ambianceParts.join(" ; ");
const system = `
Tu es un générateur qui ne renvoie que des données texte. Quand on te demande une image, tu renvoies UNIQUEMENT les octets de l'image encodés en base64, sans aucun autre texte, sans JSON, sans balises, sans préfixe data:.
`.trim();
const prompt = `
Crée une image de pochette d'album 1024x1024, moderne, abstraite et impactante, cohérente avec:
- Titre: ${title || "Sans titre"}
- Ambiance / Style: ${ambiance}
Contraintes:
- Style abstrait: formes géométriques, textures, dégradés, couleurs harmonieuses
- Aucun texte, aucun logo, aucune personne ni visage
- Image PNG, sans typographie apparente
`.trim();
// Utiliser generateImage qui renvoie directement le base64 (sans préfixe data:)
logger.info("🎨 [Cover] Calling model", { projectId });
let b64 = "";
let mimeType = "image/png";
const prompt = generatePicturePrompt(project);
// Utiliser generateImageV2 qui renvoie directement l'URL publique
logger.info("🎨 [Cover] Calling model V2", { projectId: project.id });
let coverUrl = "";
try {
const ai = await generateImage({ system, prompt });
if (typeof ai === "string") {
b64 = ai.trim();
} else if (ai && typeof ai === "object") {
if (typeof ai.imageUrl === "string") {
b64 = ai.imageUrl.replace(/^data:image\/[a-zA-Z0-9.+-]+;base64,/, "").trim();
} else if (typeof ai.b64 === "string") {
b64 = ai.b64.trim();
if (typeof ai.mimeType === "string") mimeType = ai.mimeType;
}
}
// Utiliser une taille fixe de 1024 pixels
coverUrl = await generateImageV2(prompt, 1024);
console.log("coverUrl", coverUrl);
} catch (e) {
logger.error("❌ [Cover] generateImage failed", e);
logger.error("❌ [Cover] generateImageV2 failed", e);
throw e;
}
if (!b64) {
throw new Error("Génération d'image échouée (base64 vide)");
if (!coverUrl) {
throw new Error("Génération d'image échouée (URL vide)");
}
// Sauvegarde dans Cloud Storage
const buffer = Buffer.from(b64, "base64");
const bucket = admin.storage().bucket();
const ext = mimeType === "image/jpeg" ? "jpg" : mimeType === "image/webp" ? "webp" : "png";
const path = `musics/${projectId}/cover.${ext}`;
const token = require("crypto").randomUUID();
await bucket.file(path).save(buffer, {
resumable: false,
metadata: {
contentType: mimeType || "image/png",
cacheControl: "public, max-age=31536000",
metadata: { firebaseStorageDownloadTokens: token },
},
});
const coverUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
path,
)}?alt=media&token=${token}`;
// Mettre à jour le projet avec l'URL et le statut
await db.collection("projects").doc(projectId).set(
await db
.collection("projects")
.doc(project.id)
.set(
{
cover: {
generatedBackground: coverUrl,
},
coverStatus: "GENERATED",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
);
logger.info("✅ [Cover] Saved", {
projectId: project.id,
coverUrl,
ms: Date.now() - t0,
});
return coverUrl;
}
async function performCombineGeneration(project) {
const t0 = Date.now();
const background = project?.cover?.generatedBackground || null;
const foreground = project?.cover?.userForeground || null;
if (!background || !foreground) {
throw new Error(
"Images manquantes: cover.generatedBackground et cover.userForground sont requis",
);
}
const combinedUrl = await CombineCoverAndPicture(background, foreground);
// Sauvegarder le résultat et marquer comme généré
await db.collection("projects").doc(project.id).set(
{
coverUrl,
"cover.result": combinedUrl,
coverStatus: "GENERATED",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
);
logger.info("✅ [Cover] Saved", { projectId, path, ms: Date.now() - t0 });
return coverUrl;
logger.info("✅ [Cover] Combined image saved", {
projectId: project.id,
combinedUrl,
ms: Date.now() - t0,
});
return combinedUrl;
}
// Firestore trigger: création d'une tâche de génération de cover
@@ -128,49 +91,54 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
document: "tasks/{taskId}",
},
async (event) => {
const data = event?.data?.data() || {};
const type = data?.type || "";
const projectId = data?.projectId || null;
let coverUrl = null;
try {
const data = event?.data?.data() || {};
const type = data?.type || "";
const projectId = data?.projectId || null;
if (type !== "cover") {
logger.info("️ [Task] Ignored non-cover task", { type });
return;
if (!projectId) {
throw new Error("projectId manquant dans la task");
}
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}`);
}
await db.collection("projects").doc(projectId).update({
coverStatus: "GENERATING",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
logger.info("🎨 [Task] Start cover generation", { projectId });
const project =
(await db.collection("projects").doc(projectId).get())?.data() || null;
if (!project) {
throw new Error("Projet non trouvé");
}
project.id = projectId;
// Marquer le projet en génération
await db.collection("projects").doc(projectId).set(
{
coverStatus: "GENERATING",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
);
if (type === "cover") {
logger.info("🎨 [Task] Start cover generation", { projectId });
coverUrl = await performCoverGeneration(project);
logger.info("✅ [Task] Cover generated", { projectId });
} else if (type === "combine") {
logger.info("🧩 [Task] Start combine (cover + photo)", { projectId });
coverUrl = await performCombineGeneration(project);
logger.info("✅ [Task] Combine generated", { projectId, coverUrl });
}
const coverUrl = await performCoverGeneration(projectId);
// Marquer la task comme terminée
await event.data.ref.set(
{
status: "DONE",
coverUrl,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
);
logger.info("✅ [Task] Cover generated", { projectId });
await event.data.ref.update({
status: "DONE",
coverUrl,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
return;
} catch (error) {
logger.error("❌ [Task] Cover generation error", error);
try {
await event.data.ref.set(
{ status: "ERROR", error: error?.message || "Erreur" },
{ merge: true },
);
} catch (_) {}
await event.data.ref.set(
{ status: "ERROR", error: error?.message || "Erreur" },
{ merge: true },
);
await db.collection("projects").doc(projectId).update({
coverStatus: "ERROR",
});
}
},
);
+5 -2
View File
@@ -1,6 +1,6 @@
const { onCall } = require("firebase-functions/v2/https");
const { z } = require("genkit");
const { generateAI } = require("../index");
const { generateAI } = require("../helpers/gemini");
exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
try {
@@ -45,7 +45,7 @@ Schéma de rimes : ${rhymes}
Génère un titre accrocheur et les paroles de la chanson en suivant ` +
`strictement cette structure.
Le titre doit être court, ` +
`mémorable et refléter l'esprit de l'agence Minuit.
`mémorable et refléter l'esprit de la musique.
Pour chaque ` +
`partie ("couplet" ou "refrain"), écris 4 à 16 lignes adaptées, ` +
`expressives et cohérentes avec le style et l'émotion souhaitée.
@@ -75,6 +75,9 @@ Pour chaque ` +
"Paroles de la chanson sous forme de tableau de sections " +
"structurées.",
),
lyricsDescription: z
.string()
.describe("Courte déscription qui résume les paroles de la musique"),
success: z.boolean().describe("Indique si la génération a réussi"),
});
+12 -11
View File
@@ -18,6 +18,7 @@ const SUNO_MODEL = "V4_5";
// const SUNO_MODEL = "V3_5";
const SUNO_CALLBACK_URL =
"https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback";
// "https://music-sunocallback-ifdrkc2c3a-uc.a.run.app";
/**
* Limite la longueur d'une chaîne
@@ -200,7 +201,7 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
"Content-Type": "application/json",
Authorization: `Bearer ${SUNO_API_KEY}`,
},
}
},
);
const parsed = response.data;
@@ -219,7 +220,7 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
} catch (error) {
console.error("❌ Erreur lors de la génération de musique:", error);
throw new Error(
`Erreur lors de la génération de musique: ${error.message}`
`Erreur lors de la génération de musique: ${error.message}`,
);
}
});
@@ -248,7 +249,7 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => {
Authorization: `Bearer ${SUNO_API_KEY}`,
},
timeout: 30000, // 30 secondes de timeout
}
},
);
parsed = response.data;
@@ -364,7 +365,7 @@ exports.getSunoTimestamps = onCall(async ({ data = {} }) => {
Authorization: `Bearer ${SUNO_API_KEY}`,
},
timeout: 30000,
}
},
);
parsed = response.data;
console.log("📊 Réponse timestamps Suno API:", parsed?.code, parsed?.msg);
@@ -416,7 +417,7 @@ exports.getSunoTimestamps = onCall(async ({ data = {} }) => {
},
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
{ merge: true },
);
saved = true;
console.log("💾 Timestamps sauvegardés dans Firestore", {
@@ -426,7 +427,7 @@ exports.getSunoTimestamps = onCall(async ({ data = {} }) => {
} catch (e) {
console.error(
"❌ Échec sauvegarde timestamps Firestore:",
e?.message || e
e?.message || e,
);
}
@@ -532,7 +533,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
const audioUrls = tracks
.map(
(t) =>
t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl
t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl,
)
.filter(Boolean)
.slice(0, 2);
@@ -547,7 +548,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
has_audio_url: !!t.audio_url,
has_stream_audio_url: !!t.stream_audio_url,
})),
}
},
);
}
@@ -572,14 +573,14 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
},
});
const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
path
path,
)}?alt=media&token=${token}`;
console.log("✅ [SunoCallback] Sauvegardé:", path, "URL:", downloadUrl);
return { path, url: downloadUrl };
} catch (e) {
console.error(
`❌ [SunoCallback] Échec save piste ${index + 1}:`,
e.message
e.message,
);
return null;
}
@@ -599,7 +600,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
musicUrls,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
{ merge: true },
);
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
projectId,