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
+294
View File
@@ -0,0 +1,294 @@
const { googleAI } = require("@genkit-ai/googleai");
const { genkit } = require("genkit");
const { GEMINI_API_KEY } = require("../config/keys");
const admin = require("firebase-admin");
const { Buffer } = require("buffer");
const { setTimeout } = require("timers");
exports.generateAI = async ({ system = "", prompt = "", schema }) => {
const genkitInstance = genkit({
plugins: [googleAI({ apiKey: GEMINI_API_KEY })],
model: googleAI.model("gemini-2.5-flash"),
});
if (prompt?.length < 1) {
throw new Error(
"Vous devez spécifier un prompt pour effectuer cette action.",
);
}
const { output } = await genkitInstance.generate({
system,
prompt,
output: { schema },
});
return output;
};
exports.generateImageV2 = async (prompt, size = 1024) => {
if (typeof prompt !== "string" || prompt.trim().length < 1) {
throw new Error("Vous devez spécifier un prompt (string) non vide.");
}
try {
console.log("🚀 [generateImageV2] Démarrage de la génération d'image", {
promptLength: prompt.trim().length,
size,
});
// Ajouter explicitement la taille au prompt si elle n'est pas mentionnée
if (!prompt.includes("1024x1024") && !prompt.includes(size + "x" + size)) {
prompt = `Image carrée ${size}x${size} pixels. ${prompt}`;
}
const ai = genkit({
plugins: [googleAI({ apiKey: GEMINI_API_KEY })],
});
const imageModel = googleAI.model("gemini-2.5-flash-image-preview", {
responseModalities: ["IMAGE"],
// L'API n'accepte pas generationConfig comme paramètre direct
// Les paramètres de génération doivent être passés lors de l'appel generate()
});
console.log("🔄 [generateImageV2] Envoi du prompt au modèle");
// Ajouter un timeout et des tentatives
let attempts = 0;
const maxAttempts = 3;
let res;
while (attempts < maxAttempts) {
try {
attempts++;
console.log(
`🔁 [generateImageV2] Tentative ${attempts}/${maxAttempts}`,
);
// Utiliser Promise.race pour ajouter un timeout
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Timeout dépassé (30s)")), 30000);
});
res = await Promise.race([
ai.generate({
model: imageModel,
prompt: prompt.trim(),
// Paramètres de génération passés dans un objet generation_config
generation_config: {
temperature: 0.7,
top_k: 40,
top_p: 0.95,
max_output_tokens: 8192,
},
}),
timeout,
]);
if (res?.media?.url) {
console.log(
"✅ [generateImageV2] Image générée avec succès à la tentative",
attempts,
);
break; // Sortir de la boucle si on a une image
} else {
console.warn(
`⚠️ [generateImageV2] Tentative ${attempts} sans média, nouvelle tentative...`,
);
// Attendre un peu avant de réessayer
await new Promise((resolve) => setTimeout(resolve, 2000));
}
} catch (err) {
console.error(
`❌ [generateImageV2] Erreur à la tentative ${attempts}:`,
err.message,
);
if (attempts >= maxAttempts) throw err;
// Attendre un peu plus longtemps après une erreur
await new Promise((resolve) => setTimeout(resolve, 3000));
}
}
console.log("📊 [generateImageV2] Réponse finale", {
hasMedia: !!res?.media,
hasUrl: !!res?.media?.url,
responseKeys: Object.keys(res || {}).join(", "),
});
const media = res?.media;
if (!media || !media.url) {
console.error(
"❌ [generateImageV2] Pas de média dans la réponse après toutes les tentatives",
{
response: JSON.stringify(res || {}).substring(0, 500),
prompt: prompt.substring(0, 200) + "...",
},
);
throw new Error(
"La génération n'a pas retourné de média image après plusieurs tentatives.",
);
}
const dataUrl = String(media.url);
const commaIdx = dataUrl.indexOf(",");
if (commaIdx === -1) throw new Error("FORMAT_DATA_URL_INVALIDE");
const header = dataUrl.substring(0, commaIdx);
const b64 = dataUrl.substring(commaIdx + 1);
let mimeType = media.contentType || "image/png";
const headerMatch = header.match(/^data:([^;]+);base64$/i);
if (headerMatch && headerMatch[1]) mimeType = headerMatch[1];
const buffer = Buffer.from(b64, "base64");
const bucket = admin.storage().bucket();
const ext =
mimeType === "image/jpeg"
? "jpg"
: mimeType === "image/webp"
? "webp"
: "png";
const token = require("crypto").randomUUID();
const path = `generated/images/${Date.now()}-${token}.${ext}`;
console.log(
"💾 [generateImageV2] Sauvegarde de l'image dans Firebase Storage",
);
await bucket.file(path).save(buffer, {
resumable: false,
metadata: {
contentType: mimeType,
cacheControl: "public, max-age=31536000",
metadata: { firebaseStorageDownloadTokens: token },
},
});
const publicUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
path,
)}?alt=media&token=${token}`;
console.log("✅ [generateImageV2] Image générée avec succès");
return publicUrl;
} catch (error) {
console.error(
"❌ [generateImageV2] Erreur lors de la génération d'image:",
error,
);
throw new Error(`Erreur lors de la génération d'image: ${error.message}`);
}
};
// Combine deux images avec Gemini (background = cover abstraite, foreground = photo utilisateur détourée)
// Entrées possibles pour chaque image:
// - string data URL ("data:image/...;base64,....")
// - string URL http(s)
// - { base64: string, mimeType: string }
// - { url: string, mimeType?: string }
// Sortie: URL publique de l'image composite (PNG 1024x1024)
exports.CombineCoverAndPicture = async (
backgroundImage,
foregroundImage,
options = {},
) => {
const size = Number(options.size || 1024);
const toDataUrl = (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}`;
}
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)
return `data:${input.mimeType || fallbackMime};base64,${input.base64}`;
return input.url;
}
if (input.base64) {
return `data:${input.mimeType || fallbackMime};base64,${input.base64}`;
}
}
return null;
};
const bgUrl = toDataUrl(backgroundImage, "image/png");
const fgUrl = toDataUrl(foregroundImage, "image/png");
if (!bgUrl || !fgUrl) {
throw new Error(
"CombineCoverAndPicture: images d'entrée invalides (background/foreground requis)",
);
}
const ai = genkit({ plugins: [googleAI({ apiKey: GEMINI_API_KEY })] });
const model = googleAI.model("gemini-2.5-flash-image-preview", {
responseModalities: ["IMAGE"],
});
// 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.
`.trim();
// Construire le prompt avec parties media
const parts = [
{ text: instruction },
{ media: { url: bgUrl } },
{ media: { url: fgUrl } },
];
const res = await ai.generate({ model, prompt: parts });
const media = res?.media;
if (!media || !media.url) {
throw new Error("La génération n'a pas retourné d'image composite.");
}
const dataUrl = String(media.url);
const commaIdx = dataUrl.indexOf(",");
if (commaIdx === -1) throw new Error("FORMAT_DATA_URL_INVALIDE");
const header = dataUrl.substring(0, commaIdx);
const b64 = dataUrl.substring(commaIdx + 1);
let mimeType = media.contentType || "image/png";
const headerMatch = header.match(/^data:([^;]+);base64$/i);
if (headerMatch && headerMatch[1]) mimeType = headerMatch[1];
const buffer = Buffer.from(b64, "base64");
const bucket = admin.storage().bucket();
const ext =
mimeType === "image/jpeg"
? "jpg"
: mimeType === "image/webp"
? "webp"
: "png";
const token = require("crypto").randomUUID();
const path = `generated/combined/${Date.now()}-${token}.${ext}`;
await bucket.file(path).save(buffer, {
resumable: false,
metadata: {
contentType: mimeType,
cacheControl: "public, max-age=31536000",
metadata: { firebaseStorageDownloadTokens: token },
},
});
const publicUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
path,
)}?alt=media&token=${token}`;
return publicUrl;
};
+69
View File
@@ -0,0 +1,69 @@
exports.generatePicturePrompt = (project = {}) => {
const { title = "", lyrics: lyricsRaw, musicConfig = {} } = project || {};
const {
genres = [],
tempo = "",
voice = "",
instruments = [],
mood = "",
} = musicConfig || {};
// Normalise les paroles en tableau de sections { type, lyrics }
const lyricsSections = Array.isArray(lyricsRaw)
? lyricsRaw
: [
lyricsRaw?.couplet
? { type: "couplet", lyrics: lyricsRaw.couplet }
: null,
lyricsRaw?.refrain
? { type: "refrain", lyrics: lyricsRaw.refrain }
: null,
].filter(Boolean);
// Échantillon pour l'ambiance (courtes bribes pour inspirer la direction créative)
const lyricsSample = (lyricsSections || [])
.map((s) => (s?.lyrics || "").split("\n").slice(0, 2).join(" "))
.filter(Boolean)
.slice(0, 6)
.join(" | ");
// Etiquettes d'ambiance
const tags = [];
if (Array.isArray(genres) && genres.length)
tags.push(`Genres: ${genres.join(", ")}`);
if (tempo) tags.push(`Tempo: ${tempo}`);
if (Array.isArray(instruments) && instruments.length)
tags.push(`Instruments: ${instruments.join(", ")}`);
if (voice) tags.push(`Vocal: ${voice}`);
if (mood) tags.push(`Mood: ${mood}`);
// Guidance palette selon tempo (simple heuristique)
const paletteHint =
tempo && /\b(rapid|fast|vite|agité|upbeat|energ)/i.test(String(tempo))
? "Palette vive et contrastée (magenta, cyan, jaune, bleu électrique)"
: "Palette harmonieuse et douce (bleu nuit, violet, corail, or pâle)";
// Prompt simplifié et optimisé pour Gemini
const prompt = `
Crée une pochette d'album musicale abstraite et colorée au format carré 1024x1024.
Contexte:
- Titre: ${title || "Sans titre"}
- Style musical: ${tags.join(" ; ")}
Directives essentielles:
- Style: Abstrait, graphique, avec formes géométriques et dégradés de couleurs
- Couleurs: ${paletteHint}
- IMPORTANT: Image plein cadre sans AUCUN bord blanc ou espace vide
- Composition: Éléments visuels répartis sur TOUTE la surface de l'image
Contraintes:
- PAS de texte ou typographie
- PAS de personnes ou visages
- PAS de fond blanc ou bordures
- PAS de filigrane
`.trim();
return prompt;
};