fix cover generation
This commit is contained in:
+69
-30
@@ -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
@@ -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
@@ -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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user