continue generating flow, add backend connection on main tabs
This commit is contained in:
+100
-3
@@ -1,8 +1,9 @@
|
||||
const admin = require("firebase-admin");
|
||||
const serviceAccount = require("./serviceAccountKey.json");
|
||||
const { googleAI, gemini25FlashLite } = require("@genkit-ai/googleai");
|
||||
const { googleAI } = require("@genkit-ai/googleai");
|
||||
const { GEMINI_API_KEY } = require("./config/keys");
|
||||
const { genkit } = require("genkit");
|
||||
const { genkit, z } = require("genkit");
|
||||
const axios = require("axios");
|
||||
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccount),
|
||||
@@ -16,7 +17,7 @@ exports.generateAI = async ({ system = "", prompt = "", schema }) => {
|
||||
apiKey: GEMINI_API_KEY,
|
||||
}),
|
||||
],
|
||||
model: gemini25FlashLite,
|
||||
model: googleAI.model("gemini-2.5-flash"),
|
||||
});
|
||||
|
||||
if (prompt?.length < 1) {
|
||||
@@ -34,6 +35,102 @@ exports.generateAI = async ({ system = "", prompt = "", schema }) => {
|
||||
return output;
|
||||
};
|
||||
|
||||
// Générateur d'images via appel direct REST Imagen 3.0 (retour base64 pur)
|
||||
exports.generateImage = async ({ system = "", prompt = "" }) => {
|
||||
if (typeof prompt !== "string" || prompt.trim().length < 1) {
|
||||
throw new Error(
|
||||
"Vous devez spécifier un prompt pour effectuer cette action.",
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/imagen-3.0-generate-002:predict";
|
||||
const url = `${baseUrl}?key=${encodeURIComponent(GEMINI_API_KEY)}`;
|
||||
|
||||
const mkBody = (p) => ({
|
||||
instances: [
|
||||
{
|
||||
prompt: p,
|
||||
},
|
||||
],
|
||||
parameters: {
|
||||
sampleCount: 1,
|
||||
aspectRatio: "1:1",
|
||||
personGeneration: "dont_allow",
|
||||
},
|
||||
});
|
||||
|
||||
const combinedPrompt = [
|
||||
system,
|
||||
"Contraintes supplémentaires: pas de personnes, pas de visages, style abstrait, formes géométriques, textures et motifs.",
|
||||
prompt,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
const tryOnce = async (p) => {
|
||||
const resp = await axios.post(url, mkBody(p), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: 120000,
|
||||
});
|
||||
const predictions = resp?.data?.predictions || [];
|
||||
const first = predictions.find((x) => x?.bytesBase64Encoded);
|
||||
return first
|
||||
? {
|
||||
b64: first.bytesBase64Encoded,
|
||||
mimeType: first.mimeType || "image/png",
|
||||
}
|
||||
: { b64: "", mimeType: null };
|
||||
};
|
||||
|
||||
// Primary try
|
||||
let result = { b64: "", mimeType: null };
|
||||
try {
|
||||
result = await tryOnce(combinedPrompt);
|
||||
} catch (e) {
|
||||
const em = e?.response?.data || e?.message || e;
|
||||
console.error("Imagen predict error", em);
|
||||
}
|
||||
|
||||
// Fallbacks on safety filters/no predictions
|
||||
if (!result?.b64) {
|
||||
const fallback = [
|
||||
"Crée une image de pochette d'album 1024x1024, abstraite, moderne et colorée.",
|
||||
"Utilise des formes géométriques, textures et dégradés.",
|
||||
"Aucune personne, aucun visage, aucun texte, aucun logo.",
|
||||
].join("\n");
|
||||
try {
|
||||
result = await tryOnce(fallback);
|
||||
} catch (e2) {
|
||||
const em2 = e2?.response?.data || e2?.message || e2;
|
||||
console.error("Imagen predict fallback FR error", em2);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result?.b64) {
|
||||
const fallbackEn = [
|
||||
"Create a 1024x1024 abstract, modern, colorful album cover.",
|
||||
"Use geometric shapes, textures, and gradients.",
|
||||
"No people, no faces, no text, no logos.",
|
||||
].join("\n");
|
||||
try {
|
||||
result = await tryOnce(fallbackEn);
|
||||
} catch (e3) {
|
||||
const em3 = e3?.response?.data || e3?.message || e3;
|
||||
console.error("Imagen predict fallback EN error", em3);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result?.b64) {
|
||||
throw new Error("NO_IMAGE_OUTPUT");
|
||||
}
|
||||
return {
|
||||
b64: String(result.b64).trim(),
|
||||
mimeType: result.mimeType || "image/png",
|
||||
};
|
||||
};
|
||||
|
||||
// Exporter toutes les fonctions
|
||||
exports.music = require("./src/music");
|
||||
exports.lyrics = require("./src/lyrics");
|
||||
exports.cover = require("./src/cover");
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
const { onDocumentCreated } = require("firebase-functions/v2/firestore");
|
||||
const admin = require("firebase-admin");
|
||||
const logger = require("firebase-functions/logger");
|
||||
const { generateImage } = require("../index");
|
||||
|
||||
const db = admin.firestore();
|
||||
|
||||
// Shared core for generating and saving the cover, and updating the project
|
||||
async function performCoverGeneration(projectId) {
|
||||
const t0 = Date.now();
|
||||
const snap = await db.collection("projects").doc(projectId).get();
|
||||
if (!snap.exists) {
|
||||
throw new Error("Projet introuvable");
|
||||
}
|
||||
const project = snap.data() || {};
|
||||
|
||||
const title = project?.title || "";
|
||||
const lyricsSections = Array.isArray(project?.lyrics)
|
||||
? project.lyrics
|
||||
: [
|
||||
project?.lyrics?.couplet
|
||||
? { type: "couplet", lyrics: project.lyrics.couplet }
|
||||
: null,
|
||||
project?.lyrics?.refrain
|
||||
? { type: "refrain", lyrics: project.lyrics.refrain }
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
const cfg = project?.musicConfig || {};
|
||||
const genres = Array.isArray(cfg?.genres) ? cfg.genres : [];
|
||||
const tempo = cfg?.tempo || "";
|
||||
const voice = cfg?.voice || "";
|
||||
const instruments = Array.isArray(cfg?.instruments) ? cfg.instruments : [];
|
||||
|
||||
// Construire un bref contexte visuel à partir des paroles
|
||||
const sampleLyrics = (lyricsSections || [])
|
||||
.map((s) => (s?.lyrics || "").split("\n").slice(0, 2).join(" "))
|
||||
.filter(Boolean)
|
||||
.slice(0, 6)
|
||||
.join(" | ");
|
||||
|
||||
const ambianceParts = [];
|
||||
if (genres.length) ambianceParts.push(`Genres: ${genres.join(", ")}`);
|
||||
if (tempo) ambianceParts.push(`Tempo: ${tempo}`);
|
||||
if (instruments.length)
|
||||
ambianceParts.push(`Instruments: ${instruments.join(", ")}`);
|
||||
if (voice) ambianceParts.push(`Vocal: ${voice}`);
|
||||
const ambiance = ambianceParts.join(" ; ");
|
||||
|
||||
const system = `
|
||||
Tu es un générateur qui ne renvoie que des données texte. Quand on te demande une image, tu renvoies UNIQUEMENT les octets de l'image encodés en base64, sans aucun autre texte, sans JSON, sans balises, sans préfixe data:.
|
||||
`.trim();
|
||||
|
||||
const prompt = `
|
||||
Crée une image de pochette d'album 1024x1024, moderne, abstraite et impactante, cohérente avec:
|
||||
- Titre: ${title || "Sans titre"}
|
||||
- Ambiance / Style: ${ambiance}
|
||||
|
||||
Contraintes:
|
||||
- Style abstrait: formes géométriques, textures, dégradés, couleurs harmonieuses
|
||||
- Aucun texte, aucun logo, aucune personne ni visage
|
||||
- Image PNG, sans typographie apparente
|
||||
`.trim();
|
||||
|
||||
// Utiliser generateImage qui renvoie directement le base64 (sans préfixe data:)
|
||||
logger.info("🎨 [Cover] Calling model", { projectId });
|
||||
let b64 = "";
|
||||
let mimeType = "image/png";
|
||||
try {
|
||||
const ai = await generateImage({ system, prompt });
|
||||
if (typeof ai === "string") {
|
||||
b64 = ai.trim();
|
||||
} else if (ai && typeof ai === "object") {
|
||||
if (typeof ai.imageUrl === "string") {
|
||||
b64 = ai.imageUrl.replace(/^data:image\/[a-zA-Z0-9.+-]+;base64,/, "").trim();
|
||||
} else if (typeof ai.b64 === "string") {
|
||||
b64 = ai.b64.trim();
|
||||
if (typeof ai.mimeType === "string") mimeType = ai.mimeType;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("❌ [Cover] generateImage failed", e);
|
||||
throw e;
|
||||
}
|
||||
if (!b64) {
|
||||
throw new Error("Génération d'image échouée (base64 vide)");
|
||||
}
|
||||
|
||||
// Sauvegarde dans Cloud Storage
|
||||
const buffer = Buffer.from(b64, "base64");
|
||||
const bucket = admin.storage().bucket();
|
||||
const ext = mimeType === "image/jpeg" ? "jpg" : mimeType === "image/webp" ? "webp" : "png";
|
||||
const path = `musics/${projectId}/cover.${ext}`;
|
||||
const token = require("crypto").randomUUID();
|
||||
|
||||
await bucket.file(path).save(buffer, {
|
||||
resumable: false,
|
||||
metadata: {
|
||||
contentType: mimeType || "image/png",
|
||||
cacheControl: "public, max-age=31536000",
|
||||
metadata: { firebaseStorageDownloadTokens: token },
|
||||
},
|
||||
});
|
||||
|
||||
const coverUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
||||
path,
|
||||
)}?alt=media&token=${token}`;
|
||||
|
||||
// Mettre à jour le projet avec l'URL et le statut
|
||||
await db.collection("projects").doc(projectId).set(
|
||||
{
|
||||
coverUrl,
|
||||
coverStatus: "GENERATED",
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
|
||||
logger.info("✅ [Cover] Saved", { projectId, path, ms: Date.now() - t0 });
|
||||
return coverUrl;
|
||||
}
|
||||
|
||||
// Firestore trigger: création d'une tâche de génération de cover
|
||||
exports.onTaskCreateGenerateCover = onDocumentCreated(
|
||||
{
|
||||
timeoutSeconds: 540,
|
||||
memory: "1GiB",
|
||||
document: "tasks/{taskId}",
|
||||
},
|
||||
async (event) => {
|
||||
try {
|
||||
const data = event?.data?.data() || {};
|
||||
const type = data?.type || "";
|
||||
const projectId = data?.projectId || null;
|
||||
|
||||
if (type !== "cover") {
|
||||
logger.info("ℹ️ [Task] Ignored non-cover task", { type });
|
||||
return;
|
||||
}
|
||||
if (!projectId) throw new Error("projectId manquant dans la task");
|
||||
|
||||
logger.info("🎨 [Task] Start cover generation", { projectId });
|
||||
|
||||
// Marquer le projet en génération
|
||||
await db.collection("projects").doc(projectId).set(
|
||||
{
|
||||
coverStatus: "GENERATING",
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
|
||||
const coverUrl = await performCoverGeneration(projectId);
|
||||
|
||||
// Marquer la task comme terminée
|
||||
await event.data.ref.set(
|
||||
{
|
||||
status: "DONE",
|
||||
coverUrl,
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
|
||||
logger.info("✅ [Task] Cover generated", { projectId });
|
||||
} catch (error) {
|
||||
logger.error("❌ [Task] Cover generation error", error);
|
||||
try {
|
||||
await event.data.ref.set(
|
||||
{ status: "ERROR", error: error?.message || "Erreur" },
|
||||
{ merge: true },
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user