app v1
This commit is contained in:
549
backend/app/video_generator.py
Normal file
549
backend/app/video_generator.py
Normal file
@@ -0,0 +1,549 @@
|
||||
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
|
||||
Reference in New Issue
Block a user