ismdrobiul489 commited on
Commit
1574979
Β·
1 Parent(s): 5c1a3a8

Add Quiz Reel module with TTS, frame generation, and video composition

Browse files
modules/quiz_reel/__init__.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Quiz Reel Module
3
+ Generates quiz videos with TTS, options, and answer reveal.
4
+ """
5
+ import logging
6
+ from fastapi import FastAPI
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ # Module metadata
11
+ MODULE_NAME = "quiz_reel"
12
+ MODULE_PREFIX = "/api/quiz"
13
+ MODULE_DESCRIPTION = "Quiz video generation with TTS and animated reveals"
14
+
15
+
16
+ def register(app: FastAPI, config) -> None:
17
+ """Register quiz_reel module with the app"""
18
+ from .router import router, set_app_reference
19
+
20
+ try:
21
+ # Set app reference for accessing shared services
22
+ set_app_reference(app)
23
+
24
+ # Include router
25
+ app.include_router(router, prefix=MODULE_PREFIX, tags=["Quiz Reel"])
26
+
27
+ logger.info(f"βœ… Quiz Reel module registered at {MODULE_PREFIX}")
28
+
29
+ except Exception as e:
30
+ logger.error(f"Failed to register quiz_reel module: {e}")
31
+ raise
modules/quiz_reel/router.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Quiz Reel Router
3
+ API endpoints for quiz video generation.
4
+ """
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import uuid
9
+ from typing import Dict
10
+ from pathlib import Path
11
+ from fastapi import APIRouter, BackgroundTasks, HTTPException
12
+ from fastapi.responses import FileResponse
13
+
14
+ from .schemas import QuizRequest, JobResponse, JobStatus
15
+ from .services.quiz_frame import QuizFrameGenerator
16
+ from .services.quiz_composer import QuizComposer
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ router = APIRouter()
21
+
22
+ # App reference for accessing shared services (TTS)
23
+ _app = None
24
+
25
+ # Job storage
26
+ jobs: Dict[str, Dict] = {}
27
+
28
+ # Service instances
29
+ frame_generator = QuizFrameGenerator()
30
+ quiz_composer = QuizComposer()
31
+
32
+
33
+ def set_app_reference(app):
34
+ """Set FastAPI app reference for accessing shared services"""
35
+ global _app
36
+ _app = app
37
+ logger.info("App reference set for quiz_reel module")
38
+
39
+
40
+ def update_job(job_id: str, status: str, progress: int = 0,
41
+ video_url: str = None, error: str = None):
42
+ """Update job status"""
43
+ if job_id in jobs:
44
+ jobs[job_id].update({
45
+ "status": status,
46
+ "progress": progress,
47
+ "video_url": video_url,
48
+ "error": error
49
+ })
50
+
51
+
52
+ async def generate_quiz_video(job_id: str, quizzes: list, voice: str):
53
+ """Background task to generate quiz video"""
54
+ temp_dir = f"temp/quiz_{job_id}"
55
+
56
+ try:
57
+ update_job(job_id, "processing", 5)
58
+ os.makedirs(temp_dir, exist_ok=True)
59
+
60
+ # Get TTS client from app.state
61
+ if not _app or not hasattr(_app, 'state'):
62
+ raise Exception("App reference not available")
63
+
64
+ tts_client = getattr(_app.state, 'tts_client', None)
65
+ if not tts_client:
66
+ raise Exception("TTS client not available - check if story_reels module is loaded")
67
+
68
+ logger.info(f"Generating quiz video with {len(quizzes)} quizzes")
69
+
70
+ # Generate TTS for each quiz
71
+ quiz_audios = []
72
+
73
+ for i, quiz in enumerate(quizzes):
74
+ progress = int(10 + (i / len(quizzes)) * 40)
75
+ update_job(job_id, "processing", progress)
76
+
77
+ # Question TTS
78
+ question_text = quiz["question"]
79
+ question_audio_path = os.path.join(temp_dir, f"question_{i}.wav")
80
+
81
+ logger.info(f"Generating TTS for question {i+1}: {question_text[:30]}...")
82
+ audio_bytes, duration = await tts_client.generate(text=question_text, voice=voice)
83
+ with open(question_audio_path, "wb") as f:
84
+ f.write(audio_bytes)
85
+
86
+ # Answer TTS (e.g., "The answer is A, Mount Everest")
87
+ correct_key = quiz["correct"]
88
+ correct_value = quiz["options"].get(correct_key, "")
89
+ answer_text = f"The answer is {correct_key}, {correct_value}"
90
+ answer_audio_path = os.path.join(temp_dir, f"answer_{i}.wav")
91
+
92
+ logger.info(f"Generating TTS for answer {i+1}: {answer_text[:30]}...")
93
+ audio_bytes, duration = await tts_client.generate(text=answer_text, voice=voice)
94
+ with open(answer_audio_path, "wb") as f:
95
+ f.write(audio_bytes)
96
+
97
+ quiz_audios.append({
98
+ "question": question_audio_path,
99
+ "answer": answer_audio_path
100
+ })
101
+
102
+ update_job(job_id, "processing", 60)
103
+
104
+ # Compose video
105
+ logger.info(f"Composing quiz video for job {job_id}")
106
+ video_path = quiz_composer.compose_quiz_video(
107
+ quizzes=[dict(q) for q in quizzes],
108
+ quiz_audios=quiz_audios,
109
+ output_name=f"quiz_{job_id}.mp4",
110
+ frame_generator=frame_generator
111
+ )
112
+
113
+ update_job(job_id, "processing", 85)
114
+
115
+ # Upload to HF Hub (if enabled)
116
+ from modules.shared.services.hf_storage import get_hf_storage
117
+
118
+ hf_storage = get_hf_storage()
119
+ cloud_url = None
120
+
121
+ if hf_storage and hf_storage.enabled:
122
+ logger.info(f"Uploading to HF Hub for job {job_id}")
123
+ cloud_url = hf_storage.upload_video(
124
+ local_path=Path(video_path),
125
+ video_id=job_id,
126
+ folder="quiz_reel"
127
+ )
128
+ if cloud_url:
129
+ logger.info(f"Uploaded to cloud: {cloud_url}")
130
+
131
+ # Cleanup temp files
132
+ if os.path.exists(temp_dir):
133
+ shutil.rmtree(temp_dir)
134
+
135
+ video_url = cloud_url or f"/api/quiz/video/{job_id}"
136
+ update_job(job_id, "ready", 100, video_url=video_url)
137
+ logger.info(f"Quiz video ready: {job_id}")
138
+
139
+ except Exception as e:
140
+ logger.error(f"Error generating quiz video: {e}")
141
+ import traceback
142
+ logger.error(traceback.format_exc())
143
+ update_job(job_id, "failed", error=str(e))
144
+
145
+ # Cleanup on error
146
+ if os.path.exists(temp_dir):
147
+ shutil.rmtree(temp_dir)
148
+
149
+
150
+ @router.post("/generate", response_model=JobResponse)
151
+ async def create_quiz_video(request: QuizRequest, background_tasks: BackgroundTasks):
152
+ """
153
+ Generate a quiz video from multiple quiz scenes.
154
+
155
+ Each quiz has:
156
+ - hook: Category label (optional)
157
+ - question: The question text
158
+ - options: Dict with A, B, C answers
159
+ - correct: The correct option (A, B, or C)
160
+ - explain: Explanation text (optional)
161
+ """
162
+ job_id = str(uuid.uuid4())[:8]
163
+
164
+ # Validate quizzes
165
+ for i, quiz in enumerate(request.quizzes):
166
+ if quiz.correct.upper() not in quiz.options:
167
+ raise HTTPException(
168
+ status_code=400,
169
+ detail=f"Quiz {i+1}: correct answer '{quiz.correct}' not in options"
170
+ )
171
+ if len(quiz.options) != 3:
172
+ raise HTTPException(
173
+ status_code=400,
174
+ detail=f"Quiz {i+1}: exactly 3 options (A, B, C) required"
175
+ )
176
+
177
+ # Initialize job
178
+ jobs[job_id] = {
179
+ "status": "queued",
180
+ "progress": 0,
181
+ "video_url": None,
182
+ "error": None
183
+ }
184
+
185
+ # Start background task
186
+ quizzes_data = [quiz.dict() for quiz in request.quizzes]
187
+ background_tasks.add_task(generate_quiz_video, job_id, quizzes_data, request.voice)
188
+
189
+ return JobResponse(
190
+ job_id=job_id,
191
+ status="queued",
192
+ message=f"Quiz video generation started with {len(request.quizzes)} quizzes"
193
+ )
194
+
195
+
196
+ @router.get("/{job_id}/status", response_model=JobStatus)
197
+ async def get_job_status(job_id: str):
198
+ """Get the status of a quiz video generation job"""
199
+ if job_id not in jobs:
200
+ raise HTTPException(status_code=404, detail="Job not found")
201
+
202
+ job = jobs[job_id]
203
+ return JobStatus(
204
+ job_id=job_id,
205
+ status=job["status"],
206
+ progress=job["progress"],
207
+ video_url=job.get("video_url"),
208
+ error=job.get("error")
209
+ )
210
+
211
+
212
+ @router.get("/video/{job_id}")
213
+ async def get_video(job_id: str):
214
+ """Download the generated quiz video"""
215
+ video_path = f"videos/quiz_reel/quiz_{job_id}.mp4"
216
+
217
+ if not os.path.exists(video_path):
218
+ raise HTTPException(status_code=404, detail="Video not found")
219
+
220
+ return FileResponse(
221
+ video_path,
222
+ media_type="video/mp4",
223
+ filename=f"quiz_{job_id}.mp4",
224
+ headers={"Content-Disposition": f"attachment; filename=quiz_{job_id}.mp4"}
225
+ )
modules/quiz_reel/schemas.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Quiz Reel Schemas
3
+ Request/Response models for quiz video generation.
4
+ """
5
+ from pydantic import BaseModel, Field
6
+ from typing import Dict, List, Optional
7
+
8
+
9
+ class QuizScene(BaseModel):
10
+ """Single quiz scene data"""
11
+ hook: str = Field("", description="Category label like 'IQ TEST', 'MATH QUIZ'")
12
+ question: str = Field(..., description="The quiz question text")
13
+ options: Dict[str, str] = Field(..., description="Options dict like {'A': 'Answer1', 'B': 'Answer2', 'C': 'Answer3'}")
14
+ correct: str = Field(..., description="Correct option key: 'A', 'B', or 'C'")
15
+ explain: str = Field("", description="Explanation text shown after answer reveal")
16
+
17
+
18
+ class QuizRequest(BaseModel):
19
+ """Request model for quiz video generation"""
20
+ quizzes: List[QuizScene] = Field(..., min_length=1, description="Array of quiz scenes")
21
+ voice: str = Field("af_heart", description="TTS voice ID")
22
+
23
+
24
+ class JobResponse(BaseModel):
25
+ """Job creation response"""
26
+ job_id: str
27
+ status: str
28
+ message: str
29
+
30
+
31
+ class JobStatus(BaseModel):
32
+ """Job status response"""
33
+ job_id: str
34
+ status: str
35
+ progress: int
36
+ video_url: Optional[str] = None
37
+ error: Optional[str] = None
modules/quiz_reel/services/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Quiz Reel Services"""
2
+ from .quiz_frame import QuizFrameGenerator
3
+ from .quiz_composer import QuizComposer
4
+
5
+ __all__ = ["QuizFrameGenerator", "QuizComposer"]
modules/quiz_reel/services/quiz_composer.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Quiz Composer
3
+ Assembles quiz frames with TTS audio into final video.
4
+ """
5
+ import logging
6
+ import os
7
+ import shutil
8
+ from pathlib import Path
9
+ from typing import List, Dict, Optional
10
+ from moviepy.editor import ImageSequenceClip, AudioFileClip, concatenate_videoclips, CompositeAudioClip
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class QuizComposer:
16
+ """
17
+ Composes quiz video from frames and TTS audio.
18
+
19
+ Timing flow per quiz:
20
+ - Question TTS plays + show question frame
21
+ - 3s thinking (silence, same frame)
22
+ - Answer TTS plays + show answer frame
23
+ """
24
+
25
+ FPS = 30
26
+ THINKING_DURATION = 3.0 # Fixed 3 seconds
27
+
28
+ def __init__(self, output_dir: str = "videos/quiz_reel"):
29
+ self.output_dir = output_dir
30
+ os.makedirs(output_dir, exist_ok=True)
31
+
32
+ def compose_single_quiz(
33
+ self,
34
+ frame_generator,
35
+ quiz_data: Dict,
36
+ temp_dir: str,
37
+ question_audio_path: str,
38
+ answer_audio_path: str,
39
+ quiz_index: int = 0
40
+ ) -> Dict:
41
+ """
42
+ Compose a single quiz scene with audio.
43
+
44
+ Returns dict with clip and timing info.
45
+ """
46
+ # Get audio durations
47
+ question_duration = 2.0 # Default
48
+ answer_duration = 2.0 # Default
49
+
50
+ if os.path.exists(question_audio_path):
51
+ with AudioFileClip(question_audio_path) as audio:
52
+ question_duration = audio.duration
53
+ logger.info(f"Question audio duration: {question_duration:.2f}s")
54
+
55
+ if os.path.exists(answer_audio_path):
56
+ with AudioFileClip(answer_audio_path) as audio:
57
+ answer_duration = audio.duration
58
+ logger.info(f"Answer audio duration: {answer_duration:.2f}s")
59
+
60
+ # Generate frames with correct durations
61
+ quiz_frames_dir = os.path.join(temp_dir, f"quiz_{quiz_index}")
62
+ frame_result = frame_generator.generate_quiz_frames(
63
+ quiz_data=quiz_data,
64
+ output_dir=quiz_frames_dir,
65
+ fps=self.FPS,
66
+ question_duration=question_duration,
67
+ thinking_duration=self.THINKING_DURATION,
68
+ answer_duration=answer_duration
69
+ )
70
+
71
+ frame_paths = frame_result["frame_paths"]
72
+
73
+ # Create video clip from frames
74
+ video_clip = ImageSequenceClip(frame_paths, fps=self.FPS)
75
+
76
+ # Calculate audio timing
77
+ # Question audio starts at 0
78
+ # Answer audio starts at question_duration + thinking_duration
79
+ answer_audio_start = question_duration + self.THINKING_DURATION
80
+
81
+ # Load and position audio clips
82
+ audio_clips = []
83
+
84
+ if os.path.exists(question_audio_path):
85
+ q_audio = AudioFileClip(question_audio_path)
86
+ audio_clips.append(q_audio.set_start(0))
87
+
88
+ if os.path.exists(answer_audio_path):
89
+ a_audio = AudioFileClip(answer_audio_path)
90
+ audio_clips.append(a_audio.set_start(answer_audio_start))
91
+
92
+ # Combine audio clips
93
+ if audio_clips:
94
+ combined_audio = CompositeAudioClip(audio_clips)
95
+ video_clip = video_clip.set_audio(combined_audio)
96
+
97
+ total_duration = question_duration + self.THINKING_DURATION + answer_duration
98
+
99
+ return {
100
+ "clip": video_clip,
101
+ "duration": total_duration,
102
+ "frame_paths": frame_paths
103
+ }
104
+
105
+ def compose_quiz_video(
106
+ self,
107
+ quizzes: List[Dict],
108
+ quiz_audios: List[Dict], # [{"question": path, "answer": path}, ...]
109
+ output_name: str,
110
+ frame_generator
111
+ ) -> str:
112
+ """
113
+ Compose full quiz video from multiple quizzes.
114
+
115
+ Args:
116
+ quizzes: List of quiz data dicts
117
+ quiz_audios: List of audio path dicts for each quiz
118
+ output_name: Output video filename
119
+ frame_generator: QuizFrameGenerator instance
120
+
121
+ Returns:
122
+ Path to output video file
123
+ """
124
+ temp_dir = os.path.join(self.output_dir, "temp_frames")
125
+ os.makedirs(temp_dir, exist_ok=True)
126
+
127
+ try:
128
+ clips = []
129
+ all_frame_paths = []
130
+
131
+ for i, (quiz, audio_paths) in enumerate(zip(quizzes, quiz_audios)):
132
+ logger.info(f"Composing quiz {i+1}/{len(quizzes)}: {quiz['question'][:30]}...")
133
+
134
+ result = self.compose_single_quiz(
135
+ frame_generator=frame_generator,
136
+ quiz_data=quiz,
137
+ temp_dir=temp_dir,
138
+ question_audio_path=audio_paths.get("question", ""),
139
+ answer_audio_path=audio_paths.get("answer", ""),
140
+ quiz_index=i
141
+ )
142
+
143
+ clips.append(result["clip"])
144
+ all_frame_paths.extend(result["frame_paths"])
145
+
146
+ # Concatenate all quiz clips
147
+ if len(clips) == 1:
148
+ final_clip = clips[0]
149
+ else:
150
+ final_clip = concatenate_videoclips(clips, method="compose")
151
+
152
+ # Output path
153
+ output_path = os.path.join(self.output_dir, output_name)
154
+
155
+ # Write video
156
+ logger.info(f"Writing video to {output_path}")
157
+ final_clip.write_videofile(
158
+ output_path,
159
+ fps=self.FPS,
160
+ codec="libx264",
161
+ audio_codec="aac",
162
+ preset="medium",
163
+ threads=4,
164
+ logger=None
165
+ )
166
+
167
+ # Cleanup
168
+ final_clip.close()
169
+ for clip in clips:
170
+ clip.close()
171
+
172
+ logger.info(f"Quiz video complete: {output_path}")
173
+ return output_path
174
+
175
+ finally:
176
+ # Cleanup temp frames
177
+ if os.path.exists(temp_dir):
178
+ shutil.rmtree(temp_dir)
179
+
180
+ def cleanup_frames(self, frame_paths: List[str]):
181
+ """Remove frame files"""
182
+ for path in frame_paths:
183
+ try:
184
+ if os.path.exists(path):
185
+ os.remove(path)
186
+ except Exception as e:
187
+ logger.warning(f"Failed to remove frame {path}: {e}")
modules/quiz_reel/services/quiz_frame.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Quiz Frame Generator
3
+ Creates quiz frames using Pillow based on the design specification.
4
+ """
5
+ import logging
6
+ import os
7
+ from PIL import Image, ImageDraw, ImageFont
8
+ from typing import Dict, List, Tuple, Optional
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class QuizFrameGenerator:
14
+ """
15
+ Generates quiz frames with:
16
+ - Dark green gradient background with grid
17
+ - Hook text (yellow, top)
18
+ - Question card (white rounded rectangle)
19
+ - Option pills (3 options: A, B, C)
20
+ - Answer reveal (correct = green + checkmark)
21
+ - Explain text (bottom)
22
+ """
23
+
24
+ # Canvas dimensions (9:16)
25
+ WIDTH = 1080
26
+ HEIGHT = 1920
27
+
28
+ # Colors
29
+ BG_COLOR_TOP = (15, 60, 50) # Dark green top
30
+ BG_COLOR_BOTTOM = (10, 40, 35) # Darker green bottom
31
+ GRID_COLOR = (25, 80, 65) # Grid lines
32
+
33
+ HOOK_COLOR = (204, 255, 0) # Yellow/lime #CCFF00
34
+ QUESTION_BG = (240, 240, 235) # Off-white
35
+ QUESTION_TEXT = (50, 50, 50) # Dark gray
36
+
37
+ OPTION_DEFAULT_BG = (220, 220, 215) # Light gray
38
+ OPTION_CORRECT_BG = (74, 222, 128) # Green #4ADE80
39
+ OPTION_TEXT = (50, 50, 50) # Dark gray
40
+ OPTION_TEXT_CORRECT = (255, 255, 255) # White on green
41
+
42
+ EXPLAIN_COLOR = (180, 180, 180) # Light gray
43
+ SPARKLE_COLOR = (255, 255, 255) # White
44
+
45
+ # Positions (Y coordinates as percentages of HEIGHT)
46
+ HOOK_Y = 0.06 # 6% from top (~115px)
47
+ QUESTION_TOP = 0.15 # 15% (~288px)
48
+ QUESTION_BOTTOM = 0.35 # 35% (~672px)
49
+ OPTIONS_START = 0.40 # 40% (~768px)
50
+ OPTIONS_GAP = 0.09 # 9% gap between options
51
+ OPTION_HEIGHT = 100 # Fixed pixel height
52
+ EXPLAIN_Y = 0.72 # 72% (~1382px)
53
+
54
+ def __init__(self):
55
+ self._load_fonts()
56
+
57
+ def _load_fonts(self):
58
+ """Load fonts with fallbacks"""
59
+ font_paths = [
60
+ "C:/Windows/Fonts/Inter-Bold.ttf",
61
+ "C:/Windows/Fonts/arial.ttf",
62
+ "C:/Windows/Fonts/ArialBD.ttf",
63
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
64
+ ]
65
+
66
+ # Try to load custom fonts, fallback to default
67
+ self.font_hook = None
68
+ self.font_question = None
69
+ self.font_option = None
70
+ self.font_explain = None
71
+
72
+ for path in font_paths:
73
+ if os.path.exists(path):
74
+ try:
75
+ self.font_hook = ImageFont.truetype(path, 72)
76
+ self.font_question = ImageFont.truetype(path, 48)
77
+ self.font_option = ImageFont.truetype(path, 36)
78
+ self.font_explain = ImageFont.truetype(path, 28)
79
+ logger.info(f"Loaded font: {path}")
80
+ break
81
+ except Exception as e:
82
+ logger.warning(f"Failed to load font {path}: {e}")
83
+
84
+ # Fallback to default
85
+ if not self.font_hook:
86
+ self.font_hook = ImageFont.load_default()
87
+ self.font_question = ImageFont.load_default()
88
+ self.font_option = ImageFont.load_default()
89
+ self.font_explain = ImageFont.load_default()
90
+ logger.warning("Using default font")
91
+
92
+ def _create_gradient_background(self) -> Image.Image:
93
+ """Create dark green gradient background with grid"""
94
+ img = Image.new('RGB', (self.WIDTH, self.HEIGHT))
95
+ draw = ImageDraw.Draw(img)
96
+
97
+ # Gradient from top to bottom
98
+ for y in range(self.HEIGHT):
99
+ ratio = y / self.HEIGHT
100
+ r = int(self.BG_COLOR_TOP[0] + (self.BG_COLOR_BOTTOM[0] - self.BG_COLOR_TOP[0]) * ratio)
101
+ g = int(self.BG_COLOR_TOP[1] + (self.BG_COLOR_BOTTOM[1] - self.BG_COLOR_TOP[1]) * ratio)
102
+ b = int(self.BG_COLOR_TOP[2] + (self.BG_COLOR_BOTTOM[2] - self.BG_COLOR_TOP[2]) * ratio)
103
+ draw.line([(0, y), (self.WIDTH, y)], fill=(r, g, b))
104
+
105
+ # Draw grid lines
106
+ grid_spacing = 40
107
+ for x in range(0, self.WIDTH, grid_spacing):
108
+ draw.line([(x, 0), (x, self.HEIGHT)], fill=self.GRID_COLOR, width=1)
109
+ for y in range(0, self.HEIGHT, grid_spacing):
110
+ draw.line([(0, y), (self.WIDTH, y)], fill=self.GRID_COLOR, width=1)
111
+
112
+ return img
113
+
114
+ def _draw_rounded_rect(self, draw: ImageDraw, bbox: Tuple, fill: Tuple, radius: int = 30):
115
+ """Draw a rounded rectangle"""
116
+ x1, y1, x2, y2 = bbox
117
+ draw.rounded_rectangle(bbox, radius=radius, fill=fill)
118
+
119
+ def _draw_text_centered(self, draw: ImageDraw, text: str, y: int, font, color: Tuple, max_width: int = None):
120
+ """Draw centered text, optionally wrapped"""
121
+ if max_width:
122
+ # Word wrap
123
+ words = text.split()
124
+ lines = []
125
+ current_line = ""
126
+ for word in words:
127
+ test_line = current_line + " " + word if current_line else word
128
+ bbox = draw.textbbox((0, 0), test_line, font=font)
129
+ if bbox[2] - bbox[0] <= max_width:
130
+ current_line = test_line
131
+ else:
132
+ if current_line:
133
+ lines.append(current_line)
134
+ current_line = word
135
+ if current_line:
136
+ lines.append(current_line)
137
+
138
+ # Draw each line
139
+ line_height = draw.textbbox((0, 0), "Ay", font=font)[3] + 10
140
+ for i, line in enumerate(lines):
141
+ bbox = draw.textbbox((0, 0), line, font=font)
142
+ text_width = bbox[2] - bbox[0]
143
+ x = (self.WIDTH - text_width) // 2
144
+ draw.text((x, y + i * line_height), line, fill=color, font=font)
145
+ else:
146
+ bbox = draw.textbbox((0, 0), text, font=font)
147
+ text_width = bbox[2] - bbox[0]
148
+ x = (self.WIDTH - text_width) // 2
149
+ draw.text((x, y), text, fill=color, font=font)
150
+
151
+ def _draw_checkmark(self, draw: ImageDraw, center: Tuple[int, int], size: int = 40):
152
+ """Draw a checkmark icon in a circle"""
153
+ cx, cy = center
154
+ # Circle background
155
+ draw.ellipse([cx - size//2, cy - size//2, cx + size//2, cy + size//2],
156
+ fill=(255, 255, 255))
157
+ # Checkmark
158
+ check_color = self.OPTION_CORRECT_BG
159
+ draw.line([(cx - 12, cy), (cx - 4, cy + 10)], fill=check_color, width=4)
160
+ draw.line([(cx - 4, cy + 10), (cx + 14, cy - 10)], fill=check_color, width=4)
161
+
162
+ def _draw_sparkle(self, draw: ImageDraw, pos: Tuple[int, int]):
163
+ """Draw sparkle icon at position"""
164
+ x, y = pos
165
+ # Simple 4-pointed star
166
+ points = [
167
+ (x, y - 15), (x + 5, y - 5), (x + 15, y), (x + 5, y + 5),
168
+ (x, y + 15), (x - 5, y + 5), (x - 15, y), (x - 5, y - 5)
169
+ ]
170
+ draw.polygon(points, fill=self.SPARKLE_COLOR)
171
+
172
+ def create_question_frame(
173
+ self,
174
+ hook: str,
175
+ question: str,
176
+ options: Dict[str, str]
177
+ ) -> Image.Image:
178
+ """
179
+ Create frame showing question + options (before answer reveal)
180
+ """
181
+ img = self._create_gradient_background()
182
+ draw = ImageDraw.Draw(img)
183
+
184
+ # Hook text (top, yellow)
185
+ if hook:
186
+ self._draw_text_centered(draw, hook.upper(), int(self.HEIGHT * self.HOOK_Y),
187
+ self.font_hook, self.HOOK_COLOR)
188
+
189
+ # Question card
190
+ card_x1 = int(self.WIDTH * 0.05)
191
+ card_x2 = int(self.WIDTH * 0.95)
192
+ card_y1 = int(self.HEIGHT * self.QUESTION_TOP)
193
+ card_y2 = int(self.HEIGHT * self.QUESTION_BOTTOM)
194
+ self._draw_rounded_rect(draw, (card_x1, card_y1, card_x2, card_y2),
195
+ self.QUESTION_BG, radius=40)
196
+
197
+ # Question text inside card
198
+ text_y = card_y1 + 40
199
+ max_text_width = card_x2 - card_x1 - 60
200
+ self._draw_text_centered(draw, question, text_y, self.font_question,
201
+ self.QUESTION_TEXT, max_width=max_text_width)
202
+
203
+ # Options
204
+ option_width = int(self.WIDTH * 0.85)
205
+ option_x = (self.WIDTH - option_width) // 2
206
+
207
+ for i, (key, value) in enumerate(options.items()):
208
+ y_pos = int(self.HEIGHT * (self.OPTIONS_START + i * self.OPTIONS_GAP))
209
+
210
+ # Option pill (gray - not revealed yet)
211
+ self._draw_rounded_rect(draw,
212
+ (option_x, y_pos, option_x + option_width, y_pos + self.OPTION_HEIGHT),
213
+ self.OPTION_DEFAULT_BG, radius=50)
214
+
215
+ # Option text
216
+ option_text = f"{key}. {value}"
217
+ bbox = draw.textbbox((0, 0), option_text, font=self.font_option)
218
+ text_height = bbox[3] - bbox[1]
219
+ text_y = y_pos + (self.OPTION_HEIGHT - text_height) // 2
220
+ draw.text((option_x + 30, text_y), option_text, fill=self.OPTION_TEXT, font=self.font_option)
221
+
222
+ # Sparkle icon (bottom right)
223
+ self._draw_sparkle(draw, (self.WIDTH - 60, self.HEIGHT - 60))
224
+
225
+ return img
226
+
227
+ def create_answer_frame(
228
+ self,
229
+ hook: str,
230
+ question: str,
231
+ options: Dict[str, str],
232
+ correct: str,
233
+ explain: str
234
+ ) -> Image.Image:
235
+ """
236
+ Create frame showing answer reveal (correct option green + explain)
237
+ """
238
+ img = self._create_gradient_background()
239
+ draw = ImageDraw.Draw(img)
240
+
241
+ # Hook text (top, yellow)
242
+ if hook:
243
+ self._draw_text_centered(draw, hook.upper(), int(self.HEIGHT * self.HOOK_Y),
244
+ self.font_hook, self.HOOK_COLOR)
245
+
246
+ # Question card
247
+ card_x1 = int(self.WIDTH * 0.05)
248
+ card_x2 = int(self.WIDTH * 0.95)
249
+ card_y1 = int(self.HEIGHT * self.QUESTION_TOP)
250
+ card_y2 = int(self.HEIGHT * self.QUESTION_BOTTOM)
251
+ self._draw_rounded_rect(draw, (card_x1, card_y1, card_x2, card_y2),
252
+ self.QUESTION_BG, radius=40)
253
+
254
+ # Question text inside card
255
+ text_y = card_y1 + 40
256
+ max_text_width = card_x2 - card_x1 - 60
257
+ self._draw_text_centered(draw, question, text_y, self.font_question,
258
+ self.QUESTION_TEXT, max_width=max_text_width)
259
+
260
+ # Options with answer reveal
261
+ option_width = int(self.WIDTH * 0.85)
262
+ option_x = (self.WIDTH - option_width) // 2
263
+
264
+ for i, (key, value) in enumerate(options.items()):
265
+ y_pos = int(self.HEIGHT * (self.OPTIONS_START + i * self.OPTIONS_GAP))
266
+ is_correct = key.upper() == correct.upper()
267
+
268
+ # Option pill (green for correct, gray for wrong)
269
+ bg_color = self.OPTION_CORRECT_BG if is_correct else self.OPTION_DEFAULT_BG
270
+ text_color = self.OPTION_TEXT_CORRECT if is_correct else self.OPTION_TEXT
271
+
272
+ self._draw_rounded_rect(draw,
273
+ (option_x, y_pos, option_x + option_width, y_pos + self.OPTION_HEIGHT),
274
+ bg_color, radius=50)
275
+
276
+ # Option text
277
+ option_text = f"{key}. {value}"
278
+ bbox = draw.textbbox((0, 0), option_text, font=self.font_option)
279
+ text_height = bbox[3] - bbox[1]
280
+ text_y = y_pos + (self.OPTION_HEIGHT - text_height) // 2
281
+ draw.text((option_x + 30, text_y), option_text, fill=text_color, font=self.font_option)
282
+
283
+ # Checkmark for correct answer
284
+ if is_correct:
285
+ checkmark_x = option_x + option_width - 60
286
+ checkmark_y = y_pos + self.OPTION_HEIGHT // 2
287
+ self._draw_checkmark(draw, (checkmark_x, checkmark_y))
288
+
289
+ # Explain text (bottom)
290
+ if explain:
291
+ explain_y = int(self.HEIGHT * self.EXPLAIN_Y)
292
+ self._draw_text_centered(draw, explain, explain_y, self.font_explain,
293
+ self.EXPLAIN_COLOR, max_width=int(self.WIDTH * 0.85))
294
+
295
+ # Sparkle icon (bottom right)
296
+ self._draw_sparkle(draw, (self.WIDTH - 60, self.HEIGHT - 60))
297
+
298
+ return img
299
+
300
+ def generate_quiz_frames(
301
+ self,
302
+ quiz_data: Dict,
303
+ output_dir: str,
304
+ fps: int = 30,
305
+ question_duration: float = 0, # Will be set by TTS
306
+ thinking_duration: float = 3.0,
307
+ answer_duration: float = 0 # Will be set by TTS
308
+ ) -> Dict:
309
+ """
310
+ Generate all frames for a single quiz scene.
311
+
312
+ Returns dict with frame paths and timing info.
313
+ """
314
+ os.makedirs(output_dir, exist_ok=True)
315
+
316
+ hook = quiz_data.get("hook", "")
317
+ question = quiz_data["question"]
318
+ options = quiz_data["options"]
319
+ correct = quiz_data["correct"]
320
+ explain = quiz_data.get("explain", "")
321
+
322
+ frame_paths = []
323
+ frame_num = 0
324
+
325
+ # Phase 1: Question frame (duration set by TTS)
326
+ question_frame = self.create_question_frame(hook, question, options)
327
+ question_frames_count = max(1, int(question_duration * fps)) if question_duration > 0 else fps * 2
328
+
329
+ for _ in range(question_frames_count):
330
+ path = os.path.join(output_dir, f"frame_{frame_num:05d}.png")
331
+ question_frame.save(path)
332
+ frame_paths.append(path)
333
+ frame_num += 1
334
+
335
+ # Phase 2: Thinking time (same frame, fixed 3 seconds)
336
+ thinking_frames_count = int(thinking_duration * fps)
337
+ for _ in range(thinking_frames_count):
338
+ path = os.path.join(output_dir, f"frame_{frame_num:05d}.png")
339
+ question_frame.save(path)
340
+ frame_paths.append(path)
341
+ frame_num += 1
342
+
343
+ # Phase 3: Answer frame (duration set by TTS)
344
+ answer_frame = self.create_answer_frame(hook, question, options, correct, explain)
345
+ answer_frames_count = max(1, int(answer_duration * fps)) if answer_duration > 0 else fps * 2
346
+
347
+ for _ in range(answer_frames_count):
348
+ path = os.path.join(output_dir, f"frame_{frame_num:05d}.png")
349
+ answer_frame.save(path)
350
+ frame_paths.append(path)
351
+ frame_num += 1
352
+
353
+ logger.info(f"Generated {len(frame_paths)} frames for quiz: {question[:30]}...")
354
+
355
+ return {
356
+ "frame_paths": frame_paths,
357
+ "question_frames": question_frames_count,
358
+ "thinking_frames": thinking_frames_count,
359
+ "answer_frames": answer_frames_count
360
+ }
static/index.html CHANGED
@@ -276,6 +276,9 @@
276
  <button class="tab-btn" data-tab="trends">
277
  πŸ“Š Trends
278
  </button>
 
 
 
279
  </div>
280
 
281
  <!-- Story Reels Tab -->
@@ -569,6 +572,77 @@
569
  </div>
570
  </div>
571
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  <script>
573
  // Tab switching
574
  document.querySelectorAll('.tab-btn').forEach(btn => {
@@ -1008,6 +1082,149 @@
1008
  }
1009
  });
