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) |