re choose cover and new prompt
This commit is contained in:
+243
-49
@@ -1,3 +1,192 @@
|
||||
const STYLE_PRESET_MAP = {
|
||||
'Réaliste (style cinématographique)': {
|
||||
style: 'Cinematic realism, film still, anamorphic lens, shallow depth of field',
|
||||
palettes: [
|
||||
'cool teal and amber highlights, muted neutrals',
|
||||
'stormy blues with soft gold accents',
|
||||
'charcoal, steel blue, and warm tungsten glows',
|
||||
],
|
||||
lighting: [
|
||||
'dramatic key light with soft fill and deep shadows',
|
||||
'low-key cinematic lighting with subtle haze',
|
||||
],
|
||||
textures: ['subtle film grain and crisp detail', 'matte finish with fine grain'],
|
||||
typography: ['clean cinematic title card, subtle embossing'],
|
||||
compositions: ['cinematic framing with a strong subject focus'],
|
||||
},
|
||||
'Artistique (style peinture moderne)': {
|
||||
style: 'Modern painting, expressive brushstrokes, gallery-ready composition',
|
||||
palettes: [
|
||||
'rich pigments with bold contrast',
|
||||
'muted pastels with a sharp accent color',
|
||||
'deep jewel tones and warm highlights',
|
||||
],
|
||||
lighting: ['soft gallery lighting with gentle gradients'],
|
||||
textures: ['visible canvas texture and layered paint', 'acrylic strokes with matte finish'],
|
||||
typography: ['refined serif lettering integrated into the paint'],
|
||||
compositions: ['balanced composition with painterly focal point'],
|
||||
},
|
||||
'Style Dessins': {
|
||||
style: 'Hand-drawn illustration, clean linework, ink and colored pencil',
|
||||
palettes: [
|
||||
'warm paper tones with soft color fills',
|
||||
'limited palette with bold accent color',
|
||||
],
|
||||
lighting: ['soft diffused light with minimal shadows'],
|
||||
textures: ['paper texture with visible grain'],
|
||||
typography: ['hand-lettered title with slight texture'],
|
||||
compositions: ['centered illustration with clear silhouette'],
|
||||
},
|
||||
'Style rétros': {
|
||||
style: 'Retro poster art, vintage print, halftone patterns, aged paper',
|
||||
palettes: [
|
||||
'sun-faded oranges, teal, and cream',
|
||||
'muted reds, mustard, and dark green',
|
||||
],
|
||||
lighting: ['flat poster lighting with strong contrast'],
|
||||
textures: ['distressed print texture and subtle scratches'],
|
||||
typography: ['vintage serif lettering with screen print texture'],
|
||||
compositions: ['poster-style layout with bold hierarchy'],
|
||||
},
|
||||
'Style cyberpunk': {
|
||||
style: 'Cyberpunk aesthetic, futuristic city glow, reflective surfaces',
|
||||
palettes: [
|
||||
'neon magenta and electric blue on dark backgrounds',
|
||||
'cyan, violet, and deep black with sharp highlights',
|
||||
],
|
||||
lighting: ['neon rim light and high-contrast highlights'],
|
||||
textures: ['glossy surfaces with subtle rain speckles'],
|
||||
typography: ['neon tube lettering embedded in the scene'],
|
||||
compositions: ['dynamic perspective with strong depth'],
|
||||
},
|
||||
'Style Afro': {
|
||||
style: 'Afrofuturist motifs, bold patterns, textile-inspired geometry',
|
||||
palettes: [
|
||||
'warm earth tones with vibrant accents',
|
||||
'gold, terracotta, deep blue, and emerald',
|
||||
],
|
||||
lighting: ['warm directional light with rich contrast'],
|
||||
textures: ['woven fabric texture and layered patterns'],
|
||||
typography: ['bold lettering with subtle pattern fills'],
|
||||
compositions: ['symmetrical layout with iconic central motif'],
|
||||
},
|
||||
'Style grunge': {
|
||||
style: 'Grunge collage, distressed textures, gritty mixed media',
|
||||
palettes: [
|
||||
'smoky blacks with muted reds and greys',
|
||||
'dirty neutrals with high-contrast highlights',
|
||||
],
|
||||
lighting: ['moody low-key lighting with harsh shadows'],
|
||||
textures: ['torn paper, scratches, and distressed overlays'],
|
||||
typography: ['distressed lettering with rough edges'],
|
||||
compositions: ['layered collage with imperfect alignment'],
|
||||
},
|
||||
'Style pop art': {
|
||||
style: 'Pop art illustration, bold outlines, Ben-Day dots, screen print',
|
||||
palettes: [
|
||||
'bold primary colors with black and white',
|
||||
'bright yellow, red, and cyan with clean contrast',
|
||||
],
|
||||
lighting: ['flat graphic lighting with crisp separation'],
|
||||
textures: ['screen print texture with halftone dots'],
|
||||
typography: ['bold outlined letters with halftone texture'],
|
||||
compositions: ['graphic layout with strong shapes and punchy hierarchy'],
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE_VARIANTS = [
|
||||
'Conceptual album art, abstract surrealism, high fidelity',
|
||||
'Graphic collage, mixed media, layered textures',
|
||||
'Minimalist design, bold shapes, strong negative space',
|
||||
'Dreamlike photography, cinematic framing, subtle grain',
|
||||
'Geometric abstraction, clean gradients, modern design',
|
||||
'Stylized illustration, crisp edges, bold composition',
|
||||
]
|
||||
|
||||
const COMPOSITION_VARIANTS = [
|
||||
'centered focal subject with balanced symmetry',
|
||||
'asymmetrical layout with dynamic diagonal flow',
|
||||
'minimal composition with strong negative space',
|
||||
'collage-like layout with layered elements',
|
||||
'close-up framing for intimacy and impact',
|
||||
'wide cinematic framing with a distant focal point',
|
||||
]
|
||||
|
||||
const LIGHTING_VARIANTS = [
|
||||
'dramatic chiaroscuro with deep shadows',
|
||||
'soft diffused light with gentle gradients',
|
||||
'high-contrast lighting with sharp highlights',
|
||||
'backlit silhouette with glowing edges',
|
||||
'moody low-key lighting with selective highlights',
|
||||
]
|
||||
|
||||
const PALETTE_VARIANTS = [
|
||||
'deep navy with warm amber accents',
|
||||
'muted earth tones with copper highlights',
|
||||
'cool blues with silver accents',
|
||||
'bold primary colors with black and white',
|
||||
'monochrome with a single accent color',
|
||||
'pastel gradients with a sharp contrasting highlight',
|
||||
'electric blue and magenta on dark tones',
|
||||
]
|
||||
|
||||
const TEXTURE_VARIANTS = [
|
||||
'subtle film grain with a matte finish',
|
||||
'canvas texture with visible brushwork',
|
||||
'paper cutout layers with rough edges',
|
||||
'glossy highlights with clean edges',
|
||||
'distressed print texture with scratches',
|
||||
]
|
||||
|
||||
const TYPOGRAPHY_VARIANTS = [
|
||||
'embossed metallic lettering integrated into the art',
|
||||
'minimalist sans-serif with generous tracking',
|
||||
'hand-drawn lettering with slight texture',
|
||||
'neon-style lettering embedded in the scene',
|
||||
'classic serif with elegant spacing',
|
||||
]
|
||||
|
||||
const SUBJECT_VARIANTS = [
|
||||
'a single strong symbol or scene that represents the song',
|
||||
'an abstract motif with a clear focal point',
|
||||
'a cinematic moment captured mid-action',
|
||||
'a minimalist icon or silhouette with strong presence',
|
||||
'a surreal object or landscape that feels iconic',
|
||||
]
|
||||
|
||||
const ATMOSPHERE_DARK = [
|
||||
'cinematic and moody with deep contrast',
|
||||
'noir ambience with subtle haze and high contrast',
|
||||
'tense and dramatic with low-key depth',
|
||||
]
|
||||
|
||||
const ATMOSPHERE_ENERGETIC = [
|
||||
'dynamic and high energy with punchy contrast',
|
||||
'electric and fast-paced with vivid saturation',
|
||||
'bold and kinetic with sharp highlights',
|
||||
]
|
||||
|
||||
const ATMOSPHERE_SOFT = [
|
||||
'calm and spacious with balanced mood',
|
||||
'introspective and gentle with airy depth',
|
||||
'dreamlike and serene with soft transitions',
|
||||
]
|
||||
|
||||
const hashSeed = (value) => {
|
||||
const str = String(value || '')
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i += 1) {
|
||||
hash = (hash * 31 + str.charCodeAt(i)) >>> 0
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
const pickVariant = (list, seed, salt = 0) => {
|
||||
if (!Array.isArray(list) || list.length === 0) return ''
|
||||
const idx = (seed + salt) % list.length
|
||||
return list[idx]
|
||||
}
|
||||
|
||||
exports.generatePicturePrompt = (project = {}) => {
|
||||
const {
|
||||
title = '',
|
||||
@@ -5,6 +194,7 @@ exports.generatePicturePrompt = (project = {}) => {
|
||||
musicConfig = {},
|
||||
coverStyle: coverStyleRaw = '',
|
||||
artistName: artistNameRaw = '',
|
||||
variantSeed = '',
|
||||
} = project || {}
|
||||
|
||||
// --- 1. Nettoyage et Normalisation ---
|
||||
@@ -20,10 +210,38 @@ exports.generatePicturePrompt = (project = {}) => {
|
||||
const artistName = sanitizeInline(artistNameRaw) || sanitizeInline(project?.userName || '')
|
||||
const hasArtistName = artistName.length > 0
|
||||
|
||||
const { genres = [], tempo = '', mood = '', instruments = [] } = musicConfig || {}
|
||||
const { genres = [], tempo = '', mood = '' } = musicConfig || {}
|
||||
const userStyle = sanitizeInline(coverStyleRaw)
|
||||
|
||||
// --- 2. Intelligence Visuelle (Mapping) ---
|
||||
const seedInput = [titleForPrompt, artistName, userStyle, String(variantSeed || '')].join('|')
|
||||
const baseSeed = hashSeed(seedInput)
|
||||
const seed = baseSeed || Math.floor(Math.random() * 1_000_000_000)
|
||||
|
||||
// --- 2. Intelligence Visuelle (Mapping & Variations) ---
|
||||
const preset = STYLE_PRESET_MAP[userStyle] || null
|
||||
const styleDescriptor = preset?.style || userStyle || pickVariant(DEFAULT_STYLE_VARIANTS, seed, 7)
|
||||
const renderingStyle = `Art Style: ${styleDescriptor}.`
|
||||
|
||||
const compositionChoice =
|
||||
preset?.compositions?.length > 0
|
||||
? pickVariant(preset.compositions, seed, 1)
|
||||
: pickVariant(COMPOSITION_VARIANTS, seed, 1)
|
||||
const lightingChoice =
|
||||
preset?.lighting?.length > 0
|
||||
? pickVariant(preset.lighting, seed, 2)
|
||||
: pickVariant(LIGHTING_VARIANTS, seed, 2)
|
||||
const paletteChoice =
|
||||
preset?.palettes?.length > 0
|
||||
? pickVariant(preset.palettes, seed, 3)
|
||||
: pickVariant(PALETTE_VARIANTS, seed, 3)
|
||||
const textureChoice =
|
||||
preset?.textures?.length > 0
|
||||
? pickVariant(preset.textures, seed, 4)
|
||||
: pickVariant(TEXTURE_VARIANTS, seed, 4)
|
||||
const typographyChoice =
|
||||
preset?.typography?.length > 0
|
||||
? pickVariant(preset.typography, seed, 5)
|
||||
: pickVariant(TYPOGRAPHY_VARIANTS, seed, 5)
|
||||
|
||||
// Détermination de l'énergie visuelle
|
||||
const isEnergetic =
|
||||
@@ -31,47 +249,21 @@ exports.generatePicturePrompt = (project = {}) => {
|
||||
const isDark =
|
||||
mood && /\b(sombre|triste|dark|sad|mélancoli|nuit|night|eerie)\b/i.test(String(mood))
|
||||
|
||||
// Construction de la Palette & Lumière
|
||||
let visualAtmosphere = ''
|
||||
if (isDark) {
|
||||
visualAtmosphere =
|
||||
'Atmosphere: Cinematic atmosphere, moody shadows, deep contrast. Palette: Midnight blue, obsidian, deep purple, metallic accents.'
|
||||
} else if (isEnergetic) {
|
||||
visualAtmosphere =
|
||||
'Atmosphere: Dynamic atmosphere, high energy, vibrant saturation. Palette: Neon colors, electric blue, magenta, bright yellow, high contrast.'
|
||||
} else {
|
||||
visualAtmosphere =
|
||||
'Atmosphere: Soft atmosphere, harmonious and ethereal. Palette: Pastel tones, warm gold, soft coral, balanced and elegant colors.'
|
||||
}
|
||||
|
||||
// Définition du Style de Rendu (Si l'utilisateur est vague, on renforce)
|
||||
let renderingStyle = userStyle ? `Art Style: ${userStyle}` : 'Art Style: Digital Art, Mixed Media'
|
||||
|
||||
if (userStyle.toLowerCase().includes('realist') || userStyle.toLowerCase().includes('photo')) {
|
||||
renderingStyle +=
|
||||
', 8k resolution, highly detailed texture, photorealistic, cinematic depth of field, raytracing.'
|
||||
} else if (
|
||||
userStyle.toLowerCase().includes('illu') ||
|
||||
userStyle.toLowerCase().includes('dessin')
|
||||
) {
|
||||
renderingStyle +=
|
||||
', vector art, clean lines, professional illustration, flat design or detailed painting.'
|
||||
} else {
|
||||
// Style par défaut "Album Cover" qui marche bien
|
||||
renderingStyle += ', abstract surrealism, conceptual album art, high fidelity, masterpiece.'
|
||||
}
|
||||
const atmosphereChoice = isDark
|
||||
? pickVariant(ATMOSPHERE_DARK, seed, 6)
|
||||
: isEnergetic
|
||||
? pickVariant(ATMOSPHERE_ENERGETIC, seed, 6)
|
||||
: pickVariant(ATMOSPHERE_SOFT, seed, 6)
|
||||
|
||||
// --- 3. Extraction de l'Inspiration (Lyrics) ---
|
||||
|
||||
// On cherche le REFRAIN en priorité pour l'image, car c'est le cœur visuel
|
||||
const sections = Array.isArray(lyricsRaw) ? lyricsRaw : []
|
||||
const chorus = sections.find((s) => s.type === 'refrain' || s.type === 'chorus')
|
||||
const verse = sections.find((s) => s.type === 'couplet' || s.type === 'verse')
|
||||
|
||||
// On prend 2 lignes max du refrain, ou du premier couplet
|
||||
const visualHook = (chorus?.lyrics || verse?.lyrics || '')
|
||||
.split('\n')
|
||||
.filter((l) => l.length > 10) // On évite les lignes trop courtes
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 10)
|
||||
.slice(0, 2)
|
||||
.join('. ')
|
||||
|
||||
@@ -79,36 +271,38 @@ exports.generatePicturePrompt = (project = {}) => {
|
||||
? `Visual Inspiration: An interpretation of these lyrics: "${visualHook}".`
|
||||
: "Visual Inspiration: Abstract visual representation of the song's mood."
|
||||
|
||||
// --- 4. Construction du Prompt Final (Structure Optimisée Imagen 3) ---
|
||||
const subjectChoice = pickVariant(SUBJECT_VARIANTS, seed, 8)
|
||||
|
||||
// --- 4. Construction du Prompt Final (Structure Optimisée Imagen 3) ---
|
||||
const promptParts = [
|
||||
// Rôle
|
||||
'Design a professional, high-quality music album cover.',
|
||||
|
||||
// 1. Le Texte (Crucial pour Imagen 3 - Doit être au début ou très clair)
|
||||
`**Typography & Text:**`,
|
||||
'**Typography & Text:**',
|
||||
`The song title "${titleForPrompt}" must be the CENTERPIECE. Write it in a distinct font that matches the mood.`,
|
||||
hasArtistName
|
||||
? `The artist name "${artistName}" must appear smaller, elegant, and legible near the bottom or top.`
|
||||
: '',
|
||||
'Ensure perfect spelling. The text should be integrated into the artwork (e.g., metallic texture, neon glow, or bold cut-out), not just pasted on top.',
|
||||
`Typography treatment: ${typographyChoice}.`,
|
||||
'Ensure perfect spelling. The text should be integrated into the artwork (e.g., embossed texture, neon tube, or bold cut-out), not just pasted on top.',
|
||||
|
||||
// 2. Le Visuel
|
||||
`**Visuals:**`,
|
||||
'**Visual Direction:**',
|
||||
renderingStyle,
|
||||
`Composition: ${compositionChoice}.`,
|
||||
`Surface & Texture: ${textureChoice}.`,
|
||||
`Subject: ${subjectChoice}.`,
|
||||
imageryPrompt,
|
||||
`Subject: A central visual element that represents the song. ${isEnergetic ? 'Dynamic composition.' : 'Balanced, centered composition.'}`,
|
||||
|
||||
// 3. L'Atmosphère (Context from music config)
|
||||
`**Mood & Color:**`,
|
||||
visualAtmosphere,
|
||||
'**Mood & Color:**',
|
||||
`Atmosphere: ${atmosphereChoice}.`,
|
||||
`Palette: ${paletteChoice}.`,
|
||||
`Lighting: ${lightingChoice}.`,
|
||||
genres.length > 0 ? `Musical Vibe Reference: ${genres.join(', ')}.` : '',
|
||||
|
||||
// 4. Contraintes Négatives (Phrasées positivement pour l'IA)
|
||||
'**Constraints:**',
|
||||
'Use a square 1:1 aspect ratio.',
|
||||
'Do NOT depict literal musical instruments (like guitars or microphones) unless they are part of a surreal abstract composition.',
|
||||
'No blurry text. No messy borders. No watermarks.',
|
||||
'No watermarks or logos.',
|
||||
'No blurry text or messy borders.',
|
||||
'Avoid generic stock imagery and clichéd instruments unless essential to the concept.',
|
||||
]
|
||||
|
||||
return promptParts.filter(Boolean).join('\n\n')
|
||||
|
||||
+61
-53
@@ -2,8 +2,6 @@ const { onDocumentCreated } = require('firebase-functions/v2/firestore')
|
||||
const admin = require('firebase-admin')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const logger = require('firebase-functions/logger')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const axios = require('axios')
|
||||
const sharp = require('sharp')
|
||||
const crypto = require('crypto')
|
||||
@@ -16,23 +14,47 @@ const { sendNotification } = require('./notifications')
|
||||
|
||||
// Configuration
|
||||
const bucket = admin.storage().bucket()
|
||||
const LOGO_PATH = path.resolve(__dirname, '../assets/musicLandLogo.png')
|
||||
const BRAND_TEXT = 'By Musicland.ai'
|
||||
const BRAND_FONT_FAMILY = 'Poppins, Montserrat, Arial, sans-serif'
|
||||
|
||||
// Cache mémoire pour le buffer du logo (évite les I/O disque à chaque appel sur instance chaude)
|
||||
let _cachedLogoBuffer = null
|
||||
const escapeSvgText = (value = '') =>
|
||||
String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
/**
|
||||
* Récupère le buffer du logo depuis le cache ou le disque
|
||||
*/
|
||||
const getLogoBuffer = async () => {
|
||||
if (_cachedLogoBuffer) return _cachedLogoBuffer
|
||||
try {
|
||||
_cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH)
|
||||
return _cachedLogoBuffer
|
||||
} catch (error) {
|
||||
logger.error('❌ [Cover] Impossible de lire le fichier logo', error)
|
||||
throw new Error('Asset Logo manquant sur le serveur')
|
||||
}
|
||||
const buildBrandSvg = ({ width = 1024, height = 1024, text = BRAND_TEXT } = {}) => {
|
||||
const safeWidth = Math.max(320, Math.round(width))
|
||||
const safeHeight = Math.max(320, Math.round(height))
|
||||
const margin = Math.round(safeWidth * 0.04)
|
||||
const fontSize = Math.max(22, Math.round(safeWidth * 0.045))
|
||||
const letterSpacing = Math.max(1, Math.round(fontSize * 0.06))
|
||||
const strokeWidth = Math.max(2, Math.round(fontSize * 0.08))
|
||||
const shadowDy = Math.max(2, Math.round(fontSize * 0.08))
|
||||
const shadowBlur = Math.max(4, Math.round(fontSize * 0.18))
|
||||
const safeText = escapeSvgText(text)
|
||||
|
||||
return Buffer.from(`
|
||||
<svg width="${safeWidth}" height="${safeHeight}" viewBox="0 0 ${safeWidth} ${safeHeight}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="brandShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="${shadowDy}" stdDeviation="${shadowBlur}" flood-color="rgba(0,0,0,0.55)" />
|
||||
</filter>
|
||||
</defs>
|
||||
<text
|
||||
x="${safeWidth - margin}"
|
||||
y="${safeHeight - margin}"
|
||||
text-anchor="end"
|
||||
dominant-baseline="alphabetic"
|
||||
font-family="${BRAND_FONT_FAMILY}"
|
||||
font-size="${fontSize}"
|
||||
font-style="italic"
|
||||
font-weight="700"
|
||||
letter-spacing="${letterSpacing}"
|
||||
fill="rgba(255,255,255,0.95)"
|
||||
stroke="rgba(0,0,0,0.45)"
|
||||
stroke-width="${strokeWidth}"
|
||||
paint-order="stroke"
|
||||
filter="url(#brandShadow)"
|
||||
>${safeText}</text>
|
||||
</svg>`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,39 +118,25 @@ async function resolveArtistName(project = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute le logo en filigrane sur l'image générée
|
||||
* Ajoute le texte de marque en filigrane sur l'image générée
|
||||
*/
|
||||
async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
||||
logger.info('🖼️ [Cover] Compositing logo...')
|
||||
async function buildCoverWithBrandText(backgroundUrl, targetPath) {
|
||||
logger.info('🖼️ [Cover] Compositing brand text...')
|
||||
|
||||
try {
|
||||
// Téléchargement background + Lecture Logo (parallèle)
|
||||
const [bgResponse, logoBuffer] = await Promise.all([
|
||||
axios.get(backgroundUrl, { responseType: 'arraybuffer' }),
|
||||
getLogoBuffer(),
|
||||
])
|
||||
// Téléchargement background
|
||||
const bgResponse = await axios.get(backgroundUrl, { responseType: 'arraybuffer' })
|
||||
|
||||
const baseImage = sharp(bgResponse.data)
|
||||
const metadata = await baseImage.metadata()
|
||||
const width = metadata.width || 1024
|
||||
const height = metadata.height || 1024
|
||||
|
||||
// Calcul dynamique de la taille du logo (32% de la largeur)
|
||||
const desiredWidth = Math.round(width * 0.32)
|
||||
const margin = Math.round(width * 0.04)
|
||||
|
||||
// Redimensionnement du logo
|
||||
const resizedLogo = await sharp(logoBuffer).resize({ width: desiredWidth }).png().toBuffer()
|
||||
|
||||
// Positionnement (Bas Droite)
|
||||
const logoMetadata = await sharp(resizedLogo).metadata()
|
||||
const left = Math.max(width - logoMetadata.width - margin, 0)
|
||||
const top = Math.max(height - logoMetadata.height - margin, 0)
|
||||
const brandOverlay = buildBrandSvg({ width, height, text: BRAND_TEXT })
|
||||
|
||||
// Composition
|
||||
const stampedBuffer = await baseImage
|
||||
.ensureAlpha()
|
||||
.composite([{ input: resizedLogo, left, top, blend: 'over' }])
|
||||
.composite([{ input: brandOverlay, left: 0, top: 0, blend: 'over' }])
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
@@ -147,8 +155,8 @@ async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
||||
|
||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`
|
||||
} catch (error) {
|
||||
logger.error('❌ [Cover] buildCoverWithLogo failed', error)
|
||||
// En cas d'échec du logo, on renvoie l'URL originale pour ne pas tout perdre
|
||||
logger.error('❌ [Cover] buildCoverWithBrandText failed', error)
|
||||
// En cas d'échec du texte, on renvoie l'URL originale pour ne pas tout perdre
|
||||
return backgroundUrl
|
||||
}
|
||||
}
|
||||
@@ -160,18 +168,6 @@ async function performCoverGeneration(project) {
|
||||
const t0 = Date.now()
|
||||
const artistName = await resolveArtistName(project)
|
||||
|
||||
// Génération du Prompt optimisé
|
||||
const prompt = generatePicturePrompt({
|
||||
...project,
|
||||
artistName,
|
||||
})
|
||||
|
||||
logger.info('🎨 [Cover] Prompt generated', {
|
||||
projectId: project.id,
|
||||
artistName,
|
||||
promptPreview: prompt.slice(0, 100) + '...',
|
||||
})
|
||||
|
||||
const baseTimestamp = Date.now()
|
||||
const GENERATION_COUNT = 2 // Nombre de variantes simultanées
|
||||
|
||||
@@ -181,6 +177,18 @@ async function performCoverGeneration(project) {
|
||||
const storageBasePath = `users/${project.userId}/projects/${project.id}`
|
||||
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`
|
||||
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`
|
||||
const prompt = generatePicturePrompt({
|
||||
...project,
|
||||
artistName,
|
||||
variantSeed: `${project.id || 'project'}-${uniqueSuffix}`,
|
||||
})
|
||||
|
||||
logger.info('🎨 [Cover] Prompt generated', {
|
||||
projectId: project.id,
|
||||
artistName,
|
||||
variant: uniqueSuffix,
|
||||
promptPreview: prompt.slice(0, 100) + '...',
|
||||
})
|
||||
|
||||
try {
|
||||
// 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres
|
||||
@@ -189,7 +197,7 @@ async function performCoverGeneration(project) {
|
||||
if (!generatedUrl) throw new Error("URL vide retournée par l'IA")
|
||||
|
||||
// 2. Ajout du Logo
|
||||
const finalCoverUrl = await buildCoverWithLogo(generatedUrl, stampedPath)
|
||||
const finalCoverUrl = await buildCoverWithBrandText(generatedUrl, stampedPath)
|
||||
|
||||
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`)
|
||||
|
||||
|
||||
@@ -217,6 +217,17 @@ const Home = ({ navigation, route }) => {
|
||||
const hasSongUrl = typeof menuProject?.songUrl === 'string' && menuProject.songUrl.length > 0
|
||||
|
||||
if (hasSongUrl) {
|
||||
items.push({
|
||||
label: 'Télécharger',
|
||||
onPress: () => {
|
||||
projectDropdownRef.current?.close?.()
|
||||
navigate(Routes.SongDownload, {
|
||||
project: menuProject,
|
||||
skipAdventureGate: true,
|
||||
promptPurchaseConfirm: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
items.push({
|
||||
label: 'Jouer la musique',
|
||||
onPress: () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ export const ChooseCoverType = () => {
|
||||
const hasGeneratedOptions = Array.isArray(selectedProject?.cover?.options)
|
||||
? selectedProject.cover.options.length > 0
|
||||
: false
|
||||
const generateCoverLabel = hasFinalCover ? 'Modifier la pochette' : 'Générer la pochette'
|
||||
|
||||
const hasArtistPreference = useMemo(() => {
|
||||
if (currentUserData?.userName) return true
|
||||
@@ -223,9 +224,8 @@ export const ChooseCoverType = () => {
|
||||
onPress={handlePickUserImage}
|
||||
/> */}
|
||||
<GradientButton
|
||||
title={hasFinalCover ? 'Pochette déjà validée' : 'Générer la pochette'}
|
||||
title={generateCoverLabel}
|
||||
onPress={handleGenerateCover}
|
||||
disabled={hasFinalCover}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -76,7 +76,7 @@ const PhotoCover = () => {
|
||||
<View style={{ flex: 1, marginTop: 16 }}>
|
||||
<CreateLyricsHeader
|
||||
title="Ta pochette MusicLand Production"
|
||||
subTitle="Le logo est automatiquement ajouté en bas à droite."
|
||||
subTitle="Le texte Musicland.ai est automatiquement ajouté en bas à droite."
|
||||
/>
|
||||
<View style={{ flex: 1, ...Style.containerCenter }}>
|
||||
<View style={{ width: '80%', position: 'relative' }}>
|
||||
|
||||
@@ -33,18 +33,14 @@ import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
|
||||
import ProgressBar from '../../components/ProgressBar'
|
||||
|
||||
const COVER_STYLE_PRESETS = [
|
||||
'Cyberpunk',
|
||||
'Dessins animé',
|
||||
'Dessins animé rétro',
|
||||
'Portrait théâtralisé',
|
||||
'Livre de coloriage',
|
||||
'Shooting',
|
||||
'Réaliste',
|
||||
'Artistique',
|
||||
'Afro',
|
||||
'Grunge',
|
||||
'Pop Art'
|
||||
|
||||
'Réaliste (style cinématographique)',
|
||||
'Artistique (style peinture moderne)',
|
||||
'Style Dessins',
|
||||
'Style rétros',
|
||||
'Style cyberpunk',
|
||||
'Style Afro',
|
||||
'Style grunge',
|
||||
'Style pop art',
|
||||
]
|
||||
const COVER_PROGRESS_MAX = 98
|
||||
const COVER_PROGRESS_INTERVAL_MS = 500
|
||||
@@ -120,10 +116,10 @@ const PouchReady = () => {
|
||||
}, [selectedProject?.coverStyle])
|
||||
|
||||
const generateCover = useCallback(
|
||||
async (styleValue) => {
|
||||
async (styleValue, { allowRegeneration = false } = {}) => {
|
||||
if (!selectedProjectId) return
|
||||
// We allow regeneration if isEditing is true
|
||||
if (hasGeneratedOptions && !isEditing) {
|
||||
// We allow regeneration if isEditing or explicitly requested
|
||||
if (hasGeneratedOptions && !isEditing && !allowRegeneration) {
|
||||
return
|
||||
}
|
||||
const trimmedStyle = String(styleValue || '').trim()
|
||||
@@ -171,6 +167,18 @@ const PouchReady = () => {
|
||||
generateCover(trimmedStyle)
|
||||
}, [customStyle, generateCover, selectedPresetStyle, styleMode])
|
||||
|
||||
const handleRegenerateSameStyle = useCallback(() => {
|
||||
const storedStyle =
|
||||
typeof selectedProject?.coverStyle === 'string' ? selectedProject.coverStyle.trim() : ''
|
||||
const fallbackStyle = styleMode === 'preset' ? selectedPresetStyle : customStyle
|
||||
const trimmedStyle = (storedStyle || fallbackStyle || '').trim()
|
||||
if (!trimmedStyle) {
|
||||
alert('Attention', 'Merci de renseigner un style pour la pochette.', [{ text: 'OK' }])
|
||||
return
|
||||
}
|
||||
generateCover(trimmedStyle, { allowRegeneration: true })
|
||||
}, [customStyle, generateCover, selectedPresetStyle, selectedProject?.coverStyle, styleMode])
|
||||
|
||||
const coverPreviewUrl =
|
||||
selectedProject?.cover?.result || selectedProject?.cover?.generatedBackground || null
|
||||
|
||||
@@ -542,15 +550,21 @@ const PouchReady = () => {
|
||||
setIsPresetDropdownOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text style={{ color: Palette.white, fontSize: 12, paddingLeft: 24, paddingTop: 8 }}>
|
||||
Donne les instructions que tu veux pour créer ta pochette
|
||||
<Text
|
||||
style={{
|
||||
color: Palette.white,
|
||||
fontSize: 12,
|
||||
paddingLeft: 24,
|
||||
paddingTop: 8,
|
||||
}}
|
||||
>
|
||||
(donne les instructions que tu veux pour créer ta pochette)
|
||||
</Text>
|
||||
</View>
|
||||
{styleMode === 'custom' && (
|
||||
<View style={styles.customInputWrapper}>
|
||||
<TextInput
|
||||
placeholder="Exemple : Collage rétro futuriste lumineux"
|
||||
placeholder="Exemple : Collage rétro futuriste contrasté"
|
||||
placeholderTextColor={Palette.grayMid}
|
||||
value={customStyle}
|
||||
onChangeText={setCustomStyle}
|
||||
@@ -608,7 +622,12 @@ const PouchReady = () => {
|
||||
<>
|
||||
<View style={{ flexDirection: 'column', gap: 12, width: '100%' }}>
|
||||
<GradientButton title={'Valider la pochette'} onPress={onValidatePicture} />
|
||||
|
||||
<BorderGradientButton
|
||||
title="Regénérer une autre proposition"
|
||||
onPress={handleRegenerateSameStyle}
|
||||
disabled={isGenerating}
|
||||
textStyle={{ fontSize: 13 }}
|
||||
/>
|
||||
<BorderGradientButton
|
||||
title="Pas satisfait ? Modifier ma demande"
|
||||
onPress={() => setIsEditing(true)}
|
||||
|
||||
@@ -40,6 +40,8 @@ const SongDownload = ({ route }) => {
|
||||
project: routeProject,
|
||||
selectedOption: routeSelectedOption,
|
||||
coverOptions: routeCoverOptions,
|
||||
skipAdventureGate = false,
|
||||
promptPurchaseConfirm = false,
|
||||
} = route?.params || {}
|
||||
const { selectedProject, updateProjectData, hasActiveSubscription } = useUser()
|
||||
const { createSongDownloadCheckout } = useStripe()
|
||||
@@ -53,7 +55,9 @@ const SongDownload = ({ route }) => {
|
||||
const [showIntro, setShowIntro] = useState(null)
|
||||
const [showAdventureModal, setShowAdventureModal] = useState(false)
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [adventureChoice, setAdventureChoice] = useState('pending')
|
||||
const [adventureChoice, setAdventureChoice] = useState(() =>
|
||||
skipAdventureGate ? 'stop' : 'pending'
|
||||
)
|
||||
const adventureGateTriggeredRef = useRef(false)
|
||||
|
||||
const projectForStage = useMemo(() => {
|
||||
@@ -467,6 +471,18 @@ const SongDownload = ({ route }) => {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (promptPurchaseConfirm) {
|
||||
Alert.alert(
|
||||
'Acheter ce morceau',
|
||||
'Voulez-vous acheter ce morceau pour 1,99€ ?',
|
||||
[
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{ text: 'Acheter', onPress: () => startSongDownloadCheckout() },
|
||||
],
|
||||
{ cancelable: true }
|
||||
)
|
||||
return
|
||||
}
|
||||
startSongDownloadCheckout()
|
||||
}, [
|
||||
canDownloadDirectly,
|
||||
@@ -474,6 +490,7 @@ const SongDownload = ({ route }) => {
|
||||
handleDownload,
|
||||
isCheckoutLaunching,
|
||||
isDownloading,
|
||||
promptPurchaseConfirm,
|
||||
startSongDownloadCheckout,
|
||||
])
|
||||
|
||||
|
||||
@@ -55,8 +55,7 @@ const ValidateCover = () => {
|
||||
if (
|
||||
!option ||
|
||||
option?.id === selectedOptionId ||
|
||||
isSelecting ||
|
||||
selectedOptionId // Prevent re-selection
|
||||
isSelecting
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user