BackendSpace / app /services /video_service.py
2nzi's picture
update
6b81e07 verified
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)