This commit is contained in:
2025-12-30 17:37:14 +03:00
commit c33c5fd674
66 changed files with 10282 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(node --version:*)",
"Bash(mkdir:*)"
]
}
}

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Environment
.env
.env.local
.env.*.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
.venv/
# Node
node_modules/
dist/
# Output
output/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

86
CLAUDE.md Normal file
View File

@@ -0,0 +1,86 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Anime Quiz Video Generator - A full-stack web application that generates "Guess the Anime Opening" videos for YouTube Shorts and TikTok. Combines a Python FastAPI backend for video processing with a Vue 3 frontend.
## Commands
### Docker (primary development method)
```bash
docker-compose up --build # Build and start all services
docker-compose up # Start existing containers
docker-compose down # Stop containers
```
### Frontend (from /frontend directory)
```bash
npm run dev # Start Vite dev server (port 5173)
npm run build # Build for production
npm run preview # Preview production build
```
### Backend (from /backend directory)
```bash
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
## Architecture
### Backend (FastAPI + MoviePy)
- `backend/app/main.py` - FastAPI application with REST endpoints
- `backend/app/video_generator.py` - Core `VideoGenerator` class handling all video composition
- `backend/app/models.py` - Pydantic models (VideoMode, Difficulty, QuizItem, GenerateRequest)
- `backend/app/config.py` - Settings via Pydantic BaseSettings with `QUIZ_` env prefix
**Video generation runs in ThreadPoolExecutor (max_workers=1) to avoid blocking the async event loop.**
### Frontend (Vue 3 + Vite)
- `frontend/src/App.vue` - Single component handling all UI, form state, and API calls
- Uses Vue 3 Composition API (`ref`, `reactive`, `computed`)
- Vite proxies `/api`, `/videos`, `/download` to backend at `http://backend:8000`
### Media Organization
```
media/
├── audio/ # MP3 anime openings
├── backgrounds/ # MP4 looping videos (5-10 sec recommended)
└── posters/ # Anime poster images (JPG/PNG/WebP)
output/videos/ # Generated MP4 files
```
## Video Generation Flow
1. Frontend POSTs to `/generate` with quiz configuration
2. `VideoGenerator.generate()` creates scenes in sequence:
- **Question scene**: background + countdown timer + difficulty badge + audio track
- **Answer scene**: anime title + poster image with fade-in
3. For "full" mode, adds final CTA screen
4. Concatenates all scenes → writes MP4 (H.264/AAC)
5. Returns video URL for download
**Video dimensions:**
- `shorts`: 1080x1920 (9:16) for TikTok/Shorts
- `full`: 1920x1080 (16:9) for YouTube
## API Endpoints
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/health` | Health check with FFmpeg status |
| GET | `/content` | List available audio, backgrounds, posters |
| POST | `/generate` | Generate video (returns video URL) |
| GET | `/download/{filename}` | Download generated video |
| DELETE | `/videos/{filename}` | Delete video |
| GET | `/videos-list` | List generated videos |
## Environment Variables
```
QUIZ_MEDIA_PATH=/app/media
QUIZ_OUTPUT_PATH=/app/output/videos
VITE_API_URL=http://backend:8000
```

115
README.md Normal file
View File

@@ -0,0 +1,115 @@
# Anime Quiz Video Generator
Generate "Guess the Anime Opening" videos for YouTube and TikTok.
## Quick Start
```bash
docker-compose up --build
```
After startup:
- Frontend: http://localhost:5173
- Backend API: http://localhost:8000
- API Docs: http://localhost:8000/docs
## Project Structure
```
project-root/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI endpoints
│ │ ├── models.py # Pydantic models
│ │ ├── video_generator.py # Video generation logic
│ │ └── config.py # Settings
│ ├── requirements.txt
│ └── Dockerfile
├── frontend/
│ ├── src/
│ │ ├── App.vue # Main component
│ │ ├── main.js
│ │ └── style.css
│ ├── package.json
│ └── Dockerfile
├── media/
│ ├── audio/ # MP3 anime openings
│ ├── backgrounds/ # Looping MP4 backgrounds
│ └── posters/ # Anime poster images
├── output/
│ └── videos/ # Generated videos
└── docker-compose.yml
```
## Adding Content
### Audio Files (Required)
Place MP3 files of anime openings in `media/audio/`:
```
media/audio/
├── aot_op1.mp3
├── demon_slayer_op1.mp3
└── jjk_op1.mp3
```
### Background Videos (Recommended)
Place looping MP4 backgrounds in `media/backgrounds/`:
- Recommended duration: 5-10 seconds
- Abstract animations, particles, gradients work best
- Will be looped and resized automatically
### Posters (Optional)
Place anime poster images in `media/posters/`:
- Supported formats: JPG, PNG, WebP
- Will be displayed in answer scenes
## Video Modes
### Shorts / TikTok
- Resolution: 1080x1920 (9:16)
- Fast pacing
- Best for 3-5 questions
### Full Video (YouTube)
- Resolution: 1920x1080 (16:9)
- Includes final screen with CTA
- Best for 10-20 questions
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /health | Health check |
| GET | /content | List available media |
| POST | /generate | Generate video |
| GET | /download/{filename} | Download video |
| GET | /videos-list | List generated videos |
## Example Request
```json
{
"mode": "shorts",
"questions": [
{
"anime": "Attack on Titan",
"opening_file": "aot_op1.mp3",
"start_time": 32,
"difficulty": "easy",
"poster": "aot.jpg"
}
],
"audio_duration": 3
}
```
## Requirements
- Docker
- Docker Compose
## Tech Stack
- **Backend**: Python 3.12, FastAPI, MoviePy, FFmpeg
- **Frontend**: Vue 3, Vite
- **Container**: Docker, Docker Compose

BIN
Review_Egir/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

3
Review_Egir/review Normal file
View File

@@ -0,0 +1,3 @@
1) Добавить чекбокс который переключает проигрывание опенинга после отгадывания(продолжает или сначала)
2) Редизайнуть страницу

35
backend/Dockerfile Normal file
View 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
View File

47
backend/app/config.py Normal file
View 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
View 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
View 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
View 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
View 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]

View File

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

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

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

60
docker-compose.yml Normal file
View File

@@ -0,0 +1,60 @@
services:
db:
image: postgres:16-alpine
container_name: anime-quiz-db
environment:
POSTGRES_USER: animequiz
POSTGRES_PASSWORD: animequiz123
POSTGRES_DB: animequiz
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U animequiz"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: anime-quiz-backend
ports:
- "8000:8000"
volumes:
- ./output:/app/output
env_file:
- .env
environment:
- QUIZ_OUTPUT_PATH=/app/output/videos
- DATABASE_URL=postgresql+asyncpg://animequiz:animequiz123@db:5432/animequiz
depends_on:
db:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: anime-quiz-frontend
ports:
- "5173:5173"
depends_on:
- backend
environment:
- VITE_API_URL=http://backend:8000
restart: unless-stopped
volumes:
output:
postgres_data:

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-slim
WORKDIR /app
# Copy package files
COPY package.json .
# Install dependencies
RUN npm install
# Copy source files
COPY . .
# Expose port
EXPOSE 5173
# Run dev server
CMD ["npm", "run", "dev"]

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Anime Quiz Video Generator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

18
frontend/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "anime-quiz-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.6"
}
}

756
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,756 @@
<template>
<div class="app-container">
<!-- Navigation -->
<nav class="main-nav">
<button
class="nav-btn"
:class="{ active: currentPage === 'quiz' }"
@click="currentPage = 'quiz'"
>
Quiz Generator
</button>
<button
class="nav-btn"
:class="{ active: currentPage === 'media' }"
@click="currentPage = 'media'"
>
Media Manager
</button>
<button
class="nav-btn"
:class="{ active: currentPage === 'admin' }"
@click="currentPage = 'admin'"
>
Upload Files
</button>
</nav>
<!-- Quiz Generator Page -->
<div v-if="currentPage === 'quiz'" class="app-layout">
<!-- Left Sidebar - Selected Openings Summary -->
<aside class="sidebar">
<h3>Selected Openings</h3>
<div class="selected-summary">
<div v-if="allSelectedOpenings.length === 0" class="empty-state">
No openings selected
</div>
<div
v-for="(item, index) in allSelectedOpenings"
:key="index"
class="selected-item"
:class="'difficulty-' + item.difficulty"
>
<span class="item-number">{{ index + 1 }}</span>
<div class="item-info">
<div class="item-anime">{{ item.animeName }}</div>
<div class="item-op">{{ item.opNumber }}</div>
</div>
<button class="btn-remove-small" @click="removeFromSection(item.sectionIndex, item.openingIndex)">×</button>
</div>
</div>
<div class="summary-stats" v-if="allSelectedOpenings.length > 0">
<div>Total: {{ allSelectedOpenings.length }} openings</div>
<div class="difficulty-stats">
<span class="stat easy">{{ difficultyCount.easy }} Easy</span>
<span class="stat medium">{{ difficultyCount.medium }} Medium</span>
<span class="stat hard">{{ difficultyCount.hard }} Hard</span>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<h1>Anime Quiz Generator</h1>
<p class="subtitle">Generate "Guess the Anime Opening" videos</p>
<div v-if="healthStatus" class="status-indicator" :class="healthStatus.status === 'healthy' ? 'success' : 'error'">
<span>{{ healthStatus.status === 'healthy' ? 'Backend connected' : 'Backend issue' }}</span>
</div>
<!-- Global Settings -->
<div class="card settings-card">
<h2>Global Settings</h2>
<div class="settings-grid">
<div class="setting-group">
<label>Video Mode</label>
<div class="mode-selector">
<button class="mode-btn" :class="{ active: mode === 'shorts' }" @click="mode = 'shorts'">
<span class="icon">📱</span>
<span>Shorts</span>
</button>
<button class="mode-btn" :class="{ active: mode === 'full' }" @click="mode = 'full'">
<span class="icon">🖥</span>
<span>Full</span>
</button>
</div>
</div>
<div class="setting-group">
<label>Default Guess Duration (sec)</label>
<input type="number" v-model.number="defaultAudioDuration" min="1" max="15" step="1" />
</div>
<div class="setting-group">
<label>Default Start Time (sec)</label>
<input type="number" v-model.number="defaultStartTime" min="0" step="5" />
</div>
<div class="setting-group">
<label class="checkbox-label" style="display: flex;">
<input type="checkbox" v-model="defaultContinueAudio" />
<span style="margin-top: 1px; margin-left: -8px;">Continue audio after reveal</span>
</label>
</div>
<div class="setting-group" v-if="content.background_videos.length">
<label>Background Video</label>
<select v-model="backgroundVideo">
<option value="">Auto (random)</option>
<option v-for="bg in content.background_videos" :key="bg" :value="bg">{{ bg }}</option>
</select>
</div>
</div>
</div>
<!-- Sections -->
<div class="card sections-card">
<div class="sections-header">
<h2>Sections</h2>
<button class="btn-add-section" @click="addSection">+ Add Section</button>
</div>
<div v-if="sections.length === 0" class="empty-sections">
No sections yet. Add a section to start selecting openings.
</div>
<div v-for="(section, sIndex) in sections" :key="sIndex" class="section-item" :class="'section-' + section.difficulty">
<div class="section-header">
<div class="section-title">
<span class="section-badge" :class="section.difficulty">{{ section.difficulty.toUpperCase() }}</span>
<span>Section {{ sIndex + 1 }} ({{ section.openings.length }} openings)</span>
</div>
<div class="section-actions">
<button class="btn-settings" @click="openSectionSettings(sIndex)"> Settings</button>
<button class="btn-add" @click="openModal(sIndex)">+ Add Openings</button>
<button class="btn-remove" @click="removeSection(sIndex)">🗑</button>
</div>
</div>
<!-- Section Settings (collapsible) -->
<div v-if="section.showSettings" class="section-settings">
<div class="settings-row">
<div class="setting-group">
<label>Difficulty</label>
<select v-model="section.difficulty">
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
<div class="setting-group">
<label>
<input type="checkbox" v-model="section.overrideDuration" />
Override Duration
</label>
<input
v-if="section.overrideDuration"
type="number"
v-model.number="section.audioDuration"
min="1" max="15"
/>
</div>
<div class="setting-group">
<label>
<input type="checkbox" v-model="section.overrideStartTime" />
Override Start Time
</label>
<input
v-if="section.overrideStartTime"
type="number"
v-model.number="section.startTime"
min="0"
/>
</div>
<div class="setting-group">
<label>
<input type="checkbox" v-model="section.overrideContinueAudio" />
Override Continue Audio
</label>
<select v-if="section.overrideContinueAudio" v-model="section.continueAudio">
<option :value="true">Yes</option>
<option :value="false">No</option>
</select>
</div>
</div>
</div>
<!-- Section Openings Preview -->
<div class="section-openings" v-if="section.openings.length > 0">
<div
v-for="(op, opIndex) in section.openings"
:key="opIndex"
class="opening-item"
>
<div class="opening-poster-thumb">
<img v-if="op.poster" :src="'/api/media/posters/' + op.poster" :alt="op.animeName" />
<div v-else class="no-poster-thumb">?</div>
</div>
<div class="opening-item-info">
<div class="opening-item-name">{{ op.animeName }}</div>
<div class="opening-item-op">{{ op.opNumber }}</div>
</div>
<select
class="poster-select"
:value="op.poster || ''"
@change="updatePoster(sIndex, opIndex, $event.target.value)"
>
<option value="">No poster</option>
<option v-for="poster in getPostersForAnime(op.animeName)" :key="poster" :value="poster">
{{ poster }}
</option>
<optgroup label="All posters" v-if="content.posters.length > 0">
<option v-for="poster in content.posters" :key="'all-' + poster" :value="poster">
{{ poster }}
</option>
</optgroup>
</select>
<button class="btn-remove-item" @click="removeOpening(sIndex, opIndex)">×</button>
</div>
</div>
</div>
</div>
<!-- Generate Button -->
<div class="card">
<button class="btn-generate" @click="generate" :disabled="generating || !canGenerate">
{{ generating ? 'Generating...' : 'Generate Video' }}
</button>
<div v-if="generating" class="progress-container">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
</div>
<p class="progress-text">{{ progressMessage }}</p>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<!-- Result -->
<div v-if="result" class="card result-card">
<h3>Video Generated!</h3>
<video class="video-preview" :src="'/api' + result.video_url" controls></video>
<a class="btn-download" :href="'/api/download/' + result.filename" :download="result.filename">
Download Video
</a>
</div>
</main>
<!-- Modal for Opening Selection -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h2>Select Openings for Section {{ currentSectionIndex + 1 }}</h2>
<button class="modal-close" @click="closeModal">×</button>
</div>
<div class="modal-search">
<input type="text" v-model="searchQuery" placeholder="Search anime..." />
</div>
<div class="modal-body">
<div class="openings-grid">
<div
v-for="opening in filteredOpenings"
:key="opening.filename"
class="opening-card"
:class="{ selected: isOpeningSelected(opening.filename) }"
@click="toggleOpeningSelection(opening)"
>
<div class="card-poster">
<img v-if="opening.associatedPoster" :src="'/api/media/posters/' + opening.associatedPoster" :alt="opening.animeName" />
<div v-else class="no-poster">No Poster</div>
</div>
<div class="card-info">
<div class="card-anime">{{ opening.animeName }}</div>
<div class="card-op">{{ opening.opNumber }}</div>
<div class="card-song" v-if="opening.songName">{{ opening.songName }}</div>
</div>
<div class="card-check" v-if="isOpeningSelected(opening.filename)"></div>
<!-- Poster selector -->
<div class="poster-selector" v-if="isOpeningSelected(opening.filename) && opening.availablePosters.length > 1" @click.stop>
<select v-model="selectedPosters[opening.filename]" @change="updateOpeningPoster(opening.filename)">
<option v-for="poster in opening.availablePosters" :key="poster" :value="poster">
{{ poster }}
</option>
</select>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<div class="selection-count">{{ tempSelectedOpenings.length }} selected</div>
<button class="btn-cancel" @click="closeModal">Cancel</button>
<button class="btn-confirm" @click="confirmSelection" :disabled="tempSelectedOpenings.length === 0">
Add to Section
</button>
</div>
</div>
</div>
</div>
<!-- Media Manager -->
<MediaManager v-if="currentPage === 'media'" />
<!-- Admin Page (Upload Files) -->
<AdminPage v-if="currentPage === 'admin'" />
</div>
</template>
<script>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import AdminPage from './components/AdminPage.vue'
import MediaManager from './components/MediaManager.vue'
const STORAGE_KEY = 'animeQuizSettings'
export default {
name: 'App',
components: {
AdminPage,
MediaManager
},
setup() {
// Navigation
const currentPage = ref('quiz')
// Load saved settings from localStorage
const loadSettings = () => {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
return JSON.parse(saved)
}
} catch (e) {
console.error('Failed to load settings:', e)
}
return null
}
const savedSettings = loadSettings()
// Global settings
const mode = ref(savedSettings?.mode || 'shorts')
const defaultAudioDuration = ref(savedSettings?.defaultAudioDuration || 5)
const defaultStartTime = ref(savedSettings?.defaultStartTime || 30)
const defaultContinueAudio = ref(savedSettings?.defaultContinueAudio || false)
const backgroundVideo = ref(savedSettings?.backgroundVideo || '')
// State
const generating = ref(false)
const progress = ref(0)
const progressMessage = ref('')
const error = ref('')
const result = ref(null)
const healthStatus = ref(null)
// Content from backend
const content = reactive({
audio_files: [],
background_videos: [],
posters: []
})
// Sections
const sections = ref(savedSettings?.sections || [])
// Save settings to localStorage
const saveSettings = () => {
try {
const settings = {
mode: mode.value,
defaultAudioDuration: defaultAudioDuration.value,
defaultStartTime: defaultStartTime.value,
defaultContinueAudio: defaultContinueAudio.value,
backgroundVideo: backgroundVideo.value,
sections: sections.value
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
} catch (e) {
console.error('Failed to save settings:', e)
}
}
// Watch for changes and save
watch([mode, defaultAudioDuration, defaultStartTime, defaultContinueAudio, backgroundVideo], saveSettings)
watch(sections, saveSettings, { deep: true })
// Modal state
const showModal = ref(false)
const currentSectionIndex = ref(-1)
const searchQuery = ref('')
const tempSelectedOpenings = ref([])
const selectedPosters = reactive({})
// Parse audio filename: "anime name_opnum_song name.mp3"
const parseAudioFilename = (filename) => {
const nameWithoutExt = filename.replace(/\.[^/.]+$/, '')
const parts = nameWithoutExt.split('_')
if (parts.length >= 2) {
return {
animeName: parts[0],
opNumber: parts[1] || 'OP',
songName: parts.slice(2).join(' ') || ''
}
}
return {
animeName: nameWithoutExt,
opNumber: 'OP',
songName: ''
}
}
// Find associated posters for an anime
const findAssociatedPosters = (animeName) => {
const normalizedAnimeName = animeName.toLowerCase()
return content.posters.filter(poster => {
const normalizedPoster = poster.toLowerCase()
return normalizedPoster.includes(normalizedAnimeName)
})
}
// Processed openings with parsed info
const processedOpenings = computed(() => {
return content.audio_files.map(filename => {
const parsed = parseAudioFilename(filename)
const availablePosters = findAssociatedPosters(parsed.animeName)
return {
filename,
animeName: parsed.animeName,
opNumber: parsed.opNumber,
songName: parsed.songName,
availablePosters,
associatedPoster: availablePosters[0] || null
}
})
})
// Filtered openings for search
const filteredOpenings = computed(() => {
if (!searchQuery.value) return processedOpenings.value
const query = searchQuery.value.toLowerCase()
return processedOpenings.value.filter(op =>
op.animeName.toLowerCase().includes(query) ||
op.songName.toLowerCase().includes(query)
)
})
// All selected openings across sections (for sidebar)
const allSelectedOpenings = computed(() => {
const result = []
sections.value.forEach((section, sIndex) => {
section.openings.forEach((op, opIndex) => {
result.push({
...op,
difficulty: section.difficulty,
sectionIndex: sIndex,
openingIndex: opIndex
})
})
})
return result
})
// Difficulty counts
const difficultyCount = computed(() => {
const counts = { easy: 0, medium: 0, hard: 0 }
allSelectedOpenings.value.forEach(op => {
counts[op.difficulty]++
})
return counts
})
// Can generate
const canGenerate = computed(() => {
return allSelectedOpenings.value.length > 0
})
// Section management
const getNextDifficulty = () => {
const count = sections.value.length
if (count === 0) return 'easy'
if (count === 1) return 'medium'
return 'hard'
}
const addSection = () => {
sections.value.push({
difficulty: getNextDifficulty(),
openings: [],
showSettings: false,
overrideDuration: false,
audioDuration: defaultAudioDuration.value,
overrideStartTime: false,
startTime: defaultStartTime.value,
overrideContinueAudio: false,
continueAudio: defaultContinueAudio.value
})
}
const removeSection = (index) => {
sections.value.splice(index, 1)
}
const openSectionSettings = (index) => {
sections.value[index].showSettings = !sections.value[index].showSettings
}
// Modal functions
const openModal = (sectionIndex) => {
currentSectionIndex.value = sectionIndex
tempSelectedOpenings.value = []
searchQuery.value = ''
showModal.value = true
}
const closeModal = () => {
showModal.value = false
currentSectionIndex.value = -1
tempSelectedOpenings.value = []
}
const isOpeningSelected = (filename) => {
return tempSelectedOpenings.value.some(op => op.filename === filename)
}
const toggleOpeningSelection = (opening) => {
const index = tempSelectedOpenings.value.findIndex(op => op.filename === opening.filename)
if (index >= 0) {
tempSelectedOpenings.value.splice(index, 1)
delete selectedPosters[opening.filename]
} else {
tempSelectedOpenings.value.push({ ...opening })
if (opening.associatedPoster) {
selectedPosters[opening.filename] = opening.associatedPoster
}
}
}
const updateOpeningPoster = (filename) => {
const opening = tempSelectedOpenings.value.find(op => op.filename === filename)
if (opening) {
opening.associatedPoster = selectedPosters[filename]
}
}
const confirmSelection = () => {
const section = sections.value[currentSectionIndex.value]
tempSelectedOpenings.value.forEach(op => {
// Check if already in section
if (!section.openings.some(existing => existing.filename === op.filename)) {
section.openings.push({
filename: op.filename,
animeName: op.animeName,
opNumber: op.opNumber,
songName: op.songName,
poster: selectedPosters[op.filename] || op.associatedPoster
})
}
})
closeModal()
}
const removeOpening = (sectionIndex, openingIndex) => {
sections.value[sectionIndex].openings.splice(openingIndex, 1)
}
const removeFromSection = (sectionIndex, openingIndex) => {
sections.value[sectionIndex].openings.splice(openingIndex, 1)
}
// Get posters associated with anime name
const getPostersForAnime = (animeName) => {
const normalizedName = animeName.toLowerCase()
return content.posters.filter(poster =>
poster.toLowerCase().includes(normalizedName)
)
}
// Update poster for an opening in a section
const updatePoster = (sectionIndex, openingIndex, poster) => {
sections.value[sectionIndex].openings[openingIndex].poster = poster || null
}
// Validate saved settings against available content
const validateSettings = () => {
// Check background video
if (backgroundVideo.value && !content.background_videos.includes(backgroundVideo.value)) {
backgroundVideo.value = ''
}
// Check sections openings
sections.value.forEach(section => {
// Filter out openings with non-existent audio files
section.openings = section.openings.filter(op =>
content.audio_files.includes(op.filename)
)
// Clear invalid posters
section.openings.forEach(op => {
if (op.poster && !content.posters.includes(op.poster)) {
op.poster = null
}
})
})
// Remove empty sections
sections.value = sections.value.filter(section => section.openings.length > 0)
}
// API functions
const fetchContent = async () => {
try {
const response = await fetch('/api/content')
const data = await response.json()
content.audio_files = data.audio_files
content.background_videos = data.background_videos
content.posters = data.posters
// Validate saved settings after content is loaded
validateSettings()
} catch (e) {
console.error('Failed to fetch content:', e)
}
}
const checkHealth = async () => {
try {
const response = await fetch('/api/health')
healthStatus.value = await response.json()
} catch (e) {
healthStatus.value = { status: 'error', ffmpeg: false }
}
}
const generate = async () => {
error.value = ''
result.value = null
generating.value = true
progress.value = 10
progressMessage.value = 'Preparing video generation...'
try {
// Build questions from sections
const questions = []
sections.value.forEach(section => {
const audioDuration = section.overrideDuration ? section.audioDuration : defaultAudioDuration.value
const startTime = section.overrideStartTime ? section.startTime : defaultStartTime.value
const continueAudio = section.overrideContinueAudio ? section.continueAudio : defaultContinueAudio.value
section.openings.forEach(op => {
questions.push({
anime: op.animeName,
opening_file: op.filename,
start_time: startTime,
difficulty: section.difficulty,
poster: op.poster || null,
// Per-question settings (need backend update for this)
audio_duration: audioDuration,
continue_audio: continueAudio
})
})
})
const payload = {
mode: mode.value,
questions,
audio_duration: defaultAudioDuration.value,
background_video: backgroundVideo.value || null,
continue_audio: defaultContinueAudio.value
}
progress.value = 30
progressMessage.value = 'Generating video (this may take a few minutes)...'
const response = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const data = await response.json()
if (data.success) {
progress.value = 100
progressMessage.value = 'Video generated successfully!'
result.value = data
} else {
throw new Error(data.error || 'Generation failed')
}
} catch (e) {
error.value = e.message
} finally {
generating.value = false
}
}
onMounted(() => {
checkHealth()
fetchContent()
})
return {
// Navigation
currentPage,
// Global settings
mode,
defaultAudioDuration,
defaultStartTime,
defaultContinueAudio,
backgroundVideo,
// State
generating,
progress,
progressMessage,
error,
result,
healthStatus,
content,
// Sections
sections,
addSection,
removeSection,
openSectionSettings,
// Modal
showModal,
currentSectionIndex,
searchQuery,
tempSelectedOpenings,
selectedPosters,
filteredOpenings,
openModal,
closeModal,
isOpeningSelected,
toggleOpeningSelection,
updateOpeningPoster,
confirmSelection,
removeOpening,
removeFromSection,
getPostersForAnime,
updatePoster,
// Computed
allSelectedOpenings,
difficultyCount,
canGenerate,
// Actions
generate
}
}
}
</script>
<style>
/* Import base styles then add component-specific */
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,921 @@
<template>
<div class="manager-page">
<h1>Backgrounds Manager</h1>
<p class="subtitle">Manage background videos for different difficulties</p>
<!-- Header Actions -->
<div class="header-actions">
<div class="filters">
<input
type="text"
v-model="searchQuery"
placeholder="Search by name..."
class="search-input"
/>
<select v-model="filterDifficulty" class="filter-select">
<option value="">All Difficulties</option>
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
<button class="btn-primary" @click="openCreateModal">+ Add Background</button>
</div>
<!-- Stats -->
<div class="stats-bar">
<div class="stat-item easy">
<span class="stat-value">{{ countByDifficulty('easy') }}</span>
<span class="stat-label">Easy</span>
</div>
<div class="stat-item medium">
<span class="stat-value">{{ countByDifficulty('medium') }}</span>
<span class="stat-label">Medium</span>
</div>
<div class="stat-item hard">
<span class="stat-value">{{ countByDifficulty('hard') }}</span>
<span class="stat-label">Hard</span>
</div>
<div class="stat-item total">
<span class="stat-value">{{ total }}</span>
<span class="stat-label">Total</span>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<span>Loading backgrounds...</span>
</div>
<!-- Empty State -->
<div v-else-if="backgrounds.length === 0" class="empty-state">
<span class="empty-icon">🎬</span>
<p>No backgrounds found</p>
<button class="btn-primary" @click="openCreateModal">Add your first background</button>
</div>
<!-- Backgrounds Grid -->
<div v-else class="backgrounds-grid">
<div
v-for="bg in backgrounds"
:key="bg.id"
class="background-card"
:class="'difficulty-' + bg.difficulty"
>
<div class="background-preview">
<span class="video-icon">🎬</span>
<span class="video-file">{{ bg.video_file }}</span>
</div>
<div class="background-info">
<h3 class="bg-name">{{ bg.name }}</h3>
<span class="difficulty-badge" :class="bg.difficulty">{{ bg.difficulty.toUpperCase() }}</span>
</div>
<div class="background-actions">
<button class="btn-icon" @click="openEditModal(bg)" title="Edit"></button>
<button class="btn-icon btn-delete" @click="confirmDelete(bg)" title="Delete">🗑</button>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h2>{{ editingBackground ? 'Edit Background' : 'Add Background' }}</h2>
<button class="modal-close" @click="closeModal">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input type="text" v-model="form.name" placeholder="e.g., Calm Ocean" />
</div>
<div class="form-group">
<label>Video File (S3 key) *</label>
<select v-model="form.video_file">
<option value="">Select video file...</option>
<option v-for="file in videoFiles" :key="file" :value="file">{{ file }}</option>
</select>
</div>
<div class="form-group">
<label>Difficulty *</label>
<div class="difficulty-selector">
<button
type="button"
class="diff-btn easy"
:class="{ active: form.difficulty === 'easy' }"
@click="form.difficulty = 'easy'"
>
Easy
</button>
<button
type="button"
class="diff-btn medium"
:class="{ active: form.difficulty === 'medium' }"
@click="form.difficulty = 'medium'"
>
Medium
</button>
<button
type="button"
class="diff-btn hard"
:class="{ active: form.difficulty === 'hard' }"
@click="form.difficulty = 'hard'"
>
Hard
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-cancel" @click="closeModal">Cancel</button>
<button class="btn-primary" @click="saveBackground" :disabled="saving">
{{ saving ? 'Saving...' : (editingBackground ? 'Update' : 'Create') }}
</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="delete-modal">
<h3>Confirm Delete</h3>
<p>Are you sure you want to delete this background?</p>
<div class="delete-preview">
<strong>{{ deletingBackground?.name }}</strong>
<span class="difficulty-badge" :class="deletingBackground?.difficulty">
{{ deletingBackground?.difficulty?.toUpperCase() }}
</span>
</div>
<div class="modal-actions">
<button class="btn-cancel" @click="showDeleteModal = false">Cancel</button>
<button class="btn-delete-confirm" @click="executeDelete">Delete</button>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div class="toast-container">
<div v-for="(t, index) in toasts" :key="index" class="toast" :class="t.type">
{{ t.message }}
</div>
</div>
</div>
</template>
<script>
import { ref, reactive, computed, onMounted, watch } from 'vue'
export default {
name: 'BackgroundsManager',
setup() {
const backgrounds = ref([])
const allBackgrounds = ref([])
const total = ref(0)
const loading = ref(true)
const saving = ref(false)
const searchQuery = ref('')
const filterDifficulty = ref('')
const toasts = ref([])
// Modal states
const showModal = ref(false)
const showDeleteModal = ref(false)
const editingBackground = ref(null)
const deletingBackground = ref(null)
// Form data
const form = reactive({
name: '',
video_file: '',
difficulty: 'medium'
})
// Available files from S3
const videoFiles = ref([])
const countByDifficulty = (difficulty) => {
return allBackgrounds.value.filter(bg => bg.difficulty === difficulty).length
}
const showToast = (message, type = 'success') => {
const toast = { message, type }
toasts.value.push(toast)
setTimeout(() => {
const idx = toasts.value.indexOf(toast)
if (idx > -1) toasts.value.splice(idx, 1)
}, 3000)
}
const fetchBackgrounds = async () => {
loading.value = true
try {
const params = new URLSearchParams()
if (searchQuery.value) params.append('search', searchQuery.value)
if (filterDifficulty.value) params.append('difficulty', filterDifficulty.value)
const response = await fetch(`/api/api/backgrounds?${params}`)
const data = await response.json()
backgrounds.value = data.backgrounds
total.value = data.total
// Also fetch all for stats
const allResponse = await fetch('/api/api/backgrounds')
const allData = await allResponse.json()
allBackgrounds.value = allData.backgrounds
} catch (e) {
showToast('Failed to fetch backgrounds', 'error')
} finally {
loading.value = false
}
}
const fetchContent = async () => {
try {
const response = await fetch('/api/content')
const data = await response.json()
videoFiles.value = data.background_videos || []
} catch (e) {
console.error('Failed to fetch content:', e)
}
}
const openCreateModal = () => {
editingBackground.value = null
form.name = ''
form.video_file = ''
form.difficulty = 'medium'
showModal.value = true
}
const openEditModal = (bg) => {
editingBackground.value = bg
form.name = bg.name
form.video_file = bg.video_file
form.difficulty = bg.difficulty
showModal.value = true
}
const closeModal = () => {
showModal.value = false
editingBackground.value = null
}
const saveBackground = async () => {
if (!form.name || !form.video_file) {
showToast('Please fill all required fields', 'error')
return
}
saving.value = true
try {
const payload = {
name: form.name,
video_file: form.video_file,
difficulty: form.difficulty
}
if (editingBackground.value) {
// Update
await fetch(`/api/api/backgrounds/${editingBackground.value.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
showToast('Background updated')
} else {
// Create
await fetch('/api/api/backgrounds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
showToast('Background created')
}
closeModal()
await fetchBackgrounds()
} catch (e) {
showToast('Failed to save background', 'error')
} finally {
saving.value = false
}
}
const confirmDelete = (bg) => {
deletingBackground.value = bg
showDeleteModal.value = true
}
const executeDelete = async () => {
if (!deletingBackground.value) return
try {
await fetch(`/api/api/backgrounds/${deletingBackground.value.id}`, {
method: 'DELETE'
})
showToast('Background deleted')
showDeleteModal.value = false
deletingBackground.value = null
await fetchBackgrounds()
} catch (e) {
showToast('Failed to delete background', 'error')
}
}
// Search/filter with debounce
let searchTimeout
watch([searchQuery, filterDifficulty], () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(fetchBackgrounds, 300)
})
onMounted(() => {
fetchBackgrounds()
fetchContent()
})
return {
backgrounds,
total,
loading,
saving,
searchQuery,
filterDifficulty,
toasts,
showModal,
showDeleteModal,
editingBackground,
deletingBackground,
form,
videoFiles,
countByDifficulty,
openCreateModal,
openEditModal,
closeModal,
saveBackground,
confirmDelete,
executeDelete
}
}
}
</script>
<style scoped>
.manager-page {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.manager-page h1 {
text-align: center;
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(90deg, #00d4ff, #7b2cbf);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
text-align: center;
color: #888;
margin-bottom: 2rem;
}
.header-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.filters {
display: flex;
gap: 1rem;
flex: 1;
flex-wrap: wrap;
}
.search-input {
flex: 1;
max-width: 300px;
padding: 0.75rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 1rem;
}
.search-input:focus {
outline: none;
border-color: #00d4ff;
}
.filter-select {
padding: 0.75rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 1rem;
min-width: 150px;
}
.filter-select:focus {
outline: none;
border-color: #00d4ff;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: linear-gradient(90deg, #00d4ff, #7b2cbf);
border: none;
border-radius: 8px;
color: #fff;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.stats-bar {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
background: rgba(255, 255, 255, 0.05);
padding: 1rem 2rem;
border-radius: 12px;
border: 2px solid transparent;
}
.stat-item.easy {
border-color: rgba(0, 255, 136, 0.3);
}
.stat-item.medium {
border-color: rgba(255, 170, 0, 0.3);
}
.stat-item.hard {
border-color: rgba(255, 68, 68, 0.3);
}
.stat-item.total {
border-color: rgba(0, 212, 255, 0.3);
}
.stat-item.easy .stat-value { color: #00ff88; }
.stat-item.medium .stat-value { color: #ffaa00; }
.stat-item.hard .stat-value { color: #ff4444; }
.stat-item.total .stat-value { color: #00d4ff; }
.stat-value {
font-size: 2rem;
font-weight: 700;
}
.stat-label {
color: #888;
font-size: 0.9rem;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 3rem;
color: #888;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(0, 212, 255, 0.3);
border-top-color: #00d4ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state {
text-align: center;
padding: 4rem;
color: #666;
}
.empty-icon {
font-size: 4rem;
display: block;
margin-bottom: 1rem;
}
.empty-state p {
margin-bottom: 1.5rem;
}
/* Backgrounds Grid */
.backgrounds-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.background-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
overflow: hidden;
border: 2px solid transparent;
transition: all 0.3s ease;
}
.background-card:hover {
transform: translateY(-4px);
}
.background-card.difficulty-easy {
border-color: rgba(0, 255, 136, 0.3);
}
.background-card.difficulty-medium {
border-color: rgba(255, 170, 0, 0.3);
}
.background-card.difficulty-hard {
border-color: rgba(255, 68, 68, 0.3);
}
.background-card:hover.difficulty-easy {
border-color: #00ff88;
}
.background-card:hover.difficulty-medium {
border-color: #ffaa00;
}
.background-card:hover.difficulty-hard {
border-color: #ff4444;
}
.background-preview {
height: 120px;
background: rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.video-icon {
font-size: 2.5rem;
}
.video-file {
color: #666;
font-size: 0.75rem;
max-width: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.background-info {
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.bg-name {
font-size: 1.1rem;
color: #fff;
margin: 0;
}
.difficulty-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.difficulty-badge.easy {
background: rgba(0, 255, 136, 0.2);
color: #00ff88;
}
.difficulty-badge.medium {
background: rgba(255, 170, 0, 0.2);
color: #ffaa00;
}
.difficulty-badge.hard {
background: rgba(255, 68, 68, 0.2);
color: #ff4444;
}
.background-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0 1rem 1rem;
}
.btn-icon {
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
background: rgba(255, 255, 255, 0.1);
cursor: pointer;
font-size: 1rem;
transition: all 0.2s ease;
}
.btn-icon:hover {
background: rgba(255, 255, 255, 0.2);
}
.btn-icon.btn-delete {
background: rgba(255, 68, 68, 0.2);
}
.btn-icon.btn-delete:hover {
background: rgba(255, 68, 68, 0.3);
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: #1a1a2e;
border-radius: 16px;
width: 100%;
max-width: 500px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-header h2 {
color: #00d4ff;
font-size: 1.25rem;
}
.modal-close {
background: none;
border: none;
color: #888;
font-size: 1.5rem;
cursor: pointer;
}
.modal-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
font-size: 0.9rem;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 0.95rem;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #00d4ff;
}
.difficulty-selector {
display: flex;
gap: 0.75rem;
}
.diff-btn {
flex: 1;
padding: 0.75rem;
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.2);
background: transparent;
color: #888;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
}
.diff-btn.easy:hover,
.diff-btn.easy.active {
border-color: #00ff88;
color: #00ff88;
background: rgba(0, 255, 136, 0.1);
}
.diff-btn.medium:hover,
.diff-btn.medium.active {
border-color: #ffaa00;
color: #ffaa00;
background: rgba(255, 170, 0, 0.1);
}
.diff-btn.hard:hover,
.diff-btn.hard.active {
border-color: #ff4444;
color: #ff4444;
background: rgba(255, 68, 68, 0.1);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.btn-cancel {
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #fff;
cursor: pointer;
}
.btn-cancel:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Delete Modal */
.delete-modal {
background: #1a1a2e;
border-radius: 16px;
padding: 2rem;
max-width: 400px;
width: 90%;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.delete-modal h3 {
color: #ff4444;
margin-bottom: 0.5rem;
}
.delete-modal p {
color: #888;
margin-bottom: 1.5rem;
}
.delete-preview {
background: rgba(0, 0, 0, 0.3);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.modal-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.btn-delete-confirm {
padding: 0.75rem 1.5rem;
background: #ff4444;
border: none;
border-radius: 8px;
color: #fff;
cursor: pointer;
font-weight: 500;
}
.btn-delete-confirm:hover {
background: #ff2222;
}
/* Toast */
.toast-container {
position: fixed;
bottom: 2rem;
right: 2rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 1001;
}
.toast {
padding: 1rem 1.5rem;
border-radius: 8px;
color: #fff;
font-weight: 500;
animation: slideIn 0.3s ease;
}
.toast.success {
background: linear-gradient(90deg, #00d4ff, #00a8cc);
}
.toast.error {
background: linear-gradient(90deg, #ff4444, #cc2222);
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@media (max-width: 768px) {
.manager-page {
padding: 1rem;
}
.header-actions {
flex-direction: column;
}
.filters {
width: 100%;
}
.search-input {
max-width: 100%;
}
.backgrounds-grid {
grid-template-columns: 1fr;
}
.stats-bar {
gap: 0.75rem;
}
.stat-item {
padding: 0.75rem 1rem;
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

5
frontend/src/main.js Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

1034
frontend/src/style.css Normal file

File diff suppressed because it is too large Load Diff

25
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
},
'/videos': {
target: 'http://backend:8000',
changeOrigin: true
},
'/download': {
target: 'http://backend:8000',
changeOrigin: true
}
}
}
})

BIN
image1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

0
media/audio/.gitkeep Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

0
media/posters/.gitkeep Normal file
View File

BIN
media/posters/One Piece.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

155
mvpDocker Normal file
View File

@@ -0,0 +1,155 @@
🐳 DOCKERIZATION (ОБЯЗАТЕЛЬНО)
🎯 Цель
Весь проект должен запускаться одной командой через Docker, без ручной установки зависимостей на хост-машине.
📦 Требования к Docker
1. Общие требования
Использовать Docker + Docker Compose
Проект должен запускаться командой:
docker-compose up --build
После запуска:
Backend доступен на http://localhost:8000
Frontend доступен на http://localhost:5173 (или 3000)
🧩 Архитектура контейнеров
🔹 Контейнер 1 — Backend
Содержит:
Python 3.11+
FastAPI
FFmpeg (обязательно установлен внутри контейнера)
MoviePy
Все Python-зависимости
Требования:
FFmpeg должен быть доступен через CLI (ffmpeg -version)
Видео и временные файлы хранятся в volume
Поддержка долгих задач (video rendering)
🔹 Контейнер 2 — Frontend
Содержит:
Node.js 18+
Vue 3
Vite
Требования:
Горячая перезагрузка (dev mode)
API-запросы проксируются на backend
📁 Структура проекта (обязательная)
project-root/
├── backend/
│ ├── app/
│ ├── requirements.txt
│ ├── Dockerfile
├── frontend/
│ ├── src/
│ ├── package.json
│ ├── Dockerfile
├── media/
│ ├── audio/
│ ├── backgrounds/
│ ├── posters/
├── output/
│ └── videos/
├── docker-compose.yml
└── README.md
🐳 Backend Dockerfile — требования
Базовый образ: python:3.11-slim
Установка FFmpeg через apt
Установка Python-зависимостей
Запуск FastAPI через uvicorn
🐳 Frontend Dockerfile — требования
Базовый образ: node:18
Установка зависимостей
Запуск Vite dev server
🔁 Volumes (обязательно)
volumes:
- ./media:/app/media
- ./output:/app/output
Чтобы:
не терять видео при перезапуске
удобно подменять контент
🧪 Проверка Docker-сборки (Acceptance)
После docker-compose up:
Frontend открывается в браузере
Backend отвечает на /health
Генерация видео работает
FFmpeg корректно вызывается
MP4 сохраняется в /output/videos
⚠️ ВАЖНО для AI
При генерации кода:
обязательно показать Dockerfile для backend
обязательно показать Dockerfile для frontend
обязательно показать docker-compose.yml
код должен работать без ручной донастройки
📌 Итог
AI должен выдать:
Backend код
Frontend (Vue) код
FFmpeg пайплайн
Dockerfile (2 шт.)
docker-compose.yml
Инструкцию запуска

251
mvpTask Normal file
View File

@@ -0,0 +1,251 @@
🧠 PROMPT / ТЗ ДЛЯ AI
MVP приложения генерации аниме-квиз видео
ROLE
Ты — senior full-stack developer, специализирующийся на:
Vue 3
Python (FastAPI)
FFmpeg / MoviePy
генерации видео
Твоя задача — сгенерировать код MVP, а не архитектурные рассуждения.
🎯 Цель проекта
Создать MVP веб-приложения, которое автоматически генерирует видео формата
«Guess the Anime Opening» для:
YouTube (полноценные видео)
Shorts / TikTok (вертикальные)
Без ручного видеомонтажа.
🚫 Ограничения MVP
НЕ реализовывать:
авторизацию
оплату
аналитику
AI-анализ сцен
мобильное приложение
Фокус — рабочая генерация видео.
🧩 Основной функционал
1. Два режима генерации (ОБЯЗАТЕЛЬНО)
🔹 Mode 1 — Shorts / TikTok
Формат: 9:16
Длительность: 3060 сек
Быстрый темп
Минимум пауз
Крупный текст
Подходит для TikTok / YT Shorts
🔹 Mode 2 — Full Video (YouTube)
Формат: 16:9
1020 вопросов
Более медленный тайминг
Финальный экран
Подходит для обычного YouTube
⚠️ Код должен быть расширяемым для будущих форматов.
🎞️ Структура видео (строго)
🔹 Один вопрос = 2 сцены
🟦 Сцена 1 — ВОПРОС
❗ ВАЖНО:
НЕ использовать чёрный / статичный фон
✅ Использовать зацикленный видео-фон:
абстрактная анимация
motion graphics
динамический looping video
без авторских персонажей
Пример:
частицы
неоновые линии
анимированный градиент
glow-эффекты
Элементы сцены:
Текст:
“Guess the Anime Opening”
Таймер обратного отсчёта (анимация)
Аудио фрагмент опенинга (1 / 3 / 5 сек)
Длительность:
длительность аудио + 1 сек
🟩 Сцена 2 — ОТВЕТ
Текст:
Anime: <название>
Постер аниме (если есть)
Звук подтверждения (optional)
Длительность: 2 сек
🟨 Финальный экран (только Full Video)
Текст:
“How many did you guess?”
CTA:
“Subscribe for more anime quizzes”
Длительность: 3 сек
🎶 Контент
Аудио
MP3 файлы опенингов
Обрезка по таймкоду
Нормализация громкости
Видео-фоны
Набор looping mp4 (510 сек)
Зацикливание через FFmpeg
🗂️ Формат данных
{
"anime": "Attack on Titan",
"opening_file": "aot_op1.mp3",
"start_time": 32,
"difficulty": "easy",
"poster": "aot.jpg"
}
⚙️ Технические требования
Backend
Python
FastAPI
FFmpeg
MoviePy
Функции:
выбор контента
сбор таймингов
генерация видео
возврат MP4
Frontend
❗ Использовать Vue 3, НЕ React
Почему:
не мешает масштабированию
допускает SSR / SPA
легко расширяется
Функции:
форма параметров
выбор режима (Shorts / Full)
кнопка генерации
индикатор прогресса
кнопка скачивания
📐 Разрешения видео
Режим Разрешение
Shorts 1080×1920
Full 1920×1080
⏱️ Производительность
≤ 3 мин генерации (10 вопросов)
1 видео за раз
FFmpeg обязателен
❗ Обработка ошибок
нехватка контента
падение FFmpeg
неверные параметры
Возвращать понятные ошибки.
📦 Результат
AI должен:
Сгенерировать backend-код
Сгенерировать Vue frontend
Показать пример FFmpeg пайплайна
Объяснить, как запустить MVP локально
🧪 Acceptance Criteria
Видео корректно собирается
Аудио синхронизировано
Видео подходит для YouTube / TikTok
Код читаемый и расширяемый
⚠️ ВАЖНО
Не писать абстрактные советы.
Не обсуждать «в теории».
Писать конкретный код и структуру проекта.

265
task Normal file
View File

@@ -0,0 +1,265 @@
ПЛАН СОЗДАНИЯ ПРИЛОЖЕНИЯ ДЛЯ АНИМЕ-КВИЗ ВИДЕО
🎯 1. Цель приложения
Создать приложение, которое:
автоматически генерирует квиз-видео
минимизирует ручной монтаж
поддерживает разные форматы угадай-аниме
готово к массовому выпуску видео
🧠 2. Основные форматы видео (ядро логики)
🔹 Формат 1: «Угадай опенинг»
Параметры:
1 / 3 / 5 / 10 секунд
Easy / Medium / Hard
Количество вопросов (10100)
🔹 Формат 2: «Угадай аниме по кадру»
Параметры:
1 кадр
4 кадра
затемнённый / размытый кадр
🔹 Формат 3: «Угадай персонажа»
Параметры:
силуэт
глаза / причёска
детское фото
🔹 Формат 4: «Выбери один вариант»
Пример:
Выбери один опенинг
Выбери одного персонажа
🗂️ 3. Структура данных (База контента)
📦 Аниме
{
"anime_id": 101,
"title": "Naruto",
"year": 2002,
"popularity": 95
}
🎶 Опенинги
{
"opening_id": 301,
"anime_id": 101,
"audio_file": "op1.mp3",
"start_time": 35,
"difficulty": "easy"
}
🖼️ Кадры / изображения
{
"image_id": 501,
"anime_id": 101,
"type": "scene",
"blur_level": 2
}
🛠️ 4. Архитектура приложения
📱 Frontend (Web / Desktop)
Функции:
выбор формата видео
настройка параметров
предпросмотр
экспорт видео
Технологии:
Vue
Tailwind / Material UI
⚙️ Backend
Функции:
логика квизов
генерация последовательности
управление медиа
Технологии:
Python (FastAPI)
PostgreSQL / MongoDB
🎞️ Видео-движок (ключевая часть)
Функции:
нарезка аудио
таймеры
текст + анимации
переходы
Технологии:
FFmpeg
Remotion (React → видео)
MoviePy (Python)
🎨 5. UI-шаблоны видео
Экран вопроса
Таймер (⏱️ 3…2…1)
Текст: «Угадай опенинг»
Звук фрагмента
Экран ответа
Название аниме
Постер / кадр
Звук «correct»
Финальный экран
Счёт
Call-to-Action:
“Subscribe”
“How many did you guess?”
🔊 6. Аудио-система
авто-обрезка MP3
нормализация громкости
фоновая музыка (low volume)
звуковые эффекты:
тик таймера
правильный ответ
🤖 7. Автоматизация и AI (опционально, но мощно)
AI может:
подбирать кадры из серий
определять сложность опенинга
предлагать темы для выпусков
генерировать названия и описания видео
Инструменты:
Whisper (таймкоды)
CLIP (распознавание сцен)
LLM (скрипты видео)
📤 8. Экспорт и платформы
Поддержка форматов:
YouTube (16:9)
Shorts / TikTok (9:16)
Instagram Reels
Авто-экспорт:
название
описание
теги
📈 9. Аналитика
какие форматы набирают больше просмотров
удержание внимания (видео длина)
сложность vs вовлечённость
🚀 10. MVP (что делать в первую очередь)
Версия 1 (23 недели):
✅ Только «Угадай опенинг»
✅ 1020 вопросов
✅ FFmpeg генерация
✅ Экспорт в MP4
Версия 2:
⬆️ Кадры + персонажи
⬆️ Шаблоны видео
⬆️ Shorts формат
🧩 11. Возможная монетизация
Pro-версия (без водяного знака)
Шаблоны премиум
Пакеты аниме
SaaS-подписка
🧠 Вывод
Это приложение = фабрика аниме-квиз контента
Оно идеально подходит для:
YouTube-каналов
TikTok-ферм
стримеров
фан-сообществ
Если хочешь, следующим шагом я могу:
📐 спроектировать UX-макет
🧪 составить MVP-ТЗ
🧑‍💻 предложить стек под твой уровень
⚖️ разобрать вопросы авторских прав