app v1
This commit is contained in:
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(node --version:*)",
|
||||||
|
"Bash(mkdir:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal 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
86
CLAUDE.md
Normal 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
115
README.md
Normal 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
BIN
Review_Egir/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
3
Review_Egir/review
Normal file
3
Review_Egir/review
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
1) Добавить чекбокс который переключает проигрывание опенинга после отгадывания(продолжает или сначала)
|
||||||
|
|
||||||
|
2) Редизайнуть страницу
|
||||||
35
backend/Dockerfile
Normal file
35
backend/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies including FFmpeg
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
libsm6 \
|
||||||
|
libxext6 \
|
||||||
|
fonts-dejavu \
|
||||||
|
fontconfig \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& fc-cache -f -v
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
# Create directories for media and output
|
||||||
|
RUN mkdir -p /app/media/audio /app/media/backgrounds /app/media/posters /app/media/transitions /app/output/videos
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
47
backend/app/config.py
Normal file
47
backend/app/config.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# Database
|
||||||
|
database_url: str = "postgresql+asyncpg://animequiz:animequiz123@localhost:5432/animequiz"
|
||||||
|
|
||||||
|
# S3 Storage settings
|
||||||
|
s3_endpoint: str = "https://s3.firstvds.ru"
|
||||||
|
s3_access_key: str = ""
|
||||||
|
s3_secret_key: str = ""
|
||||||
|
s3_region: str = "default"
|
||||||
|
s3_bucket: str = "anime-quiz"
|
||||||
|
|
||||||
|
# Local paths
|
||||||
|
output_path: Path = Path("/app/output/videos")
|
||||||
|
temp_path: Path = Path("/tmp/anime_quiz")
|
||||||
|
cache_path: Path = Path("/tmp/anime_quiz/cache")
|
||||||
|
|
||||||
|
# Video settings
|
||||||
|
shorts_width: int = 1080
|
||||||
|
shorts_height: int = 1920
|
||||||
|
full_width: int = 1920
|
||||||
|
full_height: int = 1080
|
||||||
|
|
||||||
|
# Timing settings (seconds)
|
||||||
|
answer_duration: float = 5.0
|
||||||
|
final_screen_duration: float = 3.0
|
||||||
|
audio_buffer: float = 1.0
|
||||||
|
|
||||||
|
# Audio
|
||||||
|
audio_fade_duration: float = 0.7
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
# Ensure directories exist
|
||||||
|
settings.temp_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
settings.output_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
settings.cache_path.mkdir(parents=True, exist_ok=True)
|
||||||
36
backend/app/database.py
Normal file
36
backend/app/database.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=False,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async_session_maker = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
"""Dependency for getting database session."""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
"""Initialize database tables."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
105
backend/app/db_models.py
Normal file
105
backend/app/db_models.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy import String, Integer, ForeignKey, DateTime, Enum as SQLEnum, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Difficulty(str, enum.Enum):
|
||||||
|
EASY = "easy"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HARD = "hard"
|
||||||
|
|
||||||
|
|
||||||
|
class Opening(Base):
|
||||||
|
"""Anime opening entity."""
|
||||||
|
__tablename__ = "openings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
anime_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
op_number: Mapped[str] = mapped_column(String(20), nullable=False) # e.g., "OP1", "ED2"
|
||||||
|
song_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
audio_file: Mapped[str] = mapped_column(String(512), nullable=False) # S3 key
|
||||||
|
|
||||||
|
# Usage tracking
|
||||||
|
last_usage: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
posters: Mapped[List["OpeningPoster"]] = relationship(
|
||||||
|
"OpeningPoster",
|
||||||
|
back_populates="opening",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Opening {self.anime_name} - {self.op_number}>"
|
||||||
|
|
||||||
|
|
||||||
|
class OpeningPoster(Base):
|
||||||
|
"""Poster image for an opening."""
|
||||||
|
__tablename__ = "opening_posters"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
opening_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("openings.id", ondelete="CASCADE"),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
poster_file: Mapped[str] = mapped_column(String(512), nullable=False) # S3 key
|
||||||
|
is_default: Mapped[bool] = mapped_column(default=False)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
opening: Mapped["Opening"] = relationship("Opening", back_populates="posters")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<OpeningPoster {self.poster_file}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Background(Base):
|
||||||
|
"""Background video entity."""
|
||||||
|
__tablename__ = "backgrounds"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
video_file: Mapped[str] = mapped_column(String(512), nullable=False) # S3 key
|
||||||
|
difficulty: Mapped[Difficulty] = mapped_column(
|
||||||
|
SQLEnum(Difficulty, native_enum=False),
|
||||||
|
nullable=False,
|
||||||
|
default=Difficulty.MEDIUM
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Background {self.name} ({self.difficulty})>"
|
||||||
378
backend/app/main.py
Normal file
378
backend/app/main.py
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .storage import storage
|
||||||
|
from .models import (
|
||||||
|
GenerateRequest,
|
||||||
|
GenerateResponse,
|
||||||
|
ContentListResponse,
|
||||||
|
)
|
||||||
|
from .video_generator import VideoGenerator, check_ffmpeg
|
||||||
|
from .database import init_db, async_session_maker
|
||||||
|
from .db_models import Opening
|
||||||
|
from .routers import openings, backgrounds
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Application lifespan handler."""
|
||||||
|
# Startup
|
||||||
|
print("Initializing database...")
|
||||||
|
await init_db()
|
||||||
|
print("Database initialized")
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
print("Shutting down...")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Anime Quiz Video Generator",
|
||||||
|
description="Generate 'Guess the Anime Opening' videos for YouTube and TikTok",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(openings.router, prefix="/api")
|
||||||
|
app.include_router(backgrounds.router, prefix="/api")
|
||||||
|
|
||||||
|
# Mount output directory for serving videos
|
||||||
|
app.mount("/videos", StaticFiles(directory=str(settings.output_path)), name="videos")
|
||||||
|
|
||||||
|
# Thread pool for video generation
|
||||||
|
executor = ThreadPoolExecutor(max_workers=1)
|
||||||
|
|
||||||
|
# Store generation status
|
||||||
|
generation_status: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
ffmpeg_ok = check_ffmpeg()
|
||||||
|
return {
|
||||||
|
"status": "healthy" if ffmpeg_ok else "degraded",
|
||||||
|
"ffmpeg": ffmpeg_ok,
|
||||||
|
"s3_endpoint": settings.s3_endpoint,
|
||||||
|
"s3_bucket": settings.s3_bucket,
|
||||||
|
"output_path": str(settings.output_path),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/content", response_model=ContentListResponse)
|
||||||
|
async def list_content():
|
||||||
|
"""List available media content from S3."""
|
||||||
|
return ContentListResponse(
|
||||||
|
audio_files=storage.list_audio_files(),
|
||||||
|
background_videos=storage.list_background_videos(),
|
||||||
|
posters=storage.list_posters(),
|
||||||
|
transition_sounds=storage.list_transition_sounds(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/media/posters/{filename}")
|
||||||
|
async def get_poster(filename: str):
|
||||||
|
"""Get poster image from S3."""
|
||||||
|
poster_path = storage.get_poster_file(filename)
|
||||||
|
if not poster_path or not poster_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Poster not found")
|
||||||
|
|
||||||
|
# Determine media type
|
||||||
|
suffix = poster_path.suffix.lower()
|
||||||
|
media_types = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".webp": "image/webp",
|
||||||
|
}
|
||||||
|
media_type = media_types.get(suffix, "application/octet-stream")
|
||||||
|
|
||||||
|
return FileResponse(path=str(poster_path), media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/media/audio/{filename}")
|
||||||
|
async def get_audio(filename: str):
|
||||||
|
"""Get audio file from S3."""
|
||||||
|
audio_path = storage.get_audio_file(filename)
|
||||||
|
if not audio_path or not audio_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Audio not found")
|
||||||
|
|
||||||
|
suffix = audio_path.suffix.lower()
|
||||||
|
media_types = {
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
".wav": "audio/wav",
|
||||||
|
".ogg": "audio/ogg",
|
||||||
|
".m4a": "audio/mp4",
|
||||||
|
}
|
||||||
|
media_type = media_types.get(suffix, "audio/mpeg")
|
||||||
|
|
||||||
|
return FileResponse(path=str(audio_path), media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/media/transitions/{filename}")
|
||||||
|
async def get_transition(filename: str):
|
||||||
|
"""Get transition sound from S3."""
|
||||||
|
transition_path = storage.get_transition_file(filename)
|
||||||
|
if not transition_path or not transition_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Transition sound not found")
|
||||||
|
|
||||||
|
suffix = transition_path.suffix.lower()
|
||||||
|
media_types = {
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
".wav": "audio/wav",
|
||||||
|
".ogg": "audio/ogg",
|
||||||
|
}
|
||||||
|
media_type = media_types.get(suffix, "audio/mpeg")
|
||||||
|
|
||||||
|
return FileResponse(path=str(transition_path), media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
|
def run_generation(request: GenerateRequest, task_id: str) -> Path:
|
||||||
|
"""Run video generation in thread pool."""
|
||||||
|
generation_status[task_id] = {"status": "processing", "progress": 0, "message": "Starting generation..."}
|
||||||
|
|
||||||
|
try:
|
||||||
|
generator = VideoGenerator(request)
|
||||||
|
generation_status[task_id]["message"] = "Generating video..."
|
||||||
|
generation_status[task_id]["progress"] = 50
|
||||||
|
|
||||||
|
output_path = generator.generate()
|
||||||
|
|
||||||
|
generation_status[task_id] = {
|
||||||
|
"status": "completed",
|
||||||
|
"progress": 100,
|
||||||
|
"message": "Video generated successfully",
|
||||||
|
"output_path": str(output_path),
|
||||||
|
"filename": output_path.name,
|
||||||
|
}
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
generation_status[task_id] = {
|
||||||
|
"status": "failed",
|
||||||
|
"progress": 0,
|
||||||
|
"message": str(e),
|
||||||
|
}
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/generate", response_model=GenerateResponse)
|
||||||
|
async def generate_video(request: GenerateRequest):
|
||||||
|
"""Generate a quiz video synchronously."""
|
||||||
|
# Validate content exists in S3
|
||||||
|
for q in request.questions:
|
||||||
|
if not storage.file_exists(f"audio/{q.opening_file}"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Audio file not found: {q.opening_file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check FFmpeg
|
||||||
|
if not check_ffmpeg():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="FFmpeg is not available"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run generation in thread pool to not block event loop
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
task_id = f"task_{id(request)}"
|
||||||
|
output_path = await loop.run_in_executor(
|
||||||
|
executor,
|
||||||
|
run_generation,
|
||||||
|
request,
|
||||||
|
task_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update last_usage for all used openings
|
||||||
|
async with async_session_maker() as db:
|
||||||
|
for q in request.questions:
|
||||||
|
await db.execute(
|
||||||
|
update(Opening)
|
||||||
|
.where(Opening.audio_file == q.opening_file)
|
||||||
|
.values(last_usage=datetime.now(timezone.utc))
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return GenerateResponse(
|
||||||
|
success=True,
|
||||||
|
video_url=f"/videos/{output_path.name}",
|
||||||
|
filename=output_path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return GenerateResponse(
|
||||||
|
success=False,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/download/{filename}")
|
||||||
|
async def download_video(filename: str):
|
||||||
|
"""Download a generated video."""
|
||||||
|
video_path = settings.output_path / filename
|
||||||
|
if not video_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Video not found")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=str(video_path),
|
||||||
|
filename=filename,
|
||||||
|
media_type="video/mp4",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/videos/{filename}")
|
||||||
|
async def delete_video(filename: str):
|
||||||
|
"""Delete a generated video."""
|
||||||
|
video_path = settings.output_path / filename
|
||||||
|
if not video_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Video not found")
|
||||||
|
|
||||||
|
video_path.unlink()
|
||||||
|
return {"message": "Video deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/videos-list")
|
||||||
|
async def list_videos():
|
||||||
|
"""List all generated videos."""
|
||||||
|
videos = []
|
||||||
|
for f in settings.output_path.glob("*.mp4"):
|
||||||
|
videos.append({
|
||||||
|
"filename": f.name,
|
||||||
|
"size": f.stat().st_size,
|
||||||
|
"url": f"/videos/{f.name}",
|
||||||
|
"download_url": f"/download/{f.name}",
|
||||||
|
})
|
||||||
|
return {"videos": sorted(videos, key=lambda x: x["filename"], reverse=True)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/cache/clear")
|
||||||
|
async def clear_cache():
|
||||||
|
"""Clear the S3 file cache."""
|
||||||
|
storage.clear_cache()
|
||||||
|
return {"message": "Cache cleared successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Media Upload Endpoints ==============
|
||||||
|
|
||||||
|
@app.post("/upload/audio")
|
||||||
|
async def upload_audio(files: List[UploadFile] = File(...)):
|
||||||
|
"""Upload audio files to S3."""
|
||||||
|
results = []
|
||||||
|
for file in files:
|
||||||
|
if not file.filename.lower().endswith((".mp3", ".wav", ".ogg", ".m4a")):
|
||||||
|
results.append({"filename": file.filename, "success": False, "error": "Invalid file type"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
success = storage.upload_audio(file.filename, content)
|
||||||
|
results.append({"filename": file.filename, "success": success})
|
||||||
|
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/upload/backgrounds")
|
||||||
|
async def upload_backgrounds(files: List[UploadFile] = File(...)):
|
||||||
|
"""Upload background videos to S3."""
|
||||||
|
results = []
|
||||||
|
for file in files:
|
||||||
|
if not file.filename.lower().endswith((".mp4", ".mov", ".avi")):
|
||||||
|
results.append({"filename": file.filename, "success": False, "error": "Invalid file type"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
success = storage.upload_background(file.filename, content)
|
||||||
|
results.append({"filename": file.filename, "success": success})
|
||||||
|
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/upload/posters")
|
||||||
|
async def upload_posters(files: List[UploadFile] = File(...)):
|
||||||
|
"""Upload poster images to S3."""
|
||||||
|
results = []
|
||||||
|
for file in files:
|
||||||
|
if not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")):
|
||||||
|
results.append({"filename": file.filename, "success": False, "error": "Invalid file type"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
success = storage.upload_poster(file.filename, content)
|
||||||
|
results.append({"filename": file.filename, "success": success})
|
||||||
|
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/upload/transitions")
|
||||||
|
async def upload_transitions(files: List[UploadFile] = File(...)):
|
||||||
|
"""Upload transition sounds to S3."""
|
||||||
|
results = []
|
||||||
|
for file in files:
|
||||||
|
if not file.filename.lower().endswith((".mp3", ".wav", ".ogg")):
|
||||||
|
results.append({"filename": file.filename, "success": False, "error": "Invalid file type"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
success = storage.upload_transition(file.filename, content)
|
||||||
|
results.append({"filename": file.filename, "success": success})
|
||||||
|
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Media Delete Endpoints ==============
|
||||||
|
|
||||||
|
@app.delete("/media/audio/{filename}")
|
||||||
|
async def delete_audio(filename: str):
|
||||||
|
"""Delete audio file from S3."""
|
||||||
|
success = storage.delete_audio(filename)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found or delete failed")
|
||||||
|
return {"message": "File deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/media/backgrounds/{filename}")
|
||||||
|
async def delete_background(filename: str):
|
||||||
|
"""Delete background video from S3."""
|
||||||
|
success = storage.delete_background(filename)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found or delete failed")
|
||||||
|
return {"message": "File deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/media/posters/{filename}")
|
||||||
|
async def delete_poster(filename: str):
|
||||||
|
"""Delete poster image from S3."""
|
||||||
|
success = storage.delete_poster(filename)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found or delete failed")
|
||||||
|
return {"message": "File deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/media/transitions/{filename}")
|
||||||
|
async def delete_transition(filename: str):
|
||||||
|
"""Delete transition sound from S3."""
|
||||||
|
success = storage.delete_transition(filename)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found or delete failed")
|
||||||
|
return {"message": "File deleted successfully"}
|
||||||
51
backend/app/models.py
Normal file
51
backend/app/models.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class VideoMode(str, Enum):
|
||||||
|
SHORTS = "shorts"
|
||||||
|
FULL = "full"
|
||||||
|
|
||||||
|
|
||||||
|
class Difficulty(str, Enum):
|
||||||
|
EASY = "easy"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HARD = "hard"
|
||||||
|
|
||||||
|
|
||||||
|
class QuizItem(BaseModel):
|
||||||
|
anime: str = Field(..., description="Anime title")
|
||||||
|
opening_file: str = Field(..., description="Filename of the opening audio")
|
||||||
|
start_time: float = Field(0, description="Start time in seconds for audio clip")
|
||||||
|
difficulty: Difficulty = Difficulty.MEDIUM
|
||||||
|
poster: Optional[str] = Field(None, description="Poster image filename")
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
mode: VideoMode = VideoMode.SHORTS
|
||||||
|
questions: list[QuizItem] = Field(..., min_length=1, max_length=20)
|
||||||
|
audio_duration: float = Field(3.0, ge=1.0, le=10.0, description="Audio clip duration in seconds")
|
||||||
|
background_video: Optional[str] = Field(None, description="Background video filename")
|
||||||
|
transition_sound: Optional[str] = Field(None, description="Transition sound filename")
|
||||||
|
continue_audio: bool = Field(False, description="Continue audio from where question ended instead of restarting")
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
video_url: Optional[str] = None
|
||||||
|
filename: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
progress: float
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class ContentListResponse(BaseModel):
|
||||||
|
audio_files: list[str]
|
||||||
|
background_videos: list[str]
|
||||||
|
posters: list[str]
|
||||||
|
transition_sounds: list[str]
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
134
backend/app/routers/backgrounds.py
Normal file
134
backend/app/routers/backgrounds.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..db_models import Background, Difficulty
|
||||||
|
from ..schemas import (
|
||||||
|
BackgroundCreate,
|
||||||
|
BackgroundUpdate,
|
||||||
|
BackgroundResponse,
|
||||||
|
BackgroundListResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/backgrounds", tags=["backgrounds"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=BackgroundListResponse)
|
||||||
|
async def list_backgrounds(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
difficulty: Optional[Difficulty] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all backgrounds with pagination and filtering."""
|
||||||
|
query = select(Background)
|
||||||
|
|
||||||
|
if difficulty:
|
||||||
|
query = query.where(Background.difficulty == difficulty)
|
||||||
|
if search:
|
||||||
|
query = query.where(Background.name.ilike(f"%{search}%"))
|
||||||
|
|
||||||
|
# Count total
|
||||||
|
count_query = select(func.count(Background.id))
|
||||||
|
if difficulty:
|
||||||
|
count_query = count_query.where(Background.difficulty == difficulty)
|
||||||
|
if search:
|
||||||
|
count_query = count_query.where(Background.name.ilike(f"%{search}%"))
|
||||||
|
total_result = await db.execute(count_query)
|
||||||
|
total = total_result.scalar()
|
||||||
|
|
||||||
|
# Get items
|
||||||
|
query = query.order_by(Background.difficulty, Background.name)
|
||||||
|
query = query.offset(skip).limit(limit)
|
||||||
|
result = await db.execute(query)
|
||||||
|
backgrounds = result.scalars().all()
|
||||||
|
|
||||||
|
return BackgroundListResponse(backgrounds=backgrounds, total=total)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{background_id}", response_model=BackgroundResponse)
|
||||||
|
async def get_background(background_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Get a single background by ID."""
|
||||||
|
query = select(Background).where(Background.id == background_id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
background = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not background:
|
||||||
|
raise HTTPException(status_code=404, detail="Background not found")
|
||||||
|
|
||||||
|
return background
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=BackgroundResponse, status_code=201)
|
||||||
|
async def create_background(data: BackgroundCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Create a new background."""
|
||||||
|
background = Background(
|
||||||
|
name=data.name,
|
||||||
|
video_file=data.video_file,
|
||||||
|
difficulty=data.difficulty,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(background)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(background)
|
||||||
|
|
||||||
|
return background
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{background_id}", response_model=BackgroundResponse)
|
||||||
|
async def update_background(
|
||||||
|
background_id: int,
|
||||||
|
data: BackgroundUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a background."""
|
||||||
|
query = select(Background).where(Background.id == background_id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
background = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not background:
|
||||||
|
raise HTTPException(status_code=404, detail="Background not found")
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
if data.name is not None:
|
||||||
|
background.name = data.name
|
||||||
|
if data.video_file is not None:
|
||||||
|
background.video_file = data.video_file
|
||||||
|
if data.difficulty is not None:
|
||||||
|
background.difficulty = data.difficulty
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(background)
|
||||||
|
|
||||||
|
return background
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{background_id}", status_code=204)
|
||||||
|
async def delete_background(background_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Delete a background."""
|
||||||
|
query = select(Background).where(Background.id == background_id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
background = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not background:
|
||||||
|
raise HTTPException(status_code=404, detail="Background not found")
|
||||||
|
|
||||||
|
await db.delete(background)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/by-difficulty/{difficulty}", response_model=BackgroundListResponse)
|
||||||
|
async def get_backgrounds_by_difficulty(
|
||||||
|
difficulty: Difficulty,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get all backgrounds for a specific difficulty."""
|
||||||
|
query = select(Background).where(Background.difficulty == difficulty)
|
||||||
|
query = query.order_by(Background.name)
|
||||||
|
result = await db.execute(query)
|
||||||
|
backgrounds = result.scalars().all()
|
||||||
|
|
||||||
|
return BackgroundListResponse(backgrounds=backgrounds, total=len(backgrounds))
|
||||||
223
backend/app/routers/openings.py
Normal file
223
backend/app/routers/openings.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..db_models import Opening, OpeningPoster
|
||||||
|
from ..schemas import (
|
||||||
|
OpeningCreate,
|
||||||
|
OpeningUpdate,
|
||||||
|
OpeningResponse,
|
||||||
|
OpeningListResponse,
|
||||||
|
OpeningPosterResponse,
|
||||||
|
AddPosterRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/openings", tags=["openings"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=OpeningListResponse)
|
||||||
|
async def list_openings(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
search: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all openings with pagination and search."""
|
||||||
|
query = select(Opening).options(selectinload(Opening.posters))
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query = query.where(Opening.anime_name.ilike(f"%{search}%"))
|
||||||
|
|
||||||
|
# Count total
|
||||||
|
count_query = select(func.count(Opening.id))
|
||||||
|
if search:
|
||||||
|
count_query = count_query.where(Opening.anime_name.ilike(f"%{search}%"))
|
||||||
|
total_result = await db.execute(count_query)
|
||||||
|
total = total_result.scalar()
|
||||||
|
|
||||||
|
# Get items
|
||||||
|
query = query.order_by(Opening.anime_name, Opening.op_number)
|
||||||
|
query = query.offset(skip).limit(limit)
|
||||||
|
result = await db.execute(query)
|
||||||
|
openings = result.scalars().all()
|
||||||
|
|
||||||
|
return OpeningListResponse(openings=openings, total=total)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{opening_id}", response_model=OpeningResponse)
|
||||||
|
async def get_opening(opening_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Get a single opening by ID."""
|
||||||
|
query = select(Opening).options(selectinload(Opening.posters)).where(Opening.id == opening_id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
opening = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not opening:
|
||||||
|
raise HTTPException(status_code=404, detail="Opening not found")
|
||||||
|
|
||||||
|
return opening
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=OpeningResponse, status_code=201)
|
||||||
|
async def create_opening(data: OpeningCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Create a new opening."""
|
||||||
|
opening = Opening(
|
||||||
|
anime_name=data.anime_name,
|
||||||
|
op_number=data.op_number,
|
||||||
|
song_name=data.song_name,
|
||||||
|
audio_file=data.audio_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add posters
|
||||||
|
for i, poster_file in enumerate(data.poster_files):
|
||||||
|
poster = OpeningPoster(
|
||||||
|
poster_file=poster_file,
|
||||||
|
is_default=(i == 0) # First poster is default
|
||||||
|
)
|
||||||
|
opening.posters.append(poster)
|
||||||
|
|
||||||
|
db.add(opening)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(opening)
|
||||||
|
|
||||||
|
# Reload with posters
|
||||||
|
query = select(Opening).options(selectinload(Opening.posters)).where(Opening.id == opening.id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{opening_id}", response_model=OpeningResponse)
|
||||||
|
async def update_opening(
|
||||||
|
opening_id: int,
|
||||||
|
data: OpeningUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update an opening."""
|
||||||
|
query = select(Opening).options(selectinload(Opening.posters)).where(Opening.id == opening_id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
opening = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not opening:
|
||||||
|
raise HTTPException(status_code=404, detail="Opening not found")
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
if data.anime_name is not None:
|
||||||
|
opening.anime_name = data.anime_name
|
||||||
|
if data.op_number is not None:
|
||||||
|
opening.op_number = data.op_number
|
||||||
|
if data.song_name is not None:
|
||||||
|
opening.song_name = data.song_name
|
||||||
|
if data.audio_file is not None:
|
||||||
|
opening.audio_file = data.audio_file
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(opening)
|
||||||
|
|
||||||
|
return opening
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{opening_id}", status_code=204)
|
||||||
|
async def delete_opening(opening_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Delete an opening."""
|
||||||
|
query = select(Opening).where(Opening.id == opening_id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
opening = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not opening:
|
||||||
|
raise HTTPException(status_code=404, detail="Opening not found")
|
||||||
|
|
||||||
|
await db.delete(opening)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Poster Management ==============
|
||||||
|
|
||||||
|
@router.post("/{opening_id}/posters", response_model=OpeningPosterResponse, status_code=201)
|
||||||
|
async def add_poster(
|
||||||
|
opening_id: int,
|
||||||
|
data: AddPosterRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Add a poster to an opening."""
|
||||||
|
query = select(Opening).where(Opening.id == opening_id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
opening = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not opening:
|
||||||
|
raise HTTPException(status_code=404, detail="Opening not found")
|
||||||
|
|
||||||
|
poster = OpeningPoster(
|
||||||
|
opening_id=opening_id,
|
||||||
|
poster_file=data.poster_file,
|
||||||
|
is_default=data.is_default,
|
||||||
|
)
|
||||||
|
|
||||||
|
# If this is set as default, unset others
|
||||||
|
if data.is_default:
|
||||||
|
await db.execute(
|
||||||
|
select(OpeningPoster)
|
||||||
|
.where(OpeningPoster.opening_id == opening_id)
|
||||||
|
)
|
||||||
|
posters_result = await db.execute(
|
||||||
|
select(OpeningPoster).where(OpeningPoster.opening_id == opening_id)
|
||||||
|
)
|
||||||
|
for p in posters_result.scalars():
|
||||||
|
p.is_default = False
|
||||||
|
|
||||||
|
db.add(poster)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(poster)
|
||||||
|
|
||||||
|
return poster
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{opening_id}/posters/{poster_id}", status_code=204)
|
||||||
|
async def remove_poster(
|
||||||
|
opening_id: int,
|
||||||
|
poster_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Remove a poster from an opening."""
|
||||||
|
query = select(OpeningPoster).where(
|
||||||
|
OpeningPoster.id == poster_id,
|
||||||
|
OpeningPoster.opening_id == opening_id,
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
poster = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not poster:
|
||||||
|
raise HTTPException(status_code=404, detail="Poster not found")
|
||||||
|
|
||||||
|
await db.delete(poster)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{opening_id}/posters/{poster_id}/set-default", response_model=OpeningPosterResponse)
|
||||||
|
async def set_default_poster(
|
||||||
|
opening_id: int,
|
||||||
|
poster_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Set a poster as the default for an opening."""
|
||||||
|
# Get all posters for this opening
|
||||||
|
query = select(OpeningPoster).where(OpeningPoster.opening_id == opening_id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
posters = result.scalars().all()
|
||||||
|
|
||||||
|
target_poster = None
|
||||||
|
for poster in posters:
|
||||||
|
if poster.id == poster_id:
|
||||||
|
poster.is_default = True
|
||||||
|
target_poster = poster
|
||||||
|
else:
|
||||||
|
poster.is_default = False
|
||||||
|
|
||||||
|
if not target_poster:
|
||||||
|
raise HTTPException(status_code=404, detail="Poster not found")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(target_poster)
|
||||||
|
|
||||||
|
return target_poster
|
||||||
102
backend/app/schemas.py
Normal file
102
backend/app/schemas.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .db_models import Difficulty
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Opening Schemas ==============
|
||||||
|
|
||||||
|
class OpeningPosterBase(BaseModel):
|
||||||
|
poster_file: str
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class OpeningPosterCreate(OpeningPosterBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OpeningPosterResponse(OpeningPosterBase):
|
||||||
|
id: int
|
||||||
|
opening_id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class OpeningBase(BaseModel):
|
||||||
|
anime_name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
op_number: str = Field(..., min_length=1, max_length=20)
|
||||||
|
song_name: Optional[str] = Field(None, max_length=255)
|
||||||
|
audio_file: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class OpeningCreate(OpeningBase):
|
||||||
|
poster_files: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class OpeningUpdate(BaseModel):
|
||||||
|
anime_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
op_number: Optional[str] = Field(None, min_length=1, max_length=20)
|
||||||
|
song_name: Optional[str] = Field(None, max_length=255)
|
||||||
|
audio_file: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OpeningResponse(OpeningBase):
|
||||||
|
id: int
|
||||||
|
last_usage: Optional[datetime] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
posters: List[OpeningPosterResponse] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class OpeningListResponse(BaseModel):
|
||||||
|
openings: List[OpeningResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Background Schemas ==============
|
||||||
|
|
||||||
|
class BackgroundBase(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
video_file: str = Field(..., min_length=1)
|
||||||
|
difficulty: Difficulty = Difficulty.MEDIUM
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundCreate(BackgroundBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundUpdate(BaseModel):
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
video_file: Optional[str] = None
|
||||||
|
difficulty: Optional[Difficulty] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundResponse(BackgroundBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundListResponse(BaseModel):
|
||||||
|
backgrounds: List[BackgroundResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Poster Management ==============
|
||||||
|
|
||||||
|
class AddPosterRequest(BaseModel):
|
||||||
|
poster_file: str
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SetDefaultPosterRequest(BaseModel):
|
||||||
|
poster_id: int
|
||||||
224
backend/app/storage.py
Normal file
224
backend/app/storage.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import hashlib
|
||||||
|
import urllib3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config as BotoConfig
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
# Suppress SSL warnings for S3 endpoint
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
|
||||||
|
class S3Storage:
|
||||||
|
"""S3-compatible storage service for media files."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=settings.s3_endpoint,
|
||||||
|
aws_access_key_id=settings.s3_access_key,
|
||||||
|
aws_secret_access_key=settings.s3_secret_key,
|
||||||
|
region_name=settings.s3_region,
|
||||||
|
config=BotoConfig(signature_version="s3v4"),
|
||||||
|
verify=False, # Disable SSL verification for FirstVDS S3
|
||||||
|
)
|
||||||
|
self.bucket = settings.s3_bucket
|
||||||
|
self.cache_path = settings.cache_path
|
||||||
|
self._ensure_bucket_exists()
|
||||||
|
|
||||||
|
def _ensure_bucket_exists(self):
|
||||||
|
"""Create bucket if it doesn't exist."""
|
||||||
|
try:
|
||||||
|
self.client.head_bucket(Bucket=self.bucket)
|
||||||
|
except ClientError as e:
|
||||||
|
error_code = e.response.get("Error", {}).get("Code")
|
||||||
|
if error_code in ("404", "NoSuchBucket"):
|
||||||
|
try:
|
||||||
|
self.client.create_bucket(Bucket=self.bucket)
|
||||||
|
print(f"Created S3 bucket: {self.bucket}")
|
||||||
|
except ClientError as create_error:
|
||||||
|
print(f"Failed to create bucket {self.bucket}: {create_error}")
|
||||||
|
|
||||||
|
def _get_cache_path(self, key: str) -> Path:
|
||||||
|
"""Get local cache path for a file."""
|
||||||
|
# Use hash of key for cache filename to avoid path issues
|
||||||
|
key_hash = hashlib.md5(key.encode()).hexdigest()[:16]
|
||||||
|
ext = Path(key).suffix
|
||||||
|
return self.cache_path / f"{key_hash}{ext}"
|
||||||
|
|
||||||
|
def list_files(self, prefix: str, extensions: Optional[list[str]] = None) -> list[str]:
|
||||||
|
"""List files in S3 bucket with given prefix."""
|
||||||
|
try:
|
||||||
|
response = self.client.list_objects_v2(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Prefix=prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for obj in response.get("Contents", []):
|
||||||
|
key = obj["Key"]
|
||||||
|
filename = key.replace(prefix, "").lstrip("/")
|
||||||
|
if filename: # Skip the prefix itself
|
||||||
|
if extensions:
|
||||||
|
if any(filename.lower().endswith(ext) for ext in extensions):
|
||||||
|
files.append(filename)
|
||||||
|
else:
|
||||||
|
files.append(filename)
|
||||||
|
|
||||||
|
return sorted(files)
|
||||||
|
|
||||||
|
except ClientError as e:
|
||||||
|
print(f"Error listing S3 files: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def download_file(self, key: str) -> Optional[Path]:
|
||||||
|
"""Download file from S3 to local cache."""
|
||||||
|
cache_file = self._get_cache_path(key)
|
||||||
|
|
||||||
|
# Return cached file if exists
|
||||||
|
if cache_file.exists():
|
||||||
|
return cache_file
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.download_file(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Key=key,
|
||||||
|
Filename=str(cache_file),
|
||||||
|
)
|
||||||
|
return cache_file
|
||||||
|
|
||||||
|
except ClientError as e:
|
||||||
|
print(f"Error downloading {key} from S3: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_audio_file(self, filename: str) -> Optional[Path]:
|
||||||
|
"""Download audio file from S3."""
|
||||||
|
key = f"audio/{filename}"
|
||||||
|
return self.download_file(key)
|
||||||
|
|
||||||
|
def get_background_file(self, filename: str) -> Optional[Path]:
|
||||||
|
"""Download background video from S3."""
|
||||||
|
key = f"backgrounds/{filename}"
|
||||||
|
return self.download_file(key)
|
||||||
|
|
||||||
|
def get_poster_file(self, filename: str) -> Optional[Path]:
|
||||||
|
"""Download poster image from S3."""
|
||||||
|
key = f"posters/{filename}"
|
||||||
|
return self.download_file(key)
|
||||||
|
|
||||||
|
def get_transition_file(self, filename: str) -> Optional[Path]:
|
||||||
|
"""Download transition sound from S3."""
|
||||||
|
key = f"transitions/{filename}"
|
||||||
|
return self.download_file(key)
|
||||||
|
|
||||||
|
def list_audio_files(self) -> list[str]:
|
||||||
|
"""List available audio files."""
|
||||||
|
return self.list_files("audio/", [".mp3", ".wav", ".ogg", ".m4a"])
|
||||||
|
|
||||||
|
def list_background_videos(self) -> list[str]:
|
||||||
|
"""List available background videos."""
|
||||||
|
return self.list_files("backgrounds/", [".mp4", ".mov", ".avi"])
|
||||||
|
|
||||||
|
def list_posters(self) -> list[str]:
|
||||||
|
"""List available poster images."""
|
||||||
|
return self.list_files("posters/", [".jpg", ".jpeg", ".png", ".webp"])
|
||||||
|
|
||||||
|
def list_transition_sounds(self) -> list[str]:
|
||||||
|
"""List available transition sounds."""
|
||||||
|
return self.list_files("transitions/", [".mp3", ".wav", ".ogg"])
|
||||||
|
|
||||||
|
def file_exists(self, key: str) -> bool:
|
||||||
|
"""Check if file exists in S3."""
|
||||||
|
try:
|
||||||
|
self.client.head_object(Bucket=self.bucket, Key=key)
|
||||||
|
return True
|
||||||
|
except ClientError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_cache(self):
|
||||||
|
"""Clear local cache."""
|
||||||
|
for file in self.cache_path.glob("*"):
|
||||||
|
try:
|
||||||
|
file.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def upload_file(self, key: str, file_data: bytes, content_type: str = None) -> bool:
|
||||||
|
"""Upload file to S3."""
|
||||||
|
try:
|
||||||
|
extra_args = {}
|
||||||
|
if content_type:
|
||||||
|
extra_args["ContentType"] = content_type
|
||||||
|
|
||||||
|
self.client.put_object(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Key=key,
|
||||||
|
Body=file_data,
|
||||||
|
**extra_args
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except ClientError as e:
|
||||||
|
print(f"Error uploading {key} to S3: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def upload_audio(self, filename: str, file_data: bytes) -> bool:
|
||||||
|
"""Upload audio file to S3."""
|
||||||
|
content_type = "audio/mpeg" if filename.lower().endswith(".mp3") else "audio/wav"
|
||||||
|
return self.upload_file(f"audio/{filename}", file_data, content_type)
|
||||||
|
|
||||||
|
def upload_background(self, filename: str, file_data: bytes) -> bool:
|
||||||
|
"""Upload background video to S3."""
|
||||||
|
return self.upload_file(f"backgrounds/{filename}", file_data, "video/mp4")
|
||||||
|
|
||||||
|
def upload_poster(self, filename: str, file_data: bytes) -> bool:
|
||||||
|
"""Upload poster image to S3."""
|
||||||
|
ext = filename.lower().split(".")[-1]
|
||||||
|
content_types = {
|
||||||
|
"jpg": "image/jpeg",
|
||||||
|
"jpeg": "image/jpeg",
|
||||||
|
"png": "image/png",
|
||||||
|
"webp": "image/webp"
|
||||||
|
}
|
||||||
|
content_type = content_types.get(ext, "application/octet-stream")
|
||||||
|
return self.upload_file(f"posters/{filename}", file_data, content_type)
|
||||||
|
|
||||||
|
def upload_transition(self, filename: str, file_data: bytes) -> bool:
|
||||||
|
"""Upload transition sound to S3."""
|
||||||
|
return self.upload_file(f"transitions/{filename}", file_data, "audio/mpeg")
|
||||||
|
|
||||||
|
def delete_file(self, key: str) -> bool:
|
||||||
|
"""Delete file from S3."""
|
||||||
|
try:
|
||||||
|
self.client.delete_object(Bucket=self.bucket, Key=key)
|
||||||
|
# Also remove from cache
|
||||||
|
cache_file = self._get_cache_path(key)
|
||||||
|
if cache_file.exists():
|
||||||
|
cache_file.unlink()
|
||||||
|
return True
|
||||||
|
except ClientError as e:
|
||||||
|
print(f"Error deleting {key} from S3: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_audio(self, filename: str) -> bool:
|
||||||
|
"""Delete audio file from S3."""
|
||||||
|
return self.delete_file(f"audio/{filename}")
|
||||||
|
|
||||||
|
def delete_background(self, filename: str) -> bool:
|
||||||
|
"""Delete background video from S3."""
|
||||||
|
return self.delete_file(f"backgrounds/{filename}")
|
||||||
|
|
||||||
|
def delete_poster(self, filename: str) -> bool:
|
||||||
|
"""Delete poster image from S3."""
|
||||||
|
return self.delete_file(f"posters/{filename}")
|
||||||
|
|
||||||
|
def delete_transition(self, filename: str) -> bool:
|
||||||
|
"""Delete transition sound from S3."""
|
||||||
|
return self.delete_file(f"transitions/{filename}")
|
||||||
|
|
||||||
|
|
||||||
|
# Global storage instance
|
||||||
|
storage = S3Storage()
|
||||||
549
backend/app/video_generator.py
Normal file
549
backend/app/video_generator.py
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
import uuid
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .storage import storage
|
||||||
|
from .models import VideoMode, QuizItem, GenerateRequest
|
||||||
|
|
||||||
|
|
||||||
|
class VideoGenerator:
|
||||||
|
"""FFmpeg-based video generator for anime quiz videos."""
|
||||||
|
|
||||||
|
FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||||
|
|
||||||
|
def __init__(self, request: GenerateRequest):
|
||||||
|
self.request = request
|
||||||
|
self.mode = request.mode
|
||||||
|
self.questions = request.questions
|
||||||
|
self.audio_duration = request.audio_duration
|
||||||
|
self.continue_audio = request.continue_audio
|
||||||
|
|
||||||
|
if self.mode == VideoMode.SHORTS:
|
||||||
|
self.width = settings.shorts_width
|
||||||
|
self.height = settings.shorts_height
|
||||||
|
else:
|
||||||
|
self.width = settings.full_width
|
||||||
|
self.height = settings.full_height
|
||||||
|
|
||||||
|
self.fps = 30
|
||||||
|
self.temp_dir = Path(tempfile.mkdtemp(prefix="quiz_"))
|
||||||
|
self.temp_files: list[Path] = []
|
||||||
|
|
||||||
|
def _run_ffmpeg(self, args: list[str], check: bool = True) -> subprocess.CompletedProcess:
|
||||||
|
"""Run FFmpeg command with given arguments."""
|
||||||
|
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"] + args
|
||||||
|
return subprocess.run(cmd, capture_output=True, text=True, check=check)
|
||||||
|
|
||||||
|
def _get_temp_path(self, suffix: str = ".mp4") -> Path:
|
||||||
|
"""Generate a temporary file path."""
|
||||||
|
path = self.temp_dir / f"temp_{uuid.uuid4().hex[:8]}{suffix}"
|
||||||
|
self.temp_files.append(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _escape_text(self, text: str) -> str:
|
||||||
|
"""Escape special characters for FFmpeg drawtext filter."""
|
||||||
|
text = text.replace("\\", "\\\\")
|
||||||
|
text = text.replace("'", "'\\''")
|
||||||
|
text = text.replace(":", "\\:")
|
||||||
|
text = text.replace("%", "\\%")
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _get_background_path(self) -> Path:
|
||||||
|
"""Get background video path from S3 or generate solid color fallback."""
|
||||||
|
if self.request.background_video:
|
||||||
|
bg_path = storage.get_background_file(self.request.background_video)
|
||||||
|
if bg_path and bg_path.exists():
|
||||||
|
return bg_path
|
||||||
|
|
||||||
|
# Get first available background
|
||||||
|
backgrounds = storage.list_background_videos()
|
||||||
|
if backgrounds:
|
||||||
|
bg_path = storage.get_background_file(backgrounds[0])
|
||||||
|
if bg_path and bg_path.exists():
|
||||||
|
return bg_path
|
||||||
|
|
||||||
|
# Create a solid color fallback background
|
||||||
|
return self._create_solid_background()
|
||||||
|
|
||||||
|
def _create_solid_background(self) -> Path:
|
||||||
|
"""Create a solid color background video as fallback."""
|
||||||
|
output_path = self._get_temp_path(suffix="_bg.mp4")
|
||||||
|
|
||||||
|
# Create 10 second loop of dark gradient background
|
||||||
|
args = [
|
||||||
|
"-f", "lavfi",
|
||||||
|
"-i", f"color=c=0x1a1a2e:s={self.width}x{self.height}:d=10:r={self.fps}",
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "ultrafast",
|
||||||
|
"-crf", "23",
|
||||||
|
str(output_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
result = self._run_ffmpeg(args, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"FFmpeg error creating solid background: {result.stderr}")
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
def _get_difficulty_color(self, difficulty: str) -> str:
|
||||||
|
"""Get color for difficulty badge."""
|
||||||
|
colors = {
|
||||||
|
"easy": "green",
|
||||||
|
"medium": "orange",
|
||||||
|
"hard": "red"
|
||||||
|
}
|
||||||
|
return colors.get(difficulty.lower(), "white")
|
||||||
|
|
||||||
|
def _create_question_scene(self, question: QuizItem, question_num: int) -> Path:
|
||||||
|
"""Create the question scene with audio and countdown."""
|
||||||
|
output_path = self._get_temp_path()
|
||||||
|
scene_duration = self.audio_duration + settings.audio_buffer
|
||||||
|
|
||||||
|
bg_path = self._get_background_path()
|
||||||
|
audio_path = storage.get_audio_file(question.opening_file)
|
||||||
|
|
||||||
|
if not audio_path:
|
||||||
|
raise RuntimeError(f"Audio file not found: {question.opening_file}")
|
||||||
|
|
||||||
|
# Font sizes based on mode
|
||||||
|
title_fontsize = 72 if self.mode == VideoMode.SHORTS else 56
|
||||||
|
diff_fontsize = 56 if self.mode == VideoMode.SHORTS else 42
|
||||||
|
countdown_fontsize = 120 if self.mode == VideoMode.SHORTS else 80
|
||||||
|
|
||||||
|
# Escape texts
|
||||||
|
question_text = self._escape_text(f"#{question_num}")
|
||||||
|
subtitle_text = self._escape_text("Guess the Anime Opening")
|
||||||
|
difficulty_text = self._escape_text(question.difficulty.upper())
|
||||||
|
diff_color = self._get_difficulty_color(question.difficulty)
|
||||||
|
|
||||||
|
# Calculate positions
|
||||||
|
title_y = int(self.height * 0.12)
|
||||||
|
subtitle_y = int(self.height * 0.20)
|
||||||
|
diff_y = int(self.height * 0.35)
|
||||||
|
countdown_y = int(self.height * 0.70)
|
||||||
|
|
||||||
|
# Build video filter
|
||||||
|
video_filter = f"""
|
||||||
|
[0:v]scale={self.width}:{self.height}:force_original_aspect_ratio=increase,
|
||||||
|
crop={self.width}:{self.height},
|
||||||
|
setsar=1,
|
||||||
|
fps={self.fps}[bg];
|
||||||
|
[bg]drawtext=fontfile={self.FONT_PATH}:text='{question_text}':fontsize={title_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={title_y},
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{subtitle_text}':fontsize={title_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={subtitle_y},
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{difficulty_text}':fontsize={diff_fontsize}:fontcolor={diff_color}:borderw=2:bordercolor=black:x=(w-tw)/2:y={diff_y},
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='%{{eif\\:{int(self.audio_duration)}-floor(t)\\:d}}':fontsize={countdown_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={countdown_y}:enable='lt(t,{int(self.audio_duration)})'[v]
|
||||||
|
""".replace("\n", "").strip()
|
||||||
|
|
||||||
|
# Build audio filter with fade in and optional fade out
|
||||||
|
audio_fade_out_start = scene_duration - settings.audio_fade_duration
|
||||||
|
if self.continue_audio:
|
||||||
|
audio_filter = f"[1:a]afade=t=in:d={settings.audio_fade_duration}[a]"
|
||||||
|
else:
|
||||||
|
audio_filter = f"[1:a]afade=t=in:d={settings.audio_fade_duration},afade=t=out:st={audio_fade_out_start}:d={settings.audio_fade_duration}[a]"
|
||||||
|
|
||||||
|
# Build FFmpeg command
|
||||||
|
args = [
|
||||||
|
"-stream_loop", "-1",
|
||||||
|
"-i", str(bg_path),
|
||||||
|
"-ss", str(question.start_time),
|
||||||
|
"-t", str(scene_duration),
|
||||||
|
"-i", str(audio_path),
|
||||||
|
"-filter_complex", f"{video_filter};{audio_filter}",
|
||||||
|
"-map", "[v]",
|
||||||
|
"-map", "[a]",
|
||||||
|
"-t", str(scene_duration),
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "medium",
|
||||||
|
"-crf", "23",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", "192k",
|
||||||
|
str(output_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
result = self._run_ffmpeg(args, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"FFmpeg error in question scene: {result.stderr}")
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
def _get_transition_sound_path(self) -> Optional[Path]:
|
||||||
|
"""Get transition sound path from S3."""
|
||||||
|
if self.request.transition_sound:
|
||||||
|
return storage.get_transition_file(self.request.transition_sound)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _create_answer_scene(self, question: QuizItem) -> Path:
|
||||||
|
"""Create the answer reveal scene with continuing audio."""
|
||||||
|
output_path = self._get_temp_path()
|
||||||
|
duration = settings.answer_duration
|
||||||
|
|
||||||
|
bg_path = self._get_background_path()
|
||||||
|
audio_path = storage.get_audio_file(question.opening_file)
|
||||||
|
transition_path = self._get_transition_sound_path()
|
||||||
|
|
||||||
|
if not audio_path:
|
||||||
|
raise RuntimeError(f"Audio file not found: {question.opening_file}")
|
||||||
|
|
||||||
|
# Calculate audio start position based on continue_audio setting
|
||||||
|
if self.continue_audio:
|
||||||
|
question_scene_duration = self.audio_duration + settings.audio_buffer
|
||||||
|
audio_start = question.start_time + question_scene_duration
|
||||||
|
else:
|
||||||
|
audio_start = question.start_time
|
||||||
|
audio_fade_out_start = duration - settings.audio_fade_duration
|
||||||
|
|
||||||
|
# Font sizes based on mode
|
||||||
|
answer_fontsize = 64 if self.mode == VideoMode.SHORTS else 48
|
||||||
|
label_fontsize = 48 if self.mode == VideoMode.SHORTS else 36
|
||||||
|
|
||||||
|
# Escape texts
|
||||||
|
label_text = self._escape_text("Anime:")
|
||||||
|
anime_text = self._escape_text(question.anime)
|
||||||
|
|
||||||
|
# Calculate positions
|
||||||
|
label_y = int(self.height * 0.25)
|
||||||
|
anime_y = int(self.height * 0.32)
|
||||||
|
|
||||||
|
# Check for poster from S3
|
||||||
|
poster_path = None
|
||||||
|
if question.poster:
|
||||||
|
poster_path = storage.get_poster_file(question.poster)
|
||||||
|
|
||||||
|
# Build audio filter - no fade in if continuing from question scene
|
||||||
|
if self.continue_audio:
|
||||||
|
base_audio_filter = f"[1:a]afade=t=out:st={audio_fade_out_start}:d={settings.audio_fade_duration}"
|
||||||
|
else:
|
||||||
|
base_audio_filter = f"[1:a]afade=t=in:d={settings.audio_fade_duration},afade=t=out:st={audio_fade_out_start}:d={settings.audio_fade_duration}"
|
||||||
|
|
||||||
|
# Build inputs and audio filter based on whether we have transition sound
|
||||||
|
if poster_path:
|
||||||
|
if transition_path:
|
||||||
|
audio_filter = f"{base_audio_filter}[music];[2:a]anull[sfx];[music][sfx]amix=inputs=2:duration=longest[a]"
|
||||||
|
inputs = [
|
||||||
|
"-loop", "1", "-i", str(poster_path),
|
||||||
|
"-ss", str(audio_start), "-t", str(duration), "-i", str(audio_path),
|
||||||
|
"-i", str(transition_path),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
audio_filter = f"{base_audio_filter}[a]"
|
||||||
|
inputs = [
|
||||||
|
"-loop", "1", "-i", str(poster_path),
|
||||||
|
"-ss", str(audio_start), "-t", str(duration), "-i", str(audio_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
video_filter = f"""
|
||||||
|
[0:v]scale={self.width}:{self.height}:force_original_aspect_ratio=increase,
|
||||||
|
crop={self.width}:{self.height},
|
||||||
|
setsar=1,
|
||||||
|
fps={self.fps},
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{label_text}':fontsize={label_fontsize}:fontcolor=cyan:borderw=2:bordercolor=black:x=(w-tw)/2:y={label_y},
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{anime_text}':fontsize={answer_fontsize}:fontcolor=cyan:borderw=3:bordercolor=black:x=(w-tw)/2:y={anime_y},
|
||||||
|
fade=t=in:d=0.3[v];
|
||||||
|
{audio_filter}
|
||||||
|
""".replace("\n", "").strip()
|
||||||
|
|
||||||
|
else:
|
||||||
|
if transition_path:
|
||||||
|
audio_filter = f"{base_audio_filter}[music];[2:a]anull[sfx];[music][sfx]amix=inputs=2:duration=longest[a]"
|
||||||
|
inputs = [
|
||||||
|
"-stream_loop", "-1", "-i", str(bg_path),
|
||||||
|
"-ss", str(audio_start), "-t", str(duration), "-i", str(audio_path),
|
||||||
|
"-i", str(transition_path),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
audio_filter = f"{base_audio_filter}[a]"
|
||||||
|
inputs = [
|
||||||
|
"-stream_loop", "-1", "-i", str(bg_path),
|
||||||
|
"-ss", str(audio_start), "-t", str(duration), "-i", str(audio_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
video_filter = f"""
|
||||||
|
[0:v]scale={self.width}:{self.height}:force_original_aspect_ratio=increase,
|
||||||
|
crop={self.width}:{self.height},
|
||||||
|
setsar=1,
|
||||||
|
fps={self.fps},
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{label_text}':fontsize={label_fontsize}:fontcolor=cyan:borderw=2:bordercolor=black:x=(w-tw)/2:y={label_y},
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{anime_text}':fontsize={answer_fontsize}:fontcolor=cyan:borderw=3:bordercolor=black:x=(w-tw)/2:y={anime_y},
|
||||||
|
fade=t=in:d=0.3[v];
|
||||||
|
{audio_filter}
|
||||||
|
""".replace("\n", "").strip()
|
||||||
|
|
||||||
|
args = inputs + [
|
||||||
|
"-filter_complex", video_filter,
|
||||||
|
"-map", "[v]",
|
||||||
|
"-map", "[a]",
|
||||||
|
"-t", str(duration),
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "medium",
|
||||||
|
"-crf", "23",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", "192k",
|
||||||
|
str(output_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
result = self._run_ffmpeg(args, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"FFmpeg error in answer scene: {result.stderr}")
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
def _create_combined_scene(self, question: QuizItem, question_num: int) -> Path:
|
||||||
|
"""Create combined question + answer scene with continuous audio."""
|
||||||
|
output_path = self._get_temp_path()
|
||||||
|
|
||||||
|
question_duration = self.audio_duration + settings.audio_buffer
|
||||||
|
answer_duration = settings.answer_duration
|
||||||
|
total_duration = question_duration + answer_duration
|
||||||
|
|
||||||
|
bg_path = self._get_background_path()
|
||||||
|
audio_path = storage.get_audio_file(question.opening_file)
|
||||||
|
|
||||||
|
if not audio_path:
|
||||||
|
raise RuntimeError(f"Audio file not found: {question.opening_file}")
|
||||||
|
|
||||||
|
# Font sizes based on mode
|
||||||
|
title_fontsize = 72 if self.mode == VideoMode.SHORTS else 56
|
||||||
|
diff_fontsize = 56 if self.mode == VideoMode.SHORTS else 42
|
||||||
|
countdown_fontsize = 120 if self.mode == VideoMode.SHORTS else 80
|
||||||
|
answer_fontsize = 64 if self.mode == VideoMode.SHORTS else 48
|
||||||
|
label_fontsize = 48 if self.mode == VideoMode.SHORTS else 36
|
||||||
|
|
||||||
|
# Escape texts
|
||||||
|
question_text = self._escape_text(f"#{question_num}")
|
||||||
|
subtitle_text = self._escape_text("Guess the Anime Opening")
|
||||||
|
difficulty_text = self._escape_text(question.difficulty.upper())
|
||||||
|
diff_color = self._get_difficulty_color(question.difficulty)
|
||||||
|
label_text = self._escape_text("Anime:")
|
||||||
|
anime_text = self._escape_text(question.anime)
|
||||||
|
|
||||||
|
# Calculate positions
|
||||||
|
title_y = int(self.height * 0.12)
|
||||||
|
subtitle_y = int(self.height * 0.20)
|
||||||
|
diff_y = int(self.height * 0.35)
|
||||||
|
countdown_y = int(self.height * 0.70)
|
||||||
|
label_y = int(self.height * 0.25)
|
||||||
|
anime_y = int(self.height * 0.32)
|
||||||
|
|
||||||
|
# Check for poster
|
||||||
|
poster_path = None
|
||||||
|
if question.poster:
|
||||||
|
poster_path = storage.get_poster_file(question.poster)
|
||||||
|
|
||||||
|
# Audio filter - fade in at start, fade out at end
|
||||||
|
audio_fade_out_start = total_duration - settings.audio_fade_duration
|
||||||
|
audio_filter = f"[a_in]afade=t=in:d={settings.audio_fade_duration},afade=t=out:st={audio_fade_out_start}:d={settings.audio_fade_duration}[a]"
|
||||||
|
|
||||||
|
if poster_path and poster_path.exists():
|
||||||
|
# Build filter with poster for answer phase
|
||||||
|
# Question phase: show background with countdown (0 to question_duration)
|
||||||
|
# Answer phase: show poster with anime title (question_duration to total_duration)
|
||||||
|
|
||||||
|
video_filter = f"""
|
||||||
|
[0:v]scale={self.width}:{self.height}:force_original_aspect_ratio=increase,crop={self.width}:{self.height},setsar=1,fps={self.fps},
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{question_text}':fontsize={title_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={title_y}:enable='lt(t,{question_duration})',
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{subtitle_text}':fontsize={title_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={subtitle_y}:enable='lt(t,{question_duration})',
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{difficulty_text}':fontsize={diff_fontsize}:fontcolor={diff_color}:borderw=2:bordercolor=black:x=(w-tw)/2:y={diff_y}:enable='lt(t,{question_duration})',
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='%{{eif\\:{int(self.audio_duration)}-floor(t)\\:d}}':fontsize={countdown_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={countdown_y}:enable='lt(t,{int(self.audio_duration)})'[bg_out];
|
||||||
|
[2:v]scale={self.width}:{self.height}:force_original_aspect_ratio=increase,crop={self.width}:{self.height},setsar=1,fps={self.fps},
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{label_text}':fontsize={label_fontsize}:fontcolor=cyan:borderw=2:bordercolor=black:x=(w-tw)/2:y={label_y},
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{anime_text}':fontsize={answer_fontsize}:fontcolor=cyan:borderw=3:bordercolor=black:x=(w-tw)/2:y={anime_y}[poster_out];
|
||||||
|
[bg_out][poster_out]overlay=enable='gte(t,{question_duration})':shortest=1[v];
|
||||||
|
[1:a]anull[a_in];
|
||||||
|
{audio_filter}
|
||||||
|
""".replace("\n", "").strip()
|
||||||
|
|
||||||
|
args = [
|
||||||
|
"-stream_loop", "-1",
|
||||||
|
"-i", str(bg_path),
|
||||||
|
"-ss", str(question.start_time),
|
||||||
|
"-t", str(total_duration),
|
||||||
|
"-i", str(audio_path),
|
||||||
|
"-loop", "1",
|
||||||
|
"-i", str(poster_path),
|
||||||
|
"-filter_complex", video_filter,
|
||||||
|
"-map", "[v]",
|
||||||
|
"-map", "[a]",
|
||||||
|
"-t", str(total_duration),
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "medium",
|
||||||
|
"-crf", "23",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", "192k",
|
||||||
|
str(output_path)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
# No poster - just use background for both phases
|
||||||
|
video_filter = f"""
|
||||||
|
[0:v]scale={self.width}:{self.height}:force_original_aspect_ratio=increase,crop={self.width}:{self.height},setsar=1,fps={self.fps},
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{question_text}':fontsize={title_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={title_y}:enable='lt(t,{question_duration})',
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{subtitle_text}':fontsize={title_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={subtitle_y}:enable='lt(t,{question_duration})',
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{difficulty_text}':fontsize={diff_fontsize}:fontcolor={diff_color}:borderw=2:bordercolor=black:x=(w-tw)/2:y={diff_y}:enable='lt(t,{question_duration})',
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='%{{eif\\:{int(self.audio_duration)}-floor(t)\\:d}}':fontsize={countdown_fontsize}:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={countdown_y}:enable='lt(t,{int(self.audio_duration)})',
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{label_text}':fontsize={label_fontsize}:fontcolor=cyan:borderw=2:bordercolor=black:x=(w-tw)/2:y={label_y}:enable='gte(t,{question_duration})',
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{anime_text}':fontsize={answer_fontsize}:fontcolor=cyan:borderw=3:bordercolor=black:x=(w-tw)/2:y={anime_y}:enable='gte(t,{question_duration})'[v];
|
||||||
|
[1:a]anull[a_in];
|
||||||
|
{audio_filter}
|
||||||
|
""".replace("\n", "").strip()
|
||||||
|
|
||||||
|
args = [
|
||||||
|
"-stream_loop", "-1",
|
||||||
|
"-i", str(bg_path),
|
||||||
|
"-ss", str(question.start_time),
|
||||||
|
"-t", str(total_duration),
|
||||||
|
"-i", str(audio_path),
|
||||||
|
"-filter_complex", video_filter,
|
||||||
|
"-map", "[v]",
|
||||||
|
"-map", "[a]",
|
||||||
|
"-t", str(total_duration),
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "medium",
|
||||||
|
"-crf", "23",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", "192k",
|
||||||
|
str(output_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
result = self._run_ffmpeg(args, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"FFmpeg error in combined scene: {result.stderr}")
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
def _create_final_screen(self) -> Path:
|
||||||
|
"""Create final CTA screen for full video mode."""
|
||||||
|
output_path = self._get_temp_path()
|
||||||
|
duration = settings.final_screen_duration
|
||||||
|
|
||||||
|
bg_path = self._get_background_path()
|
||||||
|
|
||||||
|
# Escape texts
|
||||||
|
title_text = self._escape_text("How many did you guess?")
|
||||||
|
cta_text = self._escape_text("Subscribe for more anime quizzes!")
|
||||||
|
|
||||||
|
# Calculate positions
|
||||||
|
title_y = int(self.height * 0.35)
|
||||||
|
cta_y = int(self.height * 0.55)
|
||||||
|
|
||||||
|
video_filter = f"""
|
||||||
|
[0:v]scale={self.width}:{self.height}:force_original_aspect_ratio=increase,
|
||||||
|
crop={self.width}:{self.height},
|
||||||
|
setsar=1,
|
||||||
|
fps={self.fps},
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{title_text}':fontsize=56:fontcolor=yellow:borderw=3:bordercolor=black:x=(w-tw)/2:y={title_y},
|
||||||
|
drawtext=fontfile={self.FONT_PATH}:text='{cta_text}':fontsize=40:fontcolor=white:borderw=2:bordercolor=black:x=(w-tw)/2:y={cta_y},
|
||||||
|
fade=t=in:d=0.3,
|
||||||
|
fade=t=out:st={duration - 0.5}:d=0.5[v]
|
||||||
|
""".replace("\n", "").strip()
|
||||||
|
|
||||||
|
args = [
|
||||||
|
"-stream_loop", "-1",
|
||||||
|
"-i", str(bg_path),
|
||||||
|
"-filter_complex", video_filter,
|
||||||
|
"-map", "[v]",
|
||||||
|
"-t", str(duration),
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "medium",
|
||||||
|
"-crf", "23",
|
||||||
|
"-an",
|
||||||
|
str(output_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
result = self._run_ffmpeg(args, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"FFmpeg error in final screen: {result.stderr}")
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
def _concatenate_scenes(self, scene_files: list[Path]) -> Path:
|
||||||
|
"""Concatenate all scenes into final video."""
|
||||||
|
output_filename = f"quiz_{self.mode.value}_{uuid.uuid4().hex[:8]}.mp4"
|
||||||
|
output_path = settings.output_path / output_filename
|
||||||
|
|
||||||
|
# Create concat list file
|
||||||
|
concat_file = self._get_temp_path(suffix=".txt")
|
||||||
|
with open(concat_file, "w") as f:
|
||||||
|
for scene in scene_files:
|
||||||
|
f.write(f"file '{scene}'\n")
|
||||||
|
|
||||||
|
# Re-encode for consistent output
|
||||||
|
args = [
|
||||||
|
"-f", "concat",
|
||||||
|
"-safe", "0",
|
||||||
|
"-i", str(concat_file),
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "medium",
|
||||||
|
"-crf", "23",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", "192k",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
str(output_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
result = self._run_ffmpeg(args, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"FFmpeg error in concatenation: {result.stderr}")
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
def _cleanup(self):
|
||||||
|
"""Remove temporary files and directory."""
|
||||||
|
for path in self.temp_files:
|
||||||
|
try:
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if self.temp_dir.exists():
|
||||||
|
self.temp_dir.rmdir()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def generate(self) -> Path:
|
||||||
|
"""Generate the complete quiz video."""
|
||||||
|
try:
|
||||||
|
scene_files = []
|
||||||
|
|
||||||
|
for i, question in enumerate(self.questions, 1):
|
||||||
|
if self.continue_audio:
|
||||||
|
# Create combined scene with continuous audio
|
||||||
|
combined_scene = self._create_combined_scene(question, i)
|
||||||
|
scene_files.append(combined_scene)
|
||||||
|
else:
|
||||||
|
# Question scene
|
||||||
|
q_scene = self._create_question_scene(question, i)
|
||||||
|
scene_files.append(q_scene)
|
||||||
|
|
||||||
|
# Answer scene
|
||||||
|
a_scene = self._create_answer_scene(question)
|
||||||
|
scene_files.append(a_scene)
|
||||||
|
|
||||||
|
# Final screen for full video mode
|
||||||
|
if self.mode == VideoMode.FULL:
|
||||||
|
final = self._create_final_screen()
|
||||||
|
scene_files.append(final)
|
||||||
|
|
||||||
|
# Concatenate all scenes
|
||||||
|
output_path = self._concatenate_scenes(scene_files)
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self._cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def check_ffmpeg() -> bool:
|
||||||
|
"""Check if FFmpeg is available."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ffmpeg", "-version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
10
backend/requirements.txt
Normal file
10
backend/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
python-multipart==0.0.19
|
||||||
|
pydantic==2.10.4
|
||||||
|
pydantic-settings==2.7.0
|
||||||
|
aiofiles==24.1.0
|
||||||
|
boto3==1.35.0
|
||||||
|
sqlalchemy[asyncio]==2.0.36
|
||||||
|
asyncpg==0.30.0
|
||||||
|
greenlet==3.1.1
|
||||||
60
docker-compose.yml
Normal file
60
docker-compose.yml
Normal 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
18
frontend/Dockerfile
Normal 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
15
frontend/index.html
Normal 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
18
frontend/package.json
Normal 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
756
frontend/src/App.vue
Normal 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>
|
||||||
1196
frontend/src/components/AdminPage.vue
Normal file
1196
frontend/src/components/AdminPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
921
frontend/src/components/BackgroundsManager.vue
Normal file
921
frontend/src/components/BackgroundsManager.vue
Normal 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>
|
||||||
2297
frontend/src/components/MediaManager.vue
Normal file
2297
frontend/src/components/MediaManager.vue
Normal file
File diff suppressed because it is too large
Load Diff
1130
frontend/src/components/OpeningsManager.vue
Normal file
1130
frontend/src/components/OpeningsManager.vue
Normal file
File diff suppressed because it is too large
Load Diff
5
frontend/src/main.js
Normal file
5
frontend/src/main.js
Normal 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
1034
frontend/src/style.css
Normal file
File diff suppressed because it is too large
Load Diff
25
frontend/vite.config.js
Normal file
25
frontend/vite.config.js
Normal 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
BIN
image1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
0
media/audio/.gitkeep
Normal file
0
media/audio/.gitkeep
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 10.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 10.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 11.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 11.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 12.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 12.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 13.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 13.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 14.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 14.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 15.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 15.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 16.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 16.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 17.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 17.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 18.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 18.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 19.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 19.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 2.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 2.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 20.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 20.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 21.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 21.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 22.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 22.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 23.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 23.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 24.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 24.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 3.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 3.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 4.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 4.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 5.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 5.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 6.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 6.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 7.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 7.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 8.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 8.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy 9.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy 9.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage copy.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage copy.mp3
Normal file
Binary file not shown.
BIN
media/audio/One Piece_op04_Bon-voyage.mp3
Normal file
BIN
media/audio/One Piece_op04_Bon-voyage.mp3
Normal file
Binary file not shown.
0
media/backgrounds/.gitkeep
Normal file
0
media/backgrounds/.gitkeep
Normal file
BIN
media/backgrounds/63885-508273140_small.mp4
Normal file
BIN
media/backgrounds/63885-508273140_small.mp4
Normal file
Binary file not shown.
0
media/posters/.gitkeep
Normal file
0
media/posters/.gitkeep
Normal file
BIN
media/posters/One Piece.jpg
Normal file
BIN
media/posters/One Piece.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
155
mvpDocker
Normal file
155
mvpDocker
Normal 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
251
mvpTask
Normal 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
|
||||||
|
|
||||||
|
Длительность: 30–60 сек
|
||||||
|
|
||||||
|
Быстрый темп
|
||||||
|
|
||||||
|
Минимум пауз
|
||||||
|
|
||||||
|
Крупный текст
|
||||||
|
|
||||||
|
Подходит для TikTok / YT Shorts
|
||||||
|
|
||||||
|
🔹 Mode 2 — Full Video (YouTube)
|
||||||
|
|
||||||
|
Формат: 16:9
|
||||||
|
|
||||||
|
10–20 вопросов
|
||||||
|
|
||||||
|
Более медленный тайминг
|
||||||
|
|
||||||
|
Финальный экран
|
||||||
|
|
||||||
|
Подходит для обычного 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 (5–10 сек)
|
||||||
|
|
||||||
|
Зацикливание через 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
265
task
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
ПЛАН СОЗДАНИЯ ПРИЛОЖЕНИЯ ДЛЯ АНИМЕ-КВИЗ ВИДЕО
|
||||||
|
🎯 1. Цель приложения
|
||||||
|
|
||||||
|
Создать приложение, которое:
|
||||||
|
|
||||||
|
автоматически генерирует квиз-видео
|
||||||
|
|
||||||
|
минимизирует ручной монтаж
|
||||||
|
|
||||||
|
поддерживает разные форматы угадай-аниме
|
||||||
|
|
||||||
|
готово к массовому выпуску видео
|
||||||
|
|
||||||
|
🧠 2. Основные форматы видео (ядро логики)
|
||||||
|
🔹 Формат 1: «Угадай опенинг»
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
|
||||||
|
1 / 3 / 5 / 10 секунд
|
||||||
|
|
||||||
|
Easy / Medium / Hard
|
||||||
|
|
||||||
|
Количество вопросов (10–100)
|
||||||
|
|
||||||
|
🔹 Формат 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 (2–3 недели):
|
||||||
|
|
||||||
|
✅ Только «Угадай опенинг»
|
||||||
|
✅ 10–20 вопросов
|
||||||
|
✅ FFmpeg генерация
|
||||||
|
✅ Экспорт в MP4
|
||||||
|
|
||||||
|
Версия 2:
|
||||||
|
|
||||||
|
⬆️ Кадры + персонажи
|
||||||
|
⬆️ Шаблоны видео
|
||||||
|
⬆️ Shorts формат
|
||||||
|
|
||||||
|
🧩 11. Возможная монетизация
|
||||||
|
|
||||||
|
Pro-версия (без водяного знака)
|
||||||
|
|
||||||
|
Шаблоны премиум
|
||||||
|
|
||||||
|
Пакеты аниме
|
||||||
|
|
||||||
|
SaaS-подписка
|
||||||
|
|
||||||
|
🧠 Вывод
|
||||||
|
|
||||||
|
Это приложение = фабрика аниме-квиз контента
|
||||||
|
Оно идеально подходит для:
|
||||||
|
|
||||||
|
YouTube-каналов
|
||||||
|
|
||||||
|
TikTok-ферм
|
||||||
|
|
||||||
|
стримеров
|
||||||
|
|
||||||
|
фан-сообществ
|
||||||
|
|
||||||
|
Если хочешь, следующим шагом я могу:
|
||||||
|
|
||||||
|
📐 спроектировать UX-макет
|
||||||
|
|
||||||
|
🧪 составить MVP-ТЗ
|
||||||
|
|
||||||
|
🧑💻 предложить стек под твой уровень
|
||||||
|
|
||||||
|
⚖️ разобрать вопросы авторских прав
|
||||||
Reference in New Issue
Block a user