import uuid import subprocess import tempfile from pathlib import Path from typing import Optional from .config import settings from .storage import storage from .models import VideoMode, QuizItem, GenerateRequest class VideoGenerator: """FFmpeg-based video generator for anime quiz videos.""" FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" def __init__(self, request: GenerateRequest): self.request = request self.mode = request.mode self.questions = request.questions self.audio_duration = request.audio_duration self.continue_audio = request.continue_audio if self.mode == VideoMode.SHORTS: self.width = settings.shorts_width self.height = settings.shorts_height else: self.width = settings.full_width self.height = settings.full_height self.fps = 30 self.temp_dir = Path(tempfile.mkdtemp(prefix="quiz_")) self.temp_files: list[Path] = [] def _run_ffmpeg(self, args: list[str], check: bool = True) -> subprocess.CompletedProcess: """Run FFmpeg command with given arguments.""" cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"] + args return subprocess.run(cmd, capture_output=True, text=True, check=check) def _get_temp_path(self, suffix: str = ".mp4") -> Path: """Generate a temporary file path.""" path = self.temp_dir / f"temp_{uuid.uuid4().hex[:8]}{suffix}" self.temp_files.append(path) return path def _escape_text(self, text: str) -> str: """Escape special characters for FFmpeg drawtext filter.""" text = text.replace("\\", "\\\\") text = text.replace("'", "'\\''") text = text.replace(":", "\\:") text = text.replace("%", "\\%") return text def _get_background_path(self) -> Path: """Get background video path from S3 or generate solid color fallback.""" if self.request.background_video: bg_path = storage.get_background_file(self.request.background_video) if bg_path and bg_path.exists(): return bg_path # Get first available background backgrounds = storage.list_background_videos() if backgrounds: bg_path = storage.get_background_file(backgrounds[0]) if bg_path and bg_path.exists(): return bg_path # Create a solid color fallback background return self._create_solid_background() def _create_solid_background(self) -> Path: """Create a solid color background video as fallback.""" output_path = self._get_temp_path(suffix="_bg.mp4") # Create 10 second loop of dark gradient background args = [ "-f", "lavfi", "-i", f"color=c=0x1a1a2e:s={self.width}x{self.height}:d=10:r={self.fps}", "-c:v", "libx264", "-preset", "ultrafast", "-crf", "23", str(output_path) ] result = self._run_ffmpeg(args, check=False) if result.returncode != 0: raise RuntimeError(f"FFmpeg error creating solid background: {result.stderr}") return output_path def _get_difficulty_color(self, difficulty: str) -> str: """Get color for difficulty badge.""" colors = { "easy": "green", "medium": "orange", "hard": "red" } return colors.get(difficulty.lower(), "white") def _create_question_scene(self, question: QuizItem, question_num: int) -> Path: """Create the question scene with audio and countdown.""" output_path = self._get_temp_path() scene_duration = self.audio_duration + settings.audio_buffer bg_path = self._get_background_path() audio_path = storage.get_audio_file(question.opening_file) if not audio_path: raise RuntimeError(f"Audio file not found: {question.opening_file}") # Font sizes based on mode title_fontsize = 72 if self.mode == VideoMode.SHORTS else 56 diff_fontsize = 56 if self.mode == VideoMode.SHORTS else 42 countdown_fontsize = 120 if self.mode == VideoMode.SHORTS else 80 # Escape texts question_text = self._escape_text(f"#{question_num}") subtitle_text = self._escape_text("Guess the Anime Opening") difficulty_text = self._escape_text(question.difficulty.upper()) diff_color = self._get_difficulty_color(question.difficulty) # Calculate positions title_y = int(self.height * 0.12) subtitle_y = int(self.height * 0.20) diff_y = int(self.height * 0.35) countdown_y = int(self.height * 0.70) # Build video filter video_filter = f""" [0:v]scale={self.width}:{self.height}:force_original_aspect_ratio=increase, crop={self.width}:{self.height}, setsar=1, fps={self.fps}[bg]; [bg]drawtext=fontfile={self.FONT_PATH}:text='{question_text}':fontsize={title_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={title_y}, drawtext=fontfile={self.FONT_PATH}:text='{subtitle_text}':fontsize={title_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={subtitle_y}, drawtext=fontfile={self.FONT_PATH}:text='{difficulty_text}':fontsize={diff_fontsize}:fontcolor={diff_color}:borderw=2:bordercolor=black:x=(w-tw)/2:y={diff_y}, drawtext=fontfile={self.FONT_PATH}:text='%{{eif\\:{int(self.audio_duration)}-floor(t)\\:d}}':fontsize={countdown_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={countdown_y}:enable='lt(t,{int(self.audio_duration)})'[v] """.replace("\n", "").strip() # Build audio filter with fade in and optional fade out audio_fade_out_start = scene_duration - settings.audio_fade_duration if self.continue_audio: audio_filter = f"[1:a]afade=t=in:d={settings.audio_fade_duration}[a]" else: audio_filter = f"[1:a]afade=t=in:d={settings.audio_fade_duration},afade=t=out:st={audio_fade_out_start}:d={settings.audio_fade_duration}[a]" # Build FFmpeg command args = [ "-stream_loop", "-1", "-i", str(bg_path), "-ss", str(question.start_time), "-t", str(scene_duration), "-i", str(audio_path), "-filter_complex", f"{video_filter};{audio_filter}", "-map", "[v]", "-map", "[a]", "-t", str(scene_duration), "-c:v", "libx264", "-preset", "medium", "-crf", "23", "-c:a", "aac", "-b:a", "192k", str(output_path) ] result = self._run_ffmpeg(args, check=False) if result.returncode != 0: raise RuntimeError(f"FFmpeg error in question scene: {result.stderr}") return output_path def _get_transition_sound_path(self) -> Optional[Path]: """Get transition sound path from S3.""" if self.request.transition_sound: return storage.get_transition_file(self.request.transition_sound) return None def _create_answer_scene(self, question: QuizItem) -> Path: """Create the answer reveal scene with continuing audio.""" output_path = self._get_temp_path() duration = settings.answer_duration bg_path = self._get_background_path() audio_path = storage.get_audio_file(question.opening_file) transition_path = self._get_transition_sound_path() if not audio_path: raise RuntimeError(f"Audio file not found: {question.opening_file}") # Calculate audio start position based on continue_audio setting if self.continue_audio: question_scene_duration = self.audio_duration + settings.audio_buffer audio_start = question.start_time + question_scene_duration else: audio_start = question.start_time audio_fade_out_start = duration - settings.audio_fade_duration # Font sizes based on mode answer_fontsize = 64 if self.mode == VideoMode.SHORTS else 48 label_fontsize = 48 if self.mode == VideoMode.SHORTS else 36 # Escape texts label_text = self._escape_text("Anime:") anime_text = self._escape_text(question.anime) # Calculate positions label_y = int(self.height * 0.25) anime_y = int(self.height * 0.32) # Check for poster from S3 poster_path = None if question.poster: poster_path = storage.get_poster_file(question.poster) # Build audio filter - no fade in if continuing from question scene if self.continue_audio: base_audio_filter = f"[1:a]afade=t=out:st={audio_fade_out_start}:d={settings.audio_fade_duration}" else: base_audio_filter = f"[1:a]afade=t=in:d={settings.audio_fade_duration},afade=t=out:st={audio_fade_out_start}:d={settings.audio_fade_duration}" # Build inputs and audio filter based on whether we have transition sound if poster_path: if transition_path: audio_filter = f"{base_audio_filter}[music];[2:a]anull[sfx];[music][sfx]amix=inputs=2:duration=longest[a]" inputs = [ "-loop", "1", "-i", str(poster_path), "-ss", str(audio_start), "-t", str(duration), "-i", str(audio_path), "-i", str(transition_path), ] else: audio_filter = f"{base_audio_filter}[a]" inputs = [ "-loop", "1", "-i", str(poster_path), "-ss", str(audio_start), "-t", str(duration), "-i", str(audio_path), ] video_filter = f""" [0:v]scale={self.width}:{self.height}:force_original_aspect_ratio=increase, crop={self.width}:{self.height}, setsar=1, fps={self.fps}, drawtext=fontfile={self.FONT_PATH}:text='{label_text}':fontsize={label_fontsize}:fontcolor=cyan:borderw=2:bordercolor=black:x=(w-tw)/2:y={label_y}, drawtext=fontfile={self.FONT_PATH}:text='{anime_text}':fontsize={answer_fontsize}:fontcolor=cyan:borderw=3:bordercolor=black:x=(w-tw)/2:y={anime_y}, fade=t=in:d=0.3[v]; {audio_filter} """.replace("\n", "").strip() else: if transition_path: audio_filter = f"{base_audio_filter}[music];[2:a]anull[sfx];[music][sfx]amix=inputs=2:duration=longest[a]" inputs = [ "-stream_loop", "-1", "-i", str(bg_path), "-ss", str(audio_start), "-t", str(duration), "-i", str(audio_path), "-i", str(transition_path), ] else: audio_filter = f"{base_audio_filter}[a]" inputs = [ "-stream_loop", "-1", "-i", str(bg_path), "-ss", str(audio_start), "-t", str(duration), "-i", str(audio_path), ] video_filter = f""" [0:v]scale={self.width}:{self.height}:force_original_aspect_ratio=increase, crop={self.width}:{self.height}, setsar=1, fps={self.fps}, drawtext=fontfile={self.FONT_PATH}:text='{label_text}':fontsize={label_fontsize}:fontcolor=cyan:borderw=2:bordercolor=black:x=(w-tw)/2:y={label_y}, drawtext=fontfile={self.FONT_PATH}:text='{anime_text}':fontsize={answer_fontsize}:fontcolor=cyan:borderw=3:bordercolor=black:x=(w-tw)/2:y={anime_y}, fade=t=in:d=0.3[v]; {audio_filter} """.replace("\n", "").strip() args = inputs + [ "-filter_complex", video_filter, "-map", "[v]", "-map", "[a]", "-t", str(duration), "-c:v", "libx264", "-preset", "medium", "-crf", "23", "-c:a", "aac", "-b:a", "192k", str(output_path) ] result = self._run_ffmpeg(args, check=False) if result.returncode != 0: raise RuntimeError(f"FFmpeg error in answer scene: {result.stderr}") return output_path def _create_combined_scene(self, question: QuizItem, question_num: int) -> Path: """Create combined question + answer scene with continuous audio.""" output_path = self._get_temp_path() question_duration = self.audio_duration + settings.audio_buffer answer_duration = settings.answer_duration total_duration = question_duration + answer_duration bg_path = self._get_background_path() audio_path = storage.get_audio_file(question.opening_file) if not audio_path: raise RuntimeError(f"Audio file not found: {question.opening_file}") # Font sizes based on mode title_fontsize = 72 if self.mode == VideoMode.SHORTS else 56 diff_fontsize = 56 if self.mode == VideoMode.SHORTS else 42 countdown_fontsize = 120 if self.mode == VideoMode.SHORTS else 80 answer_fontsize = 64 if self.mode == VideoMode.SHORTS else 48 label_fontsize = 48 if self.mode == VideoMode.SHORTS else 36 # Escape texts question_text = self._escape_text(f"#{question_num}") subtitle_text = self._escape_text("Guess the Anime Opening") difficulty_text = self._escape_text(question.difficulty.upper()) diff_color = self._get_difficulty_color(question.difficulty) label_text = self._escape_text("Anime:") anime_text = self._escape_text(question.anime) # Calculate positions title_y = int(self.height * 0.12) subtitle_y = int(self.height * 0.20) diff_y = int(self.height * 0.35) countdown_y = int(self.height * 0.70) label_y = int(self.height * 0.25) anime_y = int(self.height * 0.32) # Check for poster poster_path = None if question.poster: poster_path = storage.get_poster_file(question.poster) # Audio filter - fade in at start, fade out at end audio_fade_out_start = total_duration - settings.audio_fade_duration audio_filter = f"[a_in]afade=t=in:d={settings.audio_fade_duration},afade=t=out:st={audio_fade_out_start}:d={settings.audio_fade_duration}[a]" if poster_path and poster_path.exists(): # Build filter with poster for answer phase # Question phase: show background with countdown (0 to question_duration) # Answer phase: show poster with anime title (question_duration to total_duration) video_filter = f""" [0:v]scale={self.width}:{self.height}:force_original_aspect_ratio=increase,crop={self.width}:{self.height},setsar=1,fps={self.fps}, drawtext=fontfile={self.FONT_PATH}:text='{question_text}':fontsize={title_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={title_y}:enable='lt(t,{question_duration})', drawtext=fontfile={self.FONT_PATH}:text='{subtitle_text}':fontsize={title_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={subtitle_y}:enable='lt(t,{question_duration})', drawtext=fontfile={self.FONT_PATH}:text='{difficulty_text}':fontsize={diff_fontsize}:fontcolor={diff_color}:borderw=2:bordercolor=black:x=(w-tw)/2:y={diff_y}:enable='lt(t,{question_duration})', drawtext=fontfile={self.FONT_PATH}:text='%{{eif\\:{int(self.audio_duration)}-floor(t)\\:d}}':fontsize={countdown_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={countdown_y}:enable='lt(t,{int(self.audio_duration)})'[bg_out]; [2:v]scale={self.width}:{self.height}:force_original_aspect_ratio=increase,crop={self.width}:{self.height},setsar=1,fps={self.fps}, drawtext=fontfile={self.FONT_PATH}:text='{label_text}':fontsize={label_fontsize}:fontcolor=cyan:borderw=2:bordercolor=black:x=(w-tw)/2:y={label_y}, drawtext=fontfile={self.FONT_PATH}:text='{anime_text}':fontsize={answer_fontsize}:fontcolor=cyan:borderw=3:bordercolor=black:x=(w-tw)/2:y={anime_y}[poster_out]; [bg_out][poster_out]overlay=enable='gte(t,{question_duration})':shortest=1[v]; [1:a]anull[a_in]; {audio_filter} """.replace("\n", "").strip() args = [ "-stream_loop", "-1", "-i", str(bg_path), "-ss", str(question.start_time), "-t", str(total_duration), "-i", str(audio_path), "-loop", "1", "-i", str(poster_path), "-filter_complex", video_filter, "-map", "[v]", "-map", "[a]", "-t", str(total_duration), "-c:v", "libx264", "-preset", "medium", "-crf", "23", "-c:a", "aac", "-b:a", "192k", str(output_path) ] else: # No poster - just use background for both phases video_filter = f""" [0:v]scale={self.width}:{self.height}:force_original_aspect_ratio=increase,crop={self.width}:{self.height},setsar=1,fps={self.fps}, drawtext=fontfile={self.FONT_PATH}:text='{question_text}':fontsize={title_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={title_y}:enable='lt(t,{question_duration})', drawtext=fontfile={self.FONT_PATH}:text='{subtitle_text}':fontsize={title_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={subtitle_y}:enable='lt(t,{question_duration})', drawtext=fontfile={self.FONT_PATH}:text='{difficulty_text}':fontsize={diff_fontsize}:fontcolor={diff_color}:borderw=2:bordercolor=black:x=(w-tw)/2:y={diff_y}:enable='lt(t,{question_duration})', drawtext=fontfile={self.FONT_PATH}:text='%{{eif\\:{int(self.audio_duration)}-floor(t)\\:d}}':fontsize={countdown_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={countdown_y}:enable='lt(t,{int(self.audio_duration)})', drawtext=fontfile={self.FONT_PATH}:text='{label_text}':fontsize={label_fontsize}:fontcolor=cyan:borderw=2:bordercolor=black:x=(w-tw)/2:y={label_y}:enable='gte(t,{question_duration})', drawtext=fontfile={self.FONT_PATH}:text='{anime_text}':fontsize={answer_fontsize}:fontcolor=cyan:borderw=3:bordercolor=black:x=(w-tw)/2:y={anime_y}:enable='gte(t,{question_duration})'[v]; [1:a]anull[a_in]; {audio_filter} """.replace("\n", "").strip() args = [ "-stream_loop", "-1", "-i", str(bg_path), "-ss", str(question.start_time), "-t", str(total_duration), "-i", str(audio_path), "-filter_complex", video_filter, "-map", "[v]", "-map", "[a]", "-t", str(total_duration), "-c:v", "libx264", "-preset", "medium", "-crf", "23", "-c:a", "aac", "-b:a", "192k", str(output_path) ] result = self._run_ffmpeg(args, check=False) if result.returncode != 0: raise RuntimeError(f"FFmpeg error in combined scene: {result.stderr}") return output_path def _create_final_screen(self) -> Path: """Create final CTA screen for full video mode.""" output_path = self._get_temp_path() duration = settings.final_screen_duration bg_path = self._get_background_path() # Escape texts title_text = self._escape_text("How many did you guess?") cta_text = self._escape_text("Subscribe for more anime quizzes!") # Calculate positions title_y = int(self.height * 0.35) cta_y = int(self.height * 0.55) video_filter = f""" [0:v]scale={self.width}:{self.height}:force_original_aspect_ratio=increase, crop={self.width}:{self.height}, setsar=1, fps={self.fps}, drawtext=fontfile={self.FONT_PATH}:text='{title_text}':fontsize=56:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={title_y}, drawtext=fontfile={self.FONT_PATH}:text='{cta_text}':fontsize=40:fontcolor=white:borderw=2:bordercolor=black:x=(w-tw)/2:y={cta_y}, fade=t=in:d=0.3, fade=t=out:st={duration - 0.5}:d=0.5[v] """.replace("\n", "").strip() args = [ "-stream_loop", "-1", "-i", str(bg_path), "-filter_complex", video_filter, "-map", "[v]", "-t", str(duration), "-c:v", "libx264", "-preset", "medium", "-crf", "23", "-an", str(output_path) ] result = self._run_ffmpeg(args, check=False) if result.returncode != 0: raise RuntimeError(f"FFmpeg error in final screen: {result.stderr}") return output_path def _concatenate_scenes(self, scene_files: list[Path]) -> Path: """Concatenate all scenes into final video.""" output_filename = f"quiz_{self.mode.value}_{uuid.uuid4().hex[:8]}.mp4" output_path = settings.output_path / output_filename # Create concat list file concat_file = self._get_temp_path(suffix=".txt") with open(concat_file, "w") as f: for scene in scene_files: f.write(f"file '{scene}'\n") # Re-encode for consistent output args = [ "-f", "concat", "-safe", "0", "-i", str(concat_file), "-c:v", "libx264", "-preset", "medium", "-crf", "23", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", str(output_path) ] result = self._run_ffmpeg(args, check=False) if result.returncode != 0: raise RuntimeError(f"FFmpeg error in concatenation: {result.stderr}") return output_path def _cleanup(self): """Remove temporary files and directory.""" for path in self.temp_files: try: if path.exists(): path.unlink() except Exception: pass try: if self.temp_dir.exists(): self.temp_dir.rmdir() except Exception: pass def generate(self) -> Path: """Generate the complete quiz video.""" try: scene_files = [] for i, question in enumerate(self.questions, 1): if self.continue_audio: # Create combined scene with continuous audio combined_scene = self._create_combined_scene(question, i) scene_files.append(combined_scene) else: # Question scene q_scene = self._create_question_scene(question, i) scene_files.append(q_scene) # Answer scene a_scene = self._create_answer_scene(question) scene_files.append(a_scene) # Final screen for full video mode if self.mode == VideoMode.FULL: final = self._create_final_screen() scene_files.append(final) # Concatenate all scenes output_path = self._concatenate_scenes(scene_files) return output_path finally: self._cleanup() def check_ffmpeg() -> bool: """Check if FFmpeg is available.""" try: result = subprocess.run( ["ffmpeg", "-version"], capture_output=True, text=True, timeout=5, ) return result.returncode == 0 except Exception: return False