end of cover flow. Todo: fix combine pictures and save path in storage
This commit is contained in:
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user