306 lines
9.2 KiB
JavaScript
306 lines
9.2 KiB
JavaScript
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 (
|
|
<Page
|
|
backgroundImg={background.playbackBG2}
|
|
backgroundColor="#07111B"
|
|
headerType="NONE"
|
|
>
|
|
<MusicLandHeader progress={31} onPressBack={goBack} />
|
|
<View style={{ flex: 1, paddingBottom: gutters * 2 }}>
|
|
<CreateLyricsHeader title="Créer ton playback IA" />
|
|
|
|
<View style={styles.content}>
|
|
<View style={styles.summaryCard}>
|
|
<Text style={styles.summaryTitle}>{project?.title || 'Sans titre'}</Text>
|
|
<Text style={styles.summaryText}>
|
|
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.
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.previewCard}>
|
|
{selectedPhotoUri ? (
|
|
<Image source={{ uri: selectedPhotoUri }} style={styles.photoPreview} resizeMode="cover" />
|
|
) : (
|
|
<View style={[styles.photoPreview, styles.photoPlaceholder]}>
|
|
<Text style={styles.photoPlaceholderText}>Aucune photo sélectionnée</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
<View style={styles.actions}>
|
|
<BorderGradientButton
|
|
title={selectedPhotoUri ? 'Changer de photo' : 'Choisir une photo'}
|
|
onPress={handleSelectPhoto}
|
|
disabled={isSubmitting}
|
|
/>
|
|
<GradientButton
|
|
title="Générer ma vidéo"
|
|
onPress={handleGenerate}
|
|
disabled={isSubmitting || !selectedPhotoUri}
|
|
/>
|
|
{isSubmitting ? (
|
|
<ActivityLoader
|
|
defaultMessage="Envoi de la photo et lancement de HeyGen..."
|
|
containerStyle={styles.loader}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Page>
|
|
)
|
|
}
|
|
|
|
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
|