1010
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1011
  // ==========================================
1012
  // GEMINI CHATBOT TEST
1013
  // ==========================================
 
276
  <button class="tab-btn" data-tab="trends">
277
  πŸ“Š Trends
278
  </button>
279
+ <button class="tab-btn" data-tab="quiz">
280
+ 🎯 Quiz Reel
281
+ </button>
282
  </div>
283
 
284
  <!-- Story Reels Tab -->
 
572
  </div>
573
  </div>
574
 
575
+ <!-- Quiz Reel Tab -->
576
+ <div id="quiz-tab" class="tab-content">
577
+ <div class="card">
578
+ <h2>🎯 Quiz Reel Generator</h2>
579
+ <p style="color: var(--text-secondary); margin-bottom: 1.5rem;">
580
+ Create quiz videos with TTS, thinking time, and animated answer reveals
581
+ </p>
582
+
583
+ <form id="quizForm">
584
+ <div id="quizContainer">
585
+ <!-- Quiz 1 -->
586
+ <div class="quiz-item"
587
+ style="background: var(--bg-secondary); padding: 1.5rem; border-radius: 12px; margin-bottom: 1rem;">
588
+ <h4 style="color: var(--accent); margin-bottom: 1rem;">Quiz 1</h4>
589
+ <div class="form-group">
590
+ <label>Hook (Category)</label>
591
+ <input type="text" class="quiz-hook" placeholder="e.g., IQ TEST, MATH QUIZ" value="IQ TEST">
592
+ </div>
593
+ <div class="form-group">
594
+ <label>Question *</label>
595
+ <input type="text" class="quiz-question" placeholder="What is the tallest mountain?"
596
+ required>
597
+ </div>
598
+ <div class="form-row">
599
+ <div class="form-group">
600
+ <label>Option A *</label>
601
+ <input type="text" class="quiz-option-a" placeholder="Answer A" required>
602
+ </div>
603
+ <div class="form-group">
604
+ <label>Option B *</label>
605
+ <input type="text" class="quiz-option-b" placeholder="Answer B" required>
606
+ </div>
607
+ <div class="form-group">
608
+ <label>Option C *</label>
609
+ <input type="text" class="quiz-option-c" placeholder="Answer C" required>
610
+ </div>
611
+ </div>
612
+ <div class="form-row">
613
+ <div class="form-group">
614
+ <label>Correct Answer *</label>
615
+ <select class="quiz-correct" required>
616
+ <option value="A">A</option>
617
+ <option value="B">B</option>
618
+ <option value="C">C</option>
619
+ </select>
620
+ </div>
621
+ </div>
622
+ <div class="form-group">
623
+ <label>Explain (shown after answer)</label>
624
+ <input type="text" class="quiz-explain" placeholder="Brief explanation of the answer">
625
+ </div>
626
+ </div>
627
+ </div>
628
+
629
+ <div style="display: flex; gap: 1rem; margin-bottom: 1.5rem;">
630
+ <button type="button" id="addQuizBtn" class="btn btn-secondary" style="flex: 1;">
631
+ βž• Add Another Quiz
632
+ </button>
633
+ <button type="button" id="removeQuizBtn" class="btn btn-secondary"
634
+ style="flex: 1; background: #dc2626;">
635
+ βž– Remove Last Quiz
636
+ </button>
637
+ </div>
638
+
639
+ <button type="submit" class="btn btn-primary" style="width: 100%;">🎯 Generate Quiz Video</button>
640
+ </form>
641
+
642
+ <div id="quizStatus" class="status hidden"></div>
643
+ </div>
644
+ </div>
645
+
646
  <script>
