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