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;
};
+3 -123
View File
@@ -1,134 +1,14 @@
const admin = require("firebase-admin");
const serviceAccount = require("./serviceAccountKey.json");
const { googleAI } = require("@genkit-ai/googleai");
const { GEMINI_API_KEY } = require("./config/keys");
const { genkit } = require("genkit");
const axios = require("axios");
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
storageBucket: "musicland-d33f9.firebasestorage.app",
databaseURL: "https://musicland-d33f9-default-rtdb.firebaseio.com",
projectId: "musicland-d33f9",
});
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;
};
// 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",
};
};
// Les fonctions IA ont été déplacées vers helpers/gemini
// Exporter toutes les fonctions
exports.music = require("./src/music");
+205 -231
View File
@@ -7,9 +7,9 @@
"name": "functions",
"dependencies": {
"@genkit-ai/googleai": "^1.18.0",
"axios": "^1.6.0",
"firebase-admin": "^12.1.0",
"firebase-functions": "^5.0.0",
"axios": "^1.11.0",
"firebase-admin": "^12.7.0",
"firebase-functions": "^6.4.0",
"genkit": "^1.18.0"
},
"devDependencies": {
@@ -17,7 +17,7 @@
"eslint-config-google": "^0.14.0"
},
"engines": {
"node": "18"
"node": "20"
}
},
"node_modules/@colors/colors": {
@@ -43,9 +43,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
"integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.8.0.tgz",
"integrity": "sha512-MJQFqrZgcW0UNYLGOuQpey/oTN59vyWwplvCGZztn1cKz9agZPPYpJB7h2OMmuu7VLqkvEjN8feFZJmxNF9D+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -106,9 +106,9 @@
}
},
"node_modules/@fastify/busboy": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.1.1.tgz",
"integrity": "sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz",
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
"license": "MIT"
},
"node_modules/@firebase/app-check-interop-types": {
@@ -215,32 +215,14 @@
}
},
"node_modules/@genkit-ai/ai/node_modules/@types/node": {
"version": "20.19.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz",
"integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==",
"version": "20.19.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.12.tgz",
"integrity": "sha512-lSOjyS6vdO2G2g2CWrETTV3Jz2zlCXHpu1rcubLKpz9oj+z/1CceHlj+yq53W+9zgb98nSov/wjEKYDNauD+Hw==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@genkit-ai/ai/node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/@genkit-ai/core": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@genkit-ai/core/-/core-1.18.0.tgz",
@@ -343,25 +325,6 @@
"genkit": "^1.18.0"
}
},
"node_modules/@genkit-ai/google-cloud/node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"optional": true,
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/@genkit-ai/googleai": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@genkit-ai/googleai/-/googleai-1.18.0.tgz",
@@ -376,24 +339,6 @@
"genkit": "^1.18.0"
}
},
"node_modules/@genkit-ai/googleai/node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/@google-cloud/common": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.2.tgz",
@@ -433,16 +378,16 @@
}
},
"node_modules/@google-cloud/logging": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@google-cloud/logging/-/logging-11.2.0.tgz",
"integrity": "sha512-Ma94jvuoMpbgNniwtelOt8w82hxK62FuOXZonEv0Hyk3B+/YVuLG/SWNyY9yMso/RXnPEc1fP2qo9kDrjf/b2w==",
"version": "11.2.1",
"resolved": "https://registry.npmjs.org/@google-cloud/logging/-/logging-11.2.1.tgz",
"integrity": "sha512-2h9HBJG3OAsvzXmb81qXmaTPfXYU7KJTQUxunoOKFGnY293YQ/eCkW1Y5mHLocwpEqeqQYT/Qvl6Tk+Q7PfStw==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@google-cloud/common": "^5.0.0",
"@google-cloud/paginator": "^5.0.0",
"@google-cloud/projectify": "^4.0.0",
"@google-cloud/promisify": "^4.0.0",
"@google-cloud/promisify": "4.0.0",
"@opentelemetry/api": "^1.7.0",
"arrify": "^2.0.1",
"dot-prop": "^6.0.0",
@@ -599,9 +544,9 @@
}
},
"node_modules/@google-cloud/storage": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.16.0.tgz",
"integrity": "sha512-7/5LRgykyOfQENcm6hDKP8SX/u9XxE5YOiWOkgkwcoO+cG8xT/cyOvp9wwN3IxfdYgpHs8CE7Nq2PKX2lNaEXw==",
"version": "7.17.0",
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.17.0.tgz",
"integrity": "sha512-5m9GoZqKh52a1UqkxDBu/+WVFDALNtHg5up5gNmNbXQWBcV813tzJKsyDtKjOPrlR1em1TxtD7NSPCrObH7koQ==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
@@ -2184,9 +2129,9 @@
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.36.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.36.0.tgz",
"integrity": "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ==",
"version": "1.37.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz",
"integrity": "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==",
"license": "Apache-2.0",
"optional": true,
"engines": {
@@ -2431,9 +2376,9 @@
}
},
"node_modules/@types/node": {
"version": "22.17.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz",
"integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==",
"version": "22.18.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz",
"integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -2486,6 +2431,24 @@
"form-data": "^2.5.5"
}
},
"node_modules/@types/request/node_modules/form-data": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz",
"integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==",
"license": "MIT",
"optional": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.35",
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">= 0.12"
}
},
"node_modules/@types/send": {
"version": "0.17.5",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
@@ -2764,22 +2727,6 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axios/node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2971,43 +2918,6 @@
"node": ">=12"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/color": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
@@ -3308,6 +3218,12 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/enabled": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
@@ -3893,15 +3809,15 @@
}
},
"node_modules/firebase-functions": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-5.1.1.tgz",
"integrity": "sha512-KkyKZE98Leg/C73oRyuUYox04PQeeBThdygMfeX+7t1cmKWYKa/ZieYa89U8GHgED+0mF7m7wfNZOfbURYxIKg==",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-6.4.0.tgz",
"integrity": "sha512-Q/LGhJrmJEhT0dbV60J4hCkVSeOM6/r7xJS/ccmkXzTWMjo+UPAYX9zlQmGlEjotstZ0U9GtQSJSgbB2Z+TJDg==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.5",
"@types/express": "4.17.3",
"@types/express": "^4.17.21",
"cors": "^2.8.5",
"express": "^4.17.1",
"express": "^4.21.0",
"protobufjs": "^7.2.2"
},
"bin": {
@@ -3911,18 +3827,7 @@
"node": ">=14.10.0"
},
"peerDependencies": {
"firebase-admin": "^11.10.0 || ^12.0.0"
}
},
"node_modules/firebase-functions/node_modules/@types/express": {
"version": "4.17.3",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.3.tgz",
"integrity": "sha512-I8cGRJj3pyOLs/HndoP+25vOqhqWkAZsWMEmq1qXy/b/M3ppufecUwaK2/TVDVxcV61/iSdhykUjQQ2DLSrTdg==",
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "*",
"@types/serve-static": "*"
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0"
}
},
"node_modules/flat-cache": {
@@ -3975,21 +3880,19 @@
}
},
"node_modules/form-data": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz",
"integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"optional": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.35",
"safe-buffer": "^5.2.1"
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 0.12"
"node": ">= 6"
}
},
"node_modules/formdata-polyfill": {
@@ -4061,6 +3964,26 @@
"node": ">=14"
}
},
"node_modules/gaxios/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/gaxios/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
@@ -4157,6 +4080,28 @@
"node": ">= 0.4"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -4227,6 +4172,27 @@
"node": ">=14"
}
},
"node_modules/google-gax/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"optional": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/google-gax/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
@@ -4995,17 +4961,7 @@
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru-memoizer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
"license": "MIT",
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "6.0.0"
}
},
"node_modules/lru-memoizer/node_modules/lru-cache": {
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
@@ -5017,11 +4973,15 @@
"node": ">=10"
}
},
"node_modules/lru-memoizer/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
"node_modules/lru-memoizer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
"license": "MIT",
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "6.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
@@ -5170,23 +5130,21 @@
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "4.x || >=6.0.0"
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-forge": {
@@ -5505,9 +5463,9 @@
}
},
"node_modules/protobufjs": {
"version": "7.5.3",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz",
"integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==",
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -5770,28 +5728,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -6116,6 +6052,20 @@
"resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz",
"integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -6230,6 +6180,27 @@
"node": ">= 6"
}
},
"node_modules/teeny-request/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"optional": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/teeny-request/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
@@ -6560,6 +6531,23 @@
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"license": "MIT"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -6591,6 +6579,12 @@
"node": ">=10"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
@@ -6630,26 +6624,6 @@
"node": ">=12"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+4 -4
View File
@@ -10,14 +10,14 @@
"logs": "firebase functions:log"
},
"engines": {
"node": "18"
"node": "20"
},
"main": "index.js",
"dependencies": {
"@genkit-ai/googleai": "^1.18.0",
"axios": "^1.6.0",
"firebase-admin": "^12.1.0",
"firebase-functions": "^5.0.0",
"axios": "^1.11.0",
"firebase-admin": "^12.7.0",
"firebase-functions": "^6.4.0",
"genkit": "^1.18.0"
},
"devDependencies": {
+102 -134
View File
@@ -1,123 +1,86 @@
const { onDocumentCreated } = require("firebase-functions/v2/firestore");
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const { generateImage } = require("../index");
const {
generateImageV2,
CombineCoverAndPicture,
} = require("../helpers/gemini");
const { generatePicturePrompt } = require("../helpers/prompts");
const db = admin.firestore();
// Shared core for generating and saving the cover, and updating the project
async function performCoverGeneration(projectId) {
async function performCoverGeneration(project) {
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";
const prompt = generatePicturePrompt(project);
// Utiliser generateImageV2 qui renvoie directement l'URL publique
logger.info("🎨 [Cover] Calling model V2", { projectId: project.id });
let coverUrl = "";
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;
}
}
// Utiliser une taille fixe de 1024 pixels
coverUrl = await generateImageV2(prompt, 1024);
console.log("coverUrl", coverUrl);
} catch (e) {
logger.error("❌ [Cover] generateImage failed", e);
logger.error("❌ [Cover] generateImageV2 failed", e);
throw e;
}
if (!b64) {
throw new Error("Génération d'image échouée (base64 vide)");
if (!coverUrl) {
throw new Error("Génération d'image échouée (URL 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(
await db
.collection("projects")
.doc(project.id)
.set(
{
cover: {
generatedBackground: coverUrl,
},
coverStatus: "GENERATED",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
);
logger.info("✅ [Cover] Saved", {
projectId: project.id,
coverUrl,
ms: Date.now() - t0,
});
return coverUrl;
}
async function performCombineGeneration(project) {
const t0 = Date.now();
const background = project?.cover?.generatedBackground || null;
const foreground = project?.cover?.userForeground || null;
if (!background || !foreground) {
throw new Error(
"Images manquantes: cover.generatedBackground et cover.userForground sont requis",
);
}
const combinedUrl = await CombineCoverAndPicture(background, foreground);
// Sauvegarder le résultat et marquer comme généré
await db.collection("projects").doc(project.id).set(
{
coverUrl,
"cover.result": combinedUrl,
coverStatus: "GENERATED",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
);
logger.info("✅ [Cover] Saved", { projectId, path, ms: Date.now() - t0 });
return coverUrl;
logger.info("✅ [Cover] Combined image saved", {
projectId: project.id,
combinedUrl,
ms: Date.now() - t0,
});
return combinedUrl;
}
// Firestore trigger: création d'une tâche de génération de cover
@@ -128,49 +91,54 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
document: "tasks/{taskId}",
},
async (event) => {
const data = event?.data?.data() || {};
const type = data?.type || "";
const projectId = data?.projectId || null;
let coverUrl = null;
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");
}
if (!projectId) throw new Error("projectId manquant dans la task");
if (!["cover", "combine"]?.includes(type)) {
throw new Error(`Type de tâche non supporté: ${type}`);
}
await db.collection("projects").doc(projectId).update({
coverStatus: "GENERATING",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
logger.info("🎨 [Task] Start cover generation", { projectId });
const project =
(await db.collection("projects").doc(projectId).get())?.data() || null;
if (!project) {
throw new Error("Projet non trouvé");
}
project.id = projectId;
// Marquer le projet en génération
await db.collection("projects").doc(projectId).set(
{
coverStatus: "GENERATING",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
);
if (type === "cover") {
logger.info("🎨 [Task] Start cover generation", { projectId });
coverUrl = await performCoverGeneration(project);
logger.info("✅ [Task] Cover generated", { projectId });
} else if (type === "combine") {
logger.info("🧩 [Task] Start combine (cover + photo)", { projectId });
coverUrl = await performCombineGeneration(project);
logger.info("✅ [Task] Combine generated", { projectId, coverUrl });
}
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 });
await event.data.ref.update({
status: "DONE",
coverUrl,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
return;
} catch (error) {
logger.error("❌ [Task] Cover generation error", error);
try {
await event.data.ref.set(
{ status: "ERROR", error: error?.message || "Erreur" },
{ merge: true },
);
} catch (_) {}
await event.data.ref.set(
{ status: "ERROR", error: error?.message || "Erreur" },
{ merge: true },
);
await db.collection("projects").doc(projectId).update({
coverStatus: "ERROR",
});
}
},
);
+5 -2
View File
@@ -1,6 +1,6 @@
const { onCall } = require("firebase-functions/v2/https");
const { z } = require("genkit");
const { generateAI } = require("../index");
const { generateAI } = require("../helpers/gemini");
exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
try {
@@ -45,7 +45,7 @@ Schéma de rimes : ${rhymes}
Génère un titre accrocheur et les paroles de la chanson en suivant ` +
`strictement cette structure.
Le titre doit être court, ` +
`mémorable et refléter l'esprit de l'agence Minuit.
`mémorable et refléter l'esprit de la musique.
Pour chaque ` +
`partie ("couplet" ou "refrain"), écris 4 à 16 lignes adaptées, ` +
`expressives et cohérentes avec le style et l'émotion souhaitée.
@@ -75,6 +75,9 @@ Pour chaque ` +
"Paroles de la chanson sous forme de tableau de sections " +
"structurées.",
),
lyricsDescription: z
.string()
.describe("Courte déscription qui résume les paroles de la musique"),
success: z.boolean().describe("Indique si la génération a réussi"),
});
+12 -11
View File
@@ -18,6 +18,7 @@ const SUNO_MODEL = "V4_5";
// const SUNO_MODEL = "V3_5";
const SUNO_CALLBACK_URL =
"https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback";
// "https://music-sunocallback-ifdrkc2c3a-uc.a.run.app";
/**
* Limite la longueur d'une chaîne
@@ -200,7 +201,7 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
"Content-Type": "application/json",
Authorization: `Bearer ${SUNO_API_KEY}`,
},
}
},
);
const parsed = response.data;
@@ -219,7 +220,7 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
} catch (error) {
console.error("❌ Erreur lors de la génération de musique:", error);
throw new Error(
`Erreur lors de la génération de musique: ${error.message}`
`Erreur lors de la génération de musique: ${error.message}`,
);
}
});
@@ -248,7 +249,7 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => {
Authorization: `Bearer ${SUNO_API_KEY}`,
},
timeout: 30000, // 30 secondes de timeout
}
},
);
parsed = response.data;
@@ -364,7 +365,7 @@ exports.getSunoTimestamps = onCall(async ({ data = {} }) => {
Authorization: `Bearer ${SUNO_API_KEY}`,
},
timeout: 30000,
}
},
);
parsed = response.data;
console.log("📊 Réponse timestamps Suno API:", parsed?.code, parsed?.msg);
@@ -416,7 +417,7 @@ exports.getSunoTimestamps = onCall(async ({ data = {} }) => {
},
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
{ merge: true },
);
saved = true;
console.log("💾 Timestamps sauvegardés dans Firestore", {
@@ -426,7 +427,7 @@ exports.getSunoTimestamps = onCall(async ({ data = {} }) => {
} catch (e) {
console.error(
"❌ Échec sauvegarde timestamps Firestore:",
e?.message || e
e?.message || e,
);
}
@@ -532,7 +533,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
const audioUrls = tracks
.map(
(t) =>
t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl
t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl,
)
.filter(Boolean)
.slice(0, 2);
@@ -547,7 +548,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
has_audio_url: !!t.audio_url,
has_stream_audio_url: !!t.stream_audio_url,
})),
}
},
);
}
@@ -572,14 +573,14 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
},
});
const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
path
path,
)}?alt=media&token=${token}`;
console.log("✅ [SunoCallback] Sauvegardé:", path, "URL:", downloadUrl);
return { path, url: downloadUrl };
} catch (e) {
console.error(
`❌ [SunoCallback] Échec save piste ${index + 1}:`,
e.message
e.message,
);
return null;
}
@@ -599,7 +600,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
musicUrls,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
{ merge: true },
);
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
projectId,
+4 -14
View File
@@ -1,18 +1,13 @@
import React from "react";
import { createStackNavigator } from "@react-navigation/stack";
import { Palette } from "../styles";
import { Routes } from "./Routes";
import { BottomTabScreen } from "./BottomTab";
import Splash from "../screens/Splash";
import Onboarding from "../screens/Onboarding";
import Login from "../screens/Login";
import ForgotPassword from "../screens/ForgotPassword";
import Notifications from "../screens/Profile/Notifications";
import TermsOfUse from "../screens/TermsOfUse";
import PrivacyPolicy from "../screens/PrivacyPolicy";
import Writing from "../screens/Writing/Writing";
@@ -28,8 +23,6 @@ import SongReady from "../screens/Studio/SongReady";
import Regenerate from "../screens/Studio/Regenerate";
import PouchReady from "../screens/cover/PouchReady";
import PhotoCover from "../screens/cover/PhotoCover";
import AddPhotoCover from "../screens/cover/AddPhotoCover";
import FinishCompose from "../screens/cover/FinishCompose";
import Production from "../screens/Production/Production";
import ProductionOnboarding from "../screens/Production/ProductionOnboarding";
import DownloadSongs from "../screens/Production/DownloadSongs";
@@ -63,7 +56,8 @@ import NewMusicOptions from "../screens/NewMusicOptions";
import Register from "../screens/Register";
import CreatePassword from "../screens/CreatePassword";
import CreatePseudo from "../screens/CreatePseudo";
import ChooseCoverType from "../screens/cover/ChooseCoverType";
import { ChooseCoverType } from "../screens/cover/ChooseCoverType";
import ValidateCover from "../screens/cover/ValidateCover";
const screenOptions = {
headerShown: false,
@@ -155,12 +149,8 @@ const screens = [
component: PhotoCover,
},
{
name: Routes.AddPhotoCover,
component: AddPhotoCover,
},
{
name: Routes.FinishCompose,
component: FinishCompose,
name: Routes.ValidateCover,
component: ValidateCover,
},
{
name: Routes.ProductionOnboarding,
+1 -1
View File
@@ -38,7 +38,7 @@ export const Routes = {
Regenerate: "Regenerate",
PouchReady: "PouchReady",
PhotoCover: "PhotoCover",
AddPhotoCover: "AddPhotoCover",
ValidateCover: "ValidateCover",
FinishCompose: "FinishCompose",
ProductionOnboarding: "ProductionOnboarding",
+5 -1
View File
@@ -92,7 +92,11 @@ const NewMusicOptions = () => {
}
break;
case 2:
navigate(Routes.ChooseCoverType);
if (selectedProject?.coverUrl) {
navigate(Routes.ValidateCover);
} else {
navigate(Routes.ChooseCoverType);
}
break;
case 3:
console.log("test");
-123
View File
@@ -1,123 +0,0 @@
import { View, Text, Image } from "react-native";
import React, { useState } from "react";
import MusicLandHeader from "../../components/MusicLandHeader";
import { background, icons, img } from "../../assets";
import Page from "../../layouts/Page";
import { goBack, navigate } from "../../navigation/NavigationService";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import { gutters, Palette, Style } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton";
import { launchImageLibraryAsync } from "expo-image-picker";
import { Routes } from "../../navigation";
const AddPhotoCover = () => {
const [image, setImage] = useState(null);
const onPressAddImage = async () => {
const result = await launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
aspect: [4, 3],
quality: 1,
});
if (!result.canceled) {
setImage(result.assets[0]?.uri);
}
};
return (
<Page backgroundImg={background.studioBG2} headerType="NONE">
<MusicLandHeader onPressBack={goBack} progress={90} />
<View style={{ flex: 1, marginTop: 16 }}>
<CreateLyricsHeader
title="Ta pochette est prête!"
subTitle="Quen penses-tu ?"
/>
<View style={{ flex: 1, ...Style.containerCenter }}>
<View style={{ width: "80%", position: "relative" }}>
<Image
source={img.placeholder}
style={{
width: "100%",
height: 300,
borderRadius: 20,
transform: [{ rotateY: "180deg" }],
}}
/>
<View
style={{ position: "absolute", width: "100%", height: "100%" }}
>
<Image
source={{ uri: image }}
style={{ width: "100%", height: "100%", borderRadius: 20 }}
/>
</View>
<View
style={{
position: "absolute",
alignSelf: "center",
alignItems: "center",
top: 10,
}}
>
<Text
style={{
fontSize: 22,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
}}
>
Lust for Life
</Text>
<Text
style={{
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
Lana del Rey
</Text>
</View>
<View
style={{
position: "absolute",
alignItems: "center",
bottom: 10,
width: "100%",
}}
>
<Image
source={icons.musicLandLogo}
style={{ width: "100%", height: 30 }}
resizeMode="contain"
/>
</View>
</View>
</View>
</View>
<View
style={{
paddingBottom: gutters * 2,
width: "80%",
alignSelf: "center",
gap: 12,
}}
>
<BorderGradientButton
title="Ajouter une autre photo"
onPress={onPressAddImage}
/>
<GradientButton
title="Valider"
onPress={() => navigate(Routes.FlowSelection)}
/>
</View>
</Page>
);
};
export default AddPhotoCover;
+64 -16
View File
@@ -1,5 +1,6 @@
import { View, StyleSheet, Image } from "react-native";
import { View, Image } from "react-native";
import React, { useState } from "react";
import * as ImagePicker from "expo-image-picker";
import { ai, background } from "../../assets";
import Page from "../../layouts/Page";
import { goBack, navigate } from "../../navigation/NavigationService";
@@ -9,12 +10,68 @@ import GradientButton from "../../components/GradientButton";
import { gutters } from "../../styles";
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
import BorderGradientButton from "../../components/BorderGradientButton";
import { useUserData } from "../../providers/UserDataProvider";
import { uploadFileToFirebase } from "../../helpers/uploadToFirebase";
import useMinuit from "react-native-minuit/src/hooks/useMinuit";
const ChooseCoverType = () => {
export const ChooseCoverType = () => {
const [showIntro, setShowIntro] = useState(true);
const { selectedProject, selectedProjectId, updateProjectData } =
useUserData();
const { setIsLoading } = useMinuit();
const onPickUserImage = async () => {
try {
await setIsLoading(true);
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
aspect: [1, 1],
quality: 1,
});
const uri = result?.assets?.[0]?.uri || null;
if (!uri) return;
const projectId = selectedProject?.id || selectedProjectId;
const path = `musics/${projectId}/userSelectedCover.png`;
const { resultURI } = await uploadFileToFirebase({
uri,
path,
shouldCompress: true,
fileType: "IMAGE",
});
if (!resultURI) {
throw new Error("Erreur lors de l'upload");
}
await updateProjectData({
cover: {
result: resultURI,
},
});
navigate(Routes.ValidateCover);
} catch (e) {
console.log("onPickUserImage error", e?.message);
} finally {
await setIsLoading(false);
}
};
return (
<Page backgroundImg={background.productionBG2} headerType="NONE">
<Image source={ai.bena} style={styles.img} resizeMode="contain" />
<Image
source={ai.bena}
style={{
width: "100%",
height: "70%",
position: "absolute",
bottom: -40,
right: -30,
}}
resizeMode="contain"
/>
<MusicLandHeader onPressBack={goBack} progress={9} />
<View
style={{ flex: 1, position: "relative", justifyContent: "flex-end" }}
@@ -26,7 +83,10 @@ const ChooseCoverType = () => {
gap: 12,
}}
>
<BorderGradientButton title="Choisir une image" />
<BorderGradientButton
title="Choisir une image"
onPress={onPickUserImage}
/>
<GradientButton
title="Générer une pochette"
onPress={() => navigate(Routes.PouchReady)}
@@ -40,15 +100,3 @@ const ChooseCoverType = () => {
</Page>
);
};
export default ChooseCoverType;
const styles = StyleSheet.create({
img: {
width: "100%",
height: "70%",
position: "absolute",
bottom: -40,
right: -30,
},
});
-53
View File
@@ -1,53 +0,0 @@
import { View, Image, StyleSheet } from "react-native";
import React from "react";
import { ai, background } from "../../assets";
import Page from "../../layouts/Page";
import { goBack, navigate } from "../../navigation/NavigationService";
import MusicLandHeader from "../../components/MusicLandHeader";
import { gutters } from "../../styles";
import { Routes } from "../../navigation";
import GradientButton from "../../components/GradientButton";
import BorderGradientButton from "../../components/BorderGradientButton";
const FinishCompose = () => {
return (
<Page backgroundImg={background.studioBG2} headerType="NONE">
<Image source={ai.theo} style={styles.img} resizeMode="contain" />
<MusicLandHeader onPressBack={goBack} progress={100} />
<View
style={{ flex: 1, position: "relative", justifyContent: "flex-end" }}
>
<View
style={{
paddingBottom: gutters * 2,
width: "70%",
alignSelf: "center",
gap: 12,
}}
>
<BorderGradientButton
title="Continuer plus tard"
onPress={() => navigate(Routes.Home)}
/>
<GradientButton
title="Continuer la création"
onPress={() => navigate(Routes.Home)}
/>
</View>
</View>
</Page>
);
};
export default FinishCompose;
const styles = StyleSheet.create({
img: {
width: "100%",
height: "70%",
position: "absolute",
bottom: -40,
right: -30,
},
});
+92 -55
View File
@@ -1,17 +1,72 @@
import { View, Text, Image } from "react-native";
import { ActivityIndicator, Text, View } from "react-native";
import React from "react";
import MusicLandHeader from "../../components/MusicLandHeader";
import { background, icons, img } from "../../assets";
import { background } from "../../assets";
import Page from "../../layouts/Page";
import { goBack, navigate } from "../../navigation/NavigationService";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import { gutters, Palette, Style } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import BorderGradientButton from "../../components/BorderGradientButton";
import { Routes } from "../../navigation";
import GradientButton from "../../components/GradientButton";
import * as ImagePicker from "expo-image-picker";
import { uploadFileToFirebase } from "../../helpers/uploadToFirebase";
import { useUserData } from "../../providers/UserDataProvider";
import useMinuit from "react-native-minuit/src/hooks/useMinuit";
import firebase, { tasksRef } from "../../config/firebase";
import { Image as ExpoImage } from "expo-image";
const PhotoCover = () => {
const { selectedProject, selectedProjectId, updateProjectData } =
useUserData();
const { setIsLoading } = useMinuit();
const isGenerating = selectedProject?.coverStatus === "GENERATING";
const onChooseForegroundImage = async () => {
try {
await setIsLoading(true);
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
aspect: [1, 1],
quality: 1,
});
const uri = result?.assets?.[0]?.uri || null;
if (!uri) return;
const projectId = selectedProject?.id || selectedProjectId;
const path = `musics/${projectId}/userForeground.png`;
const { resultURI } = await uploadFileToFirebase({
uri,
path,
shouldCompress: true,
fileType: "IMAGE",
});
if (!resultURI) {
throw new Error("Erreur lors de l'upload");
}
await updateProjectData({
cover: {
...selectedProject.cover,
userForeground: resultURI,
},
});
await tasksRef.add({
type: "combine",
projectId: selectedProjectId,
status: "PENDING",
createdAt: firebase.firestore.FieldValue.serverTimestamp(),
});
} catch (e) {
console.log("onPickUserImage error", e?.message);
} finally {
await setIsLoading(false);
}
};
return (
<Page backgroundImg={background.studioBG2} headerType="NONE">
<MusicLandHeader onPressBack={goBack} progress={81} />
@@ -19,56 +74,35 @@ const PhotoCover = () => {
<CreateLyricsHeader title="Souhaites-tu rajouter une photo de toi sur la pochette?" />
<View style={{ flex: 1, ...Style.containerCenter }}>
<View style={{ width: "80%", position: "relative" }}>
<Image
source={img.placeholder}
style={{
width: "100%",
height: 300,
borderRadius: 20,
transform: [{ rotateY: "180deg" }],
}}
/>
<View
style={{
position: "absolute",
alignSelf: "center",
alignItems: "center",
top: 10,
}}
>
<Text
style={{
fontSize: 22,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
}}
>
Lust for Life
</Text>
<Text
style={{
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
Lana del Rey
</Text>
</View>
<View
style={{
position: "absolute",
alignItems: "center",
bottom: 10,
width: "100%",
}}
>
<Image
source={icons.musicLandLogo}
style={{ width: "100%", height: 30 }}
resizeMode="contain"
{selectedProject?.cover?.result && !isGenerating ? (
<ExpoImage
source={{ uri: selectedProject?.cover?.result }}
cachePolicy="memory-disk"
priority="high"
contentFit="cover"
transition={120}
style={{ width: "100%", height: 300, borderRadius: 20 }}
/>
</View>
) : (
<View
style={{
width: "100%",
height: 300,
borderRadius: 20,
backgroundColor: "#00000040",
...Style.containerCenter,
}}
>
{isGenerating && (
<View style={{ alignItems: "center", gap: 8 }}>
<ActivityIndicator color={Palette.white} />
<Text style={{ color: Palette.white, marginTop: 6 }}>
Génération en cours.
</Text>
</View>
)}
</View>
)}
</View>
</View>
</View>
@@ -80,10 +114,13 @@ const PhotoCover = () => {
gap: 12,
}}
>
<BorderGradientButton title="Non, laisser la pochette telle quelle" />
<BorderGradientButton
onPress={() => navigate(Routes.ValidateCover)}
title="Non, valider la pochette"
/>
<GradientButton
title="Oui"
onPress={() => navigate(Routes.AddPhotoCover)}
title="Oui choisir une image"
onPress={() => onChooseForegroundImage()}
/>
</View>
</Page>
+105 -82
View File
@@ -1,6 +1,14 @@
import { View, Text, Image, ActivityIndicator } from "react-native";
import {
View,
Text,
Image,
ActivityIndicator,
Alert,
setTimeout,
} from "react-native";
import { Image as ExpoImage } from "expo-image";
import React, { useEffect, useState } from "react";
import React, { useEffect } from "react";
import { useIsFocused } from "@react-navigation/native";
import Page from "../../layouts/Page";
import { background, icons, img } from "../../assets";
import MusicLandHeader from "../../components/MusicLandHeader";
@@ -10,35 +18,20 @@ import { gutters, Palette, Style } from "../../styles";
import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton";
import { Routes } from "../../navigation";
import { FONT_FAMILY } from "../../styles/Fonts";
import firebase, { tasksRef } from "../../config/firebase";
import { useUser } from "../../providers/UserDataProvider";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
const PouchReady = () => {
const { selectedProjectId, selectedProject, updateProjectData } = useUser();
const [coverUrl, setCoverUrl] = useState(null);
const [loading, setLoading] = useState(false);
const [title, setTitle] = useState("");
const [coverStatus, setCoverStatus] = useState(null);
const isGenerating = coverStatus === "GENERATING";
useEffect(() => {
const d = selectedProject || {};
setTitle(d?.title || "");
setCoverUrl(d?.coverUrl || null);
setCoverStatus(d?.coverStatus || null);
}, [
selectedProject?.title,
selectedProject?.coverUrl,
selectedProject?.coverStatus,
]);
const { setIsLoading } = useMinuit();
const isGenerating = selectedProject?.coverStatus === "GENERATING";
const isFocused = useIsFocused();
const generateCover = async () => {
if (!selectedProjectId) return;
try {
setLoading(true);
await updateProjectData({ coverStatus: "GENERATING" });
await setIsLoading(true);
await tasksRef.add({
type: "cover",
projectId: selectedProjectId,
@@ -48,16 +41,44 @@ const PouchReady = () => {
} catch (e) {
console.log("Cover task error", e?.message);
} finally {
setLoading(false);
setIsLoading(false);
}
};
useEffect(() => {
if (!selectedProjectId) return;
if (!coverUrl && !isGenerating && !loading) {
generateCover();
async function validateCover() {
try {
await setIsLoading(true);
await updateProjectData({
cover: {
...selectedProject.cover,
result: selectedProject.cover.generatedBackground,
},
});
navigate(Routes.PhotoCover);
} catch (e) {
console.log(e);
} finally {
await setIsLoading(false);
}
}, [selectedProjectId, coverUrl, isGenerating]);
}
useEffect(() => {
if (
isFocused &&
!selectedProject?.cover?.generatedBackground &&
selectedProject?.coverStatus !== "GENERATING"
) {
Alert.alert("Attention", "Générer une pochette ?", [
{
text: "Oui",
onPress: generateCover,
},
{
text: "Non",
},
]);
}
}, [selectedProject, isFocused]);
return (
<Page backgroundImg={background.studioBG2} headerType="NONE">
@@ -65,15 +86,17 @@ const PouchReady = () => {
<View style={{ flex: 1, marginTop: 16 }}>
<CreateLyricsHeader
title={
coverUrl ? "Ta pochette est prête!" : "Génération de la pochette"
selectedProject?.cover?.backgroundGenerated
? "Ta pochette est prête!"
: "Génération de la pochette"
}
subTitle="Quen penses-tu ?"
// subTitle="Quen penses-tu ?"
/>
<View style={{ flex: 1, ...Style.containerCenter }}>
<View style={{ width: "80%", position: "relative" }}>
{coverUrl ? (
{selectedProject?.cover?.generatedBackground && !isGenerating ? (
<ExpoImage
source={{ uri: coverUrl }}
source={{ uri: selectedProject?.cover?.generatedBackground }}
cachePolicy="memory-disk"
priority="high"
contentFit="cover"
@@ -90,57 +113,57 @@ const PouchReady = () => {
...Style.containerCenter,
}}
>
{isGenerating || loading ? (
<ActivityIndicator color={Palette.white} />
) : (
<Image
source={img.placeholder}
style={{ width: 120, height: 120, opacity: 0.5 }}
/>
{isGenerating && (
<View style={{ alignItems: "center", gap: 8 }}>
<ActivityIndicator color={Palette.white} />
<Text style={{ color: Palette.white, marginTop: 6 }}>
Génération en cours.
</Text>
</View>
)}
</View>
)}
<View
style={{
position: "absolute",
alignSelf: "center",
alignItems: "center",
top: 10,
}}
>
<Text
style={{
fontSize: 22,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
}}
>
{title || "Titre"}
</Text>
<Text
style={{
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
MusicLand
</Text>
</View>
<View
style={{
position: "absolute",
alignItems: "center",
bottom: 10,
width: "100%",
}}
>
<Image
source={icons.musicLandLogo}
style={{ width: "100%", height: 30 }}
resizeMode="contain"
/>
</View>
{/*<View*/}
{/* style={{*/}
{/* position: "absolute",*/}
{/* alignSelf: "center",*/}
{/* alignItems: "center",*/}
{/* top: 10,*/}
{/* }}*/}
{/*>*/}
{/* <Text*/}
{/* style={{*/}
{/* fontSize: 22,*/}
{/* color: Palette.white,*/}
{/* fontFamily: FONT_FAMILY.InterSemiBold,*/}
{/* }}*/}
{/* >*/}
{/* {title || "Titre"}*/}
{/* </Text>*/}
{/* <Text*/}
{/* style={{*/}
{/* fontSize: 14,*/}
{/* color: Palette.gray,*/}
{/* fontFamily: FONT_FAMILY.InterRegular,*/}
{/* }}*/}
{/* >*/}
{/* MusicLand*/}
{/* </Text>*/}
{/*</View>*/}
{/*<View*/}
{/* style={{*/}
{/* position: "absolute",*/}
{/* alignItems: "center",*/}
{/* bottom: 10,*/}
{/* width: "100%",*/}
{/* }}*/}
{/*>*/}
{/* <Image*/}
{/* source={icons.musicLandLogo}*/}
{/* style={{ width: "100%", height: 30 }}*/}
{/* resizeMode="contain"*/}
{/* />*/}
{/*</View>*/}
</View>
</View>
</View>
@@ -160,8 +183,8 @@ const PouchReady = () => {
/>
<GradientButton
title={isGenerating ? "Veuillez patienter..." : "Valider"}
disabled={isGenerating || !coverUrl}
onPress={() => navigate(Routes.PhotoCover)}
disabled={isGenerating}
onPress={() => validateCover()}
/>
</View>
</Page>
+139
View File
@@ -0,0 +1,139 @@
import { View, Image } from "react-native";
import React from "react";
import MusicLandHeader from "../../components/MusicLandHeader";
import { background, img } from "../../assets";
import Page from "../../layouts/Page";
import { goBack, navigate } from "../../navigation/NavigationService";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import { gutters, Style } from "../../styles";
import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton";
import { Routes } from "../../navigation";
import { useUserData } from "../../providers/UserDataProvider";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
const ValidateCover = () => {
const { selectedProject, updateProjectData } = useUserData();
const { setIsLoading } = useMinuit();
async function onStartAgain() {
try {
await setIsLoading(true);
await updateProjectData({
cover: null,
coverUrl: null,
});
navigate(Routes.ChooseCoverType);
} catch (e) {
console.log(e);
} finally {
await setIsLoading(false);
}
}
async function onValidatePicture() {
try {
await setIsLoading(true);
await updateProjectData({
coverUrl: selectedProject?.cover?.result,
});
navigate(Routes.FlowSelection);
} catch (e) {
console.log(e);
} finally {
await setIsLoading(false);
}
}
return (
<Page backgroundImg={background.studioBG2} headerType="NONE">
<MusicLandHeader onPressBack={goBack} progress={90} />
<View style={{ flex: 1, marginTop: 16 }}>
<CreateLyricsHeader
title="Ta pochette est prête!"
subTitle="Quen penses-tu ?"
/>
<View style={{ flex: 1, ...Style.containerCenter }}>
<View style={{ width: "80%", position: "relative" }}>
<Image
source={img.placeholder}
style={{
width: "100%",
height: 300,
borderRadius: 20,
transform: [{ rotateY: "180deg" }],
}}
/>
<View
style={{ position: "absolute", width: "100%", height: "100%" }}
>
<Image
source={{ uri: selectedProject?.cover?.result }}
style={{ width: "100%", height: "100%", borderRadius: 20 }}
/>
</View>
{/*<View*/}
{/* style={{*/}
{/* position: "absolute",*/}
{/* alignSelf: "center",*/}
{/* alignItems: "center",*/}
{/* top: 10,*/}
{/* }}*/}
{/*>*/}
{/* <Text*/}
{/* style={{*/}
{/* fontSize: 22,*/}
{/* color: Palette.white,*/}
{/* fontFamily: FONT_FAMILY.InterSemiBold,*/}
{/* }}*/}
{/* >*/}
{/* Lust for Life*/}
{/* </Text>*/}
{/* <Text*/}
{/* style={{*/}
{/* fontSize: 14,*/}
{/* color: Palette.gray,*/}
{/* fontFamily: FONT_FAMILY.InterRegular,*/}
{/* }}*/}
{/* >*/}
{/* Lana del Rey*/}
{/* </Text>*/}
{/*</View>*/}
{/*<View*/}
{/* style={{*/}
{/* position: "absolute",*/}
{/* alignItems: "center",*/}
{/* bottom: 10,*/}
{/* width: "100%",*/}
{/* }}*/}
{/*>*/}
{/* <Image*/}
{/* source={icons.musicLandLogo}*/}
{/* style={{ width: "100%", height: 30 }}*/}
{/* resizeMode="contain"*/}
{/* />*/}
{/*</View>*/}
</View>
</View>
</View>
<View
style={{
paddingBottom: gutters * 2,
width: "80%",
alignSelf: "center",
gap: 12,
}}
>
<BorderGradientButton
title="Recommencer la création"
onPress={onStartAgain}
/>
<GradientButton
title="Valider la pochette"
onPress={onValidatePicture}
/>
</View>
</Page>
);
};
export default ValidateCover;