Spaces:
Sleeping
Sleeping
| 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) | |
| } | |
| 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)) | |
| 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) | |
| 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 | |
| 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) | |
| 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 | |
| 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) |