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
|
||||
+94
-34
@@ -6,8 +6,64 @@ const { Buffer } = require('buffer')
|
||||
const { setTimeout } = require('timers/promises')
|
||||
|
||||
// --- CONFIGURATION ---
|
||||
// Plus intelligente que le Flash original, ultra rapide, et stable sur l'API.
|
||||
const TEXT_MODEL_NAME = 'gemini-flash-latest'
|
||||
// Utilise des versions stables explicites en production. L'alias `latest` peut
|
||||
// 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'
|
||||
|
||||
// --- 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.')
|
||||
}
|
||||
|
||||
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`)
|
||||
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAMES.join(' -> ')})`)
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
const { output } = await ai.generate({
|
||||
model: googleAI.model(TEXT_MODEL_NAME),
|
||||
system,
|
||||
prompt,
|
||||
output: { schema },
|
||||
config: {
|
||||
temperature: 0.7, // Créativité équilibrée
|
||||
return await generateWithModelFallback({
|
||||
ai,
|
||||
modelNames: TEXT_MODEL_NAMES,
|
||||
label: 'generateAI',
|
||||
request: {
|
||||
system,
|
||||
prompt,
|
||||
output: { schema },
|
||||
config: {
|
||||
temperature: 0.7, // Créativité équilibrée
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return output
|
||||
} catch (error) {
|
||||
console.error('❌ [generateAI] Error:', error.message)
|
||||
throw error
|
||||
@@ -129,34 +187,36 @@ ${lyricsText}
|
||||
"""
|
||||
`
|
||||
|
||||
console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`)
|
||||
console.log(`🛡️ [analyseLyrics] Start (${MODERATION_MODEL_NAMES.join(' -> ')})`)
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
const { output } = await ai.generate({
|
||||
model: googleAI.model(TEXT_MODEL_NAME),
|
||||
system,
|
||||
prompt: userPrompt,
|
||||
output: { schema: moderationSchema },
|
||||
config: {
|
||||
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
|
||||
safetySettings: [
|
||||
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
|
||||
{
|
||||
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
||||
threshold: 'BLOCK_NONE',
|
||||
},
|
||||
{
|
||||
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
||||
threshold: 'BLOCK_NONE',
|
||||
},
|
||||
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
|
||||
],
|
||||
const output = await generateWithModelFallback({
|
||||
ai,
|
||||
modelNames: MODERATION_MODEL_NAMES,
|
||||
label: 'analyseLyrics',
|
||||
request: {
|
||||
system,
|
||||
prompt: userPrompt,
|
||||
output: { schema: moderationSchema },
|
||||
config: {
|
||||
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
|
||||
safetySettings: [
|
||||
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
|
||||
{
|
||||
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
||||
threshold: 'BLOCK_NONE',
|
||||
},
|
||||
{
|
||||
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
||||
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
|
||||
if (output.blocked) {
|
||||
output.flagged = true
|
||||
|
||||
@@ -17,6 +17,7 @@ const projectsRef = db.collection('projects')
|
||||
const REGION = process.env.FIREBASE_REGION || 'europe-west1'
|
||||
|
||||
const HEYGEN_BASE_URL = 'https://api.heygen.com/v3'
|
||||
const HEYGEN_REQUEST_TIMEOUT_MS = 45000
|
||||
const PLAYBACK_STATUS = {
|
||||
GENERATING: 'GENERATING',
|
||||
DRAFT_READY: 'DRAFT_READY',
|
||||
@@ -120,6 +121,7 @@ const findProjectForWebhook = async ({ callbackId, videoId }) => {
|
||||
const fetchCanonicalVideo = async (videoId) => {
|
||||
const response = await axios.get(`${HEYGEN_BASE_URL}/videos/${videoId}`, {
|
||||
headers: getHeygenHeaders(),
|
||||
timeout: HEYGEN_REQUEST_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
return response?.data?.data || null
|
||||
@@ -176,6 +178,13 @@ exports.startPlaybackGeneration = onCall(
|
||||
const callbackUrl = buildCallbackUrl()
|
||||
const title = trimString(project?.title) || `Playback ${projectId}`
|
||||
|
||||
logger.info('[HeyGen] startPlaybackGeneration request', {
|
||||
projectId,
|
||||
uid,
|
||||
hasSongUrl: Boolean(songUrl),
|
||||
hasPhotoUrl: Boolean(photoUrl),
|
||||
})
|
||||
|
||||
await projectRef.set(
|
||||
{
|
||||
playbackProvider: 'heygen',
|
||||
@@ -211,6 +220,7 @@ exports.startPlaybackGeneration = onCall(
|
||||
},
|
||||
{
|
||||
headers: getHeygenHeaders(),
|
||||
timeout: HEYGEN_REQUEST_TIMEOUT_MS,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -227,6 +237,12 @@ exports.startPlaybackGeneration = onCall(
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
logger.info('[HeyGen] generation accepted', {
|
||||
projectId,
|
||||
uid,
|
||||
videoId,
|
||||
})
|
||||
|
||||
return {
|
||||
jobId: videoId,
|
||||
callbackId,
|
||||
|
||||
@@ -204,9 +204,9 @@ exports.generateLyrics = onCall(LYRICS_FUNCTION_OPTIONS, async ({ auth = {}, dat
|
||||
}
|
||||
} catch (moderationError) {
|
||||
if (moderationError instanceof HttpsError) throw moderationError
|
||||
console.error('⚠️ Moderation check error (fail open)', moderationError)
|
||||
// On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique.
|
||||
// Ici je fail closed par sécurité pour une app publique.
|
||||
console.error('⚠️ Moderation check error (fail closed)', moderationError)
|
||||
// La modération essaie plusieurs modèles stables avant d'arriver ici.
|
||||
// Si tous sont indisponibles, on bloque par sécurité pour une app publique.
|
||||
throw new HttpsError('internal', 'Vérification de sécurité indisponible.')
|
||||
}
|
||||
|
||||
|
||||
+19
-11
@@ -2,7 +2,7 @@ const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https')
|
||||
const axios = require('axios')
|
||||
const admin = require('firebase-admin')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const { logger } = require('firebase-functions/logger')
|
||||
const logger = require('firebase-functions/logger')
|
||||
const { pipeline } = require('stream/promises')
|
||||
const { randomUUID } = require('crypto')
|
||||
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_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)
|
||||
// ==========================================
|
||||
@@ -409,14 +415,16 @@ async function markProjectMusicFailure(projectId, error) {
|
||||
reason: sunoMessage,
|
||||
})
|
||||
}
|
||||
await docRef.set(
|
||||
{
|
||||
musicStatus: 'FAILED',
|
||||
musicError: errorPayload,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
)
|
||||
const failureUpdate = {
|
||||
musicStatus: 'FAILED',
|
||||
musicError: errorPayload,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}
|
||||
if (refundResult?.orderId) {
|
||||
failureUpdate.musicCreditsRefunded = true
|
||||
failureUpdate.musicCreditsRefundOrderId = refundResult.orderId
|
||||
}
|
||||
await docRef.set(failureUpdate, { merge: true })
|
||||
// (Notification logic here...)
|
||||
} catch (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, {
|
||||
headers: {
|
||||
'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')
|
||||
try {
|
||||
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,
|
||||
})
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user