commit c3795138da7c2d01250d644936a1f131cb75be75 Author: kihong.kim Date: Sat Jan 3 21:38:34 2026 +0900 Initial commit: YouTube Shorts maker application Features: - Video download from TikTok/Douyin using yt-dlp - Audio transcription with OpenAI Whisper - GPT-4 translation (direct/summarize/rewrite modes) - Subtitle generation with ASS format - Video trimming with frame-accurate preview - BGM integration with volume control - Intro text overlay support - Thumbnail generation with text overlay Tech stack: - Backend: FastAPI, Python 3.11+ - Frontend: React, Vite, TailwindCSS - Video processing: FFmpeg - AI: OpenAI Whisper, GPT-4 ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3f65782 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# OpenAI API Key (ํ•„์ˆ˜) +OPENAI_API_KEY=your_openai_api_key_here + +# OpenAI ๋ชจ๋ธ ์„ค์ • +OPENAI_MODEL=gpt-4o-mini # gpt-4o-mini (์ €๋ ด), gpt-4o (๊ณ ํ’ˆ์งˆ) + +# ๋ฒˆ์—ญ ๋ชจ๋“œ: direct (์ง์—ญ), summarize (์š”์•ฝ), rewrite (์žฌ๊ตฌ์„ฑ) +TRANSLATION_MODE=rewrite + +# ๋ฒˆ์—ญ ์ตœ๋Œ€ ํ† ํฐ ์ˆ˜ (๋น„์šฉ ์ œ์–ด) +TRANSLATION_MAX_TOKENS=1000 + +# Pixabay API Key (์„ ํƒ - BGM ๊ฒ€์ƒ‰์šฉ) +# https://pixabay.com/api/docs/ ์—์„œ ๋ฌด๋ฃŒ ๋ฐœ๊ธ‰ +PIXABAY_API_KEY= + +# Freesound API Key (์„ ํƒ - ์ž๋™ BGM ๊ฒ€์ƒ‰/๋‹ค์šด๋กœ๋“œ) +# https://freesound.org/apiv2/apply ์—์„œ ๋ฌด๋ฃŒ ๋ฐœ๊ธ‰ +# 50๋งŒ๊ฐœ+ CC ๋ผ์ด์„ ์Šค ์‚ฌ์šด๋“œ ๊ฒ€์ƒ‰ ๊ฐ€๋Šฅ +FREESOUND_API_KEY= + +# Whisper ๋ชจ๋ธ (small, medium, large) +# GPU ์—†์œผ๋ฉด medium ๊ถŒ์žฅ +WHISPER_MODEL=medium + +# ์›น ์„œ๋ฒ„ ํฌํŠธ +PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f43cd8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Environment +.env +.env.local + +# Data directories +data/downloads/ +data/processed/ +data/jobs.json +backend/data/downloads/ +backend/data/processed/ +backend/data/jobs.json +backend/data/cookies/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +.venv/ +venv_*/ +**/venv/ +**/venv_*/ + +# Node +node_modules/ +dist/ +.pnpm-store/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Whisper model cache +~/.cache/whisper/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1d9879 --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# Shorts Maker + +์ค‘๊ตญ ์‡ผ์ธ  ์˜์ƒ(Douyin, Kuaishou ๋“ฑ)์„ ๋‹ค์šด๋กœ๋“œํ•˜๊ณ  ํ•œ๊ธ€ ์ž๋ง‰์„ ์ž๋™์œผ๋กœ ์ƒ์„ฑํ•˜๋Š” ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ž…๋‹ˆ๋‹ค. + +## ์ฃผ์š” ๊ธฐ๋Šฅ + +- **์˜์ƒ ๋‹ค์šด๋กœ๋“œ**: Douyin, Kuaishou, TikTok, YouTube, Bilibili ์ง€์› +- **์Œ์„ฑ ์ธ์‹**: OpenAI Whisper๋กœ ์ž๋™ ์Œ์„ฑ ์ธ์‹ +- **๋ฒˆ์—ญ**: GPT-4๋กœ ์ž์—ฐ์Šค๋Ÿฌ์šด ํ•œ๊ธ€ ๋ฒˆ์—ญ +- **์ž๋ง‰ ํ•ฉ์„ฑ**: FFmpeg๋กœ ์ž๋ง‰์„ ์˜์ƒ์— burn-in +- **BGM ์ถ”๊ฐ€**: ์›๋ณธ ์Œ์„ฑ ์ œ๊ฑฐ ํ›„ BGM ์‚ฝ์ž… + +## ์‹œ์Šคํ…œ ์š”๊ตฌ์‚ฌํ•ญ + +- Docker & Docker Compose +- ์ตœ์†Œ 8GB RAM (Whisper medium ๋ชจ๋ธ) +- OpenAI API ํ‚ค + +## ๋น ๋ฅธ ์‹œ์ž‘ + +### 1. ํ™˜๊ฒฝ ์„ค์ • + +```bash +cp .env.example .env +``` + +`.env` ํŒŒ์ผ์„ ์—ด๊ณ  OpenAI API ํ‚ค๋ฅผ ์ž…๋ ฅํ•ฉ๋‹ˆ๋‹ค: + +``` +OPENAI_API_KEY=sk-your-api-key-here +``` + +### 2. Docker ์‹คํ–‰ + +```bash +docker-compose up -d --build +``` + +### 3. ์ ‘์† + +๋ธŒ๋ผ์šฐ์ €์—์„œ `http://localhost:3000` ์ ‘์† + +## ์‚ฌ์šฉ ๋ฐฉ๋ฒ• + +1. **์˜์ƒ URL ์ž…๋ ฅ**: Douyin, Kuaishou ๋“ฑ์˜ ์˜์ƒ URL์„ ์ž…๋ ฅ +2. **๋‹ค์šด๋กœ๋“œ**: ์ž๋™์œผ๋กœ ์˜์ƒ ๋‹ค์šด๋กœ๋“œ +3. **BGM ์„ ํƒ**: (์„ ํƒ์‚ฌํ•ญ) ๋ฐฐ๊ฒฝ ์Œ์•… ์„ ํƒ +4. **์ฒ˜๋ฆฌ ์‹œ์ž‘**: ์ž๋ง‰ ์ƒ์„ฑ ๋ฐ ์˜์ƒ ์ฒ˜๋ฆฌ +5. **๋‹ค์šด๋กœ๋“œ**: ์™„์„ฑ๋œ ์˜์ƒ ๋‹ค์šด๋กœ๋“œ + +## ๊ธฐ์ˆ  ์Šคํƒ + +### Backend +- Python 3.11 + FastAPI +- yt-dlp (์˜์ƒ ๋‹ค์šด๋กœ๋“œ) +- OpenAI Whisper (์Œ์„ฑ ์ธ์‹) +- OpenAI GPT-4 (๋ฒˆ์—ญ) +- FFmpeg (์˜์ƒ ์ฒ˜๋ฆฌ) + +### Frontend +- React 18 + Vite +- Tailwind CSS +- Axios + +### Infrastructure +- Docker & Docker Compose +- Redis (์ž‘์—… ํ) +- Nginx (๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ) + +## ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ + +``` +shorts-maker/ +โ”œโ”€โ”€ backend/ +โ”‚ โ”œโ”€โ”€ app/ +โ”‚ โ”‚ โ”œโ”€โ”€ main.py # FastAPI ์•ฑ +โ”‚ โ”‚ โ”œโ”€โ”€ config.py # ์„ค์ • +โ”‚ โ”‚ โ”œโ”€โ”€ routers/ # API ๋ผ์šฐํ„ฐ +โ”‚ โ”‚ โ”œโ”€โ”€ services/ # ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง +โ”‚ โ”‚ โ””โ”€โ”€ models/ # ๋ฐ์ดํ„ฐ ๋ชจ๋ธ +โ”‚ โ”œโ”€โ”€ Dockerfile +โ”‚ โ””โ”€โ”€ requirements.txt +โ”œโ”€โ”€ frontend/ +โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ”‚ โ”œโ”€โ”€ pages/ # ํŽ˜์ด์ง€ ์ปดํฌ๋„ŒํŠธ +โ”‚ โ”‚ โ”œโ”€โ”€ components/ # UI ์ปดํฌ๋„ŒํŠธ +โ”‚ โ”‚ โ””โ”€โ”€ api/ # API ํด๋ผ์ด์–ธํŠธ +โ”‚ โ”œโ”€โ”€ Dockerfile +โ”‚ โ””โ”€โ”€ package.json +โ”œโ”€โ”€ data/ +โ”‚ โ”œโ”€โ”€ downloads/ # ๋‹ค์šด๋กœ๋“œ๋œ ์˜์ƒ +โ”‚ โ”œโ”€โ”€ processed/ # ์ฒ˜๋ฆฌ๋œ ์˜์ƒ +โ”‚ โ””โ”€โ”€ bgm/ # BGM ํŒŒ์ผ +โ”œโ”€โ”€ docker-compose.yml +โ””โ”€โ”€ .env +``` + +## API ์—”๋“œํฌ์ธํŠธ + +| Method | Endpoint | ์„ค๋ช… | +|--------|----------|------| +| POST | /api/download/ | ์˜์ƒ ๋‹ค์šด๋กœ๋“œ ์‹œ์ž‘ | +| POST | /api/process/ | ์˜์ƒ ์ฒ˜๋ฆฌ ์‹œ์ž‘ | +| GET | /api/jobs/ | ์ž‘์—… ๋ชฉ๋ก ์กฐํšŒ | +| GET | /api/jobs/{id} | ์ž‘์—… ์ƒ์„ธ ์กฐํšŒ | +| GET | /api/jobs/{id}/download | ์ฒ˜๋ฆฌ๋œ ์˜์ƒ ๋‹ค์šด๋กœ๋“œ | +| GET | /api/bgm/ | BGM ๋ชฉ๋ก ์กฐํšŒ | +| POST | /api/bgm/upload | BGM ์—…๋กœ๋“œ | + +## ์„ค์ • ์˜ต์…˜ + +| ํ™˜๊ฒฝ๋ณ€์ˆ˜ | ๊ธฐ๋ณธ๊ฐ’ | ์„ค๋ช… | +|----------|--------|------| +| OPENAI_API_KEY | - | OpenAI API ํ‚ค (ํ•„์ˆ˜) | +| WHISPER_MODEL | medium | Whisper ๋ชจ๋ธ (small/medium/large) | +| PORT | 3000 | ์›น ์„œ๋ฒ„ ํฌํŠธ | + +## ๋ฌธ์ œ ํ•ด๊ฒฐ + +### ๋‹ค์šด๋กœ๋“œ ์‹คํŒจ +- ์ผ๋ถ€ ์˜์ƒ์€ ์ง€์—ญ ์ œํ•œ์ด ์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค +- VPN ๋˜๋Š” ํ”„๋ก์‹œ ์„ค์ •์ด ํ•„์š”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค + +### ์Œ์„ฑ ์ธ์‹ ํ’ˆ์งˆ +- `WHISPER_MODEL=large`๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด ์ •ํ™•๋„๊ฐ€ ์˜ฌ๋ผ๊ฐ‘๋‹ˆ๋‹ค (๋” ๋งŽ์€ ๋ฉ”๋ชจ๋ฆฌ ํ•„์š”) + +### ๋ฉ”๋ชจ๋ฆฌ ๋ถ€์กฑ +- `WHISPER_MODEL=small`๋กœ ๋ณ€๊ฒฝํ•˜์„ธ์š” + +## ๋ผ์ด์„ ์Šค + +MIT License + +## ์ฃผ์˜์‚ฌํ•ญ + +- ์ €์ž‘๊ถŒ์ด ์žˆ๋Š” ์˜์ƒ์„ ๋ฌด๋‹จ์œผ๋กœ ์žฌ๋ฐฐํฌํ•˜์ง€ ๋งˆ์„ธ์š” +- BGM์€ ์ €์ž‘๊ถŒ์— ์œ ์˜ํ•˜์—ฌ ์‚ฌ์šฉํ•˜์„ธ์š” +- API ์‚ฌ์šฉ๋Ÿ‰์— ๋”ฐ๋ฅธ ๋น„์šฉ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..5c67df8 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,50 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + ffmpeg \ + fonts-nanum \ + fonts-noto-cjk \ + git \ + curl \ + unzip \ + fontconfig \ + && rm -rf /var/lib/apt/lists/* + +# Install additional Korean fonts for YouTube Shorts +RUN mkdir -p /usr/share/fonts/truetype/korean && \ + # Pretendard (๊ฐ€๋…์„ฑ ์ตœ๊ณ ) + curl -L -o /tmp/pretendard.zip "https://github.com/orioncactus/pretendard/releases/download/v1.3.9/Pretendard-1.3.9.zip" && \ + unzip -j /tmp/pretendard.zip "*/Pretendard-Bold.otf" -d /usr/share/fonts/truetype/korean/ && \ + unzip -j /tmp/pretendard.zip "*/Pretendard-Regular.otf" -d /usr/share/fonts/truetype/korean/ && \ + # Black Han Sans (์ž„ํŒฉํŠธ) + curl -L -o /usr/share/fonts/truetype/korean/BlackHanSans-Regular.ttf "https://github.com/AcDevelopers/Black-Han-Sans/raw/master/fonts/ttf/BlackHanSans-Regular.ttf" && \ + # DoHyeon (๊ท€์—ฌ์›€) + curl -L -o /usr/share/fonts/truetype/korean/DoHyeon-Regular.ttf "https://github.com/nicholasrq/DoHyeon/raw/master/fonts/DoHyeon-Regular.ttf" && \ + # Update font cache + fc-cache -fv && \ + rm -rf /tmp/*.zip + +# Install yt-dlp +RUN pip install --no-cache-dir yt-dlp + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Download Whisper model (medium for CPU) +RUN python -c "import whisper; whisper.load_model('medium')" + +# Copy application code +COPY . . + +# Create data directories +RUN mkdir -p data/downloads data/processed data/bgm + +# Expose port +EXPOSE 8000 + +# 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..61f923c --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# Shorts Maker Backend diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..179c25e --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,53 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + # API Keys + OPENAI_API_KEY: str = "" + PIXABAY_API_KEY: str = "" # Optional: for Pixabay music search + FREESOUND_API_KEY: str = "" # Optional: for Freesound API (https://freesound.org/apiv2/apply) + + # Directories + DOWNLOAD_DIR: str = "data/downloads" + PROCESSED_DIR: str = "data/processed" + BGM_DIR: str = "data/bgm" + + # Whisper settings + WHISPER_MODEL: str = "medium" # small, medium, large + + # Redis + REDIS_URL: str = "redis://redis:6379/0" + + # OpenAI settings + OPENAI_MODEL: str = "gpt-4o-mini" # gpt-4o-mini, gpt-4o, gpt-4-turbo + TRANSLATION_MAX_TOKENS: int = 1000 # Max tokens for translation (cost control) + TRANSLATION_MODE: str = "rewrite" # direct, summarize, rewrite + + # GPT Prompt Customization + GPT_ROLE: str = "์นœ๊ทผํ•œ ์œ ํŠœ๋ธŒ ์‡ผ์ธ  ์ž๋ง‰ ์ž‘๊ฐ€" # GPT persona/role + GPT_TONE: str = "์กด๋Œ“๋ง" # ์กด๋Œ“๋ง, ๋ฐ˜๋ง, ๊ฒฉ์‹์ฒด + GPT_STYLE: str = "" # Additional style instructions (optional) + + # Processing + DEFAULT_FONT_SIZE: int = 24 + DEFAULT_FONT_COLOR: str = "white" + DEFAULT_BGM_VOLUME: float = 0.3 + + # Server + PORT: int = 3000 # Frontend port + + # Proxy (for geo-restricted platforms like Douyin) + PROXY_URL: str = "" # http://host:port or socks5://host:port + + class Config: + env_file = "../.env" # Project root .env file + extra = "ignore" # Ignore extra fields in .env + + +@lru_cache() +def get_settings(): + return Settings() + + +settings = get_settings() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..df2e677 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,64 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from contextlib import asynccontextmanager +import os + +from app.routers import download, process, bgm, jobs, fonts +from app.config import settings + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + os.makedirs(settings.DOWNLOAD_DIR, exist_ok=True) + os.makedirs(settings.PROCESSED_DIR, exist_ok=True) + os.makedirs(settings.BGM_DIR, exist_ok=True) + + # Check BGM status on startup + bgm_files = [] + if os.path.exists(settings.BGM_DIR): + bgm_files = [f for f in os.listdir(settings.BGM_DIR) if f.endswith(('.mp3', '.wav', '.m4a', '.ogg'))] + + if len(bgm_files) == 0: + print("[Startup] No BGM files found. Upload BGM via /api/bgm/upload or download from Pixabay/Mixkit") + else: + names = ', '.join(bgm_files[:3]) + suffix = f'... (+{len(bgm_files) - 3} more)' if len(bgm_files) > 3 else '' + print(f"[Startup] Found {len(bgm_files)} BGM files: {names}{suffix}") + + yield + # Shutdown + + +app = FastAPI( + title="Shorts Maker API", + description="์ค‘๊ตญ ์‡ผ์ธ  ์˜์ƒ์„ ํ•œ๊ธ€ ์ž๋ง‰์œผ๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ์„œ๋น„์Šค", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Static files for processed videos +app.mount("/static/downloads", StaticFiles(directory="data/downloads"), name="downloads") +app.mount("/static/processed", StaticFiles(directory="data/processed"), name="processed") +app.mount("/static/bgm", StaticFiles(directory="data/bgm"), name="bgm") + +# Routers +app.include_router(download.router, prefix="/api/download", tags=["Download"]) +app.include_router(process.router, prefix="/api/process", tags=["Process"]) +app.include_router(bgm.router, prefix="/api/bgm", tags=["BGM"]) +app.include_router(jobs.router, prefix="/api/jobs", tags=["Jobs"]) +app.include_router(fonts.router, prefix="/api/fonts", tags=["Fonts"]) + + +@app.get("/api/health") +async def health_check(): + return {"status": "healthy", "service": "shorts-maker"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..87b8f1b --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,2 @@ +from app.models.schemas import * +from app.models.job_store import job_store diff --git a/backend/app/models/job_store.py b/backend/app/models/job_store.py new file mode 100644 index 0000000..7a6a3fe --- /dev/null +++ b/backend/app/models/job_store.py @@ -0,0 +1,91 @@ +from typing import Dict, Optional +from datetime import datetime +import uuid +import json +import os +from app.models.schemas import JobInfo, JobStatus + + +class JobStore: + """Simple in-memory job store with file persistence.""" + + def __init__(self, persistence_file: str = "data/jobs.json"): + self._jobs: Dict[str, JobInfo] = {} + self._persistence_file = persistence_file + self._load_jobs() + + def _load_jobs(self): + """Load jobs from file on startup.""" + if os.path.exists(self._persistence_file): + try: + with open(self._persistence_file, "r") as f: + data = json.load(f) + for job_id, job_data in data.items(): + job_data["created_at"] = datetime.fromisoformat(job_data["created_at"]) + job_data["updated_at"] = datetime.fromisoformat(job_data["updated_at"]) + self._jobs[job_id] = JobInfo(**job_data) + except Exception: + pass + + def _save_jobs(self): + """Persist jobs to file.""" + os.makedirs(os.path.dirname(self._persistence_file), exist_ok=True) + data = {} + for job_id, job in self._jobs.items(): + job_dict = job.model_dump() + job_dict["created_at"] = job_dict["created_at"].isoformat() + job_dict["updated_at"] = job_dict["updated_at"].isoformat() + data[job_id] = job_dict + with open(self._persistence_file, "w") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def create_job(self, original_url: str) -> JobInfo: + """Create a new job.""" + job_id = str(uuid.uuid4())[:8] + now = datetime.now() + job = JobInfo( + job_id=job_id, + status=JobStatus.PENDING, + created_at=now, + updated_at=now, + original_url=original_url, + ) + self._jobs[job_id] = job + self._save_jobs() + return job + + def get_job(self, job_id: str) -> Optional[JobInfo]: + """Get a job by ID.""" + return self._jobs.get(job_id) + + def update_job(self, job_id: str, **kwargs) -> Optional[JobInfo]: + """Update a job.""" + job = self._jobs.get(job_id) + if job: + for key, value in kwargs.items(): + if hasattr(job, key): + setattr(job, key, value) + job.updated_at = datetime.now() + self._save_jobs() + return job + + def list_jobs(self, limit: int = 50) -> list[JobInfo]: + """List recent jobs.""" + jobs = sorted( + self._jobs.values(), + key=lambda j: j.created_at, + reverse=True + ) + return jobs[:limit] + + def delete_job(self, job_id: str) -> bool: + """Delete a job.""" + if job_id in self._jobs: + del self._jobs[job_id] + self._save_jobs() + return True + return False + + +# Global job store instance +job_store = JobStore() diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py new file mode 100644 index 0000000..da5f9aa --- /dev/null +++ b/backend/app/models/schemas.py @@ -0,0 +1,279 @@ +from pydantic import BaseModel, HttpUrl +from typing import Optional, List +from enum import Enum +from datetime import datetime + + +class JobStatus(str, Enum): + PENDING = "pending" + DOWNLOADING = "downloading" + READY_FOR_TRIM = "ready_for_trim" # Download complete, ready for trimming + TRIMMING = "trimming" # Video trimming in progress + EXTRACTING_AUDIO = "extracting_audio" # Step 2: FFmpeg audio extraction + NOISE_REDUCTION = "noise_reduction" # Step 3: Noise reduction + TRANSCRIBING = "transcribing" # Step 4: Whisper STT + TRANSLATING = "translating" # Step 5: GPT translation + AWAITING_REVIEW = "awaiting_review" # Script ready, waiting for user review before rendering + PROCESSING = "processing" # Step 6: Video composition + BGM + COMPLETED = "completed" + FAILED = "failed" + AWAITING_SUBTITLE = "awaiting_subtitle" # No audio - waiting for manual subtitle input + + +class DownloadRequest(BaseModel): + url: str + platform: Optional[str] = None # auto-detect if not provided + + +class DownloadResponse(BaseModel): + job_id: str + status: JobStatus + message: str + + +class SubtitleStyle(BaseModel): + font_size: int = 28 + font_color: str = "white" + outline_color: str = "black" + outline_width: int = 2 + position: str = "bottom" # top, center, bottom + font_name: str = "Pretendard" + # Enhanced styling options + bold: bool = True # ๊ตต์€ ๊ธ€์”จ (๊ฐ€๋…์„ฑ ํ–ฅ์ƒ) + shadow: int = 1 # ๊ทธ๋ฆผ์ž ๊นŠ์ด (0=์—†์Œ, 1-4) + background_box: bool = True # ๋ถˆํˆฌ๋ช… ๋ฐฐ๊ฒฝ ๋ฐ•์Šค๋กœ ์›๋ณธ ์ž๋ง‰ ๋ฎ๊ธฐ + background_opacity: str = "E0" # ๋ฐฐ๊ฒฝ ๋ถˆํˆฌ๋ช…๋„ (00=ํˆฌ๋ช…, FF=์™„์ „๋ถˆํˆฌ๋ช…, E0=๊ถŒ์žฅ) + animation: str = "none" # none, fade, pop (์ž๋ง‰ ์• ๋‹ˆ๋ฉ”์ด์…˜) + + +class TranslationModeEnum(str, Enum): + DIRECT = "direct" # ์ง์ ‘ ๋ฒˆ์—ญ (์›๋ณธ ๊ตฌ์กฐ ์œ ์ง€) + SUMMARIZE = "summarize" # ์š”์•ฝ ํ›„ ๋ฒˆ์—ญ + REWRITE = "rewrite" # ์™„์ „ ์žฌ๊ตฌ์„ฑ (๊ถŒ์žฅ) + + +class ProcessRequest(BaseModel): + job_id: str + bgm_id: Optional[str] = None + bgm_volume: float = 0.3 + subtitle_style: Optional[SubtitleStyle] = None + keep_original_audio: bool = False + translation_mode: Optional[str] = None # direct, summarize, rewrite (default from settings) + use_vocal_separation: bool = False # Separate vocals from BGM before transcription + + +class ProcessResponse(BaseModel): + job_id: str + status: JobStatus + message: str + + +class TrimRequest(BaseModel): + """Request to trim a video to a specific time range.""" + start_time: float # Start time in seconds + end_time: float # End time in seconds + reprocess: bool = False # Whether to automatically reprocess after trimming (default: False for manual workflow) + + +class TranscribeRequest(BaseModel): + """Request to start transcription (audio extraction + STT + translation).""" + translation_mode: Optional[str] = "rewrite" # direct, summarize, rewrite + use_vocal_separation: bool = False # Separate vocals from BGM before transcription + + +class RenderRequest(BaseModel): + """Request to render final video with subtitles and BGM.""" + bgm_id: Optional[str] = None + bgm_volume: float = 0.3 + subtitle_style: Optional[SubtitleStyle] = None + keep_original_audio: bool = False + # Intro text overlay (shown at beginning of video for YouTube Shorts thumbnail) + intro_text: Optional[str] = None # Max 10 characters recommended + intro_duration: float = 0.7 # Duration of frozen frame with intro text (seconds) + intro_font_size: int = 100 # Font size + + +class TrimResponse(BaseModel): + """Response after trimming a video.""" + job_id: str + success: bool + message: str + new_duration: Optional[float] = None + + +class VideoInfoResponse(BaseModel): + """Video information for trimming UI.""" + duration: float + width: Optional[int] = None + height: Optional[int] = None + thumbnail_url: Optional[str] = None + + +class TranscriptSegment(BaseModel): + start: float + end: float + text: str + translated: Optional[str] = None + + +class JobInfo(BaseModel): + job_id: str + status: JobStatus + created_at: datetime + updated_at: datetime + original_url: Optional[str] = None + video_path: Optional[str] = None + output_path: Optional[str] = None + transcript: Optional[List[TranscriptSegment]] = None + error: Optional[str] = None + progress: int = 0 + has_audio: Optional[bool] = None # None = not checked, True = has audio, False = no audio + audio_status: Optional[str] = None # "ok", "no_audio_stream", "audio_silent" + detected_language: Optional[str] = None # Whisper detected language (e.g., "zh", "en", "ko") + + +class BGMInfo(BaseModel): + id: str + name: str + duration: float + path: str + + +class BGMUploadResponse(BaseModel): + id: str + name: str + message: str + + +# ํ•œ๊ธ€ ํฐํŠธ ์ •์˜ +class FontInfo(BaseModel): + """Font information for subtitle styling.""" + id: str # ํฐํŠธ ID (์‹œ์Šคํ…œ ํฐํŠธ๋ช…) + name: str # ํ‘œ์‹œ ์ด๋ฆ„ + style: str # ์Šคํƒ€์ผ ๋ถ„๋ฅ˜ + recommended_for: List[str] # ์ถ”์ฒœ ์ฝ˜ํ…์ธ  ์œ ํ˜• + download_url: Optional[str] = None # ๋‹ค์šด๋กœ๋“œ ๋งํฌ + license: str = "Free for commercial use" + + +# ์‡ผ์ธ ์—์„œ ์ธ๊ธฐ์žˆ๋Š” ๋ฌด๋ฃŒ ์ƒ์—…์šฉ ํ•œ๊ธ€ ํฐํŠธ +KOREAN_FONTS = { + # ๊ธฐ๋ณธ ์‹œ์Šคํ…œ ํฐํŠธ (๋Œ€๋ถ€๋ถ„์˜ ์‹œ์Šคํ…œ์— ์„ค์น˜๋จ) + "NanumGothic": FontInfo( + id="NanumGothic", + name="๋‚˜๋ˆ”๊ณ ๋”•", + style="๊น”๋”, ๊ธฐ๋ณธ", + recommended_for=["tutorial", "news", "general"], + download_url="https://hangeul.naver.com/font", + license="OFL (Open Font License)", + ), + "NanumGothicBold": FontInfo( + id="NanumGothicBold", + name="๋‚˜๋ˆ”๊ณ ๋”• Bold", + style="๊น”๋”, ๊ฐ•์กฐ", + recommended_for=["tutorial", "news", "general"], + download_url="https://hangeul.naver.com/font", + license="OFL (Open Font License)", + ), + "NanumSquareRound": FontInfo( + id="NanumSquareRound", + name="๋‚˜๋ˆ”์Šคํ€˜์–ด๋ผ์šด๋“œ", + style="๋‘ฅ๊ธ€, ์นœ๊ทผ", + recommended_for=["travel", "lifestyle", "vlog"], + download_url="https://hangeul.naver.com/font", + license="OFL (Open Font License)", + ), + + # ์ธ๊ธฐ ๋ฌด๋ฃŒ ํฐํŠธ (๋ณ„๋„ ์„ค์น˜ ํ•„์š”) + "Pretendard": FontInfo( + id="Pretendard", + name="ํ”„๋ฆฌํ…๋‹ค๋“œ", + style="ํ˜„๋Œ€์ , ๊น”๋”", + recommended_for=["tutorial", "tech", "business"], + download_url="https://github.com/orioncactus/pretendard", + license="OFL (Open Font License)", + ), + "SpoqaHanSansNeo": FontInfo( + id="SpoqaHanSansNeo", + name="์Šคํฌ์นด ํ•œ ์‚ฐ์Šค Neo", + style="๊น”๋”, ๊ฐ€๋…์„ฑ", + recommended_for=["tutorial", "tech", "presentation"], + download_url="https://github.com/spoqa/spoqa-han-sans", + license="OFL (Open Font License)", + ), + "GmarketSans": FontInfo( + id="GmarketSans", + name="G๋งˆ์ผ“ ์‚ฐ์Šค", + style="๋‘ฅ๊ธ€, ์นœ๊ทผ", + recommended_for=["shopping", "review", "lifestyle"], + download_url="https://corp.gmarket.com/fonts", + license="Free for commercial use", + ), + + # ๊ฐœ์„ฑ์žˆ๋Š” ํฐํŠธ + "BMDoHyeon": FontInfo( + id="BMDoHyeon", + name="๋ฐฐ๋ฏผ ๋„ํ˜„์ฒด", + style="์†๊ธ€์”จ, ์œ ๋จธ", + recommended_for=["comedy", "mukbang", "cooking"], + download_url="https://www.woowahan.com/fonts", + license="OFL (Open Font License)", + ), + "BMJua": FontInfo( + id="BMJua", + name="๋ฐฐ๋ฏผ ์ฃผ์•„์ฒด", + style="๊ท€์—ฌ์›€, ์บ์ฃผ์–ผ", + recommended_for=["cooking", "lifestyle", "kids"], + download_url="https://www.woowahan.com/fonts", + license="OFL (Open Font License)", + ), + "Cafe24Ssurround": FontInfo( + id="Cafe24Ssurround", + name="์นดํŽ˜24 ์จ๋ผ์šด๋“œ", + style="๊ฐ•์กฐ, ์ž„ํŒฉํŠธ", + recommended_for=["gaming", "reaction", "highlight"], + download_url="https://fonts.cafe24.com/", + license="Free for commercial use", + ), + "Cafe24SsurroundAir": FontInfo( + id="Cafe24SsurroundAir", + name="์นดํŽ˜24 ์จ๋ผ์šด๋“œ ์—์–ด", + style="๊ฐ€๋ฒผ์›€, ๊น”๋”", + recommended_for=["vlog", "daily", "lifestyle"], + download_url="https://fonts.cafe24.com/", + license="Free for commercial use", + ), + + # ์ œ๋ชฉ/๊ฐ•์กฐ์šฉ ํฐํŠธ + "BlackHanSans": FontInfo( + id="BlackHanSans", + name="๊ฒ€์€๊ณ ๋”•", + style="๊ตต์Œ, ๊ฐ•๋ ฌ", + recommended_for=["gaming", "sports", "action"], + download_url="https://fonts.google.com/specimen/Black+Han+Sans", + license="OFL (Open Font License)", + ), + "DoHyeon": FontInfo( + id="DoHyeon", + name="๋„ํ˜„", + style="์†๊ธ€์”จ, ์ž์—ฐ์Šค๋Ÿฌ์›€", + recommended_for=["vlog", "cooking", "asmr"], + download_url="https://fonts.google.com/specimen/Do+Hyeon", + license="OFL (Open Font License)", + ), +} + + +# ์ฝ˜ํ…์ธ  ์œ ํ˜•๋ณ„ ์ถ”์ฒœ ํฐํŠธ +FONT_RECOMMENDATIONS = { + "tutorial": ["Pretendard", "SpoqaHanSansNeo", "NanumGothic"], + "gaming": ["Cafe24Ssurround", "BlackHanSans", "GmarketSans"], + "cooking": ["BMDoHyeon", "BMJua", "DoHyeon"], + "comedy": ["BMDoHyeon", "Cafe24Ssurround", "GmarketSans"], + "travel": ["NanumSquareRound", "Cafe24SsurroundAir", "GmarketSans"], + "news": ["Pretendard", "NanumGothic", "SpoqaHanSansNeo"], + "asmr": ["DoHyeon", "NanumSquareRound", "Cafe24SsurroundAir"], + "fitness": ["BlackHanSans", "Cafe24Ssurround", "GmarketSans"], + "tech": ["Pretendard", "SpoqaHanSansNeo", "NanumGothic"], + "lifestyle": ["GmarketSans", "NanumSquareRound", "Cafe24SsurroundAir"], +} diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..69e2d14 --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ +from app.routers import download, process, bgm, jobs diff --git a/backend/app/routers/bgm.py b/backend/app/routers/bgm.py new file mode 100644 index 0000000..9a0c947 --- /dev/null +++ b/backend/app/routers/bgm.py @@ -0,0 +1,578 @@ +import os +import aiofiles +import httpx +from typing import List +from fastapi import APIRouter, UploadFile, File, HTTPException +from pydantic import BaseModel +from app.models.schemas import BGMInfo, BGMUploadResponse, TranscriptSegment +from app.services.video_processor import get_audio_duration +from app.services.bgm_provider import ( + get_free_bgm_sources, + search_freesound, + download_freesound, + search_and_download_bgm, + BGMSearchResult, +) +from app.services.bgm_recommender import ( + recommend_bgm_for_script, + get_preset_recommendation, + BGMRecommendation, + BGM_PRESETS, +) +from app.services.default_bgm import ( + initialize_default_bgm, + get_default_bgm_list, + check_default_bgm_status, + DEFAULT_BGM_TRACKS, +) +from app.config import settings + +router = APIRouter() + + +class BGMDownloadRequest(BaseModel): + """Request to download BGM from URL.""" + url: str + name: str + + +class BGMRecommendRequest(BaseModel): + """Request for BGM recommendation based on script.""" + segments: List[dict] # TranscriptSegment as dict + use_translated: bool = True + + +class FreesoundSearchRequest(BaseModel): + """Request to search Freesound.""" + query: str + min_duration: int = 10 + max_duration: int = 180 + page: int = 1 + page_size: int = 15 + commercial_only: bool = True # ์ƒ์—…์  ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ผ์ด์„ ์Šค๋งŒ (CC0, CC-BY) + + +class FreesoundDownloadRequest(BaseModel): + """Request to download from Freesound.""" + sound_id: str + name: str # Custom name for the downloaded file + + +class AutoBGMRequest(BaseModel): + """Request for automatic BGM search and download.""" + keywords: List[str] # Search keywords (from BGM recommendation) + max_duration: int = 120 + commercial_only: bool = True # ์ƒ์—…์  ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ผ์ด์„ ์Šค๋งŒ + + +@router.get("/", response_model=list[BGMInfo]) +async def list_bgm(): + """List all available BGM files.""" + bgm_list = [] + + if not os.path.exists(settings.BGM_DIR): + return bgm_list + + for filename in os.listdir(settings.BGM_DIR): + if filename.endswith((".mp3", ".wav", ".m4a", ".ogg")): + filepath = os.path.join(settings.BGM_DIR, filename) + bgm_id = os.path.splitext(filename)[0] + + duration = await get_audio_duration(filepath) + + bgm_list.append(BGMInfo( + id=bgm_id, + name=bgm_id.replace("_", " ").replace("-", " ").title(), + duration=duration or 0, + path=f"/static/bgm/{filename}" + )) + + return bgm_list + + +@router.post("/upload", response_model=BGMUploadResponse) +async def upload_bgm( + file: UploadFile = File(...), + name: str | None = None +): + """Upload a new BGM file.""" + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + # Validate file type + allowed_extensions = (".mp3", ".wav", ".m4a", ".ogg") + if not file.filename.lower().endswith(allowed_extensions): + raise HTTPException( + status_code=400, + detail=f"Invalid file type. Allowed: {allowed_extensions}" + ) + + # Generate ID + bgm_id = name or os.path.splitext(file.filename)[0] + bgm_id = bgm_id.lower().replace(" ", "_") + + # Get extension + ext = os.path.splitext(file.filename)[1].lower() + filepath = os.path.join(settings.BGM_DIR, f"{bgm_id}{ext}") + + # Save file + os.makedirs(settings.BGM_DIR, exist_ok=True) + + async with aiofiles.open(filepath, 'wb') as out_file: + content = await file.read() + await out_file.write(content) + + return BGMUploadResponse( + id=bgm_id, + name=name or file.filename, + message="BGM uploaded successfully" + ) + + +@router.delete("/{bgm_id}") +async def delete_bgm(bgm_id: str): + """Delete a BGM file.""" + for ext in (".mp3", ".wav", ".m4a", ".ogg"): + filepath = os.path.join(settings.BGM_DIR, f"{bgm_id}{ext}") + if os.path.exists(filepath): + os.remove(filepath) + return {"message": f"BGM '{bgm_id}' deleted"} + + raise HTTPException(status_code=404, detail="BGM not found") + + +@router.get("/sources/free", response_model=dict) +async def get_free_sources(): + """Get list of recommended free BGM sources for commercial use.""" + sources = get_free_bgm_sources() + return { + "sources": sources, + "notice": "์ด ์†Œ์Šค๋“ค์€ ์ƒ์—…์  ์‚ฌ์šฉ์ด ๊ฐ€๋Šฅํ•œ ๋ฌด๋ฃŒ ์Œ์•…์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ๊ฐ ์‚ฌ์ดํŠธ์˜ ๋ผ์ด์„ ์Šค๋ฅผ ํ™•์ธํ•˜์„ธ์š”.", + "recommended": [ + { + "name": "Pixabay Music", + "url": "https://pixabay.com/music/search/", + "why": "CC0 ๋ผ์ด์„ ์Šค, ์ €์ž‘๊ถŒ ํ‘œ๊ธฐ ๋ถˆํ•„์š”, ์‡ผ์ธ ์šฉ ์งง์€ ํŠธ๋ž™ ๋งŽ์Œ", + "search_tips": ["upbeat", "energetic", "chill", "cinematic", "funny"], + }, + { + "name": "Mixkit", + "url": "https://mixkit.co/free-stock-music/", + "why": "๊ณ ํ’ˆ์งˆ, ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ์ •๋ฆฌ, ์ƒ์—…์  ๋ฌด๋ฃŒ ์‚ฌ์šฉ", + "search_tips": ["short", "intro", "background"], + }, + ], + } + + +@router.post("/download-url", response_model=BGMUploadResponse) +async def download_bgm_from_url(request: BGMDownloadRequest): + """ + Download BGM from external URL (Pixabay, Mixkit, etc.) + + Use this to download free BGM files directly from their source URLs. + """ + url = request.url + name = request.name.lower().replace(" ", "_") + + # Validate URL - allow trusted free music sources + allowed_domains = [ + "pixabay.com", + "cdn.pixabay.com", + "mixkit.co", + "assets.mixkit.co", + "uppbeat.io", + "freemusicarchive.org", + ] + + from urllib.parse import urlparse + parsed = urlparse(url) + domain = parsed.netloc.lower() + + if not any(allowed in domain for allowed in allowed_domains): + raise HTTPException( + status_code=400, + detail=f"URL must be from allowed sources: {', '.join(allowed_domains)}" + ) + + try: + async with httpx.AsyncClient(follow_redirects=True) as client: + response = await client.get(url, timeout=60) + + if response.status_code != 200: + raise HTTPException( + status_code=400, + detail=f"Failed to download: HTTP {response.status_code}" + ) + + # Determine file extension + content_type = response.headers.get("content-type", "") + if "mpeg" in content_type or url.endswith(".mp3"): + ext = ".mp3" + elif "wav" in content_type or url.endswith(".wav"): + ext = ".wav" + elif "ogg" in content_type or url.endswith(".ogg"): + ext = ".ogg" + else: + ext = ".mp3" + + # Save file + os.makedirs(settings.BGM_DIR, exist_ok=True) + filepath = os.path.join(settings.BGM_DIR, f"{name}{ext}") + + async with aiofiles.open(filepath, 'wb') as f: + await f.write(response.content) + + return BGMUploadResponse( + id=name, + name=request.name, + message=f"BGM downloaded from {domain}" + ) + + except httpx.TimeoutException: + raise HTTPException(status_code=408, detail="Download timed out") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Download failed: {str(e)}") + + +@router.post("/recommend") +async def recommend_bgm(request: BGMRecommendRequest): + """ + AI-powered BGM recommendation based on script content. + + Analyzes the script mood and suggests appropriate background music. + Returns matched BGM from library if available, otherwise provides search keywords. + """ + # Convert dict to TranscriptSegment + segments = [TranscriptSegment(**seg) for seg in request.segments] + + # Get available BGM list + available_bgm = [] + if os.path.exists(settings.BGM_DIR): + for filename in os.listdir(settings.BGM_DIR): + if filename.endswith((".mp3", ".wav", ".m4a", ".ogg")): + bgm_id = os.path.splitext(filename)[0] + available_bgm.append({ + "id": bgm_id, + "name": bgm_id.replace("_", " ").replace("-", " "), + }) + + # Get recommendation + success, message, recommendation = await recommend_bgm_for_script( + segments, + available_bgm, + use_translated=request.use_translated, + ) + + if not success: + raise HTTPException(status_code=500, detail=message) + + # Build Pixabay search URL + search_keywords = "+".join(recommendation.search_keywords[:3]) + pixabay_url = f"https://pixabay.com/music/search/{search_keywords}/" + + return { + "recommendation": { + "mood": recommendation.mood, + "energy": recommendation.energy, + "reasoning": recommendation.reasoning, + "suggested_genres": recommendation.suggested_genres, + "search_keywords": recommendation.search_keywords, + }, + "matched_bgm": recommendation.matched_bgm_id, + "search_urls": { + "pixabay": pixabay_url, + "mixkit": f"https://mixkit.co/free-stock-music/?q={search_keywords}", + }, + "message": message, + } + + +@router.get("/recommend/presets") +async def get_bgm_presets(): + """ + Get predefined BGM presets for common content types. + + Use these presets for quick BGM selection without AI analysis. + """ + presets = {} + for content_type, preset_info in BGM_PRESETS.items(): + presets[content_type] = { + "mood": preset_info["mood"], + "keywords": preset_info["keywords"], + "description": f"Best for {content_type} content", + } + + return { + "presets": presets, + "usage": "Use content_type parameter with /recommend/preset/{content_type}", + } + + +@router.get("/recommend/preset/{content_type}") +async def get_preset_bgm(content_type: str): + """ + Get BGM recommendation for a specific content type. + + Available types: cooking, fitness, tutorial, comedy, travel, asmr, news, gaming + """ + recommendation = get_preset_recommendation(content_type) + + if not recommendation: + available_types = list(BGM_PRESETS.keys()) + raise HTTPException( + status_code=404, + detail=f"Unknown content type. Available: {', '.join(available_types)}" + ) + + # Check for matching BGM in library + if os.path.exists(settings.BGM_DIR): + for filename in os.listdir(settings.BGM_DIR): + if filename.endswith((".mp3", ".wav", ".m4a", ".ogg")): + bgm_id = os.path.splitext(filename)[0] + bgm_name = bgm_id.lower() + + # Check if any keyword matches + for keyword in recommendation.search_keywords: + if keyword in bgm_name: + recommendation.matched_bgm_id = bgm_id + break + + if recommendation.matched_bgm_id: + break + + search_keywords = "+".join(recommendation.search_keywords[:3]) + + return { + "content_type": content_type, + "recommendation": { + "mood": recommendation.mood, + "energy": recommendation.energy, + "suggested_genres": recommendation.suggested_genres, + "search_keywords": recommendation.search_keywords, + }, + "matched_bgm": recommendation.matched_bgm_id, + "search_urls": { + "pixabay": f"https://pixabay.com/music/search/{search_keywords}/", + "mixkit": f"https://mixkit.co/free-stock-music/?q={search_keywords}", + }, + } + + +@router.post("/freesound/search") +async def search_freesound_api(request: FreesoundSearchRequest): + """ + Search for music on Freesound. + + Freesound API provides 500,000+ CC licensed sounds. + Get your API key at: https://freesound.org/apiv2/apply + + Set commercial_only=true (default) to only return CC0 licensed sounds + that can be used commercially without attribution. + """ + success, message, results = await search_freesound( + query=request.query, + min_duration=request.min_duration, + max_duration=request.max_duration, + page=request.page, + page_size=request.page_size, + commercial_only=request.commercial_only, + ) + + if not success: + raise HTTPException(status_code=400, detail=message) + + def is_commercial_ok(license_str: str) -> bool: + return "CC0" in license_str or license_str == "CC BY (Attribution)" + + return { + "message": message, + "commercial_only": request.commercial_only, + "results": [ + { + "id": r.id, + "title": r.title, + "duration": r.duration, + "tags": r.tags, + "license": r.license, + "commercial_use_ok": is_commercial_ok(r.license), + "preview_url": r.preview_url, + "source": r.source, + } + for r in results + ], + "search_url": f"https://freesound.org/search/?q={request.query}", + } + + +@router.post("/freesound/download", response_model=BGMUploadResponse) +async def download_freesound_api(request: FreesoundDownloadRequest): + """ + Download a sound from Freesound by ID. + + Downloads the high-quality preview (128kbps MP3). + """ + name = request.name.lower().replace(" ", "_") + name = "".join(c for c in name if c.isalnum() or c == "_") + + success, message, file_path = await download_freesound( + sound_id=request.sound_id, + output_dir=settings.BGM_DIR, + filename=name, + ) + + if not success: + raise HTTPException(status_code=400, detail=message) + + return BGMUploadResponse( + id=name, + name=request.name, + message=message, + ) + + +@router.post("/auto-download") +async def auto_download_bgm(request: AutoBGMRequest): + """ + Automatically search and download BGM based on keywords. + + Use this with keywords from /recommend endpoint to auto-download matching BGM. + Requires FREESOUND_API_KEY to be configured. + + Set commercial_only=true (default) to only download CC0 licensed sounds + that can be used commercially without attribution. + """ + success, message, file_path, matched_result = await search_and_download_bgm( + keywords=request.keywords, + output_dir=settings.BGM_DIR, + max_duration=request.max_duration, + commercial_only=request.commercial_only, + ) + + if not success: + return { + "success": False, + "message": message, + "downloaded": None, + "suggestion": "Configure FREESOUND_API_KEY or manually download from Pixabay/Mixkit", + } + + # Get duration of downloaded file + duration = 0 + if file_path: + duration = await get_audio_duration(file_path) or 0 + + # Check if license is commercially usable + license_name = matched_result.license if matched_result else "" + commercial_ok = "CC0" in license_name or license_name == "CC BY (Attribution)" + + return { + "success": True, + "message": message, + "downloaded": { + "id": os.path.splitext(os.path.basename(file_path))[0] if file_path else None, + "name": matched_result.title if matched_result else None, + "duration": duration, + "license": license_name, + "commercial_use_ok": commercial_ok, + "source": "freesound", + "path": f"/static/bgm/{os.path.basename(file_path)}" if file_path else None, + }, + "original": { + "freesound_id": matched_result.id if matched_result else None, + "tags": matched_result.tags if matched_result else [], + }, + } + + +@router.get("/defaults/status") +async def get_default_bgm_status(): + """ + Check status of default BGM tracks. + + Returns which default tracks are installed and which are missing. + """ + status = check_default_bgm_status(settings.BGM_DIR) + + # Add track details + tracks = [] + for track in DEFAULT_BGM_TRACKS: + installed = track.id in status["installed_ids"] + tracks.append({ + "id": track.id, + "name": track.name, + "category": track.category, + "description": track.description, + "installed": installed, + }) + + return { + "total": status["total"], + "installed": status["installed"], + "missing": status["missing"], + "tracks": tracks, + } + + +@router.post("/defaults/initialize") +async def initialize_default_bgms(force: bool = False): + """ + Download default BGM tracks. + + Downloads pre-selected royalty-free BGM tracks (Pixabay License). + Use force=true to re-download all tracks. + + These tracks are free for commercial use without attribution. + """ + downloaded, skipped, errors = await initialize_default_bgm( + settings.BGM_DIR, + force=force, + ) + + return { + "success": len(errors) == 0, + "downloaded": downloaded, + "skipped": skipped, + "errors": errors, + "message": f"Downloaded {downloaded} tracks, skipped {skipped} existing" if downloaded > 0 + else "All default tracks already installed" if skipped > 0 + else "Failed to download tracks", + } + + +@router.get("/defaults/list") +async def list_default_bgms(): + """ + Get list of available default BGM tracks with metadata. + + Returns information about all pre-configured default tracks. + """ + tracks = await get_default_bgm_list() + status = check_default_bgm_status(settings.BGM_DIR) + + for track in tracks: + track["installed"] = track["id"] in status["installed_ids"] + + return { + "tracks": tracks, + "total": len(tracks), + "installed": status["installed"], + "license": "Pixabay License (Free for commercial use, no attribution required)", + } + + +@router.get("/{bgm_id}") +async def get_bgm(bgm_id: str): + """Get BGM info by ID.""" + for ext in (".mp3", ".wav", ".m4a", ".ogg"): + filepath = os.path.join(settings.BGM_DIR, f"{bgm_id}{ext}") + if os.path.exists(filepath): + duration = await get_audio_duration(filepath) + return BGMInfo( + id=bgm_id, + name=bgm_id.replace("_", " ").replace("-", " ").title(), + duration=duration or 0, + path=f"/static/bgm/{bgm_id}{ext}" + ) + + raise HTTPException(status_code=404, detail="BGM not found") diff --git a/backend/app/routers/download.py b/backend/app/routers/download.py new file mode 100644 index 0000000..fe641e5 --- /dev/null +++ b/backend/app/routers/download.py @@ -0,0 +1,62 @@ +from fastapi import APIRouter, BackgroundTasks, HTTPException +from app.models.schemas import DownloadRequest, DownloadResponse, JobStatus +from app.models.job_store import job_store +from app.services.downloader import download_video, detect_platform + +router = APIRouter() + + +async def download_task(job_id: str, url: str): + """Background task for downloading video.""" + job_store.update_job(job_id, status=JobStatus.DOWNLOADING, progress=10) + + success, message, video_path = await download_video(url, job_id) + + if success: + job_store.update_job( + job_id, + status=JobStatus.READY_FOR_TRIM, # Ready for trimming step + video_path=video_path, + progress=30, + ) + else: + job_store.update_job( + job_id, + status=JobStatus.FAILED, + error=message, + ) + + +@router.post("/", response_model=DownloadResponse) +async def start_download( + request: DownloadRequest, + background_tasks: BackgroundTasks +): + """Start video download from URL.""" + platform = request.platform or detect_platform(request.url) + + # Create job + job = job_store.create_job(original_url=request.url) + + # Start background download + background_tasks.add_task(download_task, job.job_id, request.url) + + return DownloadResponse( + job_id=job.job_id, + status=JobStatus.PENDING, + message=f"Download started for {platform} video" + ) + + +@router.get("/platforms") +async def get_supported_platforms(): + """Get list of supported platforms.""" + return { + "platforms": [ + {"id": "douyin", "name": "ๆŠ–้Ÿณ (Douyin)", "domains": ["douyin.com", "iesdouyin.com"]}, + {"id": "kuaishou", "name": "ๅฟซๆ‰‹ (Kuaishou)", "domains": ["kuaishou.com", "gifshow.com"]}, + {"id": "bilibili", "name": "ๅ“”ๅ“ฉๅ“”ๅ“ฉ (Bilibili)", "domains": ["bilibili.com"]}, + {"id": "tiktok", "name": "TikTok", "domains": ["tiktok.com"]}, + {"id": "youtube", "name": "YouTube", "domains": ["youtube.com", "youtu.be"]}, + ] + } diff --git a/backend/app/routers/fonts.py b/backend/app/routers/fonts.py new file mode 100644 index 0000000..4e3cd56 --- /dev/null +++ b/backend/app/routers/fonts.py @@ -0,0 +1,163 @@ +""" +Fonts Router - Korean font management for subtitles. + +Provides font listing and recommendations for YouTube Shorts subtitles. +""" + +from fastapi import APIRouter, HTTPException +from app.models.schemas import FontInfo, KOREAN_FONTS, FONT_RECOMMENDATIONS + +router = APIRouter() + + +@router.get("/") +async def list_fonts(): + """ + List all available Korean fonts for subtitles. + + Returns font information including: + - id: System font name to use in subtitle_style.font_name + - name: Display name in Korean + - style: Font style description + - recommended_for: Content types this font works well with + - download_url: Where to download the font + - license: Font license information + """ + fonts = [] + for font_id, font_info in KOREAN_FONTS.items(): + fonts.append({ + "id": font_info.id, + "name": font_info.name, + "style": font_info.style, + "recommended_for": font_info.recommended_for, + "download_url": font_info.download_url, + "license": font_info.license, + }) + + return { + "fonts": fonts, + "total": len(fonts), + "default": "NanumGothic", + "usage": "Set subtitle_style.font_name to the font id", + } + + +@router.get("/recommend/{content_type}") +async def recommend_fonts(content_type: str): + """ + Get font recommendations for a specific content type. + + Available content types: + - tutorial: ํŠœํ† ๋ฆฌ์–ผ, ๊ฐ•์˜ + - gaming: ๊ฒŒ์ž„, ๋ฆฌ์•ก์…˜ + - cooking: ์š”๋ฆฌ, ๋จน๋ฐฉ + - comedy: ์ฝ”๋ฏธ๋””, ์œ ๋จธ + - travel: ์—ฌํ–‰, ๋ธŒ์ด๋กœ๊ทธ + - news: ๋‰ด์Šค, ์ •๋ณด + - asmr: ASMR, ๋ฆด๋ ‰์Šค + - fitness: ์šด๋™, ํ”ผํŠธ๋‹ˆ์Šค + - tech: ๊ธฐ์ˆ , IT + - lifestyle: ๋ผ์ดํ”„์Šคํƒ€์ผ, ์ผ์ƒ + """ + content_type_lower = content_type.lower() + + if content_type_lower not in FONT_RECOMMENDATIONS: + available_types = list(FONT_RECOMMENDATIONS.keys()) + raise HTTPException( + status_code=404, + detail=f"Unknown content type. Available: {', '.join(available_types)}" + ) + + recommended_ids = FONT_RECOMMENDATIONS[content_type_lower] + recommendations = [] + + for font_id in recommended_ids: + if font_id in KOREAN_FONTS: + font = KOREAN_FONTS[font_id] + recommendations.append({ + "id": font.id, + "name": font.name, + "style": font.style, + "download_url": font.download_url, + }) + + return { + "content_type": content_type_lower, + "recommendations": recommendations, + "primary": recommended_ids[0] if recommended_ids else "NanumGothic", + } + + +@router.get("/categories") +async def list_font_categories(): + """ + List fonts grouped by style category. + """ + categories = { + "clean": { + "name": "๊น”๋”/๋ชจ๋˜", + "description": "์ •๋ณด์„ฑ ์ฝ˜ํ…์ธ , ํŠœํ† ๋ฆฌ์–ผ์— ์ ํ•ฉ", + "fonts": ["Pretendard", "SpoqaHanSansNeo", "NanumGothic"], + }, + "friendly": { + "name": "์นœ๊ทผ/๋‘ฅ๊ธ€", + "description": "์ผ์ƒ, ๋ผ์ดํ”„์Šคํƒ€์ผ ์ฝ˜ํ…์ธ ์— ์ ํ•ฉ", + "fonts": ["GmarketSans", "NanumSquareRound", "Cafe24SsurroundAir"], + }, + "handwriting": { + "name": "์†๊ธ€์”จ/์บ์ฃผ์–ผ", + "description": "๋จน๋ฐฉ, ์š”๋ฆฌ, ์œ ๋จธ ์ฝ˜ํ…์ธ ์— ์ ํ•ฉ", + "fonts": ["BMDoHyeon", "BMJua", "DoHyeon"], + }, + "impact": { + "name": "๊ฐ•์กฐ/์ž„ํŒฉํŠธ", + "description": "๊ฒŒ์ž„, ํ•˜์ด๋ผ์ดํŠธ, ๋ฆฌ์•ก์…˜์— ์ ํ•ฉ", + "fonts": ["Cafe24Ssurround", "BlackHanSans"], + }, + } + + # Add font details to each category + for category_id, category_info in categories.items(): + font_details = [] + for font_id in category_info["fonts"]: + if font_id in KOREAN_FONTS: + font = KOREAN_FONTS[font_id] + font_details.append({ + "id": font.id, + "name": font.name, + }) + category_info["font_details"] = font_details + + return { + "categories": categories, + } + + +@router.get("/{font_id}") +async def get_font(font_id: str): + """ + Get detailed information about a specific font. + """ + if font_id not in KOREAN_FONTS: + available_fonts = list(KOREAN_FONTS.keys()) + raise HTTPException( + status_code=404, + detail=f"Font not found. Available fonts: {', '.join(available_fonts)}" + ) + + font = KOREAN_FONTS[font_id] + return { + "id": font.id, + "name": font.name, + "style": font.style, + "recommended_for": font.recommended_for, + "download_url": font.download_url, + "license": font.license, + "usage_example": { + "subtitle_style": { + "font_name": font.id, + "font_size": 36, + "position": "center", + } + }, + } diff --git a/backend/app/routers/jobs.py b/backend/app/routers/jobs.py new file mode 100644 index 0000000..2fa962a --- /dev/null +++ b/backend/app/routers/jobs.py @@ -0,0 +1,175 @@ +import os +import shutil +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse +from app.models.schemas import JobInfo +from app.models.job_store import job_store +from app.config import settings + +router = APIRouter() + + +@router.get("/", response_model=list[JobInfo]) +async def list_jobs(limit: int = 50): + """List all jobs.""" + return job_store.list_jobs(limit=limit) + + +@router.get("/{job_id}", response_model=JobInfo) +async def get_job(job_id: str): + """Get job details.""" + job = job_store.get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + print(f"[API GET] Job {job_id}: status={job.status}, progress={job.progress}") + return job + + +@router.delete("/{job_id}") +async def delete_job(job_id: str): + """Delete a job and its files.""" + job = job_store.get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + # Delete associated files + download_dir = os.path.join(settings.DOWNLOAD_DIR, job_id) + processed_dir = os.path.join(settings.PROCESSED_DIR, job_id) + + if os.path.exists(download_dir): + shutil.rmtree(download_dir) + if os.path.exists(processed_dir): + shutil.rmtree(processed_dir) + + job_store.delete_job(job_id) + return {"message": f"Job {job_id} deleted"} + + +@router.get("/{job_id}/download") +async def download_output(job_id: str): + """Download the processed video.""" + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + if not job.output_path or not os.path.exists(job.output_path): + raise HTTPException(status_code=404, detail="Output file not found") + + return FileResponse( + path=job.output_path, + media_type="video/mp4", + filename=f"shorts_{job_id}.mp4" + ) + + +@router.get("/{job_id}/original") +async def download_original(job_id: str): + """Download the original video.""" + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + if not job.video_path or not os.path.exists(job.video_path): + raise HTTPException(status_code=404, detail="Original video not found") + + filename = os.path.basename(job.video_path) + # Disable caching to ensure trimmed video is always fetched fresh + return FileResponse( + path=job.video_path, + media_type="video/mp4", + filename=filename, + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0" + } + ) + + +@router.get("/{job_id}/subtitle") +async def download_subtitle(job_id: str, format: str = "ass"): + """Download the subtitle file.""" + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + if not job.video_path: + raise HTTPException(status_code=404, detail="Video not found") + + job_dir = os.path.dirname(job.video_path) + subtitle_path = os.path.join(job_dir, f"subtitle.{format}") + + if not os.path.exists(subtitle_path): + # Try to generate from transcript + if job.transcript: + from app.services.transcriber import segments_to_ass, segments_to_srt + + if format == "srt": + content = segments_to_srt(job.transcript, use_translated=True) + else: + content = segments_to_ass(job.transcript, use_translated=True) + + with open(subtitle_path, "w", encoding="utf-8") as f: + f.write(content) + else: + raise HTTPException(status_code=404, detail="Subtitle not found") + + return FileResponse( + path=subtitle_path, + media_type="text/plain", + filename=f"subtitle_{job_id}.{format}" + ) + + +@router.get("/{job_id}/thumbnail") +async def download_thumbnail(job_id: str): + """Download the generated thumbnail image.""" + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + # Check for thumbnail in processed directory + thumbnail_path = os.path.join(settings.PROCESSED_DIR, f"{job_id}_thumbnail.jpg") + + if not os.path.exists(thumbnail_path): + raise HTTPException(status_code=404, detail="Thumbnail not found. Generate it first using /process/{job_id}/thumbnail") + + return FileResponse( + path=thumbnail_path, + media_type="image/jpeg", + filename=f"thumbnail_{job_id}.jpg" + ) + + +@router.post("/{job_id}/re-edit") +async def re_edit_job(job_id: str): + """Reset job status to awaiting_review for re-editing.""" + from app.models.schemas import JobStatus + + job = job_store.get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + if job.status != JobStatus.COMPLETED: + raise HTTPException( + status_code=400, + detail="Only completed jobs can be re-edited" + ) + + # Check if transcript exists for re-editing + if not job.transcript: + raise HTTPException( + status_code=400, + detail="No transcript found. Cannot re-edit." + ) + + # Reset status to awaiting_review + job_store.update_job( + job_id, + status=JobStatus.AWAITING_REVIEW, + progress=70, + error=None + ) + + return {"message": "Job ready for re-editing", "job_id": job_id} diff --git a/backend/app/routers/process.py b/backend/app/routers/process.py new file mode 100644 index 0000000..0a60c15 --- /dev/null +++ b/backend/app/routers/process.py @@ -0,0 +1,1057 @@ +import os +from fastapi import APIRouter, BackgroundTasks, HTTPException +from app.models.schemas import ( + ProcessRequest, ProcessResponse, JobStatus, SubtitleStyle, + TrimRequest, TrimResponse, VideoInfoResponse, + TranscribeRequest, RenderRequest, +) +from app.models.job_store import job_store +from app.services.transcriber import transcribe_video, segments_to_ass +from app.services.translator import translate_segments, TranslationMode +from app.services.video_processor import process_video, trim_video, get_video_info, get_video_duration, extract_frame +from app.services.thumbnail import generate_thumbnail, generate_catchphrase, get_video_timestamps +from app.config import settings + +router = APIRouter() + + +async def process_task( + job_id: str, + bgm_id: str | None, + bgm_volume: float, + subtitle_style: SubtitleStyle | None, + keep_original_audio: bool, + translation_mode: str | None = None, + use_vocal_separation: bool = False, +): + """Background task for full video processing pipeline.""" + job = job_store.get_job(job_id) + if not job or not job.video_path: + job_store.update_job(job_id, status=JobStatus.FAILED, error="Job or video not found") + return + + try: + # Progress callback for real-time status updates + async def progress_callback(step: str, progress: int): + step_to_status = { + "vocal_separation": JobStatus.EXTRACTING_AUDIO, + "extracting_audio": JobStatus.EXTRACTING_AUDIO, + "noise_reduction": JobStatus.NOISE_REDUCTION, + "transcribing": JobStatus.TRANSCRIBING, + } + status = step_to_status.get(step, JobStatus.TRANSCRIBING) + print(f"[Progress] Step: {step} -> Status: {status}, Progress: {progress}%") + job_store.update_job(job_id, status=status, progress=progress) + + # Start with initial status + job_store.update_job(job_id, status=JobStatus.EXTRACTING_AUDIO, progress=10) + + success, message, segments, detected_lang = await transcribe_video( + job.video_path, + use_noise_reduction=True, + noise_reduction_level="medium", + use_vocal_separation=use_vocal_separation, + progress_callback=progress_callback, + ) + + # Handle special cases + if not success: + if message in ("NO_AUDIO", "SILENT_AUDIO", "SINGING_ONLY"): + # Video has no usable speech - allow manual subtitle input or BGM-only processing + if message == "NO_AUDIO": + audio_status = "no_audio_stream" + elif message == "SILENT_AUDIO": + audio_status = "audio_silent" + else: # SINGING_ONLY + audio_status = "singing_only" + + job_store.update_job( + job_id, + status=JobStatus.AWAITING_SUBTITLE, + progress=35, + has_audio=message != "NO_AUDIO", # Has audio if it's singing + audio_status=audio_status, + ) + return + else: + job_store.update_job(job_id, status=JobStatus.FAILED, error=message) + return + + # Audio OK - speech detected and transcribed + job_store.update_job(job_id, transcript=segments, progress=50, has_audio=True, audio_status="ok", detected_language=detected_lang) + + # Step 2: Translate/Rewrite (only for Chinese content) + # Check if the detected language is Chinese (zh, zh-cn, zh-tw, chinese) + is_chinese = detected_lang and detected_lang.lower() in ['zh', 'zh-cn', 'zh-tw', 'chinese', 'mandarin'] + + if is_chinese: + job_store.update_job(job_id, status=JobStatus.TRANSLATING, progress=55) + + mode = translation_mode or settings.TRANSLATION_MODE + success, message, segments = await translate_segments( + segments, + mode=mode, + max_tokens=settings.TRANSLATION_MAX_TOKENS, + ) + if not success: + # Continue with original text if translation fails + print(f"Translation warning: {message}") + + job_store.update_job(job_id, transcript=segments, progress=70) + else: + # Skip translation for non-Chinese content - just use original text as-is + print(f"Skipping GPT translation for non-Chinese content (detected: {detected_lang})") + # Set translated to original text for subtitle generation + for seg in segments: + seg.translated = seg.text + job_store.update_job(job_id, transcript=segments, progress=70) + + # Step 3: Generate subtitle file + style = subtitle_style or SubtitleStyle() + subtitle_content = segments_to_ass( + segments, + use_translated=True, + font_size=style.font_size, + font_color=style.font_color.lstrip("#"), + outline_color=style.outline_color.lstrip("#"), + font_name=style.font_name, + position=style.position, # top, center, bottom + outline_width=style.outline_width, + bold=style.bold, + shadow=style.shadow, + background_box=style.background_box, + background_opacity=style.background_opacity, + animation=style.animation, + ) + + # Save subtitle file + job_dir = os.path.dirname(job.video_path) + subtitle_path = os.path.join(job_dir, "subtitle.ass") + with open(subtitle_path, "w", encoding="utf-8") as f: + f.write(subtitle_content) + + # Step 4: Process video + job_store.update_job(job_id, status=JobStatus.PROCESSING, progress=75) + + # Determine BGM path + bgm_path = None + if bgm_id: + bgm_path = os.path.join(settings.BGM_DIR, f"{bgm_id}.mp3") + if not os.path.exists(bgm_path): + bgm_path = os.path.join(settings.BGM_DIR, f"{bgm_id}.wav") + + # Output path + output_dir = os.path.join(settings.PROCESSED_DIR, job_id) + os.makedirs(output_dir, exist_ok=True) + output_path = os.path.join(output_dir, "output.mp4") + + success, message = await process_video( + input_path=job.video_path, + output_path=output_path, + subtitle_path=subtitle_path, + bgm_path=bgm_path, + bgm_volume=bgm_volume, + keep_original_audio=keep_original_audio, + ) + + if success: + job_store.update_job( + job_id, + status=JobStatus.COMPLETED, + output_path=output_path, + progress=100, + ) + else: + job_store.update_job(job_id, status=JobStatus.FAILED, error=message) + + except Exception as e: + job_store.update_job(job_id, status=JobStatus.FAILED, error=str(e)) + + +@router.post("/", response_model=ProcessResponse) +async def start_processing( + request: ProcessRequest, + background_tasks: BackgroundTasks +): + """Start video processing (transcribe, translate, add subtitles/BGM).""" + job = job_store.get_job(request.job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + if not job.video_path: + raise HTTPException(status_code=400, detail="Video not downloaded yet") + + if job.status == JobStatus.DOWNLOADING: + raise HTTPException(status_code=400, detail="Download still in progress") + + # Start background processing + background_tasks.add_task( + process_task, + request.job_id, + request.bgm_id, + request.bgm_volume, + request.subtitle_style, + request.keep_original_audio, + request.translation_mode, + request.use_vocal_separation, + ) + + mode_info = f" (mode: {request.translation_mode or settings.TRANSLATION_MODE})" + vocal_sep_info = ", vocal separation" if request.use_vocal_separation else "" + return ProcessResponse( + job_id=request.job_id, + status=JobStatus.TRANSCRIBING, + message=f"Processing started{mode_info}{vocal_sep_info}" + ) + + +@router.post("/{job_id}/transcribe") +async def transcribe_only(job_id: str, background_tasks: BackgroundTasks): + """Transcribe video only (without full processing).""" + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + if not job.video_path: + raise HTTPException(status_code=400, detail="Video not downloaded yet") + + async def transcribe_task(): + job_store.update_job(job_id, status=JobStatus.TRANSCRIBING, progress=35) + success, message, segments = await transcribe_video(job.video_path) + + if success: + job_store.update_job( + job_id, + transcript=segments, + status=JobStatus.PENDING, + progress=50, + ) + else: + job_store.update_job(job_id, status=JobStatus.FAILED, error=message) + + background_tasks.add_task(transcribe_task) + return {"message": "Transcription started"} + + +@router.put("/{job_id}/transcript") +async def update_transcript(job_id: str, segments: list[dict]): + """Update transcript segments (for manual editing).""" + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + from app.models.schemas import TranscriptSegment + updated_segments = [] + for seg in segments: + # Handle both dict and Pydantic model-like objects + if hasattr(seg, 'model_dump'): + seg = seg.model_dump() + updated_segments.append(TranscriptSegment(**seg)) + job_store.update_job(job_id, transcript=updated_segments) + + return {"message": "Transcript updated"} + + +async def continue_processing_task( + job_id: str, + bgm_id: str | None, + bgm_volume: float, + subtitle_style: SubtitleStyle | None, + keep_original_audio: bool, + skip_subtitle: bool = False, +): + """Continue processing for jobs that were awaiting subtitle input.""" + job = job_store.get_job(job_id) + if not job or not job.video_path: + job_store.update_job(job_id, status=JobStatus.FAILED, error="Job or video not found") + return + + try: + subtitle_path = None + + if not skip_subtitle and job.transcript: + # Generate subtitle file from existing transcript + job_store.update_job(job_id, status=JobStatus.PROCESSING, progress=60) + + style = subtitle_style or SubtitleStyle() + subtitle_content = segments_to_ass( + job.transcript, + use_translated=True, + font_size=style.font_size, + font_color=style.font_color.lstrip("#"), + outline_color=style.outline_color.lstrip("#"), + font_name=style.font_name, + position=style.position, + outline_width=style.outline_width, + bold=style.bold, + shadow=style.shadow, + background_box=style.background_box, + background_opacity=style.background_opacity, + animation=style.animation, + ) + + job_dir = os.path.dirname(job.video_path) + subtitle_path = os.path.join(job_dir, "subtitle.ass") + with open(subtitle_path, "w", encoding="utf-8") as f: + f.write(subtitle_content) + else: + # Skip subtitle - process with BGM only + job_store.update_job(job_id, status=JobStatus.PROCESSING, progress=60) + + # Process video + job_store.update_job(job_id, progress=75) + + bgm_path = None + if bgm_id: + bgm_path = os.path.join(settings.BGM_DIR, f"{bgm_id}.mp3") + if not os.path.exists(bgm_path): + bgm_path = os.path.join(settings.BGM_DIR, f"{bgm_id}.wav") + + output_dir = os.path.join(settings.PROCESSED_DIR, job_id) + os.makedirs(output_dir, exist_ok=True) + output_path = os.path.join(output_dir, "output.mp4") + + success, message = await process_video( + input_path=job.video_path, + output_path=output_path, + subtitle_path=subtitle_path, + bgm_path=bgm_path, + bgm_volume=bgm_volume, + keep_original_audio=keep_original_audio and job.has_audio, # Only keep if has audio + ) + + if success: + job_store.update_job( + job_id, + status=JobStatus.COMPLETED, + output_path=output_path, + progress=100, + ) + else: + job_store.update_job(job_id, status=JobStatus.FAILED, error=message) + + except Exception as e: + job_store.update_job(job_id, status=JobStatus.FAILED, error=str(e)) + + +@router.post("/{job_id}/continue") +async def continue_processing( + job_id: str, + request: ProcessRequest, + background_tasks: BackgroundTasks, +): + """Continue or reprocess a job (supports awaiting_subtitle and completed status).""" + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + # ํ—ˆ์šฉ๋œ ์ƒํƒœ: awaiting_subtitle (์Œ์„ฑ์—†์Œ), completed (์žฌ์ฒ˜๋ฆฌ) + allowed_statuses = [JobStatus.AWAITING_SUBTITLE, JobStatus.COMPLETED] + if job.status not in allowed_statuses: + raise HTTPException( + status_code=400, + detail=f"Job status must be 'awaiting_subtitle' or 'completed', current: {job.status}" + ) + + background_tasks.add_task( + continue_processing_task, + job_id, + request.bgm_id, + request.bgm_volume, + request.subtitle_style, + request.keep_original_audio, + skip_subtitle=not job.transcript, # Skip if no transcript + ) + + mode = "reprocessing" if job.status == JobStatus.COMPLETED else "no audio mode" + return ProcessResponse( + job_id=job_id, + status=JobStatus.PROCESSING, + message=f"Continuing processing ({mode})" + ) + + +@router.post("/{job_id}/manual-subtitle") +async def add_manual_subtitle( + job_id: str, + segments: list, + background_tasks: BackgroundTasks, +): + """Add manual subtitle segments and continue processing.""" + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + if job.status != JobStatus.AWAITING_SUBTITLE: + raise HTTPException( + status_code=400, + detail=f"Job status must be 'awaiting_subtitle', current: {job.status}" + ) + + # Parse and save the manual segments + from app.models.schemas import TranscriptSegment + manual_segments = [TranscriptSegment(**seg) for seg in segments] + job_store.update_job(job_id, transcript=manual_segments) + + return {"message": "Manual subtitles added", "segment_count": len(manual_segments)} + + +@router.get("/{job_id}/video-info", response_model=VideoInfoResponse) +async def get_job_video_info(job_id: str): + """Get video information for trimming UI.""" + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + if not job.video_path or not os.path.exists(job.video_path): + raise HTTPException(status_code=400, detail="Video file not found") + + info = await get_video_info(job.video_path) + if not info: + # Fallback to just duration + duration = await get_video_duration(job.video_path) + if duration is None: + raise HTTPException(status_code=500, detail="Could not get video info") + info = {"duration": duration} + + return VideoInfoResponse( + duration=info.get("duration", 0), + width=info.get("width"), + height=info.get("height"), + ) + + +@router.get("/{job_id}/frame") +async def get_frame_at_timestamp(job_id: str, timestamp: float): + """ + Extract a single frame at the specified timestamp. + Returns the frame as a JPEG image. + Used for precise trimming preview. + """ + from fastapi.responses import FileResponse + import tempfile + + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + if not job.video_path or not os.path.exists(job.video_path): + raise HTTPException(status_code=400, detail="Video file not found") + + # Get video duration to validate timestamp + duration = await get_video_duration(job.video_path) + if duration is None: + raise HTTPException(status_code=500, detail="Could not get video duration") + + # Clamp timestamp to valid range + timestamp = max(0, min(timestamp, duration - 0.01)) + + # Create temp file for frame + temp_dir = tempfile.gettempdir() + frame_path = os.path.join(temp_dir, f"frame_{job_id}_{timestamp:.3f}.jpg") + + success, message = await extract_frame(job.video_path, frame_path, timestamp) + + if not success: + raise HTTPException(status_code=500, detail=message) + + return FileResponse( + path=frame_path, + media_type="image/jpeg", + headers={ + "Cache-Control": "public, max-age=60", + } + ) + + +@router.post("/{job_id}/trim", response_model=TrimResponse) +async def trim_job_video( + job_id: str, + request: TrimRequest, + background_tasks: BackgroundTasks, +): + """ + Trim video to specified time range and optionally reprocess. + + This creates a new trimmed video file, replacing the original. + Use this to remove sections with unwanted content (like original subtitles). + """ + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + if not job.video_path or not os.path.exists(job.video_path): + raise HTTPException(status_code=400, detail="Video file not found") + + # Validate time range + duration = await get_video_duration(job.video_path) + if duration is None: + raise HTTPException(status_code=500, detail="Could not get video duration") + + if request.start_time < 0 or request.end_time > duration: + raise HTTPException( + status_code=400, + detail=f"Invalid time range. Video duration is {duration:.1f}s" + ) + if request.start_time >= request.end_time: + raise HTTPException( + status_code=400, + detail="Start time must be less than end time" + ) + + # Update status + job_store.update_job(job_id, status=JobStatus.TRIMMING, progress=5) + + # Create trimmed video path + video_dir = os.path.dirname(job.video_path) + video_ext = os.path.splitext(job.video_path)[1] + trimmed_path = os.path.join(video_dir, f"trimmed{video_ext}") + + # Perform trim + success, message = await trim_video( + job.video_path, + trimmed_path, + request.start_time, + request.end_time, + ) + + if not success: + job_store.update_job(job_id, status=JobStatus.FAILED, error=message) + return TrimResponse( + job_id=job_id, + success=False, + message=message, + ) + + # Replace original with trimmed video + original_path = job.video_path + backup_path = os.path.join(video_dir, f"original_backup{video_ext}") + + try: + # Backup original + os.rename(original_path, backup_path) + # Move trimmed to original location + os.rename(trimmed_path, original_path) + # Remove backup + os.remove(backup_path) + except Exception as e: + job_store.update_job(job_id, status=JobStatus.FAILED, error=f"File operation failed: {e}") + return TrimResponse( + job_id=job_id, + success=False, + message=f"File operation failed: {e}", + ) + + # Get new duration + new_duration = await get_video_duration(original_path) + + # Reset job state for next step + job_store.update_job( + job_id, + status=JobStatus.READY_FOR_TRIM, # Stay in trim-ready state for manual workflow + progress=30, + transcript=None, # Clear old transcript + output_path=None, # Clear old output + error=None, + ) + + # Optionally start reprocessing + if request.reprocess: + background_tasks.add_task( + process_task, + job_id, + None, # bgm_id + 0.3, # bgm_volume + None, # subtitle_style + True, # keep_original_audio + None, # translation_mode + False, # use_vocal_separation + ) + message = f"Video trimmed to {new_duration:.1f}s. Reprocessing started." + else: + message = f"Video trimmed to {new_duration:.1f}s. Ready for processing." + + return TrimResponse( + job_id=job_id, + success=True, + message=message, + new_duration=new_duration, + ) + + +# ============================================================ +# Step-by-step Processing API (Manual Workflow) +# ============================================================ + +async def transcribe_step_task( + job_id: str, + translation_mode: str | None = None, + use_vocal_separation: bool = False, +): + """Background task for transcription step only (audio extraction + STT + translation).""" + job = job_store.get_job(job_id) + if not job or not job.video_path: + job_store.update_job(job_id, status=JobStatus.FAILED, error="Job or video not found") + return + + try: + # Progress callback for real-time status updates + async def progress_callback(step: str, progress: int): + step_to_status = { + "vocal_separation": JobStatus.EXTRACTING_AUDIO, + "extracting_audio": JobStatus.EXTRACTING_AUDIO, + "noise_reduction": JobStatus.NOISE_REDUCTION, + "transcribing": JobStatus.TRANSCRIBING, + } + status = step_to_status.get(step, JobStatus.TRANSCRIBING) + print(f"[Progress] Step: {step} -> Status: {status}, Progress: {progress}%") + job_store.update_job(job_id, status=status, progress=progress) + + # Start with initial status + job_store.update_job(job_id, status=JobStatus.EXTRACTING_AUDIO, progress=10) + + success, message, segments, detected_lang = await transcribe_video( + job.video_path, + use_noise_reduction=True, + noise_reduction_level="medium", + use_vocal_separation=use_vocal_separation, + progress_callback=progress_callback, + ) + + # Handle special cases + if not success: + if message in ("NO_AUDIO", "SILENT_AUDIO", "SINGING_ONLY"): + if message == "NO_AUDIO": + audio_status = "no_audio_stream" + elif message == "SILENT_AUDIO": + audio_status = "audio_silent" + else: + audio_status = "singing_only" + + job_store.update_job( + job_id, + status=JobStatus.AWAITING_SUBTITLE, + progress=35, + has_audio=message != "NO_AUDIO", + audio_status=audio_status, + ) + return + else: + job_store.update_job(job_id, status=JobStatus.FAILED, error=message) + return + + # Audio OK - speech detected and transcribed + job_store.update_job(job_id, transcript=segments, progress=50, has_audio=True, audio_status="ok", detected_language=detected_lang) + + # Translate/Rewrite (only for Chinese content) + is_chinese = detected_lang and detected_lang.lower() in ['zh', 'zh-cn', 'zh-tw', 'chinese', 'mandarin'] + + if is_chinese: + job_store.update_job(job_id, status=JobStatus.TRANSLATING, progress=55) + + mode = translation_mode or settings.TRANSLATION_MODE + success, message, segments = await translate_segments( + segments, + mode=mode, + max_tokens=settings.TRANSLATION_MAX_TOKENS, + ) + if not success: + print(f"Translation warning: {message}") + + job_store.update_job(job_id, transcript=segments, progress=70) + else: + # Skip translation for non-Chinese content + print(f"Skipping GPT translation for non-Chinese content (detected: {detected_lang})") + for seg in segments: + seg.translated = seg.text + job_store.update_job(job_id, transcript=segments, progress=70) + + # STOP HERE - Set status to AWAITING_REVIEW for user to review script + job_store.update_job( + job_id, + status=JobStatus.AWAITING_REVIEW, + progress=70, + ) + + except Exception as e: + job_store.update_job(job_id, status=JobStatus.FAILED, error=str(e)) + + +async def render_step_task( + job_id: str, + bgm_id: str | None, + bgm_volume: float, + subtitle_style: SubtitleStyle | None, + keep_original_audio: bool, + intro_text: str | None = None, + intro_duration: float = 0.7, + intro_font_size: int = 100, +): + """Background task for final video rendering (subtitle composition + BGM + intro text).""" + job = job_store.get_job(job_id) + if not job or not job.video_path: + job_store.update_job(job_id, status=JobStatus.FAILED, error="Job or video not found") + return + + try: + subtitle_path = None + + if job.transcript: + # Generate subtitle file from transcript + job_store.update_job(job_id, status=JobStatus.PROCESSING, progress=75) + + style = subtitle_style or SubtitleStyle() + # When intro text is shown, delay subtitles so they don't overlap + subtitle_offset = intro_duration if intro_text else 0.0 + subtitle_content = segments_to_ass( + job.transcript, + use_translated=True, + font_size=style.font_size, + font_color=style.font_color.lstrip("#"), + outline_color=style.outline_color.lstrip("#"), + font_name=style.font_name, + position=style.position, + outline_width=style.outline_width, + bold=style.bold, + shadow=style.shadow, + background_box=style.background_box, + background_opacity=style.background_opacity, + animation=style.animation, + time_offset=subtitle_offset, + ) + + job_dir = os.path.dirname(job.video_path) + subtitle_path = os.path.join(job_dir, "subtitle.ass") + with open(subtitle_path, "w", encoding="utf-8") as f: + f.write(subtitle_content) + else: + # No subtitle - BGM only mode + job_store.update_job(job_id, status=JobStatus.PROCESSING, progress=75) + + # Determine BGM path + bgm_path = None + if bgm_id: + bgm_path = os.path.join(settings.BGM_DIR, f"{bgm_id}.mp3") + if not os.path.exists(bgm_path): + bgm_path = os.path.join(settings.BGM_DIR, f"{bgm_id}.wav") + + # Output path + output_dir = os.path.join(settings.PROCESSED_DIR, job_id) + os.makedirs(output_dir, exist_ok=True) + output_path = os.path.join(output_dir, "output.mp4") + + job_store.update_job(job_id, progress=80) + + success, message = await process_video( + input_path=job.video_path, + output_path=output_path, + subtitle_path=subtitle_path, + bgm_path=bgm_path, + bgm_volume=bgm_volume, + keep_original_audio=keep_original_audio, + intro_text=intro_text, + intro_duration=intro_duration, + intro_font_size=intro_font_size, + ) + + if success: + job_store.update_job( + job_id, + status=JobStatus.COMPLETED, + output_path=output_path, + progress=100, + ) + else: + job_store.update_job(job_id, status=JobStatus.FAILED, error=message) + + except Exception as e: + job_store.update_job(job_id, status=JobStatus.FAILED, error=str(e)) + + +@router.post("/{job_id}/start-transcription", response_model=ProcessResponse) +async def start_transcription( + job_id: str, + request: TranscribeRequest, + background_tasks: BackgroundTasks, +): + """ + Start transcription step (audio extraction + STT + translation). + After completion, job status will be 'awaiting_review' for user to review the script. + """ + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + if not job.video_path: + raise HTTPException(status_code=400, detail="Video not downloaded yet") + + # Allow starting from ready_for_trim or pending status + allowed_statuses = [JobStatus.READY_FOR_TRIM, JobStatus.PENDING] + if job.status not in allowed_statuses: + raise HTTPException( + status_code=400, + detail=f"Job status must be 'ready_for_trim' or 'pending', current: {job.status}" + ) + + background_tasks.add_task( + transcribe_step_task, + job_id, + request.translation_mode, + request.use_vocal_separation, + ) + + return ProcessResponse( + job_id=job_id, + status=JobStatus.EXTRACTING_AUDIO, + message="Transcription started. Script will be ready for review." + ) + + +@router.post("/{job_id}/render", response_model=ProcessResponse) +async def render_video( + job_id: str, + request: RenderRequest, + background_tasks: BackgroundTasks, +): + """ + Render final video with subtitles and BGM. + Call this after reviewing and editing the script. + """ + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + if not job.video_path: + raise HTTPException(status_code=400, detail="Video not found") + + # Allow rendering from awaiting_review, awaiting_subtitle, or completed (re-render) + allowed_statuses = [JobStatus.AWAITING_REVIEW, JobStatus.AWAITING_SUBTITLE, JobStatus.COMPLETED] + if job.status not in allowed_statuses: + raise HTTPException( + status_code=400, + detail=f"Job status must be 'awaiting_review', 'awaiting_subtitle', or 'completed', current: {job.status}" + ) + + background_tasks.add_task( + render_step_task, + job_id, + request.bgm_id, + request.bgm_volume, + request.subtitle_style, + request.keep_original_audio, + request.intro_text, + request.intro_duration, + request.intro_font_size, + ) + + return ProcessResponse( + job_id=job_id, + status=JobStatus.PROCESSING, + message="Final video rendering started." + ) + + +@router.post("/{job_id}/retranslate") +async def retranslate(job_id: str, background_tasks: BackgroundTasks): + """ + Re-run GPT translation on existing transcript segments. + Useful when user wants to regenerate the Korean script. + """ + import logging + logger = logging.getLogger(__name__) + + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + if not job.transcript: + raise HTTPException(status_code=400, detail="No transcript found. Run transcription first.") + + # Copy segments to avoid reference issues + segments_copy = [seg.model_copy() for seg in job.transcript] + logger.info(f"[Retranslate] Starting for job {job_id} with {len(segments_copy)} segments") + + async def retranslate_task(): + try: + logger.info(f"[Retranslate] Background task started for job {job_id}") + job_store.update_job(job_id, status=JobStatus.TRANSLATING, progress=55) + + # Reset translations + for seg in segments_copy: + seg.translated = None + + mode = settings.TRANSLATION_MODE + logger.info(f"[Retranslate] Using mode: {mode}") + + success, message, translated_segments = await translate_segments( + segments_copy, + mode=mode, + max_tokens=settings.TRANSLATION_MAX_TOKENS, + ) + + logger.info(f"[Retranslate] Translation result: success={success}, message={message}") + + if success: + job_store.update_job( + job_id, + transcript=translated_segments, + status=JobStatus.AWAITING_REVIEW, + progress=70, + ) + logger.info(f"[Retranslate] Job {job_id} updated with new translation") + else: + job_store.update_job(job_id, status=JobStatus.FAILED, error=message) + logger.error(f"[Retranslate] Translation failed: {message}") + + except Exception as e: + logger.exception(f"[Retranslate] Exception in retranslate_task: {e}") + job_store.update_job(job_id, status=JobStatus.FAILED, error=str(e)) + + # Use asyncio.create_task to properly run async background task + import asyncio + asyncio.create_task(retranslate_task()) + + return {"message": "Re-translation started", "job_id": job_id} + + +@router.post("/{job_id}/skip-trim", response_model=ProcessResponse) +async def skip_trim(job_id: str): + """ + Skip the trimming step and proceed to transcription. + Updates status from 'ready_for_trim' to 'pending'. + """ + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + if job.status != JobStatus.READY_FOR_TRIM: + raise HTTPException( + status_code=400, + detail=f"Job status must be 'ready_for_trim', current: {job.status}" + ) + + job_store.update_job(job_id, status=JobStatus.PENDING) + + return ProcessResponse( + job_id=job_id, + status=JobStatus.PENDING, + message="Trim skipped. Ready for transcription." + ) + + +# ============================================================ +# Thumbnail Generation API +# ============================================================ + +@router.get("/{job_id}/thumbnail-timestamps") +async def get_thumbnail_timestamps(job_id: str, count: int = 5): + """ + Get suggested timestamps for thumbnail frame selection. + Returns evenly distributed timestamps from the video. + """ + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + if not job.video_path or not os.path.exists(job.video_path): + raise HTTPException(status_code=400, detail="Video file not found") + + timestamps = await get_video_timestamps(job.video_path, count) + + return { + "job_id": job_id, + "timestamps": timestamps, + "count": len(timestamps), + } + + +@router.post("/{job_id}/generate-catchphrase") +async def api_generate_catchphrase(job_id: str, style: str = "homeshopping"): + """ + Generate a catchy thumbnail text using GPT. + + Available styles: + - homeshopping: ํ™ˆ์‡ผํ•‘ ์Šคํƒ€์ผ (๊ฐ•๋ ฌํ•œ ์–ดํ•„) + - viral: ๋ฐ”์ด๋Ÿด ์Šคํƒ€์ผ (ํ˜ธ๊ธฐ์‹ฌ ์œ ๋ฐœ) + - informative: ์ •๋ณด์„ฑ ์Šคํƒ€์ผ (๋ช…ํ™•ํ•œ ์ „๋‹ฌ) + """ + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + if not job.transcript: + raise HTTPException(status_code=400, detail="No transcript found. Run transcription first.") + + success, message, catchphrase = await generate_catchphrase(job.transcript, style) + + if not success: + raise HTTPException(status_code=500, detail=message) + + return { + "job_id": job_id, + "catchphrase": catchphrase, + "style": style, + } + + +@router.post("/{job_id}/thumbnail") +async def create_thumbnail( + job_id: str, + timestamp: float = 2.0, + style: str = "homeshopping", + custom_text: str | None = None, + font_size: int = 80, + position: str = "center", +): + """ + Generate a thumbnail with text overlay. + + Args: + timestamp: Time in seconds to extract frame (default: 2.0) + style: Catchphrase style (homeshopping, viral, informative) + custom_text: Custom text to use (skip GPT generation if provided) + font_size: Font size for text overlay (default: 80) + position: Text position (top, center, bottom) + + Returns: + Thumbnail file path and the text used + """ + job = job_store.get_job(job_id) + + if not job: + raise HTTPException(status_code=404, detail="Job not found") + if not job.video_path or not os.path.exists(job.video_path): + raise HTTPException(status_code=400, detail="Video file not found") + + # Use empty transcript if not available + transcript = job.transcript or [] + + success, message, thumbnail_path = await generate_thumbnail( + job_id=job_id, + video_path=job.video_path, + transcript=transcript, + timestamp=timestamp, + style=style, + custom_text=custom_text, + font_size=font_size, + position=position, + ) + + if not success: + raise HTTPException(status_code=500, detail=message) + + # Extract text from message + text_used = message.replace("Thumbnail generated: ", "") + + return { + "job_id": job_id, + "thumbnail_path": thumbnail_path, + "thumbnail_url": f"/api/jobs/{job_id}/thumbnail", + "text": text_used, + "timestamp": timestamp, + } diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..0a41bac --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,15 @@ +from app.services.downloader import download_video, detect_platform, get_video_info +from app.services.transcriber import transcribe_video, segments_to_srt, segments_to_ass +from app.services.translator import ( + translate_segments, + translate_single, + generate_shorts_script, + TranslationMode, +) +from app.services.video_processor import ( + process_video, + get_video_duration, + extract_audio, + extract_audio_with_noise_reduction, + analyze_audio_noise_level, +) diff --git a/backend/app/services/audio_separator.py b/backend/app/services/audio_separator.py new file mode 100644 index 0000000..8c12a80 --- /dev/null +++ b/backend/app/services/audio_separator.py @@ -0,0 +1,317 @@ +""" +Audio separation service using Demucs for vocal/music separation. +Also includes speech vs singing detection. +""" +import subprocess +import os +import shutil +from typing import Optional, Tuple +from pathlib import Path + +# Demucs runs in a separate Python 3.11 environment due to compatibility issues +DEMUCS_VENV_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "venv_demucs" +) +DEMUCS_PYTHON = os.path.join(DEMUCS_VENV_PATH, "bin", "python") + + +async def separate_vocals( + input_path: str, + output_dir: str, + model: str = "htdemucs" +) -> Tuple[bool, str, Optional[str], Optional[str]]: + """ + Separate vocals from background music using Demucs. + + Args: + input_path: Path to input audio/video file + output_dir: Directory to save separated tracks + model: Demucs model to use (htdemucs, htdemucs_ft, mdx_extra) + + Returns: + Tuple of (success, message, vocals_path, no_vocals_path) + """ + if not os.path.exists(input_path): + return False, f"Input file not found: {input_path}", None, None + + os.makedirs(output_dir, exist_ok=True) + + # Check if Demucs venv exists + if not os.path.exists(DEMUCS_PYTHON): + return False, f"Demucs environment not found at {DEMUCS_VENV_PATH}. Run setup script.", None, None + + # Run Demucs for two-stem separation (vocals vs accompaniment) + cmd = [ + DEMUCS_PYTHON, "-m", "demucs", + "--two-stems=vocals", + "-n", model, + "-o", output_dir, + input_path + ] + + try: + print(f"Running Demucs separation: {' '.join(cmd)}") + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=600, # 10 minute timeout + ) + + if result.returncode != 0: + error_msg = result.stderr[-500:] if result.stderr else "Unknown error" + return False, f"Demucs error: {error_msg}", None, None + + # Find output files + # Demucs outputs to: output_dir/model_name/track_name/vocals.wav, no_vocals.wav + input_name = Path(input_path).stem + demucs_output = os.path.join(output_dir, model, input_name) + + vocals_path = os.path.join(demucs_output, "vocals.wav") + no_vocals_path = os.path.join(demucs_output, "no_vocals.wav") + + if not os.path.exists(vocals_path): + return False, "Vocals file not created", None, None + + # Move files to simpler location + final_vocals = os.path.join(output_dir, "vocals.wav") + final_no_vocals = os.path.join(output_dir, "no_vocals.wav") + + shutil.move(vocals_path, final_vocals) + if os.path.exists(no_vocals_path): + shutil.move(no_vocals_path, final_no_vocals) + + # Clean up Demucs output directory + shutil.rmtree(os.path.join(output_dir, model), ignore_errors=True) + + return True, "Vocals separated successfully", final_vocals, final_no_vocals + + except subprocess.TimeoutExpired: + return False, "Separation timed out", None, None + except FileNotFoundError: + return False, "Demucs not installed. Run: pip install demucs", None, None + except Exception as e: + return False, f"Separation error: {str(e)}", None, None + + +async def analyze_vocal_type( + vocals_path: str, + speech_threshold: float = 0.7 +) -> Tuple[str, float]: + """ + Analyze if vocal track contains speech or singing. + + Uses multiple heuristics: + 1. Speech has more silence gaps (pauses between words) + 2. Speech has more varied pitch changes + 3. Singing has more sustained notes + + Args: + vocals_path: Path to vocals audio file + speech_threshold: Threshold for speech detection (0-1) + + Returns: + Tuple of (vocal_type, confidence) + vocal_type: "speech", "singing", or "mixed" + """ + if not os.path.exists(vocals_path): + return "unknown", 0.0 + + # Analyze silence ratio using FFmpeg + # Speech typically has 30-50% silence, singing has less + silence_ratio = await _get_silence_ratio(vocals_path) + + # Analyze zero-crossing rate (speech has higher ZCR variance) + zcr_variance = await _get_zcr_variance(vocals_path) + + # Analyze spectral flatness (speech has higher flatness) + spectral_score = await _get_spectral_analysis(vocals_path) + + # Combine scores + speech_score = 0.0 + + # High silence ratio indicates speech (pauses between sentences) + if silence_ratio > 0.25: + speech_score += 0.4 + elif silence_ratio > 0.15: + speech_score += 0.2 + + # High spectral variance indicates speech + if spectral_score > 0.5: + speech_score += 0.3 + elif spectral_score > 0.3: + speech_score += 0.15 + + # ZCR variance + if zcr_variance > 0.5: + speech_score += 0.3 + elif zcr_variance > 0.3: + speech_score += 0.15 + + # Determine type + # speech_threshold=0.7: High confidence speech + # singing_threshold=0.4: Below this is likely singing (music) + # Between 0.4-0.7: Mixed or uncertain + if speech_score >= speech_threshold: + return "speech", speech_score + elif speech_score < 0.4: + return "singing", 1.0 - speech_score + else: + # For mixed, lean towards singing if score is closer to lower bound + # This helps avoid transcribing song lyrics as speech + return "mixed", speech_score + + +async def _get_silence_ratio(audio_path: str, threshold_db: float = -35) -> float: + """Get ratio of silence in audio file.""" + cmd = [ + "ffmpeg", "-i", audio_path, + "-af", f"silencedetect=noise={threshold_db}dB:d=0.3", + "-f", "null", "-" + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + stderr = result.stderr + + # Count silence periods + silence_count = stderr.count("silence_end") + + # Get total duration + duration = await _get_audio_duration(audio_path) + if not duration or duration == 0: + return 0.0 + + # Parse total silence duration + total_silence = 0.0 + lines = stderr.split('\n') + for line in lines: + if 'silence_duration' in line: + try: + dur = float(line.split('silence_duration:')[1].strip().split()[0]) + total_silence += dur + except (IndexError, ValueError): + pass + + return min(total_silence / duration, 1.0) + + except Exception: + return 0.0 + + +async def _get_zcr_variance(audio_path: str) -> float: + """Get zero-crossing rate variance (simplified estimation).""" + # Use FFmpeg to analyze audio stats + cmd = [ + "ffmpeg", "-i", audio_path, + "-af", "astats=metadata=1:reset=1", + "-f", "null", "-" + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + stderr = result.stderr + + # Look for RMS level variations as proxy for ZCR variance + rms_values = [] + for line in stderr.split('\n'): + if 'RMS_level' in line: + try: + val = float(line.split(':')[1].strip().split()[0]) + if val != float('-inf'): + rms_values.append(val) + except (IndexError, ValueError): + pass + + if len(rms_values) > 1: + mean_rms = sum(rms_values) / len(rms_values) + variance = sum((x - mean_rms) ** 2 for x in rms_values) / len(rms_values) + # Normalize to 0-1 range + return min(variance / 100, 1.0) + + return 0.3 # Default moderate value + + except Exception: + return 0.3 + + +async def _get_spectral_analysis(audio_path: str) -> float: + """Analyze spectral characteristics (speech has more flat spectrum).""" + # Use volume detect as proxy for spectral analysis + cmd = [ + "ffmpeg", "-i", audio_path, + "-af", "volumedetect", + "-f", "null", "-" + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + stderr = result.stderr + + mean_vol = None + max_vol = None + + for line in stderr.split('\n'): + if 'mean_volume' in line: + try: + mean_vol = float(line.split(':')[1].strip().replace(' dB', '')) + except (IndexError, ValueError): + pass + elif 'max_volume' in line: + try: + max_vol = float(line.split(':')[1].strip().replace(' dB', '')) + except (IndexError, ValueError): + pass + + if mean_vol is not None and max_vol is not None: + # Large difference between mean and max indicates speech dynamics + diff = abs(max_vol - mean_vol) + # Speech typically has 15-25dB dynamic range + if diff > 20: + return 0.7 + elif diff > 12: + return 0.5 + else: + return 0.2 + + return 0.3 + + except Exception: + return 0.3 + + +async def _get_audio_duration(audio_path: str) -> Optional[float]: + """Get audio duration in seconds.""" + cmd = [ + "ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + audio_path + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode == 0: + return float(result.stdout.strip()) + except Exception: + pass + + return None + + +async def check_demucs_available() -> bool: + """Check if Demucs is installed in the dedicated environment.""" + if not os.path.exists(DEMUCS_PYTHON): + return False + + try: + result = subprocess.run( + [DEMUCS_PYTHON, "-m", "demucs", "--help"], + capture_output=True, + timeout=10 + ) + return result.returncode == 0 + except Exception: + return False diff --git a/backend/app/services/bgm_provider.py b/backend/app/services/bgm_provider.py new file mode 100644 index 0000000..eed7554 --- /dev/null +++ b/backend/app/services/bgm_provider.py @@ -0,0 +1,495 @@ +""" +BGM Provider Service - Freesound & Pixabay Integration + +Freesound API: https://freesound.org/docs/api/ +- 500,000+ Creative Commons licensed sounds +- Free API with generous rate limits +- Various licenses (CC0, CC-BY, CC-BY-NC, etc.) + +Pixabay: Manual download recommended (no public Music API) +""" + +import os +import httpx +import aiofiles +from typing import Optional, List, Tuple +from pydantic import BaseModel +from app.config import settings + + +class FreesoundTrack(BaseModel): + """Freesound track model.""" + id: int + name: str + duration: float # seconds + tags: List[str] + license: str + username: str + preview_url: str # HQ preview (128kbps mp3) + download_url: str # Original file (requires auth) + description: str = "" + + +class BGMSearchResult(BaseModel): + """BGM search result.""" + id: str + title: str + duration: int + tags: List[str] + preview_url: str + download_url: str = "" + license: str = "" + source: str = "freesound" + + +# Freesound license filters for commercial use +# CC0 and CC-BY are commercially usable, CC-BY-NC is NOT +COMMERCIAL_LICENSES = [ + "Creative Commons 0", # CC0 - Public Domain + "Attribution", # CC-BY - Attribution required + "Attribution Noncommercial", # Exclude this (NOT commercial) +] + +# License filter string for commercial-only search +COMMERCIAL_LICENSE_FILTER = 'license:"Creative Commons 0" OR license:"Attribution"' + + +async def search_freesound( + query: str, + min_duration: int = 10, + max_duration: int = 180, # Shorts typically < 60s, allow some buffer + page: int = 1, + page_size: int = 15, + filter_music: bool = True, + commercial_only: bool = True, # Default: only commercially usable +) -> Tuple[bool, str, List[BGMSearchResult]]: + """ + Search for sounds on Freesound API. + + Args: + query: Search keywords (e.g., "upbeat music", "chill background") + min_duration: Minimum duration in seconds + max_duration: Maximum duration in seconds + page: Page number (1-indexed) + page_size: Results per page (max 150) + filter_music: Add "music" to query for better BGM results + commercial_only: Only return commercially usable licenses (CC0, CC-BY) + + Returns: + Tuple of (success, message, results) + """ + api_key = settings.FREESOUND_API_KEY + if not api_key: + return False, "Freesound API key not configured. Get one at https://freesound.org/apiv2/apply", [] + + # Add "music" filter for better BGM results + search_query = f"{query} music" if filter_music and "music" not in query.lower() else query + + # Build filter string for duration and license + filter_parts = [f"duration:[{min_duration} TO {max_duration}]"] + + if commercial_only: + # Filter for commercially usable licenses only + # CC0 (Creative Commons 0) and CC-BY (Attribution) are commercial-OK + # Exclude CC-BY-NC (Noncommercial) + filter_parts.append('license:"Creative Commons 0"') + + filter_str = " ".join(filter_parts) + + params = { + "token": api_key, + "query": search_query, + "filter": filter_str, + "page": page, + "page_size": min(page_size, 150), + "fields": "id,name,duration,tags,license,username,previews,description", + "sort": "score", # relevance + } + + try: + async with httpx.AsyncClient() as client: + response = await client.get( + "https://freesound.org/apiv2/search/text/", + params=params, + timeout=30, + ) + + if response.status_code == 401: + return False, "Invalid Freesound API key", [] + + if response.status_code != 200: + return False, f"Freesound API error: HTTP {response.status_code}", [] + + data = response.json() + results = [] + + for sound in data.get("results", []): + # Get preview URLs (prefer high quality) + previews = sound.get("previews", {}) + preview_url = ( + previews.get("preview-hq-mp3") or + previews.get("preview-lq-mp3") or + "" + ) + + # Parse license for display + license_url = sound.get("license", "") + license_name = _parse_freesound_license(license_url) + + results.append(BGMSearchResult( + id=str(sound["id"]), + title=sound.get("name", "Unknown"), + duration=int(sound.get("duration", 0)), + tags=sound.get("tags", [])[:10], # Limit tags + preview_url=preview_url, + download_url=f"https://freesound.org/apiv2/sounds/{sound['id']}/download/", + license=license_name, + source="freesound", + )) + + total = data.get("count", 0) + license_info = " (commercial use OK)" if commercial_only else "" + message = f"Found {total} sounds on Freesound{license_info}" + + return True, message, results + + except httpx.TimeoutException: + return False, "Freesound API timeout", [] + except Exception as e: + return False, f"Freesound search error: {str(e)}", [] + + +def _parse_freesound_license(license_url: str) -> str: + """Parse Freesound license URL to human-readable name.""" + if "zero" in license_url or "cc0" in license_url.lower(): + return "CC0 (Public Domain)" + elif "by-nc" in license_url: + return "CC BY-NC (Non-Commercial)" + elif "by-sa" in license_url: + return "CC BY-SA (Share Alike)" + elif "by/" in license_url: + return "CC BY (Attribution)" + elif "sampling+" in license_url: + return "Sampling+" + else: + return "See License" + + +async def download_freesound( + sound_id: str, + output_dir: str, + filename: str, +) -> Tuple[bool, str, Optional[str]]: + """ + Download a sound from Freesound. + + Note: Freesound requires OAuth for original file downloads. + This function downloads the HQ preview (128kbps MP3) which is sufficient for BGM. + + Args: + sound_id: Freesound sound ID + output_dir: Directory to save file + filename: Output filename (without extension) + + Returns: + Tuple of (success, message, file_path) + """ + api_key = settings.FREESOUND_API_KEY + if not api_key: + return False, "Freesound API key not configured", None + + try: + async with httpx.AsyncClient() as client: + # First, get sound info to get preview URL + info_response = await client.get( + f"https://freesound.org/apiv2/sounds/{sound_id}/", + params={ + "token": api_key, + "fields": "id,name,previews,license,username", + }, + timeout=30, + ) + + if info_response.status_code != 200: + return False, f"Failed to get sound info: HTTP {info_response.status_code}", None + + sound_data = info_response.json() + previews = sound_data.get("previews", {}) + + # Get high quality preview URL + preview_url = previews.get("preview-hq-mp3") + if not preview_url: + preview_url = previews.get("preview-lq-mp3") + + if not preview_url: + return False, "No preview URL available", None + + # Download the preview + audio_response = await client.get(preview_url, timeout=60, follow_redirects=True) + + if audio_response.status_code != 200: + return False, f"Download failed: HTTP {audio_response.status_code}", None + + # Save file + os.makedirs(output_dir, exist_ok=True) + file_path = os.path.join(output_dir, f"{filename}.mp3") + + async with aiofiles.open(file_path, 'wb') as f: + await f.write(audio_response.content) + + # Get attribution info + username = sound_data.get("username", "Unknown") + license_name = _parse_freesound_license(sound_data.get("license", "")) + + return True, f"Downloaded from Freesound (by {username}, {license_name})", file_path + + except httpx.TimeoutException: + return False, "Download timeout", None + except Exception as e: + return False, f"Download error: {str(e)}", None + + +async def search_and_download_bgm( + keywords: List[str], + output_dir: str, + max_duration: int = 120, + commercial_only: bool = True, +) -> Tuple[bool, str, Optional[str], Optional[BGMSearchResult]]: + """ + Search for BGM and download the best match. + + Args: + keywords: Search keywords from BGM recommendation + output_dir: Directory to save downloaded file + max_duration: Maximum duration in seconds + commercial_only: Only search commercially usable licenses (CC0) + + Returns: + Tuple of (success, message, file_path, matched_result) + """ + if not settings.FREESOUND_API_KEY: + return False, "Freesound API key not configured", None, None + + # Try searching with combined keywords + query = " ".join(keywords[:3]) + + success, message, results = await search_freesound( + query=query, + min_duration=15, + max_duration=max_duration, + page_size=10, + commercial_only=commercial_only, + ) + + if not success or not results: + # Try with individual keywords + for keyword in keywords[:3]: + success, message, results = await search_freesound( + query=keyword, + min_duration=15, + max_duration=max_duration, + page_size=5, + commercial_only=commercial_only, + ) + if success and results: + break + + if not results: + return False, "No matching BGM found on Freesound", None, None + + # Select the best result (first one, sorted by relevance) + best_match = results[0] + + # Download it + safe_filename = best_match.title.lower().replace(" ", "_")[:50] + safe_filename = "".join(c for c in safe_filename if c.isalnum() or c == "_") + + success, download_msg, file_path = await download_freesound( + sound_id=best_match.id, + output_dir=output_dir, + filename=safe_filename, + ) + + if not success: + return False, download_msg, None, best_match + + return True, download_msg, file_path, best_match + + +async def search_pixabay_music( + query: str = "", + category: str = "", + min_duration: int = 0, + max_duration: int = 120, + page: int = 1, + per_page: int = 20, +) -> Tuple[bool, str, List[BGMSearchResult]]: + """ + Search for royalty-free music on Pixabay. + Note: Pixabay doesn't have a public Music API, returns curated list instead. + """ + # Pixabay's music API is not publicly available + # Return curated recommendations instead + return await _get_curated_bgm_list(query) + + +async def _get_curated_bgm_list(query: str = "") -> Tuple[bool, str, List[BGMSearchResult]]: + """ + Return curated list of recommended free BGM sources. + Since Pixabay Music API requires special access, we provide curated recommendations. + """ + # Curated BGM recommendations (these are categories/suggestions, not actual files) + curated_bgm = [ + { + "id": "upbeat_energetic", + "title": "Upbeat & Energetic", + "duration": 60, + "tags": ["upbeat", "energetic", "happy", "positive"], + "description": "ํ™œ๊ธฐ์ฐฌ ์‡ผ์ธ ์— ์ ํ•ฉ", + }, + { + "id": "chill_lofi", + "title": "Chill Lo-Fi", + "duration": 60, + "tags": ["chill", "lofi", "relaxing", "calm"], + "description": "ํŽธ์•ˆํ•œ ๋ถ„์œ„๊ธฐ์˜ ์ฝ˜ํ…์ธ ", + }, + { + "id": "epic_cinematic", + "title": "Epic & Cinematic", + "duration": 60, + "tags": ["epic", "cinematic", "dramatic", "intense"], + "description": "๋“œ๋ผ๋งˆํ‹ฑํ•œ ์ˆœ๊ฐ„", + }, + { + "id": "funny_quirky", + "title": "Funny & Quirky", + "duration": 30, + "tags": ["funny", "quirky", "comedy", "playful"], + "description": "์œ ๋จธ๋Ÿฌ์Šคํ•œ ์ฝ˜ํ…์ธ ", + }, + { + "id": "corporate_tech", + "title": "Corporate & Tech", + "duration": 60, + "tags": ["corporate", "tech", "modern", "professional"], + "description": "์ •๋ณด์„ฑ ์ฝ˜ํ…์ธ ", + }, + ] + + # Filter by query if provided + if query: + query_lower = query.lower() + filtered = [ + bgm for bgm in curated_bgm + if query_lower in bgm["title"].lower() + or any(query_lower in tag for tag in bgm["tags"]) + ] + curated_bgm = filtered if filtered else curated_bgm + + results = [ + BGMSearchResult( + id=bgm["id"], + title=bgm["title"], + duration=bgm["duration"], + tags=bgm["tags"], + preview_url="", # Would be filled with actual URL + source="curated", + ) + for bgm in curated_bgm + ] + + return True, "Curated BGM list", results + + +async def download_from_url( + url: str, + output_path: str, + filename: str, +) -> Tuple[bool, str, Optional[str]]: + """ + Download audio file from URL. + + Args: + url: Audio file URL + output_path: Directory to save file + filename: Output filename (without extension) + + Returns: + Tuple of (success, message, file_path) + """ + try: + os.makedirs(output_path, exist_ok=True) + + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=60, follow_redirects=True) + + if response.status_code != 200: + return False, f"Download failed: HTTP {response.status_code}", None + + # Determine file extension from content-type + content_type = response.headers.get("content-type", "") + if "mpeg" in content_type: + ext = ".mp3" + elif "wav" in content_type: + ext = ".wav" + elif "ogg" in content_type: + ext = ".ogg" + else: + ext = ".mp3" # Default to mp3 + + file_path = os.path.join(output_path, f"{filename}{ext}") + + with open(file_path, "wb") as f: + f.write(response.content) + + return True, "Download complete", file_path + + except Exception as e: + return False, f"Download error: {str(e)}", None + + +# Popular free BGM download links +FREE_BGM_SOURCES = { + "freesound": { + "name": "Freesound", + "url": "https://freesound.org/", + "license": "CC0/CC-BY/CC-BY-NC (Various)", + "description": "500,000+ CC licensed sounds, API available", + "api_available": True, + "api_url": "https://freesound.org/apiv2/apply", + }, + "pixabay": { + "name": "Pixabay Music", + "url": "https://pixabay.com/music/", + "license": "Pixabay License (Free for commercial use)", + "description": "Large collection of royalty-free music", + "api_available": False, + }, + "mixkit": { + "name": "Mixkit", + "url": "https://mixkit.co/free-stock-music/", + "license": "Mixkit License (Free for commercial use)", + "description": "High-quality free music tracks", + "api_available": False, + }, + "uppbeat": { + "name": "Uppbeat", + "url": "https://uppbeat.io/", + "license": "Free tier: 10 tracks/month", + "description": "YouTube-friendly music", + "api_available": False, + }, + "youtube_audio_library": { + "name": "YouTube Audio Library", + "url": "https://studio.youtube.com/channel/UC/music", + "license": "Free for YouTube videos", + "description": "Google's free music library", + "api_available": False, + }, +} + + +def get_free_bgm_sources() -> dict: + """Get list of recommended free BGM sources.""" + return FREE_BGM_SOURCES diff --git a/backend/app/services/bgm_recommender.py b/backend/app/services/bgm_recommender.py new file mode 100644 index 0000000..4d8fd78 --- /dev/null +++ b/backend/app/services/bgm_recommender.py @@ -0,0 +1,295 @@ +""" +BGM Recommender Service + +Analyzes script content and recommends appropriate BGM based on mood/tone. +Uses GPT to analyze the emotional tone and suggests matching music. +""" + +import os +from typing import List, Tuple, Optional +from openai import OpenAI +from pydantic import BaseModel +from app.config import settings +from app.models.schemas import TranscriptSegment + + +class BGMRecommendation(BaseModel): + """BGM recommendation result.""" + mood: str # detected mood + energy: str # low, medium, high + suggested_genres: List[str] + search_keywords: List[str] + reasoning: str + matched_bgm_id: Optional[str] = None # if found in local library + + +# Mood to BGM mapping +MOOD_BGM_MAPPING = { + "upbeat": { + "genres": ["pop", "electronic", "dance"], + "keywords": ["upbeat", "energetic", "happy", "positive"], + "energy": "high", + }, + "chill": { + "genres": ["lofi", "ambient", "acoustic"], + "keywords": ["chill", "relaxing", "calm", "peaceful"], + "energy": "low", + }, + "dramatic": { + "genres": ["cinematic", "orchestral", "epic"], + "keywords": ["dramatic", "epic", "intense", "cinematic"], + "energy": "high", + }, + "funny": { + "genres": ["comedy", "quirky", "playful"], + "keywords": ["funny", "quirky", "comedy", "playful"], + "energy": "medium", + }, + "emotional": { + "genres": ["piano", "strings", "ballad"], + "keywords": ["emotional", "sad", "touching", "heartfelt"], + "energy": "low", + }, + "informative": { + "genres": ["corporate", "background", "minimal"], + "keywords": ["corporate", "background", "tech", "modern"], + "energy": "medium", + }, + "exciting": { + "genres": ["rock", "action", "sports"], + "keywords": ["exciting", "action", "sports", "adventure"], + "energy": "high", + }, + "mysterious": { + "genres": ["ambient", "dark", "suspense"], + "keywords": ["mysterious", "suspense", "dark", "tension"], + "energy": "medium", + }, +} + + +async def analyze_script_mood( + segments: List[TranscriptSegment], + use_translated: bool = True, +) -> Tuple[bool, str, Optional[BGMRecommendation]]: + """ + Analyze script content to determine mood and recommend BGM. + + Args: + segments: Transcript segments (original or translated) + use_translated: Whether to use translated text + + Returns: + Tuple of (success, message, recommendation) + """ + if not settings.OPENAI_API_KEY: + return False, "OpenAI API key not configured", None + + if not segments: + return False, "No transcript segments provided", None + + # Combine script text + script_text = "\n".join([ + seg.translated if use_translated and seg.translated else seg.text + for seg in segments + ]) + + try: + client = OpenAI(api_key=settings.OPENAI_API_KEY) + + response = client.chat.completions.create( + model=settings.OPENAI_MODEL, + messages=[ + { + "role": "system", + "content": """You are a music supervisor for YouTube Shorts. +Analyze the script and determine the best background music mood. + +Respond in JSON format ONLY: +{ + "mood": "one of: upbeat, chill, dramatic, funny, emotional, informative, exciting, mysterious", + "energy": "low, medium, or high", + "reasoning": "brief explanation in Korean (1 sentence)" +} + +Consider: +- Overall emotional tone of the content +- Pacing and energy level +- Target audience engagement +- What would make viewers watch till the end""" + }, + { + "role": "user", + "content": f"Script:\n{script_text}" + } + ], + temperature=0.3, + max_tokens=200, + ) + + # Parse response + import json + result_text = response.choices[0].message.content.strip() + + # Clean up JSON if wrapped in markdown + if result_text.startswith("```"): + result_text = result_text.split("```")[1] + if result_text.startswith("json"): + result_text = result_text[4:] + + result = json.loads(result_text) + + mood = result.get("mood", "upbeat") + energy = result.get("energy", "medium") + reasoning = result.get("reasoning", "") + + # Get BGM suggestions based on mood + mood_info = MOOD_BGM_MAPPING.get(mood, MOOD_BGM_MAPPING["upbeat"]) + + recommendation = BGMRecommendation( + mood=mood, + energy=energy, + suggested_genres=mood_info["genres"], + search_keywords=mood_info["keywords"], + reasoning=reasoning, + ) + + return True, f"Mood analysis complete: {mood}", recommendation + + except json.JSONDecodeError as e: + return False, f"Failed to parse mood analysis: {str(e)}", None + except Exception as e: + return False, f"Mood analysis error: {str(e)}", None + + +async def find_matching_bgm( + recommendation: BGMRecommendation, + available_bgm: List[dict], +) -> Optional[str]: + """ + Find a matching BGM from available library based on recommendation. + + Args: + recommendation: BGM recommendation from mood analysis + available_bgm: List of available BGM info dicts with 'id' and 'name' + + Returns: + BGM ID if found, None otherwise + """ + if not available_bgm: + return None + + keywords = recommendation.search_keywords + [recommendation.mood] + + # Score each BGM based on keyword matching + best_match = None + best_score = 0 + + for bgm in available_bgm: + bgm_name = bgm.get("name", "").lower() + bgm_id = bgm.get("id", "").lower() + + score = 0 + for keyword in keywords: + if keyword.lower() in bgm_name or keyword.lower() in bgm_id: + score += 1 + + if score > best_score: + best_score = score + best_match = bgm.get("id") + + return best_match if best_score > 0 else None + + +async def recommend_bgm_for_script( + segments: List[TranscriptSegment], + available_bgm: List[dict], + use_translated: bool = True, +) -> Tuple[bool, str, Optional[BGMRecommendation]]: + """ + Complete BGM recommendation workflow: + 1. Analyze script mood + 2. Find matching BGM from library + 3. Return recommendation with search keywords for external sources + + Args: + segments: Transcript segments + available_bgm: List of available BGM in library + use_translated: Whether to use translated text + + Returns: + Tuple of (success, message, recommendation with matched_bgm_id if found) + """ + # Step 1: Analyze mood + success, message, recommendation = await analyze_script_mood( + segments, use_translated + ) + + if not success or not recommendation: + return success, message, recommendation + + # Step 2: Find matching BGM in library + matched_id = await find_matching_bgm(recommendation, available_bgm) + + if matched_id: + recommendation.matched_bgm_id = matched_id + message = f"Mood: {recommendation.mood} | Matched BGM: {matched_id}" + else: + message = f"Mood: {recommendation.mood} | No local BGM matched, search with: {', '.join(recommendation.search_keywords[:3])}" + + return True, message, recommendation + + +# Predefined BGM presets for common content types +BGM_PRESETS = { + "cooking": { + "mood": "chill", + "keywords": ["cooking", "food", "kitchen", "cozy"], + }, + "fitness": { + "mood": "upbeat", + "keywords": ["workout", "fitness", "energetic", "motivation"], + }, + "tutorial": { + "mood": "informative", + "keywords": ["tutorial", "tech", "corporate", "background"], + }, + "comedy": { + "mood": "funny", + "keywords": ["funny", "comedy", "quirky", "playful"], + }, + "travel": { + "mood": "exciting", + "keywords": ["travel", "adventure", "upbeat", "inspiring"], + }, + "asmr": { + "mood": "chill", + "keywords": ["asmr", "relaxing", "ambient", "soft"], + }, + "news": { + "mood": "informative", + "keywords": ["news", "corporate", "serious", "background"], + }, + "gaming": { + "mood": "exciting", + "keywords": ["gaming", "electronic", "action", "intense"], + }, +} + + +def get_preset_recommendation(content_type: str) -> Optional[BGMRecommendation]: + """Get BGM recommendation for common content types.""" + preset = BGM_PRESETS.get(content_type.lower()) + if not preset: + return None + + mood = preset["mood"] + mood_info = MOOD_BGM_MAPPING.get(mood, MOOD_BGM_MAPPING["upbeat"]) + + return BGMRecommendation( + mood=mood, + energy=mood_info["energy"], + suggested_genres=mood_info["genres"], + search_keywords=preset["keywords"], + reasoning=f"Preset for {content_type} content", + ) diff --git a/backend/app/services/default_bgm.py b/backend/app/services/default_bgm.py new file mode 100644 index 0000000..28da730 --- /dev/null +++ b/backend/app/services/default_bgm.py @@ -0,0 +1,297 @@ +""" +Default BGM Initializer + +Downloads pre-selected royalty-free BGM tracks on first startup. +Tracks are from Kevin MacLeod (incompetech.com) - CC-BY 4.0 License. +Free for commercial use with attribution: "Kevin MacLeod (incompetech.com)" +""" + +import os +import httpx +import aiofiles +import asyncio +from typing import List, Tuple, Optional +from pydantic import BaseModel + + +class DefaultBGM(BaseModel): + """Default BGM track info.""" + id: str + name: str + url: str + category: str + description: str + + +# Curated list of royalty-free BGM from Kevin MacLeod (incompetech.com) +# CC-BY 4.0 License - Free for commercial use with attribution +# Attribution: "Kevin MacLeod (incompetech.com)" +DEFAULT_BGM_TRACKS: List[DefaultBGM] = [ + # === ํ™œ๊ธฐ์ฐฌ/์—๋„ˆ์ง€ (Upbeat/Energetic) === + DefaultBGM( + id="upbeat_energetic", + name="Upbeat Energetic", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Vivacity.mp3", + category="upbeat", + description="ํ™œ๊ธฐ์ฐจ๊ณ  ์—๋„ˆ์ง€ ๋„˜์น˜๋Š” BGM - ํ”ผํŠธ๋‹ˆ์Šค, ์ฑŒ๋ฆฐ์ง€ ์˜์ƒ", + ), + DefaultBGM( + id="happy_pop", + name="Happy Pop", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Carefree.mp3", + category="upbeat", + description="๋ฐ๊ณ  ๊ฒฝ์พŒํ•œ ํŒ BGM - ์ œํ’ˆ ์†Œ๊ฐœ, ์–ธ๋ฐ•์‹ฑ", + ), + DefaultBGM( + id="upbeat_fun", + name="Upbeat Fun", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Happy%20Happy%20Game%20Show.mp3", + category="upbeat", + description="์‹ ๋‚˜๋Š” ๊ฒŒ์ž„์‡ผ ๋น„ํŠธ - ํŠธ๋ Œ๋””ํ•œ ์‡ผ์ธ ", + ), + + # === ์ฐจ๋ถ„ํ•œ/ํŽธ์•ˆํ•œ (Chill/Relaxing) === + DefaultBGM( + id="chill_lofi", + name="Chill Lo-Fi", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Gymnopedie%20No%201.mp3", + category="chill", + description="์ฐจ๋ถ„ํ•˜๊ณ  ํŽธ์•ˆํ•œ ํ”ผ์•„๋…ธ BGM - ์ผ์ƒ, ๋ธŒ์ด๋กœ๊ทธ", + ), + DefaultBGM( + id="calm_piano", + name="Calm Piano", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Prelude%20No.%201.mp3", + category="chill", + description="์ž”์ž”ํ•œ ํ”ผ์•„๋…ธ BGM - ๊ฐ์„ฑ์ ์ธ ์ฝ˜ํ…์ธ ", + ), + DefaultBGM( + id="soft_ambient", + name="Soft Ambient", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Dreamlike.mp3", + category="chill", + description="๋ถ€๋“œ๋Ÿฌ์šด ์•ฐ๋น„์–ธํŠธ - ASMR, ์ˆ˜๋ฉด ์ฝ˜ํ…์ธ ", + ), + + # === ์œ ๋จธ/์ฝ”๋ฏธ๋”” (Funny/Comedy) === + DefaultBGM( + id="funny_comedy", + name="Funny Comedy", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Sneaky%20Snitch.mp3", + category="funny", + description="์œ ์พŒํ•œ ์ฝ”๋ฏธ๋”” BGM - ์ฝ”๋ฏธ๋””, ๋ฐˆ ์˜์ƒ", + ), + DefaultBGM( + id="quirky_playful", + name="Quirky Playful", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Monkeys%20Spinning%20Monkeys.mp3", + category="funny", + description="์žฅ๋‚œ์Šค๋Ÿฝ๊ณ  ๊ท€์—ฌ์šด BGM - ํŽซ, ํ‚ค์ฆˆ ์ฝ˜ํ…์ธ ", + ), + + # === ๋“œ๋ผ๋งˆํ‹ฑ/์‹œ๋„ค๋งˆํ‹ฑ (Cinematic) === + DefaultBGM( + id="cinematic_epic", + name="Cinematic Epic", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Epic%20Unease.mp3", + category="cinematic", + description="์›…์žฅํ•œ ์‹œ๋„ค๋งˆํ‹ฑ BGM - ๋ฆฌ๋ทฐ, ์†Œ๊ฐœ ์˜์ƒ", + ), + DefaultBGM( + id="inspirational", + name="Inspirational", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Hero%20Theme.mp3", + category="cinematic", + description="์˜๊ฐ์„ ์ฃผ๋Š” BGM - ๋™๊ธฐ๋ถ€์—ฌ, ์„ฑ์žฅ ์ฝ˜ํ…์ธ ", + ), + + # === ์ƒํ™œ์šฉํ’ˆ/์ œํ’ˆ ๋ฆฌ๋ทฐ (Lifestyle/Product) === + DefaultBGM( + id="lifestyle_modern", + name="Lifestyle Modern", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Acoustic%20Breeze.mp3", + category="lifestyle", + description="๋ชจ๋˜ํ•œ ๋ผ์ดํ”„์Šคํƒ€์ผ BGM - ์ œํ’ˆ ๋ฆฌ๋ทฐ", + ), + DefaultBGM( + id="shopping_bright", + name="Shopping Bright", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Pleasant%20Porridge.mp3", + category="lifestyle", + description="๋ฐ์€ ์‡ผํ•‘ BGM - ํ•˜์šธ, ์ถ”์ฒœ ์˜์ƒ", + ), + DefaultBGM( + id="soft_corporate", + name="Soft Corporate", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Laid%20Back%20Guitars.mp3", + category="lifestyle", + description="๋ถ€๋“œ๋Ÿฌ์šด ๊ธฐ์—…ํ˜• BGM - ์ •๋ณด์„ฑ ์ฝ˜ํ…์ธ ", + ), + + # === ์–ด์ฟ ์Šคํ‹ฑ/๊ฐ์„ฑ (Acoustic/Emotional) === + DefaultBGM( + id="soft_acoustic", + name="Soft Acoustic", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Peaceful.mp3", + category="acoustic", + description="๋”ฐ๋œปํ•œ ์–ด์ฟ ์Šคํ‹ฑ BGM - ์š”๋ฆฌ, ์ผ์ƒ ๋ธŒ์ด๋กœ๊ทธ", + ), + DefaultBGM( + id="gentle_guitar", + name="Gentle Guitar", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Sunflower%20Slow%20Drag.mp3", + category="acoustic", + description="์ž”์ž”ํ•œ ๊ธฐํƒ€ BGM - ์—ฌํ–‰, ํ’๊ฒฝ ์˜์ƒ", + ), + + # === ํŠธ๋ Œ๋””/์ผ๋ ‰ํŠธ๋กœ๋‹‰ (Trendy/Electronic) === + DefaultBGM( + id="electronic_chill", + name="Electronic Chill", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Digital%20Lemonade.mp3", + category="electronic", + description="์ผ๋ ‰ํŠธ๋กœ๋‹‰ ์น ์•„์›ƒ - ํ…Œํฌ, ๊ฒŒ์ž„ ์ฝ˜ํ…์ธ ", + ), + DefaultBGM( + id="driving_beat", + name="Driving Beat", + url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/Cipher.mp3", + category="electronic", + description="๋“œ๋ผ์ด๋น™ ๋น„ํŠธ - ์Šคํฌ์ธ , ์•ก์…˜ ์˜์ƒ", + ), +] + + +async def download_bgm_file( + url: str, + output_path: str, + timeout: int = 60, +) -> Tuple[bool, str]: + """ + Download a single BGM file. + + Args: + url: Download URL + output_path: Full path to save the file + timeout: Download timeout in seconds + + Returns: + Tuple of (success, message) + """ + headers = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "audio/mpeg,audio/*;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + } + + try: + async with httpx.AsyncClient(follow_redirects=True, headers=headers) as client: + response = await client.get(url, timeout=timeout) + + if response.status_code != 200: + return False, f"HTTP {response.status_code}" + + # Ensure directory exists + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + # Save file + async with aiofiles.open(output_path, 'wb') as f: + await f.write(response.content) + + return True, "Downloaded successfully" + + except httpx.TimeoutException: + return False, "Download timeout" + except Exception as e: + return False, str(e) + + +async def initialize_default_bgm( + bgm_dir: str, + force: bool = False, +) -> Tuple[int, int, List[str]]: + """ + Initialize default BGM tracks. + + Downloads default BGM tracks if not already present. + + Args: + bgm_dir: Directory to save BGM files + force: Force re-download even if files exist + + Returns: + Tuple of (downloaded_count, skipped_count, error_messages) + """ + os.makedirs(bgm_dir, exist_ok=True) + + downloaded = 0 + skipped = 0 + errors = [] + + for track in DEFAULT_BGM_TRACKS: + output_path = os.path.join(bgm_dir, f"{track.id}.mp3") + + # Skip if already exists (unless force=True) + if os.path.exists(output_path) and not force: + skipped += 1 + print(f"[BGM] Skipping {track.name} (already exists)") + continue + + print(f"[BGM] Downloading {track.name}...") + success, message = await download_bgm_file(track.url, output_path) + + if success: + downloaded += 1 + print(f"[BGM] Downloaded {track.name}") + else: + errors.append(f"{track.name}: {message}") + print(f"[BGM] Failed to download {track.name}: {message}") + + return downloaded, skipped, errors + + +async def get_default_bgm_list() -> List[dict]: + """ + Get list of default BGM tracks with metadata. + + Returns: + List of BGM info dictionaries + """ + return [ + { + "id": track.id, + "name": track.name, + "category": track.category, + "description": track.description, + } + for track in DEFAULT_BGM_TRACKS + ] + + +def check_default_bgm_status(bgm_dir: str) -> dict: + """ + Check which default BGM tracks are installed. + + Args: + bgm_dir: BGM directory path + + Returns: + Status dictionary with installed/missing tracks + """ + installed = [] + missing = [] + + for track in DEFAULT_BGM_TRACKS: + file_path = os.path.join(bgm_dir, f"{track.id}.mp3") + if os.path.exists(file_path): + installed.append(track.id) + else: + missing.append(track.id) + + return { + "total": len(DEFAULT_BGM_TRACKS), + "installed": len(installed), + "missing": len(missing), + "installed_ids": installed, + "missing_ids": missing, + } diff --git a/backend/app/services/downloader.py b/backend/app/services/downloader.py new file mode 100644 index 0000000..d535327 --- /dev/null +++ b/backend/app/services/downloader.py @@ -0,0 +1,158 @@ +import subprocess +import os +import re +from typing import Optional, Tuple +from app.config import settings + + +def detect_platform(url: str) -> str: + """Detect video platform from URL.""" + if "douyin" in url or "iesdouyin" in url: + return "douyin" + elif "kuaishou" in url or "gifshow" in url: + return "kuaishou" + elif "bilibili" in url: + return "bilibili" + elif "youtube" in url or "youtu.be" in url: + return "youtube" + elif "tiktok" in url: + return "tiktok" + else: + return "unknown" + + +def sanitize_filename(filename: str) -> str: + """Sanitize filename to be safe for filesystem.""" + # Remove or replace invalid characters + filename = re.sub(r'[<>:"/\\|?*]', '_', filename) + # Limit length + if len(filename) > 100: + filename = filename[:100] + return filename + + +def get_cookies_path(platform: str) -> Optional[str]: + """Get cookies file path for a platform.""" + cookies_dir = os.path.join(os.path.dirname(settings.DOWNLOAD_DIR), "cookies") + + # Check for platform-specific cookies first (e.g., douyin.txt) + platform_cookies = os.path.join(cookies_dir, f"{platform}.txt") + if os.path.exists(platform_cookies): + return platform_cookies + + # Check for generic cookies.txt + generic_cookies = os.path.join(cookies_dir, "cookies.txt") + if os.path.exists(generic_cookies): + return generic_cookies + + return None + + +async def download_video(url: str, job_id: str) -> Tuple[bool, str, Optional[str]]: + """ + Download video using yt-dlp. + + Returns: + Tuple of (success, message, video_path) + """ + output_dir = os.path.join(settings.DOWNLOAD_DIR, job_id) + os.makedirs(output_dir, exist_ok=True) + + output_template = os.path.join(output_dir, "%(title).50s.%(ext)s") + + # yt-dlp command with options for Chinese platforms + cmd = [ + "yt-dlp", + "--no-playlist", + "-f", "best[ext=mp4]/best", + "--merge-output-format", "mp4", + "-o", output_template, + "--no-check-certificate", + "--socket-timeout", "30", + "--retries", "3", + "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + ] + + platform = detect_platform(url) + + # Add cookies if available (required for Douyin, Kuaishou) + cookies_path = get_cookies_path(platform) + if cookies_path: + cmd.extend(["--cookies", cookies_path]) + print(f"Using cookies from: {cookies_path}") + elif platform in ["douyin", "kuaishou", "bilibili"]: + # Try to use browser cookies if no cookies file + # Priority: Chrome > Firefox > Edge + cmd.extend(["--cookies-from-browser", "chrome"]) + print(f"Using cookies from Chrome browser for {platform}") + + # Platform-specific options + if platform in ["douyin", "kuaishou"]: + # Use browser impersonation for anti-bot bypass + cmd.extend([ + "--impersonate", "chrome-123:macos-14", + "--extractor-args", "generic:impersonate", + ]) + + # Add proxy if configured (for geo-restricted platforms) + if settings.PROXY_URL: + cmd.extend(["--proxy", settings.PROXY_URL]) + print(f"Using proxy: {settings.PROXY_URL}") + + cmd.append(url) + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300, # 5 minute timeout + ) + + if result.returncode != 0: + error_msg = result.stderr or result.stdout or "Unknown error" + return False, f"Download failed: {error_msg}", None + + # Find the downloaded file + for file in os.listdir(output_dir): + if file.endswith((".mp4", ".webm", ".mkv")): + video_path = os.path.join(output_dir, file) + return True, "Download successful", video_path + + return False, "No video file found after download", None + + except subprocess.TimeoutExpired: + return False, "Download timed out (5 minutes)", None + except Exception as e: + return False, f"Download error: {str(e)}", None + + +def get_video_info(url: str) -> Optional[dict]: + """Get video metadata without downloading.""" + cmd = [ + "yt-dlp", + "-j", # JSON output + "--no-download", + ] + + # Add proxy if configured + if settings.PROXY_URL: + cmd.extend(["--proxy", settings.PROXY_URL]) + + cmd.append(url) + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode == 0: + import json + return json.loads(result.stdout) + except Exception: + pass + + return None diff --git a/backend/app/services/thumbnail.py b/backend/app/services/thumbnail.py new file mode 100644 index 0000000..036acdd --- /dev/null +++ b/backend/app/services/thumbnail.py @@ -0,0 +1,399 @@ +""" +Thumbnail Generator Service + +Generates YouTube Shorts thumbnails with: +1. Frame extraction from video +2. GPT-generated catchphrase +3. Text overlay with styling +""" + +import os +import subprocess +import asyncio +from typing import Optional, Tuple, List +from openai import OpenAI +from PIL import Image, ImageDraw, ImageFont +from app.config import settings +from app.models.schemas import TranscriptSegment + + +def get_openai_client() -> OpenAI: + """Get OpenAI client.""" + return OpenAI(api_key=settings.OPENAI_API_KEY) + + +async def extract_frame( + video_path: str, + output_path: str, + timestamp: float = 2.0, +) -> Tuple[bool, str]: + """ + Extract a single frame from video. + + Args: + video_path: Path to video file + output_path: Path to save thumbnail image + timestamp: Time in seconds to extract frame + + Returns: + Tuple of (success, message) + """ + try: + cmd = [ + "ffmpeg", "-y", + "-ss", str(timestamp), + "-i", video_path, + "-vframes", "1", + "-q:v", "2", # High quality JPEG + output_path + ] + + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + _, stderr = await process.communicate() + + if process.returncode != 0: + return False, f"FFmpeg error: {stderr.decode()[:200]}" + + if not os.path.exists(output_path): + return False, "Frame extraction failed - no output file" + + return True, "Frame extracted successfully" + + except Exception as e: + return False, f"Frame extraction error: {str(e)}" + + +async def generate_catchphrase( + transcript: List[TranscriptSegment], + style: str = "homeshopping", +) -> Tuple[bool, str, str]: + """ + Generate a catchy thumbnail text using GPT. + + Args: + transcript: List of transcript segments (with translations) + style: Style of catchphrase (homeshopping, viral, informative) + + Returns: + Tuple of (success, message, catchphrase) + """ + if not settings.OPENAI_API_KEY: + return False, "OpenAI API key not configured", "" + + try: + client = get_openai_client() + + # Combine translated text + if transcript and transcript[0].translated: + full_text = " ".join([seg.translated for seg in transcript if seg.translated]) + else: + full_text = " ".join([seg.text for seg in transcript]) + + style_guides = { + "homeshopping": """ํ™ˆ์‡ผํ•‘ ์Šคํƒ€์ผ์˜ ์ž„ํŒฉํŠธ ์žˆ๋Š” ๋ฌธ๊ตฌ๋ฅผ ๋งŒ๋“œ์„ธ์š”. +- "์ด๊ฑฐ ํ•˜๋‚˜๋ฉด ๋!" ๊ฐ™์€ ๊ฐ•๋ ฌํ•œ ์–ดํ•„ +- ํ˜œํƒ/ํšจ๊ณผ ๊ฐ•์กฐ +- ์ˆซ์ž ํ™œ์šฉ (์˜ˆ: "10์ดˆ๋งŒ์—", "50% ์ ˆ์•ฝ") +- ์งˆ๋ฌธํ˜•๋„ OK (์˜ˆ: "์•„์ง๋„ ํž˜๋“ค๊ฒŒ?")""", + "viral": """๋ฐ”์ด๋Ÿด ์‡ผ์ธ  ์Šคํƒ€์ผ์˜ ํ˜ธ๊ธฐ์‹ฌ ์œ ๋ฐœ ๋ฌธ๊ตฌ๋ฅผ ๋งŒ๋“œ์„ธ์š”. +- ๊ถ๊ธˆ์ฆ ์œ ๋ฐœ +- ๋ฐ˜์ „/๋†€๋ผ์›€ ์•”์‹œ +- ์ด๋ชจ์ง€ 1-2๊ฐœ ์‚ฌ์šฉ ๊ฐ€๋Šฅ""", + "informative": """์ •๋ณด์„ฑ ์ฝ˜ํ…์ธ  ์Šคํƒ€์ผ์˜ ๋ช…ํ™•ํ•œ ๋ฌธ๊ตฌ๋ฅผ ๋งŒ๋“œ์„ธ์š”. +- ํ•ต์‹ฌ ์ •๋ณด ์ „๋‹ฌ +- ๊ฐ„๊ฒฐํ•˜๊ณ  ๋ช…ํ™•ํ•˜๊ฒŒ""", + } + + style_guide = style_guides.get(style, style_guides["homeshopping"]) + + system_prompt = f"""๋‹น์‹ ์€ YouTube Shorts ์ธ๋„ค์ผ ๋ฌธ๊ตฌ ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค. + +{style_guide} + +๊ทœ์น™: +- ๋ฐ˜๋“œ์‹œ 15์ž ์ด๋‚ด! +- ํ•œ ์ค„๋กœ ์ž‘์„ฑ +- ํ•œ๊ธ€๋งŒ ์‚ฌ์šฉ (์˜์–ด/ํ•œ์ž ๊ธˆ์ง€) +- ์ถœ๋ ฅ์€ ๋ฌธ๊ตฌ๋งŒ! (์„ค๋ช… ์—†์ด) + +์˜ˆ์‹œ ์ถœ๋ ฅ: +์ด๊ฑฐ ํ•˜๋‚˜๋ฉด ๋! +10์ดˆ๋ฉด ์™„์„ฑ! +์•„์ง๋„ ํž˜๋“ค๊ฒŒ? +์ง„์งœ ์ด๊ฒŒ ๋ผ์š”?""" + + response = client.chat.completions.create( + model=settings.OPENAI_MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"๋‹ค์Œ ์˜์ƒ ๋‚ด์šฉ์œผ๋กœ ์ธ๋„ค์ผ ๋ฌธ๊ตฌ๋ฅผ ๋งŒ๋“ค์–ด์ฃผ์„ธ์š”:\n\n{full_text[:500]}"} + ], + temperature=0.8, + max_tokens=50, + ) + + catchphrase = response.choices[0].message.content.strip() + # Clean up + catchphrase = catchphrase.strip('"\'""''') + + # Ensure max length + if len(catchphrase) > 20: + catchphrase = catchphrase[:20] + + return True, "Catchphrase generated", catchphrase + + except Exception as e: + return False, f"GPT error: {str(e)}", "" + + +def add_text_overlay( + image_path: str, + output_path: str, + text: str, + font_size: int = 80, + font_color: str = "#FFFFFF", + stroke_color: str = "#000000", + stroke_width: int = 4, + position: str = "center", + font_name: str = "NanumGothicBold", +) -> Tuple[bool, str]: + """ + Add text overlay to image using PIL. + + Args: + image_path: Input image path + output_path: Output image path + text: Text to overlay + font_size: Font size in pixels + font_color: Text color (hex) + stroke_color: Outline color (hex) + stroke_width: Outline thickness + position: Text position (top, center, bottom) + font_name: Font family name + + Returns: + Tuple of (success, message) + """ + try: + # Open image + img = Image.open(image_path) + draw = ImageDraw.Draw(img) + img_width, img_height = img.size + + # Maximum text width (90% of image width) + max_text_width = int(img_width * 0.9) + + # Try to load font + def load_font(size): + font_paths = [ + f"/usr/share/fonts/truetype/nanum/{font_name}.ttf", + f"/usr/share/fonts/opentype/nanum/{font_name}.otf", + f"/System/Library/Fonts/{font_name}.ttf", + f"/Library/Fonts/{font_name}.ttf", + f"~/Library/Fonts/{font_name}.ttf", + f"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + ] + for path in font_paths: + expanded_path = os.path.expanduser(path) + if os.path.exists(expanded_path): + try: + return ImageFont.truetype(expanded_path, size) + except: + continue + return None + + font = load_font(font_size) + if font is None: + font = ImageFont.load_default() + font_size = 40 + + # Check text width and adjust if necessary + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + + lines = [text] + + if text_width > max_text_width: + # Try splitting into 2 lines first + mid = len(text) // 2 + # Find best split point near middle (at space or comma if exists) + split_pos = mid + for i in range(mid, max(0, mid - 5), -1): + if text[i] in ' ,ใ€๏ผŒ': + split_pos = i + 1 + break + for i in range(mid, min(len(text), mid + 5)): + if text[i] in ' ,ใ€๏ผŒ': + split_pos = i + 1 + break + + # Split text into 2 lines + line1 = text[:split_pos].strip() + line2 = text[split_pos:].strip() + lines = [line1, line2] if line2 else [line1] + + # Check if 2-line version fits + max_line_width = max( + draw.textbbox((0, 0), line, font=font)[2] - draw.textbbox((0, 0), line, font=font)[0] + for line in lines + ) + + # If still too wide, reduce font size + while max_line_width > max_text_width and font_size > 40: + font_size -= 5 + font = load_font(font_size) + if font is None: + font = ImageFont.load_default() + break + max_line_width = max( + draw.textbbox((0, 0), line, font=font)[2] - draw.textbbox((0, 0), line, font=font)[0] + for line in lines + ) + + # Calculate total text height for multi-line + line_height = font_size + 10 + total_height = line_height * len(lines) + + # Calculate starting y position + if position == "top": + start_y = img_height // 6 + elif position == "bottom": + start_y = img_height - img_height // 4 - total_height + else: # center + start_y = (img_height - total_height) // 2 + + # Convert hex colors to RGB + def hex_to_rgb(hex_color): + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + + text_rgb = hex_to_rgb(font_color) + stroke_rgb = hex_to_rgb(stroke_color) + + # Draw each line + for i, line in enumerate(lines): + bbox = draw.textbbox((0, 0), line, font=font) + line_width = bbox[2] - bbox[0] + # Account for left bearing (bbox[0]) to prevent first character cut-off + # Some fonts/characters have non-zero left offset + x = (img_width - line_width) // 2 - bbox[0] + y = start_y + i * line_height + + # Draw text with stroke (outline) + for dx in range(-stroke_width, stroke_width + 1): + for dy in range(-stroke_width, stroke_width + 1): + if dx != 0 or dy != 0: + draw.text((x + dx, y + dy), line, font=font, fill=stroke_rgb) + + # Draw main text + draw.text((x, y), line, font=font, fill=text_rgb) + + # Save + img.save(output_path, "JPEG", quality=95) + + return True, "Text overlay added" + + except Exception as e: + return False, f"Text overlay error: {str(e)}" + + +async def generate_thumbnail( + job_id: str, + video_path: str, + transcript: List[TranscriptSegment], + timestamp: float = 2.0, + style: str = "homeshopping", + custom_text: Optional[str] = None, + font_size: int = 80, + position: str = "center", +) -> Tuple[bool, str, Optional[str]]: + """ + Generate a complete thumbnail with text overlay. + + Args: + job_id: Job ID for naming + video_path: Path to video file + transcript: Transcript segments + timestamp: Time to extract frame + style: Catchphrase style + custom_text: Custom text (skip GPT generation) + font_size: Font size + position: Text position + + Returns: + Tuple of (success, message, thumbnail_path) + """ + # Paths + frame_path = os.path.join(settings.PROCESSED_DIR, f"{job_id}_frame.jpg") + thumbnail_path = os.path.join(settings.PROCESSED_DIR, f"{job_id}_thumbnail.jpg") + + # Step 1: Extract frame + success, msg = await extract_frame(video_path, frame_path, timestamp) + if not success: + return False, msg, None + + # Step 2: Generate or use custom text + if custom_text: + catchphrase = custom_text + else: + success, msg, catchphrase = await generate_catchphrase(transcript, style) + if not success: + # Fallback: use first translation + catchphrase = transcript[0].translated if transcript and transcript[0].translated else "ํ™•์ธํ•ด๋ณด์„ธ์š”!" + + # Step 3: Add text overlay + success, msg = add_text_overlay( + frame_path, + thumbnail_path, + catchphrase, + font_size=font_size, + position=position, + ) + + if not success: + return False, msg, None + + # Cleanup frame + if os.path.exists(frame_path): + os.remove(frame_path) + + return True, f"Thumbnail generated: {catchphrase}", thumbnail_path + + +async def get_video_timestamps(video_path: str, count: int = 5) -> List[float]: + """ + Get evenly distributed timestamps from video for thumbnail selection. + + Args: + video_path: Path to video + count: Number of timestamps to return + + Returns: + List of timestamps in seconds + """ + try: + cmd = [ + "ffprobe", "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + video_path + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + duration = float(result.stdout.strip()) + + # Generate evenly distributed timestamps (skip first and last 10%) + start = duration * 0.1 + end = duration * 0.9 + step = (end - start) / (count - 1) if count > 1 else 0 + + timestamps = [start + i * step for i in range(count)] + return timestamps + + except Exception: + return [1.0, 3.0, 5.0, 7.0, 10.0] # Fallback diff --git a/backend/app/services/transcriber.py b/backend/app/services/transcriber.py new file mode 100644 index 0000000..6e141a4 --- /dev/null +++ b/backend/app/services/transcriber.py @@ -0,0 +1,421 @@ +import whisper +import asyncio +import os +from typing import List, Optional, Tuple +from app.models.schemas import TranscriptSegment +from app.config import settings + +# Global model cache +_model = None + + +def get_whisper_model(): + """Load Whisper model (cached).""" + global _model + if _model is None: + print(f"Loading Whisper model: {settings.WHISPER_MODEL}") + _model = whisper.load_model(settings.WHISPER_MODEL) + return _model + + +async def check_audio_availability(video_path: str) -> Tuple[bool, str]: + """ + Check if video has usable audio for transcription. + + Returns: + Tuple of (has_audio, message) + """ + from app.services.video_processor import has_audio_stream, get_audio_volume_info, is_audio_silent + + # Check if audio stream exists + if not await has_audio_stream(video_path): + return False, "no_audio_stream" + + # Check if audio is silent + volume_info = await get_audio_volume_info(video_path) + if is_audio_silent(volume_info): + return False, "audio_silent" + + return True, "audio_ok" + + +async def transcribe_video( + video_path: str, + use_noise_reduction: bool = True, + noise_reduction_level: str = "medium", + use_vocal_separation: bool = False, + progress_callback: Optional[callable] = None, +) -> Tuple[bool, str, Optional[List[TranscriptSegment]]]: + """ + Transcribe video audio using Whisper. + + Args: + video_path: Path to video file + use_noise_reduction: Whether to apply noise reduction before transcription + noise_reduction_level: "light", "medium", or "heavy" + use_vocal_separation: Whether to separate vocals from background music first + progress_callback: Optional async callback function(step: str, progress: int) for progress updates + + Returns: + Tuple of (success, message, segments, detected_language) + - success=False with message="NO_AUDIO" means video has no audio + - success=False with message="SILENT_AUDIO" means audio is too quiet + - success=False with message="SINGING_ONLY" means only singing detected (no speech) + """ + # Helper to call progress callback if provided + async def report_progress(step: str, progress: int): + print(f"[Transcriber] report_progress: {step} ({progress}%), has_callback: {progress_callback is not None}") + if progress_callback: + await progress_callback(step, progress) + + if not os.path.exists(video_path): + return False, f"Video file not found: {video_path}", None, None + + # Check audio availability + has_audio, audio_status = await check_audio_availability(video_path) + if not has_audio: + if audio_status == "no_audio_stream": + return False, "NO_AUDIO", None, None + elif audio_status == "audio_silent": + return False, "SILENT_AUDIO", None, None + + audio_path = video_path # Default to video path (Whisper can handle it) + temp_files = [] # Track temp files for cleanup + + try: + video_dir = os.path.dirname(video_path) + + # Step 1: Vocal separation (if enabled) + if use_vocal_separation: + from app.services.audio_separator import separate_vocals, analyze_vocal_type + + await report_progress("vocal_separation", 15) + print("Separating vocals from background music...") + separation_dir = os.path.join(video_dir, "separated") + + success, message, vocals_path, _ = await separate_vocals( + video_path, + separation_dir + ) + + if success and vocals_path: + print(f"Vocal separation complete: {vocals_path}") + temp_files.append(separation_dir) + + # Analyze if vocals are speech or singing + print("Analyzing vocal type (speech vs singing)...") + vocal_type, confidence = await analyze_vocal_type(vocals_path) + print(f"Vocal analysis: {vocal_type} (confidence: {confidence:.2f})") + + # Treat as singing if: + # 1. Explicitly detected as singing + # 2. Mixed with low confidence (< 0.6) - likely music, not clear speech + if vocal_type == "singing" or (vocal_type == "mixed" and confidence < 0.6): + # Only singing/music detected - no clear speech to transcribe + _cleanup_temp_files(temp_files) + reason = "SINGING_ONLY" if vocal_type == "singing" else "MUSIC_DOMINANT" + print(f"No clear speech detected ({reason}), awaiting manual subtitle") + return False, "SINGING_ONLY", None, None + + # Use vocals for transcription + audio_path = vocals_path + else: + print(f"Vocal separation failed: {message}, continuing with original audio") + + # Step 2: Apply noise reduction (if enabled and not using separated vocals) + if use_noise_reduction and audio_path == video_path: + from app.services.video_processor import extract_audio_with_noise_reduction + + await report_progress("extracting_audio", 20) + cleaned_path = os.path.join(video_dir, "audio_cleaned.wav") + + await report_progress("noise_reduction", 25) + print(f"Applying {noise_reduction_level} noise reduction...") + success, message = await extract_audio_with_noise_reduction( + video_path, + cleaned_path, + noise_reduction_level + ) + + if success: + print(f"Noise reduction complete: {message}") + audio_path = cleaned_path + temp_files.append(cleaned_path) + else: + print(f"Noise reduction failed: {message}, falling back to original audio") + + # Step 3: Transcribe with Whisper + await report_progress("transcribing", 35) + model = get_whisper_model() + + print(f"Transcribing audio: {audio_path}") + # Run Whisper in thread pool to avoid blocking the event loop + result = await asyncio.to_thread( + model.transcribe, + audio_path, + task="transcribe", + language=None, # Auto-detect + verbose=False, + word_timestamps=True, + ) + + # Split long segments using word-level timestamps + segments = _split_segments_by_words( + result.get("segments", []), + max_duration=2.0, # Maximum segment duration in seconds (shorter for better sync) + min_words=1, # Minimum words per segment + ) + + # Clean up temp files + _cleanup_temp_files(temp_files) + + detected_lang = result.get("language", "unknown") + print(f"Detected language: {detected_lang}") + extras = [] + if use_vocal_separation: + extras.append("vocal separation") + if use_noise_reduction: + extras.append(f"noise reduction: {noise_reduction_level}") + extra_info = f" ({', '.join(extras)})" if extras else "" + + # Return tuple with 4 elements: success, message, segments, detected_language + return True, f"Transcription complete (detected: {detected_lang}){extra_info}", segments, detected_lang + + except Exception as e: + _cleanup_temp_files(temp_files) + return False, f"Transcription error: {str(e)}", None, None + + +def _split_segments_by_words( + raw_segments: list, + max_duration: float = 4.0, + min_words: int = 2, +) -> List[TranscriptSegment]: + """ + Split long Whisper segments into shorter ones using word-level timestamps. + + Args: + raw_segments: Raw segments from Whisper output + max_duration: Maximum duration for each segment in seconds + min_words: Minimum words per segment (to avoid single-word segments) + + Returns: + List of TranscriptSegment with shorter durations + """ + segments = [] + + for seg in raw_segments: + words = seg.get("words", []) + seg_text = seg.get("text", "").strip() + seg_start = seg.get("start", 0) + seg_end = seg.get("end", 0) + seg_duration = seg_end - seg_start + + # If no word timestamps or segment is short enough, use as-is + if not words or seg_duration <= max_duration: + segments.append(TranscriptSegment( + start=seg_start, + end=seg_end, + text=seg_text, + )) + continue + + # Split segment using word timestamps + current_words = [] + current_start = None + + for i, word in enumerate(words): + word_start = word.get("start", seg_start) + word_end = word.get("end", seg_end) + word_text = word.get("word", "").strip() + + if not word_text: + continue + + # Start a new segment + if current_start is None: + current_start = word_start + + current_words.append(word_text) + current_duration = word_end - current_start + + # Check if we should split here + is_last_word = (i == len(words) - 1) + should_split = False + + if is_last_word: + should_split = True + elif current_duration >= max_duration and len(current_words) >= min_words: + should_split = True + elif current_duration >= max_duration * 0.5: + # Split at natural break points (punctuation) more aggressively + if word_text.endswith((',', '.', '!', '?', 'ใ€‚', '๏ผŒ', '๏ผ', '๏ผŸ', 'ใ€', '๏ผ›', ';')): + should_split = True + elif current_duration >= 1.0 and word_text.endswith(('ใ€‚', '๏ผ', '๏ผŸ', '.', '!', '?')): + # Always split at sentence endings if we have at least 1 second of content + should_split = True + + if should_split and current_words: + # Create segment + text = " ".join(current_words) + # For Chinese/Japanese, remove spaces between words + if any('\u4e00' <= c <= '\u9fff' for c in text): + text = text.replace(" ", "") + + segments.append(TranscriptSegment( + start=current_start, + end=word_end, + text=text, + )) + + # Reset for next segment + current_words = [] + current_start = None + + return segments + + +def _cleanup_temp_files(paths: list): + """Clean up temporary files and directories.""" + import shutil + for path in paths: + try: + if os.path.isdir(path): + shutil.rmtree(path, ignore_errors=True) + elif os.path.exists(path): + os.remove(path) + except Exception: + pass + + +def segments_to_srt(segments: List[TranscriptSegment], use_translated: bool = True) -> str: + """Convert segments to SRT format.""" + srt_lines = [] + + for i, seg in enumerate(segments, 1): + start_time = format_srt_time(seg.start) + end_time = format_srt_time(seg.end) + text = seg.translated if use_translated and seg.translated else seg.text + + srt_lines.append(f"{i}") + srt_lines.append(f"{start_time} --> {end_time}") + srt_lines.append(text) + srt_lines.append("") + + return "\n".join(srt_lines) + + +def format_srt_time(seconds: float) -> str: + """Format seconds to SRT timestamp format (HH:MM:SS,mmm).""" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + millis = int((seconds % 1) * 1000) + return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" + + +def segments_to_ass( + segments: List[TranscriptSegment], + use_translated: bool = True, + font_size: int = 28, + font_color: str = "FFFFFF", + outline_color: str = "000000", + font_name: str = "NanumGothic", + position: str = "bottom", # top, center, bottom + outline_width: int = 3, + bold: bool = True, + shadow: int = 1, + background_box: bool = True, + background_opacity: str = "E0", # 00=transparent, FF=opaque + animation: str = "none", # none, fade, pop + time_offset: float = 0.0, # Delay all subtitles by this amount (for intro text) +) -> str: + """ + Convert segments to ASS format with styling. + + Args: + segments: List of transcript segments + use_translated: Use translated text if available + font_size: Font size in pixels + font_color: Font color in hex (without #) + outline_color: Outline color in hex (without #) + font_name: Font family name + position: Subtitle position - "top", "center", or "bottom" + outline_width: Outline thickness + bold: Use bold text + shadow: Shadow depth (0-4) + background_box: Show semi-transparent background box + animation: Animation type - "none", "fade", or "pop" + time_offset: Delay all subtitle timings by this amount in seconds (useful when intro text is shown) + + Returns: + ASS formatted subtitle string + """ + # ASS Alignment values: + # 1=Bottom-Left, 2=Bottom-Center, 3=Bottom-Right + # 4=Middle-Left, 5=Middle-Center, 6=Middle-Right + # 7=Top-Left, 8=Top-Center, 9=Top-Right + alignment_map = { + "top": 8, # Top-Center + "center": 5, # Middle-Center (์˜์ƒ ๊ฐ€์šด๋ฐ) + "bottom": 2, # Bottom-Center (๊ธฐ๋ณธ๊ฐ’) + } + alignment = alignment_map.get(position, 2) + + # Adjust margin based on position (๋‚ฎ์€ ๊ฐ’ = ํ™”๋ฉด ๊ฐ€์žฅ์ž๋ฆฌ์— ๋” ๊ฐ€๊นŒ์›€) + # ์›๋ณธ ์ž๋ง‰์„ ๋ฎ๊ธฐ ์œ„ํ•ด ํ•˜๋‹จ ๋งˆ์ง„์„ ์ž‘๊ฒŒ ์„ค์ • + margin_v = 30 if position == "bottom" else (100 if position == "top" else 10) + + # Bold: -1 = bold, 0 = normal + bold_value = -1 if bold else 0 + + # BorderStyle: 1 = outline + shadow, 3 = opaque box (background) + border_style = 3 if background_box else 1 + + # BackColour alpha: use provided opacity or default + back_alpha = background_opacity if background_box else "80" + + # ASS header + ass_content = f"""[Script Info] +Title: Shorts Maker Subtitle +ScriptType: v4.00+ +PlayDepth: 0 +PlayResX: 1080 +PlayResY: 1920 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,{font_name},{font_size},&H00{font_color},&H00FFFFFF,&H00{outline_color},&H{back_alpha}000000,{bold_value},0,0,0,100,100,0,0,{border_style},{outline_width},{shadow},{alignment},30,30,{margin_v},1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +""" + + for seg in segments: + # Apply time offset (for intro text overlay) + start_time = format_ass_time(seg.start + time_offset) + end_time = format_ass_time(seg.end + time_offset) + text = seg.translated if use_translated and seg.translated else seg.text + # Escape special characters + text = text.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}") + + # Add animation effects + if animation == "fade": + # Fade in/out effect (250ms) + text = f"{{\\fad(250,250)}}{text}" + elif animation == "pop": + # Pop-in effect with scale animation + text = f"{{\\t(0,150,\\fscx110\\fscy110)\\t(150,300,\\fscx100\\fscy100)}}{text}" + + ass_content += f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{text}\n" + + return ass_content + + +def format_ass_time(seconds: float) -> str: + """Format seconds to ASS timestamp format (H:MM:SS.cc).""" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + centis = int((seconds % 1) * 100) + return f"{hours}:{minutes:02d}:{secs:02d}.{centis:02d}" diff --git a/backend/app/services/translator.py b/backend/app/services/translator.py new file mode 100644 index 0000000..a295d2b --- /dev/null +++ b/backend/app/services/translator.py @@ -0,0 +1,468 @@ +import re +from typing import List, Tuple, Optional +from openai import OpenAI +from app.models.schemas import TranscriptSegment +from app.config import settings + + +def get_openai_client() -> OpenAI: + """Get OpenAI client.""" + return OpenAI(api_key=settings.OPENAI_API_KEY) + + +class TranslationMode: + """Translation mode options.""" + DIRECT = "direct" # ์ง์ ‘ ๋ฒˆ์—ญ (์›๋ณธ ๊ตฌ์กฐ ์œ ์ง€) + SUMMARIZE = "summarize" # ์š”์•ฝ ํ›„ ๋ฒˆ์—ญ + REWRITE = "rewrite" # ์š”์•ฝ + ํ•œ๊ธ€ ๋Œ€๋ณธ ์žฌ์ž‘์„ฑ + + +async def shorten_text(client: OpenAI, text: str, max_chars: int) -> str: + """ + Shorten a Korean text to fit within character limit. + + Args: + client: OpenAI client + text: Text to shorten + max_chars: Maximum character count + + Returns: + Shortened text + """ + try: + response = client.chat.completions.create( + model=settings.OPENAI_MODEL, + messages=[ + { + "role": "system", + "content": f"""ํ•œ๊ตญ์–ด ์ž๋ง‰์„ {max_chars}์ž ์ด๋‚ด๋กœ ์ค„์ด์„ธ์š”. + +๊ทœ์น™: +- ๋ฐ˜๋“œ์‹œ {max_chars}์ž ์ดํ•˜! +- ํ•ต์‹ฌ ์˜๋ฏธ๋งŒ ์œ ์ง€ +- ์ž์—ฐ์Šค๋Ÿฌ์šด ํ•œ๊ตญ์–ด +- ์กด๋Œ“๋ง ์œ ์ง€ +- ์ถœ๋ ฅ์€ ์ค„์ธ ๋ฌธ์žฅ๋งŒ! + +์˜ˆ์‹œ: +์ž…๋ ฅ: "์š”๋ฆฌํ•  ๋•Œ๋งˆ๋‹ค ํ•œ ์‹œ๊ฐ„์ด ๊ฑธ๋ฆฌ์…จ์ฃ ?" (18์ž) +์ œํ•œ: 10์ž +์ถœ๋ ฅ: "์‹œ๊ฐ„ ์˜ค๋ž˜ ๊ฑธ๋ฆฌ์ฃ " (8์ž) + +์ž…๋ ฅ: "์ฑ„์†Œ ๋‹ค๋“ฌ๋Š” ๋ฐ๋งŒ 30๋ถ„ ๊ฑธ๋ฆฌ์…จ์ฃ " (16์ž) +์ œํ•œ: 10์ž +์ถœ๋ ฅ: "์ฑ„์†Œ๋งŒ 30๋ถ„" (6์ž)""" + }, + { + "role": "user", + "content": f"์ž…๋ ฅ: \"{text}\" ({len(text)}์ž)\n์ œํ•œ: {max_chars}์ž\n์ถœ๋ ฅ:" + } + ], + temperature=0.3, + max_tokens=50, + ) + + shortened = response.choices[0].message.content.strip() + # Remove quotes, parentheses, and extra characters + shortened = shortened.strip('"\'""''') + # Remove any trailing parenthetical notes like "(10์ž)" + shortened = re.sub(r'\s*\([^)]*์ž\)\s*$', '', shortened) + shortened = re.sub(r'\s*\(\d+์ž\)\s*$', '', shortened) + # Remove any remaining quotes + shortened = shortened.replace('"', '').replace('"', '').replace('"', '') + shortened = shortened.replace("'", '').replace("'", '').replace("'", '') + shortened = shortened.strip() + + # If still too long, truncate cleanly + if len(shortened) > max_chars: + shortened = shortened[:max_chars] + + return shortened + + except Exception as e: + # Fallback: simple truncation + if len(text) > max_chars: + return text[:max_chars-1] + "โ€ฆ" + return text + + +async def translate_segments( + segments: List[TranscriptSegment], + target_language: str = "Korean", + mode: str = TranslationMode.DIRECT, + max_tokens: Optional[int] = None, +) -> Tuple[bool, str, List[TranscriptSegment]]: + """ + Translate transcript segments to target language using OpenAI. + + Args: + segments: List of transcript segments + target_language: Target language for translation + mode: Translation mode (direct, summarize, rewrite) + max_tokens: Maximum output tokens (for cost control) + + Returns: + Tuple of (success, message, translated_segments) + """ + if not settings.OPENAI_API_KEY: + return False, "OpenAI API key not configured", segments + + try: + client = get_openai_client() + + # Batch translate for efficiency + texts = [seg.text for seg in segments] + combined_text = "\n---\n".join(texts) + + # Calculate video duration for context + total_duration = segments[-1].end if segments else 0 + + # Calculate segment info for length guidance + segment_info = [] + for i, seg in enumerate(segments): + duration = seg.end - seg.start + max_chars = int(duration * 5) # ~5 Korean chars per second (stricter for better sync) + segment_info.append(f"[{i+1}] {duration:.1f}์ดˆ = ์ตœ๋Œ€ {max_chars}์ž (์—„์ˆ˜!)") + + # Get custom prompt settings from config + gpt_role = settings.GPT_ROLE or "์นœ๊ทผํ•œ ์œ ํŠœ๋ธŒ ์‡ผ์ธ  ์ž๋ง‰ ์ž‘๊ฐ€" + gpt_tone = settings.GPT_TONE or "์กด๋Œ“๋ง" + gpt_style = settings.GPT_STYLE or "" + + # Tone examples + tone_examples = { + "์กด๋Œ“๋ง": '~ํ•ด์š”, ~์ด์—์š”, ~ํ•˜์ฃ ', + "๋ฐ˜๋ง": '~ํ•ด, ~์•ผ, ~์ง€', + "๊ฒฉ์‹์ฒด": '~ํ•ฉ๋‹ˆ๋‹ค, ~์ž…๋‹ˆ๋‹ค', + } + tone_example = tone_examples.get(gpt_tone, tone_examples["์กด๋Œ“๋ง"]) + + # Additional style instruction + style_instruction = f"\n6. Style: {gpt_style}" if gpt_style else "" + + # Select prompt based on mode + if mode == TranslationMode.REWRITE: + # Build indexed timeline input with Chinese text + # Use segment numbers to handle duplicate timestamps + timeline_input = [] + for i, seg in enumerate(segments): + mins = int(seg.start // 60) + secs = int(seg.start % 60) + timeline_input.append(f"[{i+1}] {mins}:{secs:02d} {seg.text}") + + system_prompt = f"""๋‹น์‹ ์€ ์ƒํ™œ์šฉํ’ˆ ์œ ํŠœ๋ธŒ ์‡ผ์ธ  ์ž๋ง‰ ์ž‘๊ฐ€์ž…๋‹ˆ๋‹ค. + +์ค‘๊ตญ์–ด ์›๋ฌธ์˜ "์˜๋ฏธ"๋งŒ ์ฐธ๊ณ ํ•˜์—ฌ, ํ•œ๊ตญ์ธ์ด ์ง์ ‘ ๋งํ•˜๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ์ž์—ฐ์Šค๋Ÿฌ์šด ์ž๋ง‰์„ ์ž‘์„ฑํ•˜์„ธ์š”. + +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” +๐ŸŽฏ ํ•ต์‹ฌ ์›์น™: ๋ฒˆ์—ญ์ด ์•„๋‹ˆ๋ผ "์žฌ์ฐฝ์ž‘" +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” + +โœ… ํ•„์ˆ˜ ๊ทœ์น™: +1. ํ•œ ๋ฌธ์žฅ = ํ•œ ๊ฐ€์ง€ ์ •๋ณด (๋‘ ๊ฐœ ์ด์ƒ ๊ธˆ์ง€) +2. ์ค‘๋ณต ํ‘œํ˜„ ์ ˆ๋Œ€ ๊ธˆ์ง€ ("ํŽธํ•ด์š”"๊ฐ€ ์ด๋ฏธ ๋‚˜์™”์œผ๋ฉด ๋‹ค์‹œ ์•ˆ ์”€) +3. {gpt_tone} ์‚ฌ์šฉ ({tone_example}) +4. ์„ธ๊ทธ๋จผํŠธ ์ˆ˜ ์œ ์ง€: ์ž…๋ ฅ {len(segments)}๊ฐœ โ†’ ์ถœ๋ ฅ {len(segments)}๊ฐœ +5. ์ค‘๊ตญ์–ด ํ•œ์ž ๊ธˆ์ง€, ์ˆœ์ˆ˜ ํ•œ๊ธ€๋งŒ + +โŒ ๊ธˆ์ง€ ํ‘œํ˜„ (๋ฒˆ์—ญํˆฌ): +- "~ํ•  ์ˆ˜ ์žˆ์–ด์š”" โ†’ "~๋ผ์š”", "~๋ฉ๋‹ˆ๋‹ค" +- "๋งค์šฐ/์•„์ฃผ/์ •๋ง" ๋‚จ์šฉ โ†’ ๊ผญ ํ•„์š”ํ•  ๋•Œ๋งŒ +- "๊ทธ๊ฒƒ์€/์ด๊ฒƒ์€" โ†’ "์ด๊ฑฐ", "์ด๊ฑด" +- "~ํ•˜๋Š” ๊ฒƒ์ด" โ†’ ์ง์ ‘ ํ‘œํ˜„์œผ๋กœ +- "ํŽธ๋ฆฌํ•ด์š”/ํŽธํ•ด์š”" ๋ฐ˜๋ณต โ†’ ํ•œ ๋ฒˆ๋งŒ, ์ดํ›„ ๋‹ค๋ฅธ ํ‘œํ˜„ +- "์ข‹์•„์š”/์ข‹๊ณ ์š”" ๋ฐ˜๋ณต โ†’ ๊ตฌ์ฒด์  ์žฅ์ ์œผ๋กœ ๋Œ€์ฒด + +๐ŸŽต ์‡ผ์ธ  ๋ฆฌ๋“ฌ๊ฐ: +- ์งง๊ฒŒ ๋Š์–ด์„œ +- ํ•œ ํ˜ธํก์— ํ•˜๋‚˜์”ฉ +- ์‹œ์ฒญ์ž๊ฐ€ ๋”ฐ๋ผ ์ฝ์„ ์ˆ˜ ์žˆ๊ฒŒ + +๐Ÿ“ ์ข‹์€ ์˜ˆ์‹œ: + +์›๋ฌธ: "์ด ์ž‘์€ ๋ฐ•์Šค ๋””์ž์ธ์ด ์ฐธ ์ข‹๋„ค์š”. ํ‰์†Œ์— ์”จ์•— ๋จน์„ ๋•Œ ๊ฐ„ํŽธํ•˜๊ฒŒ ๋จน์„ ์ˆ˜ ์žˆ์–ด์š”." +โŒ ๋‚˜์œ ๋ฒˆ์—ญ: "์ด ์ž‘์€ ๋ฐ•์Šค ๋””์ž์ธ์ด ์ฐธ ์ข‹๋„ค์š”. ํ‰์†Œ์— ์”จ์•— ๋จน์„ ๋•Œ ๊ฐ„ํŽธํ•˜๊ฒŒ ๋จน์„ ์ˆ˜ ์žˆ์–ด์š”." +โœ… ์ข‹์€ ์žฌ์ฐฝ์ž‘: "์ด ์ž‘์€ ๋ฐ•์Šค, ์ƒ๊ฐ๋ณด๋‹ค ์ •๋ง ์ž˜ ๋งŒ๋“ค์—ˆ์–ด์š”." + +์›๋ฌธ: "ํ…Œ์ด๋ธ”์— ๋‘๊ฑฐ๋‚˜ ์†์— ๋“ค๊ณ  ์‚ฌ์šฉํ•˜๊ธฐ์—๋„ ์ข‹๊ณ ์š”. ์นจ๋Œ€์— ๋ˆ„์›Œ์„œ๋‚˜ ์‚ฌ๋ฌด์‹ค์—์„œ๋„ ๊ฐ„์‹์ด๋‚˜ ๊ณผ์ผ ๋จน๊ธฐ ์ •๋ง ํŽธํ•ด์š”." +โŒ ๋‚˜์œ ๋ฒˆ์—ญ: "ํ…Œ์ด๋ธ”์— ๋‘๊ฑฐ๋‚˜ ์†์— ๋“ค๊ณ  ์‚ฌ์šฉํ•˜๊ธฐ์—๋„ ์ข‹๊ณ ์š”. ์นจ๋Œ€์— ๋ˆ„์›Œ์„œ๋‚˜ ์‚ฌ๋ฌด์‹ค์—์„œ๋„ ๊ฐ„์‹์ด๋‚˜ ๊ณผ์ผ ๋จน๊ธฐ ์ •๋ง ํŽธํ•ด์š”." +โœ… ์ข‹์€ ์žฌ์ฐฝ์ž‘ (2๊ฐœ๋กœ ๋ถ„๋ฆฌ): + - "ํ…Œ์ด๋ธ” ์œ„์—์„œ๋„, ์นจ๋Œ€์—์„œ๋„, ์‚ฌ๋ฌด์‹ค์—์„œ๋„ ์‚ฌ์šฉํ•˜๊ธฐ ์ข‹๊ณ " + - "๊ณผ์ผ ์”ป๊ณ  ๋ฌผ๊ธฐ ๋นผ๋Š” ๋ฐ๋„ ํ™œ์šฉ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค." + +์›๋ฌธ: "๊ฐ€์ •์—์„œ ํ•„์ˆ˜ ์•„์ดํ…œ์ด์—์š”. ์ •๋ง ์œ ์šฉํ•˜์ฃ . ๊ผญ ํ•˜๋‚˜์”ฉ ๊ฐ€์ ธ์•ผ ํ•  ์ œํ’ˆ์ด์—์š”." +โŒ ๋‚˜์œ ๋ฒˆ์—ญ: ๊ทธ๋Œ€๋กœ 3๋ฌธ์žฅ +โœ… ์ข‹์€ ์žฌ์ฐฝ์ž‘: "์ง‘์— ํ•˜๋‚˜ ์žˆ์œผ๋ฉด ์€๊ทผํžˆ ์ž์ฃผ ์“ฐ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค."{style_instruction} + +์ถœ๋ ฅ ํ˜•์‹: +[๋ฒˆํ˜ธ] ์‹œ๊ฐ„ ์ž๋ง‰ ๋‚ด์šฉ + +โš ๏ธ ์ž…๋ ฅ๊ณผ ๋™์ผํ•œ ์„ธ๊ทธ๋จผํŠธ ์ˆ˜({len(segments)}๊ฐœ)๋ฅผ ์ถœ๋ ฅํ•˜์„ธ์š”! +โš ๏ธ ๊ฐ [๋ฒˆํ˜ธ]๋Š” ์ž…๋ ฅ๊ณผ 1:1 ๋Œ€์‘ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค!""" + + # Use indexed timeline format for user content + combined_text = "[์ค‘๊ตญ์–ด ์›๋ฌธ]\n\n" + "\n".join(timeline_input) + + elif mode == TranslationMode.SUMMARIZE: + system_prompt = f"""You are: {gpt_role} + +Task: Translate Chinese to SHORT Korean subtitles. + +Length limits (์ž๋ง‰ ์‹ฑํฌ!): +{chr(10).join(segment_info)} + +Rules: +1. Use {gpt_tone} ({tone_example}) +2. Summarize to core meaning - be BRIEF +3. Max one short sentence per segment +4. {len(segments)} segments separated by '---'{style_instruction}""" + + else: # DIRECT mode + system_prompt = f"""You are: {gpt_role} + +Task: Translate Chinese to Korean subtitles. + +Length limits (์ž๋ง‰ ์‹ฑํฌ!): +{chr(10).join(segment_info)} + +Rules: +1. Use {gpt_tone} ({tone_example}) +2. Keep translations SHORT and readable +3. {len(segments)} segments separated by '---'{style_instruction}""" + + # Build API request + request_params = { + "model": settings.OPENAI_MODEL, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": combined_text} + ], + "temperature": 0.65 if mode == TranslationMode.REWRITE else 0.3, + } + + # Add max_tokens if specified (for cost control) + effective_max_tokens = max_tokens or settings.TRANSLATION_MAX_TOKENS + if effective_max_tokens: + # Use higher token limit for REWRITE mode + if mode == TranslationMode.REWRITE: + request_params["max_tokens"] = max(effective_max_tokens, 700) + else: + request_params["max_tokens"] = effective_max_tokens + + response = client.chat.completions.create(**request_params) + + translated_text = response.choices[0].message.content + + # Parse based on mode + if mode == TranslationMode.REWRITE: + # Parse indexed timeline format: "[1] 0:00 ์ž๋ง‰\n[2] 0:02 ์ž๋ง‰\n..." + indexed_pattern = re.compile(r'^\[(\d+)\]\s*\d+:\d{2}\s+(.+)$', re.MULTILINE) + matches = indexed_pattern.findall(translated_text) + + # Create mapping from segment index to translation + translations_by_index = {} + for idx, text in matches: + translations_by_index[int(idx)] = text.strip() + + # Map translations back to segments by index (1-based) + for i, seg in enumerate(segments): + seg_num = i + 1 # 1-based index + if seg_num in translations_by_index: + seg.translated = translations_by_index[seg_num] + else: + # No matching translation found - try fallback to old timestamp-based parsing + seg.translated = "" + + # Fallback: if no indexed matches, try old timestamp format + if not matches: + print("[Warning] No indexed format found, falling back to timestamp parsing") + timeline_pattern = re.compile(r'^(\d+):(\d{2})\s+(.+)$', re.MULTILINE) + timestamp_matches = timeline_pattern.findall(translated_text) + + # Create mapping from timestamp to translation + translations_by_time = {} + for mins, secs, text in timestamp_matches: + time_sec = int(mins) * 60 + int(secs) + translations_by_time[time_sec] = text.strip() + + # Track used translations to prevent duplicates + used_translations = set() + + # Map translations back to segments by matching start times + for seg in segments: + start_sec = int(seg.start) + matched_time = None + + # Try exact match first + if start_sec in translations_by_time and start_sec not in used_translations: + matched_time = start_sec + else: + # Try to find closest UNUSED match within 1 second + for t in range(start_sec - 1, start_sec + 2): + if t in translations_by_time and t not in used_translations: + matched_time = t + break + + if matched_time is not None: + seg.translated = translations_by_time[matched_time] + used_translations.add(matched_time) + else: + seg.translated = "" + else: + # Original parsing for other modes + translated_parts = translated_text.split("---") + for i, seg in enumerate(segments): + if i < len(translated_parts): + seg.translated = translated_parts[i].strip() + else: + seg.translated = seg.text # Fallback to original + + # Calculate token usage for logging + usage = response.usage + token_info = f"(tokens: {usage.prompt_tokens}+{usage.completion_tokens}={usage.total_tokens})" + + # Post-processing: Shorten segments that exceed character limit + # Skip for REWRITE mode - the prompt handles length naturally + shortened_count = 0 + if mode != TranslationMode.REWRITE: + chars_per_sec = 5 + for i, seg in enumerate(segments): + if seg.translated: + duration = seg.end - seg.start + max_chars = int(duration * chars_per_sec) + current_len = len(seg.translated) + + if current_len > max_chars * 1.3 and max_chars >= 5: + seg.translated = await shorten_text(client, seg.translated, max_chars) + shortened_count += 1 + print(f"[Shorten] Seg {i+1}: {current_len}โ†’{len(seg.translated)}์ž (์ œํ•œ:{max_chars}์ž)") + + shorten_info = f" [์ถ•์•ฝ:{shortened_count}๊ฐœ]" if shortened_count > 0 else "" + + return True, f"Translation complete [{mode}] {token_info}{shorten_info}", segments + + except Exception as e: + return False, f"Translation error: {str(e)}", segments + + +async def generate_shorts_script( + segments: List[TranscriptSegment], + style: str = "engaging", + max_tokens: int = 500, +) -> Tuple[bool, str, Optional[str]]: + """ + Generate a completely new Korean Shorts script from Chinese transcript. + + Args: + segments: Original transcript segments + style: Script style (engaging, informative, funny, dramatic) + max_tokens: Maximum output tokens + + Returns: + Tuple of (success, message, script) + """ + if not settings.OPENAI_API_KEY: + return False, "OpenAI API key not configured", None + + try: + client = get_openai_client() + + # Combine all text + full_text = " ".join([seg.text for seg in segments]) + total_duration = segments[-1].end if segments else 0 + + style_guides = { + "engaging": "Use hooks, questions, and emotional expressions. Start with attention-grabbing line.", + "informative": "Focus on facts and clear explanations. Use simple, direct language.", + "funny": "Add humor, wordplay, and light-hearted tone. Include relatable jokes.", + "dramatic": "Build tension and suspense. Use impactful short sentences.", + } + + style_guide = style_guides.get(style, style_guides["engaging"]) + + system_prompt = f"""You are a viral Korean YouTube Shorts script writer. + +Create a COMPLETELY ORIGINAL Korean script inspired by the Chinese video content. + +=== CRITICAL: ANTI-PLAGIARISM RULES === +- This is NOT translation - it's ORIGINAL CONTENT CREATION +- NEVER copy sentence structures, word order, or phrasing from original +- Extract only the CORE IDEA, then write YOUR OWN script from scratch +- Imagine you're a Korean creator who just learned this interesting fact +- Add your own personality, reactions, and Korean cultural context +======================================= + +Video duration: ~{int(total_duration)} seconds +Style: {style} +Guide: {style_guide} + +Output format: +[0:00] ์ฒซ ๋ฒˆ์งธ ๋Œ€์‚ฌ +[0:03] ๋‘ ๋ฒˆ์งธ ๋Œ€์‚ฌ +... + +Requirements: +- Write in POLITE FORMAL KOREAN (์กด๋Œ“๋ง/๊ฒฝ์–ด) - friendly but respectful +- Each line: 2-3 seconds when spoken aloud +- Start with a HOOK that grabs attention +- Use polite Korean expressions: "์ด๊ฑฐ ์•„์„ธ์š”?", "์ •๋ง ์‹ ๊ธฐํ•˜์ฃ ", "๊ทผ๋ฐ ์—ฌ๊ธฐ์„œ ์ค‘์š”ํ•œ ๊ฑด์š”" +- End with engagement: question, call-to-action, or surprise +- Make it feel like ORIGINAL Korean content, not a translation""" + + response = client.chat.completions.create( + model=settings.OPENAI_MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Chinese transcript:\n{full_text}"} + ], + temperature=0.7, + max_tokens=max_tokens, + ) + + script = response.choices[0].message.content + usage = response.usage + token_info = f"(tokens: {usage.total_tokens})" + + return True, f"Script generated [{style}] {token_info}", script + + except Exception as e: + return False, f"Script generation error: {str(e)}", None + + +async def translate_single( + text: str, + target_language: str = "Korean", + max_tokens: Optional[int] = None, +) -> Tuple[bool, str]: + """Translate a single text.""" + if not settings.OPENAI_API_KEY: + return False, text + + try: + client = get_openai_client() + + request_params = { + "model": settings.OPENAI_MODEL, + "messages": [ + { + "role": "system", + "content": f"Translate to {target_language}. Only output the translation, nothing else." + }, + { + "role": "user", + "content": text + } + ], + "temperature": 0.3, + } + + if max_tokens: + request_params["max_tokens"] = max_tokens + + response = client.chat.completions.create(**request_params) + + translated = response.choices[0].message.content + return True, translated.strip() + + except Exception as e: + return False, text diff --git a/backend/app/services/video_processor.py b/backend/app/services/video_processor.py new file mode 100644 index 0000000..784e98f --- /dev/null +++ b/backend/app/services/video_processor.py @@ -0,0 +1,659 @@ +import subprocess +import asyncio +import os +from typing import Optional, Tuple +from app.config import settings + + +async def process_video( + input_path: str, + output_path: str, + subtitle_path: Optional[str] = None, + bgm_path: Optional[str] = None, + bgm_volume: float = 0.3, + keep_original_audio: bool = False, + intro_text: Optional[str] = None, + intro_duration: float = 0.7, + intro_font_size: int = 100, +) -> Tuple[bool, str]: + """ + Process video: remove audio, add subtitles, add BGM, add intro text. + + Args: + input_path: Path to input video + output_path: Path for output video + subtitle_path: Path to ASS/SRT subtitle file + bgm_path: Path to BGM audio file + bgm_volume: Volume level for BGM (0.0 - 1.0) + keep_original_audio: Whether to keep original audio + intro_text: Text to display at the beginning of video (YouTube Shorts thumbnail) + intro_duration: How long to display intro text (seconds) + intro_font_size: Font size for intro text (100-120 recommended) + + Returns: + Tuple of (success, message) + """ + if not os.path.exists(input_path): + return False, f"Input video not found: {input_path}" + + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + # Build FFmpeg command + cmd = ["ffmpeg", "-y"] # -y to overwrite + + # Input video + cmd.extend(["-i", input_path]) + + # Input BGM if provided (stream_loop must come BEFORE -i) + if bgm_path and os.path.exists(bgm_path): + cmd.extend(["-stream_loop", "-1"]) # Loop BGM infinitely + cmd.extend(["-i", bgm_path]) + + # Build filter complex + filter_parts = [] + audio_parts = [] + + # Audio handling + if keep_original_audio and bgm_path and os.path.exists(bgm_path): + # Mix original audio with BGM + filter_parts.append(f"[0:a]volume=1.0[original]") + filter_parts.append(f"[1:a]volume={bgm_volume}[bgm]") + filter_parts.append(f"[original][bgm]amix=inputs=2:duration=shortest[audio]") + audio_output = "[audio]" + elif bgm_path and os.path.exists(bgm_path): + # BGM only (no original audio) + filter_parts.append(f"[1:a]volume={bgm_volume}[audio]") + audio_output = "[audio]" + elif keep_original_audio: + # Original audio only + audio_output = "0:a" + else: + # No audio + audio_output = None + + # Build video filter chain + video_filters = [] + + # Note: We no longer use tpad to add frozen frames, as it extends the video duration. + # Instead, intro text is simply overlaid on the existing video content. + + # 2. Add subtitle overlay if provided + if subtitle_path and os.path.exists(subtitle_path): + escaped_path = subtitle_path.replace("\\", "/").replace(":", "\\:").replace("'", "\\'") + video_filters.append(f"ass='{escaped_path}'") + + # 3. Add intro text overlay if provided (shown during frozen frame portion) + if intro_text: + # Find a suitable font - try common Korean fonts + font_options = [ + "/System/Library/Fonts/Supplemental/AppleGothic.ttf", # macOS Korean + "/System/Library/Fonts/AppleSDGothicNeo.ttc", # macOS Korean + "/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf", # Linux Korean + "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", # Linux CJK + ] + + font_file = None + for font in font_options: + if os.path.exists(font): + font_file = font.replace(":", "\\:") + break + + # Adjust font size and split text if too long + # Shorts video is 1080 width, so ~10-12 chars fit comfortably at 100px + text_len = len(intro_text) + adjusted_font_size = intro_font_size + + # Split into 2 lines if text is long (more than 10 chars) + lines = [] + if text_len > 10: + # Find best split point near middle + mid = text_len // 2 + split_pos = mid + for i in range(mid, max(0, mid - 5), -1): + if intro_text[i] in ' ,ใ€๏ผŒ': + split_pos = i + 1 + break + for i in range(mid, min(text_len, mid + 5)): + if intro_text[i] in ' ,ใ€๏ผŒ': + split_pos = i + 1 + break + + line1 = intro_text[:split_pos].strip() + line2 = intro_text[split_pos:].strip() + if line2: + lines = [line1, line2] + else: + lines = [intro_text] + else: + lines = [intro_text] + + # Adjust font size based on longest line length + max_line_len = max(len(line) for line in lines) + if max_line_len > 12: + adjusted_font_size = int(intro_font_size * 10 / max_line_len) + adjusted_font_size = max(50, min(adjusted_font_size, intro_font_size)) # Clamp between 50-100 + + # Add fade effect timing + fade_out_start = max(0.1, intro_duration - 0.3) + alpha_expr = f"if(gt(t,{fade_out_start}),(({intro_duration}-t)/0.3),1)" + + # Create drawtext filter(s) for each line + line_height = adjusted_font_size + 20 + total_height = line_height * len(lines) + + for i, line in enumerate(lines): + escaped_text = line.replace("'", "\\'").replace(":", "\\:").replace("\\", "\\\\") + + # Calculate y position for this line (centered overall) + if len(lines) == 1: + y_expr = "(h-text_h)/2" + else: + # Center the block of lines, then position each line + y_offset = int((i - (len(lines) - 1) / 2) * line_height) + y_expr = f"(h-text_h)/2+{y_offset}" + + drawtext_parts = [ + f"text='{escaped_text}'", + f"fontsize={adjusted_font_size}", + "fontcolor=white", + "x=(w-text_w)/2", # Center horizontally + f"y={y_expr}", + f"enable='lt(t,{intro_duration})'", + "borderw=3", + "bordercolor=black", + "box=1", + "boxcolor=black@0.6", + "boxborderw=15", + f"alpha='{alpha_expr}'", + ] + + if font_file: + drawtext_parts.insert(1, f"fontfile='{font_file}'") + + video_filters.append(f"drawtext={':'.join(drawtext_parts)}") + + # Combine video filters + video_filter_str = ",".join(video_filters) if video_filters else None + + # Construct FFmpeg command + if filter_parts or video_filter_str: + if filter_parts and video_filter_str: + full_filter = ";".join(filter_parts) + f";[0:v]{video_filter_str}[vout]" + cmd.extend(["-filter_complex", full_filter]) + cmd.extend(["-map", "[vout]"]) + if audio_output and audio_output.startswith("["): + cmd.extend(["-map", audio_output]) + elif audio_output: + cmd.extend(["-map", audio_output]) + elif video_filter_str: + cmd.extend(["-vf", video_filter_str]) + if bgm_path and os.path.exists(bgm_path): + cmd.extend(["-filter_complex", f"[1:a]volume={bgm_volume}[audio]"]) + cmd.extend(["-map", "0:v", "-map", "[audio]"]) + elif not keep_original_audio: + cmd.extend(["-an"]) # No audio + elif filter_parts: + cmd.extend(["-filter_complex", ";".join(filter_parts)]) + cmd.extend(["-map", "0:v"]) + if audio_output and audio_output.startswith("["): + cmd.extend(["-map", audio_output]) + else: + if not keep_original_audio: + cmd.extend(["-an"]) + + # Output settings + cmd.extend([ + "-c:v", "libx264", + "-preset", "medium", + "-crf", "23", + "-c:a", "aac", + "-b:a", "128k", + "-shortest", + output_path + ]) + + try: + # Run FFmpeg in thread pool to avoid blocking the event loop + result = await asyncio.to_thread( + subprocess.run, + cmd, + capture_output=True, + text=True, + timeout=600, # 10 minute timeout + ) + + if result.returncode != 0: + error_msg = result.stderr[-500:] if result.stderr else "Unknown error" + return False, f"FFmpeg error: {error_msg}" + + if os.path.exists(output_path): + return True, "Video processing complete" + else: + return False, "Output file not created" + + except subprocess.TimeoutExpired: + return False, "Processing timed out" + except Exception as e: + return False, f"Processing error: {str(e)}" + + +async def get_video_duration(video_path: str) -> Optional[float]: + """Get video duration in seconds.""" + cmd = [ + "ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + video_path + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode == 0: + return float(result.stdout.strip()) + except Exception: + pass + + return None + + +async def get_video_info(video_path: str) -> Optional[dict]: + """Get video information (duration, resolution, etc.).""" + import json as json_module + + cmd = [ + "ffprobe", + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height,duration:format=duration", + "-of", "json", + video_path + ] + + try: + result = await asyncio.to_thread( + subprocess.run, + cmd, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + data = json_module.loads(result.stdout) + info = {} + + # Get duration from format (more reliable) + if "format" in data and "duration" in data["format"]: + info["duration"] = float(data["format"]["duration"]) + + # Get resolution from stream + if "streams" in data and len(data["streams"]) > 0: + stream = data["streams"][0] + info["width"] = stream.get("width") + info["height"] = stream.get("height") + + return info if info else None + except Exception: + pass + + return None + + +async def trim_video( + input_path: str, + output_path: str, + start_time: float, + end_time: float, +) -> Tuple[bool, str]: + """ + Trim video to specified time range. + + Args: + input_path: Path to input video + output_path: Path for output video + start_time: Start time in seconds + end_time: End time in seconds + + Returns: + Tuple of (success, message) + """ + if not os.path.exists(input_path): + return False, f"Input video not found: {input_path}" + + # Validate time range + duration = await get_video_duration(input_path) + if duration is None: + return False, "Could not get video duration" + + if start_time < 0: + start_time = 0 + if end_time > duration: + end_time = duration + if start_time >= end_time: + return False, f"Invalid time range: start ({start_time}) >= end ({end_time})" + + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + trim_duration = end_time - start_time + + # Log trim parameters for debugging + print(f"[Trim] Input: {input_path}") + print(f"[Trim] Original duration: {duration:.3f}s") + print(f"[Trim] Requested: start={start_time:.3f}s, end={end_time:.3f}s") + print(f"[Trim] Output duration should be: {trim_duration:.3f}s") + + # Use -ss BEFORE -i for input seeking (faster and more reliable for end trimming) + # Combined with -t for accurate duration control + # -accurate_seek ensures frame-accurate seeking + cmd = [ + "ffmpeg", "-y", + "-accurate_seek", # Enable accurate seeking + "-ss", str(start_time), # Input seeking (before -i) + "-i", input_path, + "-t", str(trim_duration), # Duration of output + "-c:v", "libx264", # Re-encode video for accurate cut + "-preset", "fast", # Fast encoding preset + "-crf", "18", # High quality (lower = better) + "-c:a", "aac", # Re-encode audio + "-b:a", "128k", # Audio bitrate + "-avoid_negative_ts", "make_zero", # Fix timestamp issues + output_path + ] + + print(f"[Trim] Command: {' '.join(cmd)}") + + try: + result = await asyncio.to_thread( + subprocess.run, + cmd, + capture_output=True, + text=True, + timeout=120, + ) + + if result.returncode != 0: + error_msg = result.stderr[-300:] if result.stderr else "Unknown error" + print(f"[Trim] FFmpeg error: {error_msg}") + return False, f"Trim failed: {error_msg}" + + if os.path.exists(output_path): + new_duration = await get_video_duration(output_path) + print(f"[Trim] Success! New duration: {new_duration:.3f}s (expected: {trim_duration:.3f}s)") + print(f"[Trim] Difference from expected: {abs(new_duration - trim_duration):.3f}s") + return True, f"Video trimmed successfully ({new_duration:.1f}s)" + else: + print("[Trim] Error: Output file not created") + return False, "Output file not created" + + except subprocess.TimeoutExpired: + print("[Trim] Error: Timeout") + return False, "Trim operation timed out" + except Exception as e: + print(f"[Trim] Error: {str(e)}") + return False, f"Trim error: {str(e)}" + + +async def extract_frame( + video_path: str, + output_path: str, + timestamp: float, +) -> Tuple[bool, str]: + """ + Extract a single frame from video at specified timestamp. + + Args: + video_path: Path to input video + output_path: Path for output image (jpg/png) + timestamp: Time in seconds + + Returns: + Tuple of (success, message) + """ + if not os.path.exists(video_path): + return False, f"Video not found: {video_path}" + + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + cmd = [ + "ffmpeg", "-y", + "-ss", str(timestamp), + "-i", video_path, + "-frames:v", "1", + "-q:v", "2", + output_path + ] + + try: + result = await asyncio.to_thread( + subprocess.run, + cmd, + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode == 0 and os.path.exists(output_path): + return True, "Frame extracted" + return False, result.stderr[-200:] if result.stderr else "Unknown error" + except Exception as e: + return False, str(e) + + +async def get_audio_duration(audio_path: str) -> Optional[float]: + """Get audio duration in seconds.""" + return await get_video_duration(audio_path) # Same command works + + +async def extract_audio(video_path: str, output_path: str) -> Tuple[bool, str]: + """Extract audio from video.""" + cmd = [ + "ffmpeg", "-y", + "-i", video_path, + "-vn", + "-acodec", "pcm_s16le", + "-ar", "16000", + "-ac", "1", + output_path + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + if result.returncode == 0: + return True, "Audio extracted" + return False, result.stderr + except Exception as e: + return False, str(e) + + +async def extract_audio_with_noise_reduction( + video_path: str, + output_path: str, + noise_reduction_level: str = "medium" +) -> Tuple[bool, str]: + """ + Extract audio from video with noise reduction for better STT accuracy. + + Args: + video_path: Path to input video + output_path: Path for output audio (WAV format recommended) + noise_reduction_level: "light", "medium", or "heavy" + + Returns: + Tuple of (success, message) + """ + if not os.path.exists(video_path): + return False, f"Video file not found: {video_path}" + + # Build audio filter chain based on noise reduction level + filters = [] + + # 1. High-pass filter: Remove low frequency rumble (< 80Hz) + filters.append("highpass=f=80") + + # 2. Low-pass filter: Remove high frequency hiss (> 8000Hz for speech) + filters.append("lowpass=f=8000") + + if noise_reduction_level == "light": + # Light: Just basic frequency filtering + pass + + elif noise_reduction_level == "medium": + # Medium: Add FFT-based denoiser + # afftdn: nr=noise reduction amount (0-100), nf=noise floor + filters.append("afftdn=nf=-25:nr=10:nt=w") + + elif noise_reduction_level == "heavy": + # Heavy: More aggressive noise reduction + filters.append("afftdn=nf=-20:nr=20:nt=w") + # Add dynamic range compression to normalize volume + filters.append("acompressor=threshold=-20dB:ratio=4:attack=5:release=50") + + # 3. Normalize audio levels + filters.append("loudnorm=I=-16:TP=-1.5:LRA=11") + + filter_chain = ",".join(filters) + + cmd = [ + "ffmpeg", "-y", + "-i", video_path, + "-vn", # No video + "-af", filter_chain, + "-acodec", "pcm_s16le", # PCM format for Whisper + "-ar", "16000", # 16kHz sample rate (Whisper optimal) + "-ac", "1", # Mono + output_path + ] + + try: + # Run FFmpeg in thread pool to avoid blocking the event loop + result = await asyncio.to_thread( + subprocess.run, + cmd, + capture_output=True, + text=True, + timeout=120, + ) + + if result.returncode != 0: + error_msg = result.stderr[-300:] if result.stderr else "Unknown error" + return False, f"Audio extraction failed: {error_msg}" + + if os.path.exists(output_path): + return True, f"Audio extracted with {noise_reduction_level} noise reduction" + else: + return False, "Output file not created" + + except subprocess.TimeoutExpired: + return False, "Audio extraction timed out" + except Exception as e: + return False, f"Audio extraction error: {str(e)}" + + +async def analyze_audio_noise_level(audio_path: str) -> Optional[dict]: + """ + Analyze audio to detect noise level. + + Returns dict with mean_volume, max_volume, noise_floor estimates. + """ + cmd = [ + "ffmpeg", + "-i", audio_path, + "-af", "volumedetect", + "-f", "null", + "-" + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + stderr = result.stderr + + # Parse volume detection output + info = {} + for line in stderr.split('\n'): + if 'mean_volume' in line: + info['mean_volume'] = float(line.split(':')[1].strip().replace(' dB', '')) + elif 'max_volume' in line: + info['max_volume'] = float(line.split(':')[1].strip().replace(' dB', '')) + + return info if info else None + + except Exception: + return None + + +async def has_audio_stream(video_path: str) -> bool: + """ + Check if video file has an audio stream. + + Returns: + True if video has audio, False otherwise + """ + cmd = [ + "ffprobe", + "-v", "error", + "-select_streams", "a", # Select only audio streams + "-show_entries", "stream=codec_type", + "-of", "csv=p=0", + video_path + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + # If there's audio, ffprobe will output "audio" + return "audio" in result.stdout.lower() + except Exception: + return False + + +async def get_audio_volume_info(video_path: str) -> Optional[dict]: + """ + Get audio volume information to detect silent audio. + + Returns: + dict with mean_volume, or None if no audio or error + """ + # First check if audio stream exists + if not await has_audio_stream(video_path): + return None + + cmd = [ + "ffmpeg", + "-i", video_path, + "-af", "volumedetect", + "-f", "null", + "-" + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + stderr = result.stderr + + info = {} + for line in stderr.split('\n'): + if 'mean_volume' in line: + info['mean_volume'] = float(line.split(':')[1].strip().replace(' dB', '')) + elif 'max_volume' in line: + info['max_volume'] = float(line.split(':')[1].strip().replace(' dB', '')) + + return info if info else None + + except Exception: + return None + + +def is_audio_silent(volume_info: Optional[dict], threshold_db: float = -50.0) -> bool: + """ + Check if audio is effectively silent (below threshold). + + Args: + volume_info: dict from get_audio_volume_info + threshold_db: Volume below this is considered silent (default -50dB) + + Returns: + True if silent or no audio, False otherwise + """ + if not volume_info: + return True + + mean_volume = volume_info.get('mean_volume', -100) + return mean_volume < threshold_db diff --git a/backend/data/bgm/bit_forest_intro_music.mp3 b/backend/data/bgm/bit_forest_intro_music.mp3 new file mode 100644 index 0000000..3293175 Binary files /dev/null and b/backend/data/bgm/bit_forest_intro_music.mp3 differ diff --git a/backend/data/bgm/chill_lofi.mp3 b/backend/data/bgm/chill_lofi.mp3 new file mode 100644 index 0000000..e7d40ff Binary files /dev/null and b/backend/data/bgm/chill_lofi.mp3 differ diff --git a/backend/data/bgm/cinematic_epic.mp3 b/backend/data/bgm/cinematic_epic.mp3 new file mode 100644 index 0000000..3e66c2e Binary files /dev/null and b/backend/data/bgm/cinematic_epic.mp3 differ diff --git a/backend/data/bgm/electronic_chill.mp3 b/backend/data/bgm/electronic_chill.mp3 new file mode 100644 index 0000000..f066889 Binary files /dev/null and b/backend/data/bgm/electronic_chill.mp3 differ diff --git a/backend/data/bgm/funny_comedy.mp3 b/backend/data/bgm/funny_comedy.mp3 new file mode 100644 index 0000000..7b59a63 Binary files /dev/null and b/backend/data/bgm/funny_comedy.mp3 differ diff --git a/backend/data/bgm/happy_pop.mp3 b/backend/data/bgm/happy_pop.mp3 new file mode 100644 index 0000000..74d7d9d Binary files /dev/null and b/backend/data/bgm/happy_pop.mp3 differ diff --git a/backend/data/bgm/inspirational.mp3 b/backend/data/bgm/inspirational.mp3 new file mode 100644 index 0000000..e1cbfb2 Binary files /dev/null and b/backend/data/bgm/inspirational.mp3 differ diff --git a/backend/data/bgm/quirky_playful.mp3 b/backend/data/bgm/quirky_playful.mp3 new file mode 100644 index 0000000..b85cc7c Binary files /dev/null and b/backend/data/bgm/quirky_playful.mp3 differ diff --git a/backend/data/bgm/shopping_bright.mp3 b/backend/data/bgm/shopping_bright.mp3 new file mode 100644 index 0000000..e728f32 Binary files /dev/null and b/backend/data/bgm/shopping_bright.mp3 differ diff --git a/backend/data/bgm/soft_ambient.mp3 b/backend/data/bgm/soft_ambient.mp3 new file mode 100644 index 0000000..86d02bd Binary files /dev/null and b/backend/data/bgm/soft_ambient.mp3 differ diff --git a/backend/data/bgm/soft_corporate.mp3 b/backend/data/bgm/soft_corporate.mp3 new file mode 100644 index 0000000..1a91651 Binary files /dev/null and b/backend/data/bgm/soft_corporate.mp3 differ diff --git a/backend/data/bgm/upbeat_80s_synth.mp3 b/backend/data/bgm/upbeat_80s_synth.mp3 new file mode 100644 index 0000000..dd33c58 Binary files /dev/null and b/backend/data/bgm/upbeat_80s_synth.mp3 differ diff --git a/backend/data/bgm/upbeat_energetic.mp3 b/backend/data/bgm/upbeat_energetic.mp3 new file mode 100644 index 0000000..fca7813 Binary files /dev/null and b/backend/data/bgm/upbeat_energetic.mp3 differ diff --git a/backend/data/bgm/upbeat_fun.mp3 b/backend/data/bgm/upbeat_fun.mp3 new file mode 100644 index 0000000..3d680ef Binary files /dev/null and b/backend/data/bgm/upbeat_fun.mp3 differ diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..b1e1520 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +python-multipart>=0.0.6 +yt-dlp>=2024.10.7 +openai-whisper>=20231117 +openai>=1.12.0 +pydantic>=2.5.3 +pydantic-settings>=2.1.0 +aiofiles>=23.2.1 +python-jose[cryptography]>=3.3.0 +celery>=5.3.6 +redis>=5.0.1 +httpx>=0.26.0 +curl_cffi>=0.13.0,<0.14.0 # For yt-dlp browser impersonation (yt-dlp only supports <0.14) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..27966ee --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: shorts-maker-backend + restart: unless-stopped + environment: + - OPENAI_API_KEY=${OPENAI_API_KEY} + - WHISPER_MODEL=${WHISPER_MODEL:-medium} + - REDIS_URL=redis://redis:6379/0 + volumes: + - ./data/downloads:/app/data/downloads + - ./data/processed:/app/data/processed + - ./data/bgm:/app/data/bgm + - ./data/jobs.json:/app/data/jobs.json + depends_on: + - redis + networks: + - shorts-network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: shorts-maker-frontend + restart: unless-stopped + ports: + - "${PORT:-3000}:80" + depends_on: + - backend + networks: + - shorts-network + + redis: + image: redis:7-alpine + container_name: shorts-maker-redis + restart: unless-stopped + volumes: + - redis-data:/data + networks: + - shorts-network + +volumes: + redis-data: + +networks: + shorts-network: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..1c57af6 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,29 @@ +# Build stage +FROM node:20-alpine as build + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build the app +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built files +COPY --from=build /app/dist /usr/share/nginx/html + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..974cb61 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + Shorts Maker - ์‡ผ์ธ  ํ•œ๊ธ€ ์ž๋ง‰ ๋ณ€ํ™˜๊ธฐ + + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..d85b957 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,43 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; + + # API proxy + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } + + # Static files proxy + location /static/ { + proxy_pass http://backend:8000/static/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + # SPA routing + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..fe8bf0b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2912 @@ +{ + "name": "shorts-maker-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shorts-maker-frontend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.5", + "lucide-react": "^0.312.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.2" + }, + "devDependencies": { + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "vite": "^5.0.12" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", + "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001762", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", + "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.312.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.312.0.tgz", + "integrity": "sha512-3UZsqyswRXjW4t+nw+InICewSimjPKHuSxiFYqTshv9xkK3tPPntXk/lvXc9pKlXIxm3v9WKyoxcrB6YHhP+dg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", + "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", + "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1", + "react-router": "6.30.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e152c7b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "shorts-maker-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "axios": "^1.6.5", + "react-router-dom": "^6.21.2", + "lucide-react": "^0.312.0" + }, + "devDependencies": { + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "vite": "^5.0.12" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..8124ed4 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,74 @@ +import React, { useState, useEffect } from 'react'; +import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'; +import { Video, List, Music, Settings } from 'lucide-react'; +import HomePage from './pages/HomePage'; +import JobsPage from './pages/JobsPage'; +import BGMPage from './pages/BGMPage'; + +function NavLink({ to, icon: Icon, children }) { + const location = useLocation(); + const isActive = location.pathname === to; + + return ( + + + {children} + + ); +} + +function Layout({ children }) { + return ( +
+ {/* Header */} +
+
+ +
+
+ + {/* Main Content */} +
+
+ {children} +
+
+ + {/* Footer */} +
+ Shorts Maker - ์ค‘๊ตญ ์‡ผ์ธ  ์˜์ƒ ํ•œ๊ธ€ ์ž๋ง‰ ๋ณ€ํ™˜๊ธฐ +
+
+ ); +} + +function App() { + return ( + + + + } /> + } /> + } /> + + + + ); +} + +export default App; diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..5f38c56 --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,159 @@ +import axios from 'axios'; + +const API_BASE = '/api'; + +const client = axios.create({ + baseURL: API_BASE, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Download API +export const downloadApi = { + start: (url) => client.post('/download/', { url }), + getPlatforms: () => client.get('/download/platforms'), +}; + +// Process API +export const processApi = { + // Legacy: Full auto processing (not used in manual workflow) + start: (jobId, options = {}) => + client.post('/process/', { + job_id: jobId, + bgm_id: options.bgmId || null, + bgm_volume: options.bgmVolume || 0.3, + subtitle_style: options.subtitleStyle || null, + keep_original_audio: options.keepOriginalAudio || false, + translation_mode: options.translationMode || null, + use_vocal_separation: options.useVocalSeparation || false, + }), + + // === Step-by-step Processing API === + + // Skip trimming and proceed to transcription + skipTrim: (jobId) => client.post(`/process/${jobId}/skip-trim`), + + // Start transcription step (audio extraction + STT + translation) + startTranscription: (jobId, options = {}) => + client.post(`/process/${jobId}/start-transcription`, { + translation_mode: options.translationMode || 'rewrite', + use_vocal_separation: options.useVocalSeparation || false, + }), + + // Render final video with subtitles and BGM + render: (jobId, options = {}) => + client.post(`/process/${jobId}/render`, { + bgm_id: options.bgmId || null, + bgm_volume: options.bgmVolume || 0.3, + subtitle_style: options.subtitleStyle || null, + keep_original_audio: options.keepOriginalAudio || false, + // Intro text overlay (YouTube Shorts thumbnail) + intro_text: options.introText || null, + intro_duration: options.introDuration || 0.7, + intro_font_size: options.introFontSize || 100, + }), + + // === Other APIs === + + // Re-run GPT translation + retranslate: (jobId) => client.post(`/process/${jobId}/retranslate`), + + transcribe: (jobId) => client.post(`/process/${jobId}/transcribe`), + updateTranscript: (jobId, segments) => + client.put(`/process/${jobId}/transcript`, segments), + // Continue processing for jobs with no audio + continue: (jobId, options = {}) => + client.post(`/process/${jobId}/continue`, { + job_id: jobId, + bgm_id: options.bgmId || null, + bgm_volume: options.bgmVolume || 0.3, + subtitle_style: options.subtitleStyle || null, + keep_original_audio: options.keepOriginalAudio || false, + }), + // Add manual subtitles + addManualSubtitle: (jobId, segments) => + client.post(`/process/${jobId}/manual-subtitle`, segments), + // Get video info for trimming + getVideoInfo: (jobId) => client.get(`/process/${jobId}/video-info`), + // Get frame at specific timestamp (for precise trimming preview) + getFrameUrl: (jobId, timestamp) => `${API_BASE}/process/${jobId}/frame?timestamp=${timestamp}`, + // Trim video (reprocess default: false for manual workflow) + trim: (jobId, startTime, endTime, reprocess = false) => + client.post(`/process/${jobId}/trim`, { + start_time: startTime, + end_time: endTime, + reprocess, + }), +}; + +// Jobs API +export const jobsApi = { + list: (limit = 50) => client.get('/jobs/', { params: { limit } }), + get: (jobId) => client.get(`/jobs/${jobId}`), + delete: (jobId) => client.delete(`/jobs/${jobId}`), + downloadOutput: (jobId) => `${API_BASE}/jobs/${jobId}/download`, + downloadOriginal: (jobId) => `${API_BASE}/jobs/${jobId}/original`, + downloadSubtitle: (jobId, format = 'ass') => + `${API_BASE}/jobs/${jobId}/subtitle?format=${format}`, + downloadThumbnail: (jobId) => `${API_BASE}/jobs/${jobId}/thumbnail`, + // Re-edit completed job (reset to awaiting_review) + reEdit: (jobId) => client.post(`/jobs/${jobId}/re-edit`), +}; + +// Thumbnail API +export const thumbnailApi = { + // Get suggested timestamps for frame selection + getTimestamps: (jobId, count = 5) => + client.get(`/process/${jobId}/thumbnail-timestamps`, { params: { count } }), + // Generate catchphrase using GPT + generateCatchphrase: (jobId, style = 'homeshopping') => + client.post(`/process/${jobId}/generate-catchphrase`, null, { params: { style } }), + // Generate thumbnail with text overlay + generate: (jobId, options = {}) => + client.post(`/process/${jobId}/thumbnail`, null, { + params: { + timestamp: options.timestamp || 2.0, + style: options.style || 'homeshopping', + custom_text: options.customText || null, + font_size: options.fontSize || 80, + position: options.position || 'center', + }, + }), +}; + +// Fonts API +export const fontsApi = { + list: () => client.get('/fonts/'), + recommend: (contentType) => client.get(`/fonts/recommend/${contentType}`), + categories: () => client.get('/fonts/categories'), +}; + +// BGM API +export const bgmApi = { + list: () => client.get('/bgm/'), + get: (bgmId) => client.get(`/bgm/${bgmId}`), + upload: (file, name) => { + const formData = new FormData(); + formData.append('file', file); + if (name) formData.append('name', name); + return client.post('/bgm/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }, + delete: (bgmId) => client.delete(`/bgm/${bgmId}`), + // Auto-download BGM from Freesound + autoDownload: (keywords, maxDuration = 60) => + client.post('/bgm/auto-download', { + keywords, + max_duration: maxDuration, + commercial_only: true, + }), + // Download default BGM tracks (force re-download if needed) + initializeDefaults: (force = false) => + client.post(`/bgm/defaults/initialize?force=${force}`), + // Get default BGM list with status + getDefaultStatus: () => client.get('/bgm/defaults/status'), +}; + +export default client; diff --git a/frontend/src/components/ManualSubtitleInput.jsx b/frontend/src/components/ManualSubtitleInput.jsx new file mode 100644 index 0000000..0144bea --- /dev/null +++ b/frontend/src/components/ManualSubtitleInput.jsx @@ -0,0 +1,219 @@ +import React, { useState, useEffect } from 'react'; +import { Plus, Trash2, Save, Clock, PenLine, Volume2, XCircle } from 'lucide-react'; +import { processApi } from '../api/client'; + +export default function ManualSubtitleInput({ job, onProcessWithSubtitle, onProcessWithoutSubtitle, onCancel }) { + const [segments, setSegments] = useState([]); + const [isSaving, setIsSaving] = useState(false); + const [videoDuration, setVideoDuration] = useState(60); // Default 60 seconds + + // Initialize with one empty segment + useEffect(() => { + if (segments.length === 0) { + addSegment(); + } + }, []); + + const addSegment = () => { + const lastEnd = segments.length > 0 ? segments[segments.length - 1].end : 0; + setSegments([ + ...segments, + { + id: Date.now(), + start: lastEnd, + end: Math.min(lastEnd + 3, videoDuration), + text: '', + translated: '', + }, + ]); + }; + + const removeSegment = (id) => { + setSegments(segments.filter((seg) => seg.id !== id)); + }; + + const updateSegment = (id, field, value) => { + setSegments( + segments.map((seg) => + seg.id === id ? { ...seg, [field]: value } : seg + ) + ); + }; + + const formatTimeInput = (seconds) => { + const mins = Math.floor(seconds / 60); + const secs = (seconds % 60).toFixed(1); + return `${mins}:${secs.padStart(4, '0')}`; + }; + + const parseTimeInput = (timeStr) => { + const parts = timeStr.split(':'); + if (parts.length === 2) { + const mins = parseInt(parts[0]) || 0; + const secs = parseFloat(parts[1]) || 0; + return mins * 60 + secs; + } + return parseFloat(timeStr) || 0; + }; + + const handleSaveSubtitles = async () => { + // Validate segments + const validSegments = segments.filter( + (seg) => seg.text.trim() || seg.translated.trim() + ); + + if (validSegments.length === 0) { + alert('์ตœ์†Œ ํ•œ ๊ฐœ์˜ ์ž๋ง‰์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'); + return; + } + + setIsSaving(true); + try { + // Format segments for API + const formattedSegments = validSegments.map((seg) => ({ + start: seg.start, + end: seg.end, + text: seg.text.trim() || seg.translated.trim(), + translated: seg.translated.trim() || seg.text.trim(), + })); + + await processApi.addManualSubtitle(job.job_id, formattedSegments); + onProcessWithSubtitle?.(formattedSegments); + } catch (err) { + console.error('Failed to save subtitles:', err); + alert('์ž๋ง‰ ์ €์žฅ ์‹คํŒจ: ' + (err.response?.data?.detail || err.message)); + } finally { + setIsSaving(false); + } + }; + + const handleProcessWithoutSubtitle = () => { + onProcessWithoutSubtitle?.(); + }; + + return ( +
+
+
+ +

์ž๋ง‰ ์ง์ ‘ ์ž…๋ ฅ

+
+ + {segments.length}๊ฐœ ์„ธ๊ทธ๋จผํŠธ + +
+ +

+ ์˜์ƒ์— ํ‘œ์‹œํ•  ์ž๋ง‰์„ ์ง์ ‘ ์ž…๋ ฅํ•˜์„ธ์š”. ์‹œ์ž‘/์ข…๋ฃŒ ์‹œ๊ฐ„๊ณผ ํ…์ŠคํŠธ๋ฅผ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. +

+ + {/* Segments List */} +
+ {segments.map((segment, index) => ( +
+
+ #{index + 1} + +
+ + {/* Time Inputs */} +
+
+ + + updateSegment(segment.id, 'start', parseFloat(e.target.value) || 0) + } + className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-amber-500" + /> +
+
+ + + updateSegment(segment.id, 'end', parseFloat(e.target.value) || 0) + } + className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-amber-500" + /> +
+
+ + {/* Text Input */} +
+ +