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 import tempfile from fastapi import HTTPException 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 video_buffer = BytesIO() # Utiliser un fichier temporaire en mémoire with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as temp_file: temp_path = temp_file.name # Créer le writer avec cv2 fourcc = cv2.VideoWriter_fourcc(*'mp4v') out = cv2.VideoWriter(temp_path, 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 dans le buffer temp_file.seek(0) video_buffer.write(temp_file.read()) # Supprimer le fichier temporaire try: os.unlink(temp_path) except: pass # 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 HTTPException(status_code=500, detail=str(e)) @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)