heygen
This commit is contained in:
@@ -3,7 +3,3 @@ Starting Metro Bundler
|
|||||||
warning: Bundler cache is empty, rebuilding (this may take a minute)
|
warning: Bundler cache is empty, rebuilding (this may take a minute)
|
||||||
Waiting on http://localhost:8081
|
Waiting on http://localhost:8081
|
||||||
Logs for your project will appear below.
|
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
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./plugins/withFmtConstevalWorkaround",
|
"./plugins/withFmtConstevalWorkaround",
|
||||||
|
"./plugins/withXcode27Compatibility",
|
||||||
[
|
[
|
||||||
"expo-document-picker",
|
"expo-document-picker",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 RESEND_API_KEY = defineSecret('RESEND_API_KEY')
|
||||||
const STRIPE_SECRET_KEY = defineSecret('STRIPE_SECRET_KEY')
|
const STRIPE_SECRET_KEY = defineSecret('STRIPE_SECRET_KEY')
|
||||||
const STRIPE_WEBHOOK_SECRET = defineSecret('STRIPE_WEBHOOK_SECRET')
|
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 = {
|
module.exports = {
|
||||||
GEMINI_API_KEY,
|
GEMINI_API_KEY,
|
||||||
@@ -12,4 +15,7 @@ module.exports = {
|
|||||||
RESEND_API_KEY,
|
RESEND_API_KEY,
|
||||||
STRIPE_SECRET_KEY,
|
STRIPE_SECRET_KEY,
|
||||||
STRIPE_WEBHOOK_SECRET,
|
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')
|
const admin = require('firebase-admin')
|
||||||
|
|
||||||
// Use default credentials/environment provided by Cloud Functions.
|
// Use default credentials/environment provided by Cloud Functions.
|
||||||
@@ -31,6 +33,7 @@ exports.ALERT_TYPE = {
|
|||||||
MUSIC_GENERATION_FAILED: 'MUSIC_GENERATION_FAILED',
|
MUSIC_GENERATION_FAILED: 'MUSIC_GENERATION_FAILED',
|
||||||
COVER_GENERATION_SUCCESS: 'COVER_GENERATION_SUCCESS',
|
COVER_GENERATION_SUCCESS: 'COVER_GENERATION_SUCCESS',
|
||||||
COVER_GENERATION_FAILED: 'COVER_GENERATION_FAILED',
|
COVER_GENERATION_FAILED: 'COVER_GENERATION_FAILED',
|
||||||
|
PLAYBACK_GENERATION_SUCCESS: 'PLAYBACK_GENERATION_SUCCESS',
|
||||||
CREDITS_UPDATED: 'CREDITS_UPDATED',
|
CREDITS_UPDATED: 'CREDITS_UPDATED',
|
||||||
PAYOUT_AVAILABLE: 'PAYOUT_AVAILABLE',
|
PAYOUT_AVAILABLE: 'PAYOUT_AVAILABLE',
|
||||||
}
|
}
|
||||||
@@ -43,6 +46,12 @@ exports.cover = require('./src/cover')
|
|||||||
exports.projects = require('./src/project')
|
exports.projects = require('./src/project')
|
||||||
exports.thumbnail = require('./src/thumbnail')
|
exports.thumbnail = require('./src/thumbnail')
|
||||||
exports.upload = require('./src/upload')
|
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.algolia = require('./src/algolia')
|
||||||
exports.notifications = require('./src/notifications')
|
exports.notifications = require('./src/notifications')
|
||||||
exports.rankings = require('./src/rankings')
|
exports.rankings = require('./src/rankings')
|
||||||
|
|||||||
Generated
+13
-10
@@ -11,6 +11,7 @@
|
|||||||
"@google-cloud/firestore": "^7.11.6",
|
"@google-cloud/firestore": "^7.11.6",
|
||||||
"@google-cloud/storage": "^7.17.1",
|
"@google-cloud/storage": "^7.17.1",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"expo-server-sdk": "^4.0.0",
|
"expo-server-sdk": "^4.0.0",
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.2.0",
|
||||||
"firebase-admin": "^12.6.0",
|
"firebase-admin": "^12.6.0",
|
||||||
@@ -548,7 +549,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz",
|
"resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz",
|
||||||
"integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==",
|
"integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/api": "^1.3.0",
|
"@opentelemetry/api": "^1.3.0",
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
@@ -1648,7 +1648,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
@@ -1744,7 +1743,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz",
|
||||||
"integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==",
|
"integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/semantic-conventions": "1.25.1"
|
"@opentelemetry/semantic-conventions": "1.25.1"
|
||||||
},
|
},
|
||||||
@@ -2932,7 +2930,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.25.1.tgz",
|
||||||
"integrity": "sha512-pkZT+iFYIZsVn6+GzM0kSX+u3MSLCY9md+lIJOoKl/P+gJFfxJte/60Usdp8Ce4rOs8GduUpSPNe1ddGyDT1sQ==",
|
"integrity": "sha512-pkZT+iFYIZsVn6+GzM0kSX+u3MSLCY9md+lIJOoKl/P+gJFfxJte/60Usdp8Ce4rOs8GduUpSPNe1ddGyDT1sQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/core": "1.25.1",
|
"@opentelemetry/core": "1.25.1",
|
||||||
"@opentelemetry/semantic-conventions": "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",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.25.1.tgz",
|
||||||
"integrity": "sha512-9Mb7q5ioFL4E4dDrc4wC/A3NTHDat44v4I3p2pLPSxRvqUbDIQyMVr9uK+EU69+HWhlET1VaSrRzwdckWqY15Q==",
|
"integrity": "sha512-9Mb7q5ioFL4E4dDrc4wC/A3NTHDat44v4I3p2pLPSxRvqUbDIQyMVr9uK+EU69+HWhlET1VaSrRzwdckWqY15Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/core": "1.25.1",
|
"@opentelemetry/core": "1.25.1",
|
||||||
"@opentelemetry/resources": "1.25.1",
|
"@opentelemetry/resources": "1.25.1",
|
||||||
@@ -3549,7 +3545,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4187,6 +4182,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/dotprompt": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/dotprompt/-/dotprompt-1.1.2.tgz",
|
"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.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -4888,7 +4894,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-12.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-12.7.0.tgz",
|
||||||
"integrity": "sha512-raFIrOyTqREbyXsNkSHyciQLfv8AUZazehPaQS1lZBSCDYW74FYXU0nQZa3qHI4K+hawohlDbywZ4+qce9YNxA==",
|
"integrity": "sha512-raFIrOyTqREbyXsNkSHyciQLfv8AUZazehPaQS1lZBSCDYW74FYXU0nQZa3qHI4K+hawohlDbywZ4+qce9YNxA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/busboy": "^3.0.0",
|
"@fastify/busboy": "^3.0.0",
|
||||||
"@firebase/database-compat": "1.0.8",
|
"@firebase/database-compat": "1.0.8",
|
||||||
@@ -5193,7 +5198,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/genkit/-/genkit-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/genkit/-/genkit-1.29.0.tgz",
|
||||||
"integrity": "sha512-m0oqw4AU8l6LTELH/0JmWPUA5ZuEVwm4ZhgUaVopy86gqgWyDF+SdNIpIxtXqdTmxxQHK9stQXuq/zJUDCLF4Q==",
|
"integrity": "sha512-m0oqw4AU8l6LTELH/0JmWPUA5ZuEVwm4ZhgUaVopy86gqgWyDF+SdNIpIxtXqdTmxxQHK9stQXuq/zJUDCLF4Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@genkit-ai/ai": "1.29.0",
|
"@genkit-ai/ai": "1.29.0",
|
||||||
"@genkit-ai/core": "1.29.0",
|
"@genkit-ai/core": "1.29.0",
|
||||||
@@ -8039,7 +8043,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@google-cloud/firestore": "^7.11.6",
|
"@google-cloud/firestore": "^7.11.6",
|
||||||
"@google-cloud/storage": "^7.17.1",
|
"@google-cloud/storage": "^7.17.1",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"expo-server-sdk": "^4.0.0",
|
"expo-server-sdk": "^4.0.0",
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.2.0",
|
||||||
"firebase-admin": "^12.6.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)
|
const tokens = Array.from(tokensSet)
|
||||||
|
|
||||||
if (!tokens?.length) {
|
if (tokens.length) {
|
||||||
await sendExpoNotification({
|
await sendExpoNotification({
|
||||||
tokens,
|
tokens,
|
||||||
receiverId: receiver,
|
receiverId: receiver,
|
||||||
|
|||||||
+9
-1
@@ -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['RCT_NEW_ARCH_ENABLED'] = podfile_properties['newArchEnabled'] == 'true' ? '1' : '0'
|
||||||
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] = podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
|
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',
|
install! 'cocoapods',
|
||||||
:deterministic_uuids => false
|
:deterministic_uuids => false
|
||||||
|
|
||||||
@@ -52,6 +53,13 @@ target 'MusicLand' do
|
|||||||
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
|
: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.
|
# Workaround for Xcode/clang consteval regression with fmt 11.0.2.
|
||||||
# Keep this until React Native ships a newer fmt pod.
|
# 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')
|
fmt_base_header = File.join(installer.sandbox.root.to_s, 'fmt', 'include', 'fmt', 'base.h')
|
||||||
|
|||||||
@@ -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 <UIWindowSceneDelegate>
|
||||||
|
|
||||||
|
@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<UIOpenURLContext *> *)URLContexts
|
||||||
|
{
|
||||||
|
AppDelegate *appDelegate = (AppDelegate *)UIApplication.sharedApplication.delegate;
|
||||||
|
|
||||||
|
for (UIOpenURLContext *urlContext in URLContexts) {
|
||||||
|
NSMutableDictionary<UIApplicationOpenURLOptionsKey, id> *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<id<UIUserActivityRestoring>> *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 <React/RCTLinkingManager.h>";
|
||||||
|
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"
|
||||||
|
);
|
||||||
+31
-31
@@ -1,18 +1,18 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage"
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
import { getReactNativePersistence, initializeAuth } from "firebase/auth/react-native"
|
import { getReactNativePersistence, initializeAuth } from 'firebase/auth/react-native'
|
||||||
import firebase from "firebase/compat/app"
|
import firebase from 'firebase/compat/app'
|
||||||
import "firebase/compat/auth"
|
import 'firebase/compat/auth'
|
||||||
import "firebase/compat/firestore"
|
import 'firebase/compat/firestore'
|
||||||
import "firebase/compat/functions"
|
import 'firebase/compat/functions'
|
||||||
import "firebase/compat/storage"
|
import 'firebase/compat/storage'
|
||||||
import { Platform } from "react-native"
|
import { Platform } from 'react-native'
|
||||||
|
|
||||||
const functionsInstances = {}
|
const functionsInstances = {}
|
||||||
const emulatorConfigured = {}
|
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 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
|
const shouldUseEmulator = USE_FUNCTIONS_EMULATOR && instance?.useEmulator
|
||||||
|
|
||||||
if (!shouldUseEmulator || emulatorConfigured[regionKey]) {
|
if (!shouldUseEmulator || emulatorConfigured[regionKey]) {
|
||||||
@@ -31,16 +31,16 @@ const configureFunctionsEmulator = (instance, regionKey = "us-central1") => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const firebaseConfig = {
|
export const firebaseConfig = {
|
||||||
apiKey: "AIzaSyCuHJHdwVN_F-VmUG4Hd7bGiMRqj6rPlLo",
|
apiKey: 'AIzaSyCuHJHdwVN_F-VmUG4Hd7bGiMRqj6rPlLo',
|
||||||
authDomain: "musicland-d33f9.firebaseapp.com",
|
authDomain: 'musicland-d33f9.firebaseapp.com',
|
||||||
projectId: "musicland-d33f9",
|
projectId: 'musicland-d33f9',
|
||||||
storageBucket: "musicland-d33f9.firebasestorage.app",
|
storageBucket: 'musicland-d33f9.firebasestorage.app',
|
||||||
messagingSenderId: "305598753437",
|
messagingSenderId: '305598753437',
|
||||||
appId: "1:305598753437:web:9930f10af59e5cf7f28274",
|
appId: '1:305598753437:web:9930f10af59e5cf7f28274',
|
||||||
measurementId: "G-RV4MS1SVTT",
|
measurementId: 'G-RV4MS1SVTT',
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!firebase?.apps?.filter(({ name_ }) => name_ === "[DEFAULT]").length) {
|
if (!firebase?.apps?.filter(({ name_ }) => name_ === '[DEFAULT]').length) {
|
||||||
const defaultApp = firebase.initializeApp(firebaseConfig)
|
const defaultApp = firebase.initializeApp(firebaseConfig)
|
||||||
|
|
||||||
initializeAuth(defaultApp, {
|
initializeAuth(defaultApp, {
|
||||||
@@ -51,9 +51,9 @@ if (!firebase?.apps?.filter(({ name_ }) => name_ === "[DEFAULT]").length) {
|
|||||||
// firebase.functions().useEmulator("localhost", 5001);
|
// firebase.functions().useEmulator("localhost", 5001);
|
||||||
// }
|
// }
|
||||||
const defaultFunctions = firebase.functions()
|
const defaultFunctions = firebase.functions()
|
||||||
functionsInstances["us-central1"] = defaultFunctions
|
functionsInstances['us-central1'] = defaultFunctions
|
||||||
configureFunctionsEmulator(defaultFunctions, "us-central1")
|
configureFunctionsEmulator(defaultFunctions, 'us-central1')
|
||||||
console.log("Firebase init")
|
console.log('Firebase init')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Firestore settings for React Native/iOS: avoid streaming transport issues
|
// Firestore settings for React Native/iOS: avoid streaming transport issues
|
||||||
@@ -69,20 +69,20 @@ try {
|
|||||||
// Ignore if settings were already set elsewhere
|
// Ignore if settings were already set elsewhere
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usersRef = firestore.collection("users")
|
export const usersRef = firestore.collection('users')
|
||||||
export const projectsRef = firestore.collection("projects")
|
export const projectsRef = firestore.collection('projects')
|
||||||
export const tasksRef = firestore.collection("tasks")
|
export const tasksRef = firestore.collection('tasks')
|
||||||
export const playlistsRef = firestore.collection("playlists")
|
export const playlistsRef = firestore.collection('playlists')
|
||||||
export const chatsRef = firestore.collection("chats")
|
export const chatsRef = firestore.collection('chats')
|
||||||
export const videosRef = firestore.collection("videos")
|
export const videosRef = firestore.collection('videos')
|
||||||
export const notificationsRef = firestore.collection("notifications")
|
export const notificationsRef = firestore.collection('notifications')
|
||||||
export const reportsRef = firestore.collection("reports")
|
export const reportsRef = firestore.collection('reports')
|
||||||
|
|
||||||
export const { arrayUnion, arrayRemove, increment, serverTimestamp } = firebase.firestore.FieldValue
|
export const { arrayUnion, arrayRemove, increment, serverTimestamp } = firebase.firestore.FieldValue
|
||||||
export const deleteField = firebase.firestore.FieldValue.delete
|
export const deleteField = firebase.firestore.FieldValue.delete
|
||||||
|
|
||||||
export const getFunctionsClient = (region = "us-central1") => {
|
export const getFunctionsClient = (region = 'us-central1') => {
|
||||||
const regionKey = region || "us-central1"
|
const regionKey = region || 'us-central1'
|
||||||
|
|
||||||
if (!functionsInstances[regionKey]) {
|
if (!functionsInstances[regionKey]) {
|
||||||
functionsInstances[regionKey] = firebase.app().functions(regionKey)
|
functionsInstances[regionKey] = firebase.app().functions(regionKey)
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import ResetPassword from '../screens/ResetPassword'
|
|||||||
import ChooseDecor from '../screens/Playback/ChooseDecor'
|
import ChooseDecor from '../screens/Playback/ChooseDecor'
|
||||||
import CreatingDecor from '../screens/Playback/CreatingDecor'
|
import CreatingDecor from '../screens/Playback/CreatingDecor'
|
||||||
import Playback from '../screens/Playback/Playback'
|
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 PlaybackGuide from '../screens/Playback/PlaybackGuide'
|
||||||
import PlaybackOnboarding from '../screens/Playback/PlaybackOnboarding'
|
import PlaybackOnboarding from '../screens/Playback/PlaybackOnboarding'
|
||||||
import RecordPlayback from '../screens/Playback/RecordPlayback'
|
import RecordPlayback from '../screens/Playback/RecordPlayback'
|
||||||
@@ -235,6 +237,14 @@ const baseScreens = [
|
|||||||
name: Routes.PlaybackGuide,
|
name: Routes.PlaybackGuide,
|
||||||
component: PlaybackGuide,
|
component: PlaybackGuide,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: Routes.PlaybackAi,
|
||||||
|
component: PlaybackAi,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: Routes.PlaybackAiStatus,
|
||||||
|
component: PlaybackAiStatus,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: Routes.RecordPlayback,
|
name: Routes.RecordPlayback,
|
||||||
component: RecordPlayback,
|
component: RecordPlayback,
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export const Routes = {
|
|||||||
PlaybackOnboarding: 'PlaybackOnboarding',
|
PlaybackOnboarding: 'PlaybackOnboarding',
|
||||||
Playback: 'Playback',
|
Playback: 'Playback',
|
||||||
PlaybackGuide: 'PlaybackGuide',
|
PlaybackGuide: 'PlaybackGuide',
|
||||||
|
PlaybackAi: 'PlaybackAi',
|
||||||
|
PlaybackAiStatus: 'PlaybackAiStatus',
|
||||||
RecordPlayback: 'RecordPlayback',
|
RecordPlayback: 'RecordPlayback',
|
||||||
RecordedPlayback: 'RecordedPlayback',
|
RecordedPlayback: 'RecordedPlayback',
|
||||||
ChooseDecor: 'ChooseDecor',
|
ChooseDecor: 'ChooseDecor',
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ const HIDDEN_ROUTE_NAMES = new Set([
|
|||||||
Routes.PlaybackOnboarding,
|
Routes.PlaybackOnboarding,
|
||||||
Routes.Playback,
|
Routes.Playback,
|
||||||
Routes.PlaybackGuide,
|
Routes.PlaybackGuide,
|
||||||
|
Routes.PlaybackAi,
|
||||||
|
Routes.PlaybackAiStatus,
|
||||||
Routes.Playbacks,
|
Routes.Playbacks,
|
||||||
Routes.RecordPlayback,
|
Routes.RecordPlayback,
|
||||||
Routes.RecordedPlayback,
|
Routes.RecordedPlayback,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback } from 'react'
|
import React, { useCallback, useMemo } from 'react'
|
||||||
import { StyleSheet, View } from 'react-native'
|
import { StyleSheet, Text, View } from 'react-native'
|
||||||
|
import { useIsFocused } from '@react-navigation/native'
|
||||||
import { background } from '../../assets'
|
import { background } from '../../assets'
|
||||||
import BackgroundVideo from '../../components/BackgroundVideo'
|
import BackgroundVideo from '../../components/BackgroundVideo'
|
||||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||||
@@ -9,13 +10,31 @@ import Page from '../../layouts/Page'
|
|||||||
import { isWeb } from '../../hooks/useLayoutType'
|
import { isWeb } from '../../hooks/useLayoutType'
|
||||||
import { Routes } from '../../navigation'
|
import { Routes } from '../../navigation'
|
||||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
import { goBack, navigate } from '../../navigation/NavigationService'
|
||||||
import { useUser } from '../../providers/UserDataProvider'
|
import { useUser, useUserData } from '../../providers/UserDataProvider'
|
||||||
import { gutters } from '../../styles'
|
import { gutters, Palette } from '../../styles'
|
||||||
|
import {
|
||||||
|
getPlaybackAiStatusLabel,
|
||||||
|
isPlaybackAiBlockingManual,
|
||||||
|
shouldResumePlaybackAi,
|
||||||
|
} from '../../utils/playbackAi'
|
||||||
|
|
||||||
const Playback = ({ route, navigation }) => {
|
const Playback = ({ route, navigation }) => {
|
||||||
const { project, returnToAdventureModal = false } = route.params || {}
|
const isFocused = useIsFocused()
|
||||||
|
const { project: routeProject, returnToAdventureModal = false } = route.params || {}
|
||||||
const { videos } = useUser()
|
const { videos } = useUser()
|
||||||
|
const { selectedProject } = useUserData() || {}
|
||||||
const theoUrl = isWeb ? videos?.theoWeb : videos?.theo
|
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(() => {
|
const handleBackPress = useCallback(() => {
|
||||||
if (returnToAdventureModal) {
|
if (returnToAdventureModal) {
|
||||||
@@ -26,18 +45,29 @@ const Playback = ({ route, navigation }) => {
|
|||||||
}, [navigation, returnToAdventureModal])
|
}, [navigation, returnToAdventureModal])
|
||||||
|
|
||||||
const onPressRecord = useCallback(() => {
|
const onPressRecord = useCallback(() => {
|
||||||
|
if (isManualBlocked) return
|
||||||
navigate(Routes.RecordPlayback, { project, returnToAdventureModal })
|
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 (
|
return (
|
||||||
<Page
|
<Page
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
backgroundImg={isWeb ? background.playbackWeb : background.playbackMobile}
|
backgroundImg={isWeb ? background.playbackWeb : background.playbackMobile}
|
||||||
backgroundContent={
|
backgroundContent={
|
||||||
|
isFocused ? (
|
||||||
<BackgroundVideo
|
<BackgroundVideo
|
||||||
source={theoUrl}
|
source={theoUrl}
|
||||||
soundButtonStyle={isWeb ? styles.rightSideControl : undefined}
|
soundButtonStyle={isWeb ? styles.rightSideControl : undefined}
|
||||||
/>
|
/>
|
||||||
|
) : null
|
||||||
}
|
}
|
||||||
containerStyle={isWeb ? styles.videoPage : undefined}
|
containerStyle={isWeb ? styles.videoPage : undefined}
|
||||||
>
|
>
|
||||||
@@ -51,7 +81,21 @@ const Playback = ({ route, navigation }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ width: '80%', alignSelf: 'center', gap: 12 }}>
|
<View style={{ width: '80%', alignSelf: 'center', gap: 12 }}>
|
||||||
<GradientButton title="Enregistrer mon Playback" onPress={onPressRecord} />
|
<GradientButton
|
||||||
|
title="Enregistrer mon Playback"
|
||||||
|
onPress={onPressRecord}
|
||||||
|
disabled={!project || isManualBlocked}
|
||||||
|
/>
|
||||||
|
<BorderGradientButton title={aiButtonLabel} onPress={onPressPlaybackAi} disabled={!project} />
|
||||||
|
{isManualBlocked ? (
|
||||||
|
<Text style={styles.blockedText}>
|
||||||
|
Le playback manuel est bloqué tant que le playback IA est en cours ou en attente de
|
||||||
|
validation.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{shouldResumePlaybackAi(project) ? (
|
||||||
|
<Text style={styles.statusText}>{getPlaybackAiStatusLabel(aiStatus)}</Text>
|
||||||
|
) : null}
|
||||||
<BorderGradientButton
|
<BorderGradientButton
|
||||||
title="Guide du Playbacker"
|
title="Guide du Playbacker"
|
||||||
onPress={() => navigate(Routes.PlaybackGuide, { returnToAdventureModal })}
|
onPress={() => navigate(Routes.PlaybackGuide, { returnToAdventureModal })}
|
||||||
@@ -74,4 +118,17 @@ const styles = StyleSheet.create({
|
|||||||
right: 24,
|
right: 24,
|
||||||
left: 'auto',
|
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',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Page
|
||||||
|
backgroundImg={background.playbackBG2}
|
||||||
|
backgroundColor="#07111B"
|
||||||
|
headerType="NONE"
|
||||||
|
>
|
||||||
|
<MusicLandHeader progress={31} onPressBack={goBack} />
|
||||||
|
<View style={{ flex: 1, paddingBottom: gutters * 2 }}>
|
||||||
|
<CreateLyricsHeader title="Créer ton playback IA" />
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.summaryCard}>
|
||||||
|
<Text style={styles.summaryTitle}>{project?.title || 'Sans titre'}</Text>
|
||||||
|
<Text style={styles.summaryText}>
|
||||||
|
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.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.previewCard}>
|
||||||
|
{selectedPhotoUri ? (
|
||||||
|
<Image source={{ uri: selectedPhotoUri }} style={styles.photoPreview} resizeMode="cover" />
|
||||||
|
) : (
|
||||||
|
<View style={[styles.photoPreview, styles.photoPlaceholder]}>
|
||||||
|
<Text style={styles.photoPlaceholderText}>Aucune photo sélectionnée</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<BorderGradientButton
|
||||||
|
title={selectedPhotoUri ? 'Changer de photo' : 'Choisir une photo'}
|
||||||
|
onPress={handleSelectPhoto}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<GradientButton
|
||||||
|
title="Générer ma vidéo"
|
||||||
|
onPress={handleGenerate}
|
||||||
|
disabled={isSubmitting || !selectedPhotoUri}
|
||||||
|
/>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<ActivityLoader
|
||||||
|
defaultMessage="Envoi de la photo et lancement de HeyGen..."
|
||||||
|
containerStyle={styles.loader}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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 (
|
||||||
|
<Page
|
||||||
|
backgroundImg={background.playbackBG2}
|
||||||
|
backgroundColor="#07111B"
|
||||||
|
headerType="NONE"
|
||||||
|
>
|
||||||
|
<MusicLandHeader progress={38} onPressBack={goBack} />
|
||||||
|
<View style={{ flex: 1, paddingBottom: gutters * 2 }}>
|
||||||
|
<CreateLyricsHeader title="Playback IA" />
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.headerCard}>
|
||||||
|
{project?.coverUrl ? (
|
||||||
|
<Image source={{ uri: project.coverUrl }} style={styles.cover} resizeMode="cover" />
|
||||||
|
) : null}
|
||||||
|
<View style={{ flex: 1, gap: 4 }}>
|
||||||
|
<Text style={styles.title}>{project?.title || 'Sans titre'}</Text>
|
||||||
|
<Text style={styles.subtitle}>Format vertical 9:16 généré avec HeyGen</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{status === PLAYBACK_AI_STATUS.GENERATING ? (
|
||||||
|
<View style={styles.stateCard}>
|
||||||
|
<ActivityLoader defaultMessage="HeyGen génère ton playback IA..." />
|
||||||
|
<Text style={styles.helperText}>
|
||||||
|
Tu peux fermer cet écran et revenir plus tard. Une notification te sera envoyée dès
|
||||||
|
que ta vidéo sera prête.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === PLAYBACK_AI_STATUS.FAILED ? (
|
||||||
|
<View style={styles.stateCard}>
|
||||||
|
<Text style={styles.errorTitle}>La génération a échoué</Text>
|
||||||
|
<Text style={styles.helperText}>
|
||||||
|
{project?.playbackError || 'HeyGen n’a pas réussi à générer la vidéo.'}
|
||||||
|
</Text>
|
||||||
|
<BorderGradientButton title="Réessayer" onPress={handleRetry} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === PLAYBACK_AI_STATUS.DRAFT_READY ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.videoCard}>
|
||||||
|
{draftUrl ? (
|
||||||
|
<VideoView
|
||||||
|
player={player}
|
||||||
|
nativeControls
|
||||||
|
contentFit="contain"
|
||||||
|
style={styles.video}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.video, styles.emptyVideo]}>
|
||||||
|
<Text style={styles.helperText}>La vidéo HeyGen est introuvable.</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<GradientButton
|
||||||
|
title="Valider ce playback"
|
||||||
|
onPress={handleValidate}
|
||||||
|
disabled={isValidating || !draftUrl}
|
||||||
|
/>
|
||||||
|
<BorderGradientButton
|
||||||
|
title="Recommencer"
|
||||||
|
onPress={handleRetry}
|
||||||
|
disabled={isValidating}
|
||||||
|
/>
|
||||||
|
{isValidating ? (
|
||||||
|
<ActivityLoader defaultMessage="Validation du playback IA..." />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === PLAYBACK_AI_STATUS.READY && project?.playbackUrl ? (
|
||||||
|
<View style={styles.stateCard}>
|
||||||
|
<Text style={styles.successTitle}>Le playback IA est déjà validé</Text>
|
||||||
|
<GradientButton
|
||||||
|
title="Continuer"
|
||||||
|
onPress={() =>
|
||||||
|
navigate(Routes.PlaybackDownload, {
|
||||||
|
action: 'playback',
|
||||||
|
project,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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 (
|
||||||
|
<Page
|
||||||
|
backgroundImg={background.playbackBG2}
|
||||||
|
backgroundColor="#07111B"
|
||||||
|
headerType="NONE"
|
||||||
|
>
|
||||||
|
<MusicLandHeader progress={38} onPressBack={goBack} />
|
||||||
|
<View style={{ flex: 1, paddingBottom: gutters * 2 }}>
|
||||||
|
<CreateLyricsHeader title="Playback IA" />
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.headerCard}>
|
||||||
|
{project?.coverUrl ? (
|
||||||
|
<Image source={{ uri: project.coverUrl }} style={styles.cover} resizeMode="cover" />
|
||||||
|
) : null}
|
||||||
|
<View style={{ flex: 1, gap: 4 }}>
|
||||||
|
<Text style={styles.title}>{project?.title || 'Sans titre'}</Text>
|
||||||
|
<Text style={styles.subtitle}>Format vertical 9:16 généré avec HeyGen</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{status === PLAYBACK_AI_STATUS.GENERATING ? (
|
||||||
|
<View style={styles.stateCard}>
|
||||||
|
<ActivityLoader defaultMessage="HeyGen génère ton playback IA..." />
|
||||||
|
<Text style={styles.helperText}>
|
||||||
|
Tu peux fermer cet écran et revenir plus tard. Une notification te sera envoyée dès
|
||||||
|
que ta vidéo sera prête.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === PLAYBACK_AI_STATUS.FAILED ? (
|
||||||
|
<View style={styles.stateCard}>
|
||||||
|
<Text style={styles.errorTitle}>La génération a échoué</Text>
|
||||||
|
<Text style={styles.helperText}>
|
||||||
|
{project?.playbackError || 'HeyGen n’a pas réussi à générer la vidéo.'}
|
||||||
|
</Text>
|
||||||
|
<BorderGradientButton title="Réessayer" onPress={handleRetry} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === PLAYBACK_AI_STATUS.DRAFT_READY ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.videoCard}>
|
||||||
|
{draftUrl ? (
|
||||||
|
<video
|
||||||
|
src={draftUrl}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
style={styles.video}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.videoFallback, styles.emptyVideo]}>
|
||||||
|
<Text style={styles.helperText}>La vidéo HeyGen est introuvable.</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<GradientButton
|
||||||
|
title="Valider ce playback"
|
||||||
|
onPress={handleValidate}
|
||||||
|
disabled={isValidating || !draftUrl}
|
||||||
|
/>
|
||||||
|
<BorderGradientButton
|
||||||
|
title="Recommencer"
|
||||||
|
onPress={handleRetry}
|
||||||
|
disabled={isValidating}
|
||||||
|
/>
|
||||||
|
{isValidating ? (
|
||||||
|
<ActivityLoader defaultMessage="Validation du playback IA..." />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === PLAYBACK_AI_STATUS.READY && project?.playbackUrl ? (
|
||||||
|
<View style={styles.stateCard}>
|
||||||
|
<Text style={styles.successTitle}>Le playback IA est déjà validé</Text>
|
||||||
|
<GradientButton
|
||||||
|
title="Continuer"
|
||||||
|
onPress={() =>
|
||||||
|
navigate(Routes.PlaybackDownload, {
|
||||||
|
action: 'playback',
|
||||||
|
project,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
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 { responsiveHeight } from 'react-native-responsive-dimensions'
|
||||||
import { SheetManager } from 'react-native-actions-sheet'
|
import { SheetManager } from 'react-native-actions-sheet'
|
||||||
import { useUser } from '../../../providers/UserDataProvider'
|
import { useUser } from '../../../providers/UserDataProvider'
|
||||||
@@ -53,6 +53,18 @@ const PlaybackItem = ({ item, isActive, userCache }) => {
|
|||||||
isActive,
|
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 alignedWords = useMemo(() => {
|
||||||
const idx = Number(item?.songIndex) || 0
|
const idx = Number(item?.songIndex) || 0
|
||||||
const ts = item?.musicTimestamps?.[idx]
|
const ts = item?.musicTimestamps?.[idx]
|
||||||
@@ -124,7 +136,11 @@ const PlaybackItem = ({ item, isActive, userCache }) => {
|
|||||||
}, [item?.userId])
|
}, [item?.userId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<Pressable
|
||||||
|
accessible={false}
|
||||||
|
onPress={togglePlayback}
|
||||||
|
style={styles.container}
|
||||||
|
>
|
||||||
<PlaybackVideo videoUrl={videoUrl} videoPlayer={videoPlayer} />
|
<PlaybackVideo videoUrl={videoUrl} videoPlayer={videoPlayer} />
|
||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<PlaybackActions
|
<PlaybackActions
|
||||||
@@ -148,7 +164,7 @@ const PlaybackItem = ({ item, isActive, userCache }) => {
|
|||||||
fallbackTitle={item?.title}
|
fallbackTitle={item?.title}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Pressable>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'
|
|||||||
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
|
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
|
||||||
import * as FileSystem from 'expo-file-system'
|
import * as FileSystem from 'expo-file-system'
|
||||||
import { shareAsync } from 'expo-sharing'
|
import { shareAsync } from 'expo-sharing'
|
||||||
|
import { background } from '../../assets'
|
||||||
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
|
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
|
||||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||||
@@ -424,12 +425,10 @@ const PlaybackDownload = ({ route }) => {
|
|||||||
onClose={() => setIsAfterPlaybackVideoVisible(false)}
|
onClose={() => setIsAfterPlaybackVideoVisible(false)}
|
||||||
/>
|
/>
|
||||||
<Page
|
<Page
|
||||||
// backgroundImg={
|
backgroundImg={
|
||||||
// action === "playback"
|
action === 'playback' ? background.playbackBG2 : background.productionBG2
|
||||||
// ? background.playbackBG2
|
}
|
||||||
// : background.productionBG2
|
backgroundColor="#07111B"
|
||||||
// }
|
|
||||||
backgroundColor={Palette.grayMid}
|
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
>
|
>
|
||||||
<MusicLandHeader onPressBack={goBack} progress={50} />
|
<MusicLandHeader onPressBack={goBack} progress={50} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo, useRef, useState } from 'react'
|
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 { Dimensions, Modal, Text, View } from 'react-native'
|
||||||
import SwiperFlatList from 'react-native-swiper-flatlist'
|
import SwiperFlatList from 'react-native-swiper-flatlist'
|
||||||
import { background, icons } from '../../assets'
|
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 RHYTHM_STEP_INDEX = LAST_STEP_INDEX
|
||||||
|
|
||||||
const ComposeSong = () => {
|
const ComposeSong = () => {
|
||||||
|
const isFocused = useIsFocused()
|
||||||
const scrollRef = useRef(null)
|
const scrollRef = useRef(null)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
const [progress, setProgress] = useState(18)
|
const [progress, setProgress] = useState(18)
|
||||||
@@ -295,7 +296,9 @@ const ComposeSong = () => {
|
|||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
backgroundImg={background.studioBG2}
|
backgroundImg={background.studioBG2}
|
||||||
backgroundContent={<BackgroundVideo source={videos?.malik} />}
|
backgroundContent={
|
||||||
|
isFocused ? <BackgroundVideo source={videos?.malik} /> : null
|
||||||
|
}
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
>
|
>
|
||||||
<MusicLandHeader onPressBack={onPressBack} progress={progress} logo={icons.musicLandStudio} />
|
<MusicLandHeader onPressBack={onPressBack} progress={progress} logo={icons.musicLandStudio} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
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 { FlatList, Modal, StyleSheet, Text, View, useWindowDimensions } from 'react-native'
|
||||||
import { background } from '../../assets'
|
import { background } from '../../assets'
|
||||||
import BackgroundVideo from '../../components/BackgroundVideo'
|
import BackgroundVideo from '../../components/BackgroundVideo'
|
||||||
@@ -34,6 +34,7 @@ const PAGE_MAX_WIDTH = 1200
|
|||||||
const PAGE_HORIZONTAL_PADDING = gutters * 2
|
const PAGE_HORIZONTAL_PADDING = gutters * 2
|
||||||
|
|
||||||
const ComposeSong = () => {
|
const ComposeSong = () => {
|
||||||
|
const isFocused = useIsFocused()
|
||||||
const scrollRef = useRef(null)
|
const scrollRef = useRef(null)
|
||||||
const { width: windowWidth } = useWindowDimensions()
|
const { width: windowWidth } = useWindowDimensions()
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
@@ -298,10 +299,12 @@ const ComposeSong = () => {
|
|||||||
<Page
|
<Page
|
||||||
backgroundImg={background.studioBG2}
|
backgroundImg={background.studioBG2}
|
||||||
backgroundContent={
|
backgroundContent={
|
||||||
|
isFocused ? (
|
||||||
<BackgroundVideo
|
<BackgroundVideo
|
||||||
source={videos?.malikWeb}
|
source={videos?.malikWeb}
|
||||||
soundButtonStyle={styles.rightSideControl}
|
soundButtonStyle={styles.rightSideControl}
|
||||||
/>
|
/>
|
||||||
|
) : null
|
||||||
}
|
}
|
||||||
coinBadgeContainerStyle={styles.rightSideCredits}
|
coinBadgeContainerStyle={styles.rightSideCredits}
|
||||||
containerStyle={styles.videoQuestionPage}
|
containerStyle={styles.videoQuestionPage}
|
||||||
|
|||||||
@@ -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 ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ const getStageMetadata = (project) => {
|
|||||||
project?.playbackGenerating === true ||
|
project?.playbackGenerating === true ||
|
||||||
project?.isPlaybackGenerating === true ||
|
project?.isPlaybackGenerating === true ||
|
||||||
playbackStatus === 'GENERATING'
|
playbackStatus === 'GENERATING'
|
||||||
|
const isPlaybackDraftReady = playbackStatus === 'DRAFT_READY'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lyricsCount,
|
lyricsCount,
|
||||||
@@ -63,6 +64,7 @@ const getStageMetadata = (project) => {
|
|||||||
musicStatus,
|
musicStatus,
|
||||||
coverStatus,
|
coverStatus,
|
||||||
playbackStatus,
|
playbackStatus,
|
||||||
|
isPlaybackDraftReady,
|
||||||
youtubeStatus,
|
youtubeStatus,
|
||||||
youtubeError,
|
youtubeError,
|
||||||
isMusicGenerating: musicStatus === 'GENERATING',
|
isMusicGenerating: musicStatus === 'GENERATING',
|
||||||
@@ -103,6 +105,7 @@ const getStageDescription = (key, metadata) => {
|
|||||||
hasMusicDraft,
|
hasMusicDraft,
|
||||||
isMusicGenerating,
|
isMusicGenerating,
|
||||||
isPlaybackGenerating,
|
isPlaybackGenerating,
|
||||||
|
isPlaybackDraftReady,
|
||||||
} = metadata
|
} = metadata
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
@@ -141,6 +144,9 @@ const getStageDescription = (key, metadata) => {
|
|||||||
if (isPlaybackGenerating) {
|
if (isPlaybackGenerating) {
|
||||||
return 'Votre playback est en cours de préparation'
|
return 'Votre playback est en cours de préparation'
|
||||||
}
|
}
|
||||||
|
if (isPlaybackDraftReady) {
|
||||||
|
return 'Votre playback IA est prêt à être validé'
|
||||||
|
}
|
||||||
if (hasPlaybackAsset) {
|
if (hasPlaybackAsset) {
|
||||||
return 'Modifier le playback généré'
|
return 'Modifier le playback généré'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user