2nzi commited on
Commit
4f8d450
·
verified ·
1 Parent(s): 8841a46

update service

Browse files
Files changed (1) hide show
  1. app/services/video_service.py +373 -392
app/services/video_service.py CHANGED
@@ -1,393 +1,374 @@
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)
 
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
+ from io import BytesIO
12
+ from fastapi.responses import StreamingResponse
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ class VideoService:
17
+ # Initialiser le gestionnaire de polices
18
+ font_manager = FontManager()
19
+
20
+ # Constantes de style
21
+ COLORS = {
22
+ 'background': (25, 25, 25),
23
+ 'text': (255, 255, 255),
24
+ 'highlight': (64, 156, 255),
25
+ 'correct': (46, 204, 113),
26
+ 'option_bg': (50, 50, 50)
27
+ }
28
+
29
+ @staticmethod
30
+ async def generate_quiz_video(quiz_data: dict):
31
+ try:
32
+ # Configuration
33
+ WIDTH, HEIGHT = 720, 1280
34
+ FPS = 24
35
+ DURATION_QUESTION = 5
36
+ DURATION_ANSWER = 3
37
+
38
+ # Récupérer les styles depuis quiz_data
39
+ style_config = quiz_data.get('styleConfig', {})
40
+ title_style = style_config.get('title', {})
41
+ questions_style = style_config.get('questions', {})
42
+ answers_style = style_config.get('answers', {})
43
+ background_style = style_config.get('background', {})
44
+
45
+ # Créer un buffer en mémoire au lieu d'un fichier
46
+ video_buffer = BytesIO()
47
+
48
+ # Créer le writer avec cv2
49
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
50
+ out = cv2.VideoWriter('temp.mp4', fourcc, FPS, (WIDTH, HEIGHT))
51
+
52
+ # Charger l'image de fond si elle existe
53
+ background_image = None
54
+ if background_style.get('image'):
55
+ # Décoder l'image base64 en gardant les couleurs d'origine
56
+ image_data = base64.b64decode(background_style['image'].split(',')[1])
57
+ img = Image.open(io.BytesIO(image_data))
58
+
59
+ # Redimensionner en conservant le ratio
60
+ ratio = img.width / img.height
61
+ new_height = HEIGHT
62
+ new_width = int(HEIGHT * ratio)
63
+ img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
64
+
65
+ # Centrer et recadrer si nécessaire
66
+ if new_width > WIDTH:
67
+ left = (new_width - WIDTH) // 2
68
+ img = img.crop((left, 0, left + WIDTH, HEIGHT))
69
+ elif new_width < WIDTH:
70
+ new_img = Image.new('RGB', (WIDTH, HEIGHT), (0, 0, 0))
71
+ paste_x = (WIDTH - new_width) // 2
72
+ new_img.paste(img, (paste_x, 0))
73
+ img = new_img
74
+
75
+ # Convertir en array numpy en préservant les couleurs
76
+ background_image = np.array(img)
77
+
78
+ # Liste pour stocker les moments où jouer le son
79
+ correct_answer_times = []
80
+ current_time = 0
81
+
82
+ # Création des frames
83
+ for i, question in enumerate(quiz_data["questions"], 1):
84
+ frame = Image.new('RGB', (WIDTH, HEIGHT))
85
+ if background_image is not None:
86
+ # Utiliser l'image de fond en RGB
87
+ frame = Image.fromarray(background_image)
88
+ if background_style.get('opacity', 1) < 1:
89
+ overlay = Image.new('RGB', (WIDTH, HEIGHT), (0, 0, 0))
90
+ frame = Image.blend(frame, overlay, 1 - background_style.get('opacity', 1))
91
+
92
+ # Créer les frames
93
+ question_frame = VideoService._create_question_frame(
94
+ frame, question, i, len(quiz_data["questions"]),
95
+ title_style, questions_style, answers_style,
96
+ WIDTH, HEIGHT, show_answer=False
97
+ )
98
+
99
+ # Convertir en BGR pour OpenCV
100
+ frame_cv = cv2.cvtColor(np.array(question_frame), cv2.COLOR_RGB2BGR)
101
+
102
+ for _ in range(int(FPS * DURATION_QUESTION)):
103
+ out.write(frame_cv)
104
+ current_time += DURATION_QUESTION
105
+
106
+ # Marquer le moment pour jouer le son
107
+ correct_answer_times.append(current_time)
108
+
109
+ # Frame de réponse
110
+ answer_frame = VideoService._create_question_frame(
111
+ frame.copy(), question, i, len(quiz_data["questions"]),
112
+ title_style, questions_style, answers_style,
113
+ WIDTH, HEIGHT, show_answer=True
114
+ )
115
+
116
+ frame_cv = cv2.cvtColor(np.array(answer_frame), cv2.COLOR_RGB2BGR)
117
+
118
+ for _ in range(int(FPS * DURATION_ANSWER)):
119
+ out.write(frame_cv)
120
+ current_time += DURATION_ANSWER
121
+
122
+ out.release()
123
+
124
+ # Lire le fichier temporaire en mémoire
125
+ with open('temp.mp4', 'rb') as f:
126
+ video_buffer.write(f.read())
127
+
128
+ # Supprimer le fichier temporaire
129
+ os.remove('temp.mp4')
130
+
131
+ # Remettre le curseur au début du buffer
132
+ video_buffer.seek(0)
133
+
134
+ # Retourner le stream
135
+ return StreamingResponse(
136
+ video_buffer,
137
+ media_type="video/mp4",
138
+ headers={
139
+ 'Content-Disposition': f'attachment; filename="quiz_{quiz_data["id"]}.mp4"'
140
+ }
141
+ )
142
+
143
+ except Exception as e:
144
+ logger.error(f"Erreur dans generate_quiz_video: {str(e)}")
145
+ raise
146
+
147
+ @staticmethod
148
+ def _scale_size(size, preview_height=170, video_height=720):
149
+ """
150
+ Convertit une taille de la preview vers la taille vidéo ou inverse
151
+ en conservant le ratio
152
+ """
153
+ scale_factor = video_height / preview_height
154
+ return int(size * scale_factor)
155
+
156
+ @staticmethod
157
+ def _create_question_frame(frame, question, current_num, total_questions,
158
+ title_style, questions_style, answers_style,
159
+ width, height, show_answer=False):
160
+ draw = ImageDraw.Draw(frame)
161
+
162
+ try:
163
+ # Définir les tailles de base pour la preview (170px)
164
+ BASE_PREVIEW_HEIGHT = 170
165
+
166
+ # Convertir les tailles de police de la preview vers la taille vidéo
167
+ title_base_size = int(title_style.get('fontSize', 11)) # taille en px pour preview
168
+ question_base_size = int(questions_style.get('fontSize', 8))
169
+ answer_base_size = int(answers_style.get('fontSize', 6))
170
+
171
+ # Mettre à l'échelle pour la vidéo
172
+ title_font_size = VideoService._scale_size(title_base_size)
173
+ question_font_size = VideoService._scale_size(question_base_size)
174
+ answer_font_size = VideoService._scale_size(answer_base_size)
175
+
176
+ title_font = ImageFont.truetype(
177
+ VideoService.font_manager.get_font_path(title_style.get('fontFamily')),
178
+ title_font_size
179
+ )
180
+ question_font = ImageFont.truetype(
181
+ VideoService.font_manager.get_font_path(questions_style.get('fontFamily')),
182
+ question_font_size
183
+ )
184
+ answer_font = ImageFont.truetype(
185
+ VideoService.font_manager.get_font_path(answers_style.get('fontFamily')),
186
+ answer_font_size
187
+ )
188
+
189
+ # Position du titre à 10% du haut
190
+ title_y = int(0.10 * height)
191
+ title_text = f"Question {current_num}/{total_questions}"
192
+ VideoService._draw_text(draw, title_text, title_font, title_y, width, title_style)
193
+
194
+ # Position de la question à 10% en dessous du titre
195
+ question_y = int(0.23 * height) # 10% + 10%
196
+ question_text = VideoService._wrap_text(question['question'], question_font, width - 100)
197
+ VideoService._draw_text(draw, question_text, question_font, question_y, width, questions_style)
198
+
199
+ # Position des réponses à 10% en dessous de la question
200
+ start_y = int(0.38 * height)
201
+ spacing_between_options = int(0.12 * height) # Espacement entre les centres des blocs
202
+
203
+ # Pré-calculer les hauteurs des blocs
204
+ option_heights = []
205
+ for option in question['options']:
206
+ letter = chr(65 + len(option_heights))
207
+ full_text = f"{letter}. {option}"
208
+ wrapped_text = VideoService._wrap_text(full_text, answer_font, width - (width * 0.1 + 50))
209
+ lines = wrapped_text.split('\n')
210
+
211
+ bbox = answer_font.getbbox('Ag')
212
+ line_height = bbox[3] - bbox[1]
213
+ text_height = line_height * len(lines)
214
+ actual_height = max(80, text_height + 40) # même calcul que dans _draw_option
215
+ option_heights.append(actual_height)
216
+
217
+ # Dessiner chaque option en tenant compte des hauteurs
218
+ current_y = start_y
219
+ for i, option in enumerate(question['options']):
220
+ is_correct = show_answer and option == question['correct_answer']
221
+ VideoService._draw_option(draw, option, current_y,
222
+ answer_font, width, answers_style, is_correct, i)
223
+
224
+ # Calculer la position du prochain bloc en tenant compte des hauteurs
225
+ if i < len(question['options']) - 1:
226
+ current_block_half = option_heights[i] / 2
227
+ next_block_half = option_heights[i + 1] / 2
228
+ current_y += spacing_between_options # Espacement fixe entre les centres
229
+
230
+ return frame
231
+
232
+ except Exception as e:
233
+ logger.error(f"Erreur dans _create_question_frame: {str(e)}")
234
+ raise
235
+
236
+ @staticmethod
237
+ def _draw_option(draw, option_text, y_position, font, width, style, is_correct=False, option_index=0):
238
+ # Hauteur minimale du bloc d'option
239
+ option_height = 80
240
+ margin_left = width * 0.1
241
+
242
+ # Calculer la hauteur réelle du texte
243
+ letter = chr(65 + option_index)
244
+ full_text = f"{letter}. {option_text}"
245
+ wrapped_text = VideoService._wrap_text(full_text, font, width - (margin_left + 50))
246
+ lines = wrapped_text.split('\n')
247
+
248
+ # Calculer la hauteur totale du texte
249
+ bbox = font.getbbox('Ag') # Utiliser une ligne de référence pour la hauteur
250
+ line_height = bbox[3] - bbox[1]
251
+ text_height = line_height * len(lines)
252
+
253
+ # Utiliser la plus grande valeur entre option_height et text_height
254
+ actual_height = max(option_height, text_height + 40) # +40 pour le padding
255
+
256
+ # Dessiner le fond
257
+ if style.get('backgroundColor'):
258
+ bg_color = tuple(int(style['backgroundColor'][i:i+2], 16) for i in (1, 3, 5))
259
+ draw.rectangle(
260
+ [
261
+ (50, y_position - actual_height//2),
262
+ (width - 50, y_position + actual_height//2)
263
+ ],
264
+ fill=bg_color
265
+ )
266
+
267
+ # Si c'est la bonne réponse, on force la couleur en vert
268
+ if is_correct:
269
+ style = style.copy()
270
+ style['color'] = '#2ECC71'
271
+
272
+ # Dessiner le texte aligné à gauche avec la marge
273
+ VideoService._draw_text(draw, wrapped_text, font, y_position, width, style, align_left=True, margin_left=margin_left)
274
+
275
+ @staticmethod
276
+ def _draw_text(draw, text, font, y_position, width, style, align_left=False, margin_left=0):
277
+ try:
278
+ lines = text.split('\n')
279
+
280
+ # Calculer la hauteur totale avec plus d'espacement entre les lignes
281
+ line_heights = []
282
+ line_widths = []
283
+ total_height = 0
284
+ max_width = 0
285
+ line_spacing = 1 # Facteur d'espacement entre les lignes (1.5 fois la hauteur normale)
286
+
287
+ for line in lines:
288
+ bbox = font.getbbox(line)
289
+ line_height = bbox[3] - bbox[1]
290
+ line_width = bbox[2] - bbox[0]
291
+
292
+ line_heights.append(line_height)
293
+ line_widths.append(line_width)
294
+ total_height += line_height * line_spacing # Multiplier par le facteur d'espacement
295
+ max_width = max(max_width, line_width)
296
+
297
+ # Augmenter le padding autour du texte
298
+ padding = 20 # Augmenté de 20 à 30
299
+
300
+ current_y = y_position - (total_height // 2)
301
+
302
+ if style.get('backgroundColor'):
303
+ corner_radius = 15
304
+ bg_color = tuple(int(style['backgroundColor'][i:i+2], 16) for i in (1, 3, 5))
305
+
306
+ if align_left:
307
+ x1, y1 = 50, current_y - padding
308
+ x2, y2 = width - 50, current_y + total_height + padding
309
+ else:
310
+ center_x = width // 2
311
+ x1 = center_x - (max_width // 2) - padding
312
+ x2 = center_x + (max_width // 2) + padding
313
+ y1 = current_y - padding
314
+ y2 = current_y + total_height + padding
315
+
316
+ # Dessiner un rectangle avec coins arrondis
317
+ draw.pieslice([x1, y1, x1 + corner_radius * 2, y1 + corner_radius * 2], 180, 270, fill=bg_color)
318
+ draw.pieslice([x2 - corner_radius * 2, y1, x2, y1 + corner_radius * 2], 270, 0, fill=bg_color)
319
+ draw.pieslice([x1, y2 - corner_radius * 2, x1 + corner_radius * 2, y2], 90, 180, fill=bg_color)
320
+ draw.pieslice([x2 - corner_radius * 2, y2 - corner_radius * 2, x2, y2], 0, 90, fill=bg_color)
321
+
322
+ draw.rectangle([x1 + corner_radius, y1, x2 - corner_radius, y2], fill=bg_color)
323
+ draw.rectangle([x1, y1 + corner_radius, x2, y2 - corner_radius], fill=bg_color)
324
+
325
+ # Dessiner chaque ligne de texte avec plus d'espacement
326
+ for i, line in enumerate(lines):
327
+ if align_left:
328
+ x_position = margin_left
329
+ else:
330
+ x_position = (width - line_widths[i]) // 2
331
+
332
+ color = tuple(int(style.get('color', '#FFFFFF')[i:i+2], 16) for i in (1, 3, 5))
333
+ # Assurer une épaisseur minimale de 1 pour le contour
334
+ stroke_width = max(1, 8*int(float(style.get('textStrokeWidth', 0))))
335
+ stroke_color = tuple(int(style.get('textStrokeColor', '#000000')[i:i+2], 16) for i in (1, 3, 5))
336
+
337
+ if float(style.get('textStrokeWidth', 0)) > 0: # Vérifier la valeur originale
338
+ # Dessiner d'abord le contour
339
+ draw.text((x_position, current_y), line,
340
+ font=font, fill=stroke_color, stroke_width=stroke_width)
341
+
342
+ # Dessiner le texte principal
343
+ draw.text((x_position, current_y), line,
344
+ font=font, fill=color)
345
+
346
+ current_y += line_heights[i] * line_spacing # Multiplier par le facteur d'espacement
347
+
348
+ except Exception as e:
349
+ logger.error(f"Erreur dans _draw_text: {str(e)}")
350
+ raise
351
+
352
+ @staticmethod
353
+ def _wrap_text(text: str, font: ImageFont, max_width: int) -> str:
354
+ words = text.split()
355
+ lines = []
356
+ current_line = []
357
+
358
+ for word in words:
359
+ current_line.append(word)
360
+ line = ' '.join(current_line)
361
+ bbox = font.getbbox(line)
362
+ if bbox[2] > max_width:
363
+ if len(current_line) == 1:
364
+ lines.append(line)
365
+ current_line = []
366
+ else:
367
+ current_line.pop()
368
+ lines.append(' '.join(current_line))
369
+ current_line = [word]
370
+
371
+ if current_line:
372
+ lines.append(' '.join(current_line))
373
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  return '\n'.join(lines)