2nzi commited on
Commit
cf51ebb
·
verified ·
1 Parent(s): 2f29495

first commit

Browse files
.dockerignore ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ venv/
8
+ env/
9
+ .env
10
+ .env.local
11
+ .env.*.local
12
+
13
+ # IDE
14
+ .idea/
15
+ .vscode/
16
+ *.swp
17
+ *.swo
18
+
19
+ # OS
20
+ .DS_Store
21
+ Thumbs.db
22
+
23
+ # Logs
24
+ *.log
25
+
26
+ # Git
27
+ .git
28
+ .gitignore
29
+
30
+ # Tests
31
+ .pytest_cache/
32
+ .coverage
33
+ htmlcov/
34
+
35
+ # Distribution / packaging
36
+ dist/
37
+ build/
38
+ *.egg-info/
39
+
40
+ # Local development
41
+ docker-compose.yml
42
+ docker-compose.*.yml
43
+ README.md
.gitignore ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environnements virtuels
2
+ venv/
3
+ env/
4
+ ENV/
5
+ .env
6
+ .venv
7
+ env.bak/
8
+ venv.bak/
9
+
10
+ # Python
11
+ __pycache__/
12
+ *.py[cod]
13
+ *$py.class
14
+ *.so
15
+ .Python
16
+ build/
17
+ develop-eggs/
18
+ dist/
19
+ downloads/
20
+ eggs/
21
+ .eggs/
22
+ lib/
23
+ lib64/
24
+ parts/
25
+ sdist/
26
+ var/
27
+ wheels/
28
+ *.egg-info/
29
+ .installed.cfg
30
+ *.egg
31
+
32
+ # Fichiers de test et coverage
33
+ htmlcov/
34
+ .tox/
35
+ .coverage
36
+ .coverage.*
37
+ .cache
38
+ nosetests.xml
39
+ coverage.xml
40
+ *.cover
41
+ .hypothesis/
42
+ .pytest_cache/
43
+
44
+ # IDEs et éditeurs
45
+ .idea/
46
+ .vscode/
47
+ *.swp
48
+ *.swo
49
+ .project
50
+ .pydevproject
51
+ .settings
52
+ *.sublime-workspace
53
+ *.sublime-project
54
+
55
+ # Notebooks Jupyter
56
+ .ipynb_checkpoints
57
+ */.ipynb_checkpoints/*
58
+
59
+ # Logs et bases de données
60
+ *.log
61
+ *.sqlite
62
+ *.db
63
+
64
+ # Fichiers système
65
+ .DS_Store
66
+ Thumbs.db
67
+ *.mp4
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /code
4
+
5
+ # Copier les fichiers de requirements
6
+ COPY requirements.txt .
7
+
8
+ # Installer les dépendances
9
+ RUN pip install --no-cache-dir -r requirements.txt
10
+
11
+ # Copier le code de l'application
12
+ COPY . .
13
+
14
+ # Exposer le port
15
+ EXPOSE 7860
16
+
17
+ # Commande pour démarrer l'application
18
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
app/__init__.py ADDED
File without changes
app/api/__init__.py ADDED
File without changes
app/api/routes/__init__.py ADDED
File without changes
app/api/routes/routes.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from fastapi.responses import FileResponse
3
+ from app.services.video_service import VideoService
4
+ from fastapi import APIRouter, HTTPException
5
+ from app.services.quiz_generator import QuizGenerator
6
+ from app.models.quiz import QuizRequest
7
+ from app.core.config import settings
8
+ import logging
9
+ import uuid
10
+
11
+ # Configurer le logging
12
+ logging.basicConfig(level=logging.DEBUG)
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Importons le quiz de test
16
+ TEST_QUIZ = {
17
+ "id": "test-quiz",
18
+ "theme": "Intelligence Artificielle",
19
+ "questions": [
20
+ {
21
+ "question": "Qu'est-ce que le Machine Learning ?",
22
+ "options": [
23
+ "Un type de robot",
24
+ "Une branche de l'IA permettant aux machines d'apprendre",
25
+ "Un langage de programmation",
26
+ "Un système d'exploitation"
27
+ ],
28
+ "correct_answer": "Une branche de l'IA permettant aux machines d'apprendre"
29
+ },
30
+ {
31
+ "question": "Qu'est-ce qu'un réseau de neurones ?",
32
+ "options": [
33
+ "Un système inspiré du cerveau humain",
34
+ "Un réseau social",
35
+ "Un câble ethernet",
36
+ "Un type de processeur"
37
+ ],
38
+ "correct_answer": "Un système inspiré du cerveau humain"
39
+ },
40
+ {
41
+ "question": "Quel est le langage le plus utilisé en IA ?",
42
+ "options": [
43
+ "Java",
44
+ "C++",
45
+ "Python",
46
+ "JavaScript"
47
+ ],
48
+ "correct_answer": "Python"
49
+ }
50
+ ]
51
+ }
52
+
53
+
54
+ router = APIRouter()
55
+ router = APIRouter()
56
+ quiz_generator = QuizGenerator(provider=settings.AI_PROVIDER)
57
+
58
+ @router.post("/quiz")
59
+ async def create_quiz(request: QuizRequest):
60
+ try:
61
+ questions = await quiz_generator.generate_quiz(
62
+ theme=request.theme,
63
+ num_questions=request.num_questions
64
+ )
65
+ # Créer un ID unique pour le quiz
66
+ quiz_id = f"quiz_{uuid.uuid4().hex[:8]}"
67
+
68
+ quiz_data = {
69
+ "id": quiz_id,
70
+ "theme": request.theme,
71
+ "questions": [
72
+ {
73
+ "question": q.question,
74
+ "options": q.options,
75
+ "correct_answer": q.correct_answer
76
+ } for q in questions
77
+ ]
78
+ }
79
+
80
+ return quiz_data
81
+ except Exception as e:
82
+ logger.error(f"Erreur dans create_quiz: {str(e)}")
83
+ raise HTTPException(status_code=500, detail=str(e))
84
+
85
+
86
+ @router.post("/quiz/{quiz_id}/video")
87
+ async def generate_video(quiz_id: str, quiz_data: dict):
88
+ try:
89
+ logger.info(f"Début de la génération de vidéo pour le quiz {quiz_id}")
90
+ logger.debug(f"Données du quiz reçues: {quiz_data}")
91
+
92
+ # Vérifier que les données nécessaires sont présentes
93
+ if not quiz_data.get("questions") or quiz_data.get("styleConfig") is None:
94
+ raise HTTPException(status_code=400, detail="Données du quiz ou style manquants")
95
+
96
+ # Utiliser directement les données reçues
97
+ video_path = await VideoService.generate_quiz_video(quiz_data)
98
+ logger.info(f"Vidéo générée avec succès: {video_path}")
99
+
100
+ return FileResponse(
101
+ path=video_path,
102
+ media_type="video/mp4",
103
+ filename=f"quiz_{quiz_id}.mp4"
104
+ )
105
+ except Exception as e:
106
+ logger.error(f"Erreur lors de la génération de la vidéo: {str(e)}", exc_info=True)
107
+ raise HTTPException(status_code=500, detail=str(e))
app/api/routes/stripe_routes.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Request
2
+ from fastapi.responses import JSONResponse
3
+ import stripe
4
+ from app.core.config import settings
5
+ from pydantic import BaseModel
6
+
7
+ router = APIRouter()
8
+ stripe.api_key = settings.STRIPE_SECRET_KEY
9
+
10
+ # Utilisation des variables d'environnement pour les price IDs
11
+ STRIPE_PRICE_IDS = {
12
+ 'starter': settings.STRIPE_PRICE_ID_STARTER,
13
+ 'pro': settings.STRIPE_PRICE_ID_PRO,
14
+ 'business': settings.STRIPE_PRICE_ID_BUSINESS
15
+ }
16
+
17
+ class SubscriptionRequest(BaseModel):
18
+ plan_id: str
19
+ user_id: str
20
+
21
+ class PaymentSuccessRequest(BaseModel):
22
+ session_id: str
23
+
24
+ @router.post("/create-subscription")
25
+ async def create_subscription(request: SubscriptionRequest):
26
+ try:
27
+ price_id = STRIPE_PRICE_IDS.get(request.plan_id)
28
+ print(f"Plan ID reçu: {request.plan_id}")
29
+ print(f"Price ID trouvé: {price_id}")
30
+ print(f"Mode Stripe: {stripe.api_key.startswith('sk_test_') and 'TEST' or 'LIVE'}")
31
+
32
+ if not price_id:
33
+ raise HTTPException(status_code=400, detail=f"Plan non trouvé: {request.plan_id}")
34
+
35
+ session = stripe.checkout.Session.create(
36
+ payment_method_types=['card'],
37
+ line_items=[{
38
+ 'price': price_id,
39
+ 'quantity': 1,
40
+ }],
41
+ mode='subscription',
42
+ success_url=f"{settings.FRONTEND_URL}/generate?session_id={{CHECKOUT_SESSION_ID}}",
43
+ cancel_url=f"{settings.FRONTEND_URL}/tokens",
44
+ metadata={
45
+ 'user_id': request.user_id,
46
+ 'plan_id': request.plan_id
47
+ }
48
+ )
49
+ return {"sessionId": session.id}
50
+ except Exception as e:
51
+ print(f"Erreur complète: {str(e)}")
52
+ raise HTTPException(status_code=400, detail=str(e))
53
+
54
+ @router.post("/webhook")
55
+ async def stripe_webhook(request: Request):
56
+ payload = await request.body()
57
+ sig_header = request.headers.get("stripe-signature")
58
+
59
+ try:
60
+ event = stripe.Webhook.construct_event(
61
+ payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
62
+ )
63
+
64
+ if event["type"] == "checkout.session.completed":
65
+ session = event["data"]["object"]
66
+ user_id = session["metadata"]["user_id"]
67
+ plan_id = session["metadata"]["plan_id"]
68
+
69
+ # Au lieu de mettre à jour Firebase, on renvoie les informations
70
+ return {
71
+ "status": "success",
72
+ "user_id": user_id,
73
+ "plan_id": plan_id
74
+ }
75
+
76
+ return {"status": "success"}
77
+ except Exception as e:
78
+ raise HTTPException(status_code=400, detail=str(e))
79
+
80
+ @router.post("/create-webhook")
81
+ async def create_webhook_endpoint():
82
+ try:
83
+ endpoint = stripe.WebhookEndpoint.create(
84
+ url=f"{settings.FRONTEND_URL}/api/webhook",
85
+ enabled_events=[
86
+ "checkout.session.completed",
87
+ "customer.subscription.created",
88
+ "customer.subscription.deleted",
89
+ "invoice.paid",
90
+ "invoice.payment_failed"
91
+ ],
92
+ description="Endpoint pour la gestion des abonnements"
93
+ )
94
+
95
+ # Sauvegarder la clé secrète dans les variables d'environnement
96
+ webhook_secret = endpoint.secret
97
+ return {
98
+ "status": "success",
99
+ "endpoint_id": endpoint.id,
100
+ "webhook_secret": webhook_secret # À stocker dans .env
101
+ }
102
+ except Exception as e:
103
+ raise HTTPException(status_code=400, detail=str(e))
104
+
105
+ @router.post("/payment-success")
106
+ async def handle_payment_success(request: PaymentSuccessRequest):
107
+ try:
108
+ print("=== Début du traitement payment-success ===")
109
+ print(f"Session ID reçu: {request.session_id}")
110
+
111
+ session = stripe.checkout.Session.retrieve(request.session_id)
112
+ print(f"Status du paiement: {session.payment_status}")
113
+ print(f"Customer: {session.customer}")
114
+ print(f"Metadata complète: {session.metadata}")
115
+
116
+ user_id = session.metadata.get('user_id')
117
+ plan_id = session.metadata.get('plan_id')
118
+
119
+ if not user_id or not plan_id:
120
+ raise HTTPException(
121
+ status_code=400,
122
+ detail="Metadata manquante: user_id ou plan_id non trouvé"
123
+ )
124
+
125
+ print(f"User ID extrait: {user_id}")
126
+ print(f"Plan ID extrait: {plan_id}")
127
+
128
+ return {
129
+ "status": "success",
130
+ "user_id": user_id,
131
+ "plan_id": plan_id
132
+ }
133
+ except Exception as e:
134
+ print(f"Erreur lors du traitement: {str(e)}")
135
+ raise HTTPException(status_code=400, detail=str(e))
app/assets/sounds/correct.mp3 ADDED
Binary file (183 kB). View file
 
app/core/config.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+ from typing import Optional
3
+ import os
4
+
5
+ class Settings(BaseSettings):
6
+ # Configuration Firebase
7
+ FIREBASE_API_KEY: str
8
+ FIREBASE_AUTH_DOMAIN: str
9
+ FIREBASE_PROJECT_ID: str
10
+ FIREBASE_STORAGE_BUCKET: str
11
+ FIREBASE_MESSAGING_SENDER_ID: str
12
+ FIREBASE_APP_ID: str
13
+
14
+ # Configuration Stripe
15
+ STRIPE_SECRET_KEY: str
16
+ STRIPE_WEBHOOK_SECRET: str
17
+ STRIPE_PRICE_ID_STARTER: str = "price_1QmXNE2NCHU0qPWW3A8sEK6l" # Remplacer par votre ID de prix test
18
+ STRIPE_PRICE_ID_PRO: str = "price_1QmXNE2NCHU0qPWW3A8sEK6l" # Remplacer par votre ID de prix test
19
+ STRIPE_PRICE_ID_BUSINESS: str = "price_1QmXNE2NCHU0qPWW3A8sEK6l" # Remplacer par votre ID de prix test
20
+
21
+ # Configuration générale
22
+ FRONTEND_URL: str = "http://localhost:8080" # URL du frontend
23
+ BACKEND_URL: str = "http://localhost:8000" # URL du backend
24
+ AI_PROVIDER: str = "openai"
25
+ # AI_PROVIDER: str = "deepseek"
26
+
27
+ # OpenAI
28
+ OPENAI_API_KEY: str
29
+ DEEPSEEK_API_KEY: str
30
+ MODEL_NAME: str = "gpt-3.5-turbo" # default OpenAI model
31
+
32
+ # Chemins
33
+ VECTOR_DB_PATH: str = "data/vectors"
34
+
35
+ class Config:
36
+ env_file = ".env"
37
+ env_file_encoding = "utf-8"
38
+ extra = "allow" # Permet les variables supplémentaires
39
+
40
+ settings = Settings()
app/fonts/Gotham Black Regular.ttf ADDED
Binary file (65.3 kB). View file
 
app/fonts/Montserrat-Black.ttf ADDED
Binary file (199 kB). View file
 
app/fonts/Montserrat-BlackItalic.ttf ADDED
Binary file (204 kB). View file
 
app/fonts/Montserrat-ExtraBold.ttf ADDED
Binary file (199 kB). View file
 
app/fonts/Urbanist-Italic-VariableFont_wght.ttf ADDED
Binary file (85.3 kB). View file
 
app/fonts/Urbanist-VariableFont_wght.ttf ADDED
Binary file (82.8 kB). View file
 
app/main.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from app.core.config import settings
4
+ from app.api.routes import stripe_routes, routes
5
+
6
+ app = FastAPI(title="Quiz Video API")
7
+
8
+ # Configuration CORS
9
+ app.add_middleware(
10
+ CORSMiddleware,
11
+ allow_origins=[settings.FRONTEND_URL],
12
+ allow_credentials=True,
13
+ allow_methods=["*"],
14
+ allow_headers=["*"],
15
+ )
16
+
17
+ app.include_router(routes.router, prefix="/api")
18
+ app.include_router(stripe_routes.router, prefix="/api")
19
+
20
+ @app.get("/")
21
+ async def root():
22
+ return {"message": "Quiz Video API"}
app/models/quiz.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import List
3
+
4
+ class Question(BaseModel):
5
+ question: str
6
+ options: List[str]
7
+ correct_answer: str
8
+
9
+ class QuizRequest(BaseModel):
10
+ theme: str
11
+ num_questions: int
12
+ language: str = "fr"
13
+
14
+ class Quiz(BaseModel):
15
+ id: str
16
+ theme: str
17
+ questions: List[Question]
app/services/font_manager.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from fontTools.ttLib import TTFont
3
+ import logging
4
+ from typing import Dict
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ class FontManager:
9
+ FONT_MAPPING = {
10
+ 'Gotham Black': 'Gotham-Black.otf',
11
+ 'Montserrat': 'Montserrat-Black.ttf',
12
+ 'Montserrat Bold': 'Montserrat-Bold.ttf',
13
+ 'Montserrat Extra': 'Montserrat-ExtraBold.ttf',
14
+ 'Urbanist Italic': 'Urbanist-Italic.ttf',
15
+ 'Urbanist': 'Urbanist-Variable.ttf'
16
+ }
17
+
18
+ def __init__(self):
19
+ self.font_dir = Path(__file__).parent.parent / "fonts"
20
+ self.font_dir.mkdir(exist_ok=True)
21
+ self.fonts_cache: Dict[str, str] = {}
22
+ self.default_font = "Montserrat-Black.ttf"
23
+ self._load_fonts()
24
+
25
+ def _load_fonts(self):
26
+ """Charge et valide toutes les polices disponibles"""
27
+ for font_path in self.font_dir.glob("*.[ot]tf"):
28
+ try:
29
+ font = TTFont(font_path)
30
+ font_name = font['name'].getName(4, 3, 1, 1033)
31
+ if font_name:
32
+ self.fonts_cache[font_path.name.lower()] = str(font_path)
33
+ font.close()
34
+ except Exception as e:
35
+ logger.warning(f"Impossible de charger la police {font_path}: {e}")
36
+
37
+ def get_font_path(self, font_name: str) -> str:
38
+ """Retourne le chemin de la police demandée ou de la police par défaut"""
39
+ if not font_name:
40
+ return str(self.font_dir / self.default_font)
41
+
42
+ # Chercher d'abord dans le mapping des noms
43
+ mapped_name = self.FONT_MAPPING.get(font_name)
44
+ if mapped_name:
45
+ font_path = self.fonts_cache.get(mapped_name.lower())
46
+ if font_path:
47
+ return font_path
48
+
49
+ # Si pas trouvé, essayer avec le nom direct
50
+ font_name = font_name.lower().replace(" ", "-")
51
+ font_path = self.fonts_cache.get(f"{font_name}.ttf") or self.fonts_cache.get(f"{font_name}.otf")
52
+
53
+ if not font_path:
54
+ logger.warning(f"Police {font_name} non trouvée, utilisation de la police par défaut")
55
+ return str(self.font_dir / self.default_font)
56
+
57
+ return font_path
app/services/quiz_generator.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Dict, Literal
2
+ import json
3
+ from langchain_community.vectorstores import FAISS
4
+ from langchain_openai import OpenAIEmbeddings
5
+ from langchain_openai import ChatOpenAI
6
+ from langchain.prompts import ChatPromptTemplate
7
+ from app.core.config import settings
8
+ from app.models.quiz import Question
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class QuizGenerator:
14
+ def __init__(self, provider: Literal["openai", "deepseek"] = "openai"):
15
+ self.embeddings = OpenAIEmbeddings(openai_api_key=settings.OPENAI_API_KEY)
16
+ self.vector_store = FAISS.load_local(
17
+ settings.VECTOR_DB_PATH,
18
+ self.embeddings,
19
+ allow_dangerous_deserialization=True
20
+ )
21
+
22
+ # Configuration selon le provider
23
+ if provider == "deepseek":
24
+ self.llm = ChatOpenAI(
25
+ model_name="deepseek-chat",
26
+ temperature=0.7,
27
+ openai_api_key=settings.DEEPSEEK_API_KEY,
28
+ base_url="https://api.deepseek.com"
29
+ )
30
+ else: # openai
31
+ self.llm = ChatOpenAI(
32
+ model_name=settings.MODEL_NAME,
33
+ temperature=0.7,
34
+ openai_api_key=settings.OPENAI_API_KEY
35
+ )
36
+
37
+ def clean_json_response(self, response_text: str) -> str:
38
+ """Nettoie la réponse du LLM pour obtenir un JSON valide."""
39
+ # Supprime les backticks markdown et le mot 'json'
40
+ cleaned = response_text.replace('```json', '').replace('```', '').strip()
41
+ # Supprime les espaces et sauts de ligne superflus au début et à la fin
42
+ return cleaned.strip()
43
+
44
+
45
+ async def generate_quiz(self, theme: str, num_questions: int = 5) -> List[Question]:
46
+ logger.debug(f"Génération de quiz - Thème: {theme}, Nb questions: {num_questions}")
47
+
48
+ try:
49
+ # Récupérer le contexte pertinent
50
+ docs = self.vector_store.similarity_search(theme, k=3)
51
+ context = "\n".join([doc.page_content for doc in docs])
52
+
53
+ # Template pour la génération de questions
54
+ prompt = ChatPromptTemplate.from_template("""
55
+ Tu es un générateur de quiz intelligent.
56
+
57
+ Si tu as du contexte pertinent, utilise-le : {context}
58
+ Sinon, utilise tes connaissances générales.
59
+
60
+ Génère {num_questions} questions de quiz sur le thème: {theme}
61
+
62
+ IMPORTANT: Réponds UNIQUEMENT avec un JSON valide sans backticks ni formatage markdown.
63
+ Format exact attendu:
64
+ {{
65
+ "questions": [
66
+ {{
67
+ "question": "La question",
68
+ "options": ["Option A", "Option B", "Option C"],
69
+ "correct_answer": "La bonne réponse (qui doit être une des options)"
70
+ }}
71
+ ]
72
+ }}
73
+ """)
74
+
75
+ # Générer les questions
76
+ response = await self.llm.agenerate([
77
+ prompt.format_messages(
78
+ context=context,
79
+ theme=theme,
80
+ num_questions=num_questions
81
+ )
82
+ ])
83
+
84
+ # Parser le JSON et créer les objets Question
85
+ response_text = response.generations[0][0].text
86
+ cleaned_response = self.clean_json_response(response_text)
87
+
88
+ try:
89
+ response_json = json.loads(cleaned_response)
90
+ except json.JSONDecodeError as e:
91
+ logger.error(f"Réponse brute: {response_text}")
92
+ logger.error(f"Réponse nettoyée: {cleaned_response}")
93
+ raise Exception(f"Erreur de parsing JSON: {str(e)}")
94
+
95
+ questions = []
96
+ for q in response_json["questions"]:
97
+ questions.append(Question(
98
+ question=q["question"],
99
+ options=q["options"],
100
+ correct_answer=q["correct_answer"]
101
+ ))
102
+
103
+ return questions
104
+
105
+ except Exception as e:
106
+ print(f"Erreur dans generate_quiz: {str(e)}")
107
+ raise e
app/services/video_service.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ from PIL import Image, ImageDraw, ImageFont
4
+ import os
5
+ import logging
6
+ import base64
7
+ from app.services.font_manager import FontManager
8
+ import io
9
+ from moviepy.editor import VideoFileClip, AudioFileClip, CompositeVideoClip
10
+ from moviepy.audio.AudioClip import CompositeAudioClip
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ class VideoService:
15
+ # Initialiser le gestionnaire de polices
16
+ font_manager = FontManager()
17
+
18
+ # Constantes de style
19
+ COLORS = {
20
+ 'background': (25, 25, 25),
21
+ 'text': (255, 255, 255),
22
+ 'highlight': (64, 156, 255),
23
+ 'correct': (46, 204, 113),
24
+ 'option_bg': (50, 50, 50)
25
+ }
26
+
27
+ @staticmethod
28
+ async def generate_quiz_video(quiz_data: dict):
29
+ try:
30
+ # Configuration
31
+ WIDTH, HEIGHT = 720, 1280
32
+ FPS = 24
33
+ DURATION_QUESTION = 5
34
+ DURATION_ANSWER = 3
35
+
36
+ # Récupérer les styles depuis quiz_data
37
+ style_config = quiz_data.get('styleConfig', {})
38
+ title_style = style_config.get('title', {})
39
+ questions_style = style_config.get('questions', {})
40
+ answers_style = style_config.get('answers', {})
41
+ background_style = style_config.get('background', {})
42
+
43
+ os.makedirs("temp", exist_ok=True)
44
+ output_path = f"temp/quiz_{quiz_data['id']}.mp4"
45
+
46
+ # Créer une vidéo temporaire sans audio
47
+ temp_video_path = f"temp/temp_quiz_{quiz_data['id']}.mp4"
48
+ final_output_path = f"temp/quiz_{quiz_data['id']}.mp4"
49
+
50
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
51
+ out = cv2.VideoWriter(temp_video_path, fourcc, FPS, (WIDTH, HEIGHT))
52
+
53
+ # Charger l'image de fond si elle existe
54
+ background_image = None
55
+ if background_style.get('image'):
56
+ # Décoder l'image base64 en gardant les couleurs d'origine
57
+ image_data = base64.b64decode(background_style['image'].split(',')[1])
58
+ img = Image.open(io.BytesIO(image_data))
59
+
60
+ # Redimensionner en conservant le ratio
61
+ ratio = img.width / img.height
62
+ new_height = HEIGHT
63
+ new_width = int(HEIGHT * ratio)
64
+ img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
65
+
66
+ # Centrer et recadrer si nécessaire
67
+ if new_width > WIDTH:
68
+ left = (new_width - WIDTH) // 2
69
+ img = img.crop((left, 0, left + WIDTH, HEIGHT))
70
+ elif new_width < WIDTH:
71
+ new_img = Image.new('RGB', (WIDTH, HEIGHT), (0, 0, 0))
72
+ paste_x = (WIDTH - new_width) // 2
73
+ new_img.paste(img, (paste_x, 0))
74
+ img = new_img
75
+
76
+ # Convertir en array numpy en préservant les couleurs
77
+ background_image = np.array(img)
78
+
79
+ # Liste pour stocker les moments où jouer le son
80
+ correct_answer_times = []
81
+ current_time = 0
82
+
83
+ # Création des frames
84
+ for i, question in enumerate(quiz_data["questions"], 1):
85
+ frame = Image.new('RGB', (WIDTH, HEIGHT))
86
+ if background_image is not None:
87
+ # Utiliser l'image de fond en RGB
88
+ frame = Image.fromarray(background_image)
89
+ if background_style.get('opacity', 1) < 1:
90
+ overlay = Image.new('RGB', (WIDTH, HEIGHT), (0, 0, 0))
91
+ frame = Image.blend(frame, overlay, 1 - background_style.get('opacity', 1))
92
+
93
+ # Créer les frames
94
+ question_frame = VideoService._create_question_frame(
95
+ frame, question, i, len(quiz_data["questions"]),
96
+ title_style, questions_style, answers_style,
97
+ WIDTH, HEIGHT, show_answer=False
98
+ )
99
+
100
+ # Convertir en BGR pour OpenCV
101
+ frame_cv = cv2.cvtColor(np.array(question_frame), cv2.COLOR_RGB2BGR)
102
+
103
+ for _ in range(int(FPS * DURATION_QUESTION)):
104
+ out.write(frame_cv)
105
+ current_time += DURATION_QUESTION
106
+
107
+ # Marquer le moment pour jouer le son
108
+ correct_answer_times.append(current_time)
109
+
110
+ # Frame de réponse
111
+ answer_frame = VideoService._create_question_frame(
112
+ frame.copy(), question, i, len(quiz_data["questions"]),
113
+ title_style, questions_style, answers_style,
114
+ WIDTH, HEIGHT, show_answer=True
115
+ )
116
+
117
+ frame_cv = cv2.cvtColor(np.array(answer_frame), cv2.COLOR_RGB2BGR)
118
+
119
+ for _ in range(int(FPS * DURATION_ANSWER)):
120
+ out.write(frame_cv)
121
+ current_time += DURATION_ANSWER
122
+
123
+ out.release()
124
+
125
+ # Ajouter l'audio
126
+ video = VideoFileClip(temp_video_path)
127
+ correct_sound = AudioFileClip("app/assets/sounds/correct.mp3")
128
+
129
+ # Créer plusieurs clips audio, un pour chaque bonne réponse
130
+ audio_clips = []
131
+ for time in correct_answer_times:
132
+ # Créer une nouvelle instance du son pour chaque moment
133
+ audio_clip = correct_sound.copy()
134
+ audio_clips.append(audio_clip.set_start(time-0.5))
135
+
136
+ # Combiner tous les clips audio
137
+ if audio_clips:
138
+ # Fusionner tous les clips audio en un seul
139
+ final_audio = CompositeAudioClip(audio_clips)
140
+ # Ajouter l'audio à la vidéo
141
+ final_video = video.set_audio(final_audio)
142
+ else:
143
+ final_video = video
144
+
145
+ # Écrire la vidéo finale
146
+ final_video.write_videofile(
147
+ final_output_path,
148
+ codec='libx264',
149
+ audio_codec='aac',
150
+ fps=FPS
151
+ )
152
+
153
+ # Nettoyer les fichiers temporaires
154
+ os.remove(temp_video_path)
155
+ video.close()
156
+ correct_sound.close()
157
+ for clip in audio_clips:
158
+ clip.close()
159
+
160
+ return final_output_path
161
+
162
+ except Exception as e:
163
+ logger.error(f"Erreur dans generate_quiz_video: {str(e)}")
164
+ raise
165
+
166
+ @staticmethod
167
+ def _scale_size(size, preview_height=170, video_height=720):
168
+ """
169
+ Convertit une taille de la preview vers la taille vidéo ou inverse
170
+ en conservant le ratio
171
+ """
172
+ scale_factor = video_height / preview_height
173
+ return int(size * scale_factor)
174
+
175
+ @staticmethod
176
+ def _create_question_frame(frame, question, current_num, total_questions,
177
+ title_style, questions_style, answers_style,
178
+ width, height, show_answer=False):
179
+ draw = ImageDraw.Draw(frame)
180
+
181
+ try:
182
+ # Définir les tailles de base pour la preview (170px)
183
+ BASE_PREVIEW_HEIGHT = 170
184
+
185
+ # Convertir les tailles de police de la preview vers la taille vidéo
186
+ title_base_size = int(title_style.get('fontSize', 11)) # taille en px pour preview
187
+ question_base_size = int(questions_style.get('fontSize', 8))
188
+ answer_base_size = int(answers_style.get('fontSize', 6))
189
+
190
+ # Mettre à l'échelle pour la vidéo
191
+ title_font_size = VideoService._scale_size(title_base_size)
192
+ question_font_size = VideoService._scale_size(question_base_size)
193
+ answer_font_size = VideoService._scale_size(answer_base_size)
194
+
195
+ title_font = ImageFont.truetype(
196
+ VideoService.font_manager.get_font_path(title_style.get('fontFamily')),
197
+ title_font_size
198
+ )
199
+ question_font = ImageFont.truetype(
200
+ VideoService.font_manager.get_font_path(questions_style.get('fontFamily')),
201
+ question_font_size
202
+ )
203
+ answer_font = ImageFont.truetype(
204
+ VideoService.font_manager.get_font_path(answers_style.get('fontFamily')),
205
+ answer_font_size
206
+ )
207
+
208
+ # Position du titre à 10% du haut
209
+ title_y = int(0.10 * height)
210
+ title_text = f"Question {current_num}/{total_questions}"
211
+ VideoService._draw_text(draw, title_text, title_font, title_y, width, title_style)
212
+
213
+ # Position de la question à 10% en dessous du titre
214
+ question_y = int(0.23 * height) # 10% + 10%
215
+ question_text = VideoService._wrap_text(question['question'], question_font, width - 100)
216
+ VideoService._draw_text(draw, question_text, question_font, question_y, width, questions_style)
217
+
218
+ # Position des réponses à 10% en dessous de la question
219
+ start_y = int(0.38 * height)
220
+ spacing_between_options = int(0.12 * height) # Espacement entre les centres des blocs
221
+
222
+ # Pré-calculer les hauteurs des blocs
223
+ option_heights = []
224
+ for option in question['options']:
225
+ letter = chr(65 + len(option_heights))
226
+ full_text = f"{letter}. {option}"
227
+ wrapped_text = VideoService._wrap_text(full_text, answer_font, width - (width * 0.1 + 50))
228
+ lines = wrapped_text.split('\n')
229
+
230
+ bbox = answer_font.getbbox('Ag')
231
+ line_height = bbox[3] - bbox[1]
232
+ text_height = line_height * len(lines)
233
+ actual_height = max(80, text_height + 40) # même calcul que dans _draw_option
234
+ option_heights.append(actual_height)
235
+
236
+ # Dessiner chaque option en tenant compte des hauteurs
237
+ current_y = start_y
238
+ for i, option in enumerate(question['options']):
239
+ is_correct = show_answer and option == question['correct_answer']
240
+ VideoService._draw_option(draw, option, current_y,
241
+ answer_font, width, answers_style, is_correct, i)
242
+
243
+ # Calculer la position du prochain bloc en tenant compte des hauteurs
244
+ if i < len(question['options']) - 1:
245
+ current_block_half = option_heights[i] / 2
246
+ next_block_half = option_heights[i + 1] / 2
247
+ current_y += spacing_between_options # Espacement fixe entre les centres
248
+
249
+ return frame
250
+
251
+ except Exception as e:
252
+ logger.error(f"Erreur dans _create_question_frame: {str(e)}")
253
+ raise
254
+
255
+ @staticmethod
256
+ def _draw_option(draw, option_text, y_position, font, width, style, is_correct=False, option_index=0):
257
+ # Hauteur minimale du bloc d'option
258
+ option_height = 80
259
+ margin_left = width * 0.1
260
+
261
+ # Calculer la hauteur réelle du texte
262
+ letter = chr(65 + option_index)
263
+ full_text = f"{letter}. {option_text}"
264
+ wrapped_text = VideoService._wrap_text(full_text, font, width - (margin_left + 50))
265
+ lines = wrapped_text.split('\n')
266
+
267
+ # Calculer la hauteur totale du texte
268
+ bbox = font.getbbox('Ag') # Utiliser une ligne de référence pour la hauteur
269
+ line_height = bbox[3] - bbox[1]
270
+ text_height = line_height * len(lines)
271
+
272
+ # Utiliser la plus grande valeur entre option_height et text_height
273
+ actual_height = max(option_height, text_height + 40) # +40 pour le padding
274
+
275
+ # Dessiner le fond
276
+ if style.get('backgroundColor'):
277
+ bg_color = tuple(int(style['backgroundColor'][i:i+2], 16) for i in (1, 3, 5))
278
+ draw.rectangle(
279
+ [
280
+ (50, y_position - actual_height//2),
281
+ (width - 50, y_position + actual_height//2)
282
+ ],
283
+ fill=bg_color
284
+ )
285
+
286
+ # Si c'est la bonne réponse, on force la couleur en vert
287
+ if is_correct:
288
+ style = style.copy()
289
+ style['color'] = '#2ECC71'
290
+
291
+ # Dessiner le texte aligné à gauche avec la marge
292
+ VideoService._draw_text(draw, wrapped_text, font, y_position, width, style, align_left=True, margin_left=margin_left)
293
+
294
+ @staticmethod
295
+ def _draw_text(draw, text, font, y_position, width, style, align_left=False, margin_left=0):
296
+ try:
297
+ lines = text.split('\n')
298
+
299
+ # Calculer la hauteur totale avec plus d'espacement entre les lignes
300
+ line_heights = []
301
+ line_widths = []
302
+ total_height = 0
303
+ max_width = 0
304
+ line_spacing = 1 # Facteur d'espacement entre les lignes (1.5 fois la hauteur normale)
305
+
306
+ for line in lines:
307
+ bbox = font.getbbox(line)
308
+ line_height = bbox[3] - bbox[1]
309
+ line_width = bbox[2] - bbox[0]
310
+
311
+ line_heights.append(line_height)
312
+ line_widths.append(line_width)
313
+ total_height += line_height * line_spacing # Multiplier par le facteur d'espacement
314
+ max_width = max(max_width, line_width)
315
+
316
+ # Augmenter le padding autour du texte
317
+ padding = 20 # Augmenté de 20 à 30
318
+
319
+ current_y = y_position - (total_height // 2)
320
+
321
+ if style.get('backgroundColor'):
322
+ corner_radius = 15
323
+ bg_color = tuple(int(style['backgroundColor'][i:i+2], 16) for i in (1, 3, 5))
324
+
325
+ if align_left:
326
+ x1, y1 = 50, current_y - padding
327
+ x2, y2 = width - 50, current_y + total_height + padding
328
+ else:
329
+ center_x = width // 2
330
+ x1 = center_x - (max_width // 2) - padding
331
+ x2 = center_x + (max_width // 2) + padding
332
+ y1 = current_y - padding
333
+ y2 = current_y + total_height + padding
334
+
335
+ # Dessiner un rectangle avec coins arrondis
336
+ draw.pieslice([x1, y1, x1 + corner_radius * 2, y1 + corner_radius * 2], 180, 270, fill=bg_color)
337
+ draw.pieslice([x2 - corner_radius * 2, y1, x2, y1 + corner_radius * 2], 270, 0, fill=bg_color)
338
+ draw.pieslice([x1, y2 - corner_radius * 2, x1 + corner_radius * 2, y2], 90, 180, fill=bg_color)
339
+ draw.pieslice([x2 - corner_radius * 2, y2 - corner_radius * 2, x2, y2], 0, 90, fill=bg_color)
340
+
341
+ draw.rectangle([x1 + corner_radius, y1, x2 - corner_radius, y2], fill=bg_color)
342
+ draw.rectangle([x1, y1 + corner_radius, x2, y2 - corner_radius], fill=bg_color)
343
+
344
+ # Dessiner chaque ligne de texte avec plus d'espacement
345
+ for i, line in enumerate(lines):
346
+ if align_left:
347
+ x_position = margin_left
348
+ else:
349
+ x_position = (width - line_widths[i]) // 2
350
+
351
+ color = tuple(int(style.get('color', '#FFFFFF')[i:i+2], 16) for i in (1, 3, 5))
352
+ # Assurer une épaisseur minimale de 1 pour le contour
353
+ stroke_width = max(1, 8*int(float(style.get('textStrokeWidth', 0))))
354
+ stroke_color = tuple(int(style.get('textStrokeColor', '#000000')[i:i+2], 16) for i in (1, 3, 5))
355
+
356
+ if float(style.get('textStrokeWidth', 0)) > 0: # Vérifier la valeur originale
357
+ # Dessiner d'abord le contour
358
+ draw.text((x_position, current_y), line,
359
+ font=font, fill=stroke_color, stroke_width=stroke_width)
360
+
361
+ # Dessiner le texte principal
362
+ draw.text((x_position, current_y), line,
363
+ font=font, fill=color)
364
+
365
+ current_y += line_heights[i] * line_spacing # Multiplier par le facteur d'espacement
366
+
367
+ except Exception as e:
368
+ logger.error(f"Erreur dans _draw_text: {str(e)}")
369
+ raise
370
+
371
+ @staticmethod
372
+ def _wrap_text(text: str, font: ImageFont, max_width: int) -> str:
373
+ words = text.split()
374
+ lines = []
375
+ current_line = []
376
+
377
+ for word in words:
378
+ current_line.append(word)
379
+ line = ' '.join(current_line)
380
+ bbox = font.getbbox(line)
381
+ if bbox[2] > max_width:
382
+ if len(current_line) == 1:
383
+ lines.append(line)
384
+ current_line = []
385
+ else:
386
+ current_line.pop()
387
+ lines.append(' '.join(current_line))
388
+ current_line = [word]
389
+
390
+ if current_line:
391
+ lines.append(' '.join(current_line))
392
+
393
+ return '\n'.join(lines)
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-dotenv
4
+ langchain
5
+ langchain-community
6
+ langchain-openai
7
+ openai
8
+ faiss-cpu
9
+ numpy
10
+ pydantic
11
+ pydantic-settings
12
+ python-multipart
13
+ moviepy