heygen
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
HEYGEN_API_KEY=
|
||||
HEYGEN_WEBHOOK_URL=https://europe-west1-musicland-d33f9.cloudfunctions.net/heygenPlaybackWebhook
|
||||
HEYGEN_WEBHOOK_TOKEN=replace-with-a-random-token
|
||||
@@ -5,6 +5,9 @@ const SUNO_API_KEY = defineSecret('SUNO_API_KEY')
|
||||
const RESEND_API_KEY = defineSecret('RESEND_API_KEY')
|
||||
const STRIPE_SECRET_KEY = defineSecret('STRIPE_SECRET_KEY')
|
||||
const STRIPE_WEBHOOK_SECRET = defineSecret('STRIPE_WEBHOOK_SECRET')
|
||||
const HEYGEN_API_KEY = defineSecret('HEYGEN_API_KEY')
|
||||
const HEYGEN_WEBHOOK_URL = defineSecret('HEYGEN_WEBHOOK_URL')
|
||||
const HEYGEN_WEBHOOK_TOKEN = defineSecret('HEYGEN_WEBHOOK_TOKEN')
|
||||
|
||||
module.exports = {
|
||||
GEMINI_API_KEY,
|
||||
@@ -12,4 +15,7 @@ module.exports = {
|
||||
RESEND_API_KEY,
|
||||
STRIPE_SECRET_KEY,
|
||||
STRIPE_WEBHOOK_SECRET,
|
||||
HEYGEN_API_KEY,
|
||||
HEYGEN_WEBHOOK_URL,
|
||||
HEYGEN_WEBHOOK_TOKEN,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
require('dotenv').config({ quiet: true })
|
||||
|
||||
const admin = require('firebase-admin')
|
||||
|
||||
// Use default credentials/environment provided by Cloud Functions.
|
||||
@@ -31,6 +33,7 @@ exports.ALERT_TYPE = {
|
||||
MUSIC_GENERATION_FAILED: 'MUSIC_GENERATION_FAILED',
|
||||
COVER_GENERATION_SUCCESS: 'COVER_GENERATION_SUCCESS',
|
||||
COVER_GENERATION_FAILED: 'COVER_GENERATION_FAILED',
|
||||
PLAYBACK_GENERATION_SUCCESS: 'PLAYBACK_GENERATION_SUCCESS',
|
||||
CREDITS_UPDATED: 'CREDITS_UPDATED',
|
||||
PAYOUT_AVAILABLE: 'PAYOUT_AVAILABLE',
|
||||
}
|
||||
@@ -43,6 +46,12 @@ exports.cover = require('./src/cover')
|
||||
exports.projects = require('./src/project')
|
||||
exports.thumbnail = require('./src/thumbnail')
|
||||
exports.upload = require('./src/upload')
|
||||
const heygen = require('./src/heygen')
|
||||
exports.heygen = {
|
||||
startPlaybackGeneration: heygen.startPlaybackGeneration,
|
||||
validatePlaybackDraft: heygen.validatePlaybackDraft,
|
||||
}
|
||||
exports.heygenPlaybackWebhook = heygen.playbackWebhook
|
||||
exports.algolia = require('./src/algolia')
|
||||
exports.notifications = require('./src/notifications')
|
||||
exports.rankings = require('./src/rankings')
|
||||
|
||||
Generated
+13
-10
@@ -11,6 +11,7 @@
|
||||
"@google-cloud/firestore": "^7.11.6",
|
||||
"@google-cloud/storage": "^7.17.1",
|
||||
"axios": "^1.11.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"expo-server-sdk": "^4.0.0",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
"firebase-admin": "^12.6.0",
|
||||
@@ -548,7 +549,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz",
|
||||
"integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.3.0",
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
@@ -1648,7 +1648,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -1744,7 +1743,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz",
|
||||
"integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "1.25.1"
|
||||
},
|
||||
@@ -2932,7 +2930,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.25.1.tgz",
|
||||
"integrity": "sha512-pkZT+iFYIZsVn6+GzM0kSX+u3MSLCY9md+lIJOoKl/P+gJFfxJte/60Usdp8Ce4rOs8GduUpSPNe1ddGyDT1sQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "1.25.1"
|
||||
@@ -2975,7 +2972,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.25.1.tgz",
|
||||
"integrity": "sha512-9Mb7q5ioFL4E4dDrc4wC/A3NTHDat44v4I3p2pLPSxRvqUbDIQyMVr9uK+EU69+HWhlET1VaSrRzwdckWqY15Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "1.25.1",
|
||||
"@opentelemetry/resources": "1.25.1",
|
||||
@@ -3549,7 +3545,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4187,6 +4182,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dotprompt": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/dotprompt/-/dotprompt-1.1.2.tgz",
|
||||
@@ -4380,7 +4387,6 @@
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -4888,7 +4894,6 @@
|
||||
"resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-12.7.0.tgz",
|
||||
"integrity": "sha512-raFIrOyTqREbyXsNkSHyciQLfv8AUZazehPaQS1lZBSCDYW74FYXU0nQZa3qHI4K+hawohlDbywZ4+qce9YNxA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^3.0.0",
|
||||
"@firebase/database-compat": "1.0.8",
|
||||
@@ -5193,7 +5198,6 @@
|
||||
"resolved": "https://registry.npmjs.org/genkit/-/genkit-1.29.0.tgz",
|
||||
"integrity": "sha512-m0oqw4AU8l6LTELH/0JmWPUA5ZuEVwm4ZhgUaVopy86gqgWyDF+SdNIpIxtXqdTmxxQHK9stQXuq/zJUDCLF4Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@genkit-ai/ai": "1.29.0",
|
||||
"@genkit-ai/core": "1.29.0",
|
||||
@@ -8039,7 +8043,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@google-cloud/firestore": "^7.11.6",
|
||||
"@google-cloud/storage": "^7.17.1",
|
||||
"axios": "^1.11.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"expo-server-sdk": "^4.0.0",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
"firebase-admin": "^12.6.0",
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -105,7 +105,7 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
|
||||
)
|
||||
const tokens = Array.from(tokensSet)
|
||||
|
||||
if (!tokens?.length) {
|
||||
if (tokens.length) {
|
||||
await sendExpoNotification({
|
||||
tokens,
|
||||
receiverId: receiver,
|
||||
|
||||
Reference in New Issue
Block a user