647
  // Tab switching
648
  document.querySelectorAll('.tab-btn').forEach(btn => {
 
1082
  }
1083
  });
1084
 
1085
+ // ==========================================
1086
+ // QUIZ REEL MODULE
1087
+ // ==========================================
1088
+
1089
+ let quizCount = 1;
1090
+
1091
+ // Add Quiz button
1092
+ document.getElementById('addQuizBtn').addEventListener('click', () => {
1093
+ quizCount++;
1094
+ const container = document.getElementById('quizContainer');
1095
+ const quizHtml = `
1096
+ <div class="quiz-item" style="background: var(--bg-secondary); padding: 1.5rem; border-radius: 12px; margin-bottom: 1rem;">
1097
+ <h4 style="color: var(--accent); margin-bottom: 1rem;">Quiz ${quizCount}</h4>
1098
+ <div class="form-group">
1099
+ <label>Hook (Category)</label>
1100
+ <input type="text" class="quiz-hook" placeholder="e.g., IQ TEST, MATH QUIZ">
1101
+ </div>
1102
+ <div class="form-group">
1103
+ <label>Question *</label>
1104
+ <input type="text" class="quiz-question" placeholder="Enter your question" required>
1105
+ </div>
1106
+ <div class="form-row">
1107
+ <div class="form-group">
1108
+ <label>Option A *</label>
1109
+ <input type="text" class="quiz-option-a" placeholder="Answer A" required>
1110
+ </div>
1111
+ <div class="form-group">
1112
+ <label>Option B *</label>
1113
+ <input type="text" class="quiz-option-b" placeholder="Answer B" required>
1114
+ </div>
1115
+ <div class="form-group">
1116
+ <label>Option C *</label>
1117
+ <input type="text" class="quiz-option-c" placeholder="Answer C" required>
1118
+ </div>
1119
+ </div>
1120
+ <div class="form-row">
1121
+ <div class="form-group">
1122
+ <label>Correct Answer *</label>
1123
+ <select class="quiz-correct" required>
1124
+ <option value="A">A</option>
1125
+ <option value="B">B</option>
1126
+ <option value="C">C</option>
1127
+ </select>
1128
+ </div>
1129
+ </div>
1130
+ <div class="form-group">
1131
+ <label>Explain (shown after answer)</label>
1132
+ <input type="text" class="quiz-explain" placeholder="Brief explanation">
1133
+ </div>
1134
+ </div>
1135
+ `;
1136
+ container.insertAdjacentHTML('beforeend', quizHtml);
1137
+ });
1138
+
1139
+ // Remove Quiz button
1140
+ document.getElementById('removeQuizBtn').addEventListener('click', () => {
1141
+ const container = document.getElementById('quizContainer');
1142
+ const items = container.querySelectorAll('.quiz-item');
1143
+ if (items.length > 1) {
1144
+ items[items.length - 1].remove();
1145
+ quizCount--;
1146
+ }
1147
+ });
1148
+
1149
+ // Quiz status polling
1150
+ async function pollQuizStatus(jobId, statusDiv) {
1151
+ const checkStatus = async () => {
1152
+ try {
1153
+ const res = await fetch(`/api/quiz/${jobId}/status`);
1154
+ const status = await res.json();
1155
+
1156
+ if (status.status === 'ready') {
1157
+ statusDiv.className = 'status success';
1158
+ statusDiv.innerHTML = `βœ… Video Ready! <a href="${status.video_url}" target="_blank" style="color: var(--accent);">Download Video</a>`;
1159
+ return;
1160
+ } else if (status.status === 'failed') {
1161
+ statusDiv.className = 'status error';
1162
+ statusDiv.innerHTML = `❌ Failed: ${status.error}`;
1163
+ return;
1164
+ } else {
1165
+ statusDiv.innerHTML = `⏳ ${status.status}... ${status.progress}%`;
1166
+ setTimeout(checkStatus, 2000);
1167
+ }
1168
+ } catch (err) {
1169
+ statusDiv.className = 'status error';
1170
+ statusDiv.innerHTML = `❌ Error: ${err.message}`;
1171
+ }
1172
+ };
1173
+ checkStatus();
1174
+ }
1175
+
1176
+ // Quiz Form submission
1177
+ document.getElementById('quizForm').addEventListener('submit', async (e) => {
1178
+ e.preventDefault();
1179
+ const status = document.getElementById('quizStatus');
1180
+ status.className = 'status';
1181
+ status.classList.remove('hidden');
1182
+ status.innerHTML = '⏳ Starting quiz video generation...';
1183
+
1184
+ // Collect all quizzes
1185
+ const quizItems = document.querySelectorAll('.quiz-item');
1186
+ const quizzes = [];
1187
+
1188
+ quizItems.forEach(item => {
1189
+ quizzes.push({
1190
+ hook: item.querySelector('.quiz-hook').value || '',
1191
+ question: item.querySelector('.quiz-question').value,
1192
+ options: {
1193
+ A: item.querySelector('.quiz-option-a').value,
1194
+ B: item.querySelector('.quiz-option-b').value,
1195
+ C: item.querySelector('.quiz-option-c').value
1196
+ },
1197
+ correct: item.querySelector('.quiz-correct').value,
1198
+ explain: item.querySelector('.quiz-explain').value || ''
1199
+ });
1200
+ });
1201
+
1202
+ const data = {
1203
+ quizzes: quizzes,
1204
+ voice: 'af_heart'
1205
+ };
1206
+
1207
+ try {
1208
+ const res = await fetch('/api/quiz/generate', {
1209
+ method: 'POST',
1210
+ headers: { 'Content-Type': 'application/json' },
1211
+ body: JSON.stringify(data)
1212
+ });
1213
+ const result = await res.json();
1214
+
1215
+ if (result.job_id) {
1216
+ status.innerHTML = `⏳ Job started: ${result.job_id} (${quizzes.length} quizzes)`;
1217
+ pollQuizStatus(result.job_id, status);
1218
+ } else {
1219
+ status.className = 'status error';
1220
+ status.innerHTML = `❌ Error: ${result.detail || 'Failed to start'}`;
1221
+ }
1222
+ } catch (err) {
1223
+ status.className = 'status error';
1224
+ status.innerHTML = '❌ Error: ' + err.message;
1225
+ }
1226
+ });
1227
+
1228
  // ==========================================
1229
  // GEMINI CHATBOT TEST
1230
  // ==========================================