commit c33c5fd6747b483a23e09cc352461f386f98c116 Author: maxim Date: Tue Dec 30 17:37:14 2025 +0300 app v1 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..63e381d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(node --version:*)", + "Bash(mkdir:*)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e5d4cae --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cbcf484 --- /dev/null +++ b/CLAUDE.md @@ -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 +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..edf85ba --- /dev/null +++ b/README.md @@ -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 diff --git a/Review_Egir/image.png b/Review_Egir/image.png new file mode 100644 index 0000000..24a655d Binary files /dev/null and b/Review_Egir/image.png differ diff --git a/Review_Egir/review b/Review_Egir/review new file mode 100644 index 0000000..ae8f0c0 --- /dev/null +++ b/Review_Egir/review @@ -0,0 +1,3 @@ +1) Добавить чекбокс который переключает проигрывание опенинга после отгадывания(продолжает или сначала) + +2) Редизайнуть страницу \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..75c8cfb --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..5371f46 --- /dev/null +++ b/backend/app/config.py @@ -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) diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..0aca50c --- /dev/null +++ b/backend/app/database.py @@ -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) diff --git a/backend/app/db_models.py b/backend/app/db_models.py new file mode 100644 index 0000000..dea9255 --- /dev/null +++ b/backend/app/db_models.py @@ -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"" + + +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"" + + +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"" diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..df3263b --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..fe44c88 --- /dev/null +++ b/backend/app/models.py @@ -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] diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/backgrounds.py b/backend/app/routers/backgrounds.py new file mode 100644 index 0000000..fc68e79 --- /dev/null +++ b/backend/app/routers/backgrounds.py @@ -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)) diff --git a/backend/app/routers/openings.py b/backend/app/routers/openings.py new file mode 100644 index 0000000..7d3823c --- /dev/null +++ b/backend/app/routers/openings.py @@ -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 diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..0eebce1 --- /dev/null +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/storage.py b/backend/app/storage.py new file mode 100644 index 0000000..8010e4a --- /dev/null +++ b/backend/app/storage.py @@ -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() diff --git a/backend/app/video_generator.py b/backend/app/video_generator.py new file mode 100644 index 0000000..10b6557 --- /dev/null +++ b/backend/app/video_generator.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..529f977 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6042cc6 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..6b0c268 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..fdce647 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + Anime Quiz Video Generator + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..ef5e429 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..8e4f098 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,756 @@ + + + + + diff --git a/frontend/src/components/AdminPage.vue b/frontend/src/components/AdminPage.vue new file mode 100644 index 0000000..3bb520e --- /dev/null +++ b/frontend/src/components/AdminPage.vue @@ -0,0 +1,1196 @@ + + + + + diff --git a/frontend/src/components/BackgroundsManager.vue b/frontend/src/components/BackgroundsManager.vue new file mode 100644 index 0000000..426fc13 --- /dev/null +++ b/frontend/src/components/BackgroundsManager.vue @@ -0,0 +1,921 @@ + + + + + diff --git a/frontend/src/components/MediaManager.vue b/frontend/src/components/MediaManager.vue new file mode 100644 index 0000000..e28255a --- /dev/null +++ b/frontend/src/components/MediaManager.vue @@ -0,0 +1,2297 @@ + + + + + diff --git a/frontend/src/components/OpeningsManager.vue b/frontend/src/components/OpeningsManager.vue new file mode 100644 index 0000000..ac6efe9 --- /dev/null +++ b/frontend/src/components/OpeningsManager.vue @@ -0,0 +1,1130 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..fe5bae3 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './style.css' + +createApp(App).mount('#app') diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..13d8038 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,1034 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); + min-height: 100vh; + color: #fff; +} + +#app { + min-height: 100vh; +} + +/* App Container */ +.app-container { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Main Navigation */ +.main-nav { + display: flex; + justify-content: center; + gap: 0.5rem; + padding: 1rem; + background: rgba(0, 0, 0, 0.3); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.nav-btn { + padding: 0.75rem 1.5rem; + background: transparent; + border: 2px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + color: #888; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; +} + +.nav-btn:hover { + border-color: #00d4ff; + color: #fff; +} + +.nav-btn.active { + background: linear-gradient(90deg, #00d4ff, #7b2cbf); + border-color: transparent; + color: #fff; +} + +/* App Layout - Sidebar + Main */ +.app-layout { + display: flex; + flex: 1; + min-height: 0; +} + +/* Sidebar */ +.sidebar { + width: 280px; + background: rgba(0, 0, 0, 0.3); + border-right: 1px solid rgba(255, 255, 255, 0.1); + padding: 1.5rem; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.sidebar h3 { + color: #00d4ff; + margin-bottom: 1rem; + font-size: 1.1rem; +} + +.selected-summary { + flex: 1; + overflow-y: auto; +} + +.empty-state { + color: #666; + text-align: center; + padding: 2rem 0; + font-size: 0.9rem; +} + +.selected-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + border-radius: 8px; + margin-bottom: 0.5rem; + background: rgba(255, 255, 255, 0.05); + border-left: 3px solid; +} + +.selected-item.difficulty-easy { + border-left-color: #00ff88; + background: rgba(0, 255, 136, 0.1); +} + +.selected-item.difficulty-medium { + border-left-color: #ffaa00; + background: rgba(255, 170, 0, 0.1); +} + +.selected-item.difficulty-hard { + border-left-color: #ff4444; + background: rgba(255, 68, 68, 0.1); +} + +.item-number { + background: rgba(255, 255, 255, 0.2); + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; +} + +.item-info { + flex: 1; + min-width: 0; +} + +.item-anime { + font-size: 0.85rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-op { + font-size: 0.75rem; + color: #888; +} + +.btn-remove-small { + background: rgba(255, 68, 68, 0.3); + border: none; + color: #ff4444; + width: 20px; + height: 20px; + border-radius: 50%; + cursor: pointer; + font-size: 0.9rem; + line-height: 1; +} + +.btn-remove-small:hover { + background: rgba(255, 68, 68, 0.5); +} + +.summary-stats { + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); + font-size: 0.85rem; + color: #888; +} + +.difficulty-stats { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + flex-wrap: wrap; +} + +.stat { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; +} + +.stat.easy { + background: rgba(0, 255, 136, 0.2); + color: #00ff88; +} + +.stat.medium { + background: rgba(255, 170, 0, 0.2); + color: #ffaa00; +} + +.stat.hard { + background: rgba(255, 68, 68, 0.2); + color: #ff4444; +} + +/* Main Content */ +.main-content { + flex: 1; + padding: 2rem; + max-width: 900px; + margin: 0 auto; +} + +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; +} + +.status-indicator { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: 8px; + font-size: 0.875rem; + margin-bottom: 1rem; +} + +.status-indicator.success { + background: rgba(0, 255, 136, 0.2); + color: #00ff88; +} + +.status-indicator.error { + background: rgba(255, 59, 48, 0.2); + color: #ff3b30; +} + +/* Cards */ +.card { + background: rgba(255, 255, 255, 0.05); + border-radius: 16px; + padding: 1.5rem; + margin-bottom: 1.5rem; + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); +} + +.card h2 { + font-size: 1.25rem; + margin-bottom: 1rem; + color: #00d4ff; +} + +/* Settings Grid */ +.settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.setting-group { + margin-bottom: 0.5rem; +} + +.setting-group > label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #ccc; + font-size: 0.9rem; +} + +.setting-group input[type="number"], +.setting-group select { + width: 100%; + padding: 0.6rem 0.8rem; + 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; +} + +.setting-group input:focus, +.setting-group select:focus { + outline: none; + border-color: #00d4ff; +} + +/* Mode Selector */ +.mode-selector { + display: flex; + gap: 0.5rem; +} + +.mode-btn { + flex: 1; + padding: 0.75rem; + border: 2px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + background: transparent; + color: #fff; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.mode-btn:hover { + border-color: #00d4ff; + background: rgba(0, 212, 255, 0.1); +} + +.mode-btn.active { + border-color: #00d4ff; + background: rgba(0, 212, 255, 0.2); +} + +.mode-btn .icon { + font-size: 1.2rem; +} + +/* Checkbox */ +.checkbox-label { + display: inline-flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; + padding: 0.6rem 0; + line-height: 1; +} + +.setting-group .checkbox-label { + margin-top: 1.5rem; +} + +.checkbox-label input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 22px; + height: 22px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 6px; + background: rgba(0, 0, 0, 0.3); + cursor: pointer; + position: relative; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.checkbox-label input[type="checkbox"]:hover { + border-color: #00d4ff; + background: rgba(0, 212, 255, 0.1); +} + +.checkbox-label input[type="checkbox"]:checked { + background: linear-gradient(135deg, #00d4ff, #7b2cbf); + border-color: #00d4ff; +} + +.checkbox-label input[type="checkbox"]:checked::after { + content: '✓'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #fff; + font-size: 14px; + font-weight: bold; +} + +.checkbox-label span { + color: #ccc; + font-weight: 500; + line-height: 22px; +} + +/* Sections */ +.sections-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.sections-header h2 { + margin-bottom: 0; +} + +.btn-add-section { + padding: 0.5rem 1rem; + background: linear-gradient(90deg, #00d4ff, #7b2cbf); + border: none; + border-radius: 8px; + color: #fff; + cursor: pointer; + font-weight: 500; +} + +.btn-add-section:hover { + opacity: 0.9; +} + +.empty-sections { + text-align: center; + color: #666; + padding: 2rem; +} + +.section-item { + background: rgba(0, 0, 0, 0.2); + border-radius: 12px; + padding: 1rem; + margin-bottom: 1rem; + border-left: 4px solid; +} + +.section-item.section-easy { + border-left-color: #00ff88; +} + +.section-item.section-medium { + border-left-color: #ffaa00; +} + +.section-item.section-hard { + border-left-color: #ff4444; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.section-title { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.section-badge { + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; +} + +.section-badge.easy { + background: rgba(0, 255, 136, 0.2); + color: #00ff88; +} + +.section-badge.medium { + background: rgba(255, 170, 0, 0.2); + color: #ffaa00; +} + +.section-badge.hard { + background: rgba(255, 68, 68, 0.2); + color: #ff4444; +} + +.section-actions { + display: flex; + gap: 0.5rem; +} + +.section-actions button { + padding: 0.4rem 0.8rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.05); + color: #fff; + cursor: pointer; + font-size: 0.85rem; +} + +.section-actions button:hover { + background: rgba(255, 255, 255, 0.1); +} + +.btn-settings { + border-color: #00d4ff !important; +} + +.btn-add { + border-color: #00ff88 !important; + color: #00ff88 !important; +} + +.btn-remove { + border-color: #ff4444 !important; + color: #ff4444 !important; +} + +/* Section Settings */ +.section-settings { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.settings-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; +} + +.settings-row .setting-group > label { + font-size: 0.85rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + cursor: pointer; + padding: 0.25rem 0; +} + +.settings-row .setting-group input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 18px; + height: 18px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 4px; + background: rgba(0, 0, 0, 0.3); + cursor: pointer; + position: relative; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.settings-row .setting-group input[type="checkbox"]:hover { + border-color: #00d4ff; +} + +.settings-row .setting-group input[type="checkbox"]:checked { + background: linear-gradient(135deg, #00d4ff, #7b2cbf); + border-color: #00d4ff; +} + +.settings-row .setting-group input[type="checkbox"]:checked::after { + content: '✓'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #fff; + font-size: 11px; + font-weight: bold; +} + +.settings-row .setting-group input[type="number"], +.settings-row .setting-group select { + margin-top: 0.5rem; +} + +/* Section Openings */ +.section-openings { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.opening-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.opening-poster-thumb { + width: 40px; + height: 40px; + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; + background: rgba(0, 0, 0, 0.3); +} + +.opening-poster-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.no-poster-thumb { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: #666; + font-size: 1.2rem; +} + +.opening-item-info { + flex: 1; + min-width: 0; +} + +.opening-item-name { + font-size: 0.9rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.opening-item-op { + font-size: 0.75rem; + color: #00d4ff; +} + +.poster-select { + padding: 0.4rem 0.6rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + background: rgba(0, 0, 0, 0.3); + color: #fff; + font-size: 0.8rem; + max-width: 150px; + cursor: pointer; +} + +.poster-select:focus { + outline: none; + border-color: #00d4ff; +} + +.btn-remove-item { + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: rgba(255, 68, 68, 0.2); + color: #ff4444; + cursor: pointer; + font-size: 1.1rem; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s ease; +} + +.btn-remove-item:hover { + background: rgba(255, 68, 68, 0.4); +} + +/* Generate Button */ +.btn-generate { + width: 100%; + padding: 1rem 2rem; + font-size: 1.125rem; + font-weight: 600; + border: none; + border-radius: 12px; + background: linear-gradient(90deg, #00d4ff, #7b2cbf); + color: #fff; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-generate:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 10px 30px rgba(0, 212, 255, 0.3); +} + +.btn-generate:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Progress */ +.progress-container { + margin-top: 1.5rem; +} + +.progress-bar { + height: 8px; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + overflow: hidden; + margin-bottom: 0.5rem; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #00d4ff, #7b2cbf); + transition: width 0.3s ease; +} + +.progress-text { + text-align: center; + color: #888; + font-size: 0.875rem; +} + +/* Result */ +.result-card { + text-align: center; +} + +.result-card h3 { + color: #00ff88; + margin-bottom: 1rem; +} + +.video-preview { + width: 100%; + max-height: 400px; + border-radius: 12px; + margin-bottom: 1rem; +} + +.btn-download { + display: inline-block; + padding: 0.75rem 2rem; + background: #00ff88; + color: #000; + text-decoration: none; + border-radius: 8px; + font-weight: 600; + transition: all 0.3s ease; +} + +.btn-download:hover { + background: #00cc6a; + transform: translateY(-2px); +} + +.error-message { + background: rgba(255, 59, 48, 0.2); + border: 1px solid #ff3b30; + border-radius: 8px; + padding: 1rem; + color: #ff3b30; + margin-top: 1rem; +} + +/* 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: 2rem; +} + +.modal { + background: #1a1a2e; + border-radius: 16px; + width: 100%; + max-width: 900px; + max-height: 90vh; + display: flex; + flex-direction: column; + 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-close:hover { + color: #fff; +} + +.modal-search { + padding: 1rem 1.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.modal-search input { + width: 100%; + 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; +} + +.modal-search input:focus { + outline: none; + border-color: #00d4ff; +} + +.modal-body { + flex: 1; + overflow-y: auto; + padding: 1.5rem; +} + +.openings-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; +} + +.opening-card { + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + overflow: hidden; + cursor: pointer; + transition: all 0.3s ease; + border: 2px solid transparent; + position: relative; +} + +.opening-card:hover { + background: rgba(255, 255, 255, 0.1); + transform: translateY(-2px); +} + +.opening-card.selected { + border-color: #00d4ff; + background: rgba(0, 212, 255, 0.1); +} + +.card-poster { + height: 120px; + background: rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; +} + +.card-poster img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.no-poster { + color: #666; + font-size: 0.85rem; +} + +.card-info { + padding: 0.75rem; +} + +.card-anime { + font-weight: 600; + font-size: 0.9rem; + margin-bottom: 0.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-op { + color: #00d4ff; + font-size: 0.8rem; + margin-bottom: 0.25rem; +} + +.card-song { + color: #888; + font-size: 0.75rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-check { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: #00d4ff; + color: #000; + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; +} + +.poster-selector { + padding: 0.5rem 0.75rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.poster-selector select { + width: 100%; + padding: 0.4rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: rgba(0, 0, 0, 0.3); + color: #fff; + font-size: 0.75rem; +} + +.modal-footer { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.selection-count { + flex: 1; + color: #888; +} + +.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); +} + +.btn-confirm { + padding: 0.75rem 1.5rem; + background: linear-gradient(90deg, #00d4ff, #7b2cbf); + border: none; + border-radius: 8px; + color: #fff; + cursor: pointer; + font-weight: 500; +} + +.btn-confirm:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Responsive */ +@media (max-width: 900px) { + .app-layout { + flex-direction: column; + } + + .sidebar { + width: 100%; + height: auto; + position: relative; + max-height: 200px; + } + + .selected-summary { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .selected-item { + margin-bottom: 0; + } +} + +@media (max-width: 600px) { + .main-content { + padding: 1rem; + } + + .settings-grid { + grid-template-columns: 1fr; + } + + .section-header { + flex-direction: column; + align-items: flex-start; + } + + .section-actions { + width: 100%; + justify-content: flex-end; + } + + .openings-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + } + + h1 { + font-size: 1.75rem; + } + + .modal { + margin: 1rem; + max-height: calc(100vh - 2rem); + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..5e12c6a --- /dev/null +++ b/frontend/vite.config.js @@ -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 + } + } + } +}) diff --git a/image1.png b/image1.png new file mode 100644 index 0000000..4333d0a Binary files /dev/null and b/image1.png differ diff --git a/media/audio/.gitkeep b/media/audio/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/media/audio/One Piece_op04_Bon-voyage copy 10.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 10.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 10.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 11.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 11.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 11.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 12.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 12.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 12.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 13.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 13.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 13.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 14.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 14.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 14.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 15.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 15.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 15.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 16.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 16.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 16.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 17.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 17.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 17.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 18.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 18.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 18.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 19.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 19.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 19.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 2.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 2.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 2.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 20.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 20.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 20.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 21.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 21.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 21.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 22.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 22.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 22.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 23.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 23.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 23.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 24.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 24.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 24.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 3.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 3.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 3.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 4.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 4.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 4.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 5.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 5.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 5.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 6.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 6.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 6.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 7.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 7.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 7.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 8.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 8.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 8.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy 9.mp3 b/media/audio/One Piece_op04_Bon-voyage copy 9.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy 9.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage copy.mp3 b/media/audio/One Piece_op04_Bon-voyage copy.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage copy.mp3 differ diff --git a/media/audio/One Piece_op04_Bon-voyage.mp3 b/media/audio/One Piece_op04_Bon-voyage.mp3 new file mode 100644 index 0000000..cc890d2 Binary files /dev/null and b/media/audio/One Piece_op04_Bon-voyage.mp3 differ diff --git a/media/backgrounds/.gitkeep b/media/backgrounds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/media/backgrounds/63885-508273140_small.mp4 b/media/backgrounds/63885-508273140_small.mp4 new file mode 100644 index 0000000..37b0562 Binary files /dev/null and b/media/backgrounds/63885-508273140_small.mp4 differ diff --git a/media/posters/.gitkeep b/media/posters/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/media/posters/One Piece.jpg b/media/posters/One Piece.jpg new file mode 100644 index 0000000..b48bd9e Binary files /dev/null and b/media/posters/One Piece.jpg differ diff --git a/mvpDocker b/mvpDocker new file mode 100644 index 0000000..1ee0c9c --- /dev/null +++ b/mvpDocker @@ -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 + +Инструкцию запуска \ No newline at end of file diff --git a/mvpTask b/mvpTask new file mode 100644 index 0000000..a39027f --- /dev/null +++ b/mvpTask @@ -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 + +Код читаемый и расширяемый + +⚠️ ВАЖНО + +Не писать абстрактные советы. +Не обсуждать «в теории». +Писать конкретный код и структуру проекта. \ No newline at end of file diff --git a/task b/task new file mode 100644 index 0000000..8256789 --- /dev/null +++ b/task @@ -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-ТЗ + +🧑‍💻 предложить стек под твой уровень + +⚖️ разобрать вопросы авторских прав \ No newline at end of file