import * as ImagePicker from 'expo-image-picker' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import React, { useMemo, useState } from 'react' import { Image, Platform, Text, View } from 'react-native' import { background } from '../../assets' import ActivityLoader from '../../components/ActivityLoader' import BorderGradientButton from '../../components/BorderGradientButton' import GradientButton from '../../components/GradientButton' import MusicLandHeader from '../../components/MusicLandHeader' import { getFunctionsClient } from '../../config/firebase' import { uploadFileToFirebase } from '../../helpers/uploadToFirebase' import Page from '../../layouts/Page' import { Routes } from '../../navigation' import { goBack, navigate } from '../../navigation/NavigationService' import { useUserData } from '../../providers/UserDataProvider' import { gutters, Palette } from '../../styles' import { FONT_FAMILY } from '../../styles/Fonts' import { PLAYBACK_AI_STATUS } from '../../utils/playbackAi' import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader' const FUNCTIONS_REGION = 'europe-west1' const PHOTO_UPLOAD_TIMEOUT_MS = 120000 const HEYGEN_START_TIMEOUT_MS = 60000 const trimString = (value) => (typeof value === 'string' ? value.trim() : '') const isRemoteUrl = (value) => /^https?:\/\//i.test(trimString(value)) const resolveProject = ({ routeProject, selectedProject }) => { if (routeProject && typeof routeProject === 'object' && routeProject.id) { if (selectedProject?.id === routeProject.id) { return selectedProject } return routeProject } if (selectedProject?.id) { return selectedProject } return null } const getInitialPhotoUri = ({ routePhotoUrl, project }) => trimString(routePhotoUrl) || trimString(project?.playbackPhotoUrl) || null const getImageExtension = (fileName) => { const normalizedFileName = trimString(fileName) const extension = normalizedFileName.includes('.') ? normalizedFileName.split('.').pop().toLowerCase() : '' return /^[a-z0-9]+$/.test(extension) ? extension : 'jpg' } const withTimeout = (promise, timeoutMs, message) => { let timeoutId const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs) }) return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId)) } const callStartPlaybackGeneration = (payload) => { const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('heygen-startPlaybackGeneration') return callable(payload) } const PlaybackAi = ({ route }) => { const { project: routeProject, initialPhotoUrl = null } = route.params || {} const { selectedProject, currentUID } = useUserData() || {} const { setTooltip } = useMinuit() const [selectedPhotoUri, setSelectedPhotoUri] = useState(() => getInitialPhotoUri({ routePhotoUrl: initialPhotoUrl, project: resolveProject({ routeProject, selectedProject }), }) ) const [selectedPhotoAsset, setSelectedPhotoAsset] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) const project = useMemo( () => resolveProject({ routeProject, selectedProject }), [routeProject, selectedProject] ) const handleSelectPhoto = async () => { try { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: true, aspect: [9, 16], quality: 1, }) const asset = result?.assets?.[0] if (asset?.uri) { setSelectedPhotoUri(asset.uri) setSelectedPhotoAsset(asset) } } catch (error) { setTooltip({ type: 'error', text: error?.message || 'Impossible de sélectionner une photo', }) } } const handleGenerate = async () => { let step = 'validation' try { if (!project?.id) { throw new Error('Projet introuvable') } if (!currentUID) { throw new Error('Utilisateur non authentifié') } if (!selectedPhotoUri) { throw new Error('Choisis une photo avant de lancer la génération') } setIsSubmitting(true) let photoUrl = trimString(selectedPhotoUri) if (!isRemoteUrl(photoUrl)) { step = 'photo_upload' const extension = getImageExtension(selectedPhotoAsset?.fileName) const path = `users/${currentUID}/projects/${project.id}/heygen/photo-${Date.now()}.${extension}` console.log('[PlaybackAi] photo upload start', { path, fileSize: selectedPhotoAsset?.fileSize || null, platform: Platform.OS, }) const uploadResult = await withTimeout( uploadFileToFirebase({ uri: selectedPhotoUri, path, shouldCompress: true, fileType: 'IMAGE', blob: Platform.OS === 'web' ? selectedPhotoAsset?.file : null, }), PHOTO_UPLOAD_TIMEOUT_MS, "L'envoi de la photo a dépassé 2 minutes. Vérifie ta connexion puis réessaie." ) photoUrl = trimString(uploadResult?.resultURI) console.log('[PlaybackAi] photo upload complete', { path }) } if (!photoUrl) { throw new Error("Impossible d'envoyer la photo vers Firebase") } step = 'heygen_start' console.log('[PlaybackAi] HeyGen request start', { projectId: project.id }) await withTimeout( callStartPlaybackGeneration({ projectId: project.id, photoUrl, }), HEYGEN_START_TIMEOUT_MS, 'HeyGen ne répond pas après 1 minute. Réessaie dans quelques instants.' ) console.log('[PlaybackAi] HeyGen request accepted', { projectId: project.id }) navigate(Routes.PlaybackAiStatus, { project: { ...project, playbackStatus: PLAYBACK_AI_STATUS.GENERATING, playbackGenerating: true, playbackPhotoUrl: photoUrl, }, }) } catch (error) { console.error('[PlaybackAi] generation failed', { step, code: error?.code, message: error?.message, }) setTooltip({ type: 'error', text: error?.message || 'Erreur lors du lancement du playback IA', }) } finally { setIsSubmitting(false) } } return ( {project?.title || 'Sans titre'} Ajoute une photo portrait de toi. HeyGen générera ensuite une vidéo verticale 9:16 où tu chantes la musique de ton projet. {selectedPhotoUri ? ( ) : ( Aucune photo sélectionnée )} {isSubmitting ? ( ) : null} ) } const styles = { content: { flex: 1, width: '90%', alignSelf: 'center', gap: 16, }, summaryCard: { padding: 18, borderRadius: 18, borderWidth: 1, borderColor: 'rgba(255,255,255,0.14)', backgroundColor: 'rgba(5,12,24,0.76)', gap: 8, }, summaryTitle: { color: Palette.white, fontSize: 18, fontFamily: FONT_FAMILY.InterSemiBold, }, summaryText: { color: Palette.white, opacity: 0.78, lineHeight: 20, fontFamily: FONT_FAMILY.InterRegular, }, previewCard: { flex: 1, minHeight: 320, borderRadius: 22, borderWidth: 1, borderColor: 'rgba(255,255,255,0.14)', backgroundColor: 'rgba(5,12,24,0.8)', padding: 12, }, photoPreview: { width: '100%', height: '100%', borderRadius: 18, backgroundColor: '#141414', }, photoPlaceholder: { alignItems: 'center', justifyContent: 'center', }, photoPlaceholderText: { color: Palette.white, opacity: 0.5, fontFamily: FONT_FAMILY.InterRegular, }, actions: { gap: 12, }, loader: { marginTop: 4, }, } export default PlaybackAi