feat: fix clouds functions and heygen
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
# This file specifies files that are *not* uploaded to Google Cloud
|
||||||
|
# using gcloud. It follows the same syntax as .gitignore, with the addition of
|
||||||
|
# "#!include" directives (which insert the entries of the given .gitignore-style
|
||||||
|
# file at that point).
|
||||||
|
#
|
||||||
|
# For more information, run:
|
||||||
|
# $ gcloud topic gcloudignore
|
||||||
|
#
|
||||||
|
.gcloudignore
|
||||||
|
# If you would like to upload your .git directory, .gitignore file or files
|
||||||
|
# from your .gitignore file, remove the corresponding line
|
||||||
|
# below:
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.secret.local
|
||||||
|
serviceAccountKey.json
|
||||||
+72
-12
@@ -6,8 +6,64 @@ const { Buffer } = require('buffer')
|
|||||||
const { setTimeout } = require('timers/promises')
|
const { setTimeout } = require('timers/promises')
|
||||||
|
|
||||||
// --- CONFIGURATION ---
|
// --- CONFIGURATION ---
|
||||||
// Plus intelligente que le Flash original, ultra rapide, et stable sur l'API.
|
// Utilise des versions stables explicites en production. L'alias `latest` peut
|
||||||
const TEXT_MODEL_NAME = 'gemini-flash-latest'
|
// changer sans déploiement et pointer temporairement vers un modèle saturé.
|
||||||
|
const TEXT_MODEL_NAMES = ['gemini-3.6-flash', 'gemini-3.5-flash', 'gemini-3.1-flash-lite']
|
||||||
|
const MODERATION_MODEL_NAMES = ['gemini-3.1-flash-lite', 'gemini-3.6-flash', 'gemini-3.5-flash']
|
||||||
|
const RETRYABLE_STATUS_CODES = new Set([404, 429, 500, 502, 503, 504])
|
||||||
|
|
||||||
|
const getErrorStatus = (error) => {
|
||||||
|
const status = Number(error?.status || error?.statusCode || error?.response?.status)
|
||||||
|
return Number.isFinite(status) ? status : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const canTryFallbackModel = (error) => {
|
||||||
|
const status = getErrorStatus(error)
|
||||||
|
if (status && RETRYABLE_STATUS_CODES.has(status)) return true
|
||||||
|
|
||||||
|
const message = String(error?.message || '').toLowerCase()
|
||||||
|
return (
|
||||||
|
message.includes('high demand') ||
|
||||||
|
message.includes('overloaded') ||
|
||||||
|
message.includes('temporarily unavailable') ||
|
||||||
|
message.includes('model not found')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateWithModelFallback = async ({ ai, modelNames, label, request }) => {
|
||||||
|
let lastError = null
|
||||||
|
|
||||||
|
for (let index = 0; index < modelNames.length; index++) {
|
||||||
|
const modelName = modelNames[index]
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`🤖 [${label}] Modèle ${modelName} (${index + 1}/${modelNames.length})`)
|
||||||
|
const response = await ai.generate({
|
||||||
|
...request,
|
||||||
|
model: googleAI.model(modelName),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response?.output) {
|
||||||
|
throw new Error(`${label}: réponse structurée vide pour ${modelName}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.output
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
const hasFallback = index < modelNames.length - 1
|
||||||
|
|
||||||
|
if (!hasFallback || !canTryFallbackModel(error)) throw error
|
||||||
|
|
||||||
|
console.warn(`⚠️ [${label}] ${modelName} indisponible, essai du modèle suivant`, {
|
||||||
|
status: getErrorStatus(error),
|
||||||
|
message: error?.message,
|
||||||
|
})
|
||||||
|
await setTimeout(500 * (index + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error(`${label}: aucun modèle disponible.`)
|
||||||
|
}
|
||||||
const IMAGE_MODEL_NAME = 'gemini-3-pro-image'
|
const IMAGE_MODEL_NAME = 'gemini-3-pro-image'
|
||||||
|
|
||||||
// --- SINGLETON PATTERN (WARM START) ---
|
// --- SINGLETON PATTERN (WARM START) ---
|
||||||
@@ -35,21 +91,23 @@ exports.generateAI = async ({ system = '', prompt = '', schema }) => {
|
|||||||
throw new Error('Vous devez spécifier un prompt pour effectuer cette action.')
|
throw new Error('Vous devez spécifier un prompt pour effectuer cette action.')
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`)
|
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAMES.join(' -> ')})`)
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { output } = await ai.generate({
|
return await generateWithModelFallback({
|
||||||
model: googleAI.model(TEXT_MODEL_NAME),
|
ai,
|
||||||
|
modelNames: TEXT_MODEL_NAMES,
|
||||||
|
label: 'generateAI',
|
||||||
|
request: {
|
||||||
system,
|
system,
|
||||||
prompt,
|
prompt,
|
||||||
output: { schema },
|
output: { schema },
|
||||||
config: {
|
config: {
|
||||||
temperature: 0.7, // Créativité équilibrée
|
temperature: 0.7, // Créativité équilibrée
|
||||||
},
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return output
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ [generateAI] Error:', error.message)
|
console.error('❌ [generateAI] Error:', error.message)
|
||||||
throw error
|
throw error
|
||||||
@@ -129,12 +187,15 @@ ${lyricsText}
|
|||||||
"""
|
"""
|
||||||
`
|
`
|
||||||
|
|
||||||
console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`)
|
console.log(`🛡️ [analyseLyrics] Start (${MODERATION_MODEL_NAMES.join(' -> ')})`)
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { output } = await ai.generate({
|
const output = await generateWithModelFallback({
|
||||||
model: googleAI.model(TEXT_MODEL_NAME),
|
ai,
|
||||||
|
modelNames: MODERATION_MODEL_NAMES,
|
||||||
|
label: 'analyseLyrics',
|
||||||
|
request: {
|
||||||
system,
|
system,
|
||||||
prompt: userPrompt,
|
prompt: userPrompt,
|
||||||
output: { schema: moderationSchema },
|
output: { schema: moderationSchema },
|
||||||
@@ -153,10 +214,9 @@ ${lyricsText}
|
|||||||
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
|
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!output) throw new Error("Échec de l'analyse de modération.")
|
|
||||||
|
|
||||||
// Correction de cohérence
|
// Correction de cohérence
|
||||||
if (output.blocked) {
|
if (output.blocked) {
|
||||||
output.flagged = true
|
output.flagged = true
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const projectsRef = db.collection('projects')
|
|||||||
const REGION = process.env.FIREBASE_REGION || 'europe-west1'
|
const REGION = process.env.FIREBASE_REGION || 'europe-west1'
|
||||||
|
|
||||||
const HEYGEN_BASE_URL = 'https://api.heygen.com/v3'
|
const HEYGEN_BASE_URL = 'https://api.heygen.com/v3'
|
||||||
|
const HEYGEN_REQUEST_TIMEOUT_MS = 45000
|
||||||
const PLAYBACK_STATUS = {
|
const PLAYBACK_STATUS = {
|
||||||
GENERATING: 'GENERATING',
|
GENERATING: 'GENERATING',
|
||||||
DRAFT_READY: 'DRAFT_READY',
|
DRAFT_READY: 'DRAFT_READY',
|
||||||
@@ -120,6 +121,7 @@ const findProjectForWebhook = async ({ callbackId, videoId }) => {
|
|||||||
const fetchCanonicalVideo = async (videoId) => {
|
const fetchCanonicalVideo = async (videoId) => {
|
||||||
const response = await axios.get(`${HEYGEN_BASE_URL}/videos/${videoId}`, {
|
const response = await axios.get(`${HEYGEN_BASE_URL}/videos/${videoId}`, {
|
||||||
headers: getHeygenHeaders(),
|
headers: getHeygenHeaders(),
|
||||||
|
timeout: HEYGEN_REQUEST_TIMEOUT_MS,
|
||||||
})
|
})
|
||||||
|
|
||||||
return response?.data?.data || null
|
return response?.data?.data || null
|
||||||
@@ -176,6 +178,13 @@ exports.startPlaybackGeneration = onCall(
|
|||||||
const callbackUrl = buildCallbackUrl()
|
const callbackUrl = buildCallbackUrl()
|
||||||
const title = trimString(project?.title) || `Playback ${projectId}`
|
const title = trimString(project?.title) || `Playback ${projectId}`
|
||||||
|
|
||||||
|
logger.info('[HeyGen] startPlaybackGeneration request', {
|
||||||
|
projectId,
|
||||||
|
uid,
|
||||||
|
hasSongUrl: Boolean(songUrl),
|
||||||
|
hasPhotoUrl: Boolean(photoUrl),
|
||||||
|
})
|
||||||
|
|
||||||
await projectRef.set(
|
await projectRef.set(
|
||||||
{
|
{
|
||||||
playbackProvider: 'heygen',
|
playbackProvider: 'heygen',
|
||||||
@@ -211,6 +220,7 @@ exports.startPlaybackGeneration = onCall(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: getHeygenHeaders(),
|
headers: getHeygenHeaders(),
|
||||||
|
timeout: HEYGEN_REQUEST_TIMEOUT_MS,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -227,6 +237,12 @@ exports.startPlaybackGeneration = onCall(
|
|||||||
{ merge: true }
|
{ merge: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info('[HeyGen] generation accepted', {
|
||||||
|
projectId,
|
||||||
|
uid,
|
||||||
|
videoId,
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jobId: videoId,
|
jobId: videoId,
|
||||||
callbackId,
|
callbackId,
|
||||||
|
|||||||
@@ -204,9 +204,9 @@ exports.generateLyrics = onCall(LYRICS_FUNCTION_OPTIONS, async ({ auth = {}, dat
|
|||||||
}
|
}
|
||||||
} catch (moderationError) {
|
} catch (moderationError) {
|
||||||
if (moderationError instanceof HttpsError) throw moderationError
|
if (moderationError instanceof HttpsError) throw moderationError
|
||||||
console.error('⚠️ Moderation check error (fail open)', moderationError)
|
console.error('⚠️ Moderation check error (fail closed)', moderationError)
|
||||||
// On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique.
|
// La modération essaie plusieurs modèles stables avant d'arriver ici.
|
||||||
// Ici je fail closed par sécurité pour une app publique.
|
// Si tous sont indisponibles, on bloque par sécurité pour une app publique.
|
||||||
throw new HttpsError('internal', 'Vérification de sécurité indisponible.')
|
throw new HttpsError('internal', 'Vérification de sécurité indisponible.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-8
@@ -2,7 +2,7 @@ const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https')
|
|||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const admin = require('firebase-admin')
|
const admin = require('firebase-admin')
|
||||||
const { FieldValue } = require('firebase-admin/firestore')
|
const { FieldValue } = require('firebase-admin/firestore')
|
||||||
const { logger } = require('firebase-functions/logger')
|
const logger = require('firebase-functions/logger')
|
||||||
const { pipeline } = require('stream/promises')
|
const { pipeline } = require('stream/promises')
|
||||||
const { randomUUID } = require('crypto')
|
const { randomUUID } = require('crypto')
|
||||||
const { ALERT_TYPE, refList } = require('../index')
|
const { ALERT_TYPE, refList } = require('../index')
|
||||||
@@ -22,6 +22,12 @@ const MUSIC_REFUND_SOURCE = 'music_generation_refund'
|
|||||||
const SUNO_LYRICS_PROMPT_MAX_LENGTH = 5000
|
const SUNO_LYRICS_PROMPT_MAX_LENGTH = 5000
|
||||||
const SUNO_TITLE_MAX_LENGTH = 100
|
const SUNO_TITLE_MAX_LENGTH = 100
|
||||||
|
|
||||||
|
const getSunoAuthorizationHeader = () => {
|
||||||
|
const apiKey = String(SUNO_API_KEY.value() || '').trim()
|
||||||
|
if (!apiKey) throw new Error('SUNO_API_KEY_EMPTY')
|
||||||
|
return `Bearer ${apiKey}`
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 1. DICTIONNAIRES DE TRADUCTION (FRONT -> SUNO)
|
// 1. DICTIONNAIRES DE TRADUCTION (FRONT -> SUNO)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -409,14 +415,16 @@ async function markProjectMusicFailure(projectId, error) {
|
|||||||
reason: sunoMessage,
|
reason: sunoMessage,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
await docRef.set(
|
const failureUpdate = {
|
||||||
{
|
|
||||||
musicStatus: 'FAILED',
|
musicStatus: 'FAILED',
|
||||||
musicError: errorPayload,
|
musicError: errorPayload,
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
},
|
}
|
||||||
{ merge: true }
|
if (refundResult?.orderId) {
|
||||||
)
|
failureUpdate.musicCreditsRefunded = true
|
||||||
|
failureUpdate.musicCreditsRefundOrderId = refundResult.orderId
|
||||||
|
}
|
||||||
|
await docRef.set(failureUpdate, { merge: true })
|
||||||
// (Notification logic here...)
|
// (Notification logic here...)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error marking failure', err)
|
console.error('Error marking failure', err)
|
||||||
@@ -592,7 +600,7 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
|
|||||||
const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, {
|
const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${SUNO_API_KEY.value()}`,
|
Authorization: getSunoAuthorizationHeader(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -626,7 +634,7 @@ exports.getSunoStatus = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
|
|||||||
if (!taskId) throw new Error('TaskId manquant')
|
if (!taskId) throw new Error('TaskId manquant')
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, {
|
const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, {
|
||||||
headers: { Authorization: `Bearer ${SUNO_API_KEY.value()}` },
|
headers: { Authorization: getSunoAuthorizationHeader() },
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -113,10 +113,18 @@ const FullscreenIntroVideo = ({
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const nextUri = await resolveMediaUri(url)
|
const nextUri = await resolveMediaUri(url)
|
||||||
|
if (!nextUri) {
|
||||||
|
if (isMounted) {
|
||||||
|
setUri(null)
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
assignUri(nextUri)
|
assignUri(nextUri)
|
||||||
} catch {
|
} catch {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setUri(null)
|
setUri(null)
|
||||||
|
handleClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,7 +134,7 @@ const FullscreenIntroVideo = ({
|
|||||||
return () => {
|
return () => {
|
||||||
isMounted = false
|
isMounted = false
|
||||||
}
|
}
|
||||||
}, [url])
|
}, [handleClose, url])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || !uri) {
|
if (!visible || !uri) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const emulatorHost = Platform.OS === 'web' ? 'localhost' : '192.168.1.60'
|
|||||||
const USE_FUNCTIONS_EMULATOR = __DEV__
|
const USE_FUNCTIONS_EMULATOR = __DEV__
|
||||||
|
|
||||||
const configureFunctionsEmulator = (instance, regionKey = 'us-central1') => {
|
const configureFunctionsEmulator = (instance, regionKey = 'us-central1') => {
|
||||||
const shouldUseEmulator = USE_FUNCTIONS_EMULATOR && instance?.useEmulator
|
const shouldUseEmulator = false
|
||||||
|
|
||||||
if (!shouldUseEmulator || emulatorConfigured[regionKey]) {
|
if (!shouldUseEmulator || emulatorConfigured[regionKey]) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export function uploadFileToFirebase({
|
|||||||
shouldCompress,
|
shouldCompress,
|
||||||
fileType,
|
fileType,
|
||||||
hasBlob: Boolean(providedBlob),
|
hasBlob: Boolean(providedBlob),
|
||||||
uri,
|
uriScheme: typeof uri === 'string' ? uri.split(':')[0] : null,
|
||||||
|
uriLength: typeof uri === 'string' ? uri.length : 0,
|
||||||
})
|
})
|
||||||
let workingURI = uri
|
let workingURI = uri
|
||||||
let uploadBlob = providedBlob
|
let uploadBlob = providedBlob
|
||||||
@@ -170,6 +171,7 @@ export function uploadFileToFirebase({
|
|||||||
reject(error)
|
reject(error)
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
|
try {
|
||||||
const url = await firebase.storage().ref(path).getDownloadURL()
|
const url = await firebase.storage().ref(path).getDownloadURL()
|
||||||
|
|
||||||
console.log('[uploadToFirebase] upload success (finalize)', { resultURI: url })
|
console.log('[uploadToFirebase] upload success (finalize)', { resultURI: url })
|
||||||
@@ -177,6 +179,14 @@ export function uploadFileToFirebase({
|
|||||||
uploadBlob.close()
|
uploadBlob.close()
|
||||||
}
|
}
|
||||||
resolve({ resultURI: url })
|
resolve({ resultURI: url })
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[uploadToFirebase] download URL error', {
|
||||||
|
message: error?.message,
|
||||||
|
code: error?.code,
|
||||||
|
name: error?.name,
|
||||||
|
})
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as ImagePicker from 'expo-image-picker'
|
import * as ImagePicker from 'expo-image-picker'
|
||||||
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
|
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
|
||||||
import React, { useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import { Image, Text, View } from 'react-native'
|
import { Image, Platform, Text, View } from 'react-native'
|
||||||
import { background } from '../../assets'
|
import { background } from '../../assets'
|
||||||
import ActivityLoader from '../../components/ActivityLoader'
|
import ActivityLoader from '../../components/ActivityLoader'
|
||||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||||
@@ -19,21 +19,50 @@ import { PLAYBACK_AI_STATUS } from '../../utils/playbackAi'
|
|||||||
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
|
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
|
||||||
|
|
||||||
const FUNCTIONS_REGION = 'europe-west1'
|
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 trimString = (value) => (typeof value === 'string' ? value.trim() : '')
|
||||||
|
|
||||||
const isRemoteUrl = (value) => /^https?:\/\//i.test(trimString(value))
|
const isRemoteUrl = (value) => /^https?:\/\//i.test(trimString(value))
|
||||||
|
|
||||||
const resolveProject = ({ routeProject, selectedProject }) => {
|
const resolveProject = ({ routeProject, selectedProject }) => {
|
||||||
if (routeProject?.id && selectedProject?.id === routeProject.id) {
|
if (routeProject && typeof routeProject === 'object' && routeProject.id) {
|
||||||
|
if (selectedProject?.id === routeProject.id) {
|
||||||
return selectedProject
|
return selectedProject
|
||||||
}
|
}
|
||||||
return routeProject || null
|
|
||||||
|
return routeProject
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedProject?.id) {
|
||||||
|
return selectedProject
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const getInitialPhotoUri = ({ routePhotoUrl, project }) =>
|
const getInitialPhotoUri = ({ routePhotoUrl, project }) =>
|
||||||
trimString(routePhotoUrl) || trimString(project?.playbackPhotoUrl) || null
|
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 callStartPlaybackGeneration = (payload) => {
|
||||||
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('heygen-startPlaybackGeneration')
|
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('heygen-startPlaybackGeneration')
|
||||||
return callable(payload)
|
return callable(payload)
|
||||||
@@ -49,6 +78,7 @@ const PlaybackAi = ({ route }) => {
|
|||||||
project: resolveProject({ routeProject, selectedProject }),
|
project: resolveProject({ routeProject, selectedProject }),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
const [selectedPhotoAsset, setSelectedPhotoAsset] = useState(null)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const project = useMemo(
|
const project = useMemo(
|
||||||
() => resolveProject({ routeProject, selectedProject }),
|
() => resolveProject({ routeProject, selectedProject }),
|
||||||
@@ -64,9 +94,10 @@ const PlaybackAi = ({ route }) => {
|
|||||||
quality: 1,
|
quality: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
const nextUri = result?.assets?.[0]?.uri || null
|
const asset = result?.assets?.[0]
|
||||||
if (nextUri) {
|
if (asset?.uri) {
|
||||||
setSelectedPhotoUri(nextUri)
|
setSelectedPhotoUri(asset.uri)
|
||||||
|
setSelectedPhotoAsset(asset)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setTooltip({
|
setTooltip({
|
||||||
@@ -77,6 +108,8 @@ const PlaybackAi = ({ route }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
|
let step = 'validation'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!project?.id) {
|
if (!project?.id) {
|
||||||
throw new Error('Projet introuvable')
|
throw new Error('Projet introuvable')
|
||||||
@@ -94,24 +127,46 @@ const PlaybackAi = ({ route }) => {
|
|||||||
|
|
||||||
let photoUrl = trimString(selectedPhotoUri)
|
let photoUrl = trimString(selectedPhotoUri)
|
||||||
if (!isRemoteUrl(photoUrl)) {
|
if (!isRemoteUrl(photoUrl)) {
|
||||||
const extension = trimString(selectedPhotoUri.split('.').pop()) || 'jpg'
|
step = 'photo_upload'
|
||||||
const uploadResult = await uploadFileToFirebase({
|
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,
|
uri: selectedPhotoUri,
|
||||||
path: `users/${currentUID}/projects/${project.id}/heygen/photo-${Date.now()}.${extension}`,
|
path,
|
||||||
shouldCompress: true,
|
shouldCompress: true,
|
||||||
fileType: 'IMAGE',
|
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)
|
photoUrl = trimString(uploadResult?.resultURI)
|
||||||
|
console.log('[PlaybackAi] photo upload complete', { path })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!photoUrl) {
|
if (!photoUrl) {
|
||||||
throw new Error("Impossible d'envoyer la photo vers Firebase")
|
throw new Error("Impossible d'envoyer la photo vers Firebase")
|
||||||
}
|
}
|
||||||
|
|
||||||
await callStartPlaybackGeneration({
|
step = 'heygen_start'
|
||||||
|
console.log('[PlaybackAi] HeyGen request start', { projectId: project.id })
|
||||||
|
await withTimeout(
|
||||||
|
callStartPlaybackGeneration({
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
photoUrl,
|
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, {
|
navigate(Routes.PlaybackAiStatus, {
|
||||||
project: {
|
project: {
|
||||||
@@ -122,6 +177,11 @@ const PlaybackAi = ({ route }) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[PlaybackAi] generation failed', {
|
||||||
|
step,
|
||||||
|
code: error?.code,
|
||||||
|
message: error?.message,
|
||||||
|
})
|
||||||
setTooltip({
|
setTooltip({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
text: error?.message || 'Erreur lors du lancement du playback IA',
|
text: error?.message || 'Erreur lors du lancement du playback IA',
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ const ComposeSong = () => {
|
|||||||
},
|
},
|
||||||
musicStatus: null,
|
musicStatus: null,
|
||||||
sunoTaskId: firebase.firestore.FieldValue.delete(),
|
sunoTaskId: firebase.firestore.FieldValue.delete(),
|
||||||
|
musicCreditsRefunded: firebase.firestore.FieldValue.delete(),
|
||||||
|
musicCreditsRefundOrderId: firebase.firestore.FieldValue.delete(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRegenerationFlow) {
|
if (!isRegenerationFlow) {
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ const ComposeSong = () => {
|
|||||||
},
|
},
|
||||||
musicStatus: null,
|
musicStatus: null,
|
||||||
sunoTaskId: firebase.firestore.FieldValue.delete(),
|
sunoTaskId: firebase.firestore.FieldValue.delete(),
|
||||||
|
musicCreditsRefunded: firebase.firestore.FieldValue.delete(),
|
||||||
|
musicCreditsRefundOrderId: firebase.firestore.FieldValue.delete(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRegenerationFlow) {
|
if (!isRegenerationFlow) {
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ const SongReady = () => {
|
|||||||
onTogglePlayback={handleTogglePlayback}
|
onTogglePlayback={handleTogglePlayback}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
|
fallbackDurationS={selectedProject?.musicDurations?.[index]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
contentContainerStyle={{ gap: 16, paddingBottom: gutters }}
|
contentContainerStyle={{ gap: 16, paddingBottom: gutters }}
|
||||||
@@ -346,6 +347,7 @@ const SongOptionCard = ({
|
|||||||
onTogglePlayback,
|
onTogglePlayback,
|
||||||
projectId,
|
projectId,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
|
fallbackDurationS,
|
||||||
}) => {
|
}) => {
|
||||||
const trackTitle =
|
const trackTitle =
|
||||||
(Array.isArray(selectedProject?.musicTitles) ? selectedProject.musicTitles?.[index] : null) ||
|
(Array.isArray(selectedProject?.musicTitles) ? selectedProject.musicTitles?.[index] : null) ||
|
||||||
@@ -360,9 +362,20 @@ const SongOptionCard = ({
|
|||||||
metadata: { index, projectId },
|
metadata: { index, projectId },
|
||||||
})
|
})
|
||||||
|
|
||||||
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 })
|
const fallbackDurationMs = Math.max(0, Math.round((Number(fallbackDurationS) || 0) * 1000))
|
||||||
|
const [progressInfo, setProgressInfo] = useState({
|
||||||
|
pos: 0,
|
||||||
|
dur: fallbackDurationMs,
|
||||||
|
})
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fallbackDurationMs) return
|
||||||
|
setProgressInfo((current) =>
|
||||||
|
current.dur ? current : { ...current, dur: fallbackDurationMs }
|
||||||
|
)
|
||||||
|
}, [fallbackDurationMs])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerPlayer(index, player)
|
registerPlayer(index, player)
|
||||||
return () => registerPlayer(index, null)
|
return () => registerPlayer(index, null)
|
||||||
@@ -387,7 +400,8 @@ const SongOptionCard = ({
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
const durationMs = Math.max(0, Math.round((Number(player.duration) || 0) * 1000))
|
const playerDurationMs = Math.max(0, Math.round((Number(player.duration) || 0) * 1000))
|
||||||
|
const durationMs = playerDurationMs || fallbackDurationMs
|
||||||
const positionMs = Math.max(0, Math.round((Number(player.currentTime) || 0) * 1000))
|
const positionMs = Math.max(0, Math.round((Number(player.currentTime) || 0) * 1000))
|
||||||
setProgressInfo((prev) => {
|
setProgressInfo((prev) => {
|
||||||
if (
|
if (
|
||||||
@@ -407,7 +421,7 @@ const SongOptionCard = ({
|
|||||||
})
|
})
|
||||||
}, 300)
|
}, 300)
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [player])
|
}, [fallbackDurationMs, player])
|
||||||
|
|
||||||
const handleSeek = useCallback(
|
const handleSeek = useCallback(
|
||||||
async (targetMs) => {
|
async (targetMs) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user