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