fix cover generation

This commit is contained in:
Thomas Demirdjian
2025-09-04 15:33:04 +02:00
parent ce786d4750
commit dd19172804
4 changed files with 128 additions and 50 deletions
+69 -30
View File
@@ -191,36 +191,56 @@ exports.CombineCoverAndPicture = async (
options = {},
) => {
const size = Number(options.size || 1024);
console.log("🧩 [CombineCoverAndPicture] Start", { size });
const toDataUrl = (input, fallbackMime = "image/png") => {
// Convertit diverses entrées (URL http(s), data URL, {base64, mimeType}) en data URL
const toDataUrl = async (input, fallbackMime = "image/png") => {
if (!input) return null;
if (typeof input === "string") {
if (input.startsWith("data:")) return input;
// http(s) URL - laissez le middleware télécharger
if (/^https?:\/\//i.test(input)) return input;
// Base64 brut (non recommandé sans mimeType) -> tenter fallback
if (/^[A-Za-z0-9+/=]+$/.test(input) && input.length > 256) {
return `data:${fallbackMime};base64,${input}`;
try {
if (typeof input === "string") {
if (input.startsWith("data:")) return input;
if (/^https?:\/\//i.test(input)) {
const axios = require("axios");
const resp = await axios.get(input, { responseType: "arraybuffer" });
const mime = resp.headers["content-type"] || fallbackMime;
const b64 = Buffer.from(resp.data).toString("base64");
return `data:${mime};base64,${b64}`;
}
// Base64 brut -> fallback
if (/^[A-Za-z0-9+/=]+$/.test(input) && input.length > 256) {
return `data:${fallbackMime};base64,${input}`;
}
return input;
}
return input; // dernier recours
}
if (typeof input === "object") {
if (input.url) {
if (input.url.startsWith("data:")) return input.url;
if (/^https?:\/\//i.test(input.url)) return input.url; // middleware download
if (input.base64)
if (typeof input === "object") {
if (input.url) {
if (input.url.startsWith("data:")) return input.url;
if (/^https?:\/\//i.test(input.url)) {
const axios = require("axios");
const resp = await axios.get(input.url, {
responseType: "arraybuffer",
});
const mime =
input.mimeType || resp.headers["content-type"] || fallbackMime;
const b64 = Buffer.from(resp.data).toString("base64");
return `data:${mime};base64,${b64}`;
}
if (input.base64)
return `data:${input.mimeType || fallbackMime};base64,${input.base64}`;
return input.url;
}
if (input.base64) {
return `data:${input.mimeType || fallbackMime};base64,${input.base64}`;
return input.url;
}
if (input.base64) {
return `data:${input.mimeType || fallbackMime};base64,${input.base64}`;
}
}
} catch (e) {
console.warn("toDataUrl: échec de conversion en data URL", e?.message);
}
return null;
};
const bgUrl = toDataUrl(backgroundImage, "image/png");
const fgUrl = toDataUrl(foregroundImage, "image/png");
const bgUrl = await toDataUrl(backgroundImage, "image/png");
const fgUrl = await toDataUrl(foregroundImage, "image/png");
if (!bgUrl || !fgUrl) {
throw new Error(
"CombineCoverAndPicture: images d'entrée invalides (background/foreground requis)",
@@ -234,23 +254,38 @@ exports.CombineCoverAndPicture = async (
// Instruction en FR pour un compositing propre
const instruction = `
Combine les deux images fournies sur une toile carrée ${size}x${size}.
Utilise la première image comme arrière-plan en plein cadre (recadrer au besoin sans bords vides).
Détoure précisément le sujet principal de la seconde image (suppression d'arrière-plan),
puis place-le par-dessus, centré, avec une légère ombre portée douce pour le détacher.
Préserve les détails/contours, couleurs naturelles, et une intégration réaliste (échelle, lumière).
L'image générer sera une pochette d'album de musique.
Aucun texte, aucun logo, aucun filigrane. Export en PNG.
Tu reçois 2 images: la première est l'arrière-plan, la seconde est une photo à incruster.
1) Crée une toile carrée ${size}x${size} et place l'arrière-plan en plein cadre, sans bords vides (recadrer si nécessaire).
2) Détoure le sujet principal de la seconde image.
3) Place le sujet détouré par-dessus l'arrière-plan, et essaie de faire une jolie pochette.
4) Aucun texte, aucun logo, aucun filigrane. Export en PNG.
`.trim();
// Construire le prompt avec parties media
// Utiliser des data URLs pour garantir la prise en compte des 2 images
const parts = [
{ text: instruction },
{ media: { url: bgUrl } },
{ media: { url: fgUrl } },
];
const res = await ai.generate({ model, prompt: parts });
console.log("🧩 [CombineCoverAndPicture] Entrées prêtes", {
bgIsDataUrl: typeof bgUrl === "string" && bgUrl.startsWith("data:"),
fgIsDataUrl: typeof fgUrl === "string" && fgUrl.startsWith("data:"),
bgLen: (bgUrl || "").length,
fgLen: (fgUrl || "").length,
});
let res;
try {
res = await ai.generate({ model, prompt: parts });
} catch (err) {
console.error(
"❌ [CombineCoverAndPicture] ai.generate failed",
err?.message || err,
);
throw err;
}
const media = res?.media;
if (!media || !media.url) {
throw new Error("La génération n'a pas retourné d'image composite.");
@@ -289,6 +324,10 @@ Aucun texte, aucun logo, aucun filigrane. Export en PNG.
const publicUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
path,
)}?alt=media&token=${token}`;
console.log("✅ [CombineCoverAndPicture] Uploaded", {
path,
mimeType,
urlLen: publicUrl.length,
});
return publicUrl;
};
+3 -7
View File
@@ -1,12 +1,8 @@
const admin = require("firebase-admin");
const serviceAccount = require("./serviceAccountKey.json");
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
storageBucket: "musicland-d33f9.firebasestorage.app",
databaseURL: "https://musicland-d33f9-default-rtdb.firebaseio.com",
projectId: "musicland-d33f9",
});
// Use default credentials/environment provided by Cloud Functions.
// Avoid bundling a service account key and hardcoding project/bucket.
admin.initializeApp();
// Les fonctions IA ont été déplacées vers helpers/gemini
+48 -11
View File
@@ -14,14 +14,20 @@ async function performCoverGeneration(project) {
const t0 = Date.now();
const prompt = generatePicturePrompt(project);
// Utiliser generateImageV2 qui renvoie directement l'URL publique
logger.info("🎨 [Cover] Calling model V2", { projectId: project.id });
logger.info("🎨 [Cover] Calling model V2", {
projectId: project.id,
promptPreview: String(prompt).slice(0, 160),
});
let coverUrl = "";
try {
// Utiliser une taille fixe de 1024 pixels
coverUrl = await generateImageV2(prompt, 1024);
console.log("coverUrl", coverUrl);
} catch (e) {
logger.error("❌ [Cover] generateImageV2 failed", e);
logger.error("❌ [Cover] generateImageV2 failed", {
projectId: project.id,
error: e?.message || String(e),
});
throw e;
}
if (!coverUrl) {
@@ -62,17 +68,28 @@ async function performCombineGeneration(project) {
);
}
logger.info("🧩 [Combine] Inputs detected", {
bgPreview: String(background).slice(0, 80),
fgPreview: String(foreground).slice(0, 80),
});
const combinedUrl = await CombineCoverAndPicture(background, foreground);
// Sauvegarder le résultat et marquer comme généré
await db.collection("projects").doc(project.id).set(
{
"cover.result": combinedUrl,
coverStatus: "GENERATED",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
);
await db
.collection("projects")
.doc(project.id)
.set(
{
cover: {
...project.cover,
result: combinedUrl,
},
coverStatus: "GENERATED",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
);
logger.info("✅ [Cover] Combined image saved", {
projectId: project.id,
@@ -102,6 +119,11 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
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,
});
await db.collection("projects").doc(projectId).update({
coverStatus: "GENERATING",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
@@ -114,6 +136,12 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
}
project.id = projectId;
logger.info("🔎 [Task] Project loaded", {
projectId,
hasGeneratedBackground: !!project?.cover?.generatedBackground,
hasUserForeground: !!project?.cover?.userForeground,
});
if (type === "cover") {
logger.info("🎨 [Task] Start cover generation", { projectId });
coverUrl = await performCoverGeneration(project);
@@ -129,9 +157,18 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
coverUrl,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
logger.info("📦 [Task] Marked DONE", {
taskId: event?.params?.taskId,
projectId,
});
return;
} catch (error) {
logger.error("❌ [Task] Cover generation error", 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 },