From c8656d62736b038bb19f2e000096921454e572b2 Mon Sep 17 00:00:00 2001 From: Thomas Demirdjian Date: Tue, 4 Aug 2026 10:59:27 +0200 Subject: [PATCH] heygen --- .minuitbuild-metro.log | 4 - app.json | 3 +- functions/.env.example | 3 + functions/config/secrets.js | 6 + functions/index.js | 9 + functions/package-lock.json | 23 +- functions/package.json | 1 + functions/src/heygen.js | 481 ++++++++++++++++++ functions/src/notifications.js | 2 +- ios/Podfile | 10 +- plugins/withXcode27Compatibility.js | 225 ++++++++ src/config/firebase.js | 62 +-- src/navigation/MainStack.js | 10 + src/navigation/Routes.js | 2 + src/providers/PlayerProvider.js | 2 + src/screens/Playback/Playback.js | 79 ++- src/screens/Playback/PlaybackAi.js | 245 +++++++++ src/screens/Playback/PlaybackAiStatus.js | 279 ++++++++++ src/screens/Playback/PlaybackAiStatus.web.js | 283 +++++++++++ .../Playbacks/components/PlaybackItem.js | 22 +- src/screens/Production/PlaybackDownload.js | 11 +- src/screens/Studio/ComposeSong.js | 7 +- src/screens/Studio/ComposeSong.web.js | 13 +- src/utils/playbackAi.js | 30 ++ src/utils/projectStages.js | 6 + 25 files changed, 1743 insertions(+), 75 deletions(-) create mode 100644 functions/.env.example create mode 100644 functions/src/heygen.js create mode 100644 plugins/withXcode27Compatibility.js create mode 100644 src/screens/Playback/PlaybackAi.js create mode 100644 src/screens/Playback/PlaybackAiStatus.js create mode 100644 src/screens/Playback/PlaybackAiStatus.web.js create mode 100644 src/utils/playbackAi.js diff --git a/.minuitbuild-metro.log b/.minuitbuild-metro.log index f2e4fbd..ef9bf6c 100644 --- a/.minuitbuild-metro.log +++ b/.minuitbuild-metro.log @@ -3,7 +3,3 @@ Starting Metro Bundler warning: Bundler cache is empty, rebuilding (this may take a minute) Waiting on http://localhost:8081 Logs for your project will appear below. -Web Bundled 3390ms index.web.js (1956 modules) -Web Bundled 49ms index.web.js (1 module) - LOG [web] Logs will appear in the browser console - LOG [web] Logs will appear in the browser console diff --git a/app.json b/app.json index 3a607a5..9139fb2 100644 --- a/app.json +++ b/app.json @@ -110,6 +110,7 @@ } ], "./plugins/withFmtConstevalWorkaround", + "./plugins/withXcode27Compatibility", [ "expo-document-picker", { @@ -134,4 +135,4 @@ } } } -} \ No newline at end of file +} diff --git a/functions/.env.example b/functions/.env.example new file mode 100644 index 0000000..ad166a1 --- /dev/null +++ b/functions/.env.example @@ -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 diff --git a/functions/config/secrets.js b/functions/config/secrets.js index 764e6d1..3debe73 100644 --- a/functions/config/secrets.js +++ b/functions/config/secrets.js @@ -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, } diff --git a/functions/index.js b/functions/index.js index 9695fcd..8ec895e 100644 --- a/functions/index.js +++ b/functions/index.js @@ -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') diff --git a/functions/package-lock.json b/functions/package-lock.json index 1fffc23..af45858 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -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" } diff --git a/functions/package.json b/functions/package.json index 2cb2dcf..7e068fb 100644 --- a/functions/package.json +++ b/functions/package.json @@ -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", diff --git a/functions/src/heygen.js b/functions/src/heygen.js new file mode 100644 index 0000000..292e844 --- /dev/null +++ b/functions/src/heygen.js @@ -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' }) + } + } +) diff --git a/functions/src/notifications.js b/functions/src/notifications.js index 21d5b16..14b1843 100644 --- a/functions/src/notifications.js +++ b/functions/src/notifications.js @@ -105,7 +105,7 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated( ) const tokens = Array.from(tokensSet) - if (!tokens?.length) { + if (tokens.length) { await sendExpoNotification({ tokens, receiverId: receiver, diff --git a/ios/Podfile b/ios/Podfile index c7a4979..b7c6b58 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -7,7 +7,8 @@ podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties ENV['RCT_NEW_ARCH_ENABLED'] = podfile_properties['newArchEnabled'] == 'true' ? '1' : '0' ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] = podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR'] -platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1' +ios_deployment_target = podfile_properties['ios.deploymentTarget'] || '15.1' +platform :ios, ios_deployment_target install! 'cocoapods', :deterministic_uuids => false @@ -52,6 +53,13 @@ target 'MusicLand' do :ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true', ) + # Xcode 27 minimum deployment target compatibility + installer.pods_project.targets.each do |target| + target.build_configurations.each do |build_configuration| + build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = ios_deployment_target + end + end + # Workaround for Xcode/clang consteval regression with fmt 11.0.2. # Keep this until React Native ships a newer fmt pod. fmt_base_header = File.join(installer.sandbox.root.to_s, 'fmt', 'include', 'fmt', 'base.h') diff --git a/plugins/withXcode27Compatibility.js b/plugins/withXcode27Compatibility.js new file mode 100644 index 0000000..bf9ba5e --- /dev/null +++ b/plugins/withXcode27Compatibility.js @@ -0,0 +1,225 @@ +const fs = require("fs"); +const path = require("path"); +const { + createRunOncePlugin, + withAppDelegate, + withDangerousMod, + withInfoPlist, +} = require("@expo/config-plugins"); + +const SCENE_MARKER = "Xcode 27 UIScene lifecycle compatibility"; +const DEPLOYMENT_TARGET_MARKER = "Xcode 27 minimum deployment target compatibility"; + +const SCENE_INTERFACE = `// ${SCENE_MARKER} +@interface SceneDelegate : UIResponder + +@property (nonatomic, strong) UIWindow *window; + +@end`; + +const SCENE_CONFIGURATION = `- (UISceneConfiguration *)application:(UIApplication *)application + configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession + options:(UISceneConnectionOptions *)options +{ + UISceneConfiguration *configuration = + [[UISceneConfiguration alloc] initWithName:@"Default Configuration" + sessionRole:connectingSceneSession.role]; + configuration.delegateClass = [SceneDelegate class]; + return configuration; +} +`; + +const SCENE_IMPLEMENTATION = `@implementation SceneDelegate + +- (void)scene:(UIScene *)scene + willConnectToSession:(UISceneSession *)session + options:(UISceneConnectionOptions *)connectionOptions +{ + UIWindowScene *windowScene = (UIWindowScene *)scene; + AppDelegate *appDelegate = (AppDelegate *)UIApplication.sharedApplication.delegate; + + self.window = appDelegate.window; + self.window.windowScene = windowScene; + [self.window makeKeyAndVisible]; + + if (connectionOptions.URLContexts.count > 0) { + [self scene:scene openURLContexts:connectionOptions.URLContexts]; + } + + for (NSUserActivity *userActivity in connectionOptions.userActivities) { + [self scene:scene continueUserActivity:userActivity]; + } +} + +- (void)sceneDidBecomeActive:(UIScene *)scene +{ + AppDelegate *appDelegate = (AppDelegate *)UIApplication.sharedApplication.delegate; + [appDelegate applicationDidBecomeActive:UIApplication.sharedApplication]; +} + +- (void)sceneWillResignActive:(UIScene *)scene +{ + AppDelegate *appDelegate = (AppDelegate *)UIApplication.sharedApplication.delegate; + [appDelegate applicationWillResignActive:UIApplication.sharedApplication]; +} + +- (void)sceneWillEnterForeground:(UIScene *)scene +{ + AppDelegate *appDelegate = (AppDelegate *)UIApplication.sharedApplication.delegate; + [appDelegate applicationWillEnterForeground:UIApplication.sharedApplication]; +} + +- (void)sceneDidEnterBackground:(UIScene *)scene +{ + AppDelegate *appDelegate = (AppDelegate *)UIApplication.sharedApplication.delegate; + [appDelegate applicationDidEnterBackground:UIApplication.sharedApplication]; +} + +- (void)scene:(UIScene *)scene openURLContexts:(NSSet *)URLContexts +{ + AppDelegate *appDelegate = (AppDelegate *)UIApplication.sharedApplication.delegate; + + for (UIOpenURLContext *urlContext in URLContexts) { + NSMutableDictionary *options = [NSMutableDictionary dictionary]; + options[UIApplicationOpenURLOptionsOpenInPlaceKey] = @(urlContext.options.openInPlace); + + if (urlContext.options.sourceApplication != nil) { + options[UIApplicationOpenURLOptionsSourceApplicationKey] = urlContext.options.sourceApplication; + } + if (urlContext.options.annotation != nil) { + options[UIApplicationOpenURLOptionsAnnotationKey] = urlContext.options.annotation; + } + + [appDelegate application:UIApplication.sharedApplication openURL:urlContext.URL options:options]; + } +} + +- (void)scene:(UIScene *)scene continueUserActivity:(NSUserActivity *)userActivity +{ + AppDelegate *appDelegate = (AppDelegate *)UIApplication.sharedApplication.delegate; + [appDelegate application:UIApplication.sharedApplication + continueUserActivity:userActivity + restorationHandler:^(NSArray> *restorableObjects) { + }]; +} + +@end`; + +const DEPLOYMENT_TARGET_BLOCK = ` # ${DEPLOYMENT_TARGET_MARKER} + installer.pods_project.targets.each do |target| + target.build_configurations.each do |build_configuration| + build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = ios_deployment_target + end + end`; + +function applySceneLifecycle(contents) { + if (contents.includes(SCENE_MARKER)) { + return contents; + } + + const importAnchor = "#import "; + const configurationAnchor = "- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge"; + + if (!contents.includes(importAnchor) || !contents.includes(configurationAnchor)) { + throw new Error( + "withXcode27Compatibility: could not find the Objective-C AppDelegate anchors." + ); + } + + const withInterface = contents.replace( + importAnchor, + `${importAnchor}\n\n${SCENE_INTERFACE}` + ); + const withConfiguration = withInterface.replace( + configurationAnchor, + `${SCENE_CONFIGURATION}\n${configurationAnchor}` + ); + + return `${withConfiguration.trimEnd()}\n\n${SCENE_IMPLEMENTATION}\n`; +} + +function applyDeploymentTarget(contents) { + let patched = contents; + + if (!patched.includes("ios_deployment_target =")) { + const platformLine = + "platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'"; + if (!patched.includes(platformLine)) { + throw new Error( + "withXcode27Compatibility: could not find the iOS platform declaration." + ); + } + patched = patched.replace( + platformLine, + "ios_deployment_target = podfile_properties['ios.deploymentTarget'] || '15.1'\n" + + "platform :ios, ios_deployment_target" + ); + } + + if (!patched.includes(DEPLOYMENT_TARGET_MARKER)) { + const anchor = + " # This is necessary for Xcode 14, because it signs resource bundles by default"; + if (!patched.includes(anchor)) { + throw new Error( + "withXcode27Compatibility: could not find the Podfile post-install anchor." + ); + } + patched = patched.replace( + anchor, + `${DEPLOYMENT_TARGET_BLOCK}\n\n${anchor}` + ); + } + + return patched; +} + +const withXcode27Compatibility = (config) => { + config = withInfoPlist(config, (config) => { + config.modResults.UIApplicationSceneManifest = { + UIApplicationSupportsMultipleScenes: false, + UISceneConfigurations: { + UIWindowSceneSessionRoleApplication: [ + { + UISceneConfigurationName: "Default Configuration", + UISceneDelegateClassName: "SceneDelegate", + }, + ], + }, + }; + return config; + }); + + config = withAppDelegate(config, (config) => { + if (!["objc", "objcpp"].includes(config.modResults.language)) { + throw new Error( + "withXcode27Compatibility: expected an Objective-C AppDelegate." + ); + } + config.modResults.contents = applySceneLifecycle(config.modResults.contents); + return config; + }); + + config = withDangerousMod(config, [ + "ios", + async (config) => { + const podfilePath = path.join( + config.modRequest.platformProjectRoot, + "Podfile" + ); + const podfile = fs.readFileSync(podfilePath, "utf8"); + const patched = applyDeploymentTarget(podfile); + if (patched !== podfile) { + fs.writeFileSync(podfilePath, patched); + } + return config; + }, + ]); + + return config; +}; + +module.exports = createRunOncePlugin( + withXcode27Compatibility, + "with-xcode-27-compatibility", + "1.0.0" +); diff --git a/src/config/firebase.js b/src/config/firebase.js index 56f234f..58f0c1e 100644 --- a/src/config/firebase.js +++ b/src/config/firebase.js @@ -1,18 +1,18 @@ -import AsyncStorage from "@react-native-async-storage/async-storage" -import { getReactNativePersistence, initializeAuth } from "firebase/auth/react-native" -import firebase from "firebase/compat/app" -import "firebase/compat/auth" -import "firebase/compat/firestore" -import "firebase/compat/functions" -import "firebase/compat/storage" -import { Platform } from "react-native" +import AsyncStorage from '@react-native-async-storage/async-storage' +import { getReactNativePersistence, initializeAuth } from 'firebase/auth/react-native' +import firebase from 'firebase/compat/app' +import 'firebase/compat/auth' +import 'firebase/compat/firestore' +import 'firebase/compat/functions' +import 'firebase/compat/storage' +import { Platform } from 'react-native' const functionsInstances = {} const emulatorConfigured = {} -const emulatorHost = Platform.OS === "web" ? "localhost" : "192.168.1.114" +const emulatorHost = Platform.OS === 'web' ? 'localhost' : '192.168.1.60' const USE_FUNCTIONS_EMULATOR = false // Toggle to route functions traffic to the local emulator. -const configureFunctionsEmulator = (instance, regionKey = "us-central1") => { +const configureFunctionsEmulator = (instance, regionKey = 'us-central1') => { const shouldUseEmulator = USE_FUNCTIONS_EMULATOR && instance?.useEmulator if (!shouldUseEmulator || emulatorConfigured[regionKey]) { @@ -31,16 +31,16 @@ const configureFunctionsEmulator = (instance, regionKey = "us-central1") => { } export const firebaseConfig = { - apiKey: "AIzaSyCuHJHdwVN_F-VmUG4Hd7bGiMRqj6rPlLo", - authDomain: "musicland-d33f9.firebaseapp.com", - projectId: "musicland-d33f9", - storageBucket: "musicland-d33f9.firebasestorage.app", - messagingSenderId: "305598753437", - appId: "1:305598753437:web:9930f10af59e5cf7f28274", - measurementId: "G-RV4MS1SVTT", + apiKey: 'AIzaSyCuHJHdwVN_F-VmUG4Hd7bGiMRqj6rPlLo', + authDomain: 'musicland-d33f9.firebaseapp.com', + projectId: 'musicland-d33f9', + storageBucket: 'musicland-d33f9.firebasestorage.app', + messagingSenderId: '305598753437', + appId: '1:305598753437:web:9930f10af59e5cf7f28274', + measurementId: 'G-RV4MS1SVTT', } -if (!firebase?.apps?.filter(({ name_ }) => name_ === "[DEFAULT]").length) { +if (!firebase?.apps?.filter(({ name_ }) => name_ === '[DEFAULT]').length) { const defaultApp = firebase.initializeApp(firebaseConfig) initializeAuth(defaultApp, { @@ -51,9 +51,9 @@ if (!firebase?.apps?.filter(({ name_ }) => name_ === "[DEFAULT]").length) { // firebase.functions().useEmulator("localhost", 5001); // } const defaultFunctions = firebase.functions() - functionsInstances["us-central1"] = defaultFunctions - configureFunctionsEmulator(defaultFunctions, "us-central1") - console.log("Firebase init") + functionsInstances['us-central1'] = defaultFunctions + configureFunctionsEmulator(defaultFunctions, 'us-central1') + console.log('Firebase init') } // Firestore settings for React Native/iOS: avoid streaming transport issues @@ -69,20 +69,20 @@ try { // Ignore if settings were already set elsewhere } -export const usersRef = firestore.collection("users") -export const projectsRef = firestore.collection("projects") -export const tasksRef = firestore.collection("tasks") -export const playlistsRef = firestore.collection("playlists") -export const chatsRef = firestore.collection("chats") -export const videosRef = firestore.collection("videos") -export const notificationsRef = firestore.collection("notifications") -export const reportsRef = firestore.collection("reports") +export const usersRef = firestore.collection('users') +export const projectsRef = firestore.collection('projects') +export const tasksRef = firestore.collection('tasks') +export const playlistsRef = firestore.collection('playlists') +export const chatsRef = firestore.collection('chats') +export const videosRef = firestore.collection('videos') +export const notificationsRef = firestore.collection('notifications') +export const reportsRef = firestore.collection('reports') export const { arrayUnion, arrayRemove, increment, serverTimestamp } = firebase.firestore.FieldValue export const deleteField = firebase.firestore.FieldValue.delete -export const getFunctionsClient = (region = "us-central1") => { - const regionKey = region || "us-central1" +export const getFunctionsClient = (region = 'us-central1') => { + const regionKey = region || 'us-central1' if (!functionsInstances[regionKey]) { functionsInstances[regionKey] = firebase.app().functions(regionKey) diff --git a/src/navigation/MainStack.js b/src/navigation/MainStack.js index fac7909..55194c1 100644 --- a/src/navigation/MainStack.js +++ b/src/navigation/MainStack.js @@ -18,6 +18,8 @@ import ResetPassword from '../screens/ResetPassword' import ChooseDecor from '../screens/Playback/ChooseDecor' import CreatingDecor from '../screens/Playback/CreatingDecor' import Playback from '../screens/Playback/Playback' +import PlaybackAi from '../screens/Playback/PlaybackAi' +import PlaybackAiStatus from '../screens/Playback/PlaybackAiStatus' import PlaybackGuide from '../screens/Playback/PlaybackGuide' import PlaybackOnboarding from '../screens/Playback/PlaybackOnboarding' import RecordPlayback from '../screens/Playback/RecordPlayback' @@ -235,6 +237,14 @@ const baseScreens = [ name: Routes.PlaybackGuide, component: PlaybackGuide, }, + { + name: Routes.PlaybackAi, + component: PlaybackAi, + }, + { + name: Routes.PlaybackAiStatus, + component: PlaybackAiStatus, + }, { name: Routes.RecordPlayback, component: RecordPlayback, diff --git a/src/navigation/Routes.js b/src/navigation/Routes.js index 6e80645..42ee0dd 100644 --- a/src/navigation/Routes.js +++ b/src/navigation/Routes.js @@ -58,6 +58,8 @@ export const Routes = { PlaybackOnboarding: 'PlaybackOnboarding', Playback: 'Playback', PlaybackGuide: 'PlaybackGuide', + PlaybackAi: 'PlaybackAi', + PlaybackAiStatus: 'PlaybackAiStatus', RecordPlayback: 'RecordPlayback', RecordedPlayback: 'RecordedPlayback', ChooseDecor: 'ChooseDecor', diff --git a/src/providers/PlayerProvider.js b/src/providers/PlayerProvider.js index f074ca1..f432a8c 100644 --- a/src/providers/PlayerProvider.js +++ b/src/providers/PlayerProvider.js @@ -36,6 +36,8 @@ const HIDDEN_ROUTE_NAMES = new Set([ Routes.PlaybackOnboarding, Routes.Playback, Routes.PlaybackGuide, + Routes.PlaybackAi, + Routes.PlaybackAiStatus, Routes.Playbacks, Routes.RecordPlayback, Routes.RecordedPlayback, diff --git a/src/screens/Playback/Playback.js b/src/screens/Playback/Playback.js index 5dff195..66260ae 100644 --- a/src/screens/Playback/Playback.js +++ b/src/screens/Playback/Playback.js @@ -1,5 +1,6 @@ -import React, { useCallback } from 'react' -import { StyleSheet, View } from 'react-native' +import React, { useCallback, useMemo } from 'react' +import { StyleSheet, Text, View } from 'react-native' +import { useIsFocused } from '@react-navigation/native' import { background } from '../../assets' import BackgroundVideo from '../../components/BackgroundVideo' import BorderGradientButton from '../../components/BorderGradientButton' @@ -9,13 +10,31 @@ import Page from '../../layouts/Page' import { isWeb } from '../../hooks/useLayoutType' import { Routes } from '../../navigation' import { goBack, navigate } from '../../navigation/NavigationService' -import { useUser } from '../../providers/UserDataProvider' -import { gutters } from '../../styles' +import { useUser, useUserData } from '../../providers/UserDataProvider' +import { gutters, Palette } from '../../styles' +import { + getPlaybackAiStatusLabel, + isPlaybackAiBlockingManual, + shouldResumePlaybackAi, +} from '../../utils/playbackAi' const Playback = ({ route, navigation }) => { - const { project, returnToAdventureModal = false } = route.params || {} + const isFocused = useIsFocused() + const { project: routeProject, returnToAdventureModal = false } = route.params || {} const { videos } = useUser() + const { selectedProject } = useUserData() || {} const theoUrl = isWeb ? videos?.theoWeb : videos?.theo + const project = useMemo(() => { + if (routeProject?.id && selectedProject?.id === routeProject.id) { + return selectedProject + } + return routeProject || null + }, [routeProject, selectedProject]) + const aiStatus = project?.playbackStatus || null + const isManualBlocked = isPlaybackAiBlockingManual(project) + const aiButtonLabel = shouldResumePlaybackAi(project) + ? 'Voir mon playback IA' + : 'Créer mon playback IA' const handleBackPress = useCallback(() => { if (returnToAdventureModal) { @@ -26,18 +45,29 @@ const Playback = ({ route, navigation }) => { }, [navigation, returnToAdventureModal]) const onPressRecord = useCallback(() => { + if (isManualBlocked) return navigate(Routes.RecordPlayback, { project, returnToAdventureModal }) - }, [project, returnToAdventureModal]) + }, [isManualBlocked, project, returnToAdventureModal]) + + const onPressPlaybackAi = useCallback(() => { + if (shouldResumePlaybackAi(project)) { + navigate(Routes.PlaybackAiStatus, { project }) + return + } + navigate(Routes.PlaybackAi, { project }) + }, [project]) return ( + isFocused ? ( + + ) : null } containerStyle={isWeb ? styles.videoPage : undefined} > @@ -51,7 +81,21 @@ const Playback = ({ route, navigation }) => { }} > - + + + {isManualBlocked ? ( + + Le playback manuel est bloqué tant que le playback IA est en cours ou en attente de + validation. + + ) : null} + {shouldResumePlaybackAi(project) ? ( + {getPlaybackAiStatusLabel(aiStatus)} + ) : null} navigate(Routes.PlaybackGuide, { returnToAdventureModal })} @@ -74,4 +118,17 @@ const styles = StyleSheet.create({ right: 24, left: 'auto', }, + blockedText: { + color: Palette.white, + opacity: 0.75, + fontSize: 12, + lineHeight: 18, + textAlign: 'center', + }, + statusText: { + color: Palette.white, + opacity: 0.55, + fontSize: 12, + textAlign: 'center', + }, }) diff --git a/src/screens/Playback/PlaybackAi.js b/src/screens/Playback/PlaybackAi.js new file mode 100644 index 0000000..75d57df --- /dev/null +++ b/src/screens/Playback/PlaybackAi.js @@ -0,0 +1,245 @@ +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, 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 trimString = (value) => (typeof value === 'string' ? value.trim() : '') + +const isRemoteUrl = (value) => /^https?:\/\//i.test(trimString(value)) + +const resolveProject = ({ routeProject, selectedProject }) => { + if (routeProject?.id && selectedProject?.id === routeProject.id) { + return selectedProject + } + return routeProject || null +} + +const getInitialPhotoUri = ({ routePhotoUrl, project }) => + trimString(routePhotoUrl) || trimString(project?.playbackPhotoUrl) || null + +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 [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 nextUri = result?.assets?.[0]?.uri || null + if (nextUri) { + setSelectedPhotoUri(nextUri) + } + } catch (error) { + setTooltip({ + type: 'error', + text: error?.message || 'Impossible de sélectionner une photo', + }) + } + } + + const handleGenerate = async () => { + 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)) { + const extension = trimString(selectedPhotoUri.split('.').pop()) || 'jpg' + const uploadResult = await uploadFileToFirebase({ + uri: selectedPhotoUri, + path: `users/${currentUID}/projects/${project.id}/heygen/photo-${Date.now()}.${extension}`, + shouldCompress: true, + fileType: 'IMAGE', + }) + photoUrl = trimString(uploadResult?.resultURI) + } + + if (!photoUrl) { + throw new Error("Impossible d'envoyer la photo vers Firebase") + } + + await callStartPlaybackGeneration({ + projectId: project.id, + photoUrl, + }) + + navigate(Routes.PlaybackAiStatus, { + project: { + ...project, + playbackStatus: PLAYBACK_AI_STATUS.GENERATING, + playbackGenerating: true, + playbackPhotoUrl: photoUrl, + }, + }) + } catch (error) { + setTooltip({ + type: 'error', + text: error?.message || 'Erreur lors du lancement du playback IA', + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + + + + + {project?.title || 'Sans titre'} + + 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. + + + + + {selectedPhotoUri ? ( + + ) : ( + + Aucune photo sélectionnée + + )} + + + + + + {isSubmitting ? ( + + ) : null} + + + + + ) +} + +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 diff --git a/src/screens/Playback/PlaybackAiStatus.js b/src/screens/Playback/PlaybackAiStatus.js new file mode 100644 index 0000000..f576de8 --- /dev/null +++ b/src/screens/Playback/PlaybackAiStatus.js @@ -0,0 +1,279 @@ +import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' +import { VideoView, useVideoPlayer } from 'expo-video' +import React, { useMemo, useState } from 'react' +import { Image, Text, View } from 'react-native' +import { background } from '../../assets' +import useDataFromRef from '../../hooks/useDataFromRef' +import ActivityLoader from '../../components/ActivityLoader' +import BorderGradientButton from '../../components/BorderGradientButton' +import GradientButton from '../../components/GradientButton' +import MusicLandHeader from '../../components/MusicLandHeader' +import { getFunctionsClient, projectsRef } from '../../config/firebase' +import Page from '../../layouts/Page' +import { Routes } from '../../navigation' +import { goBack, navigate, push } from '../../navigation/NavigationService' +import { useUserData } from '../../providers/UserDataProvider' +import { gutters, Palette } from '../../styles' +import { FONT_FAMILY } from '../../styles/Fonts' +import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader' +import { PLAYBACK_AI_STATUS } from '../../utils/playbackAi' + +const FUNCTIONS_REGION = 'europe-west1' + +const resolveProject = ({ routeProject, selectedProject, liveProject }) => { + if (liveProject?.id) return liveProject + if (routeProject?.id && selectedProject?.id === routeProject.id) { + return selectedProject + } + return routeProject || null +} + +const callValidatePlaybackDraft = (payload) => { + const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('heygen-validatePlaybackDraft') + return callable(payload) +} + +const PlaybackAiStatus = ({ route }) => { + const { project: routeProject } = route.params || {} + const { selectedProject } = useUserData() || {} + const { setTooltip } = useMinuit() + const [isValidating, setIsValidating] = useState(false) + const projectId = routeProject?.id || selectedProject?.id || null + const { data: liveProject = null } = useDataFromRef({ + ref: projectId ? projectsRef.doc(projectId) : null, + simpleRef: true, + listener: true, + condition: !!projectId, + refreshArray: [projectId], + }) + const project = useMemo( + () => resolveProject({ routeProject, selectedProject, liveProject }), + [liveProject, routeProject, selectedProject] + ) + const status = + project?.playbackStatus || + (routeProject?.playbackGenerating ? PLAYBACK_AI_STATUS.GENERATING : null) + const draftUrl = project?.playbackDraftUrl || routeProject?.playbackDraftUrl || null + const player = useVideoPlayer(draftUrl ? { uri: draftUrl } : null, (instance) => { + instance.loop = false + instance.muted = false + }) + + const handleValidate = async () => { + try { + if (!project?.id) { + throw new Error('Projet introuvable') + } + + setIsValidating(true) + await callValidatePlaybackDraft({ projectId: project.id }) + + const latestProjectSnap = await projectsRef.doc(project.id).get() + const latestProject = latestProjectSnap.exists + ? { ...latestProjectSnap.data(), id: latestProjectSnap.id } + : project + + navigate(Routes.PlaybackDownload, { + action: 'playback', + project: latestProject, + }) + } catch (error) { + setTooltip({ + type: 'error', + text: error?.message || 'Impossible de valider le playback IA', + }) + } finally { + setIsValidating(false) + } + } + + const handleRetry = () => { + push(Routes.PlaybackAi, { + project, + initialPhotoUrl: project?.playbackPhotoUrl || null, + }) + } + + return ( + + + + + + + + {project?.coverUrl ? ( + + ) : null} + + {project?.title || 'Sans titre'} + Format vertical 9:16 généré avec HeyGen + + + + {status === PLAYBACK_AI_STATUS.GENERATING ? ( + + + + Tu peux fermer cet écran et revenir plus tard. Une notification te sera envoyée dès + que ta vidéo sera prête. + + + ) : null} + + {status === PLAYBACK_AI_STATUS.FAILED ? ( + + La génération a échoué + + {project?.playbackError || 'HeyGen n’a pas réussi à générer la vidéo.'} + + + + ) : null} + + {status === PLAYBACK_AI_STATUS.DRAFT_READY ? ( + <> + + {draftUrl ? ( + + ) : ( + + La vidéo HeyGen est introuvable. + + )} + + + + + + {isValidating ? ( + + ) : null} + + + ) : null} + + {status === PLAYBACK_AI_STATUS.READY && project?.playbackUrl ? ( + + Le playback IA est déjà validé + + navigate(Routes.PlaybackDownload, { + action: 'playback', + project, + }) + } + /> + + ) : null} + + + + ) +} + +const styles = { + content: { + flex: 1, + width: '92%', + alignSelf: 'center', + gap: 16, + }, + headerCard: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + padding: 16, + borderRadius: 18, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.14)', + backgroundColor: 'rgba(5,12,24,0.76)', + }, + cover: { + width: 68, + height: 68, + borderRadius: 14, + }, + title: { + color: Palette.white, + fontSize: 18, + fontFamily: FONT_FAMILY.InterSemiBold, + }, + subtitle: { + color: Palette.white, + opacity: 0.7, + fontFamily: FONT_FAMILY.InterRegular, + }, + stateCard: { + flex: 1, + minHeight: 240, + borderRadius: 20, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.14)', + backgroundColor: 'rgba(5,12,24,0.8)', + alignItems: 'center', + justifyContent: 'center', + gap: 14, + paddingHorizontal: 20, + }, + videoCard: { + width: '60%', + maxWidth: 360, + aspectRatio: 9 / 16, + alignSelf: 'center', + borderRadius: 22, + overflow: 'hidden', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.14)', + backgroundColor: 'rgba(5,12,24,0.8)', + }, + video: { + width: '100%', + height: '100%', + backgroundColor: '#0A0A0A', + }, + emptyVideo: { + alignItems: 'center', + justifyContent: 'center', + }, + helperText: { + color: Palette.white, + opacity: 0.72, + fontFamily: FONT_FAMILY.InterRegular, + lineHeight: 20, + textAlign: 'center', + }, + errorTitle: { + color: Palette.white, + fontSize: 18, + fontFamily: FONT_FAMILY.InterSemiBold, + }, + successTitle: { + color: Palette.white, + fontSize: 18, + fontFamily: FONT_FAMILY.InterSemiBold, + }, + actions: { + gap: 12, + }, +} + +export default PlaybackAiStatus diff --git a/src/screens/Playback/PlaybackAiStatus.web.js b/src/screens/Playback/PlaybackAiStatus.web.js new file mode 100644 index 0000000..f1dfc50 --- /dev/null +++ b/src/screens/Playback/PlaybackAiStatus.web.js @@ -0,0 +1,283 @@ +import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' +import React, { useMemo, useState } from 'react' +import { Image, Text, View } from 'react-native' +import { background } from '../../assets' +import useDataFromRef from '../../hooks/useDataFromRef' +import ActivityLoader from '../../components/ActivityLoader' +import BorderGradientButton from '../../components/BorderGradientButton' +import GradientButton from '../../components/GradientButton' +import MusicLandHeader from '../../components/MusicLandHeader' +import { getFunctionsClient, projectsRef } from '../../config/firebase' +import Page from '../../layouts/Page' +import { Routes } from '../../navigation' +import { goBack, navigate, push } from '../../navigation/NavigationService' +import { useUserData } from '../../providers/UserDataProvider' +import { gutters, Palette } from '../../styles' +import { FONT_FAMILY } from '../../styles/Fonts' +import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader' +import { PLAYBACK_AI_STATUS } from '../../utils/playbackAi' + +const FUNCTIONS_REGION = 'europe-west1' + +const resolveProject = ({ routeProject, selectedProject, liveProject }) => { + if (liveProject?.id) return liveProject + if (routeProject?.id && selectedProject?.id === routeProject.id) { + return selectedProject + } + return routeProject || null +} + +const callValidatePlaybackDraft = (payload) => { + const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('heygen-validatePlaybackDraft') + return callable(payload) +} + +const PlaybackAiStatus = ({ route }) => { + const { project: routeProject } = route.params || {} + const { selectedProject } = useUserData() || {} + const { setTooltip } = useMinuit() + const [isValidating, setIsValidating] = useState(false) + const projectId = routeProject?.id || selectedProject?.id || null + const { data: liveProject = null } = useDataFromRef({ + ref: projectId ? projectsRef.doc(projectId) : null, + simpleRef: true, + listener: true, + condition: !!projectId, + refreshArray: [projectId], + }) + const project = useMemo( + () => resolveProject({ routeProject, selectedProject, liveProject }), + [liveProject, routeProject, selectedProject] + ) + const status = + project?.playbackStatus || + (routeProject?.playbackGenerating ? PLAYBACK_AI_STATUS.GENERATING : null) + const draftUrl = project?.playbackDraftUrl || routeProject?.playbackDraftUrl || null + + const handleValidate = async () => { + try { + if (!project?.id) { + throw new Error('Projet introuvable') + } + + setIsValidating(true) + await callValidatePlaybackDraft({ projectId: project.id }) + + const latestProjectSnap = await projectsRef.doc(project.id).get() + const latestProject = latestProjectSnap.exists + ? { ...latestProjectSnap.data(), id: latestProjectSnap.id } + : project + + navigate(Routes.PlaybackDownload, { + action: 'playback', + project: latestProject, + }) + } catch (error) { + setTooltip({ + type: 'error', + text: error?.message || 'Impossible de valider le playback IA', + }) + } finally { + setIsValidating(false) + } + } + + const handleRetry = () => { + push(Routes.PlaybackAi, { + project, + initialPhotoUrl: project?.playbackPhotoUrl || null, + }) + } + + return ( + + + + + + + + {project?.coverUrl ? ( + + ) : null} + + {project?.title || 'Sans titre'} + Format vertical 9:16 généré avec HeyGen + + + + {status === PLAYBACK_AI_STATUS.GENERATING ? ( + + + + Tu peux fermer cet écran et revenir plus tard. Une notification te sera envoyée dès + que ta vidéo sera prête. + + + ) : null} + + {status === PLAYBACK_AI_STATUS.FAILED ? ( + + La génération a échoué + + {project?.playbackError || 'HeyGen n’a pas réussi à générer la vidéo.'} + + + + ) : null} + + {status === PLAYBACK_AI_STATUS.DRAFT_READY ? ( + <> + + {draftUrl ? ( + + + + + + {isValidating ? ( + + ) : null} + + + ) : null} + + {status === PLAYBACK_AI_STATUS.READY && project?.playbackUrl ? ( + + Le playback IA est déjà validé + + navigate(Routes.PlaybackDownload, { + action: 'playback', + project, + }) + } + /> + + ) : null} + + + + ) +} + +const styles = { + content: { + flex: 1, + width: '92%', + alignSelf: 'center', + gap: 16, + }, + headerCard: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + padding: 16, + borderRadius: 18, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.14)', + backgroundColor: 'rgba(5,12,24,0.76)', + }, + cover: { + width: 68, + height: 68, + borderRadius: 14, + }, + title: { + color: Palette.white, + fontSize: 18, + fontFamily: FONT_FAMILY.InterSemiBold, + }, + subtitle: { + color: Palette.white, + opacity: 0.7, + fontFamily: FONT_FAMILY.InterRegular, + }, + stateCard: { + flex: 1, + minHeight: 240, + borderRadius: 20, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.14)', + backgroundColor: 'rgba(5,12,24,0.8)', + alignItems: 'center', + justifyContent: 'center', + gap: 14, + paddingHorizontal: 20, + }, + videoCard: { + width: '60%', + maxWidth: 360, + aspectRatio: 9 / 16, + alignSelf: 'center', + borderRadius: 22, + overflow: 'hidden', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.14)', + backgroundColor: 'rgba(5,12,24,0.8)', + padding: 12, + }, + video: { + width: '100%', + height: '100%', + borderRadius: 18, + backgroundColor: '#0A0A0A', + objectFit: 'contain', + }, + videoFallback: { + width: '100%', + height: '100%', + borderRadius: 18, + backgroundColor: '#0A0A0A', + }, + emptyVideo: { + alignItems: 'center', + justifyContent: 'center', + }, + helperText: { + color: Palette.white, + opacity: 0.72, + fontFamily: FONT_FAMILY.InterRegular, + lineHeight: 20, + textAlign: 'center', + }, + errorTitle: { + color: Palette.white, + fontSize: 18, + fontFamily: FONT_FAMILY.InterSemiBold, + }, + successTitle: { + color: Palette.white, + fontSize: 18, + fontFamily: FONT_FAMILY.InterSemiBold, + }, + actions: { + gap: 12, + }, +} + +export default PlaybackAiStatus diff --git a/src/screens/Playbacks/components/PlaybackItem.js b/src/screens/Playbacks/components/PlaybackItem.js index eb0f334..7a8f651 100644 --- a/src/screens/Playbacks/components/PlaybackItem.js +++ b/src/screens/Playbacks/components/PlaybackItem.js @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { View, StyleSheet } from 'react-native' +import { Pressable, View, StyleSheet } from 'react-native' import { responsiveHeight } from 'react-native-responsive-dimensions' import { SheetManager } from 'react-native-actions-sheet' import { useUser } from '../../../providers/UserDataProvider' @@ -53,6 +53,18 @@ const PlaybackItem = ({ item, isActive, userCache }) => { isActive, }) + const togglePlayback = useCallback(() => { + if (!isActive || !videoUrl || !videoPlayer) return + + try { + if (videoPlayer.playing) { + videoPlayer.pause() + } else { + videoPlayer.play() + } + } catch (e) {} + }, [isActive, videoPlayer, videoUrl]) + const alignedWords = useMemo(() => { const idx = Number(item?.songIndex) || 0 const ts = item?.musicTimestamps?.[idx] @@ -124,7 +136,11 @@ const PlaybackItem = ({ item, isActive, userCache }) => { }, [item?.userId]) return ( - + { fallbackTitle={item?.title} /> - + ) } diff --git a/src/screens/Production/PlaybackDownload.js b/src/screens/Production/PlaybackDownload.js index 06d7f07..b54c7b7 100644 --- a/src/screens/Production/PlaybackDownload.js +++ b/src/screens/Production/PlaybackDownload.js @@ -4,6 +4,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import * as FileSystem from 'expo-file-system' import { shareAsync } from 'expo-sharing' +import { background } from '../../assets' import FullscreenIntroVideo from '../../components/FullscreenIntroVideo' import BorderGradientButton from '../../components/BorderGradientButton' import MusicLandHeader from '../../components/MusicLandHeader' @@ -424,12 +425,10 @@ const PlaybackDownload = ({ route }) => { onClose={() => setIsAfterPlaybackVideoVisible(false)} /> diff --git a/src/screens/Studio/ComposeSong.js b/src/screens/Studio/ComposeSong.js index 8619dcc..465d7f7 100644 --- a/src/screens/Studio/ComposeSong.js +++ b/src/screens/Studio/ComposeSong.js @@ -1,5 +1,5 @@ import React, { useMemo, useRef, useState } from 'react' -import { useRoute } from '@react-navigation/native' +import { useIsFocused, useRoute } from '@react-navigation/native' import { Dimensions, Modal, Text, View } from 'react-native' import SwiperFlatList from 'react-native-swiper-flatlist' import { background, icons } from '../../assets' @@ -37,6 +37,7 @@ const INSTRUMENT_STEP_INDEX = FIRST_VOICE_STEP_INDEX + VOICE_SECTION_TITLES.leng const RHYTHM_STEP_INDEX = LAST_STEP_INDEX const ComposeSong = () => { + const isFocused = useIsFocused() const scrollRef = useRef(null) const [selectedIndex, setSelectedIndex] = useState(0) const [progress, setProgress] = useState(18) @@ -295,7 +296,9 @@ const ComposeSong = () => { return ( } + backgroundContent={ + isFocused ? : null + } headerType="NONE" > diff --git a/src/screens/Studio/ComposeSong.web.js b/src/screens/Studio/ComposeSong.web.js index 47f5cf5..2892298 100644 --- a/src/screens/Studio/ComposeSong.web.js +++ b/src/screens/Studio/ComposeSong.web.js @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useRoute } from '@react-navigation/native' +import { useIsFocused, useRoute } from '@react-navigation/native' import { FlatList, Modal, StyleSheet, Text, View, useWindowDimensions } from 'react-native' import { background } from '../../assets' import BackgroundVideo from '../../components/BackgroundVideo' @@ -34,6 +34,7 @@ const PAGE_MAX_WIDTH = 1200 const PAGE_HORIZONTAL_PADDING = gutters * 2 const ComposeSong = () => { + const isFocused = useIsFocused() const scrollRef = useRef(null) const { width: windowWidth } = useWindowDimensions() const [selectedIndex, setSelectedIndex] = useState(0) @@ -298,10 +299,12 @@ const ComposeSong = () => { + isFocused ? ( + + ) : null } coinBadgeContainerStyle={styles.rightSideCredits} containerStyle={styles.videoQuestionPage} diff --git a/src/utils/playbackAi.js b/src/utils/playbackAi.js new file mode 100644 index 0000000..dd0b065 --- /dev/null +++ b/src/utils/playbackAi.js @@ -0,0 +1,30 @@ +export const PLAYBACK_AI_STATUS = { + GENERATING: 'GENERATING', + DRAFT_READY: 'DRAFT_READY', + FAILED: 'FAILED', + READY: 'READY', +} + +export const isPlaybackAiBlockingManual = (project) => + project?.playbackStatus === PLAYBACK_AI_STATUS.GENERATING || + project?.playbackStatus === PLAYBACK_AI_STATUS.DRAFT_READY + +export const shouldResumePlaybackAi = (project) => + project?.playbackStatus === PLAYBACK_AI_STATUS.GENERATING || + project?.playbackStatus === PLAYBACK_AI_STATUS.DRAFT_READY || + project?.playbackStatus === PLAYBACK_AI_STATUS.FAILED + +export const getPlaybackAiStatusLabel = (status) => { + switch (status) { + case PLAYBACK_AI_STATUS.GENERATING: + return 'Playback IA en cours de génération' + case PLAYBACK_AI_STATUS.DRAFT_READY: + return 'Playback IA prêt à être validé' + case PLAYBACK_AI_STATUS.FAILED: + return 'Le dernier playback IA a échoué' + case PLAYBACK_AI_STATUS.READY: + return 'Playback IA validé' + default: + return '' + } +} diff --git a/src/utils/projectStages.js b/src/utils/projectStages.js index 3bdf6bf..199de95 100644 --- a/src/utils/projectStages.js +++ b/src/utils/projectStages.js @@ -51,6 +51,7 @@ const getStageMetadata = (project) => { project?.playbackGenerating === true || project?.isPlaybackGenerating === true || playbackStatus === 'GENERATING' + const isPlaybackDraftReady = playbackStatus === 'DRAFT_READY' return { lyricsCount, @@ -63,6 +64,7 @@ const getStageMetadata = (project) => { musicStatus, coverStatus, playbackStatus, + isPlaybackDraftReady, youtubeStatus, youtubeError, isMusicGenerating: musicStatus === 'GENERATING', @@ -103,6 +105,7 @@ const getStageDescription = (key, metadata) => { hasMusicDraft, isMusicGenerating, isPlaybackGenerating, + isPlaybackDraftReady, } = metadata switch (key) { @@ -141,6 +144,9 @@ const getStageDescription = (key, metadata) => { if (isPlaybackGenerating) { return 'Votre playback est en cours de préparation' } + if (isPlaybackDraftReady) { + return 'Votre playback IA est prêt à être validé' + } if (hasPlaybackAsset) { return 'Modifier le playback généré' }