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 = {}, options = {},
) => { ) => {
const size = Number(options.size || 1024); 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 (!input) return null;
if (typeof input === "string") { try {
if (input.startsWith("data:")) return input; if (typeof input === "string") {
// http(s) URL - laissez le middleware télécharger if (input.startsWith("data:")) return input;
if (/^https?:\/\//i.test(input)) return input; if (/^https?:\/\//i.test(input)) {
// Base64 brut (non recommandé sans mimeType) -> tenter fallback const axios = require("axios");
if (/^[A-Za-z0-9+/=]+$/.test(input) && input.length > 256) { const resp = await axios.get(input, { responseType: "arraybuffer" });
return `data:${fallbackMime};base64,${input}`; 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 (typeof input === "object") { if (input.url.startsWith("data:")) return input.url;
if (input.url) { if (/^https?:\/\//i.test(input.url)) {
if (input.url.startsWith("data:")) return input.url; const axios = require("axios");
if (/^https?:\/\//i.test(input.url)) return input.url; // middleware download const resp = await axios.get(input.url, {
if (input.base64) 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 `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; return null;
}; };
const bgUrl = toDataUrl(backgroundImage, "image/png"); const bgUrl = await toDataUrl(backgroundImage, "image/png");
const fgUrl = toDataUrl(foregroundImage, "image/png"); const fgUrl = await toDataUrl(foregroundImage, "image/png");
if (!bgUrl || !fgUrl) { if (!bgUrl || !fgUrl) {
throw new Error( throw new Error(
"CombineCoverAndPicture: images d'entrée invalides (background/foreground requis)", "CombineCoverAndPicture: images d'entrée invalides (background/foreground requis)",
@@ -234,23 +254,38 @@ exports.CombineCoverAndPicture = async (
// Instruction en FR pour un compositing propre // Instruction en FR pour un compositing propre
const instruction = ` const instruction = `
Combine les deux images fournies sur une toile carrée ${size}x${size}. Tu reçois 2 images: la première est l'arrière-plan, la seconde est une photo à incruster.
Utilise la première image comme arrière-plan en plein cadre (recadrer au besoin sans bords vides). 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).
Détoure précisément le sujet principal de la seconde image (suppression d'arrière-plan), 2) Détoure le sujet principal de la seconde image.
puis place-le par-dessus, centré, avec une légère ombre portée douce pour le détacher. 3) Place le sujet détouré par-dessus l'arrière-plan, et essaie de faire une jolie pochette.
Préserve les détails/contours, couleurs naturelles, et une intégration réaliste (échelle, lumière). 4) Aucun texte, aucun logo, aucun filigrane. Export en PNG.
L'image générer sera une pochette d'album de musique.
Aucun texte, aucun logo, aucun filigrane. Export en PNG.
`.trim(); `.trim();
// Construire le prompt avec parties media // Construire le prompt avec parties media
// Utiliser des data URLs pour garantir la prise en compte des 2 images
const parts = [ const parts = [
{ text: instruction }, { text: instruction },
{ media: { url: bgUrl } }, { media: { url: bgUrl } },
{ media: { url: fgUrl } }, { 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; const media = res?.media;
if (!media || !media.url) { if (!media || !media.url) {
throw new Error("La génération n'a pas retourné d'image composite."); 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( const publicUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
path, path,
)}?alt=media&token=${token}`; )}?alt=media&token=${token}`;
console.log("✅ [CombineCoverAndPicture] Uploaded", {
path,
mimeType,
urlLen: publicUrl.length,
});
return publicUrl; return publicUrl;
}; };
+3 -7
View File
@@ -1,12 +1,8 @@
const admin = require("firebase-admin"); const admin = require("firebase-admin");
const serviceAccount = require("./serviceAccountKey.json");
admin.initializeApp({ // Use default credentials/environment provided by Cloud Functions.
credential: admin.credential.cert(serviceAccount), // Avoid bundling a service account key and hardcoding project/bucket.
storageBucket: "musicland-d33f9.firebasestorage.app", admin.initializeApp();
databaseURL: "https://musicland-d33f9-default-rtdb.firebaseio.com",
projectId: "musicland-d33f9",
});
// Les fonctions IA ont été déplacées vers helpers/gemini // 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 t0 = Date.now();
const prompt = generatePicturePrompt(project); const prompt = generatePicturePrompt(project);
// Utiliser generateImageV2 qui renvoie directement l'URL publique // 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 = ""; let coverUrl = "";
try { try {
// Utiliser une taille fixe de 1024 pixels // Utiliser une taille fixe de 1024 pixels
coverUrl = await generateImageV2(prompt, 1024); coverUrl = await generateImageV2(prompt, 1024);
console.log("coverUrl", coverUrl); console.log("coverUrl", coverUrl);
} catch (e) { } catch (e) {
logger.error("❌ [Cover] generateImageV2 failed", e); logger.error("❌ [Cover] generateImageV2 failed", {
projectId: project.id,
error: e?.message || String(e),
});
throw e; throw e;
} }
if (!coverUrl) { 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); const combinedUrl = await CombineCoverAndPicture(background, foreground);
// Sauvegarder le résultat et marquer comme généré // Sauvegarder le résultat et marquer comme généré
await db.collection("projects").doc(project.id).set( await db
{ .collection("projects")
"cover.result": combinedUrl, .doc(project.id)
coverStatus: "GENERATED", .set(
updatedAt: admin.firestore.FieldValue.serverTimestamp(), {
}, cover: {
{ merge: true }, ...project.cover,
); result: combinedUrl,
},
coverStatus: "GENERATED",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
);
logger.info("✅ [Cover] Combined image saved", { logger.info("✅ [Cover] Combined image saved", {
projectId: project.id, projectId: project.id,
@@ -102,6 +119,11 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
if (!["cover", "combine"]?.includes(type)) { if (!["cover", "combine"]?.includes(type)) {
throw new Error(`Type de tâche non supporté: ${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({ await db.collection("projects").doc(projectId).update({
coverStatus: "GENERATING", coverStatus: "GENERATING",
updatedAt: admin.firestore.FieldValue.serverTimestamp(), updatedAt: admin.firestore.FieldValue.serverTimestamp(),
@@ -114,6 +136,12 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
} }
project.id = projectId; project.id = projectId;
logger.info("🔎 [Task] Project loaded", {
projectId,
hasGeneratedBackground: !!project?.cover?.generatedBackground,
hasUserForeground: !!project?.cover?.userForeground,
});
if (type === "cover") { if (type === "cover") {
logger.info("🎨 [Task] Start cover generation", { projectId }); logger.info("🎨 [Task] Start cover generation", { projectId });
coverUrl = await performCoverGeneration(project); coverUrl = await performCoverGeneration(project);
@@ -129,9 +157,18 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
coverUrl, coverUrl,
updatedAt: admin.firestore.FieldValue.serverTimestamp(), updatedAt: admin.firestore.FieldValue.serverTimestamp(),
}); });
logger.info("📦 [Task] Marked DONE", {
taskId: event?.params?.taskId,
projectId,
});
return; return;
} catch (error) { } 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( await event.data.ref.set(
{ status: "ERROR", error: error?.message || "Erreur" }, { status: "ERROR", error: error?.message || "Erreur" },
{ merge: true }, { merge: true },
+8 -2
View File
@@ -116,11 +116,17 @@ const PhotoCover = () => {
> >
<BorderGradientButton <BorderGradientButton
onPress={() => navigate(Routes.ValidateCover)} onPress={() => navigate(Routes.ValidateCover)}
title="Non, valider la pochette" title={
isGenerating ? "Génération en cours..." : "Valider la pochette"
}
disabled={isGenerating}
/> />
<GradientButton <GradientButton
title="Oui choisir une image" title={
isGenerating ? "Génération en cours..." : "Oui choisir une image"
}
onPress={() => onChooseForegroundImage()} onPress={() => onChooseForegroundImage()}
disabled={isGenerating}
/> />
</View> </View>
</Page> </Page>