This commit is contained in:
Thomas Demirdjian
2026-08-04 10:59:27 +02:00
parent 761f3cc200
commit c8656d6273
25 changed files with 1743 additions and 75 deletions
+481
View File
@@ -0,0 +1,481 @@
const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https')
const logger = require('firebase-functions/logger')
const admin = require('firebase-admin')
const axios = require('axios')
const crypto = require('node:crypto')
const { FieldValue } = require('firebase-admin/firestore')
const {
HEYGEN_API_KEY,
HEYGEN_WEBHOOK_URL,
HEYGEN_WEBHOOK_TOKEN,
} = require('../config/secrets')
if (!admin.apps.length) admin.initializeApp()
const db = admin.firestore()
const projectsRef = db.collection('projects')
const REGION = process.env.FIREBASE_REGION || 'europe-west1'
const HEYGEN_BASE_URL = 'https://api.heygen.com/v3'
const PLAYBACK_STATUS = {
GENERATING: 'GENERATING',
DRAFT_READY: 'DRAFT_READY',
FAILED: 'FAILED',
READY: 'READY',
}
const trimString = (value) => (typeof value === 'string' ? value.trim() : '')
const normalizeStatus = (value) => trimString(value).toLowerCase()
const getSecretValue = (secret, key) => {
const value = trimString(secret.value())
if (!value) {
throw new HttpsError('failed-precondition', `Configuration manquante: ${key}`)
}
return value
}
const buildCallbackUrl = () => {
const rawUrl = getSecretValue(HEYGEN_WEBHOOK_URL, 'HEYGEN_WEBHOOK_URL')
const token = getSecretValue(HEYGEN_WEBHOOK_TOKEN, 'HEYGEN_WEBHOOK_TOKEN')
try {
const url = new URL(rawUrl)
if (url.protocol !== 'https:') {
throw new Error('invalid_protocol')
}
url.searchParams.set('token', token)
return url.toString()
} catch (error) {
throw new HttpsError(
'failed-precondition',
'Configuration invalide: HEYGEN_WEBHOOK_URL doit être une URL HTTPS publique.'
)
}
}
const buildPlaybackReadyNotificationId = ({ projectId, videoId }) => {
const digest = crypto
.createHash('sha256')
.update(`${projectId}:${videoId}`)
.digest('hex')
.slice(0, 32)
return `playback-ready-${digest}`
}
const getHeygenHeaders = () => ({
'Content-Type': 'application/json',
'X-Api-Key': getSecretValue(HEYGEN_API_KEY, 'HEYGEN_API_KEY'),
})
const getProjectSnapshot = async ({ projectId, uid }) => {
const projectRef = projectsRef.doc(projectId)
const projectSnap = await projectRef.get()
if (!projectSnap.exists) {
throw new HttpsError('not-found', 'Projet introuvable')
}
const project = projectSnap.data() || {}
if (trimString(project?.userId) !== uid) {
throw new HttpsError('permission-denied', 'Vous ne pouvez pas modifier ce projet')
}
return { projectRef, projectSnap, project }
}
const getHeygenErrorMessage = (error) => {
const responseMessage = trimString(error?.response?.data?.error?.message)
if (responseMessage) return responseMessage
const dataMessage = trimString(error?.response?.data?.message)
if (dataMessage) return dataMessage
const directMessage = trimString(error?.message)
if (directMessage) return directMessage
return 'Erreur HeyGen inconnue'
}
const findProjectForWebhook = async ({ callbackId, videoId }) => {
if (callbackId) {
const byCallback = await projectsRef.where('playbackCallbackId', '==', callbackId).limit(1).get()
if (!byCallback.empty) {
return byCallback.docs[0]
}
}
if (videoId) {
const byJob = await projectsRef.where('playbackJobId', '==', videoId).limit(1).get()
if (!byJob.empty) {
return byJob.docs[0]
}
}
return null
}
const fetchCanonicalVideo = async (videoId) => {
const response = await axios.get(`${HEYGEN_BASE_URL}/videos/${videoId}`, {
headers: getHeygenHeaders(),
})
return response?.data?.data || null
}
const extractWebhookPayload = (body = {}) => {
const eventData =
body?.event_data && typeof body.event_data === 'object' ? body.event_data : null
const rootPayload = body && typeof body === 'object' ? body : {}
const payload = eventData || rootPayload
return {
eventType: trimString(rootPayload?.event_type),
callbackId: trimString(payload?.callback_id),
videoId: trimString(payload?.video_id),
}
}
exports.startPlaybackGeneration = onCall(
{
region: REGION,
timeoutSeconds: 540,
secrets: [HEYGEN_API_KEY, HEYGEN_WEBHOOK_URL, HEYGEN_WEBHOOK_TOKEN],
},
async (request) => {
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError('unauthenticated', 'Authentification requise')
}
const projectId = trimString(request?.data?.projectId)
const photoUrl = trimString(request?.data?.photoUrl)
if (!projectId || !photoUrl) {
throw new HttpsError('invalid-argument', 'Requis: { projectId, photoUrl }')
}
const { projectRef, project } = await getProjectSnapshot({ projectId, uid })
if (trimString(project?.playbackUrl)) {
throw new HttpsError('failed-precondition', 'Ce projet possède déjà un playback validé')
}
const songUrl = trimString(project?.songUrl)
if (!songUrl) {
throw new HttpsError('failed-precondition', 'Aucune piste audio disponible pour ce projet')
}
if (project?.playbackStatus === PLAYBACK_STATUS.GENERATING) {
throw new HttpsError('failed-precondition', 'Une génération de playback IA est déjà en cours')
}
const callbackId = `playback_${crypto.randomUUID()}`
const callbackUrl = buildCallbackUrl()
const title = trimString(project?.title) || `Playback ${projectId}`
await projectRef.set(
{
playbackProvider: 'heygen',
playbackStatus: PLAYBACK_STATUS.GENERATING,
playbackGenerating: true,
playbackDraftUrl: null,
playbackPhotoUrl: photoUrl,
playbackJobId: null,
playbackCallbackId: callbackId,
playbackError: null,
playbackRequestedAt: FieldValue.serverTimestamp(),
playbackCompletedAt: null,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
try {
const response = await axios.post(
`${HEYGEN_BASE_URL}/videos`,
{
type: 'image',
image: {
type: 'url',
url: photoUrl,
},
audio_url: songUrl,
aspect_ratio: '9:16',
resolution: '1080p',
title,
callback_url: callbackUrl,
callback_id: callbackId,
},
{
headers: getHeygenHeaders(),
}
)
const videoId = trimString(response?.data?.data?.video_id)
if (!videoId) {
throw new Error('Réponse HeyGen invalide: video_id manquant')
}
await projectRef.set(
{
playbackJobId: videoId,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
return {
jobId: videoId,
callbackId,
status: PLAYBACK_STATUS.GENERATING,
}
} catch (error) {
const message = getHeygenErrorMessage(error)
logger.error('[HeyGen] startPlaybackGeneration failed', {
projectId,
uid,
message,
})
await projectRef.set(
{
playbackStatus: PLAYBACK_STATUS.FAILED,
playbackGenerating: false,
playbackError: message,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
throw new HttpsError('internal', message)
}
}
)
exports.validatePlaybackDraft = onCall({ region: REGION, timeoutSeconds: 540 }, async (request) => {
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError('unauthenticated', 'Authentification requise')
}
const projectId = trimString(request?.data?.projectId)
if (!projectId) {
throw new HttpsError('invalid-argument', 'Requis: { projectId }')
}
const { projectRef, project } = await getProjectSnapshot({ projectId, uid })
const playbackDraftUrl = trimString(project?.playbackDraftUrl)
if (project?.playbackStatus !== PLAYBACK_STATUS.DRAFT_READY || !playbackDraftUrl) {
throw new HttpsError('failed-precondition', 'Aucun brouillon HeyGen valide à promouvoir')
}
await projectRef.set(
{
playbackUrl: playbackDraftUrl,
playbackStatus: PLAYBACK_STATUS.READY,
playbackGenerating: false,
playbackProvider: 'heygen',
playbackDraftUrl: null,
playbackJobId: null,
playbackCallbackId: null,
playbackError: null,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
return {
playbackUrl: playbackDraftUrl,
status: PLAYBACK_STATUS.READY,
}
})
exports.playbackWebhook = onRequest(
{
region: REGION,
timeoutSeconds: 540,
secrets: [HEYGEN_API_KEY, HEYGEN_WEBHOOK_TOKEN],
},
async (req, res) => {
if (req.method !== 'POST') {
res.status(405).json({ ok: false, message: 'Method not allowed' })
return
}
try {
const expectedToken = getSecretValue(HEYGEN_WEBHOOK_TOKEN, 'HEYGEN_WEBHOOK_TOKEN')
const token = trimString(req?.query?.token)
if (!token || token !== expectedToken) {
res.status(401).json({ ok: false, message: 'Unauthorized webhook token' })
return
}
const { eventType, callbackId, videoId } = extractWebhookPayload(req.body || {})
if (!callbackId && !videoId) {
logger.warn('[HeyGen] webhook ignored: missing identifiers', {
eventType,
bodyKeys: Object.keys(req.body || {}),
})
res.status(400).json({ ok: false, message: 'Missing callback_id or video_id' })
return
}
const projectDoc = await findProjectForWebhook({ callbackId, videoId })
if (!projectDoc) {
logger.info('[HeyGen] webhook ignored: project not found', {
callbackId,
videoId,
eventType,
})
res.status(202).json({ ok: true, ignored: true })
return
}
const project = projectDoc.data() || {}
const currentCallbackId = trimString(project?.playbackCallbackId)
const currentVideoId = trimString(project?.playbackJobId)
if (callbackId && currentCallbackId && callbackId !== currentCallbackId) {
logger.info('[HeyGen] webhook ignored: stale callback_id', {
projectId: projectDoc.id,
callbackId,
currentCallbackId,
})
res.status(202).json({ ok: true, ignored: true })
return
}
if (videoId && currentVideoId && videoId !== currentVideoId) {
logger.info('[HeyGen] webhook ignored: stale video_id', {
projectId: projectDoc.id,
videoId,
currentVideoId,
})
res.status(202).json({ ok: true, ignored: true })
return
}
const canonicalVideoId = currentVideoId || videoId
if (!canonicalVideoId) {
logger.warn('[HeyGen] webhook ignored: no canonical video id', {
projectId: projectDoc.id,
callbackId,
})
res.status(202).json({ ok: true, ignored: true })
return
}
const canonicalVideo = await fetchCanonicalVideo(canonicalVideoId)
const canonicalStatus = normalizeStatus(canonicalVideo?.status)
if (canonicalStatus === 'completed') {
const canonicalVideoUrl = trimString(canonicalVideo?.video_url)
if (!canonicalVideoUrl) {
logger.warn('[HeyGen] webhook ignored: completed without video_url', {
projectId: projectDoc.id,
canonicalVideoId,
})
res.status(202).json({ ok: true, ignored: true })
return
}
const notificationRef = db
.collection('notifications')
.doc(
buildPlaybackReadyNotificationId({
projectId: projectDoc.id,
videoId: canonicalVideoId,
})
)
await db.runTransaction(async (transaction) => {
const [latestProjectSnap, notificationSnap] = await Promise.all([
transaction.get(projectDoc.ref),
transaction.get(notificationRef),
])
const latestProject = latestProjectSnap.data() || {}
const userId = trimString(latestProject?.userId)
const projectTitle = trimString(latestProject?.title) || 'ton projet'
transaction.set(
projectDoc.ref,
{
playbackProvider: 'heygen',
playbackStatus: PLAYBACK_STATUS.DRAFT_READY,
playbackGenerating: false,
playbackDraftUrl: canonicalVideoUrl,
playbackJobId: canonicalVideoId,
playbackCompletedAt: FieldValue.serverTimestamp(),
playbackError: null,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
if (userId && !notificationSnap.exists) {
transaction.set(notificationRef, {
sender: 'SYSTEM',
receiver: userId,
receiverCollection: 'users',
title: 'Ton playback IA est prêt !',
message: `La vidéo IA de "${projectTitle}" est terminée. Tu peux maintenant la découvrir et la valider.`,
time: FieldValue.serverTimestamp(),
read: false,
readAt: null,
mailOnly: false,
data: {
type: 'PLAYBACK_GENERATION_SUCCESS',
projectId: projectDoc.id,
projectTitle,
},
})
}
})
res.status(200).json({ ok: true, status: PLAYBACK_STATUS.DRAFT_READY })
return
}
if (canonicalStatus === 'failed') {
const failureMessage =
trimString(canonicalVideo?.failure_message) || 'La génération HeyGen a échoué'
await projectDoc.ref.set(
{
playbackProvider: 'heygen',
playbackStatus: PLAYBACK_STATUS.FAILED,
playbackGenerating: false,
playbackError: failureMessage,
playbackJobId: canonicalVideoId,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
res.status(200).json({ ok: true, status: PLAYBACK_STATUS.FAILED })
return
}
logger.info('[HeyGen] webhook acknowledged without terminal status', {
projectId: projectDoc.id,
canonicalVideoId,
canonicalStatus,
eventType,
})
res.status(202).json({ ok: true, status: canonicalStatus || 'processing' })
} catch (error) {
logger.error('[HeyGen] webhook failed', {
message: error?.message || String(error || ''),
})
res.status(500).json({ ok: false, message: error?.message || 'Webhook failure' })
}
}
)
+1 -1
View File
@@ -105,7 +105,7 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
)
const tokens = Array.from(tokensSet)
if (!tokens?.length) {
if (tokens.length) {
await sendExpoNotification({
tokens,
receiverId: receiver,