diff --git a/functions/helpers/prompts.js b/functions/helpers/prompts.js index ba7f6d0..1d7a7b8 100644 --- a/functions/helpers/prompts.js +++ b/functions/helpers/prompts.js @@ -1,5 +1,10 @@ exports.generatePicturePrompt = (project = {}) => { - const { title = "", lyrics: lyricsRaw, musicConfig = {} } = project || {}; + const { + title = "", + lyrics: lyricsRaw, + musicConfig = {}, + coverStyle: coverStyleRaw = "", + } = project || {}; const { genres = [], @@ -44,28 +49,33 @@ exports.generatePicturePrompt = (project = {}) => { ? "Palette vive et contrastée (magenta, cyan, jaune, bleu électrique)" : "Palette harmonieuse et douce (bleu nuit, violet, corail, or pâle)"; + const coverStyle = String(coverStyleRaw || "").trim(); const tagsLine = tags.join(" ; ") || "Non spécifié"; const lyricsLine = lyricsSample || "Pas d'extraits fournis"; + const styleLine = coverStyle + ? `${coverStyle}. ${paletteHint}` + : `${paletteHint}. Style libre, artistique et lumineux.`; - const prompt = ` - - Créer une pochette lumineuse. + const prompt = ` + Créer une pochette d'album en adéquation avec les paroles de la musique ${title || "Sans titre"} ${tagsLine} ${lyricsLine} - Image carrée 1024x1024 pixels. - - Remplis toute la surface sans laisser de bordures ou zones vides. + Image carrée 1024x1024 pixels, résolution haute. + + La composition doit être dynamique et remplir toute la surface, sans laisser de bordures ni de zones vides. + 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é. + 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. - Texte ou typographie. - Personnes ou visages. - Fond blanc ou bordures. - Autre logo ou filigrane que celui prévu en bas à droite. + Personnes, visages ou silhouettes reconnaissables. + Fonds blancs ou bordures délimitant l'image. + Tout autre logo, filigrane ou texte promotionnel que le titre, l'artiste et la zone réservée pour "MusicLand Production". + Texte illisible ou trop petit. `.trim(); diff --git a/functions/src/cover.js b/functions/src/cover.js index 44cdb65..254aa7c 100644 --- a/functions/src/cover.js +++ b/functions/src/cover.js @@ -75,49 +75,65 @@ async function performCoverGeneration(project) { projectId: project.id, promptPreview: String(prompt).slice(0, 160), }); - let coverUrl = ""; - const timestamp = Date.now(); - try { - // Utiliser une taille fixe de 1024 pixels - coverUrl = await generateImageV2( - prompt, - 1024, - `users/${project.userId}/projects/${project.id}/generated-${timestamp}.png`, - ); - console.log("coverUrl", coverUrl); - } catch (e) { - logger.error("❌ [Cover] generateImageV2 failed", { - projectId: project.id, - error: e?.message || String(e), + const baseTimestamp = 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 { + generatedUrl = await generateImageV2(prompt, 1024, generatedPath); + logger.info("🎨 [Cover] Candidate generated", { + projectId: project.id, + candidateIndex: index, + }); + } catch (e) { + logger.error("❌ [Cover] generateImageV2 failed", { + projectId: project.id, + candidateIndex: index, + error: e?.message || String(e), + }); + throw e; + } + + if (!generatedUrl) { + throw new Error("Génération d'image échouée (URL vide)"); + } + + let finalCoverUrl = generatedUrl; + try { + const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`; + finalCoverUrl = await buildCoverWithLogo(generatedUrl, stampedPath); + } catch (e) { + logger.error("❌ [Cover] Logo overlay failed", { + projectId: project.id, + candidateIndex: index, + error: e?.message || String(e), + }); + } + + options.push({ + id: uniqueSuffix, + generatedUrl, + finalUrl: finalCoverUrl, }); - throw e; - } - if (!coverUrl) { - throw new Error("Génération d'image échouée (URL vide)"); } - let finalCoverUrl = ""; - try { - const stampedPath = `users/${project.userId}/projects/${project.id}/cover-${timestamp}-stamped.png`; - finalCoverUrl = await buildCoverWithLogo(coverUrl, stampedPath); - } catch (e) { - logger.error("❌ [Cover] Logo overlay failed", { - projectId: project.id, - error: e?.message || String(e), - }); - // En cas d'échec, on retient au moins la cover générée - finalCoverUrl = coverUrl; - } + const [firstOption] = options; - // Mettre à jour le projet avec l'URL et le statut await db .collection("projects") .doc(project.id) .set( { cover: { - generatedBackground: coverUrl, - result: finalCoverUrl, + generatedBackground: firstOption?.generatedUrl || null, + result: firstOption?.finalUrl || null, + selectedOptionId: firstOption?.id || null, + options, }, coverStatus: "GENERATED", updatedAt: admin.firestore.FieldValue.serverTimestamp(), @@ -127,11 +143,11 @@ async function performCoverGeneration(project) { logger.info("✅ [Cover] Saved", { projectId: project.id, - coverUrl, - finalCoverUrl, + optionsCount: options.length, ms: Date.now() - t0, }); - return finalCoverUrl; + + return firstOption?.finalUrl || null; } // Firestore trigger: création d'une tâche de génération de cover @@ -185,12 +201,34 @@ exports.onTaskCreateGenerateCover = onDocumentCreated( } project.id = projectId; + const existingOptionsCount = Array.isArray(project?.cover?.options) + ? project.cover.options.length + : 0; + logger.info("🔎 [Task] Project loaded", { projectId, hasGeneratedBackground: !!project?.cover?.generatedBackground, 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") { logger.info("🎨 [Task] Start cover generation", { projectId }); coverUrl = await performCoverGeneration(project); diff --git a/src/screens/Studio/Studio.js b/src/screens/Studio/Studio.js index d135990..8b1ca2e 100644 --- a/src/screens/Studio/Studio.js +++ b/src/screens/Studio/Studio.js @@ -125,16 +125,18 @@ const Studio = () => { navigate(Routes.SongReady); }} /> - { - selectProject(selected.id); - navigate(Routes.PouchReady); - }} - /> + {!selected?.coverUrl && ( + { + selectProject(selected.id); + navigate(Routes.PouchReady); + }} + /> + )} )} diff --git a/src/screens/Writing/CustomizeSongStructure.web.js b/src/screens/Writing/CustomizeSongStructure.web.js index 508b10e..beccabd 100644 --- a/src/screens/Writing/CustomizeSongStructure.web.js +++ b/src/screens/Writing/CustomizeSongStructure.web.js @@ -1,9 +1,23 @@ -import React from "react"; -import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Image, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; import { icons } from "../../assets"; import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; import { + arraysAreSame, formatStructureLabel, getSegmentMeta, 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 buildSortableItems = (values = []) => { - const counters = {}; - return sanitizeStructureList(values).map((segment, index) => { - counters[segment] = (counters[segment] || 0) + 1; - return { - id: `${segment}-${index}-${randomSuffix()}`, - type: segment, - value: segment, - }; - }); -}; +const createSortableItem = (type, index = 0) => ({ + id: `${type}-${index}-${randomSuffix()}`, + type, + value: type, +}); + +const buildSortableItems = (values = []) => + sanitizeStructureList(values).map((type, index) => + createSortableItem(type, index) + ); const reorder = (list, from, to) => { if (from === to) return list; @@ -40,108 +53,137 @@ const reorder = (list, from, to) => { const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { - const sanitized = React.useMemo( + const sanitizedBase = useMemo( () => sanitizeStructureList(baseStructure), [baseStructure] ); - const [items, setItems] = React.useState(() => buildSortableItems(sanitized)); - const [activeItemId, setActiveItemId] = React.useState(null); - const containerRef = React.useRef(null); - const dragStateRef = React.useRef({ + const [sortableItems, setSortableItems] = useState(() => + buildSortableItems(sanitizedBase) + ); + const [activeItemId, setActiveItemId] = useState(null); + const containerRef = useRef(null); + const dragStateRef = useRef({ itemId: null, containerTop: 0, }); - React.useEffect(() => { - console.debug("[CustomizeSongStructure.web] sanitized", sanitized); - setItems(buildSortableItems(sanitized)); - dragStateRef.current = { itemId: null, containerTop: 0 }; - setActiveItemId(null); - }, [sanitized]); + const extractValues = useCallback( + (items) => + (items || []).map((item) => item?.type || item?.value).filter(Boolean), + [] + ); - React.useEffect(() => { - console.debug("[CustomizeSongStructure.web] items", items); - onChange?.( - items.map((item) => (item?.type || item?.value)).filter(Boolean) - ); - }, [items, onChange]); + useEffect(() => { + setSortableItems((prev) => { + const currentValues = extractValues(prev); + if (arraysAreSame(currentValues, sanitizedBase)) { + return prev; + } + return buildSortableItems(sanitizedBase); + }); + }, [sanitizedBase, extractValues]); - const labeledItems = React.useMemo(() => { + useEffect(() => { + onChange?.(extractValues(sortableItems)); + }, [sortableItems, extractValues, onChange]); + + const labeledItems = useMemo(() => { const counts = {}; - return items.map((item) => { - const type = item?.type || item?.value; - counts[type] = (counts[type] || 0) + 1; + return sortableItems.map((item) => { + const baseType = item.type || item.value; + const nextCount = (counts[baseType] || 0) + 1; + counts[baseType] = nextCount; return { ...item, - type, - value: type, - label: formatStructureLabel(type, counts[type]), + type: baseType, + value: baseType, + label: formatStructureLabel(baseType, nextCount), }; }); - }, [items]); + }, [sortableItems]); - const selectedTypes = React.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( + const optionalSegments = useMemo( () => OPTIONAL_STRUCTURE_SEGMENTS.map((type) => { const meta = getSegmentMeta(type); - return { type, label: meta?.label }; + return { type, label: meta?.label || formatStructureLabel(type, 1) }; }), [] ); - const toggleOptionalSegment = React.useCallback((type) => { - setItems((prev) => { - const currentValues = prev.map((item) => item.type || item.value); - if (currentValues.includes(type)) { - const filtered = currentValues.filter((value) => value !== type); - return buildSortableItems(filtered); - } - const meta = getSegmentMeta(type); - let nextValues = [...currentValues]; - if (meta?.exclusiveGroup) { - nextValues = nextValues.filter((value) => { - const itemMeta = getSegmentMeta(value); - return itemMeta?.exclusiveGroup !== meta.exclusiveGroup; - }); - } - nextValues.push(type); - return buildSortableItems(nextValues); + const segmentCounts = useMemo(() => { + const counts = {}; + extractValues(sortableItems).forEach((type) => { + counts[type] = (counts[type] || 0) + 1; }); - }, []); + return counts; + }, [sortableItems, extractValues]); - const handleGrant = (index, event) => { - const item = labeledItems[index]; - if (!item) return; - const rect = containerRef.current?.getBoundingClientRect(); - const scrollY = getScrollY(); - const nativeEvent = event.nativeEvent || {}; - const fallbackY = - typeof nativeEvent.pageY === "number" - ? nativeEvent.pageY - : typeof nativeEvent.clientY === "number" - ? nativeEvent.clientY + scrollY - : scrollY; - const containerTop = rect ? rect.top + scrollY : fallbackY; - dragStateRef.current = { - itemId: item.id, - containerTop, - }; - setActiveItemId(item.id); - if (typeof document !== "undefined") { - document.body.style.userSelect = "none"; - } - }; + const handleRemoveItem = useCallback( + (itemId) => { + setSortableItems((prev) => { + const remaining = prev.filter((item) => item.id !== itemId); + const sanitized = sanitizeStructureList(extractValues(remaining)); + return buildSortableItems(sanitized); + }); + }, + [extractValues] + ); - const handleMove = (event) => { + const handleOptionalSegmentPress = useCallback( + (type) => { + const meta = getSegmentMeta(type); + setSortableItems((prev) => { + const currentValues = extractValues(prev); + let nextValues = [...currentValues]; + + if (meta?.exclusiveGroup) { + nextValues = nextValues.filter((value) => { + const itemMeta = getSegmentMeta(value); + return itemMeta?.exclusiveGroup !== meta.exclusiveGroup; + }); + } + + const allowsMultiple = meta?.allowMultiple !== false; + if (!allowsMultiple && nextValues.includes(type)) { + 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 scrollY = getScrollY(); + const nativeEvent = event.nativeEvent || {}; + const fallbackY = + typeof nativeEvent.pageY === "number" + ? nativeEvent.pageY + : typeof nativeEvent.clientY === "number" + ? nativeEvent.clientY + scrollY + : scrollY; + const containerTop = rect ? rect.top + scrollY : fallbackY; + dragStateRef.current = { + itemId, + containerTop, + }; + setActiveItemId(itemId); + if (typeof document !== "undefined") { + document.body.style.userSelect = "none"; + } + }, + [] + ); + + const handleMove = useCallback((event) => { const { itemId, containerTop } = dragStateRef.current; if (!itemId) return; const nativeEvent = event.nativeEvent || {}; @@ -154,7 +196,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { : scrollY; const pointerY = absoluteY - containerTop; - setItems((prev) => { + setSortableItems((prev) => { const currentIndex = prev.findIndex((it) => it.id === itemId); if (currentIndex === -1) return prev; const targetIndex = clamp( @@ -165,11 +207,11 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { if (targetIndex === currentIndex) return prev; return reorder(prev, currentIndex, targetIndex); }); - }; + }, []); - const handleRelease = () => { - setItems((prev) => - buildSortableItems(prev.map((item) => item.type || item.value)) + const handleRelease = useCallback(() => { + setSortableItems((prev) => + buildSortableItems(extractValues(prev)) ); dragStateRef.current = { itemId: null, @@ -179,86 +221,94 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { if (typeof document !== "undefined") { 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 ( - - - - {optionalSegments.map((segment) => { - const selected = selectedTypes.has(segment.type); - return ( + + + {item.label} + + {canRemove && ( toggleOptionalSegment(segment.type)} - style={[styles.optionItem, selected && styles.selectedItem]} + onPress={() => handleRemoveItem(item.id)} + style={styles.removeButton} + hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }} > - {segment.label} - + - ); - })} + )} + true} + onResponderGrant={(event) => handleGrant(item.id, event)} + onResponderMove={handleMove} + onResponderRelease={handleRelease} + onResponderTerminate={handleRelease} + > + + + ); - } + }; return ( - - {labeledItems.map((item, index) => ( - true} - onResponderGrant={(event) => handleGrant(index, event)} - onResponderMove={handleMove} - onResponderRelease={handleRelease} - onResponderTerminate={handleRelease} - > - - {item.label} - - + + + + Structure actuelle - ))} - - - {optionalSegments.map((segment) => { - const selected = selectedTypes.has(segment.type); - return ( - toggleOptionalSegment(segment.type)} - style={[styles.optionItem, selected && styles.selectedItem]} - > - {segment.label} - - - ); - })} + + {labeledItems.map((item) => ( + + ))} + + + Éléments à ajouter + + + {optionalSegments.map((segment) => { + const count = segmentCounts[segment.type] || 0; + const selected = count > 0; + return ( + handleOptionalSegmentPress(segment.type)} + style={[styles.optionItem, selected && styles.selectedItem]} + > + {segment.label} + + {count} + + + ); + })} + + ); @@ -267,8 +317,29 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { export default CustomizeSongStructure; 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: { - flex: 1, gap: 12, }, itemContainer: { @@ -298,6 +369,25 @@ const styles = StyleSheet.create({ color: Palette.white, 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: { width: 22, height: 22, @@ -305,8 +395,8 @@ const styles = StyleSheet.create({ cursor: "grab", }, optionsContainer: { - marginTop: 16, gap: 12, + marginTop: 12, }, optionItem: { height: ROW_HEIGHT, @@ -323,19 +413,25 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "space-between", }, - optionText: { - fontSize: 16, - color: Palette.white, - fontFamily: FONT_FAMILY.InterRegular, - }, - optionIcon: { - width: 20, - height: 20, - tintColor: Palette.white, - }, - selectedItem: { borderWidth: 1, 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, + }, }); diff --git a/src/screens/cover/ChooseCoverType.js b/src/screens/cover/ChooseCoverType.js index 70a7502..b37e7ce 100644 --- a/src/screens/cover/ChooseCoverType.js +++ b/src/screens/cover/ChooseCoverType.js @@ -36,6 +36,10 @@ export const ChooseCoverType = () => { const { setIsLoading, setTooltip } = useMinuit(); 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(() => { if (currentUserData?.userName) return true; @@ -160,8 +164,12 @@ export const ChooseCoverType = () => { // }, [ensureArtistPreference, pickUserImage]); const handleGenerateCover = useCallback(() => { + if (hasFinalCover || hasGeneratedOptions) { + navigate(Routes.ValidateCover); + return; + } ensureArtistPreference(() => navigate(Routes.PouchReady)); - }, [ensureArtistPreference]); + }, [ensureArtistPreference, hasFinalCover, hasGeneratedOptions]); const closeChoiceModal = useCallback(() => { setChoiceVisible(false); @@ -296,8 +304,11 @@ export const ChooseCoverType = () => { onPress={handlePickUserImage} /> */} diff --git a/src/screens/cover/PhotoCover.js b/src/screens/cover/PhotoCover.js index a38ce45..399870c 100644 --- a/src/screens/cover/PhotoCover.js +++ b/src/screens/cover/PhotoCover.js @@ -2,7 +2,6 @@ import { Image as ExpoImage } from "expo-image"; import React from "react"; import { ActivityIndicator, Text, View } from "react-native"; import { background } from "../../assets"; -import BorderGradientButton from "../../components/BorderGradientButton"; import GradientButton from "../../components/GradientButton"; import MusicLandHeader from "../../components/MusicLandHeader"; import loaderMessages from "../../config/loaderMessages"; @@ -21,9 +20,14 @@ const PhotoCover = () => { ? loaderMessages.photoCoverGenerationWeb : ""; + const coverOptions = Array.isArray(selectedProject?.cover?.options) + ? selectedProject.cover.options + : []; const coverUrl = selectedProject?.cover?.result || selectedProject?.cover?.generatedBackground || + coverOptions?.[0]?.finalUrl || + coverOptions?.[0]?.generatedUrl || null; return ( @@ -115,11 +119,6 @@ const PhotoCover = () => { gap: 12, }} > - navigate(Routes.PouchReady)} - title="Regénérer la pochette" - disabled={isGenerating} - /> navigate(Routes.ValidateCover)} diff --git a/src/screens/cover/PouchReady.js b/src/screens/cover/PouchReady.js index 358b687..6c4022e 100644 --- a/src/screens/cover/PouchReady.js +++ b/src/screens/cover/PouchReady.js @@ -1,6 +1,5 @@ -import { useIsFocused } from "@react-navigation/native"; 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 useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; import { background, icons } from "../../assets"; @@ -17,20 +16,48 @@ import { goBack, navigate } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { gutters, Palette, Style } from "../../styles"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; +import CustomInput from "../Writing/components/CustomInput"; const PouchReady = () => { - const { selectedProjectId, selectedProject } = useUser(); + const { selectedProjectId, selectedProject, updateProjectData } = useUser(); const { setIsLoading } = useMinuit(); 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 ? 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 (hasGeneratedOptions) { + return; + } + const trimmedStyle = String(styleValue || "").trim(); + if (!trimmedStyle) { + alert("Attention", "Merci de renseigner un style pour la pochette.", [ + { text: "OK" }, + ]); + return; + } try { await setIsLoading(true); + await updateProjectData( + { + coverStyle: trimmedStyle, + }, + { merge: true } + ); await tasksRef.add({ type: "cover", projectId: selectedProjectId, @@ -42,40 +69,49 @@ const PouchReady = () => { } finally { 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 = () => { 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 = selectedProject?.cover?.result || selectedProject?.cover?.generatedBackground || null; + const displayOptions = hasGeneratedOptions + ? coverOptions + : coverPreviewUrl + ? [ + { + id: "preview", + finalUrl: selectedProject?.cover?.result || null, + generatedUrl: coverPreviewUrl, + }, + ] + : []; + return ( @@ -89,37 +125,82 @@ const PouchReady = () => { // subTitle="Qu’en penses-tu ?" /> - - {coverPreviewUrl && !isGenerating ? ( - - ) : ( - - {isGenerating && ( - - - {isWeb ? ( - coverBackgroundMessage ? ( + + + {displayOptions.length > 0 ? ( + displayOptions.map((option, index) => { + const optionUri = option?.finalUrl || option?.generatedUrl || ""; + if (!optionUri) return null; + return ( + + + {hasGeneratedOptions && ( + + Pochette {index + 1} + + )} + + ); + }) + ) : ( + + {isGenerating && ( + + + {isWeb ? ( + coverBackgroundMessage ? ( + + {coverBackgroundMessage} + + ) : null + ) : ( { opacity: 0.9, }} > - {coverBackgroundMessage} + Génération en cours. - ) : null - ) : ( - - Génération en cours. - - )} - - )} - - )} - {/**/} - {/* */} - {/* {title || "Titre"}*/} - {/* */} - {/* */} - {/* MusicLand*/} - {/* */} - {/**/} - {/**/} - {/* */} - {/**/} + )} + + )} + + )} + + @@ -199,12 +237,14 @@ const PouchReady = () => { gap: 12, }} > - + {!hasGeneratedOptions && ( + + )} { const { selectedProject, updateProjectData } = useUserData(); - 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() { try { await setIsLoading(true); @@ -32,11 +52,59 @@ const ValidateCover = () => { 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() { + if (!selectedOption) { + return; + } try { await setIsLoading(true); + const existingCover = selectedProject?.cover || {}; + const finalUrl = + selectedOption.finalUrl || selectedOption.generatedUrl || null; 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); } catch (e) { @@ -45,80 +113,124 @@ const ValidateCover = () => { await setIsLoading(false); } } + return ( - - - - + {coverOptions.map((option, index) => { + const isSelected = option?.id === selectedOption?.id; + const imageSource = + option?.finalUrl || option?.generatedUrl || null; + + if (!imageSource) { + return null; + } + + return ( + 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", + }} + > + + + + {`Option ${index + 1}`} + + + {isSelected && ( + + + Sélectionnée + + + )} + + ); + })} + {!coverOptions.length && ( + - - {/**/} - {/* */} - {/* Lust for Life*/} - {/* */} - {/* */} - {/* Lana del Rey*/} - {/* */} - {/**/} - {/**/} - {/* */} - {/**/} + > + Les options de pochette seront disponibles à la fin de la + génération. + + )} @@ -130,13 +242,14 @@ const ValidateCover = () => { gap: 12, }} > - + /> */} diff --git a/src/utils/projectStages.js b/src/utils/projectStages.js index 2ac3d59..f14b478 100644 --- a/src/utils/projectStages.js +++ b/src/utils/projectStages.js @@ -49,6 +49,9 @@ const getStageLockState = (key, metadata) => { case "songwriter": return metadata.hasSongUrl; case "beatmaker": + if (metadata.hasCover) { + return true; + } return metadata.lyricsCount <= 0; case "director": return !metadata.hasCover; @@ -78,6 +81,9 @@ const getStageDescription = (key, metadata) => { } return "Modifier les paroles créées"; case "beatmaker": + if (metadata.hasCover) { + return "Impossible de modifier la cover ou la production musicale"; + } if (!hasLyrics) { return "Écrivez vos paroles pour débloquer la musique"; } @@ -117,6 +123,9 @@ const getStageLockedDescription = (key, metadata) => { ? "Impossible de modifier les paroles" : undefined; case "beatmaker": + if (metadata.hasCover) { + return "Impossible de modifier la cover ou la production musicale"; + } return metadata.lyricsCount > 0 ? undefined : "Créez vos paroles pour débloquer le studio";