generate two covers

This commit is contained in:
2025-10-23 10:16:36 +02:00
parent b4375d04be
commit ebe26542fe
9 changed files with 754 additions and 436 deletions
+21 -11
View File
@@ -1,5 +1,10 @@
exports.generatePicturePrompt = (project = {}) => { exports.generatePicturePrompt = (project = {}) => {
const { title = "", lyrics: lyricsRaw, musicConfig = {} } = project || {}; const {
title = "",
lyrics: lyricsRaw,
musicConfig = {},
coverStyle: coverStyleRaw = "",
} = project || {};
const { const {
genres = [], genres = [],
@@ -44,28 +49,33 @@ exports.generatePicturePrompt = (project = {}) => {
? "Palette vive et contrastée (magenta, cyan, jaune, bleu électrique)" ? "Palette vive et contrastée (magenta, cyan, jaune, bleu électrique)"
: "Palette harmonieuse et douce (bleu nuit, violet, corail, or pâle)"; : "Palette harmonieuse et douce (bleu nuit, violet, corail, or pâle)";
const coverStyle = String(coverStyleRaw || "").trim();
const tagsLine = tags.join(" ; ") || "Non spécifié"; const tagsLine = tags.join(" ; ") || "Non spécifié";
const lyricsLine = lyricsSample || "Pas d'extraits fournis"; const lyricsLine = lyricsSample || "Pas d'extraits fournis";
const styleLine = coverStyle
? `${coverStyle}. ${paletteHint}`
: `${paletteHint}. Style libre, artistique et lumineux.`;
const prompt = ` const prompt = `<BRIEF>
<BRIEF> <OBJECTIF>Créer une pochette d'album en adéquation avec les paroles de la musique</OBJECTIF>
<OBJECTIF>Créer une pochette lumineuse.</OBJECTIF>
<TITRE>${title || "Sans titre"}</TITRE> <TITRE>${title || "Sans titre"}</TITRE>
<STYLE_MUSICAL>${tagsLine}</STYLE_MUSICAL> <STYLE_MUSICAL>${tagsLine}</STYLE_MUSICAL>
<EXTRAITS_PAROLES>${lyricsLine}</EXTRAITS_PAROLES> <EXTRAITS_PAROLES>${lyricsLine}</EXTRAITS_PAROLES>
</BRIEF> </BRIEF>
<CONTEXTES_VISUELS> <CONTEXTES_VISUELS>
<FORMAT>Image carrée 1024x1024 pixels.</FORMAT> <FORMAT>Image carrée 1024x1024 pixels, résolution haute.</FORMAT>
<STYLE>Abstrait, graphique, formes géométriques et dégradés.</STYLE> <STYLE>${styleLine}</STYLE>
<COMPOSITION>Remplis toute la surface sans laisser de bordures ou zones vides.</COMPOSITION> <COMPOSITION>La composition doit être dynamique et remplir toute la surface, sans laisser de bordures ni de zones vides.</COMPOSITION>
<TYPOGRAPHIE>Intègre de manière artistique et lisible le titre de la chanson ${title || "Sans titre"}. La typographie doit compléter le style abstrait et lumineux de la pochette. Priorise la lisibilité.</TYPOGRAPHIE>
<LOGO_RESERVE>Réserve un espace net et dégagé en bas à droite, occupant environ 25% de la largeur totale. Cette zone doit être légèrement plus sombre ou avoir un contraste suffisant.</LOGO_RESERVE>
</CONTEXTES_VISUELS> </CONTEXTES_VISUELS>
<CONTRAINTES> <CONTRAINTES>
<INTERDIT>Texte ou typographie.</INTERDIT> <INTERDIT>Personnes, visages ou silhouettes reconnaissables.</INTERDIT>
<INTERDIT>Personnes ou visages.</INTERDIT> <INTERDIT>Fonds blancs ou bordures délimitant l'image.</INTERDIT>
<INTERDIT>Fond blanc ou bordures.</INTERDIT> <INTERDIT>Tout autre logo, filigrane ou texte promotionnel que le titre, l'artiste et la zone réservée pour "MusicLand Production".</INTERDIT>
<INTERDIT>Autre logo ou filigrane que celui prévu en bas à droite.</INTERDIT> <INTERDIT>Texte illisible ou trop petit.</INTERDIT>
</CONTRAINTES> </CONTRAINTES>
`.trim(); `.trim();
+59 -21
View File
@@ -75,49 +75,65 @@ async function performCoverGeneration(project) {
projectId: project.id, projectId: project.id,
promptPreview: String(prompt).slice(0, 160), promptPreview: String(prompt).slice(0, 160),
}); });
let coverUrl = ""; const baseTimestamp = Date.now();
const timestamp = Date.now(); const options = [];
for (let index = 0; index < 2; index += 1) {
const uniqueSuffix = `${baseTimestamp}-${index}`;
const storageBasePath = `users/${project.userId}/projects/${project.id}`;
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`;
let generatedUrl = "";
try { try {
// Utiliser une taille fixe de 1024 pixels generatedUrl = await generateImageV2(prompt, 1024, generatedPath);
coverUrl = await generateImageV2( logger.info("🎨 [Cover] Candidate generated", {
prompt, projectId: project.id,
1024, candidateIndex: index,
`users/${project.userId}/projects/${project.id}/generated-${timestamp}.png`, });
);
console.log("coverUrl", coverUrl);
} catch (e) { } catch (e) {
logger.error("❌ [Cover] generateImageV2 failed", { logger.error("❌ [Cover] generateImageV2 failed", {
projectId: project.id, projectId: project.id,
candidateIndex: index,
error: e?.message || String(e), error: e?.message || String(e),
}); });
throw e; throw e;
} }
if (!coverUrl) {
if (!generatedUrl) {
throw new Error("Génération d'image échouée (URL vide)"); throw new Error("Génération d'image échouée (URL vide)");
} }
let finalCoverUrl = ""; let finalCoverUrl = generatedUrl;
try { try {
const stampedPath = `users/${project.userId}/projects/${project.id}/cover-${timestamp}-stamped.png`; const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`;
finalCoverUrl = await buildCoverWithLogo(coverUrl, stampedPath); finalCoverUrl = await buildCoverWithLogo(generatedUrl, stampedPath);
} catch (e) { } catch (e) {
logger.error("❌ [Cover] Logo overlay failed", { logger.error("❌ [Cover] Logo overlay failed", {
projectId: project.id, projectId: project.id,
candidateIndex: index,
error: e?.message || String(e), error: e?.message || String(e),
}); });
// En cas d'échec, on retient au moins la cover générée
finalCoverUrl = coverUrl;
} }
// Mettre à jour le projet avec l'URL et le statut options.push({
id: uniqueSuffix,
generatedUrl,
finalUrl: finalCoverUrl,
});
}
const [firstOption] = options;
await db await db
.collection("projects") .collection("projects")
.doc(project.id) .doc(project.id)
.set( .set(
{ {
cover: { cover: {
generatedBackground: coverUrl, generatedBackground: firstOption?.generatedUrl || null,
result: finalCoverUrl, result: firstOption?.finalUrl || null,
selectedOptionId: firstOption?.id || null,
options,
}, },
coverStatus: "GENERATED", coverStatus: "GENERATED",
updatedAt: admin.firestore.FieldValue.serverTimestamp(), updatedAt: admin.firestore.FieldValue.serverTimestamp(),
@@ -127,11 +143,11 @@ async function performCoverGeneration(project) {
logger.info("✅ [Cover] Saved", { logger.info("✅ [Cover] Saved", {
projectId: project.id, projectId: project.id,
coverUrl, optionsCount: options.length,
finalCoverUrl,
ms: Date.now() - t0, ms: Date.now() - t0,
}); });
return finalCoverUrl;
return firstOption?.finalUrl || null;
} }
// Firestore trigger: création d'une tâche de génération de cover // Firestore trigger: création d'une tâche de génération de cover
@@ -185,12 +201,34 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
} }
project.id = projectId; project.id = projectId;
const existingOptionsCount = Array.isArray(project?.cover?.options)
? project.cover.options.length
: 0;
logger.info("🔎 [Task] Project loaded", { logger.info("🔎 [Task] Project loaded", {
projectId, projectId,
hasGeneratedBackground: !!project?.cover?.generatedBackground, hasGeneratedBackground: !!project?.cover?.generatedBackground,
hasUserForeground: !!project?.cover?.userForeground, hasUserForeground: !!project?.cover?.userForeground,
existingOptionsCount,
}); });
if (existingOptionsCount > 0) {
logger.warn("⛔ [Task] Cover already generated, skipping", {
projectId,
existingOptionsCount,
});
await db.collection("projects").doc(projectId).update({
coverStatus: "GENERATED",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
await event.data.ref.update({
status: "CANCELLED",
error: "Cover already generated",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
return;
}
if (type === "cover") { if (type === "cover") {
logger.info("🎨 [Task] Start cover generation", { projectId }); logger.info("🎨 [Task] Start cover generation", { projectId });
coverUrl = await performCoverGeneration(project); coverUrl = await performCoverGeneration(project);
+2
View File
@@ -125,6 +125,7 @@ const Studio = () => {
navigate(Routes.SongReady); navigate(Routes.SongReady);
}} }}
/> />
{!selected?.coverUrl && (
<GradientButton <GradientButton
title="Générer une pochette" title="Générer une pochette"
containerStyle={{ containerStyle={{
@@ -135,6 +136,7 @@ const Studio = () => {
navigate(Routes.PouchReady); navigate(Routes.PouchReady);
}} }}
/> />
)}
</> </>
)} )}
</View> </View>
+230 -134
View File
@@ -1,9 +1,23 @@
import React from "react"; import React, {
import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { icons } from "../../assets"; import { icons } from "../../assets";
import { Palette } from "../../styles"; import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts"; import { FONT_FAMILY } from "../../styles/Fonts";
import { import {
arraysAreSame,
formatStructureLabel, formatStructureLabel,
getSegmentMeta, getSegmentMeta,
OPTIONAL_STRUCTURE_SEGMENTS, OPTIONAL_STRUCTURE_SEGMENTS,
@@ -17,17 +31,16 @@ const getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0);
const randomSuffix = () => Math.random().toString(36).slice(2, 8); const randomSuffix = () => Math.random().toString(36).slice(2, 8);
const buildSortableItems = (values = []) => { const createSortableItem = (type, index = 0) => ({
const counters = {}; id: `${type}-${index}-${randomSuffix()}`,
return sanitizeStructureList(values).map((segment, index) => { type,
counters[segment] = (counters[segment] || 0) + 1; value: type,
return {
id: `${segment}-${index}-${randomSuffix()}`,
type: segment,
value: segment,
};
}); });
};
const buildSortableItems = (values = []) =>
sanitizeStructureList(values).map((type, index) =>
createSortableItem(type, index)
);
const reorder = (list, from, to) => { const reorder = (list, from, to) => {
if (from === to) return list; if (from === to) return list;
@@ -40,87 +53,114 @@ const reorder = (list, from, to) => {
const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
const sanitized = React.useMemo( const sanitizedBase = useMemo(
() => sanitizeStructureList(baseStructure), () => sanitizeStructureList(baseStructure),
[baseStructure] [baseStructure]
); );
const [items, setItems] = React.useState(() => buildSortableItems(sanitized)); const [sortableItems, setSortableItems] = useState(() =>
const [activeItemId, setActiveItemId] = React.useState(null); buildSortableItems(sanitizedBase)
const containerRef = React.useRef(null); );
const dragStateRef = React.useRef({ const [activeItemId, setActiveItemId] = useState(null);
const containerRef = useRef(null);
const dragStateRef = useRef({
itemId: null, itemId: null,
containerTop: 0, containerTop: 0,
}); });
React.useEffect(() => { const extractValues = useCallback(
console.debug("[CustomizeSongStructure.web] sanitized", sanitized); (items) =>
setItems(buildSortableItems(sanitized)); (items || []).map((item) => item?.type || item?.value).filter(Boolean),
dragStateRef.current = { itemId: null, containerTop: 0 }; []
setActiveItemId(null);
}, [sanitized]);
React.useEffect(() => {
console.debug("[CustomizeSongStructure.web] items", items);
onChange?.(
items.map((item) => (item?.type || item?.value)).filter(Boolean)
); );
}, [items, onChange]);
const labeledItems = React.useMemo(() => { useEffect(() => {
setSortableItems((prev) => {
const currentValues = extractValues(prev);
if (arraysAreSame(currentValues, sanitizedBase)) {
return prev;
}
return buildSortableItems(sanitizedBase);
});
}, [sanitizedBase, extractValues]);
useEffect(() => {
onChange?.(extractValues(sortableItems));
}, [sortableItems, extractValues, onChange]);
const labeledItems = useMemo(() => {
const counts = {}; const counts = {};
return items.map((item) => { return sortableItems.map((item) => {
const type = item?.type || item?.value; const baseType = item.type || item.value;
counts[type] = (counts[type] || 0) + 1; const nextCount = (counts[baseType] || 0) + 1;
counts[baseType] = nextCount;
return { return {
...item, ...item,
type, type: baseType,
value: type, value: baseType,
label: formatStructureLabel(type, counts[type]), label: formatStructureLabel(baseType, nextCount),
}; };
}); });
}, [items]); }, [sortableItems]);
const selectedTypes = React.useMemo(() => { const optionalSegments = useMemo(
const set = new Set();
items.forEach((item) => {
if (item?.type) set.add(item.type);
else if (item?.value) set.add(item.value);
});
return set;
}, [items]);
const optionalSegments = React.useMemo(
() => () =>
OPTIONAL_STRUCTURE_SEGMENTS.map((type) => { OPTIONAL_STRUCTURE_SEGMENTS.map((type) => {
const meta = getSegmentMeta(type); const meta = getSegmentMeta(type);
return { type, label: meta?.label }; return { type, label: meta?.label || formatStructureLabel(type, 1) };
}), }),
[] []
); );
const toggleOptionalSegment = React.useCallback((type) => { const segmentCounts = useMemo(() => {
setItems((prev) => { const counts = {};
const currentValues = prev.map((item) => item.type || item.value); extractValues(sortableItems).forEach((type) => {
if (currentValues.includes(type)) { counts[type] = (counts[type] || 0) + 1;
const filtered = currentValues.filter((value) => value !== type); });
return buildSortableItems(filtered); return counts;
} }, [sortableItems, extractValues]);
const handleRemoveItem = useCallback(
(itemId) => {
setSortableItems((prev) => {
const remaining = prev.filter((item) => item.id !== itemId);
const sanitized = sanitizeStructureList(extractValues(remaining));
return buildSortableItems(sanitized);
});
},
[extractValues]
);
const handleOptionalSegmentPress = useCallback(
(type) => {
const meta = getSegmentMeta(type); const meta = getSegmentMeta(type);
setSortableItems((prev) => {
const currentValues = extractValues(prev);
let nextValues = [...currentValues]; let nextValues = [...currentValues];
if (meta?.exclusiveGroup) { if (meta?.exclusiveGroup) {
nextValues = nextValues.filter((value) => { nextValues = nextValues.filter((value) => {
const itemMeta = getSegmentMeta(value); const itemMeta = getSegmentMeta(value);
return itemMeta?.exclusiveGroup !== meta.exclusiveGroup; return itemMeta?.exclusiveGroup !== meta.exclusiveGroup;
}); });
} }
nextValues.push(type);
return buildSortableItems(nextValues);
});
}, []);
const handleGrant = (index, event) => { const allowsMultiple = meta?.allowMultiple !== false;
const item = labeledItems[index]; if (!allowsMultiple && nextValues.includes(type)) {
if (!item) return; const filtered = nextValues.filter((value) => value !== type);
const sanitized = sanitizeStructureList(filtered);
return buildSortableItems(sanitized);
}
nextValues.push(type);
const sanitized = sanitizeStructureList(nextValues);
return buildSortableItems(sanitized);
});
},
[extractValues]
);
const handleGrant = useCallback(
(itemId, event) => {
const rect = containerRef.current?.getBoundingClientRect(); const rect = containerRef.current?.getBoundingClientRect();
const scrollY = getScrollY(); const scrollY = getScrollY();
const nativeEvent = event.nativeEvent || {}; const nativeEvent = event.nativeEvent || {};
@@ -132,16 +172,18 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
: scrollY; : scrollY;
const containerTop = rect ? rect.top + scrollY : fallbackY; const containerTop = rect ? rect.top + scrollY : fallbackY;
dragStateRef.current = { dragStateRef.current = {
itemId: item.id, itemId,
containerTop, containerTop,
}; };
setActiveItemId(item.id); setActiveItemId(itemId);
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
document.body.style.userSelect = "none"; document.body.style.userSelect = "none";
} }
}; },
[]
);
const handleMove = (event) => { const handleMove = useCallback((event) => {
const { itemId, containerTop } = dragStateRef.current; const { itemId, containerTop } = dragStateRef.current;
if (!itemId) return; if (!itemId) return;
const nativeEvent = event.nativeEvent || {}; const nativeEvent = event.nativeEvent || {};
@@ -154,7 +196,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
: scrollY; : scrollY;
const pointerY = absoluteY - containerTop; const pointerY = absoluteY - containerTop;
setItems((prev) => { setSortableItems((prev) => {
const currentIndex = prev.findIndex((it) => it.id === itemId); const currentIndex = prev.findIndex((it) => it.id === itemId);
if (currentIndex === -1) return prev; if (currentIndex === -1) return prev;
const targetIndex = clamp( const targetIndex = clamp(
@@ -165,11 +207,11 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
if (targetIndex === currentIndex) return prev; if (targetIndex === currentIndex) return prev;
return reorder(prev, currentIndex, targetIndex); return reorder(prev, currentIndex, targetIndex);
}); });
}; }, []);
const handleRelease = () => { const handleRelease = useCallback(() => {
setItems((prev) => setSortableItems((prev) =>
buildSortableItems(prev.map((item) => item.type || item.value)) buildSortableItems(extractValues(prev))
); );
dragStateRef.current = { dragStateRef.current = {
itemId: null, itemId: null,
@@ -179,87 +221,95 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
document.body.style.userSelect = ""; document.body.style.userSelect = "";
} }
}; }, [extractValues]);
if (!labeledItems.length) { const StructureRow = ({ item }) => {
const baseType = item?.type || item?.value;
const meta = getSegmentMeta(baseType);
const canRemove = meta?.optional === true;
return ( return (
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
<CreateLyricsHeader
title="Personnalise la structure de ta chanson"
subTitle="Sélectionne une structure pour commencer"
/>
<View style={styles.optionsContainer}>
{optionalSegments.map((segment) => {
const selected = selectedTypes.has(segment.type);
return (
<TouchableOpacity
key={segment.type}
activeOpacity={0.85}
onPress={() => toggleOptionalSegment(segment.type)}
style={[styles.optionItem, selected && styles.selectedItem]}
>
<Text style={styles.optionText}>{segment.label}</Text>
<Image
source={selected ? icons.check : icons.add}
style={[styles.optionIcon]}
/>
</TouchableOpacity>
);
})}
</View>
</View>
);
}
return (
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
<CreateLyricsHeader
title="Personnalise la structure de ta chanson"
subTitle="Glisse-dépose pour réorganiser"
/>
<View style={styles.listContainer} ref={containerRef}>
{labeledItems.map((item, index) => (
<View <View
key={item.id}
style={[ style={[
styles.itemContainer, styles.itemContainer,
activeItemId === item.id && styles.activeItem, activeItemId === item.id && styles.activeItem,
]} ]}
>
<View style={styles.row}>
<Text style={styles.rowText}>{item.label}</Text>
<View style={styles.rowActions}>
{canRemove && (
<TouchableOpacity
onPress={() => handleRemoveItem(item.id)}
style={styles.removeButton}
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
>
<Image source={icons.close} style={styles.removeIcon} />
</TouchableOpacity>
)}
<View
style={styles.handleWrapper}
onStartShouldSetResponder={() => true} onStartShouldSetResponder={() => true}
onResponderGrant={(event) => handleGrant(index, event)} onResponderGrant={(event) => handleGrant(item.id, event)}
onResponderMove={handleMove} onResponderMove={handleMove}
onResponderRelease={handleRelease} onResponderRelease={handleRelease}
onResponderTerminate={handleRelease} onResponderTerminate={handleRelease}
> >
<View style={styles.row}>
<Text style={styles.rowText}>{item.label}</Text>
<Image source={icons.dragDots} style={styles.handleIcon} /> <Image source={icons.dragDots} style={styles.handleIcon} />
</View> </View>
</View> </View>
</View>
</View>
);
};
return (
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
<CreateLyricsHeader
title="Personnalise la structure de ta chanson"
subTitle="Réorganise par glisser-déposer"
/>
<View style={{ flex: 1 }}>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.sectionTag}>
<Text style={styles.sectionTagText}>Structure actuelle</Text>
</View>
<View style={styles.listContainer} ref={containerRef}>
{labeledItems.map((item) => (
<StructureRow key={item.id} item={item} />
))} ))}
</View> </View>
<View style={[styles.sectionTag, styles.addSectionTag]}>
<Text style={styles.sectionTagText}>Éléments à ajouter</Text>
</View>
<View style={styles.optionsContainer}> <View style={styles.optionsContainer}>
{optionalSegments.map((segment) => { {optionalSegments.map((segment) => {
const selected = selectedTypes.has(segment.type); const count = segmentCounts[segment.type] || 0;
const selected = count > 0;
return ( return (
<TouchableOpacity <TouchableOpacity
key={segment.type} key={segment.type}
activeOpacity={0.85} activeOpacity={0.85}
onPress={() => toggleOptionalSegment(segment.type)} onPress={() => handleOptionalSegmentPress(segment.type)}
style={[styles.optionItem, selected && styles.selectedItem]} style={[styles.optionItem, selected && styles.selectedItem]}
> >
<Text style={styles.optionText}>{segment.label}</Text> <Text style={styles.rowText}>{segment.label}</Text>
<Image <View
source={selected ? icons.check : icons.add}
style={[ style={[
styles.optionIcon, styles.countBadge,
selected && styles.optionIconSelected, selected && styles.countBadgeActive,
]} ]}
/> >
<Text style={styles.countBadgeText}>{count}</Text>
</View>
</TouchableOpacity> </TouchableOpacity>
); );
})} })}
</View> </View>
</ScrollView>
</View>
</View> </View>
); );
}; };
@@ -267,8 +317,29 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
export default CustomizeSongStructure; export default CustomizeSongStructure;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scrollContent: {
paddingBottom: 24,
gap: 12,
},
sectionTag: {
alignSelf: "flex-start",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.12)",
marginTop: 12,
},
addSectionTag: {
marginTop: 24,
},
sectionTagText: {
fontSize: 12,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
letterSpacing: 1,
textTransform: "uppercase",
},
listContainer: { listContainer: {
flex: 1,
gap: 12, gap: 12,
}, },
itemContainer: { itemContainer: {
@@ -298,6 +369,25 @@ const styles = StyleSheet.create({
color: Palette.white, color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular, fontFamily: FONT_FAMILY.InterRegular,
}, },
rowActions: {
flexDirection: "row",
alignItems: "center",
},
removeButton: {
padding: 6,
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.08)",
marginRight: 8,
},
removeIcon: {
width: 16,
height: 16,
tintColor: Palette.white,
},
handleWrapper: {
padding: 6,
borderRadius: 999,
},
handleIcon: { handleIcon: {
width: 22, width: 22,
height: 22, height: 22,
@@ -305,8 +395,8 @@ const styles = StyleSheet.create({
cursor: "grab", cursor: "grab",
}, },
optionsContainer: { optionsContainer: {
marginTop: 16,
gap: 12, gap: 12,
marginTop: 12,
}, },
optionItem: { optionItem: {
height: ROW_HEIGHT, height: ROW_HEIGHT,
@@ -323,19 +413,25 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
}, },
optionText: {
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
},
optionIcon: {
width: 20,
height: 20,
tintColor: Palette.white,
},
selectedItem: { selectedItem: {
borderWidth: 1, borderWidth: 1,
borderColor: Palette.white, borderColor: Palette.white,
}, },
countBadge: {
minWidth: 28,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.08)",
alignItems: "center",
justifyContent: "center",
},
countBadgeActive: {
backgroundColor: Palette.transparentWhite,
},
countBadgeText: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
},
}); });
+13 -2
View File
@@ -36,6 +36,10 @@ export const ChooseCoverType = () => {
const { setIsLoading, setTooltip } = useMinuit(); const { setIsLoading, setTooltip } = useMinuit();
const projectId = selectedProject?.id || selectedProjectId || null; const projectId = selectedProject?.id || selectedProjectId || null;
const hasFinalCover = !!selectedProject?.coverUrl;
const hasGeneratedOptions = Array.isArray(selectedProject?.cover?.options)
? selectedProject.cover.options.length > 0
: false;
const hasArtistPreference = useMemo(() => { const hasArtistPreference = useMemo(() => {
if (currentUserData?.userName) return true; if (currentUserData?.userName) return true;
@@ -160,8 +164,12 @@ export const ChooseCoverType = () => {
// }, [ensureArtistPreference, pickUserImage]); // }, [ensureArtistPreference, pickUserImage]);
const handleGenerateCover = useCallback(() => { const handleGenerateCover = useCallback(() => {
if (hasFinalCover || hasGeneratedOptions) {
navigate(Routes.ValidateCover);
return;
}
ensureArtistPreference(() => navigate(Routes.PouchReady)); ensureArtistPreference(() => navigate(Routes.PouchReady));
}, [ensureArtistPreference]); }, [ensureArtistPreference, hasFinalCover, hasGeneratedOptions]);
const closeChoiceModal = useCallback(() => { const closeChoiceModal = useCallback(() => {
setChoiceVisible(false); setChoiceVisible(false);
@@ -296,8 +304,11 @@ export const ChooseCoverType = () => {
onPress={handlePickUserImage} onPress={handlePickUserImage}
/> */} /> */}
<GradientButton <GradientButton
title="Générer une pochette" title={
hasFinalCover ? "Pochette déjà validée" : "Générer une pochette"
}
onPress={handleGenerateCover} onPress={handleGenerateCover}
disabled={hasFinalCover}
/> />
</View> </View>
</View> </View>
+5 -6
View File
@@ -2,7 +2,6 @@ import { Image as ExpoImage } from "expo-image";
import React from "react"; import React from "react";
import { ActivityIndicator, Text, View } from "react-native"; import { ActivityIndicator, Text, View } from "react-native";
import { background } from "../../assets"; import { background } from "../../assets";
import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton"; import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader"; import MusicLandHeader from "../../components/MusicLandHeader";
import loaderMessages from "../../config/loaderMessages"; import loaderMessages from "../../config/loaderMessages";
@@ -21,9 +20,14 @@ const PhotoCover = () => {
? loaderMessages.photoCoverGenerationWeb ? loaderMessages.photoCoverGenerationWeb
: ""; : "";
const coverOptions = Array.isArray(selectedProject?.cover?.options)
? selectedProject.cover.options
: [];
const coverUrl = const coverUrl =
selectedProject?.cover?.result || selectedProject?.cover?.result ||
selectedProject?.cover?.generatedBackground || selectedProject?.cover?.generatedBackground ||
coverOptions?.[0]?.finalUrl ||
coverOptions?.[0]?.generatedUrl ||
null; null;
return ( return (
@@ -115,11 +119,6 @@ const PhotoCover = () => {
gap: 12, gap: 12,
}} }}
> >
<BorderGradientButton
onPress={() => navigate(Routes.PouchReady)}
title="Regénérer la pochette"
disabled={isGenerating}
/>
<GradientButton <GradientButton
title="Valider la pochette" title="Valider la pochette"
onPress={() => navigate(Routes.ValidateCover)} onPress={() => navigate(Routes.ValidateCover)}
+118 -78
View File
@@ -1,6 +1,5 @@
import { useIsFocused } from "@react-navigation/native";
import { Image as ExpoImage } from "expo-image"; import { Image as ExpoImage } from "expo-image";
import React, { useCallback, useEffect } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Text, View } from "react-native"; import { ActivityIndicator, Text, View } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { background, icons } from "../../assets"; import { background, icons } from "../../assets";
@@ -17,20 +16,48 @@ import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider"; import { useUser } from "../../providers/UserDataProvider";
import { gutters, Palette, Style } from "../../styles"; import { gutters, Palette, Style } from "../../styles";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import CustomInput from "../Writing/components/CustomInput";
const PouchReady = () => { const PouchReady = () => {
const { selectedProjectId, selectedProject } = useUser(); const { selectedProjectId, selectedProject, updateProjectData } = useUser();
const { setIsLoading } = useMinuit(); const { setIsLoading } = useMinuit();
const isGenerating = selectedProject?.coverStatus === "GENERATING"; const isGenerating = selectedProject?.coverStatus === "GENERATING";
const isFocused = useIsFocused(); const coverOptions = Array.isArray(selectedProject?.cover?.options)
? selectedProject.cover.options
: [];
const hasGeneratedOptions = coverOptions.length > 0;
const coverBackgroundMessage = isWeb const coverBackgroundMessage = isWeb
? loaderMessages.pouchReadyGenerationWeb ? loaderMessages.pouchReadyGenerationWeb
: ""; : "";
const generateCover = useCallback(async () => { const [coverStyle, setCoverStyle] = useState(
selectedProject?.coverStyle || ""
);
useEffect(() => {
setCoverStyle(selectedProject?.coverStyle || "");
}, [selectedProject?.coverStyle]);
const generateCover = useCallback(async (styleValue) => {
if (!selectedProjectId) return; if (!selectedProjectId) return;
if (hasGeneratedOptions) {
return;
}
const trimmedStyle = String(styleValue || "").trim();
if (!trimmedStyle) {
alert("Attention", "Merci de renseigner un style pour la pochette.", [
{ text: "OK" },
]);
return;
}
try { try {
await setIsLoading(true); await setIsLoading(true);
await updateProjectData(
{
coverStyle: trimmedStyle,
},
{ merge: true }
);
await tasksRef.add({ await tasksRef.add({
type: "cover", type: "cover",
projectId: selectedProjectId, projectId: selectedProjectId,
@@ -42,40 +69,49 @@ const PouchReady = () => {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [selectedProjectId, setIsLoading]); }, [hasGeneratedOptions, selectedProjectId, setIsLoading, updateProjectData]);
const requestCoverGeneration = useCallback(() => {
const trimmedStyle = (coverStyle || "").trim();
if (!trimmedStyle) {
alert("Attention", "Merci de renseigner un style pour la pochette.", [
{ text: "OK" },
]);
return;
}
alert("Attention", "Générer une pochette ?", [
{
text: "Annuler",
style: "cancel",
},
{
text: "Oui",
onPress: () => generateCover(trimmedStyle),
},
]);
}, [coverStyle, generateCover]);
const validateCover = () => { const validateCover = () => {
navigate(Routes.ValidateCover); navigate(Routes.ValidateCover);
}; };
useEffect(() => {
if (
isFocused &&
!selectedProject?.cover?.generatedBackground &&
selectedProject?.coverStatus !== "GENERATING"
) {
alert(
"Attention",
"Générer une pochette ?",
[
{
text: "Oui",
onPress: generateCover,
},
{
text: "Non",
},
],
{ cancelable: true }
);
}
}, [generateCover, isFocused, selectedProject]);
const coverPreviewUrl = const coverPreviewUrl =
selectedProject?.cover?.result || selectedProject?.cover?.result ||
selectedProject?.cover?.generatedBackground || selectedProject?.cover?.generatedBackground ||
null; null;
const displayOptions = hasGeneratedOptions
? coverOptions
: coverPreviewUrl
? [
{
id: "preview",
finalUrl: selectedProject?.cover?.result || null,
generatedUrl: coverPreviewUrl,
},
]
: [];
return ( return (
<Page backgroundImg={background.studioBG2} headerType="NONE"> <Page backgroundImg={background.studioBG2} headerType="NONE">
<MusicLandHeader onPressBack={goBack} progress={72} /> <MusicLandHeader onPressBack={goBack} progress={72} />
@@ -89,21 +125,54 @@ const PouchReady = () => {
// subTitle="Quen penses-tu ?" // subTitle="Quen penses-tu ?"
/> />
<View style={{ flex: 1, ...Style.containerCenter }}> <View style={{ flex: 1, ...Style.containerCenter }}>
<View style={{ width: "80%", position: "relative" }}> <View style={{ width: "80%", gap: 24 }}>
{coverPreviewUrl && !isGenerating ? ( <View
style={{
width: "100%",
flexDirection: isWeb ? "row" : "column",
gap: 16,
justifyContent: "center",
flexWrap: "wrap",
}}
>
{displayOptions.length > 0 ? (
displayOptions.map((option, index) => {
const optionUri = option?.finalUrl || option?.generatedUrl || "";
if (!optionUri) return null;
return (
<View
key={option?.id || index}
style={{
width: isWeb ? 280 : "100%",
alignItems: "center",
gap: 8,
}}
>
<ExpoImage <ExpoImage
source={{ uri: coverPreviewUrl }} source={{ uri: optionUri }}
cachePolicy="memory-disk" cachePolicy="memory-disk"
priority="high" priority="high"
contentFit="cover" contentFit="cover"
transition={120} transition={120}
style={{ style={{
width: isWeb ? 300 : "100%", width: "100%",
height: 300, height: 260,
borderRadius: 20, borderRadius: 20,
alignSelf: "center",
}} }}
/> />
{hasGeneratedOptions && (
<Text
style={{
color: Palette.white,
opacity: 0.7,
}}
>
Pochette {index + 1}
</Text>
)}
</View>
);
})
) : ( ) : (
<View <View
style={{ style={{
@@ -147,47 +216,16 @@ const PouchReady = () => {
)} )}
</View> </View>
)} )}
{/*<View*/} </View>
{/* style={{*/} <CustomInput
{/* position: "absolute",*/} label="Style de la pochette"
{/* alignSelf: "center",*/} placeholder="Exemple : Collage rétro futuriste lumineux"
{/* alignItems: "center",*/} value={coverStyle}
{/* top: 10,*/} setValue={setCoverStyle}
{/* }}*/} multiline={false}
{/*>*/} height={55}
{/* <Text*/} maxLength={120}
{/* 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> </View>
</View> </View>
@@ -199,12 +237,14 @@ const PouchReady = () => {
gap: 12, gap: 12,
}} }}
> >
{!hasGeneratedOptions && (
<BorderGradientButton <BorderGradientButton
title={isGenerating ? "Génération en cours..." : "Regénérer"} title={isGenerating ? "Génération en cours..." : "Générer la pochette"}
icon={icons.stars} icon={icons.stars}
onPress={generateCover} onPress={requestCoverGeneration}
disabled={isGenerating} disabled={isGenerating}
/> />
)}
<GradientButton <GradientButton
title={isGenerating ? "Veuillez patienter..." : "Valider"} title={isGenerating ? "Veuillez patienter..." : "Valider"}
disabled={isGenerating || !coverPreviewUrl} disabled={isGenerating || !coverPreviewUrl}
+181 -68
View File
@@ -1,8 +1,8 @@
import React from "react"; import { Image as ExpoImage } from "expo-image";
import { Image, View } from "react-native"; import React, { useCallback, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { background, img } from "../../assets"; import { background } from "../../assets";
import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton"; import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader"; import MusicLandHeader from "../../components/MusicLandHeader";
import { isWeb } from "../../hooks/useLayoutType"; import { isWeb } from "../../hooks/useLayoutType";
@@ -10,14 +10,34 @@ import Page from "../../layouts/Page";
import { Routes } from "../../navigation"; import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService"; import { goBack, navigate } from "../../navigation/NavigationService";
import { useUserData } from "../../providers/UserDataProvider"; import { useUserData } from "../../providers/UserDataProvider";
import { gutters, Style } from "../../styles"; import { gutters, Palette, Style } from "../../styles";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
const ValidateCover = () => { const ValidateCover = () => {
const { selectedProject, updateProjectData } = useUserData(); const { selectedProject, updateProjectData } = useUserData();
const { setIsLoading } = useMinuit(); const { setIsLoading } = useMinuit();
const coverOptions = useMemo(() => {
if (!Array.isArray(selectedProject?.cover?.options)) {
return [];
}
return selectedProject.cover.options.filter((option) => option);
}, [selectedProject]);
const selectedOptionId = selectedProject?.cover?.selectedOptionId || null;
const selectedOption = useMemo(() => {
if (!coverOptions.length) {
return null;
}
const found = coverOptions.find(
(option) => option?.id === selectedOptionId
);
return found || coverOptions[0] || null;
}, [coverOptions, selectedOptionId]);
const isMultipleOptions = coverOptions.length > 1;
const [isSelecting, setIsSelecting] = useState(false);
async function onStartAgain() { async function onStartAgain() {
try { try {
await setIsLoading(true); await setIsLoading(true);
@@ -32,11 +52,59 @@ const ValidateCover = () => {
await setIsLoading(false); await setIsLoading(false);
} }
} }
const handleSelectOption = useCallback(
async (option) => {
if (!option || option?.id === selectedOptionId || isSelecting) {
return;
}
const existingCover = selectedProject?.cover || {};
const finalUrl = option.finalUrl || option.generatedUrl || null;
setIsSelecting(true);
try {
await updateProjectData({
cover: {
...existingCover,
options: coverOptions,
selectedOptionId: option.id,
result: finalUrl,
generatedBackground: option.generatedUrl || option.finalUrl || null,
},
});
} catch (e) {
console.log("ValidateCover: unable to select cover", e?.message);
} finally {
setIsSelecting(false);
}
},
[
coverOptions,
isSelecting,
selectedOptionId,
selectedProject,
updateProjectData,
]
);
async function onValidatePicture() { async function onValidatePicture() {
if (!selectedOption) {
return;
}
try { try {
await setIsLoading(true); await setIsLoading(true);
const existingCover = selectedProject?.cover || {};
const finalUrl =
selectedOption.finalUrl || selectedOption.generatedUrl || null;
await updateProjectData({ await updateProjectData({
coverUrl: selectedProject?.cover?.result, cover: {
...existingCover,
options: coverOptions,
selectedOptionId: selectedOption.id,
result: finalUrl,
generatedBackground:
selectedOption.generatedUrl || selectedOption.finalUrl || null,
},
coverUrl: finalUrl,
}); });
navigate(Routes.Home); navigate(Routes.Home);
} catch (e) { } catch (e) {
@@ -45,80 +113,124 @@ const ValidateCover = () => {
await setIsLoading(false); await setIsLoading(false);
} }
} }
return ( return (
<Page backgroundImg={background.studioBG2} headerType="NONE"> <Page backgroundImg={background.studioBG2} headerType="NONE">
<MusicLandHeader onPressBack={goBack} progress={90} /> <MusicLandHeader onPressBack={goBack} progress={90} />
<View style={{ flex: 1, marginTop: 16 }}> <View style={{ flex: 1, marginTop: 16 }}>
<CreateLyricsHeader <CreateLyricsHeader
title="Ta pochette est prête!" title="Ta pochette est prête!"
subTitle="Quen penses-tu ?" subTitle="Choisis ta version préférée."
/> />
<View style={{ flex: 1, ...Style.containerCenter }}> <View style={{ flex: 1, ...Style.containerCenter }}>
<View style={{ width: "80%", position: "relative" }}> <View
<Image
source={img.placeholder}
style={{ style={{
width: isWeb ? 300 : "100%", width: "90%",
height: 300, gap: 16,
borderRadius: 20, flexDirection: isMultipleOptions ? "row" : "column",
transform: [{ rotateY: "180deg" }], flexWrap: isMultipleOptions ? "wrap" : "nowrap",
alignSelf: "center", justifyContent: "center",
alignItems: isMultipleOptions ? "stretch" : "center",
}} }}
>
{coverOptions.map((option, index) => {
const isSelected = option?.id === selectedOption?.id;
const imageSource =
option?.finalUrl || option?.generatedUrl || null;
if (!imageSource) {
return null;
}
return (
<Pressable
key={option?.id || index}
onPress={() => handleSelectOption(option)}
disabled={isSelecting}
style={{
borderRadius: 20,
borderWidth: isSelected ? 2 : 1,
borderColor: isSelected
? Palette.primary
: Palette.ultraLightWhite,
overflow: "hidden",
width: isMultipleOptions
? isWeb
? 280
: "48%"
: isWeb
? 300
: "90%",
maxWidth: isWeb ? 320 : "100%",
position: "relative",
}}
>
<ExpoImage
source={{ uri: imageSource }}
cachePolicy="memory-disk"
contentFit="cover"
transition={120}
style={{ width: "100%", aspectRatio: 1 }}
/> />
<View <View
style={{ position: "absolute", width: "100%", height: "100%" }}
>
<Image
source={{ uri: selectedProject?.cover?.result }}
style={{ style={{
width: isWeb ? 300 : "100%", position: "absolute",
height: "100%", top: 12,
borderRadius: 20, right: 12,
alignSelf: "center", backgroundColor: Palette.transparentBlack,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 12,
}} }}
/> >
<Text
style={{
color: Palette.white,
fontWeight: "600",
fontSize: 12,
}}
>
{`Option ${index + 1}`}
</Text>
</View> </View>
{/*<View*/} {isSelected && (
{/* style={{*/} <View
{/* position: "absolute",*/} style={{
{/* alignSelf: "center",*/} position: "absolute",
{/* alignItems: "center",*/} bottom: 12,
{/* top: 10,*/} left: 12,
{/* }}*/} backgroundColor: Palette.primary,
{/*>*/} paddingHorizontal: 12,
{/* <Text*/} paddingVertical: 6,
{/* style={{*/} borderRadius: 12,
{/* fontSize: 22,*/} }}
{/* color: Palette.white,*/} >
{/* fontFamily: FONT_FAMILY.InterSemiBold,*/} <Text
{/* }}*/} style={{
{/* >*/} color: Palette.white,
{/* Lust for Life*/} fontWeight: "600",
{/* </Text>*/} fontSize: 12,
{/* <Text*/} }}
{/* style={{*/} >
{/* fontSize: 14,*/} Sélectionnée
{/* color: Palette.gray,*/} </Text>
{/* fontFamily: FONT_FAMILY.InterRegular,*/} </View>
{/* }}*/} )}
{/* >*/} </Pressable>
{/* Lana del Rey*/} );
{/* </Text>*/} })}
{/*</View>*/} {!coverOptions.length && (
{/*<View*/} <Text
{/* style={{*/} style={{
{/* position: "absolute",*/} color: Palette.white,
{/* alignItems: "center",*/} textAlign: "center",
{/* bottom: 10,*/} opacity: 0.8,
{/* width: "100%",*/} }}
{/* }}*/} >
{/*>*/} Les options de pochette seront disponibles à la fin de la
{/* <Image*/} génération.
{/* source={icons.musicLandLogo}*/} </Text>
{/* style={{ width: "100%", height: 30 }}*/} )}
{/* resizeMode="contain"*/}
{/* />*/}
{/*</View>*/}
</View> </View>
</View> </View>
</View> </View>
@@ -130,13 +242,14 @@ const ValidateCover = () => {
gap: 12, gap: 12,
}} }}
> >
<BorderGradientButton {/* <BorderGradientButton
title="Recommencer la création" title="Recommencer la création"
onPress={onStartAgain} onPress={onStartAgain}
/> /> */}
<GradientButton <GradientButton
title="Valider la pochette" title="Valider la pochette"
onPress={onValidatePicture} onPress={onValidatePicture}
disabled={!selectedOption || isSelecting}
/> />
</View> </View>
</Page> </Page>
+9
View File
@@ -49,6 +49,9 @@ const getStageLockState = (key, metadata) => {
case "songwriter": case "songwriter":
return metadata.hasSongUrl; return metadata.hasSongUrl;
case "beatmaker": case "beatmaker":
if (metadata.hasCover) {
return true;
}
return metadata.lyricsCount <= 0; return metadata.lyricsCount <= 0;
case "director": case "director":
return !metadata.hasCover; return !metadata.hasCover;
@@ -78,6 +81,9 @@ const getStageDescription = (key, metadata) => {
} }
return "Modifier les paroles créées"; return "Modifier les paroles créées";
case "beatmaker": case "beatmaker":
if (metadata.hasCover) {
return "Impossible de modifier la cover ou la production musicale";
}
if (!hasLyrics) { if (!hasLyrics) {
return "Écrivez vos paroles pour débloquer la musique"; return "Écrivez vos paroles pour débloquer la musique";
} }
@@ -117,6 +123,9 @@ const getStageLockedDescription = (key, metadata) => {
? "Impossible de modifier les paroles" ? "Impossible de modifier les paroles"
: undefined; : undefined;
case "beatmaker": case "beatmaker":
if (metadata.hasCover) {
return "Impossible de modifier la cover ou la production musicale";
}
return metadata.lyricsCount > 0 return metadata.lyricsCount > 0
? undefined ? undefined
: "Créez vos paroles pour débloquer le studio"; : "Créez vos paroles pour débloquer le studio";