app v1
This commit is contained in:
35
backend/Dockerfile
Normal file
35
backend/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies including FFmpeg
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
fonts-dejavu \
|
||||
fontconfig \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& fc-cache -f -v
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app/ ./app/
|
||||
|
||||
# Create directories for media and output
|
||||
RUN mkdir -p /app/media/audio /app/media/backgrounds /app/media/posters /app/media/transitions /app/output/videos
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
47
backend/app/config.py
Normal file
47
backend/app/config.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Database
|
||||
database_url: str = "postgresql+asyncpg://animequiz:animequiz123@localhost:5432/animequiz"
|
||||
|
||||
# S3 Storage settings
|
||||
s3_endpoint: str = "https://s3.firstvds.ru"
|
||||
s3_access_key: str = ""
|
||||
s3_secret_key: str = ""
|
||||
s3_region: str = "default"
|
||||
s3_bucket: str = "anime-quiz"
|
||||
|
||||
# Local paths
|
||||
output_path: Path = Path("/app/output/videos")
|
||||
temp_path: Path = Path("/tmp/anime_quiz")
|
||||
cache_path: Path = Path("/tmp/anime_quiz/cache")
|
||||
|
||||
# Video settings
|
||||
shorts_width: int = 1080
|
||||
shorts_height: int = 1920
|
||||
full_width: int = 1920
|
||||
full_height: int = 1080
|
||||
|
||||
# Timing settings (seconds)
|
||||
answer_duration: float = 5.0
|
||||
final_screen_duration: float = 3.0
|
||||
audio_buffer: float = 1.0
|
||||
|
||||
# Audio
|
||||
audio_fade_duration: float = 0.7
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
extra = "ignore"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# Ensure directories exist
|
||||
settings.temp_path.mkdir(parents=True, exist_ok=True)
|
||||
settings.output_path.mkdir(parents=True, exist_ok=True)
|
||||
settings.cache_path.mkdir(parents=True, exist_ok=True)
|
||||
36
backend/app/database.py
Normal file
36
backend/app/database.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
async_session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Dependency for getting database session."""
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database tables."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
105
backend/app/db_models.py
Normal file
105
backend/app/db_models.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import String, Integer, ForeignKey, DateTime, Enum as SQLEnum, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
import enum
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class Difficulty(str, enum.Enum):
|
||||
EASY = "easy"
|
||||
MEDIUM = "medium"
|
||||
HARD = "hard"
|
||||
|
||||
|
||||
class Opening(Base):
|
||||
"""Anime opening entity."""
|
||||
__tablename__ = "openings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
anime_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
op_number: Mapped[str] = mapped_column(String(20), nullable=False) # e.g., "OP1", "ED2"
|
||||
song_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
audio_file: Mapped[str] = mapped_column(String(512), nullable=False) # S3 key
|
||||
|
||||
# Usage tracking
|
||||
last_usage: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
default=None
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
posters: Mapped[List["OpeningPoster"]] = relationship(
|
||||
"OpeningPoster",
|
||||
back_populates="opening",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Opening {self.anime_name} - {self.op_number}>"
|
||||
|
||||
|
||||
class OpeningPoster(Base):
|
||||
"""Poster image for an opening."""
|
||||
__tablename__ = "opening_posters"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
opening_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("openings.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
poster_file: Mapped[str] = mapped_column(String(512), nullable=False) # S3 key
|
||||
is_default: Mapped[bool] = mapped_column(default=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
opening: Mapped["Opening"] = relationship("Opening", back_populates="posters")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OpeningPoster {self.poster_file}>"
|
||||
|
||||
|
||||
class Background(Base):
|
||||
"""Background video entity."""
|
||||
__tablename__ = "backgrounds"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
video_file: Mapped[str] = mapped_column(String(512), nullable=False) # S3 key
|
||||
difficulty: Mapped[Difficulty] = mapped_column(
|
||||
SQLEnum(Difficulty, native_enum=False),
|
||||
nullable=False,
|
||||
default=Difficulty.MEDIUM
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now()
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Background {self.name} ({self.difficulty})>"
|
||||
378
backend/app/main.py
Normal file
378
backend/app/main.py
Normal file
@@ -0,0 +1,378 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import List
|
||||
|
||||
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .config import settings
|
||||
from .storage import storage
|
||||
from .models import (
|
||||
GenerateRequest,
|
||||
GenerateResponse,
|
||||
ContentListResponse,
|
||||
)
|
||||
from .video_generator import VideoGenerator, check_ffmpeg
|
||||
from .database import init_db, async_session_maker
|
||||
from .db_models import Opening
|
||||
from .routers import openings, backgrounds
|
||||
from sqlalchemy import select, update
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan handler."""
|
||||
# Startup
|
||||
print("Initializing database...")
|
||||
await init_db()
|
||||
print("Database initialized")
|
||||
yield
|
||||
# Shutdown
|
||||
print("Shutting down...")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Anime Quiz Video Generator",
|
||||
description="Generate 'Guess the Anime Opening' videos for YouTube and TikTok",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(openings.router, prefix="/api")
|
||||
app.include_router(backgrounds.router, prefix="/api")
|
||||
|
||||
# Mount output directory for serving videos
|
||||
app.mount("/videos", StaticFiles(directory=str(settings.output_path)), name="videos")
|
||||
|
||||
# Thread pool for video generation
|
||||
executor = ThreadPoolExecutor(max_workers=1)
|
||||
|
||||
# Store generation status
|
||||
generation_status: dict[str, dict] = {}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
ffmpeg_ok = check_ffmpeg()
|
||||
return {
|
||||
"status": "healthy" if ffmpeg_ok else "degraded",
|
||||
"ffmpeg": ffmpeg_ok,
|
||||
"s3_endpoint": settings.s3_endpoint,
|
||||
"s3_bucket": settings.s3_bucket,
|
||||
"output_path": str(settings.output_path),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/content", response_model=ContentListResponse)
|
||||
async def list_content():
|
||||
"""List available media content from S3."""
|
||||
return ContentListResponse(
|
||||
audio_files=storage.list_audio_files(),
|
||||
background_videos=storage.list_background_videos(),
|
||||
posters=storage.list_posters(),
|
||||
transition_sounds=storage.list_transition_sounds(),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/media/posters/{filename}")
|
||||
async def get_poster(filename: str):
|
||||
"""Get poster image from S3."""
|
||||
poster_path = storage.get_poster_file(filename)
|
||||
if not poster_path or not poster_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Poster not found")
|
||||
|
||||
# Determine media type
|
||||
suffix = poster_path.suffix.lower()
|
||||
media_types = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
media_type = media_types.get(suffix, "application/octet-stream")
|
||||
|
||||
return FileResponse(path=str(poster_path), media_type=media_type)
|
||||
|
||||
|
||||
@app.get("/media/audio/{filename}")
|
||||
async def get_audio(filename: str):
|
||||
"""Get audio file from S3."""
|
||||
audio_path = storage.get_audio_file(filename)
|
||||
if not audio_path or not audio_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Audio not found")
|
||||
|
||||
suffix = audio_path.suffix.lower()
|
||||
media_types = {
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".m4a": "audio/mp4",
|
||||
}
|
||||
media_type = media_types.get(suffix, "audio/mpeg")
|
||||
|
||||
return FileResponse(path=str(audio_path), media_type=media_type)
|
||||
|
||||
|
||||
@app.get("/media/transitions/{filename}")
|
||||
async def get_transition(filename: str):
|
||||
"""Get transition sound from S3."""
|
||||
transition_path = storage.get_transition_file(filename)
|
||||
if not transition_path or not transition_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Transition sound not found")
|
||||
|
||||
suffix = transition_path.suffix.lower()
|
||||
media_types = {
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
}
|
||||
media_type = media_types.get(suffix, "audio/mpeg")
|
||||
|
||||
return FileResponse(path=str(transition_path), media_type=media_type)
|
||||
|
||||
|
||||
def run_generation(request: GenerateRequest, task_id: str) -> Path:
|
||||
"""Run video generation in thread pool."""
|
||||
generation_status[task_id] = {"status": "processing", "progress": 0, "message": "Starting generation..."}
|
||||
|
||||
try:
|
||||
generator = VideoGenerator(request)
|
||||
generation_status[task_id]["message"] = "Generating video..."
|
||||
generation_status[task_id]["progress"] = 50
|
||||
|
||||
output_path = generator.generate()
|
||||
|
||||
generation_status[task_id] = {
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"message": "Video generated successfully",
|
||||
"output_path": str(output_path),
|
||||
"filename": output_path.name,
|
||||
}
|
||||
return output_path
|
||||
|
||||
except Exception as e:
|
||||
generation_status[task_id] = {
|
||||
"status": "failed",
|
||||
"progress": 0,
|
||||
"message": str(e),
|
||||
}
|
||||
raise
|
||||
|
||||
|
||||
@app.post("/generate", response_model=GenerateResponse)
|
||||
async def generate_video(request: GenerateRequest):
|
||||
"""Generate a quiz video synchronously."""
|
||||
# Validate content exists in S3
|
||||
for q in request.questions:
|
||||
if not storage.file_exists(f"audio/{q.opening_file}"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Audio file not found: {q.opening_file}"
|
||||
)
|
||||
|
||||
# Check FFmpeg
|
||||
if not check_ffmpeg():
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="FFmpeg is not available"
|
||||
)
|
||||
|
||||
try:
|
||||
# Run generation in thread pool to not block event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
task_id = f"task_{id(request)}"
|
||||
output_path = await loop.run_in_executor(
|
||||
executor,
|
||||
run_generation,
|
||||
request,
|
||||
task_id,
|
||||
)
|
||||
|
||||
# Update last_usage for all used openings
|
||||
async with async_session_maker() as db:
|
||||
for q in request.questions:
|
||||
await db.execute(
|
||||
update(Opening)
|
||||
.where(Opening.audio_file == q.opening_file)
|
||||
.values(last_usage=datetime.now(timezone.utc))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return GenerateResponse(
|
||||
success=True,
|
||||
video_url=f"/videos/{output_path.name}",
|
||||
filename=output_path.name,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return GenerateResponse(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/download/{filename}")
|
||||
async def download_video(filename: str):
|
||||
"""Download a generated video."""
|
||||
video_path = settings.output_path / filename
|
||||
if not video_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
|
||||
return FileResponse(
|
||||
path=str(video_path),
|
||||
filename=filename,
|
||||
media_type="video/mp4",
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/videos/{filename}")
|
||||
async def delete_video(filename: str):
|
||||
"""Delete a generated video."""
|
||||
video_path = settings.output_path / filename
|
||||
if not video_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
|
||||
video_path.unlink()
|
||||
return {"message": "Video deleted successfully"}
|
||||
|
||||
|
||||
@app.get("/videos-list")
|
||||
async def list_videos():
|
||||
"""List all generated videos."""
|
||||
videos = []
|
||||
for f in settings.output_path.glob("*.mp4"):
|
||||
videos.append({
|
||||
"filename": f.name,
|
||||
"size": f.stat().st_size,
|
||||
"url": f"/videos/{f.name}",
|
||||
"download_url": f"/download/{f.name}",
|
||||
})
|
||||
return {"videos": sorted(videos, key=lambda x: x["filename"], reverse=True)}
|
||||
|
||||
|
||||
@app.post("/cache/clear")
|
||||
async def clear_cache():
|
||||
"""Clear the S3 file cache."""
|
||||
storage.clear_cache()
|
||||
return {"message": "Cache cleared successfully"}
|
||||
|
||||
|
||||
# ============== Media Upload Endpoints ==============
|
||||
|
||||
@app.post("/upload/audio")
|
||||
async def upload_audio(files: List[UploadFile] = File(...)):
|
||||
"""Upload audio files to S3."""
|
||||
results = []
|
||||
for file in files:
|
||||
if not file.filename.lower().endswith((".mp3", ".wav", ".ogg", ".m4a")):
|
||||
results.append({"filename": file.filename, "success": False, "error": "Invalid file type"})
|
||||
continue
|
||||
|
||||
content = await file.read()
|
||||
success = storage.upload_audio(file.filename, content)
|
||||
results.append({"filename": file.filename, "success": success})
|
||||
|
||||
return {"results": results}
|
||||
|
||||
|
||||
@app.post("/upload/backgrounds")
|
||||
async def upload_backgrounds(files: List[UploadFile] = File(...)):
|
||||
"""Upload background videos to S3."""
|
||||
results = []
|
||||
for file in files:
|
||||
if not file.filename.lower().endswith((".mp4", ".mov", ".avi")):
|
||||
results.append({"filename": file.filename, "success": False, "error": "Invalid file type"})
|
||||
continue
|
||||
|
||||
content = await file.read()
|
||||
success = storage.upload_background(file.filename, content)
|
||||
results.append({"filename": file.filename, "success": success})
|
||||
|
||||
return {"results": results}
|
||||
|
||||
|
||||
@app.post("/upload/posters")
|
||||
async def upload_posters(files: List[UploadFile] = File(...)):
|
||||
"""Upload poster images to S3."""
|
||||
results = []
|
||||
for file in files:
|
||||
if not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")):
|
||||
results.append({"filename": file.filename, "success": False, "error": "Invalid file type"})
|
||||
continue
|
||||
|
||||
content = await file.read()
|
||||
success = storage.upload_poster(file.filename, content)
|
||||
results.append({"filename": file.filename, "success": success})
|
||||
|
||||
return {"results": results}
|
||||
|
||||
|
||||
@app.post("/upload/transitions")
|
||||
async def upload_transitions(files: List[UploadFile] = File(...)):
|
||||
"""Upload transition sounds to S3."""
|
||||
results = []
|
||||
for file in files:
|
||||
if not file.filename.lower().endswith((".mp3", ".wav", ".ogg")):
|
||||
results.append({"filename": file.filename, "success": False, "error": "Invalid file type"})
|
||||
continue
|
||||
|
||||
content = await file.read()
|
||||
success = storage.upload_transition(file.filename, content)
|
||||
results.append({"filename": file.filename, "success": success})
|
||||
|
||||
return {"results": results}
|
||||
|
||||
|
||||
# ============== Media Delete Endpoints ==============
|
||||
|
||||
@app.delete("/media/audio/{filename}")
|
||||
async def delete_audio(filename: str):
|
||||
"""Delete audio file from S3."""
|
||||
success = storage.delete_audio(filename)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="File not found or delete failed")
|
||||
return {"message": "File deleted successfully"}
|
||||
|
||||
|
||||
@app.delete("/media/backgrounds/{filename}")
|
||||
async def delete_background(filename: str):
|
||||
"""Delete background video from S3."""
|
||||
success = storage.delete_background(filename)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="File not found or delete failed")
|
||||
return {"message": "File deleted successfully"}
|
||||
|
||||
|
||||
@app.delete("/media/posters/{filename}")
|
||||
async def delete_poster(filename: str):
|
||||
"""Delete poster image from S3."""
|
||||
success = storage.delete_poster(filename)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="File not found or delete failed")
|
||||
return {"message": "File deleted successfully"}
|
||||
|
||||
|
||||
@app.delete("/media/transitions/{filename}")
|
||||
async def delete_transition(filename: str):
|
||||
"""Delete transition sound from S3."""
|
||||
success = storage.delete_transition(filename)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="File not found or delete failed")
|
||||
return {"message": "File deleted successfully"}
|
||||
51
backend/app/models.py
Normal file
51
backend/app/models.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VideoMode(str, Enum):
|
||||
SHORTS = "shorts"
|
||||
FULL = "full"
|
||||
|
||||
|
||||
class Difficulty(str, Enum):
|
||||
EASY = "easy"
|
||||
MEDIUM = "medium"
|
||||
HARD = "hard"
|
||||
|
||||
|
||||
class QuizItem(BaseModel):
|
||||
anime: str = Field(..., description="Anime title")
|
||||
opening_file: str = Field(..., description="Filename of the opening audio")
|
||||
start_time: float = Field(0, description="Start time in seconds for audio clip")
|
||||
difficulty: Difficulty = Difficulty.MEDIUM
|
||||
poster: Optional[str] = Field(None, description="Poster image filename")
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
mode: VideoMode = VideoMode.SHORTS
|
||||
questions: list[QuizItem] = Field(..., min_length=1, max_length=20)
|
||||
audio_duration: float = Field(3.0, ge=1.0, le=10.0, description="Audio clip duration in seconds")
|
||||
background_video: Optional[str] = Field(None, description="Background video filename")
|
||||
transition_sound: Optional[str] = Field(None, description="Transition sound filename")
|
||||
continue_audio: bool = Field(False, description="Continue audio from where question ended instead of restarting")
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
success: bool
|
||||
video_url: Optional[str] = None
|
||||
filename: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ProgressResponse(BaseModel):
|
||||
status: str
|
||||
progress: float
|
||||
message: str
|
||||
|
||||
|
||||
class ContentListResponse(BaseModel):
|
||||
audio_files: list[str]
|
||||
background_videos: list[str]
|
||||
posters: list[str]
|
||||
transition_sounds: list[str]
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
134
backend/app/routers/backgrounds.py
Normal file
134
backend/app/routers/backgrounds.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
from ..db_models import Background, Difficulty
|
||||
from ..schemas import (
|
||||
BackgroundCreate,
|
||||
BackgroundUpdate,
|
||||
BackgroundResponse,
|
||||
BackgroundListResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/backgrounds", tags=["backgrounds"])
|
||||
|
||||
|
||||
@router.get("", response_model=BackgroundListResponse)
|
||||
async def list_backgrounds(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
difficulty: Optional[Difficulty] = None,
|
||||
search: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all backgrounds with pagination and filtering."""
|
||||
query = select(Background)
|
||||
|
||||
if difficulty:
|
||||
query = query.where(Background.difficulty == difficulty)
|
||||
if search:
|
||||
query = query.where(Background.name.ilike(f"%{search}%"))
|
||||
|
||||
# Count total
|
||||
count_query = select(func.count(Background.id))
|
||||
if difficulty:
|
||||
count_query = count_query.where(Background.difficulty == difficulty)
|
||||
if search:
|
||||
count_query = count_query.where(Background.name.ilike(f"%{search}%"))
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar()
|
||||
|
||||
# Get items
|
||||
query = query.order_by(Background.difficulty, Background.name)
|
||||
query = query.offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
backgrounds = result.scalars().all()
|
||||
|
||||
return BackgroundListResponse(backgrounds=backgrounds, total=total)
|
||||
|
||||
|
||||
@router.get("/{background_id}", response_model=BackgroundResponse)
|
||||
async def get_background(background_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a single background by ID."""
|
||||
query = select(Background).where(Background.id == background_id)
|
||||
result = await db.execute(query)
|
||||
background = result.scalar_one_or_none()
|
||||
|
||||
if not background:
|
||||
raise HTTPException(status_code=404, detail="Background not found")
|
||||
|
||||
return background
|
||||
|
||||
|
||||
@router.post("", response_model=BackgroundResponse, status_code=201)
|
||||
async def create_background(data: BackgroundCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a new background."""
|
||||
background = Background(
|
||||
name=data.name,
|
||||
video_file=data.video_file,
|
||||
difficulty=data.difficulty,
|
||||
)
|
||||
|
||||
db.add(background)
|
||||
await db.commit()
|
||||
await db.refresh(background)
|
||||
|
||||
return background
|
||||
|
||||
|
||||
@router.put("/{background_id}", response_model=BackgroundResponse)
|
||||
async def update_background(
|
||||
background_id: int,
|
||||
data: BackgroundUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a background."""
|
||||
query = select(Background).where(Background.id == background_id)
|
||||
result = await db.execute(query)
|
||||
background = result.scalar_one_or_none()
|
||||
|
||||
if not background:
|
||||
raise HTTPException(status_code=404, detail="Background not found")
|
||||
|
||||
# Update fields
|
||||
if data.name is not None:
|
||||
background.name = data.name
|
||||
if data.video_file is not None:
|
||||
background.video_file = data.video_file
|
||||
if data.difficulty is not None:
|
||||
background.difficulty = data.difficulty
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(background)
|
||||
|
||||
return background
|
||||
|
||||
|
||||
@router.delete("/{background_id}", status_code=204)
|
||||
async def delete_background(background_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Delete a background."""
|
||||
query = select(Background).where(Background.id == background_id)
|
||||
result = await db.execute(query)
|
||||
background = result.scalar_one_or_none()
|
||||
|
||||
if not background:
|
||||
raise HTTPException(status_code=404, detail="Background not found")
|
||||
|
||||
await db.delete(background)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/by-difficulty/{difficulty}", response_model=BackgroundListResponse)
|
||||
async def get_backgrounds_by_difficulty(
|
||||
difficulty: Difficulty,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get all backgrounds for a specific difficulty."""
|
||||
query = select(Background).where(Background.difficulty == difficulty)
|
||||
query = query.order_by(Background.name)
|
||||
result = await db.execute(query)
|
||||
backgrounds = result.scalars().all()
|
||||
|
||||
return BackgroundListResponse(backgrounds=backgrounds, total=len(backgrounds))
|
||||
223
backend/app/routers/openings.py
Normal file
223
backend/app/routers/openings.py
Normal file
@@ -0,0 +1,223 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..database import get_db
|
||||
from ..db_models import Opening, OpeningPoster
|
||||
from ..schemas import (
|
||||
OpeningCreate,
|
||||
OpeningUpdate,
|
||||
OpeningResponse,
|
||||
OpeningListResponse,
|
||||
OpeningPosterResponse,
|
||||
AddPosterRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/openings", tags=["openings"])
|
||||
|
||||
|
||||
@router.get("", response_model=OpeningListResponse)
|
||||
async def list_openings(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
search: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all openings with pagination and search."""
|
||||
query = select(Opening).options(selectinload(Opening.posters))
|
||||
|
||||
if search:
|
||||
query = query.where(Opening.anime_name.ilike(f"%{search}%"))
|
||||
|
||||
# Count total
|
||||
count_query = select(func.count(Opening.id))
|
||||
if search:
|
||||
count_query = count_query.where(Opening.anime_name.ilike(f"%{search}%"))
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar()
|
||||
|
||||
# Get items
|
||||
query = query.order_by(Opening.anime_name, Opening.op_number)
|
||||
query = query.offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
openings = result.scalars().all()
|
||||
|
||||
return OpeningListResponse(openings=openings, total=total)
|
||||
|
||||
|
||||
@router.get("/{opening_id}", response_model=OpeningResponse)
|
||||
async def get_opening(opening_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a single opening by ID."""
|
||||
query = select(Opening).options(selectinload(Opening.posters)).where(Opening.id == opening_id)
|
||||
result = await db.execute(query)
|
||||
opening = result.scalar_one_or_none()
|
||||
|
||||
if not opening:
|
||||
raise HTTPException(status_code=404, detail="Opening not found")
|
||||
|
||||
return opening
|
||||
|
||||
|
||||
@router.post("", response_model=OpeningResponse, status_code=201)
|
||||
async def create_opening(data: OpeningCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a new opening."""
|
||||
opening = Opening(
|
||||
anime_name=data.anime_name,
|
||||
op_number=data.op_number,
|
||||
song_name=data.song_name,
|
||||
audio_file=data.audio_file,
|
||||
)
|
||||
|
||||
# Add posters
|
||||
for i, poster_file in enumerate(data.poster_files):
|
||||
poster = OpeningPoster(
|
||||
poster_file=poster_file,
|
||||
is_default=(i == 0) # First poster is default
|
||||
)
|
||||
opening.posters.append(poster)
|
||||
|
||||
db.add(opening)
|
||||
await db.commit()
|
||||
await db.refresh(opening)
|
||||
|
||||
# Reload with posters
|
||||
query = select(Opening).options(selectinload(Opening.posters)).where(Opening.id == opening.id)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@router.put("/{opening_id}", response_model=OpeningResponse)
|
||||
async def update_opening(
|
||||
opening_id: int,
|
||||
data: OpeningUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update an opening."""
|
||||
query = select(Opening).options(selectinload(Opening.posters)).where(Opening.id == opening_id)
|
||||
result = await db.execute(query)
|
||||
opening = result.scalar_one_or_none()
|
||||
|
||||
if not opening:
|
||||
raise HTTPException(status_code=404, detail="Opening not found")
|
||||
|
||||
# Update fields
|
||||
if data.anime_name is not None:
|
||||
opening.anime_name = data.anime_name
|
||||
if data.op_number is not None:
|
||||
opening.op_number = data.op_number
|
||||
if data.song_name is not None:
|
||||
opening.song_name = data.song_name
|
||||
if data.audio_file is not None:
|
||||
opening.audio_file = data.audio_file
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(opening)
|
||||
|
||||
return opening
|
||||
|
||||
|
||||
@router.delete("/{opening_id}", status_code=204)
|
||||
async def delete_opening(opening_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Delete an opening."""
|
||||
query = select(Opening).where(Opening.id == opening_id)
|
||||
result = await db.execute(query)
|
||||
opening = result.scalar_one_or_none()
|
||||
|
||||
if not opening:
|
||||
raise HTTPException(status_code=404, detail="Opening not found")
|
||||
|
||||
await db.delete(opening)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ============== Poster Management ==============
|
||||
|
||||
@router.post("/{opening_id}/posters", response_model=OpeningPosterResponse, status_code=201)
|
||||
async def add_poster(
|
||||
opening_id: int,
|
||||
data: AddPosterRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add a poster to an opening."""
|
||||
query = select(Opening).where(Opening.id == opening_id)
|
||||
result = await db.execute(query)
|
||||
opening = result.scalar_one_or_none()
|
||||
|
||||
if not opening:
|
||||
raise HTTPException(status_code=404, detail="Opening not found")
|
||||
|
||||
poster = OpeningPoster(
|
||||
opening_id=opening_id,
|
||||
poster_file=data.poster_file,
|
||||
is_default=data.is_default,
|
||||
)
|
||||
|
||||
# If this is set as default, unset others
|
||||
if data.is_default:
|
||||
await db.execute(
|
||||
select(OpeningPoster)
|
||||
.where(OpeningPoster.opening_id == opening_id)
|
||||
)
|
||||
posters_result = await db.execute(
|
||||
select(OpeningPoster).where(OpeningPoster.opening_id == opening_id)
|
||||
)
|
||||
for p in posters_result.scalars():
|
||||
p.is_default = False
|
||||
|
||||
db.add(poster)
|
||||
await db.commit()
|
||||
await db.refresh(poster)
|
||||
|
||||
return poster
|
||||
|
||||
|
||||
@router.delete("/{opening_id}/posters/{poster_id}", status_code=204)
|
||||
async def remove_poster(
|
||||
opening_id: int,
|
||||
poster_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Remove a poster from an opening."""
|
||||
query = select(OpeningPoster).where(
|
||||
OpeningPoster.id == poster_id,
|
||||
OpeningPoster.opening_id == opening_id,
|
||||
)
|
||||
result = await db.execute(query)
|
||||
poster = result.scalar_one_or_none()
|
||||
|
||||
if not poster:
|
||||
raise HTTPException(status_code=404, detail="Poster not found")
|
||||
|
||||
await db.delete(poster)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{opening_id}/posters/{poster_id}/set-default", response_model=OpeningPosterResponse)
|
||||
async def set_default_poster(
|
||||
opening_id: int,
|
||||
poster_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Set a poster as the default for an opening."""
|
||||
# Get all posters for this opening
|
||||
query = select(OpeningPoster).where(OpeningPoster.opening_id == opening_id)
|
||||
result = await db.execute(query)
|
||||
posters = result.scalars().all()
|
||||
|
||||
target_poster = None
|
||||
for poster in posters:
|
||||
if poster.id == poster_id:
|
||||
poster.is_default = True
|
||||
target_poster = poster
|
||||
else:
|
||||
poster.is_default = False
|
||||
|
||||
if not target_poster:
|
||||
raise HTTPException(status_code=404, detail="Poster not found")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(target_poster)
|
||||
|
||||
return target_poster
|
||||
102
backend/app/schemas.py
Normal file
102
backend/app/schemas.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .db_models import Difficulty
|
||||
|
||||
|
||||
# ============== Opening Schemas ==============
|
||||
|
||||
class OpeningPosterBase(BaseModel):
|
||||
poster_file: str
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class OpeningPosterCreate(OpeningPosterBase):
|
||||
pass
|
||||
|
||||
|
||||
class OpeningPosterResponse(OpeningPosterBase):
|
||||
id: int
|
||||
opening_id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class OpeningBase(BaseModel):
|
||||
anime_name: str = Field(..., min_length=1, max_length=255)
|
||||
op_number: str = Field(..., min_length=1, max_length=20)
|
||||
song_name: Optional[str] = Field(None, max_length=255)
|
||||
audio_file: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class OpeningCreate(OpeningBase):
|
||||
poster_files: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class OpeningUpdate(BaseModel):
|
||||
anime_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
op_number: Optional[str] = Field(None, min_length=1, max_length=20)
|
||||
song_name: Optional[str] = Field(None, max_length=255)
|
||||
audio_file: Optional[str] = None
|
||||
|
||||
|
||||
class OpeningResponse(OpeningBase):
|
||||
id: int
|
||||
last_usage: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
posters: List[OpeningPosterResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class OpeningListResponse(BaseModel):
|
||||
openings: List[OpeningResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ============== Background Schemas ==============
|
||||
|
||||
class BackgroundBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
video_file: str = Field(..., min_length=1)
|
||||
difficulty: Difficulty = Difficulty.MEDIUM
|
||||
|
||||
|
||||
class BackgroundCreate(BackgroundBase):
|
||||
pass
|
||||
|
||||
|
||||
class BackgroundUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
video_file: Optional[str] = None
|
||||
difficulty: Optional[Difficulty] = None
|
||||
|
||||
|
||||
class BackgroundResponse(BackgroundBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BackgroundListResponse(BaseModel):
|
||||
backgrounds: List[BackgroundResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ============== Poster Management ==============
|
||||
|
||||
class AddPosterRequest(BaseModel):
|
||||
poster_file: str
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class SetDefaultPosterRequest(BaseModel):
|
||||
poster_id: int
|
||||
224
backend/app/storage.py
Normal file
224
backend/app/storage.py
Normal file
@@ -0,0 +1,224 @@
|
||||
import hashlib
|
||||
import urllib3
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config as BotoConfig
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from .config import settings
|
||||
|
||||
# Suppress SSL warnings for S3 endpoint
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
|
||||
class S3Storage:
|
||||
"""S3-compatible storage service for media files."""
|
||||
|
||||
def __init__(self):
|
||||
self.client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.s3_endpoint,
|
||||
aws_access_key_id=settings.s3_access_key,
|
||||
aws_secret_access_key=settings.s3_secret_key,
|
||||
region_name=settings.s3_region,
|
||||
config=BotoConfig(signature_version="s3v4"),
|
||||
verify=False, # Disable SSL verification for FirstVDS S3
|
||||
)
|
||||
self.bucket = settings.s3_bucket
|
||||
self.cache_path = settings.cache_path
|
||||
self._ensure_bucket_exists()
|
||||
|
||||
def _ensure_bucket_exists(self):
|
||||
"""Create bucket if it doesn't exist."""
|
||||
try:
|
||||
self.client.head_bucket(Bucket=self.bucket)
|
||||
except ClientError as e:
|
||||
error_code = e.response.get("Error", {}).get("Code")
|
||||
if error_code in ("404", "NoSuchBucket"):
|
||||
try:
|
||||
self.client.create_bucket(Bucket=self.bucket)
|
||||
print(f"Created S3 bucket: {self.bucket}")
|
||||
except ClientError as create_error:
|
||||
print(f"Failed to create bucket {self.bucket}: {create_error}")
|
||||
|
||||
def _get_cache_path(self, key: str) -> Path:
|
||||
"""Get local cache path for a file."""
|
||||
# Use hash of key for cache filename to avoid path issues
|
||||
key_hash = hashlib.md5(key.encode()).hexdigest()[:16]
|
||||
ext = Path(key).suffix
|
||||
return self.cache_path / f"{key_hash}{ext}"
|
||||
|
||||
def list_files(self, prefix: str, extensions: Optional[list[str]] = None) -> list[str]:
|
||||
"""List files in S3 bucket with given prefix."""
|
||||
try:
|
||||
response = self.client.list_objects_v2(
|
||||
Bucket=self.bucket,
|
||||
Prefix=prefix,
|
||||
)
|
||||
|
||||
files = []
|
||||
for obj in response.get("Contents", []):
|
||||
key = obj["Key"]
|
||||
filename = key.replace(prefix, "").lstrip("/")
|
||||
if filename: # Skip the prefix itself
|
||||
if extensions:
|
||||
if any(filename.lower().endswith(ext) for ext in extensions):
|
||||
files.append(filename)
|
||||
else:
|
||||
files.append(filename)
|
||||
|
||||
return sorted(files)
|
||||
|
||||
except ClientError as e:
|
||||
print(f"Error listing S3 files: {e}")
|
||||
return []
|
||||
|
||||
def download_file(self, key: str) -> Optional[Path]:
|
||||
"""Download file from S3 to local cache."""
|
||||
cache_file = self._get_cache_path(key)
|
||||
|
||||
# Return cached file if exists
|
||||
if cache_file.exists():
|
||||
return cache_file
|
||||
|
||||
try:
|
||||
self.client.download_file(
|
||||
Bucket=self.bucket,
|
||||
Key=key,
|
||||
Filename=str(cache_file),
|
||||
)
|
||||
return cache_file
|
||||
|
||||
except ClientError as e:
|
||||
print(f"Error downloading {key} from S3: {e}")
|
||||
return None
|
||||
|
||||
def get_audio_file(self, filename: str) -> Optional[Path]:
|
||||
"""Download audio file from S3."""
|
||||
key = f"audio/{filename}"
|
||||
return self.download_file(key)
|
||||
|
||||
def get_background_file(self, filename: str) -> Optional[Path]:
|
||||
"""Download background video from S3."""
|
||||
key = f"backgrounds/{filename}"
|
||||
return self.download_file(key)
|
||||
|
||||
def get_poster_file(self, filename: str) -> Optional[Path]:
|
||||
"""Download poster image from S3."""
|
||||
key = f"posters/{filename}"
|
||||
return self.download_file(key)
|
||||
|
||||
def get_transition_file(self, filename: str) -> Optional[Path]:
|
||||
"""Download transition sound from S3."""
|
||||
key = f"transitions/{filename}"
|
||||
return self.download_file(key)
|
||||
|
||||
def list_audio_files(self) -> list[str]:
|
||||
"""List available audio files."""
|
||||
return self.list_files("audio/", [".mp3", ".wav", ".ogg", ".m4a"])
|
||||
|
||||
def list_background_videos(self) -> list[str]:
|
||||
"""List available background videos."""
|
||||
return self.list_files("backgrounds/", [".mp4", ".mov", ".avi"])
|
||||
|
||||
def list_posters(self) -> list[str]:
|
||||
"""List available poster images."""
|
||||
return self.list_files("posters/", [".jpg", ".jpeg", ".png", ".webp"])
|
||||
|
||||
def list_transition_sounds(self) -> list[str]:
|
||||
"""List available transition sounds."""
|
||||
return self.list_files("transitions/", [".mp3", ".wav", ".ogg"])
|
||||
|
||||
def file_exists(self, key: str) -> bool:
|
||||
"""Check if file exists in S3."""
|
||||
try:
|
||||
self.client.head_object(Bucket=self.bucket, Key=key)
|
||||
return True
|
||||
except ClientError:
|
||||
return False
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear local cache."""
|
||||
for file in self.cache_path.glob("*"):
|
||||
try:
|
||||
file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def upload_file(self, key: str, file_data: bytes, content_type: str = None) -> bool:
|
||||
"""Upload file to S3."""
|
||||
try:
|
||||
extra_args = {}
|
||||
if content_type:
|
||||
extra_args["ContentType"] = content_type
|
||||
|
||||
self.client.put_object(
|
||||
Bucket=self.bucket,
|
||||
Key=key,
|
||||
Body=file_data,
|
||||
**extra_args
|
||||
)
|
||||
return True
|
||||
except ClientError as e:
|
||||
print(f"Error uploading {key} to S3: {e}")
|
||||
return False
|
||||
|
||||
def upload_audio(self, filename: str, file_data: bytes) -> bool:
|
||||
"""Upload audio file to S3."""
|
||||
content_type = "audio/mpeg" if filename.lower().endswith(".mp3") else "audio/wav"
|
||||
return self.upload_file(f"audio/{filename}", file_data, content_type)
|
||||
|
||||
def upload_background(self, filename: str, file_data: bytes) -> bool:
|
||||
"""Upload background video to S3."""
|
||||
return self.upload_file(f"backgrounds/{filename}", file_data, "video/mp4")
|
||||
|
||||
def upload_poster(self, filename: str, file_data: bytes) -> bool:
|
||||
"""Upload poster image to S3."""
|
||||
ext = filename.lower().split(".")[-1]
|
||||
content_types = {
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
"webp": "image/webp"
|
||||
}
|
||||
content_type = content_types.get(ext, "application/octet-stream")
|
||||
return self.upload_file(f"posters/{filename}", file_data, content_type)
|
||||
|
||||
def upload_transition(self, filename: str, file_data: bytes) -> bool:
|
||||
"""Upload transition sound to S3."""
|
||||
return self.upload_file(f"transitions/{filename}", file_data, "audio/mpeg")
|
||||
|
||||
def delete_file(self, key: str) -> bool:
|
||||
"""Delete file from S3."""
|
||||
try:
|
||||
self.client.delete_object(Bucket=self.bucket, Key=key)
|
||||
# Also remove from cache
|
||||
cache_file = self._get_cache_path(key)
|
||||
if cache_file.exists():
|
||||
cache_file.unlink()
|
||||
return True
|
||||
except ClientError as e:
|
||||
print(f"Error deleting {key} from S3: {e}")
|
||||
return False
|
||||
|
||||
def delete_audio(self, filename: str) -> bool:
|
||||
"""Delete audio file from S3."""
|
||||
return self.delete_file(f"audio/{filename}")
|
||||
|
||||
def delete_background(self, filename: str) -> bool:
|
||||
"""Delete background video from S3."""
|
||||
return self.delete_file(f"backgrounds/{filename}")
|
||||
|
||||
def delete_poster(self, filename: str) -> bool:
|
||||
"""Delete poster image from S3."""
|
||||
return self.delete_file(f"posters/{filename}")
|
||||
|
||||
def delete_transition(self, filename: str) -> bool:
|
||||
"""Delete transition sound from S3."""
|
||||
return self.delete_file(f"transitions/{filename}")
|
||||
|
||||
|
||||
# Global storage instance
|
||||
storage = S3Storage()
|
||||
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
|
||||
10
backend/requirements.txt
Normal file
10
backend/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
python-multipart==0.0.19
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.0
|
||||
aiofiles==24.1.0
|
||||
boto3==1.35.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
asyncpg==0.30.0
|
||||
greenlet==3.1.1
|
||||
Reference in New Issue
Block a user