From efbb7994f2d351208bb3b9e73ab31e93a93c4893 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Fri, 4 Sep 2026 12:22:29 +0200 Subject: [PATCH] feat: fix clouds functions and heygen --- functions/.gcloudignore | 21 ++++ functions/helpers/gemini.js | 128 +++++++++++++++------ functions/src/heygen.js | 16 +++ functions/src/lyrics.js | 6 +- functions/src/music.js | 30 +++-- src/components/FullscreenIntroVideo.web.js | 10 +- src/config/firebase.js | 2 +- src/helpers/uploadToFirebase.js | 22 +++- src/screens/Playback/PlaybackAi.js | 92 ++++++++++++--- src/screens/Studio/ComposeSong.js | 2 + src/screens/Studio/ComposeSong.web.js | 2 + src/screens/Studio/SongReady.js | 20 +++- 12 files changed, 276 insertions(+), 75 deletions(-) create mode 100644 functions/.gcloudignore diff --git a/functions/.gcloudignore b/functions/.gcloudignore new file mode 100644 index 0000000..482911b --- /dev/null +++ b/functions/.gcloudignore @@ -0,0 +1,21 @@ +# This file specifies files that are *not* uploaded to Google Cloud +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +node_modules +.env +.env.* +!.env.example +.secret.local +serviceAccountKey.json diff --git a/functions/helpers/gemini.js b/functions/helpers/gemini.js index 7f17714..566d405 100644 --- a/functions/helpers/gemini.js +++ b/functions/helpers/gemini.js @@ -6,8 +6,64 @@ const { Buffer } = require('buffer') const { setTimeout } = require('timers/promises') // --- CONFIGURATION --- -// Plus intelligente que le Flash original, ultra rapide, et stable sur l'API. -const TEXT_MODEL_NAME = 'gemini-flash-latest' +// Utilise des versions stables explicites en production. L'alias `latest` peut +// changer sans déploiement et pointer temporairement vers un modèle saturé. +const TEXT_MODEL_NAMES = ['gemini-3.6-flash', 'gemini-3.5-flash', 'gemini-3.1-flash-lite'] +const MODERATION_MODEL_NAMES = ['gemini-3.1-flash-lite', 'gemini-3.6-flash', 'gemini-3.5-flash'] +const RETRYABLE_STATUS_CODES = new Set([404, 429, 500, 502, 503, 504]) + +const getErrorStatus = (error) => { + const status = Number(error?.status || error?.statusCode || error?.response?.status) + return Number.isFinite(status) ? status : null +} + +const canTryFallbackModel = (error) => { + const status = getErrorStatus(error) + if (status && RETRYABLE_STATUS_CODES.has(status)) return true + + const message = String(error?.message || '').toLowerCase() + return ( + message.includes('high demand') || + message.includes('overloaded') || + message.includes('temporarily unavailable') || + message.includes('model not found') + ) +} + +const generateWithModelFallback = async ({ ai, modelNames, label, request }) => { + let lastError = null + + for (let index = 0; index < modelNames.length; index++) { + const modelName = modelNames[index] + + try { + console.log(`🤖 [${label}] Modèle ${modelName} (${index + 1}/${modelNames.length})`) + const response = await ai.generate({ + ...request, + model: googleAI.model(modelName), + }) + + if (!response?.output) { + throw new Error(`${label}: réponse structurée vide pour ${modelName}.`) + } + + return response.output + } catch (error) { + lastError = error + const hasFallback = index < modelNames.length - 1 + + if (!hasFallback || !canTryFallbackModel(error)) throw error + + console.warn(`⚠️ [${label}] ${modelName} indisponible, essai du modèle suivant`, { + status: getErrorStatus(error), + message: error?.message, + }) + await setTimeout(500 * (index + 1)) + } + } + + throw lastError || new Error(`${label}: aucun modèle disponible.`) +} const IMAGE_MODEL_NAME = 'gemini-3-pro-image' // --- SINGLETON PATTERN (WARM START) --- @@ -35,21 +91,23 @@ exports.generateAI = async ({ system = '', prompt = '', schema }) => { throw new Error('Vous devez spécifier un prompt pour effectuer cette action.') } - console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`) + console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAMES.join(' -> ')})`) const startedAt = Date.now() try { - const { output } = await ai.generate({ - model: googleAI.model(TEXT_MODEL_NAME), - system, - prompt, - output: { schema }, - config: { - temperature: 0.7, // Créativité équilibrée + return await generateWithModelFallback({ + ai, + modelNames: TEXT_MODEL_NAMES, + label: 'generateAI', + request: { + system, + prompt, + output: { schema }, + config: { + temperature: 0.7, // Créativité équilibrée + }, }, }) - - return output } catch (error) { console.error('❌ [generateAI] Error:', error.message) throw error @@ -129,34 +187,36 @@ ${lyricsText} """ ` - console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`) + console.log(`🛡️ [analyseLyrics] Start (${MODERATION_MODEL_NAMES.join(' -> ')})`) const startedAt = Date.now() try { - const { output } = await ai.generate({ - model: googleAI.model(TEXT_MODEL_NAME), - system, - prompt: userPrompt, - output: { schema: moderationSchema }, - config: { - // Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu - safetySettings: [ - { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' }, - { - category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', - threshold: 'BLOCK_NONE', - }, - { - category: 'HARM_CATEGORY_DANGEROUS_CONTENT', - threshold: 'BLOCK_NONE', - }, - { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }, - ], + const output = await generateWithModelFallback({ + ai, + modelNames: MODERATION_MODEL_NAMES, + label: 'analyseLyrics', + request: { + system, + prompt: userPrompt, + output: { schema: moderationSchema }, + config: { + // Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu + safetySettings: [ + { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' }, + { + category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', + threshold: 'BLOCK_NONE', + }, + { + category: 'HARM_CATEGORY_DANGEROUS_CONTENT', + threshold: 'BLOCK_NONE', + }, + { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }, + ], + }, }, }) - if (!output) throw new Error("Échec de l'analyse de modération.") - // Correction de cohérence if (output.blocked) { output.flagged = true diff --git a/functions/src/heygen.js b/functions/src/heygen.js index 4d82779..b5df740 100644 --- a/functions/src/heygen.js +++ b/functions/src/heygen.js @@ -17,6 +17,7 @@ const projectsRef = db.collection('projects') const REGION = process.env.FIREBASE_REGION || 'europe-west1' const HEYGEN_BASE_URL = 'https://api.heygen.com/v3' +const HEYGEN_REQUEST_TIMEOUT_MS = 45000 const PLAYBACK_STATUS = { GENERATING: 'GENERATING', DRAFT_READY: 'DRAFT_READY', @@ -120,6 +121,7 @@ const findProjectForWebhook = async ({ callbackId, videoId }) => { const fetchCanonicalVideo = async (videoId) => { const response = await axios.get(`${HEYGEN_BASE_URL}/videos/${videoId}`, { headers: getHeygenHeaders(), + timeout: HEYGEN_REQUEST_TIMEOUT_MS, }) return response?.data?.data || null @@ -176,6 +178,13 @@ exports.startPlaybackGeneration = onCall( const callbackUrl = buildCallbackUrl() const title = trimString(project?.title) || `Playback ${projectId}` + logger.info('[HeyGen] startPlaybackGeneration request', { + projectId, + uid, + hasSongUrl: Boolean(songUrl), + hasPhotoUrl: Boolean(photoUrl), + }) + await projectRef.set( { playbackProvider: 'heygen', @@ -211,6 +220,7 @@ exports.startPlaybackGeneration = onCall( }, { headers: getHeygenHeaders(), + timeout: HEYGEN_REQUEST_TIMEOUT_MS, } ) @@ -227,6 +237,12 @@ exports.startPlaybackGeneration = onCall( { merge: true } ) + logger.info('[HeyGen] generation accepted', { + projectId, + uid, + videoId, + }) + return { jobId: videoId, callbackId, diff --git a/functions/src/lyrics.js b/functions/src/lyrics.js index ba9f9a5..b351f4e 100644 --- a/functions/src/lyrics.js +++ b/functions/src/lyrics.js @@ -204,9 +204,9 @@ exports.generateLyrics = onCall(LYRICS_FUNCTION_OPTIONS, async ({ auth = {}, dat } } catch (moderationError) { if (moderationError instanceof HttpsError) throw moderationError - console.error('⚠️ Moderation check error (fail open)', moderationError) - // On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique. - // Ici je fail closed par sécurité pour une app publique. + console.error('⚠️ Moderation check error (fail closed)', moderationError) + // La modération essaie plusieurs modèles stables avant d'arriver ici. + // Si tous sont indisponibles, on bloque par sécurité pour une app publique. throw new HttpsError('internal', 'Vérification de sécurité indisponible.') } diff --git a/functions/src/music.js b/functions/src/music.js index f40926f..4780210 100644 --- a/functions/src/music.js +++ b/functions/src/music.js @@ -2,7 +2,7 @@ const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https') const axios = require('axios') const admin = require('firebase-admin') const { FieldValue } = require('firebase-admin/firestore') -const { logger } = require('firebase-functions/logger') +const logger = require('firebase-functions/logger') const { pipeline } = require('stream/promises') const { randomUUID } = require('crypto') const { ALERT_TYPE, refList } = require('../index') @@ -22,6 +22,12 @@ const MUSIC_REFUND_SOURCE = 'music_generation_refund' const SUNO_LYRICS_PROMPT_MAX_LENGTH = 5000 const SUNO_TITLE_MAX_LENGTH = 100 +const getSunoAuthorizationHeader = () => { + const apiKey = String(SUNO_API_KEY.value() || '').trim() + if (!apiKey) throw new Error('SUNO_API_KEY_EMPTY') + return `Bearer ${apiKey}` +} + // ========================================== // 1. DICTIONNAIRES DE TRADUCTION (FRONT -> SUNO) // ========================================== @@ -409,14 +415,16 @@ async function markProjectMusicFailure(projectId, error) { reason: sunoMessage, }) } - await docRef.set( - { - musicStatus: 'FAILED', - musicError: errorPayload, - updatedAt: FieldValue.serverTimestamp(), - }, - { merge: true } - ) + const failureUpdate = { + musicStatus: 'FAILED', + musicError: errorPayload, + updatedAt: FieldValue.serverTimestamp(), + } + if (refundResult?.orderId) { + failureUpdate.musicCreditsRefunded = true + failureUpdate.musicCreditsRefundOrderId = refundResult.orderId + } + await docRef.set(failureUpdate, { merge: true }) // (Notification logic here...) } catch (err) { console.error('Error marking failure', err) @@ -592,7 +600,7 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} } const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${SUNO_API_KEY.value()}`, + Authorization: getSunoAuthorizationHeader(), }, }) @@ -626,7 +634,7 @@ exports.getSunoStatus = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} } if (!taskId) throw new Error('TaskId manquant') try { const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, { - headers: { Authorization: `Bearer ${SUNO_API_KEY.value()}` }, + headers: { Authorization: getSunoAuthorizationHeader() }, timeout: 30000, }) return { diff --git a/src/components/FullscreenIntroVideo.web.js b/src/components/FullscreenIntroVideo.web.js index cfd6ca9..37b02b7 100644 --- a/src/components/FullscreenIntroVideo.web.js +++ b/src/components/FullscreenIntroVideo.web.js @@ -113,10 +113,18 @@ const FullscreenIntroVideo = ({ const load = async () => { try { const nextUri = await resolveMediaUri(url) + if (!nextUri) { + if (isMounted) { + setUri(null) + handleClose() + } + return + } assignUri(nextUri) } catch { if (isMounted) { setUri(null) + handleClose() } } } @@ -126,7 +134,7 @@ const FullscreenIntroVideo = ({ return () => { isMounted = false } - }, [url]) + }, [handleClose, url]) useEffect(() => { if (!visible || !uri) { diff --git a/src/config/firebase.js b/src/config/firebase.js index c4ad0c5..5497e0b 100644 --- a/src/config/firebase.js +++ b/src/config/firebase.js @@ -13,7 +13,7 @@ const emulatorHost = Platform.OS === 'web' ? 'localhost' : '192.168.1.60' const USE_FUNCTIONS_EMULATOR = __DEV__ const configureFunctionsEmulator = (instance, regionKey = 'us-central1') => { - const shouldUseEmulator = USE_FUNCTIONS_EMULATOR && instance?.useEmulator + const shouldUseEmulator = false if (!shouldUseEmulator || emulatorConfigured[regionKey]) { return diff --git a/src/helpers/uploadToFirebase.js b/src/helpers/uploadToFirebase.js index fa45024..66704c9 100644 --- a/src/helpers/uploadToFirebase.js +++ b/src/helpers/uploadToFirebase.js @@ -33,7 +33,8 @@ export function uploadFileToFirebase({ shouldCompress, fileType, hasBlob: Boolean(providedBlob), - uri, + uriScheme: typeof uri === 'string' ? uri.split(':')[0] : null, + uriLength: typeof uri === 'string' ? uri.length : 0, }) let workingURI = uri let uploadBlob = providedBlob @@ -170,13 +171,22 @@ export function uploadFileToFirebase({ reject(error) }, async () => { - const url = await firebase.storage().ref(path).getDownloadURL() + try { + const url = await firebase.storage().ref(path).getDownloadURL() - console.log('[uploadToFirebase] upload success (finalize)', { resultURI: url }) - if (uploadBlob && typeof uploadBlob.close === 'function') { - uploadBlob.close() + console.log('[uploadToFirebase] upload success (finalize)', { resultURI: url }) + if (uploadBlob && typeof uploadBlob.close === 'function') { + uploadBlob.close() + } + resolve({ resultURI: url }) + } catch (error) { + console.log('[uploadToFirebase] download URL error', { + message: error?.message, + code: error?.code, + name: error?.name, + }) + reject(error) } - resolve({ resultURI: url }) } ) } catch (e) { diff --git a/src/screens/Playback/PlaybackAi.js b/src/screens/Playback/PlaybackAi.js index 75d57df..de051af 100644 --- a/src/screens/Playback/PlaybackAi.js +++ b/src/screens/Playback/PlaybackAi.js @@ -1,7 +1,7 @@ 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 { Image, Platform, Text, View } from 'react-native' import { background } from '../../assets' import ActivityLoader from '../../components/ActivityLoader' import BorderGradientButton from '../../components/BorderGradientButton' @@ -19,21 +19,50 @@ import { PLAYBACK_AI_STATUS } from '../../utils/playbackAi' import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader' const FUNCTIONS_REGION = 'europe-west1' +const PHOTO_UPLOAD_TIMEOUT_MS = 120000 +const HEYGEN_START_TIMEOUT_MS = 60000 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) { + if (routeProject && typeof routeProject === 'object' && routeProject.id) { + if (selectedProject?.id === routeProject.id) { + return selectedProject + } + + return routeProject + } + + if (selectedProject?.id) { return selectedProject } - return routeProject || null + + return null } const getInitialPhotoUri = ({ routePhotoUrl, project }) => trimString(routePhotoUrl) || trimString(project?.playbackPhotoUrl) || null +const getImageExtension = (fileName) => { + const normalizedFileName = trimString(fileName) + const extension = normalizedFileName.includes('.') + ? normalizedFileName.split('.').pop().toLowerCase() + : '' + + return /^[a-z0-9]+$/.test(extension) ? extension : 'jpg' +} + +const withTimeout = (promise, timeoutMs, message) => { + let timeoutId + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs) + }) + + return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId)) +} + const callStartPlaybackGeneration = (payload) => { const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('heygen-startPlaybackGeneration') return callable(payload) @@ -49,6 +78,7 @@ const PlaybackAi = ({ route }) => { project: resolveProject({ routeProject, selectedProject }), }) ) + const [selectedPhotoAsset, setSelectedPhotoAsset] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) const project = useMemo( () => resolveProject({ routeProject, selectedProject }), @@ -64,9 +94,10 @@ const PlaybackAi = ({ route }) => { quality: 1, }) - const nextUri = result?.assets?.[0]?.uri || null - if (nextUri) { - setSelectedPhotoUri(nextUri) + const asset = result?.assets?.[0] + if (asset?.uri) { + setSelectedPhotoUri(asset.uri) + setSelectedPhotoAsset(asset) } } catch (error) { setTooltip({ @@ -77,6 +108,8 @@ const PlaybackAi = ({ route }) => { } const handleGenerate = async () => { + let step = 'validation' + try { if (!project?.id) { throw new Error('Projet introuvable') @@ -94,24 +127,46 @@ const PlaybackAi = ({ route }) => { 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', + step = 'photo_upload' + const extension = getImageExtension(selectedPhotoAsset?.fileName) + const path = `users/${currentUID}/projects/${project.id}/heygen/photo-${Date.now()}.${extension}` + + console.log('[PlaybackAi] photo upload start', { + path, + fileSize: selectedPhotoAsset?.fileSize || null, + platform: Platform.OS, }) + + const uploadResult = await withTimeout( + uploadFileToFirebase({ + uri: selectedPhotoUri, + path, + shouldCompress: true, + fileType: 'IMAGE', + blob: Platform.OS === 'web' ? selectedPhotoAsset?.file : null, + }), + PHOTO_UPLOAD_TIMEOUT_MS, + "L'envoi de la photo a dépassé 2 minutes. Vérifie ta connexion puis réessaie." + ) photoUrl = trimString(uploadResult?.resultURI) + console.log('[PlaybackAi] photo upload complete', { path }) } if (!photoUrl) { throw new Error("Impossible d'envoyer la photo vers Firebase") } - await callStartPlaybackGeneration({ - projectId: project.id, - photoUrl, - }) + step = 'heygen_start' + console.log('[PlaybackAi] HeyGen request start', { projectId: project.id }) + await withTimeout( + callStartPlaybackGeneration({ + projectId: project.id, + photoUrl, + }), + HEYGEN_START_TIMEOUT_MS, + 'HeyGen ne répond pas après 1 minute. Réessaie dans quelques instants.' + ) + console.log('[PlaybackAi] HeyGen request accepted', { projectId: project.id }) navigate(Routes.PlaybackAiStatus, { project: { @@ -122,6 +177,11 @@ const PlaybackAi = ({ route }) => { }, }) } catch (error) { + console.error('[PlaybackAi] generation failed', { + step, + code: error?.code, + message: error?.message, + }) setTooltip({ type: 'error', text: error?.message || 'Erreur lors du lancement du playback IA', diff --git a/src/screens/Studio/ComposeSong.js b/src/screens/Studio/ComposeSong.js index abad6da..46c32bf 100644 --- a/src/screens/Studio/ComposeSong.js +++ b/src/screens/Studio/ComposeSong.js @@ -144,6 +144,8 @@ const ComposeSong = () => { }, musicStatus: null, sunoTaskId: firebase.firestore.FieldValue.delete(), + musicCreditsRefunded: firebase.firestore.FieldValue.delete(), + musicCreditsRefundOrderId: firebase.firestore.FieldValue.delete(), } if (!isRegenerationFlow) { diff --git a/src/screens/Studio/ComposeSong.web.js b/src/screens/Studio/ComposeSong.web.js index f5fd816..13bfe98 100644 --- a/src/screens/Studio/ComposeSong.web.js +++ b/src/screens/Studio/ComposeSong.web.js @@ -200,6 +200,8 @@ const ComposeSong = () => { }, musicStatus: null, sunoTaskId: firebase.firestore.FieldValue.delete(), + musicCreditsRefunded: firebase.firestore.FieldValue.delete(), + musicCreditsRefundOrderId: firebase.firestore.FieldValue.delete(), } if (!isRegenerationFlow) { diff --git a/src/screens/Studio/SongReady.js b/src/screens/Studio/SongReady.js index 1c89587..d538255 100644 --- a/src/screens/Studio/SongReady.js +++ b/src/screens/Studio/SongReady.js @@ -297,6 +297,7 @@ const SongReady = () => { onTogglePlayback={handleTogglePlayback} projectId={projectId} selectedProject={selectedProject} + fallbackDurationS={selectedProject?.musicDurations?.[index]} /> )} contentContainerStyle={{ gap: 16, paddingBottom: gutters }} @@ -346,6 +347,7 @@ const SongOptionCard = ({ onTogglePlayback, projectId, selectedProject, + fallbackDurationS, }) => { const trackTitle = (Array.isArray(selectedProject?.musicTitles) ? selectedProject.musicTitles?.[index] : null) || @@ -360,9 +362,20 @@ const SongOptionCard = ({ metadata: { index, projectId }, }) - const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 }) + const fallbackDurationMs = Math.max(0, Math.round((Number(fallbackDurationS) || 0) * 1000)) + const [progressInfo, setProgressInfo] = useState({ + pos: 0, + dur: fallbackDurationMs, + }) const [isPlaying, setIsPlaying] = useState(false) + useEffect(() => { + if (!fallbackDurationMs) return + setProgressInfo((current) => + current.dur ? current : { ...current, dur: fallbackDurationMs } + ) + }, [fallbackDurationMs]) + useEffect(() => { registerPlayer(index, player) return () => registerPlayer(index, null) @@ -387,7 +400,8 @@ const SongOptionCard = ({ return undefined } const id = setInterval(() => { - const durationMs = Math.max(0, Math.round((Number(player.duration) || 0) * 1000)) + const playerDurationMs = Math.max(0, Math.round((Number(player.duration) || 0) * 1000)) + const durationMs = playerDurationMs || fallbackDurationMs const positionMs = Math.max(0, Math.round((Number(player.currentTime) || 0) * 1000)) setProgressInfo((prev) => { if ( @@ -407,7 +421,7 @@ const SongOptionCard = ({ }) }, 300) return () => clearInterval(id) - }, [player]) + }, [fallbackDurationMs, player]) const handleSeek = useCallback( async (targetMs) => {