diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..858edd170ea14cf188a9b88b342058c371f5ea6f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +.venv + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment +.env +.env.local +.env.*.local + +# Storage +storage/images/* +storage/videos/* +!storage/images/.gitkeep +!storage/videos/.gitkeep + +# Git +.git/ +.gitignore + +# Documentation +*.md +!README.md + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# Output +output_videos/ + +# OS +.DS_Store +Thumbs.db + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..412bc19e56f6a9a1e33f6cfd08fcb7d615a00fd1 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Environment Configuration +# Copy this file to .env.local for local development or set these in your deployment platform + +# Server Configuration +SERVER_PORT=4000 +PUBLIC_URL=http://localhost:4000 +ENVIRONMENT=production + +# API Keys (Required) +# Get your KIE API key at: https://kie.ai/api-key +KIE_API_KEY=your_kie_api_key_here + +# Optional API Keys +# Get your Gemini API key at: https://makersuite.google.com/app/apikey +VITE_GEMINI_API_KEY=your_gemini_api_key_here + +# Get your OpenAI API key at: https://platform.openai.com/api-keys +OPENAI_API_KEY=your_openai_api_key_here + +# Get your Replicate API token at: https://replicate.com/account/api-tokens +REPLICATE_API_TOKEN=your_replicate_token_here + +# Frontend Configuration (for build) +# Set this to your backend URL when building frontend +# Example: VITE_API_BASE_URL=https://api.yourdomain.com +VITE_API_BASE_URL=http://localhost:4000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a713d2a8b7d8b0c80e95946d7149931205b14cbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +env/ +ENV/ +.venv + +# Environment Variables +.env +.env.local +.env.*.local + +# Storage +storage/images/* +storage/videos/* +!storage/images/.gitkeep +!storage/videos/.gitkeep + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Logs +*.log +logs/ + +# Embedded repositories +Video_AdGenesis_App/ diff --git a/AUTH_SETUP.md b/AUTH_SETUP.md new file mode 100644 index 0000000000000000000000000000000000000000..1ec46d3cecbdb3eea0ae923bd920658327852802 --- /dev/null +++ b/AUTH_SETUP.md @@ -0,0 +1,194 @@ +# Authentication Setup Guide + +Your Video Genesis Studio now has authentication enabled to restrict access to authorized users only. + +## How It Works + +1. **Login Required**: Users must log in before accessing the app +2. **JWT Tokens**: Authentication uses JWT (JSON Web Tokens) for secure, stateless authentication +3. **Protected Routes**: All API endpoints require a valid token (except `/health` and `/api/auth/*`) + +## Setting Up Users + +### Option 1: Environment Variable (Recommended for Production) + +Set the `ALLOWED_USERS` environment variable with your user credentials: + +```bash +# Format: "username1:password1,username2:password2" +ALLOWED_USERS="admin:your-secure-password,user1:password123,user2:password456" +``` + +**For Hugging Face Spaces:** +1. Go to your Space settings +2. Navigate to "Variables and secrets" +3. Add a new variable: + - **Key**: `ALLOWED_USERS` + - **Value**: `admin:your-password,user1:pass1,user2:pass2` +4. Save and restart your Space + +### Option 2: Default Credentials (Development Only) + +If `ALLOWED_USERS` is not set, the app uses default credentials: +- **Username**: `admin` +- **Password**: `admin` + +⚠️ **Warning**: Never use default credentials in production! + +## Security Configuration + +### JWT Secret Key + +Set a strong secret key for JWT token signing: + +```bash +JWT_SECRET_KEY=your-very-secure-random-secret-key-here +``` + +**For Hugging Face Spaces:** +- Add `JWT_SECRET_KEY` to your Space environment variables +- Use a long, random string (at least 32 characters) + +### Generating a Secure Secret Key + +You can generate a secure key using Python: + +```python +import secrets +print(secrets.token_urlsafe(32)) +``` + +Or using OpenSSL: + +```bash +openssl rand -base64 32 +``` + +## User Management + +### Adding Users + +Add users by updating the `ALLOWED_USERS` environment variable: + +```bash +# Add a new user +ALLOWED_USERS="admin:pass1,user1:pass2,newuser:newpass" +``` + +### Removing Users + +Remove users by updating `ALLOWED_USERS`: + +```bash +# Remove user1 +ALLOWED_USERS="admin:pass1,user2:pass2" +``` + +### Changing Passwords + +Update the password in `ALLOWED_USERS`: + +```bash +# Change admin password +ALLOWED_USERS="admin:newpassword,user1:pass1" +``` + +## API Endpoints + +### Public Endpoints (No Auth Required) +- `GET /health` - Health check +- `POST /api/auth/login` - Login endpoint +- `GET /api/auth/verify` - Verify token (requires auth) + +### Protected Endpoints (Auth Required) +All other `/api/*` endpoints require a valid JWT token in the Authorization header: +``` +Authorization: Bearer +``` + +## Frontend Authentication + +The frontend automatically: +1. Shows login page if not authenticated +2. Stores JWT token in localStorage +3. Includes token in all API requests +4. Redirects to login on 401 errors +5. Provides logout functionality + +## Token Expiration + +Tokens expire after **7 days** by default. Users will need to log in again after expiration. + +To change expiration, modify `ACCESS_TOKEN_EXPIRE_HOURS` in `api/auth.py`. + +## Troubleshooting + +### Can't Log In + +1. **Check credentials**: Verify username and password are correct +2. **Check environment variable**: Ensure `ALLOWED_USERS` is set correctly +3. **Check logs**: Look for authentication errors in backend logs + +### Token Expired + +- Users will be automatically logged out +- They need to log in again + +### 401 Unauthorized Errors + +- Token may be expired or invalid +- Clear browser localStorage and log in again +- Check that token is being sent in requests + +### Multiple Users + +To add multiple users, separate them with commas: + +```bash +ALLOWED_USERS="admin:admin123,john:john456,jane:jane789" +``` + +## Security Best Practices + +1. ✅ **Use strong passwords** for all users +2. ✅ **Set JWT_SECRET_KEY** to a secure random value +3. ✅ **Never commit credentials** to version control +4. ✅ **Use environment variables** for all secrets +5. ✅ **Rotate secrets** periodically +6. ✅ **Use HTTPS** in production (Hugging Face Spaces provides this) + +## Example Configuration + +For Hugging Face Spaces, set these environment variables: + +``` +ALLOWED_USERS=admin:SecurePass123!,user1:AnotherPass456! +JWT_SECRET_KEY=your-super-secret-key-minimum-32-characters-long +``` + +## Testing + +1. **Test Login**: + ```bash + curl -X POST http://localhost:4000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin"}' + ``` + +2. **Test Protected Endpoint**: + ```bash + curl -X GET http://localhost:4000/api/auth/me \ + -H "Authorization: Bearer " + ``` + +## Next Steps + +1. Set `ALLOWED_USERS` with your actual credentials +2. Set `JWT_SECRET_KEY` to a secure random value +3. Deploy and test login functionality +4. Share credentials only with authorized users + +--- + +**Your app is now secured! 🔒** + diff --git a/Dockerfile.hf b/Dockerfile.hf new file mode 100644 index 0000000000000000000000000000000000000000..5e8e0f144b202c051cc8edb2b83c3f526fba2636 --- /dev/null +++ b/Dockerfile.hf @@ -0,0 +1,64 @@ +# Hugging Face Spaces Dockerfile +# Multi-stage build for Hugging Face deployment + +# Stage 1: Build frontend +FROM node:20-alpine AS frontend-builder + +WORKDIR /app/frontend + +# Copy package files +COPY frontend/package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy frontend source +COPY frontend/ ./ + +# Build arguments for environment variables +# Hugging Face Spaces will provide the public URL +ARG VITE_API_BASE_URL +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} + +# Build frontend +RUN npm run build + +# Stage 2: Python backend with frontend +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies including ffmpeg +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Copy built frontend from builder stage +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist + +# Create storage directories +RUN mkdir -p storage/images storage/videos + +# Hugging Face Spaces uses port 7860 by default +# But we'll use PORT env var if provided, or default to 7860 +EXPOSE 7860 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')" + +# Run the application +# Hugging Face Spaces provides PORT env var, default to 7860 +CMD python -c "import os; port = int(os.getenv('PORT', 7860)); import uvicorn; uvicorn.run('main:app', host='0.0.0.0', port=port)" + diff --git a/HUGGINGFACE_DEPLOY.md b/HUGGINGFACE_DEPLOY.md new file mode 100644 index 0000000000000000000000000000000000000000..f3ed62dbf421b323748fd76c16fff7a59c67961d --- /dev/null +++ b/HUGGINGFACE_DEPLOY.md @@ -0,0 +1,294 @@ +# Deploying to Hugging Face Spaces + +This guide will help you deploy Video Genesis Studio to Hugging Face Spaces. + +## Prerequisites + +1. **Hugging Face Account** + - Sign up at https://huggingface.co/join + - Verify your email + +2. **API Keys** + - KIE API Key (required): https://kie.ai/api-key + - Optional: Gemini, OpenAI, Replicate keys + +3. **GitHub Repository** + - Your code should be in a GitHub repository + - Or you can upload directly to Hugging Face + +## Step-by-Step Deployment + +### Option 1: Deploy from GitHub (Recommended) + +1. **Prepare Your Repository**: + ```bash + # Make sure Dockerfile.hf is in the root + # Make sure README_HF.md exists (or rename it to README.md) + git add Dockerfile.hf README_HF.md + git commit -m "Add Hugging Face deployment files" + git push + ``` + +2. **Create a New Space**: + - Go to https://huggingface.co/spaces + - Click "Create new Space" + - Fill in: + - **Space name**: `video-genesis-studio` (or your choice) + - **SDK**: Select **Docker** + - **Visibility**: Public or Private + - **Hardware**: CPU Basic (free) or upgrade for better performance + - Click "Create Space" + +3. **Connect Repository**: + - In Space settings, go to "Repository" tab + - Click "Connect to GitHub" + - Select your repository + - Set **Dockerfile path**: `Dockerfile.hf` + - Save + +4. **Set Environment Variables**: + - Go to "Variables and secrets" tab + - Add the following **required** variables: + ``` + KIE_API_KEY=your_kie_api_key_here + VITE_API_BASE_URL=https://your-username-video-genesis-studio.hf.space + ENVIRONMENT=production + ALLOWED_USERS=admin:your-secure-password,user1:password123 + JWT_SECRET_KEY=your-very-secure-random-secret-key-minimum-32-chars + ``` + - **Authentication Setup** (Required): + - `ALLOWED_USERS`: Format is `username1:password1,username2:password2` + - `JWT_SECRET_KEY`: Generate a secure random key (see AUTH_SETUP.md) + - Optional variables: + ``` + VITE_GEMINI_API_KEY=your_gemini_key + OPENAI_API_KEY=your_openai_key + REPLICATE_API_TOKEN=your_replicate_token + ``` + - Click "Save" after adding each variable + + **Note**: See `AUTH_SETUP.md` for detailed authentication configuration. + +5. **Build Arguments**: + - Go to "Settings" → "Build arguments" + - Add: + ``` + VITE_API_BASE_URL=https://your-username-video-genesis-studio.hf.space + ``` + - This ensures the frontend knows the correct API URL + +6. **Deploy**: + - Hugging Face will automatically build and deploy + - Watch the build logs in the "Logs" tab + - Wait for "Your Space is live!" message + +### Option 2: Upload Directly to Hugging Face + +1. **Create Space**: + - Go to https://huggingface.co/spaces + - Click "Create new Space" + - Select **Docker** SDK + - Create the Space + +2. **Clone and Upload**: + ```bash + # Clone your Hugging Face Space + git clone https://huggingface.co/spaces/your-username/video-genesis-studio + cd video-genesis-studio + + # Copy your files + cp -r /path/to/your/project/* . + + # Make sure Dockerfile.hf is named correctly + # Rename README_HF.md to README.md + mv README_HF.md README.md + + # Commit and push + git add . + git commit -m "Initial deployment" + git push + ``` + +3. **Set Environment Variables** (same as Option 1, step 4) + +## Configuration + +### Space Settings + +1. **Hardware**: + - **CPU Basic** (Free): Good for testing, limited resources + - **CPU Upgrade** ($0.60/hour): Better performance + - **GPU** (if needed): For heavy video processing + +2. **Storage**: + - Default: 50GB + - Upgrade if you need more for video storage + +3. **Environment Variables**: + - Set in "Variables and secrets" tab + - Required: `KIE_API_KEY` + - Recommended: `VITE_API_BASE_URL` (your Space URL) + +### Build Configuration + +The `Dockerfile.hf` is configured to: +- Build React frontend with correct API URL +- Install Python dependencies +- Install FFmpeg for video processing +- Serve on port 7860 (Hugging Face default) + +### Frontend Configuration + +The frontend is built with `VITE_API_BASE_URL` pointing to your Space URL. This is set via: +- Build argument in Dockerfile +- Environment variable during build + +## Verifying Deployment + +1. **Check Build Logs**: + - Go to "Logs" tab in your Space + - Look for successful build messages + - Check for any errors + +2. **Test Health Endpoint**: + - Visit: `https://your-space.hf.space/health` + - Should return JSON with status "healthy" + +3. **Test Frontend**: + - Visit: `https://your-space.hf.space` + - Should load the React application + +4. **Test API Docs**: + - Visit: `https://your-space.hf.space/docs` + - Should show Swagger UI + +## Troubleshooting + +### Build Fails + +**Issue**: Build fails with npm errors +- **Solution**: Check that `frontend/package.json` exists and is valid +- Check build logs for specific error messages + +**Issue**: Frontend build fails +- **Solution**: Ensure `VITE_API_BASE_URL` is set correctly +- Check that all frontend dependencies are in `package.json` + +### App Not Loading + +**Issue**: Frontend shows blank page +- **Solution**: + - Check browser console for errors + - Verify `VITE_API_BASE_URL` matches your Space URL + - Check that frontend was built successfully (check logs) + +**Issue**: API calls fail +- **Solution**: + - Verify `KIE_API_KEY` is set correctly + - Check API logs in Space logs + - Ensure CORS is configured (already set to allow all) + +### Port Issues + +**Issue**: App doesn't start +- **Solution**: + - Hugging Face uses port 7860 by default + - The Dockerfile is configured to use `PORT` env var + - Check logs for port binding errors + +### Storage Issues + +**Issue**: Videos not saving +- **Solution**: + - Check storage directory permissions + - Verify storage path exists + - Check available disk space + +## Updating Your Space + +1. **Make Changes**: + ```bash + # Make your code changes + git add . + git commit -m "Update app" + git push + ``` + +2. **Hugging Face Auto-Rebuilds**: + - Spaces automatically rebuild on push + - Watch the logs for build progress + +3. **Manual Rebuild**: + - Go to Space settings + - Click "Rebuild" if needed + +## Environment Variables Reference + +| Variable | Required | Description | +|----------|----------|-------------| +| `KIE_API_KEY` | Yes | KIE API key for video generation | +| `ALLOWED_USERS` | Yes | User credentials: `username1:pass1,username2:pass2` | +| `JWT_SECRET_KEY` | Yes | Secure random key for JWT signing (min 32 chars) | +| `VITE_API_BASE_URL` | Recommended | Your Space URL (for frontend) | +| `ENVIRONMENT` | No | Set to `production` | +| `VITE_GEMINI_API_KEY` | No | Gemini API key | +| `OPENAI_API_KEY` | No | OpenAI API key | +| `REPLICATE_API_TOKEN` | No | Replicate API token | +| `PORT` | Auto | Set by Hugging Face (7860) | + +## Cost Considerations + +- **Free Tier**: CPU Basic, 50GB storage +- **Paid Tiers**: + - CPU Upgrade: $0.60/hour + - GPU options available +- **Storage**: Additional storage can be purchased + +## Security Notes + +1. **API Keys**: + - Never commit API keys to your repository + - Use Hugging Face's "Variables and secrets" feature + - Keys are encrypted and only accessible to your Space + +2. **CORS**: + - Currently set to allow all origins + - For production, consider restricting to your domain + +3. **Rate Limiting**: + - Consider implementing rate limiting for public Spaces + - Monitor API usage + +## Authentication + +Your app now requires login! See `AUTH_SETUP.md` for: +- Setting up user credentials +- Configuring JWT secret key +- Managing multiple users +- Security best practices + +**Quick Setup:** +1. Set `ALLOWED_USERS` environment variable: `admin:your-password,user1:pass1` +2. Set `JWT_SECRET_KEY` to a secure random string (32+ characters) +3. Users will see a login page before accessing the app + +## Support + +- **Hugging Face Docs**: https://huggingface.co/docs/hub/spaces +- **Space Community**: https://huggingface.co/spaces +- **Issues**: Check Space logs for errors +- **Authentication**: See `AUTH_SETUP.md` for auth configuration + +## Next Steps + +1. ✅ Deploy your Space +2. ✅ Set environment variables (including `ALLOWED_USERS` and `JWT_SECRET_KEY`) +3. ✅ Test login functionality +4. ✅ Test the deployment +5. ✅ Share credentials with authorized users only +6. ✅ Monitor usage and performance + +--- + +**Your Video Genesis Studio is now live and secured on Hugging Face! 🚀🔒** + diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..81ca92a1e242edd5386fdc708a1a02398b296665 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +--- +title: Video AdGenesis Studio +emoji: 🎬 +colorFrom: blue +colorTo: purple +sdk: docker +sdk_version: latest +app_file: Dockerfile.hf +pinned: false +license: mit +--- + +# Video Genesis Studio + +AI-powered video generation studio with React frontend and FastAPI backend. Create professional videos from text prompts and images using cutting-edge AI models. + +## Features + +✨ **AI Video Generation** +- KIE Veo 3.1 integration for high-quality video generation +- Text-to-video and image-to-video support +- Real-time progress tracking + +🖼️ **Image Processing** +- Intelligent image compression +- Multiple format support +- Automatic optimization + +🎬 **Video Processing** +- Frame extraction +- Video trimming and concatenation +- Full ffmpeg integration + +## Setup + +### Environment Variables + +Set these in your Hugging Face Space settings: + +**Required:** +- `KIE_API_KEY` - Get from https://kie.ai/api-key + +**Optional:** +- `VITE_GEMINI_API_KEY` - For Gemini AI features +- `OPENAI_API_KEY` - For GPT-4o prompt generation +- `REPLICATE_API_TOKEN` - For Replicate video generation +- `VITE_API_BASE_URL` - Your Space URL (auto-set by HF, but can override) +- `ENVIRONMENT` - Set to `production` + +### Getting Your API Keys + +1. **KIE API Key** (Required): + - Visit https://kie.ai/api-key + - Sign up or log in + - Copy your API key + +2. **Gemini API Key** (Optional): + - Visit https://makersuite.google.com/app/apikey + - Create a new API key + - Copy the key + +3. **OpenAI API Key** (Optional): + - Visit https://platform.openai.com/api-keys + - Create a new secret key + - Copy the key + +4. **Replicate Token** (Optional): + - Visit https://replicate.com/account/api-tokens + - Create a new token + - Copy the token + +## Usage + +1. **Set Environment Variables**: + - Go to your Space settings + - Add all required environment variables + - Save and restart the Space + +2. **Access the App**: + - Your Space will be available at: `https://your-username-video-genesis-studio.hf.space` + - The frontend loads automatically + - API docs available at: `/docs` + +3. **Generate Videos**: + - Enter a text prompt + - Optionally upload images + - Select video model and settings + - Click generate and wait for results + +## API Endpoints + +- `GET /health` - Health check +- `GET /docs` - API documentation (Swagger UI) +- `POST /api/veo/generate` - Start video generation +- `GET /api/veo/status/{task_id}` - Check generation status +- `POST /api/prompts/generate` - Generate prompts with AI + +## Technical Details + +- **Backend**: FastAPI (Python 3.11) +- **Frontend**: React + TypeScript + Vite +- **Video Processing**: FFmpeg +- **AI Models**: KIE Veo 3.1, Gemini, GPT-4o + +## License + +MIT License + diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4b449e799157596314cd62cbe9e15c7411d4a39c --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,2 @@ +# API package + diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..ac08de6953a5eceb3a40d0d603195c5bfa752b0c --- /dev/null +++ b/api/auth.py @@ -0,0 +1,137 @@ +""" +Authentication endpoints for user login and access control +""" +from fastapi import APIRouter, HTTPException, Depends, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel +from datetime import datetime, timedelta +from typing import Optional +import os +import hashlib +from jose import JWTError, jwt + +router = APIRouter() +security = HTTPBearer() + +# JWT Configuration +SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_HOURS = 24 * 7 # 7 days + +# User credentials (in production, use a database) +# Format: username -> hashed_password +# You can set these via environment variables or use a simple hash +ALLOWED_USERS = {} + +def load_allowed_users(): + """Load allowed users from environment variables""" + users_str = os.getenv("ALLOWED_USERS", "") + if not users_str: + # Default user for development (username: admin, password: admin) + # In production, always set ALLOWED_USERS env var + print("⚠️ Using default credentials (admin/admin). Set ALLOWED_USERS env var for production.") + return {"admin": hash_password("admin")} + + users = {} + for user_entry in users_str.split(","): + if ":" in user_entry: + username, password = user_entry.split(":", 1) + users[username.strip()] = hash_password(password.strip()) + print(f"✅ Loaded {len(users)} user(s) from ALLOWED_USERS") + return users + +def hash_password(password: str) -> str: + """Hash password using SHA256 (simple, for basic auth)""" + return hashlib.sha256(password.encode()).hexdigest() + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify password against hash""" + return hash_password(plain_password) == hashed_password + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + """Create JWT access token""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): + """Verify JWT token""" + token = credentials.credentials + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + return username + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + +# Load users on module import +ALLOWED_USERS = load_allowed_users() + +# Request/Response Models +class LoginRequest(BaseModel): + username: str + password: str + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + username: str + +class VerifyResponse(BaseModel): + authenticated: bool + username: Optional[str] = None + +@router.post("/auth/login", response_model=LoginResponse) +async def login(request: LoginRequest): + """Login endpoint - returns JWT token""" + username = request.username + password = request.password + + # Check if user exists + if username not in ALLOWED_USERS: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password" + ) + + # Verify password + if not verify_password(password, ALLOWED_USERS[username]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password" + ) + + # Create access token + access_token = create_access_token(data={"sub": username}) + + return LoginResponse( + access_token=access_token, + token_type="bearer", + username=username + ) + +@router.get("/auth/verify", response_model=VerifyResponse) +async def verify_token_endpoint(username: str = Depends(verify_token)): + """Verify if token is valid""" + return VerifyResponse(authenticated=True, username=username) + +@router.get("/auth/me", response_model=VerifyResponse) +async def get_current_user(username: str = Depends(verify_token)): + """Get current authenticated user""" + return VerifyResponse(authenticated=True, username=username) + diff --git a/api/frame_extraction.py b/api/frame_extraction.py new file mode 100644 index 0000000000000000000000000000000000000000..14e91eb93c707b1aefcf92fb488830721b63c3b1 --- /dev/null +++ b/api/frame_extraction.py @@ -0,0 +1,232 @@ +""" +Frame Extraction API endpoints +Intelligent frame selection using Whisper +""" + +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from typing import List, Optional +import tempfile +import os + +from utils.whisper_trim import ( + extract_post_speech_frames, + find_last_word_timestamp, + trim_video_to_last_word, + is_whisper_available +) + +router = APIRouter() + + +class FrameExtractionRequest(BaseModel): + video_url: str + script: str + buffer_time: Optional[float] = 0.3 + num_frames: Optional[int] = 3 + model_size: Optional[str] = "base" + + +class FrameExtractionResponse(BaseModel): + frames: List[dict] # [{timestamp, frame_data_url, label}] + last_word_time: float + total_duration: float + + +@router.post("/extract-frames", response_model=FrameExtractionResponse) +async def extract_frames_api(request: FrameExtractionRequest): + """ + Extract transition frames using Whisper to detect last spoken word + """ + if not is_whisper_available(): + raise HTTPException( + status_code=501, + detail="Whisper not installed. Install with: pip install openai-whisper moviepy" + ) + + try: + # Download video temporarily + import httpx + temp_video = tempfile.mktemp(suffix='.mp4') + + async with httpx.AsyncClient() as client: + response = await client.get(request.video_url) + if response.status_code != 200: + raise HTTPException( + status_code=400, + detail=f"Failed to download video: {response.status_code}" + ) + + with open(temp_video, 'wb') as f: + f.write(response.content) + + frames = [] + last_word_time = None + + try: + # Prefer Whisper-based post-speech detection + frames = extract_post_speech_frames( + temp_video, + request.script, + buffer_time=request.buffer_time, + num_frames=request.num_frames, + model_size=request.model_size + ) + + # Get last word timestamp + last_word_time = find_last_word_timestamp( + temp_video, + request.script, + model_size=request.model_size + ) + except Exception as whisper_err: + # Fallback: simple fixed timestamps near the end of the video + print(f"⚠️ Whisper-based frame extraction failed: {whisper_err}") + try: + from moviepy.editor import VideoFileClip + from utils.video_processor import extract_frame + + clip = VideoFileClip(temp_video) + duration = clip.duration + clip.close() + + fallback_timestamps = [ + max(0, duration - 1.5), + max(0, duration - 1.0), + max(0, duration - 0.5), + ] + labels = ["Early End", "Mid End", "Final Frame"] + + for ts, label in zip(fallback_timestamps, labels): + frame_data = extract_frame(temp_video, ts, return_base64=True) + frames.append((ts, frame_data, label)) + + last_word_time = fallback_timestamps[-1] if fallback_timestamps else None + print("✅ Returned fallback frames near video end.") + except Exception as fallback_err: + print(f"❌ Fallback frame extraction failed: {fallback_err}") + raise HTTPException( + status_code=500, + detail=f"Frame extraction failed: {str(whisper_err)}" + ) + + # Get video duration + from moviepy.editor import VideoFileClip + clip = VideoFileClip(temp_video) + duration = clip.duration + clip.close() + + # Clean up + os.remove(temp_video) + + # Format response + frames_data = [ + { + "timestamp": timestamp, + "frame_data_url": frame_data, + "label": label + } + for timestamp, frame_data, label in frames + ] + + return FrameExtractionResponse( + frames=frames_data, + last_word_time=last_word_time, + total_duration=duration + ) + + except Exception as e: + # Clean up temp file if it exists + if 'temp_video' in locals() and os.path.exists(temp_video): + os.remove(temp_video) + + raise HTTPException( + status_code=500, + detail=f"Frame extraction failed: {str(e)}" + ) + + +@router.post("/trim-video") +async def trim_video_api( + video_url: str = Form(...), + script: str = Form(...), + padding: float = Form(0.5), + model_size: str = Form("base") +): + """ + Trim video to end after last spoken word + """ + if not is_whisper_available(): + raise HTTPException( + status_code=501, + detail="Whisper not installed. Install with: pip install openai-whisper moviepy" + ) + + try: + # Download video temporarily + import httpx + temp_video = tempfile.mktemp(suffix='.mp4') + output_video = tempfile.mktemp(suffix='_trimmed.mp4') + + async with httpx.AsyncClient() as client: + response = await client.get(video_url) + if response.status_code != 200: + raise HTTPException( + status_code=400, + detail=f"Failed to download video: {response.status_code}" + ) + + with open(temp_video, 'wb') as f: + f.write(response.content) + + # Trim video + output_path = trim_video_to_last_word( + temp_video, + script, + output_video, + padding=padding, + model_size=model_size + ) + + # Read trimmed video + with open(output_path, 'rb') as f: + video_data = f.read() + + # Clean up + os.remove(temp_video) + os.remove(output_video) + + # Return trimmed video + from fastapi.responses import Response + return Response( + content=video_data, + media_type="video/mp4", + headers={ + "Content-Disposition": "attachment; filename=trimmed_video.mp4" + } + ) + + except Exception as e: + # Clean up temp files if they exist + for temp_file in ['temp_video', 'output_video']: + if temp_file in locals() and os.path.exists(locals()[temp_file]): + os.remove(locals()[temp_file]) + + raise HTTPException( + status_code=500, + detail=f"Video trimming failed: {str(e)}" + ) + + +@router.get("/whisper-status") +async def whisper_status(): + """ + Check if Whisper is available + """ + return { + "available": is_whisper_available(), + "message": "Whisper is available" if is_whisper_available() + else "Install with: pip install openai-whisper moviepy" + } + diff --git a/api/image_service.py b/api/image_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e8bad8bd41fcf71e2fdf3b3221568a1b6949af1f --- /dev/null +++ b/api/image_service.py @@ -0,0 +1,61 @@ +""" +Image Service API endpoints +Handles image compression, storage, and serving +""" + +from fastapi import APIRouter, HTTPException, Response, UploadFile, File +from fastapi.responses import JSONResponse +from utils.storage import temp_images +from utils.image_processor import compress_and_store_image +import os + +router = APIRouter() + +@router.post("/upload-image") +async def upload_image(file: UploadFile = File(...)): + """ + Upload and host an image, returns public URL + """ + try: + # Read image bytes + image_bytes = await file.read() + + # Convert to data URL for processing + import base64 + encoded = base64.b64encode(image_bytes).decode('utf-8') + data_url = f"data:{file.content_type};base64,{encoded}" + + # Get public URL from env or use default + public_url = os.getenv('PUBLIC_URL', 'http://localhost:4000') + + # Compress and store, get hosted URL + hosted_url = await compress_and_store_image(data_url, public_url) + + return JSONResponse(content={ + "url": hosted_url, + "filename": file.filename + }) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Image upload failed: {str(e)}") + + +@router.get("/images/{image_id}") +async def serve_image(image_id: str): + """ + Serve temporarily stored images + Images are compressed and cached for 1 hour + """ + if image_id not in temp_images: + raise HTTPException(status_code=404, detail="Image not found") + + image_data = temp_images[image_id] + + return Response( + content=image_data['buffer'], + media_type=image_data['content_type'], + headers={ + 'Cache-Control': 'public, max-age=3600' + } + ) + diff --git a/api/prompt_generation.py b/api/prompt_generation.py new file mode 100644 index 0000000000000000000000000000000000000000..4aaa2db0f33e824ecd5e61bff6065522e725f5ad --- /dev/null +++ b/api/prompt_generation.py @@ -0,0 +1,350 @@ +""" +GPT-4o Prompt Generation API +Structured, validated segment generation for video prompts +""" + +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from typing import Optional +import base64 + +from utils.prompt_generator import ( + VeoInputs, + generate_segments_payload, + split_script_into_segments +) +from openai import OpenAI +import os +import json + +router = APIRouter() + + +class PromptGenerationRequest(BaseModel): + """Request for prompt generation""" + script: str + style: str = "clean, lifestyle UGC" + jsonFormat: str = "standard" + continuationMode: bool = True + voiceType: Optional[str] = None + energyLevel: Optional[str] = None + settingMode: str = "single" + cameraStyle: Optional[str] = "handheld steadicam" + energyArc: Optional[str] = None + narrativeStyle: Optional[str] = "direct address" + accentRegion: Optional[str] = None + model: str = "gpt-4o" + + +@router.post("/generate-prompts") +async def generate_prompts_api( + script: str = Form(...), + style: str = Form("clean, lifestyle UGC"), + jsonFormat: str = Form("standard"), + continuationMode: str = Form("true"), + voiceType: Optional[str] = Form(None), + energyLevel: Optional[str] = Form(None), + settingMode: str = Form("single"), + cameraStyle: Optional[str] = Form("handheld steadicam"), + energyArc: Optional[str] = Form(None), + narrativeStyle: Optional[str] = Form("direct address"), + accentRegion: Optional[str] = Form(None), + model: str = Form("gpt-4o"), + image: UploadFile = File(...) +): + """ + Generate structured video prompts using GPT-4o + + This endpoint: + 1. Splits the script into 8-second segments + 2. Generates detailed production prompts using GPT-4o + 3. Validates the output against strict rules + 4. Returns structured JSON for video generation + + Accepts multipart/form-data with: + - script: The video script text + - style: Visual style description + - image: Character reference image (required) + - Other optional parameters for fine-tuning + + Returns: + Validated segments payload ready for video generation + """ + try: + # Read image + image_bytes = await image.read() + print(f"📷 Received reference image: {len(image_bytes)} bytes") + + # Convert continuationMode string to boolean + continuation_mode = continuationMode.lower() == "true" + + # Create inputs from form data + inputs = VeoInputs( + script=script, + style=style, + jsonFormat=jsonFormat, + continuationMode=continuation_mode, + voiceType=voiceType if voiceType else None, + energyLevel=energyLevel if energyLevel else None, + settingMode=settingMode, + cameraStyle=cameraStyle if cameraStyle else None, + energyArc=energyArc if energyArc else None, + narrativeStyle=narrativeStyle if narrativeStyle else None, + accentRegion=accentRegion if accentRegion else None + ) + + # Check environment mode + environment = os.getenv('ENVIRONMENT', 'dev').lower() + is_dev_mode = environment == 'dev' or environment == 'development' + + # Generate payload + payload = generate_segments_payload( + inputs=inputs, + image_bytes=image_bytes, + model=model + ) + + # Add environment mode to response + payload['environment'] = environment + payload['is_dev_mode'] = is_dev_mode + payload['max_segments'] = 2 if is_dev_mode else None + + # Validation warnings (if any) are logged to console but don't block + return JSONResponse(content=payload) + + except Exception as e: + # API/network errors only (validation is non-blocking now) + raise HTTPException( + status_code=500, + detail=f"Prompt generation failed: {str(e)}" + ) + + +@router.post("/split-script") +async def split_script_api( + script: str = Form(...), + seconds_per_segment: int = Form(8), + words_per_second: float = Form(2.2) +): + """ + Split script into segments for preview + + Useful for checking how the script will be divided before generation + """ + try: + segments = split_script_into_segments( + script, + seconds_per_segment=seconds_per_segment, + words_per_second=words_per_second + ) + + return { + "segments": segments, + "count": len(segments), + "total_words": sum(len(s.split()) for s in segments) + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Script splitting failed: {str(e)}" + ) + + +@router.post("/validate-payload") +async def validate_payload_api(payload: dict): + """ + Validate a segments payload against strict rules + + Use this to check if a manually created or modified payload is valid + """ + try: + from utils.prompt_generator import validate_segments_payload + + expected_segments = len(payload.get("segments", [])) + errors = validate_segments_payload(payload, expected_segments) + + if errors: + return { + "valid": False, + "errors": errors + } + + return { + "valid": True, + "message": "Payload is valid" + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Validation failed: {str(e)}" + ) + + +@router.get("/prompt-status") +async def prompt_status(): + """ + Check if GPT-4o prompt generation is available + """ + import os + + openai_key = os.getenv('OPENAI_API_KEY') + + return { + "available": bool(openai_key), + "message": "GPT-4o is configured" if openai_key + else "Add OPENAI_API_KEY to .env.local" + } + + +@router.post("/refine-prompt-continuity") +async def refine_prompt_for_continuity( + segmentPrompt: str = Form(...), # JSON string of the next segment + lastFrame: UploadFile = File(...), # Last frame image from previous video + transcribedDialogue: str = Form(default=""), # Whisper transcription from previous segment + expectedDialogue: str = Form(default="") # Expected dialogue from previous segment +): + """ + Refine a segment prompt to match the actual visual AND audio from the previous segment. + + This ensures perfect continuity by having GPT-4o analyze: + 1. The last frame (visual consistency) + 2. The transcribed dialogue (audio consistency - what was actually said) + """ + try: + # Read the image + image_bytes = await lastFrame.read() + encoded_image = base64.b64encode(image_bytes).decode('utf-8') + + # Parse the segment prompt + try: + segment_data = json.loads(segmentPrompt) + except json.JSONDecodeError: + raise HTTPException( + status_code=400, + detail="Invalid JSON in segmentPrompt" + ) + + # Initialize OpenAI client + client = OpenAI(api_key=os.getenv('OPENAI_API_KEY')) + + # Build audio context if available + audio_context = "" + if transcribedDialogue.strip(): + audio_context = f""" + +═══════════════════════════════════════════════════════════ +AUDIO CONTINUITY CONTEXT (WHAT WAS ACTUALLY SPOKEN) +═══════════════════════════════════════════════════════════ + +Previous segment's dialogue (from Whisper transcription): +\"{transcribedDialogue.strip()}\" + +Expected dialogue was: +\"{expectedDialogue.strip() if expectedDialogue.strip() else 'Not provided'}\" + +IMPORTANT: The next segment should continue naturally from what was ACTUALLY said. +If there are differences between expected and transcribed dialogue, use the TRANSCRIBED version +as the ground truth for continuity (it's what the viewer actually heard). +""" + + # Build the refinement prompt + refinement_instructions = f""" +You are a video continuity expert. Your task is to UPDATE the provided segment prompt to ensure PERFECT VISUAL AND AUDIO CONTINUITY with the previous video segment. + +═══════════════════════════════════════════════════════════ +VISUAL CONTINUITY (from attached image) +═══════════════════════════════════════════════════════════ + +Analyze the image carefully - this is the ACTUAL last frame from the previous video. + +1. Update the character_description to match the ACTUAL person in the image: + - Physical appearance (EXACT age, hair color/style, facial features, skin tone) + - Clothing (EXACTLY what they're wearing - color, style, pattern) + - Current state (their actual expression and posture at this moment) + - Voice matching (adjust to match their appearance) + +2. Update the scene_continuity to match the ACTUAL environment: + - Environment (describe what you see - bedroom, office, outdoor, etc.) + - Camera position (maintain the SAME angle/framing) + - Lighting state (match the EXACT lighting conditions in the image) + - Props and background elements (describe what's actually visible) + - Spatial relationships (match the actual layout) +{audio_context} +═══════════════════════════════════════════════════════════ +ORIGINAL PROMPT TO UPDATE +═══════════════════════════════════════════════════════════ + +{json.dumps(segment_data, indent=2)} + +═══════════════════════════════════════════════════════════ +CRITICAL RULES +═══════════════════════════════════════════════════════════ + +- Be EXTREMELY specific about what you see in the image +- If the image shows a young woman with red hair, describe EXACTLY that +- If it's a sunset beach scene, describe EXACTLY that setting +- If they're wearing a beige blazer, describe EXACTLY that clothing +- Match colors, styles, and details PRECISELY to what's visible +- Maintain the SAME camera angle and distance +- Keep the action_timeline.dialogue EXACTLY as provided (this is the NEXT segment's dialogue) +- Update segment_info.continuity_markers to reflect the visual state +- Adjust synchronized_actions to fit the actual character appearance + +🚨 CRITICAL: NO BLUR TRANSITIONS AT SEGMENT START 🚨 +- The video MUST start immediately at 0:00 with a SHARP, CLEAR, IN-FOCUS frame +- NO fade-in, NO blur transition, NO gradual focus effect at the start +- The first frame (0:00) must be as clear and sharp as any other frame +- camera_movement MUST describe movement that starts from a clear, sharp state + + +The goal is SEAMLESS video extension with ZERO visual or audio discontinuity. + +Return ONLY the updated JSON segment object with the same structure. No explanation, just the corrected JSON. +""" + + print(f"🔄 Refining prompt for visual continuity...") + + # Call GPT-4o with vision + response = client.chat.completions.create( + model="gpt-4o", + messages=[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": refinement_instructions + }, + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{encoded_image}" + } + } + ] + } + ], + response_format={"type": "json_object"}, + temperature=0.3, # Lower temperature for precise matching + ) + + # Parse the response + refined_prompt = json.loads(response.choices[0].message.content) + + print(f"✅ Prompt refined for visual continuity") + + return JSONResponse(content={ + "refined_prompt": refined_prompt, + "original_prompt": segment_data + }) + + except Exception as e: + print(f"❌ Prompt refinement error: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Prompt refinement failed: {str(e)}" + ) + diff --git a/api/replicate_service.py b/api/replicate_service.py new file mode 100644 index 0000000000000000000000000000000000000000..9fcbcf877b26a5aa1aed669e8791115f769e1256 --- /dev/null +++ b/api/replicate_service.py @@ -0,0 +1,289 @@ +""" +Replicate API endpoints +Handles video generation via Replicate's Python SDK + +Based on standalone_video_creator.py flow: +- Uses replicate.run() for synchronous generation +- Sends prompt as stringified JSON (like the standalone script) +- Supports image input for frame continuity +""" + +from fastapi import APIRouter, HTTPException, BackgroundTasks +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from typing import Optional, Dict, Any +import os +import asyncio +import uuid +import json +from concurrent.futures import ThreadPoolExecutor + +router = APIRouter() + +# Try importing replicate +try: + import replicate + REPLICATE_AVAILABLE = True +except ImportError: + REPLICATE_AVAILABLE = False + print("⚠️ Replicate package not installed. Run: pip install replicate") + +# Thread pool for running blocking replicate.run() calls +executor = ThreadPoolExecutor(max_workers=4) + +# In-memory store for prediction status (in production, use Redis) +predictions: Dict[str, Dict[str, Any]] = {} + + +# Request/Response Models +class ReplicateGenerateRequest(BaseModel): + prompt: str + imageUrl: Optional[str] = None + model: Optional[str] = "google/veo-3" + aspectRatio: Optional[str] = "9:16" + seed: Optional[int] = None + + +class ReplicateGenerateResponse(BaseModel): + id: str + status: str + + +class ReplicateStatusResponse(BaseModel): + status: str + output: Optional[str] = None + url: Optional[str] = None + error: Optional[str] = None + + +def get_replicate_api_key(): + """Get Replicate API key from environment""" + api_key = os.getenv('REPLICATE_API_TOKEN') + if not api_key: + raise HTTPException( + status_code=500, + detail="REPLICATE_API_TOKEN not configured. Add REPLICATE_API_TOKEN to .env.local" + ) + return api_key + + +def run_replicate_sync( + prediction_id: str, + model: str, + input_data: Dict[str, Any] +): + """ + Run replicate.run() synchronously in a thread. + Updates the predictions dict with status. + + This mirrors the standalone_video_creator.py approach. + """ + try: + # Set API token + api_key = os.getenv('REPLICATE_API_TOKEN') + os.environ['REPLICATE_API_TOKEN'] = api_key + + print(f"🎬 Running replicate.run('{model}')...") + print(f"📦 Input keys: {list(input_data.keys())}") + + # Run the model (blocking call) + output = replicate.run(model, input=input_data) + + # Handle different output types (same as standalone_video_creator.py) + video_url = None + if isinstance(output, str): + video_url = output + elif hasattr(output, 'url'): + # url is a property, not a method + video_url = output.url + elif hasattr(output, '__iter__'): + # Could be a generator or list + for item in output: + if isinstance(item, str): + video_url = item + break + else: + video_url = str(output) + + print(f"✅ Replicate completed: {video_url[:80] if video_url else 'no url'}...") + + predictions[prediction_id] = { + "status": "succeeded", + "url": video_url, + "output": video_url, + "error": None + } + + except Exception as e: + error_msg = str(e) + print(f"❌ Replicate error: {error_msg}") + + predictions[prediction_id] = { + "status": "failed", + "url": None, + "output": None, + "error": error_msg + } + + +@router.post("/replicate/generate", response_model=ReplicateGenerateResponse) +async def generate_video(request: ReplicateGenerateRequest, background_tasks: BackgroundTasks): + """ + Generate video using Replicate Python SDK. + + Mirrors standalone_video_creator.py: + - Uses replicate.run() + - Sends prompt as-is (frontend should send text prompt) + - Supports image URL for frame continuity + """ + if not REPLICATE_AVAILABLE: + raise HTTPException( + status_code=500, + detail="Replicate package not installed. Run: pip install replicate" + ) + + try: + # Verify API key is set + get_replicate_api_key() + + model_id = request.model or "google/veo-3" + + # Build input params (matching standalone_video_creator.py) + input_data: Dict[str, Any] = { + "prompt": request.prompt, + } + + # Add aspect ratio + if request.aspectRatio: + input_data["aspect_ratio"] = request.aspectRatio + + # Add seed if provided + if request.seed is not None: + input_data["seed"] = request.seed + + # Add image URL if provided + if request.imageUrl: + input_data["image"] = request.imageUrl + + print(f"🎬 Starting Replicate generation with model: {model_id}") + print(f"📝 Prompt: {request.prompt[:100]}...") + if request.imageUrl: + print(f"🖼️ Using reference image: {request.imageUrl[:50]}...") + print(f"⚙️ Input params: {list(input_data.keys())}") + + # Create prediction ID + prediction_id = f"rep_{uuid.uuid4().hex[:12]}" + + # Initialize prediction status + predictions[prediction_id] = { + "status": "processing", + "url": None, + "output": None, + "error": None + } + + # Run in background thread (replicate.run() is blocking) + loop = asyncio.get_event_loop() + loop.run_in_executor( + executor, + run_replicate_sync, + prediction_id, + model_id, + input_data + ) + + return ReplicateGenerateResponse( + id=prediction_id, + status="processing" + ) + + except HTTPException: + raise + except Exception as e: + print(f"❌ Replicate generation error: {str(e)}") + import traceback + traceback.print_exc() + raise HTTPException( + status_code=500, + detail=f"Replicate generation failed: {str(e)}" + ) + + +@router.get("/replicate/status/{prediction_id}", response_model=ReplicateStatusResponse) +async def get_prediction_status(prediction_id: str): + """ + Get the status of a Replicate prediction. + """ + if prediction_id not in predictions: + raise HTTPException( + status_code=404, + detail=f"Prediction not found: {prediction_id}" + ) + + pred = predictions[prediction_id] + + return ReplicateStatusResponse( + status=pred["status"], + output=pred.get("output"), + url=pred.get("url"), + error=pred.get("error") + ) + + +@router.get("/replicate/models") +async def list_available_models(): + """List available video generation models""" + return { + "models": [ + { + "id": "google/veo-3", + "name": "Google Veo 3 (Recommended)", + "description": "High-quality text/image-to-video generation", + "type": "text-to-video", + "supports_image": True + }, + { + "id": "minimax/video-01", + "name": "MiniMax Video-01", + "description": "High-quality text-to-video generation", + "type": "text-to-video", + "supports_image": True + }, + { + "id": "luma/ray", + "name": "Luma Ray", + "description": "Cinematic video generation", + "type": "text-to-video", + "supports_image": True + } + ] + } + + +@router.post("/replicate/cancel/{prediction_id}") +async def cancel_prediction(prediction_id: str): + """Cancel a running prediction (marks as cancelled in our store)""" + if prediction_id in predictions: + predictions[prediction_id]["status"] = "failed" + predictions[prediction_id]["error"] = "Cancelled by user" + + return JSONResponse( + status_code=200, + content={"message": "Prediction cancelled", "id": prediction_id} + ) + + +@router.get("/replicate/health") +async def check_replicate_health(): + """Check if Replicate is configured""" + api_key = os.getenv('REPLICATE_API_TOKEN') + return { + "configured": bool(api_key), + "package_installed": REPLICATE_AVAILABLE, + "message": "Replicate is ready" if (api_key and REPLICATE_AVAILABLE) + else "Missing: " + ( + "REPLICATE_API_TOKEN" if not api_key else "" + ) + ( + " replicate package" if not REPLICATE_AVAILABLE else "" + ) + } diff --git a/api/video_export.py b/api/video_export.py new file mode 100644 index 0000000000000000000000000000000000000000..6af83ead1307ba72681fc2bcc976816452a946c1 --- /dev/null +++ b/api/video_export.py @@ -0,0 +1,250 @@ +""" +Video Export API +Handles merging multiple video clips into a single output video +""" + +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from fastapi.responses import FileResponse, StreamingResponse +from typing import List, Optional, Tuple +import os +import tempfile +import subprocess +import json +from pathlib import Path + +router = APIRouter() + + +def get_video_dimensions(video_path: Path) -> Tuple[int, int]: + """Get video width and height using ffprobe""" + try: + cmd = [ + 'ffprobe', + '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'stream=width,height', + '-of', 'json', + str(video_path) + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + if result.returncode == 0: + data = json.loads(result.stdout) + streams = data.get('streams', []) + if streams: + width = streams[0].get('width', 1080) + height = streams[0].get('height', 1920) + return (width, height) + except Exception as e: + print(f"⚠️ Could not detect video dimensions: {e}") + + # Default to 9:16 portrait if detection fails + return (1080, 1920) + + +@router.post("/export/merge") +async def merge_videos( + clips_data: str = Form(...), # JSON string with clip metadata + files: List[UploadFile] = File(...) +): + """ + Merge multiple video clips into a single output video + + clips_data: JSON string containing array of clip objects with: + - index: order in timeline + - startTime: start time in clip (seconds) + - endTime: end time in clip (seconds) + - type: 'video' or 'image' + - duration: duration for images (seconds) + + files: Video/image files in the same order as clips_data + """ + try: + # Parse clips data + clips = json.loads(clips_data) + + if len(clips) != len(files): + raise HTTPException( + status_code=400, + detail=f"Mismatch: {len(clips)} clips but {len(files)} files" + ) + + if len(clips) == 0: + raise HTTPException(status_code=400, detail="No clips to merge") + + # Create temporary directory for processing + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Save all uploaded files + file_paths = [] + for i, file in enumerate(files): + clip = clips[i] + file_path = temp_path / f"input_{i}.{file.filename.split('.')[-1] if '.' in file.filename else 'mp4'}" + + with open(file_path, 'wb') as f: + content = await file.read() + f.write(content) + + file_paths.append(file_path) + + # Detect dimensions from first video to preserve aspect ratio + target_width, target_height = get_video_dimensions(file_paths[0]) + print(f"📐 Detected video dimensions: {target_width}x{target_height}") + + # Build FFmpeg command + output_path = temp_path / "output.mp4" + + # Helper function to check if video has audio stream + def has_audio_stream(video_path: Path) -> bool: + """Check if video file has an audio stream""" + try: + cmd = [ + 'ffprobe', + '-v', 'error', + '-select_streams', 'a', + '-show_entries', 'stream=codec_type', + '-of', 'json', + str(video_path) + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + if result.returncode == 0: + import json as json_lib + data = json_lib.loads(result.stdout) + streams = data.get('streams', []) + return len(streams) > 0 + return False + except Exception: + return False + + # Build filter complex - process clips in order + filter_parts = [] + input_args = [] + concat_inputs = [] + + # Process all clips in order + input_index = 0 + for clip_idx, clip in enumerate(clips): + file_path = file_paths[clip_idx] + + if clip['type'] == 'video': + clip_duration = clip['endTime'] - clip['startTime'] + input_args.extend(['-i', str(file_path)]) + + # Check if video has audio + has_audio = has_audio_stream(file_path) + + # Trim video and scale to match first video's dimensions + # Using scale with force_original_aspect_ratio to handle any size differences + filter_parts.append( + f"[{input_index}:v]trim=start={clip['startTime']}:end={clip['endTime']}," + f"setpts=PTS-STARTPTS," + f"scale={target_width}:{target_height}:force_original_aspect_ratio=decrease," + f"pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2," + f"setsar=1[v{clip_idx}];" + ) + + if has_audio: + # Use existing audio stream + filter_parts.append( + f"[{input_index}:a]atrim=start={clip['startTime']}:end={clip['endTime']}," + f"asetpts=PTS-STARTPTS[a{clip_idx}];" + ) + else: + # Generate silent audio for videos without audio + filter_parts.append( + f"anullsrc=channel_layout=stereo:sample_rate=44100,atrim=0:{clip_duration}," + f"asetpts=PTS-STARTPTS[a{clip_idx}];" + ) + + input_index += 1 + else: + # Image clip + clip_duration = clip.get('duration', 3.0) # Default 3 seconds for images + input_args.extend(['-loop', '1', '-t', str(clip_duration), '-i', str(file_path)]) + + # Scale image to match video dimensions + filter_parts.append( + f"[{input_index}:v]scale={target_width}:{target_height}:force_original_aspect_ratio=decrease," + f"pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2," + f"setsar=1,format=yuv420p[v{clip_idx}];" + ) + # Generate silent audio + filter_parts.append( + f"anullsrc=channel_layout=stereo:sample_rate=44100,atrim=0:{clip_duration}," + f"asetpts=PTS-STARTPTS[a{clip_idx}];" + ) + + input_index += 1 + + # Add to concat inputs in order + concat_inputs.append(f"[v{clip_idx}][a{clip_idx}]") + + # Build complete filter complex + filter_complex = ''.join(filter_parts) + filter_complex += f"{''.join(concat_inputs)}concat=n={len(clips)}:v=1:a=1[outv][outa]" + + # Build FFmpeg command + ffmpeg_cmd = [ + 'ffmpeg', + *input_args, + '-filter_complex', filter_complex, + '-map', '[outv]', + '-map', '[outa]', + '-c:v', 'libx264', + '-c:a', 'aac', + '-movflags', '+faststart', + '-y', # Overwrite output + str(output_path) + ] + + print(f"🎬 Running FFmpeg merge with dimensions: {target_width}x{target_height}") + + # Run FFmpeg + result = subprocess.run( + ffmpeg_cmd, + capture_output=True, + text=True, + timeout=300 # 5 minute timeout + ) + + if result.returncode != 0: + print(f"❌ FFmpeg error: {result.stderr}") + raise HTTPException( + status_code=500, + detail=f"FFmpeg failed: {result.stderr[:500]}" + ) + + if not output_path.exists(): + raise HTTPException(status_code=500, detail="Output file was not created") + + # Read the entire file into memory before temp directory is deleted + print(f"📦 Reading merged video file ({output_path.stat().st_size / 1024 / 1024:.2f} MB)...") + with open(output_path, 'rb') as f: + video_content = f.read() + + print(f"✅ Video merged successfully: {target_width}x{target_height}") + + # Return the merged video file + def generate(): + # Yield in chunks to avoid loading entire file in memory at once + chunk_size = 8192 + for i in range(0, len(video_content), chunk_size): + yield video_content[i:i + chunk_size] + + return StreamingResponse( + generate(), + media_type="video/mp4", + headers={ + "Content-Disposition": "attachment; filename=exported-video.mp4", + "Content-Type": "video/mp4", + "Content-Length": str(len(video_content)) + } + ) + + except json.JSONDecodeError as e: + raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}") + except subprocess.TimeoutExpired: + raise HTTPException(status_code=504, detail="Video processing timed out") + except Exception as e: + print(f"❌ Export error: {str(e)}") + raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}") diff --git a/api/video_generation.py b/api/video_generation.py new file mode 100644 index 0000000000000000000000000000000000000000..5287f88e6f3570517591ff3a359486a591004d1f --- /dev/null +++ b/api/video_generation.py @@ -0,0 +1,614 @@ +""" +Video Generation API endpoints +Handles KIE API integration with SSE support for real-time updates +""" + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import StreamingResponse, JSONResponse, Response +from pydantic import BaseModel +from typing import List, Optional, Dict, Any +import httpx +import asyncio +import json +import os +from datetime import datetime + +from utils.image_processor import compress_and_store_image +from utils.storage import video_results, sse_clients, cleanup_old_results + +router = APIRouter() + +KIE_API_BASE = "https://api.kie.ai" + +# Request/Response Models +class VideoGenerationRequest(BaseModel): + prompt: Any # Can be string (legacy) or dict/object (structured JSON) + imageUrls: Optional[List[str]] = [] + model: Optional[str] = "veo3_fast" + aspectRatio: Optional[str] = "9:16" + generationType: Optional[str] = None + seeds: Optional[int] = None # Seed for consistent lighting/style (e.g., 12005) + voiceType: Optional[str] = None # Voice type for audio generation (e.g., "Deep", "Warm", "Crisp", "None") + +class VideoExtendRequest(BaseModel): + taskId: str + prompt: Any # Can be string or structured JSON + seeds: Optional[int] = None + watermark: Optional[str] = None + voiceType: Optional[str] = None # Voice type for audio generation + +class VideoGenerationResponse(BaseModel): + taskId: str + status: str + +class CallbackData(BaseModel): + code: int + msg: str + data: Optional[Dict[str, Any]] = None + +# Helper functions +def get_kie_api_key(): + """Get KIE API key from environment""" + api_key = os.getenv('KIE_API_KEY') + if not api_key: + raise HTTPException( + status_code=500, + detail="KIE_API_KEY not configured on server." + ) + return api_key + +async def send_sse_event(task_id: str, data: dict): + """Send Server-Sent Event to connected client""" + if task_id in sse_clients: + queue = sse_clients[task_id] + await queue.put(data) + +# Endpoints +@router.post("/veo/generate", response_model=VideoGenerationResponse) +async def generate_video(request: VideoGenerationRequest, req: Request): + """ + Generate video using KIE Veo 3.1 API + Supports text-to-video and image-to-video generation + """ + try: + api_key = get_kie_api_key() + + # Build public URL for callback + public_url = os.getenv('PUBLIC_URL', f"http://localhost:{os.getenv('SERVER_PORT', 4000)}") + callback_url = f"{public_url}/api/veo/callback" + + # Process image URLs + public_image_urls = [] + if request.imageUrls: + print(f"📷 Processing {len(request.imageUrls)} images...") + for image_url in request.imageUrls: + # If it's already a public URL, use it as-is + if image_url.startswith(('http://', 'https://')): + print(f" Using external URL: {image_url}") + public_image_urls.append(image_url) + else: + # Compress and host the data URL + hosted_url = await compress_and_store_image(image_url, public_url) + print(f" Hosted image: {hosted_url}") + public_image_urls.append(hosted_url) + + # Determine generation type + generation_type = request.generationType + if not generation_type: + generation_type = "FIRST_AND_LAST_FRAMES_2_VIDEO" if public_image_urls else "TEXT_2_VIDEO" + + # Log prompt format and seed + if isinstance(request.prompt, dict): + print(f"📝 Sending structured JSON prompt to Veo 3.1") + else: + print(f"📝 Sending text prompt to Veo 3.1") + + if request.seeds: + print(f"🎲 Using seed: {request.seeds} (warm, flattering lighting)") + + # Call KIE API + async with httpx.AsyncClient(timeout=30.0) as client: + payload = { + "prompt": request.prompt, # Can be string or structured JSON object + "imageUrls": public_image_urls, + "model": request.model, + "aspectRatio": request.aspectRatio, + "generationType": generation_type, + "enableTranslation": True, + "callBackUrl": callback_url + } + + # Add optional seed parameter + if request.seeds is not None: + payload["seeds"] = request.seeds + + # Add voice type for audio generation (if not "None") + if request.voiceType and request.voiceType.lower() != "none": + payload["voiceType"] = request.voiceType + print(f"🎤 Using voice type: {request.voiceType}") + else: + print(f"🔇 No voice/audio requested (voiceType: {request.voiceType})") + + response = await client.post( + f"{KIE_API_BASE}/api/v1/veo/generate", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + }, + json=payload + ) + + # Log raw response for debugging + print(f"📡 KIE API response status: {response.status_code}") + + # Check HTTP status first + if response.status_code != 200: + error_text = response.text + content_type = response.headers.get('content-type', '').lower() + + # Handle HTML error responses (like 502 Bad Gateway pages) + if 'text/html' in content_type or error_text.strip().startswith('' in error_text: + import re + title_match = re.search(r'(.*?)', error_text, re.IGNORECASE | re.DOTALL) + if title_match: + title = title_match.group(1).strip() + # Extract just the error part (e.g., "502: Bad gateway" from "kie.ai | 502: Bad gateway") + if ':' in title: + error_message = f"KIE API error: {title.split('|')[-1].strip()}" + else: + error_message = f"KIE API error: {title}" + + print(f"❌ KIE API HTTP error: {response.status_code} - {error_message}") + raise HTTPException( + status_code=502, # Bad Gateway - the KIE API is down/unavailable + detail=error_message + ) + else: + # Non-HTML error response, try to extract JSON error if possible + try: + error_data = response.json() + error_message = error_data.get('msg') or error_data.get('message') or error_data.get('detail') or f"KIE API error (HTTP {response.status_code})" + except (json.JSONDecodeError, ValueError): + # Not JSON, use text (truncated) + error_message = error_text[:200] if len(error_text) > 200 else error_text + + print(f"❌ KIE API HTTP error: {response.status_code} - {error_message[:200]}") + raise HTTPException( + status_code=response.status_code, + detail=f"KIE API error: {error_message}" + ) + + result = response.json() + print(f"📡 KIE API result code: {result.get('code')}, msg: {result.get('msg')}") + + if result.get('code') != 200: + raise HTTPException( + status_code=result.get('code', 500), + detail=result.get('msg', 'KIE API request failed') + ) + + task_id = result['data']['taskId'] + print(f"✅ Video generation started: {task_id}") + + return VideoGenerationResponse( + taskId=task_id, + status="processing" + ) + + except HTTPException: + raise + except httpx.HTTPStatusError as e: + error_text = e.response.text + content_type = e.response.headers.get('content-type', '').lower() + + # Handle HTML error responses + if 'text/html' in content_type or error_text.strip().startswith('' in error_text: + import re + title_match = re.search(r'(.*?)', error_text, re.IGNORECASE | re.DOTALL) + if title_match: + title = title_match.group(1).strip() + if ':' in title: + error_msg = f"KIE API error: {title.split('|')[-1].strip()}" + else: + # Try to extract JSON error if possible + try: + error_data = e.response.json() + error_msg = error_data.get('msg') or error_data.get('message') or error_data.get('detail') or f"KIE API error (HTTP {e.response.status_code})" + except (json.JSONDecodeError, ValueError): + error_msg = error_text[:200] if len(error_text) > 200 else error_text + + print(f"❌ {error_msg}") + raise HTTPException(status_code=502 if 'text/html' in content_type else e.response.status_code, detail=error_msg) + except httpx.RequestError as e: + error_msg = f"KIE API request error: {type(e).__name__} - {str(e)}" + print(f"❌ {error_msg}") + raise HTTPException(status_code=502, detail=error_msg) + except json.JSONDecodeError as e: + error_msg = f"Invalid JSON response from KIE API. The service may be unavailable." + print(f"❌ JSON decode error: {str(e)}") + raise HTTPException(status_code=502, detail=error_msg) + except Exception as e: + import traceback + error_msg = f"{type(e).__name__}: {str(e)}" + print(f"❌ Video generation error: {error_msg}") + traceback.print_exc() + raise HTTPException( + status_code=500, + detail=f"Video generation request failed: {error_msg}" + ) + +@router.post("/veo/callback") +async def veo_callback(callback_data: CallbackData): + """ + Callback endpoint for KIE API + Receives video generation status updates + """ + try: + data = callback_data.data or {} + task_id = data.get('taskId') + info = data.get('info', {}) + fallback_flag = data.get('fallbackFlag') + + print(f"📥 Callback received for task {task_id}: code={callback_data.code}, msg={callback_data.msg}") + + # Store result + video_results[task_id] = { + 'code': callback_data.code, + 'msg': callback_data.msg, + 'taskId': task_id, + 'info': info, + 'fallbackFlag': fallback_flag, + 'timestamp': datetime.now().timestamp() + } + + # Send SSE update to client + if callback_data.code == 200 and info: + await send_sse_event(task_id, { + 'status': 'succeeded', + 'url': info.get('resultUrls', [None])[0], + 'resultUrls': info.get('resultUrls', []), + 'originUrls': info.get('originUrls', []), + 'resolution': info.get('resolution'), + 'fallbackFlag': fallback_flag + }) + else: + await send_sse_event(task_id, { + 'status': 'failed', + 'error': callback_data.msg, + 'code': callback_data.code + }) + + # Clean up old results + cleanup_old_results() + + return JSONResponse( + status_code=200, + content={'code': 200, 'msg': 'success'} + ) + + except Exception as e: + print(f"❌ Callback processing error: {str(e)}") + raise HTTPException( + status_code=500, + detail="Failed to process callback" + ) + + +@router.post("/veo/extend", response_model=VideoGenerationResponse) +async def extend_video(request: VideoExtendRequest): + """ + Extend an existing video using KIE Veo 3.1 extend API + Takes an existing taskId and extends it with new prompt + """ + try: + api_key = get_kie_api_key() + + # Build public URL for callback + public_url = os.getenv('PUBLIC_URL', f"http://localhost:{os.getenv('SERVER_PORT', 4000)}") + callback_url = f"{public_url}/api/veo/callback" + + print(f"🎬 Extending video from task: {request.taskId}") + + # Log prompt format and seed + if isinstance(request.prompt, dict): + print(f"📝 Extending with structured JSON prompt") + else: + print(f"📝 Extending with text prompt") + + if request.seeds: + print(f"🎲 Using seed: {request.seeds} (consistent lighting)") + + # Call KIE extend API + async with httpx.AsyncClient(timeout=30.0) as client: + payload = { + "taskId": request.taskId, + "prompt": request.prompt, + "callBackUrl": callback_url + } + + # Add optional parameters + if request.seeds is not None: + payload["seeds"] = request.seeds + if request.watermark: + payload["watermark"] = request.watermark + if request.voiceType and request.voiceType.lower() != "none": + payload["voiceType"] = request.voiceType + print(f"🎤 Using voice type: {request.voiceType}") + else: + print(f"🔇 No voice/audio requested (voiceType: {request.voiceType})") + + response = await client.post( + f"{KIE_API_BASE}/api/v1/veo/extend", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + }, + json=payload + ) + + # Check for HTML error responses + if response.status_code != 200: + error_text = response.text + content_type = response.headers.get('content-type', '').lower() + + if 'text/html' in content_type or error_text.strip().startswith('' in error_text: + import re + title_match = re.search(r'(.*?)', error_text, re.IGNORECASE | re.DOTALL) + if title_match: + title = title_match.group(1).strip() + if ':' in title: + error_message = f"KIE API error: {title.split('|')[-1].strip()}" + raise HTTPException(status_code=502, detail=error_message) + + result = response.json() + + if result.get('code') != 200: + raise HTTPException( + status_code=result.get('code', 500), + detail=result.get('msg', 'KIE extend API request failed') + ) + + new_task_id = result['data']['taskId'] + print(f"✅ Video extension started: {new_task_id}") + + return VideoGenerationResponse( + taskId=new_task_id, + status="processing" + ) + + except HTTPException: + raise + except httpx.HTTPStatusError as e: + error_text = e.response.text + content_type = e.response.headers.get('content-type', '').lower() + + if 'text/html' in content_type or error_text.strip().startswith('' in error_text: + import re + title_match = re.search(r'(.*?)', error_text, re.IGNORECASE | re.DOTALL) + if title_match: + title = title_match.group(1).strip() + if ':' in title: + error_msg = f"KIE API error: {title.split('|')[-1].strip()}" + else: + try: + error_data = e.response.json() + error_msg = error_data.get('msg') or error_data.get('message') or error_data.get('detail') or f"KIE API error (HTTP {e.response.status_code})" + except (json.JSONDecodeError, ValueError): + error_msg = error_text[:200] if len(error_text) > 200 else error_text + + print(f"❌ {error_msg}") + raise HTTPException(status_code=502 if 'text/html' in content_type else e.response.status_code, detail=error_msg) + except httpx.RequestError as e: + error_msg = f"KIE API request error: {type(e).__name__} - {str(e)}" + print(f"❌ {error_msg}") + raise HTTPException(status_code=502, detail=error_msg) + except json.JSONDecodeError as e: + error_msg = f"Invalid JSON response from KIE API. The service may be unavailable." + print(f"❌ JSON decode error: {str(e)}") + raise HTTPException(status_code=502, detail=error_msg) + except Exception as e: + import traceback + error_msg = f"{type(e).__name__}: {str(e)}" + print(f"❌ Video extension error: {error_msg}") + traceback.print_exc() + raise HTTPException( + status_code=500, + detail=f"Video extension error: {error_msg}" + ) + + +@router.get("/veo/events/{task_id}") +async def sse_events(task_id: str): + """ + Server-Sent Events endpoint for real-time updates + """ + async def event_generator(): + # Create queue for this client + queue = asyncio.Queue() + sse_clients[task_id] = queue + + print(f"🔌 SSE client connected for task {task_id}") + + try: + # Check if result already exists + if task_id in video_results: + result = video_results[task_id] + if result['code'] == 200 and result.get('info'): + info = result['info'] + event_data = { + 'status': 'succeeded', + 'url': info.get('resultUrls', [None])[0], + 'resultUrls': info.get('resultUrls', []), + 'originUrls': info.get('originUrls', []), + 'resolution': info.get('resolution'), + 'fallbackFlag': result.get('fallbackFlag') + } + else: + event_data = { + 'status': 'failed', + 'error': result['msg'], + 'code': result['code'] + } + yield f"data: {json.dumps(event_data)}\n\n" + + # Stream events + while True: + data = await queue.get() + yield f"data: {json.dumps(data)}\n\n" + + except asyncio.CancelledError: + print(f"🔌 SSE client disconnected for task {task_id}") + finally: + if task_id in sse_clients: + del sse_clients[task_id] + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive" + } + ) + +@router.get("/veo/status/{task_id}") +async def get_video_status(task_id: str): + """ + Get video generation status from KIE API + """ + try: + api_key = get_kie_api_key() + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{KIE_API_BASE}/api/v1/veo/video/{task_id}", + headers={ + "Authorization": f"Bearer {api_key}" + } + ) + + # Check for HTML error responses + if response.status_code != 200: + error_text = response.text + content_type = response.headers.get('content-type', '').lower() + + if 'text/html' in content_type or error_text.strip().startswith('' in error_text: + import re + title_match = re.search(r'(.*?)', error_text, re.IGNORECASE | re.DOTALL) + if title_match: + title = title_match.group(1).strip() + if ':' in title: + error_message = f"KIE API error: {title.split('|')[-1].strip()}" + raise HTTPException(status_code=502, detail=error_message) + + result = response.json() + + if result.get('code') != 200: + raise HTTPException( + status_code=result.get('code', 500), + detail=result.get('msg', 'Failed to get video status') + ) + + # Transform response + status = result['data'].get('status') + video_url = result['data'].get('videoUrl') + + return { + 'status': 'succeeded' if status == 'completed' else 'failed' if status == 'failed' else 'processing', + 'output': video_url if status == 'completed' else None, + 'url': video_url if status == 'completed' else None + } + + except HTTPException: + raise + except httpx.HTTPStatusError as e: + error_text = e.response.text + content_type = e.response.headers.get('content-type', '').lower() + + if 'text/html' in content_type or error_text.strip().startswith('' in error_text: + import re + title_match = re.search(r'(.*?)', error_text, re.IGNORECASE | re.DOTALL) + if title_match: + title = title_match.group(1).strip() + if ':' in title: + error_msg = f"KIE API error: {title.split('|')[-1].strip()}" + else: + try: + error_data = e.response.json() + error_msg = error_data.get('msg') or error_data.get('message') or error_data.get('detail') or f"KIE API error (HTTP {e.response.status_code})" + except (json.JSONDecodeError, ValueError): + error_msg = error_text[:200] if len(error_text) > 200 else error_text + + print(f"❌ {error_msg}") + raise HTTPException(status_code=502 if 'text/html' in content_type else e.response.status_code, detail=error_msg) + except httpx.RequestError as e: + error_msg = f"KIE API request error: {type(e).__name__} - {str(e)}" + print(f"❌ {error_msg}") + raise HTTPException(status_code=502, detail=error_msg) + except json.JSONDecodeError as e: + error_msg = f"Invalid JSON response from KIE API. The service may be unavailable." + print(f"❌ JSON decode error: {str(e)}") + raise HTTPException(status_code=502, detail=error_msg) + except Exception as e: + import traceback + error_msg = f"{type(e).__name__}: {str(e)}" + print(f"❌ Status check error: {error_msg}") + traceback.print_exc() + raise HTTPException( + status_code=500, + detail=f"Failed to check video status: {error_msg}" + ) + +@router.get("/veo/download") +async def download_video(url: str): + """ + Download video from external URL + Proxies the video stream to avoid CORS issues + """ + if not url: + raise HTTPException(status_code=400, detail="Missing url query parameter") + + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.get(url) + if response.status_code != 200: + raise HTTPException( + status_code=response.status_code, + detail="Failed to download asset" + ) + + # Return the video content directly + return Response( + content=response.content, + media_type=response.headers.get('content-type', 'video/mp4'), + headers={ + 'Content-Disposition': 'attachment; filename="video.mp4"', + 'Content-Length': str(len(response.content)) + } + ) + + except HTTPException: + raise + except Exception as e: + print(f"❌ Download error: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to download asset: {str(e)}" + ) + diff --git a/api/whisper_service.py b/api/whisper_service.py new file mode 100644 index 0000000000000000000000000000000000000000..1df6d5a3ce2db359a2da0bb5129533788920094e --- /dev/null +++ b/api/whisper_service.py @@ -0,0 +1,166 @@ +""" +Whisper-based Video Analysis Service +Optimized endpoint that finds trim point and extracts frame in one call +""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional +import tempfile +import os +import httpx + +router = APIRouter() + +# Check Whisper availability +try: + from utils.whisper_trim import find_last_word_timestamp, transcribe_video, is_whisper_available + from utils.video_processor import extract_frame, get_video_info + WHISPER_AVAILABLE = is_whisper_available() +except ImportError: + WHISPER_AVAILABLE = False + + +class WhisperAnalyzeRequest(BaseModel): + video_url: str + dialogue: str # The expected dialogue/script for this segment + buffer_time: float = 0.3 # Time after last word for frame extraction + model_size: str = "base" # Whisper model size + + +class WhisperAnalyzeResponse(BaseModel): + success: bool + last_word_timestamp: Optional[float] = None # When last word ends + trim_point: Optional[float] = None # Recommended trim point (last_word + buffer) + frame_timestamp: Optional[float] = None # Where frame was extracted + frame_base64: Optional[str] = None # Base64 encoded frame image + video_duration: float = 0 # Total video duration + transcribed_text: Optional[str] = None # What Whisper actually heard (for consistency check) + error: Optional[str] = None + + +@router.post("/whisper/analyze-and-extract", response_model=WhisperAnalyzeResponse) +async def analyze_and_extract_frame(request: WhisperAnalyzeRequest): + """ + Analyze video with Whisper to find last spoken word, + then extract frame at that point for visual continuity. + + This is the optimized flow: + 1. Download video + 2. Use Whisper to find last spoken word timestamp + 3. Extract frame at (last_word_time + buffer) + 4. Return frame + trim metadata + + The trim metadata can be used later during final merge. + """ + temp_video = None + + try: + # Download video to temp file + print(f"🎤 Downloading video for Whisper analysis...") + temp_video = tempfile.mktemp(suffix='.mp4') + + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.get(request.video_url) + if response.status_code != 200: + return WhisperAnalyzeResponse( + success=False, + error=f"Failed to download video: {response.status_code}" + ) + + with open(temp_video, 'wb') as f: + f.write(response.content) + + # Get video duration + video_info = get_video_info(temp_video) + video_duration = float(video_info['format']['duration']) + print(f"📹 Video duration: {video_duration:.2f}s") + + # Try Whisper-based analysis + last_word_time = None + frame_base64 = None + trim_point = None + frame_timestamp = None + transcribed_text = None + + if WHISPER_AVAILABLE: + try: + print(f"🎤 Running Whisper transcription (model: {request.model_size})...") + + # Get full transcription and last word timestamp + transcribed_text, last_word_time = transcribe_video( + video_path=temp_video, + model_size=request.model_size + ) + + if last_word_time and last_word_time > 0: + print(f"✅ Last spoken word at: {last_word_time:.2f}s") + + # Calculate trim point and frame timestamp + trim_point = min(last_word_time + request.buffer_time, video_duration) + frame_timestamp = min(last_word_time + request.buffer_time, video_duration - 0.1) + + print(f"📍 Trim point: {trim_point:.2f}s, Frame at: {frame_timestamp:.2f}s") + else: + print(f"⚠️ Could not find last word, using fallback") + + except Exception as whisper_err: + print(f"⚠️ Whisper analysis failed: {str(whisper_err)}") + else: + print("⚠️ Whisper not available, using fallback") + + # Fallback: use end of video + if frame_timestamp is None: + frame_timestamp = max(0, video_duration - 0.5) + trim_point = video_duration + print(f"📍 Fallback: Frame at {frame_timestamp:.2f}s (near end)") + + # Extract frame at the calculated timestamp (uncompressed for continuity) + print(f"📸 Extracting frame at {frame_timestamp:.2f}s") + frame_base64 = extract_frame( + video_path=temp_video, + timestamp=frame_timestamp, + return_base64=True, + compress=False # No compression for continuity frames + ) + print(f"✅ Frame extracted successfully") + + return WhisperAnalyzeResponse( + success=True, + last_word_timestamp=last_word_time, + trim_point=trim_point, + frame_timestamp=frame_timestamp, + frame_base64=frame_base64, + video_duration=video_duration, + transcribed_text=transcribed_text, + error=None + ) + + except Exception as e: + print(f"❌ Whisper analyze error: {str(e)}") + import traceback + traceback.print_exc() + + return WhisperAnalyzeResponse( + success=False, + error=str(e) + ) + + finally: + # Clean up temp file + if temp_video and os.path.exists(temp_video): + try: + os.remove(temp_video) + except: + pass + + +@router.get("/whisper/status") +async def whisper_status(): + """Check if Whisper is available and ready""" + return { + "available": WHISPER_AVAILABLE, + "message": "Whisper is ready" if WHISPER_AVAILABLE + else "Whisper not installed. Run: pip install openai-whisper moviepy" + } + diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a547bf36d8d11a4f89c59c144f24795749086dd1 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/FLOW.md b/frontend/FLOW.md new file mode 100644 index 0000000000000000000000000000000000000000..c3f076a185557740aa640d7fb2f10e4849b2a714 --- /dev/null +++ b/frontend/FLOW.md @@ -0,0 +1,512 @@ +# Video Generation Flows + +## Overview + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ VIDEO GENESIS STUDIO │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ │ │ │ │ +│ │ KLING AI │ OR │ REPLICATE │ │ +│ │ (KIE) │ │ │ │ +│ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ GPT-4o │ │ Simple │ │ +│ │ Segmentation│ │ Prompt │ │ +│ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ Multi-Video │ │ Single │ │ +│ │ Generation │ │ Video Gen │ │ +│ └─────────────┘ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Flow 1: Kling AI (Recommended) + +This is the **advanced flow** for professional UGC and talking head videos. + +### Two Generation Modes + +#### Mode A: 🎯 Frame Continuity (Recommended) +**Best for visual consistency** - mirrors the Replicate approach from `standalone_video_creator.py` + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ FRAME CONTINUITY FLOW │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Segment 1: │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ +│ │ Original Image │ ───► │ Generate Video │ ───► │ Extract Last │ │ +│ │ (your upload) │ │ Segment 1 │ │ Frame │ │ +│ └────────────────┘ └────────────────┘ └───────┬────────┘ │ +│ │ │ +│ ▼ │ +│ Segment 2: ┌────────────────┐ │ +│ ┌────────────────┐ ┌────────────────┐ │ Last Frame │ │ +│ │ Frame from │ ◄─── │ Generate Video │ ◄─│ from Seg 1 │ │ +│ │ Segment 1 │ │ Segment 2 │ │ (new reference)│ │ +│ └────────────────┘ └───────┬────────┘ └────────────────┘ │ +│ │ │ +│ ▼ │ +│ Segment 3: ┌────────────────┐ │ +│ ┌────────────────┐ │ Extract Last │ │ +│ │ Frame from │ ◄────│ Frame │ │ +│ │ Segment 2 │ └────────────────┘ │ +│ └────────────────┘ │ +│ │ │ +│ ▼ │ +│ ... continues until all segments complete ... │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +✅ BENEFITS: +• Perfect visual continuity - each segment starts exactly where previous ended +• Character appearance stays consistent across all segments +• Scene/lighting matches between segments +• Same approach that makes Replicate flow work so well +``` + +#### Mode B: ➕ Extend API +**Faster but potentially less consistent** - uses Kling's native extend functionality + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ EXTEND API FLOW │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────┐ ┌────────────────┐ │ +│ │ Original Image │ ───► │ Generate Video │ ───► taskId_1 │ +│ │ │ │ Segment 1 │ │ +│ └────────────────┘ └────────────────┘ │ +│ │ │ +│ │ extend(taskId_1) │ +│ ▼ │ +│ ┌────────────────┐ │ +│ │ Extend Video │ ───► taskId_2 │ +│ │ Segment 2 │ │ +│ └────────────────┘ │ +│ │ │ +│ │ extend(taskId_2) │ +│ ▼ │ +│ ┌────────────────┐ │ +│ │ Extend Video │ ───► taskId_3 │ +│ │ Segment 3 │ │ +│ └────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +⚠️ TRADEOFFS: +• Faster (no frame extraction step) +• May have slight visual drift between segments +• Relies on Kling's internal continuity handling +``` + +### Complete Step-by-Step Flow + +``` +USER INPUT BACKEND EXTERNAL APIs +───────────────────────────────────────────────────────────────────────────── + +┌──────────────────┐ +│ 1. Enter Script │ +│ + Upload Image│ +│ + Settings │ +│ + SELECT MODE │ ◄── NEW: Choose Frame Continuity or Extend +└────────┬─────────┘ + │ + ▼ +┌──────────────────┐ ┌──────────────────┐ +│ 2. Submit Form │────────►│ /api/generate- │ +│ │ │ prompts │ +└──────────────────┘ └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ ┌──────────────────┐ + │ 3. GPT-4o │────────►│ OpenAI API │ + │ Analysis │ │ (GPT-4o Vision) │ + └────────┬─────────┘ └──────────────────┘ + │ + │ Returns structured + │ segments with: + │ - Character description + │ - Scene continuity + │ - Action timeline + │ - Dialogue sync + ▼ +┌──────────────────┐ ┌──────────────────┐ +│ 4. See segments │◄────────│ Segments payload │ +│ preview │ │ (2-10 segments) │ +└────────┬─────────┘ └──────────────────┘ + │ + │ For each segment: + ▼ +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ 5. Generating... │────────►│ /api/veo/ │────────►│ KIE Veo 3.1 API │ +│ (progress UI) │ │ generate │ │ │ +└──────────────────┘ └────────┬─────────┘ └────────┬─────────┘ + │ │ + │ SSE Events │ + │◄───────────────────────────┘ + │ + ▼ +┌──────────────────┐ ┌──────────────────┐ +│ 6. Video Ready │◄────────│ /api/veo/events/ │ +│ Download │ │ {taskId} │ +└──────────────────┘ └──────────────────┘ + │ + │ IF Frame Continuity mode: + │ └── Extract last frame → Use as next reference + │ IF Extend mode: + │ └── Use extend API with current taskId + ▼ +┌──────────────────┐ +│ 7. All Complete │ +│ Download All │ +└──────────────────┘ +``` + +### Detailed Steps + +#### Step 1: User Input +``` +┌─────────────────────────────────────────────────────────────┐ +│ GENERATION FORM │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 📝 Script (Required) │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ "Hey everyone! Today I want to share something │ │ +│ │ amazing with you. This product changed my life... │ │ +│ │ Let me show you how it works..." │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ Word count: 85 words → ~5 segments estimated │ +│ │ +│ 🖼️ Character Image (Required) │ +│ ┌───────────────────┐ │ +│ │ [Drag & Drop] │ │ +│ │ Your photo │ │ +│ └───────────────────┘ │ +│ │ +│ ⚙️ Settings │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ Voice: Deep│ │Energy: Med │ │Camera: Std │ │ +│ └────────────┘ └────────────┘ └────────────┘ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │Ratio: 9:16 │ │Seed: 12005 │ │Style: UGC │ │ +│ └────────────┘ └────────────┘ └────────────┘ │ +│ │ +│ [🚀 Generate Video with AI] │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### Step 2-3: GPT-4o Prompt Generation +The backend sends your script + image to GPT-4o which: + +1. **Analyzes the script** for natural breakpoints +2. **Splits into ~8 second segments** (based on speaking pace) +3. **Generates detailed prompts** for each segment: + +```json +{ + "segments": [ + { + "segment_info": { + "segment_number": 1, + "total_segments": 5, + "duration": "8s", + "continuity_markers": { + "start_position": "facing camera, centered", + "end_position": "slight lean forward", + "start_expression": "neutral, friendly", + "end_expression": "engaged, excited" + } + }, + "character_description": { + "current_state": "Young woman, warm smile, casual attire", + "voice_matching": "Deep, confident, natural pace" + }, + "scene_continuity": { + "environment": "Modern living room, natural light", + "camera_position": "Medium shot, eye level", + "lighting_state": "Warm, soft shadows" + }, + "action_timeline": { + "dialogue": "Hey everyone! Today I want to share...", + "synchronized_actions": { + "0:00-0:02": "Wave hello, direct eye contact", + "0:02-0:04": "Hands gesture outward", + "0:04-0:06": "Touch chest (sincerity)", + "0:06-0:08": "Lean slightly forward" + } + } + } + ] +} +``` + +#### Step 4-5: Video Generation Loop +For each segment: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ GENERATION PROGRESS │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ╭──────────╮ │ +│ ( ) │ +│ │ 45% │ │ +│ ( ) │ +│ ╰──────────╯ │ +│ │ +│ 🎬 Generating video 2 of 5... │ +│ │ +│ ●────────────●────────────○────────────○────────────○ │ +│ ✓ ✓ ⟳ │ +│ Seg 1 Seg 2 Seg 3 Seg 4 Seg 5 │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Processing: Uploaded image → KIE API → Rendering... │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ⏱️ Estimated time: 1-2 minutes per segment │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### Step 6-7: Completion & Download + +``` +┌─────────────────────────────────────────────────────────────┐ +│ GENERATION COMPLETE! ✅ │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 5 videos generated successfully │ +│ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ +│ │ Seg 1 │ │ Seg 2 │ │ Seg 3 │ │ Seg 4 │ │ Seg 5 │ +│ │ ▶️ │ │ ▶️ │ │ ▶️ │ │ ▶️ │ │ ▶️ │ +│ │ ~8s │ │ ~8s │ │ ~8s │ │ ~8s │ │ ~8s │ +│ │ [⬇️] │ │ [⬇️] │ │ [⬇️] │ │ [⬇️] │ │ [⬇️] │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ +│ │ +│ [📥 Download All Videos] [🔄 Generate More] │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Flow 2: Replicate (Flexible) + +This is the **simple flow** for creative experimentation. + +### Step-by-Step Flow + +``` +USER INPUT BACKEND EXTERNAL APIs +───────────────────────────────────────────────────────────────────────────── + +┌──────────────────┐ +│ 1. Enter Prompt │ +│ + Settings │ +└────────┬─────────┘ + │ + ▼ +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ 2. Submit │────────►│ /api/replicate/ │────────►│ Replicate API │ +│ │ │ generate │ │ (Various Models) │ +└──────────────────┘ └────────┬─────────┘ └────────┬─────────┘ + │ │ + │ │ + ▼ │ +┌──────────────────┐ ┌──────────────────┐ │ +│ 3. Polling │◄────────│ /api/replicate/ │◄────────────────┘ +│ for status │ │ status/{id} │ +└────────┬─────────┘ └──────────────────┘ + │ + ▼ +┌──────────────────┐ +│ 4. Video Ready │ +│ Download │ +└──────────────────┘ +``` + +### Available Models + +``` +┌─────────────────────────────────────────────────────────────┐ +│ REPLICATE MODELS │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 🎬 minimax/video-01 │ │ +│ │ High-quality text-to-video generation │ │ +│ │ Duration: 5s | Best for: General content │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 🎥 luma/ray │ │ +│ │ Cinematic video generation │ │ +│ │ Duration: Variable | Best for: Artistic content │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 🖼️ stability-ai/stable-video-diffusion │ │ +│ │ Image-to-video generation │ │ +│ │ Duration: 4s | Best for: Animating images │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## API Endpoints Reference + +### Kling AI Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/generate-prompts` | POST | GPT-4o script analysis & segmentation | +| `/api/upload-image` | POST | Upload character reference image | +| `/api/veo/generate` | POST | Start video generation | +| `/api/veo/extend` | POST | Extend existing video | +| `/api/veo/events/{taskId}` | GET | SSE stream for real-time updates | +| `/api/veo/status/{taskId}` | GET | Check generation status | +| `/api/veo/download` | GET | Download generated video | + +### Replicate Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/replicate/generate` | POST | Start video generation | +| `/api/replicate/status/{id}` | GET | Check prediction status | +| `/api/replicate/models` | GET | List available models | +| `/api/replicate/cancel/{id}` | POST | Cancel running prediction | + +--- + +## Comparison + +| Feature | Kling (Frame Continuity) | Kling (Extend) | Replicate | +|---------|--------------------------|----------------|-----------| +| Script Segmentation | ✅ GPT-4o auto | ✅ GPT-4o auto | ✅ GPT-4o auto | +| Multi-segment | ✅ Yes | ✅ Yes | ✅ Yes | +| Visual Consistency | ✅✅ Best | ⚠️ Good | ✅✅ Best | +| Speed | ⚠️ Slower | ✅ Fast | ⚠️ Slower | +| Frame Extraction | ✅ Yes | ❌ No | ✅ Yes | +| Voice/Audio | ✅ Yes | ✅ Yes | ❌ No | +| Best for | **Consistent UGC** | Quick drafts | Creative work | + +### Why Frame Continuity Works Better + +``` +The secret from standalone_video_creator.py: + +┌────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ PROBLEM with Extend API: │ +│ • AI tries to "continue" but may drift visually │ +│ • Character appearance can subtly change │ +│ • Lighting/scene may shift between segments │ +│ │ +│ SOLUTION with Frame Continuity: │ +│ • Extract the EXACT last frame of each video │ +│ • Use it as the reference image for next segment │ +│ • AI sees exactly what it needs to continue from │ +│ • Result: Perfect visual match between segments │ +│ │ +│ This is why the Replicate flow in standalone_video_creator.py │ +│ produces such consistent results! │ +│ │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Technical Architecture + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ FRONTEND │ +│ (React + Vite + TS) │ +│ │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ +│ │ ProviderSelect │ │ GenerationForm │ │ GenerationProg │ │ +│ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │ +│ │ │ │ │ +│ └───────────────────┼───────────────────┘ │ +│ │ │ +│ ┌─────────▼─────────┐ │ +│ │ GenerationContext │ │ +│ │ (State Mgmt) │ │ +│ └─────────┬─────────┘ │ +│ │ │ +│ ┌─────────▼─────────┐ │ +│ │ API Client │ │ +│ │ (utils/api.ts) │ │ +│ └─────────┬─────────┘ │ +└──────────────────────────────┼───────────────────────────────────────────┘ + │ HTTP / SSE + ▼ +┌──────────────────────────────────────────────────────────────────────────┐ +│ BACKEND │ +│ (FastAPI + Python) │ +│ │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ +│ │ video_ │ │ replicate_ │ │ prompt_ │ │ +│ │ generation.py │ │ service.py │ │ generation.py │ │ +│ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │ +│ │ │ │ │ +│ └───────────────────┼───────────────────┘ │ +│ │ │ +│ ┌─────────▼─────────┐ │ +│ │ main.py │ │ +│ │ (FastAPI App) │ │ +│ └─────────┬─────────┘ │ +└──────────────────────────────┼───────────────────────────────────────────┘ + │ HTTPS + ▼ +┌──────────────────────────────────────────────────────────────────────────┐ +│ EXTERNAL APIs │ +│ │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ +│ │ KIE/Kling │ │ Replicate │ │ OpenAI │ │ +│ │ Veo 3.1 │ │ (Models) │ │ GPT-4o │ │ +│ └────────────────┘ └────────────────┘ └────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Quick Start + +1. **Start Backend**: + ```bash + cd /Users/sushil/Desktop/python-backend + source venv/bin/activate + python main.py + ``` + +2. **Start Frontend**: + ```bash + cd frontend + npm run dev + ``` + +3. **Open Browser**: http://localhost:3000 + +4. **Choose Provider** → Enter script → Generate! + diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..6a07bd8500923df92ca4fc7b2ba3b963de1a870f --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,127 @@ +# Video Genesis Studio + +A modern, beautiful frontend for automatic AI video generation with dual provider support (Kling AI & Replicate). + +## Features + +- **Dual Provider Support**: Choose between Kling AI (Veo 3.1) and Replicate for video generation +- **Automatic Script Segmentation**: GPT-4o analyzes your script and creates optimal video segments +- **Beautiful UI**: Modern, distinctive design with ocean-dark theme +- **Real-time Progress**: Watch your videos generate with live status updates +- **Download & Preview**: Preview generated videos and download them individually or all at once + +## Getting Started + +### Prerequisites + +- Node.js 18+ +- Python backend running on port 4000 + +### Installation + +```bash +# Navigate to frontend directory +cd frontend + +# Install dependencies +npm install + +# Start development server +npm run dev +``` + +The frontend will be available at `http://localhost:3000`. + +### Environment Variables + +Create a `.env.local` file in the frontend directory: + +```env +VITE_API_BASE_URL=http://localhost:4000 +``` + +## Architecture + +``` +frontend/ +├── src/ +│ ├── components/ # React components +│ │ ├── Icons.tsx # SVG icons +│ │ ├── ProviderSelect.tsx # Provider selection screen +│ │ ├── GenerationForm.tsx # Video generation form +│ │ ├── GenerationProgress.tsx # Progress display +│ │ ├── GenerationComplete.tsx # Results screen +│ │ └── ErrorDisplay.tsx # Error handling +│ ├── context/ # React context +│ │ └── GenerationContext.tsx +│ ├── types/ # TypeScript types +│ ├── utils/ # Utilities & API client +│ │ └── api.ts +│ ├── App.tsx # Main application +│ ├── main.tsx # Entry point +│ └── index.css # Tailwind styles +├── public/ # Static assets +├── package.json +├── tailwind.config.js +└── vite.config.ts +``` + +## Provider Flows + +### Kling AI (Recommended) + +1. Upload character reference image +2. Enter your full script +3. Configure generation settings (voice, camera, style) +4. GPT-4o analyzes and segments your script +5. Videos are generated segment by segment +6. Download all segments when complete + +### Replicate + +1. Enter your prompt +2. Select from available models +3. Configure aspect ratio and duration +4. Video is generated via Replicate API +5. Download when complete + +## Design System + +The UI uses a custom design system with: + +- **Colors**: Ocean-inspired dark theme with coral and electric accent colors +- **Typography**: Clash Display (headings) + Satoshi (body) +- **Components**: Glass morphism effects, smooth animations via Framer Motion +- **Layout**: Responsive grid with fluid animations + +## Development + +```bash +# Development server with hot reload +npm run dev + +# Build for production +npm run build + +# Preview production build +npm run preview +``` + +## Tech Stack + +- **React 19** - UI framework +- **TypeScript** - Type safety +- **Vite** - Build tool +- **Tailwind CSS** - Styling +- **Framer Motion** - Animations + +## API Integration + +The frontend communicates with the Python backend at `/api/*`: + +- `POST /api/generate-prompts` - Generate video prompts with GPT-4o +- `POST /api/veo/generate` - Start Kling video generation +- `GET /api/veo/events/:taskId` - SSE for generation progress +- `POST /api/replicate/generate` - Start Replicate generation +- `GET /api/replicate/status/:id` - Check Replicate status +- `GET /health` - Backend health check diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..5e6b472f583e34a1cca751440d4f241495475723 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..a5a2621faa77e6408fde47d8d7f0b47af62fc3d3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,21 @@ + + + + + + + Video AdGenesis Studio + + + + + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..58527c7d489d57cb468e4ccec6b0d832bd384a71 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2776 @@ +{ + "name": "video-genesis-studio", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "video-genesis-studio", + "version": "1.0.0", + "dependencies": { + "framer-motion": "^11.15.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "~5.6.0", + "vite": "^6.0.0" + } + }, + "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", + "peer": true, + "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.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/@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.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "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/node": { + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.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/anymatch/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/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/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "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.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "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/baseline-browser-mapping": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.3.tgz", + "integrity": "sha512-8QdH6czo+G7uBsNo0GiUfouPN1lRzKdJTGnKXwe12gkFbnnOUaUKGN55dMkfy+mnxmvjwl9zcI4VncczcVXDhA==", + "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", + "peer": true, + "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/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.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "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/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/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/electron-to-chromium": { + "version": "1.5.266", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", + "integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "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/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/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/framer-motion": { + "version": "11.18.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", + "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^11.18.1", + "motion-utils": "^11.18.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "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==", + "dev": true, + "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/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/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "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", + "peer": true, + "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==", + "dev": true, + "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/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/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/micromatch/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/motion-dom": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", + "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^11.18.1" + } + }, + "node_modules/motion-utils": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz", + "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==", + "license": "MIT" + }, + "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/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "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": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "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", + "peer": true, + "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/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": "19.2.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", + "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", + "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.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/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/readdirp/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/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.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "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.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "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.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "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/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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "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": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "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 0000000000000000000000000000000000000000..6c5184a641c3a4994be8a0e34d1ec70d7082636b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "video-genesis-studio", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "framer-motion": "^11.15.0" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "~5.6.0", + "vite": "^6.0.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..b4a6220e2db5b2afb8b7eb96b2f252893fd913f0 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000000000000000000000000000000000000..1b6099b4f43ab949484a390e83d0ea3ee9649c7e --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000000000000000000000000000000000000..b9d355df2a5956b526c004531b7b0ffe412461e0 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9e422727a10fd3d2b45977e58537f7f965956358 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,192 @@ +import { useEffect, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { GenerationProvider, useGeneration } from '@/context/GenerationContext'; +import { AuthProvider, useAuth } from '@/context/AuthContext'; +import { + ProviderSelect, + GenerationForm, + GenerationProgress, + GenerationComplete, + ErrorDisplay, + Login, + LogoIcon +} from '@/components'; +import { checkHealth } from '@/utils/api'; +import type { HealthStatus } from '@/types'; + +// Main App Content (uses context) +function AppContent() { + const { isAuthenticated, loading: authLoading, logout } = useAuth(); + const { state, selectProvider, reset } = useGeneration(); + const [healthStatus, setHealthStatus] = useState(null); + const [healthError, setHealthError] = useState(null); + + // Check backend health on mount (must be called before any conditional returns) + useEffect(() => { + if (isAuthenticated) { + checkHealth() + .then(setHealthStatus) + .catch((err) => setHealthError(err.message)); + } + }, [isAuthenticated]); + + // Show login if not authenticated + if (authLoading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + // Render based on current step + const renderContent = () => { + switch (state.step) { + case 'idle': + return ( + selectProvider(provider)} + /> + ); + + case 'configuring': + return ( + reset()} + /> + ); + + case 'generating_prompts': + case 'generating_video': + case 'processing': + return ; + + case 'completed': + return ; + + case 'error': + return ; + + default: + return ( + selectProvider(provider)} + /> + ); + } + }; + + return ( +
+ {/* Header */} +
+
+
+ {/* Logo */} + + + {/* Status Indicator */} +
+ {/* Provider Badge */} + {state.provider && state.step !== 'idle' && ( + + {state.provider === 'kling' ? 'KIE API' : 'Replicate'} + + )} + + {/* Backend Status */} +
+
+ + {healthError ? 'Backend Offline' : + healthStatus ? (healthStatus.is_dev_mode ? 'Dev Mode' : 'Production') : + 'Connecting...'} + +
+ + {/* Logout Button */} + +
+
+
+
+ + {/* Main Content */} +
+ + + {renderContent()} + + +
+ + {/* Footer */} +
+

+ Powered by {' '} + AdGenesis +

+
+
+ ); +} + +// App Wrapper with Providers +function App() { + return ( + + + + + + ); +} + +export default App; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000000000000000000000000000000000000..6c87de9bb3358469122cc991d5cf578927246184 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/ErrorDisplay.tsx b/frontend/src/components/ErrorDisplay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0a2f0dc3e555132beab9d3cb4b54db7016eb0c60 --- /dev/null +++ b/frontend/src/components/ErrorDisplay.tsx @@ -0,0 +1,70 @@ +import { motion } from 'framer-motion'; +import { useGeneration } from '@/context/GenerationContext'; +import { XIcon, RefreshIcon, ArrowLeftIcon } from './Icons'; + +export const ErrorDisplay: React.FC = () => { + const { state, reset, setStep } = useGeneration(); + const { error } = state; + + return ( + +
+ {/* Error Icon */} + + + + + {/* Error Message */} +

+ Generation Failed +

+ +
+

+ {error || 'An unexpected error occurred during video generation.'} +

+
+ + {/* Actions */} +
+ + + +
+ + {/* Help Text */} +
+

Troubleshooting Tips

+
    +
  • • Check that your API keys are properly configured
  • +
  • • Ensure your image is under 10MB and in JPG/PNG format
  • +
  • • Try a shorter script if timeouts occur
  • +
  • • Check the backend server logs for more details
  • +
+
+
+
+ ); +}; + diff --git a/frontend/src/components/GenerationComplete.tsx b/frontend/src/components/GenerationComplete.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0a8c78c065a1a31234bae1c2318da0e36df4bd95 --- /dev/null +++ b/frontend/src/components/GenerationComplete.tsx @@ -0,0 +1,375 @@ +import { useState } from 'react'; +import { motion } from 'framer-motion'; +import { useGeneration } from '@/context/GenerationContext'; +import { CheckIcon, DownloadIcon, PlayIcon, RefreshIcon, VideoIcon } from './Icons'; +import { mergeVideos, ClipMetadata } from '@/utils/api'; + +export const GenerationComplete: React.FC = () => { + const { state, reset } = useGeneration(); + const { generatedVideos, provider } = state; + const [playingIndex, setPlayingIndex] = useState(null); + const [isMerging, setIsMerging] = useState(false); + const [mergeError, setMergeError] = useState(null); + const [mergedVideoUrl, setMergedVideoUrl] = useState(null); + const [isPlayingMerged, setIsPlayingMerged] = useState(false); + + const accentColor = provider === 'kling' ? 'coral' : 'electric'; + + const handleDownload = async (video: typeof generatedVideos[0], index: number) => { + try { + const url = video.blobUrl || video.url; + const response = await fetch(url); + const blob = await response.blob(); + + const downloadUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = `video-segment-${index + 1}.mp4`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(downloadUrl); + } catch (error) { + console.error('Download failed:', error); + } + }; + + const handleDownloadAll = async () => { + for (let i = 0; i < generatedVideos.length; i++) { + await handleDownload(generatedVideos[i], i); + // Small delay between downloads + await new Promise(r => setTimeout(r, 500)); + } + }; + + // Merge all videos into a single file + const handleMergeAndExport = async () => { + if (generatedVideos.length === 0) return; + + setIsMerging(true); + setMergeError(null); + + try { + // Collect all video blobs + const videoBlobs: Blob[] = []; + const clipMetadata: ClipMetadata[] = []; + + for (let i = 0; i < generatedVideos.length; i++) { + const video = generatedVideos[i]; + const url = video.blobUrl || video.url; + + // Fetch blob from URL + const response = await fetch(url); + const blob = await response.blob(); + videoBlobs.push(blob); + + // Create clip metadata + // Use Whisper-detected trim point if available, otherwise use full duration + // No start trimming - keep full video from beginning + const trimStart = 0; // Always start from beginning (no overlap removal) + const trimEnd = video.trimPoint || video.duration; // Use Whisper trim point if available + + clipMetadata.push({ + index: i, + startTime: trimStart, + endTime: trimEnd, + type: 'video', + duration: trimEnd - trimStart, + }); + } + + console.log('🎬 Merging videos...', clipMetadata); + + // Call merge API + const mergedBlob = await mergeVideos(videoBlobs, clipMetadata); + + // Create URL for preview (don't auto-download) + const previewUrl = URL.createObjectURL(mergedBlob); + setMergedVideoUrl(previewUrl); + + console.log('✅ Merged video ready for preview!'); + + } catch (error) { + console.error('Merge failed:', error); + setMergeError(error instanceof Error ? error.message : 'Failed to merge videos'); + } finally { + setIsMerging(false); + } + }; + + // Download the merged video + const handleDownloadMerged = () => { + if (!mergedVideoUrl) return; + + const a = document.createElement('a'); + a.href = mergedVideoUrl; + a.download = `final-video-${Date.now()}.mp4`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + + return ( + +
+ {/* Success Header */} +
+ + + + + + + Generation Complete! + + + + + {generatedVideos.length} video{generatedVideos.length !== 1 ? 's' : ''} generated successfully + +
+ + {/* Video Grid */} + + {generatedVideos.map((video, index) => ( +
+ {/* Video Preview */} +
+
+ + {/* Video Info */} +
+
+

+ Segment {index + 1} +

+

+ ~{Math.round(video.duration)}s duration +

+
+ +
+
+ ))} +
+ + {/* Merged Video Preview */} + {mergedVideoUrl && ( + +
+
+
+ +
+
+

Final Exported Video

+

All segments merged into one video

+
+
+ + {/* Video Player */} +
+
+ + {/* Download Button */} + +
+
+ )} + + {/* Merge Error */} + {mergeError && ( + +

{mergeError}

+
+ )} + + {/* Actions */} + + {/* Primary: Merge & Export */} + {!mergedVideoUrl && ( + + )} + + {/* Re-merge option if already merged */} + {mergedVideoUrl && ( + + )} + + {/* Secondary: Download All */} + + + {/* Tertiary: Generate More */} + + + + {/* Tip */} + + {mergedVideoUrl + ? 'Your final video is ready! Download it or re-merge with different settings.' + : generatedVideos.length >= 2 + ? '"Export Final Video" will merge all segments into a single video file with Whisper-optimized trim points.' + : 'Videos are ready to use in your video editor or social media.' + } + +
+
+ ); +}; diff --git a/frontend/src/components/GenerationForm.tsx b/frontend/src/components/GenerationForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7a58749107fb7fe67a4e019e561c42f57184673f --- /dev/null +++ b/frontend/src/components/GenerationForm.tsx @@ -0,0 +1,1362 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { motion } from 'framer-motion'; +import { useGeneration } from '@/context/GenerationContext'; +import type { GenerationInputs, VideoProvider, GeneratedVideo, VeoSegment } from '@/types'; +import { + SparklesIcon, + ArrowLeftIcon, + ImageIcon +} from './Icons'; +import { + generatePrompts, + uploadImage, + klingGenerate, + klingExtend, + waitForKlingVideo, + generateVideoWithRetry, + downloadVideo, + getVideoDuration, + generateThumbnails, + replicateGenerate, + waitForReplicateVideo, + whisperAnalyzeAndExtract +} from '@/utils/api'; + +interface GenerationFormProps { + provider: VideoProvider; + onBack: () => void; +} + +const voiceTypes = ['Deep', 'Warm', 'Crisp', 'None']; +const energyLevels = ['Low', 'Medium', 'High']; +const cameraStyles = ['Standard', 'Handheld', 'Steadicam', 'FPV Drone']; +const narrativeStyles = ['Standard', 'Documentary', 'Action', 'Introspective']; +const aspectRatios = ['9:16', '16:9', '1:1']; + +// Generation modes +type GenerationMode = 'extend' | 'frame-continuity'; + +export const GenerationForm: React.FC = ({ provider, onBack }) => { + const { startGeneration, updateProgress, addVideo, setStep, setError, setRetryState, updateSegments, state } = useGeneration(); + const { retryState, generatedVideos, segments } = state; + + // Draft storage key + const draftKey = `video-gen-draft-${provider}`; + + // Load draft on mount - initialize state from localStorage + const loadDraft = useCallback(() => { + try { + const savedDraft = localStorage.getItem(draftKey); + if (savedDraft) { + const draft = JSON.parse(savedDraft); + return draft; + } + } catch (error) { + console.warn('Failed to load draft:', error); + } + return null; + }, [draftKey]); + + const draft = loadDraft(); + const [draftRestored, setDraftRestored] = useState(!!draft); + + const [formState, setFormState] = useState( + draft?.formState || { + script: '', + style: '', + voiceType: 'Deep', + energyLevel: 'Medium', + cameraStyle: 'Standard', + narrativeStyle: 'Standard', + seedValue: 12005, + aspectRatio: '9:16', + model: provider === 'kling' ? 'veo3_fast' : 'google/veo-3', + } + ); + + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(draft?.imagePreview || null); + const [isDragging, setIsDragging] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + + // Generation mode selection + const [generationMode, setGenerationMode] = useState(draft?.generationMode || 'frame-continuity'); + + // Retry editing state + const [retryDialogue, setRetryDialogue] = useState(''); + const [retryEnvironment, setRetryEnvironment] = useState(''); + const [retryAction, setRetryAction] = useState(''); + + // Initialize retry fields when error occurs + useEffect(() => { + if (retryState && segments[retryState.failedSegmentIndex]) { + const seg = segments[retryState.failedSegmentIndex]; + setRetryDialogue(seg.action_timeline?.dialogue || ''); + setRetryEnvironment(seg.scene_continuity?.environment || ''); + setRetryAction(seg.character_description?.current_state || ''); + } + }, [retryState, segments]); + + const handleRetrySubmit = () => { + if (!retryState) return; + + const idx = retryState.failedSegmentIndex; + const updatedSegments = [...segments]; + + // Update the segment with edited values + if (updatedSegments[idx]) { + updatedSegments[idx] = { + ...updatedSegments[idx], + action_timeline: { + ...updatedSegments[idx].action_timeline, + dialogue: retryDialogue + }, + scene_continuity: { + ...updatedSegments[idx].scene_continuity, + environment: retryEnvironment + }, + character_description: { + ...updatedSegments[idx].character_description, + current_state: retryAction + } + }; + + updateSegments(updatedSegments); + } + + // Clear error and resume + setRetryState(null); + setStep('generating_video'); + setIsGenerating(true); + + // Resume generation based on provider + if (provider === 'kling') { + if (generationMode === 'frame-continuity') { + handleKlingFrameContinuityFlow(); + } else { + handleKlingExtendFlow(); + } + } else { + handleReplicateGeneration(); + } + }; + + const handleCancelRetry = () => { + setRetryState(null); + setIsGenerating(false); + }; + + // Show notification if draft was restored + useEffect(() => { + if (draftRestored) { + console.log('📝 Draft restored from localStorage'); + // Auto-hide notification after 5 seconds + const timer = setTimeout(() => setDraftRestored(false), 5000); + return () => clearTimeout(timer); + } + }, [draftRestored]); + + // Save draft whenever formState, imagePreview, or generationMode changes + // Skip saving on initial mount to avoid overwriting with default values + const isInitialMount = useRef(true); + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + + try { + const draft = { + formState, + imagePreview, + generationMode, + savedAt: new Date().toISOString(), + }; + localStorage.setItem(draftKey, JSON.stringify(draft)); + } catch (error) { + console.warn('Failed to save draft:', error); + } + }, [formState, imagePreview, generationMode, draftKey]); + + // Clear draft function + const clearDraft = useCallback(() => { + try { + localStorage.removeItem(draftKey); + setDraftRestored(false); + console.log('🗑️ Draft cleared'); + } catch (error) { + console.warn('Failed to clear draft:', error); + } + }, [draftKey]); + + // Calculate estimated segments + const wordCount = formState.script.trim().split(/\s+/).filter(w => w).length; + const estimatedSegments = wordCount > 0 ? Math.max(1, Math.min(Math.ceil(wordCount / 17), 10)) : 0; + + // Handle input changes + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormState(prev => ({ ...prev, [name]: value })); + }; + + // Handle image upload + const handleImageUpload = useCallback((file: File) => { + if (file.type.startsWith('image/')) { + setImageFile(file); + const reader = new FileReader(); + reader.onloadend = () => setImagePreview(reader.result as string); + reader.readAsDataURL(file); + } + }, []); + + // Drag and drop handlers + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = () => setIsDragging(false); + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleImageUpload(file); + }; + + // Extract last frame from video blob + const extractLastFrame = async (videoBlob: Blob): Promise => { + return new Promise((resolve, reject) => { + const video = document.createElement('video'); + video.preload = 'metadata'; + video.muted = true; + video.src = URL.createObjectURL(videoBlob); + + video.onloadedmetadata = async () => { + // Seek to near the end of the video + const targetTime = Math.max(0, video.duration - 0.1); + video.currentTime = targetTime; + }; + + video.onseeked = () => { + // Create canvas and draw current frame + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext('2d'); + + if (!ctx) { + URL.revokeObjectURL(video.src); + reject(new Error('Could not get canvas context')); + return; + } + + ctx.drawImage(video, 0, 0); + + // Convert to blob then to file + canvas.toBlob((blob) => { + URL.revokeObjectURL(video.src); + + if (!blob) { + reject(new Error('Could not extract frame')); + return; + } + + const file = new File([blob], `frame-${Date.now()}.jpg`, { type: 'image/jpeg' }); + resolve(file); + }, 'image/jpeg', 0.95); + }; + + video.onerror = () => { + URL.revokeObjectURL(video.src); + reject(new Error('Failed to load video for frame extraction')); + }; + }); + }; + + // ============================================ + // KIE GENERATION - FRAME CONTINUITY FLOW + // ============================================ + // This mirrors the Replicate flow from standalone_video_creator.py: + // 1. Generate first video with original reference image + // 2. Extract last frame using the whisper analysis from generated video + // 3. Use that frame as reference for next segment + // 4. Repeat for all segments + + const handleKlingFrameContinuityFlow = async () => { + if (!imageFile || !formState.script.trim()) return; + + setIsGenerating(true); + setError(null); + + try { + // Step 1: Generate prompts using GPT-4o + updateProgress('Analyzing script with GPT-4o...'); + + const formData = new FormData(); + formData.append('script', formState.script); + formData.append('style', formState.style || 'clean, lifestyle UGC'); + formData.append('jsonFormat', 'standard'); + formData.append('continuationMode', 'true'); + formData.append('voiceType', formState.voiceType || ''); + formData.append('energyLevel', formState.energyLevel || ''); + formData.append('settingMode', 'single'); + formData.append('cameraStyle', formState.cameraStyle || ''); + formData.append('narrativeStyle', formState.narrativeStyle || ''); + formData.append('image', imageFile); + + const payload = await generatePrompts(formData); + + if (!payload?.segments?.length) { + throw new Error('No segments generated from script'); + } + + const segments = payload.segments; + updateProgress(`Generated ${segments.length} segments. Starting video generation...`); + startGeneration(segments); + + // Track current reference image (starts with original) + let currentImageFile = imageFile; + const generatedVideos: GeneratedVideo[] = []; + + // Step 2: Generate videos segment by segment with frame continuity + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + const isLastSegment = i === segments.length - 1; + + updateProgress( + `Generating video ${i + 1} of ${segments.length}...${i > 0 ? ' (using last frame from previous)' : ''}`, + i, + segments.length + ); + + // Upload current reference image + updateProgress(`Uploading reference image for segment ${i + 1}...`); + const uploadResult = await uploadImage(currentImageFile); + const hostedImageUrl = uploadResult.url; + + console.log(`🖼️ Segment ${i + 1} using image: ${i === 0 ? 'original' : 'last frame from previous'}`); + + // Generate video with current reference image + updateProgress(`Submitting segment ${i + 1} to KIE Veo 3.1...`); + const generateResult = await klingGenerate({ + prompt: segment, + imageUrls: [hostedImageUrl], + model: 'veo3_fast', + aspectRatio: formState.aspectRatio, + generationType: 'FIRST_AND_LAST_FRAMES_2_VIDEO', + seeds: formState.seedValue, + voiceType: formState.voiceType, + }); + + // Wait for completion + updateProgress(`Processing video ${i + 1}... (this may take 1-2 minutes)`); + const videoUrl = await waitForKlingVideo(generateResult.taskId); + + // Download video + updateProgress(`Downloading video ${i + 1}...`); + const videoBlob = await downloadVideo(videoUrl); + const blobUrl = URL.createObjectURL(videoBlob); + + // Get video duration + const videoFile = new File([videoBlob], `segment-${i + 1}.mp4`, { type: 'video/mp4' }); + const duration = await getVideoDuration(videoFile); + const thumbnails = await generateThumbnails(videoFile); + + // Use Whisper to find optimal trim point, extract frame, and get transcription + let trimPoint = duration; // Default to full duration + let transcribedText = ''; // What Whisper actually heard + + if (!isLastSegment) { + updateProgress(`Analyzing video ${i + 1} with Whisper for optimal continuity...`); + try { + // Get dialogue from segment for Whisper analysis + const dialogue = segment.action_timeline?.dialogue || ''; + + const whisperResult = await whisperAnalyzeAndExtract({ + video_url: videoUrl, + dialogue: dialogue, + buffer_time: 0.3, + model_size: 'base' + }); + + if (whisperResult.success && whisperResult.frame_base64) { + // Convert base64 frame to File for next segment + const base64Data = whisperResult.frame_base64.split(',')[1] || whisperResult.frame_base64; + const byteCharacters = atob(base64Data); + const byteNumbers = new Array(byteCharacters.length); + for (let j = 0; j < byteCharacters.length; j++) { + byteNumbers[j] = byteCharacters.charCodeAt(j); + } + const byteArray = new Uint8Array(byteNumbers); + const frameBlob = new Blob([byteArray], { type: 'image/jpeg' }); + currentImageFile = new File([frameBlob], `whisper-frame-${i + 1}.jpg`, { type: 'image/jpeg' }); + + // Store trim point for later merge + if (whisperResult.trim_point) { + trimPoint = whisperResult.trim_point; + } + + // Store transcribed text for prompt refinement + if (whisperResult.transcribed_text) { + transcribedText = whisperResult.transcribed_text; + console.log(`📝 Whisper transcription: "${transcribedText.substring(0, 100)}..."`); + } + + console.log(`✅ Whisper: Last word at ${whisperResult.last_word_timestamp?.toFixed(2)}s, frame at ${whisperResult.frame_timestamp?.toFixed(2)}s, trim at ${trimPoint.toFixed(2)}s`); + + // REFINE NEXT SEGMENT PROMPT with frame + transcription + const nextSegment = segments[i + 1]; + if (nextSegment && currentImageFile) { + updateProgress(`Refining segment ${i + 2} prompt with visual and audio context...`); + try { + const { refinePromptWithContext } = await import('@/utils/api'); + const refined = await refinePromptWithContext( + nextSegment, + currentImageFile, + transcribedText, + dialogue + ); + // Update the next segment with refined prompt + segments[i + 1] = refined.refined_prompt as typeof nextSegment; + console.log(`✅ Refined segment ${i + 2} prompt for consistency`); + } catch (refineError) { + console.warn(`⚠️ Prompt refinement failed, using original:`, refineError); + } + } + } else { + // Fallback to simple last frame extraction + console.log(`⚠️ Whisper failed (${whisperResult.error}), falling back to last frame extraction`); + const lastFrameFile = await extractLastFrame(videoBlob); + currentImageFile = lastFrameFile; + } + } catch (frameError) { + console.error(`⚠️ Whisper analysis failed, using fallback:`, frameError); + try { + const lastFrameFile = await extractLastFrame(videoBlob); + currentImageFile = lastFrameFile; + } catch { + // Continue with current image if all extraction fails + } + } + } + + // Add to generated videos with trim metadata + const generatedVideo: GeneratedVideo = { + id: `video-${Date.now()}-${i}`, + url: videoUrl, + blobUrl, + segment, + duration, + thumbnails, + trimPoint, // Store trim point for merge + }; + generatedVideos.push(generatedVideo); + addVideo(generatedVideo); + + updateProgress(`Completed video ${i + 1} of ${segments.length}`, i + 1, segments.length); + } + + // All done! + clearDraft(); // Clear draft on successful generation + clearDraft(); // Clear draft on successful generation + setStep('completed'); + updateProgress('All videos generated successfully!'); + + } catch (err) { + console.error('Generation error:', err); + const errorMessage = err instanceof Error ? err.message : 'Generation failed'; + + // Enable retry mode + setRetryState({ + failedSegmentIndex: generatedVideos.length, // Current segment that failed + error: errorMessage + }); + setStep('configuring'); // Go back to form, but with retry overlay + + } finally { + setIsGenerating(false); + } + }; + + // ============================================ + // KIE GENERATION - EXTEND API FLOW + // ============================================ + // Original flow using KIE's extend API + + const handleKlingExtendFlow = async () => { + if (!imageFile || !formState.script.trim()) return; + + setIsGenerating(true); + setError(null); + + try { + // Step 1: Generate prompts using GPT-4o + updateProgress('Analyzing script with GPT-4o...'); + + const formData = new FormData(); + formData.append('script', formState.script); + formData.append('style', formState.style || 'clean, lifestyle UGC'); + formData.append('jsonFormat', 'standard'); + formData.append('continuationMode', 'true'); + formData.append('voiceType', formState.voiceType || ''); + formData.append('energyLevel', formState.energyLevel || ''); + formData.append('settingMode', 'single'); + formData.append('cameraStyle', formState.cameraStyle || ''); + formData.append('narrativeStyle', formState.narrativeStyle || ''); + formData.append('image', imageFile); + + // Use existing segments if retrying, otherwise generate new ones + let payload: { segments: VeoSegment[] }; + if (retryState && segments.length > 0) { + // Retry mode: use existing segments (they may have been edited) + payload = { segments }; + updateProgress(`Using existing ${segments.length} segments for retry...`); + } else { + // Normal mode: generate new segments + payload = await generatePrompts(formData); + if (!payload?.segments?.length) { + throw new Error('No segments generated from script'); + } + updateProgress(`Generated ${payload.segments.length} segments. Starting video generation...`); + startGeneration(payload.segments); + } + + // Step 2: Upload reference image once + updateProgress('Uploading reference image...'); + const uploadResult = await uploadImage(imageFile); + const hostedImageUrl = uploadResult.url; + + // Step 3: Generate videos (resume from where we left off if retrying) + const startIndex = generatedVideos.length; + let currentTaskId: string | null = null; + let currentImageUrl = hostedImageUrl; // Start with original image + + // If resuming, extract last frame from previous video for continuity + if (startIndex > 0 && generatedVideos[startIndex - 1]?.blobUrl) { + updateProgress(`Extracting last frame from segment ${startIndex} for continuity...`); + try { + const lastVideoBlob = await fetch(generatedVideos[startIndex - 1].blobUrl!).then(r => r.blob()); + const lastFrameFile = await extractLastFrame(lastVideoBlob); + const frameUploadResult = await uploadImage(lastFrameFile); + currentImageUrl = frameUploadResult.url; + updateProgress(`Using frame from segment ${startIndex} for segment ${startIndex + 1}...`); + } catch (frameError) { + console.warn('Failed to extract frame, using original image:', frameError); + // Continue with original image + } + } + + for (let i = startIndex; i < payload.segments.length; i++) { + const segment = payload.segments[i]; + + updateProgress(`Generating video ${i + 1} of ${payload.segments.length}...`, i, payload.segments.length); + + // Generate video with automatic retry (retries once on failure) + updateProgress(`Processing video ${i + 1}... (this may take 1-2 minutes)`); + const videoUrl = await generateVideoWithRetry(async () => { + if (i === 0 || (i === startIndex && startIndex > 0)) { + // First segment OR resuming after failure: use generate API with current image + const generateResult = await klingGenerate({ + prompt: segment, + imageUrls: [currentImageUrl], + model: 'veo3_fast', + aspectRatio: formState.aspectRatio, + generationType: 'FIRST_AND_LAST_FRAMES_2_VIDEO', + seeds: formState.seedValue, + voiceType: formState.voiceType, + }); + currentTaskId = generateResult.taskId; + return generateResult; + } else { + // Subsequent segments: use extend API + const extendResult = await klingExtend( + currentTaskId!, + segment, + formState.seedValue, + formState.voiceType + ); + currentTaskId = extendResult.taskId; + return extendResult; + } + }, 300000, (attempt) => { + updateProgress(`Retrying video ${i + 1}... (attempt ${attempt}/2)`); + }); + + // Download and save + updateProgress(`Downloading video ${i + 1}...`); + const videoBlob = await downloadVideo(videoUrl); + const blobUrl = URL.createObjectURL(videoBlob); + + const videoFile = new File([videoBlob], `segment-${i + 1}.mp4`, { type: 'video/mp4' }); + const duration = await getVideoDuration(videoFile); + const thumbnails = await generateThumbnails(videoFile); + + addVideo({ + id: `video-${Date.now()}-${i}`, + url: videoUrl, + blobUrl, + segment, + duration, + thumbnails, + }); + + updateProgress(`Completed video ${i + 1} of ${payload.segments.length}`, i + 1, payload.segments.length); + } + + clearDraft(); // Clear draft on successful generation + setStep('completed'); + updateProgress('All videos generated successfully!'); + + } catch (err) { + console.error('Generation error:', err); + const errorMessage = err instanceof Error ? err.message : 'Generation failed'; + + // Enable retry mode + setRetryState({ + failedSegmentIndex: generatedVideos.length, // Current segment that failed + error: errorMessage + }); + setStep('configuring'); // Go back to form, but with retry overlay + + } finally { + setIsGenerating(false); + } + }; + + // ============================================ + // REPLICATE GENERATION - FRAME CONTINUITY FLOW + // ============================================ + // This mirrors the approach from standalone_video_creator.py: + // 1. Generate prompts using GPT-4o + // 2. For each segment, generate video with current reference image + // 3. Extract last frame from generated video + // 4. Use that frame as reference for next segment + // 5. Result: Perfect visual continuity across all segments + + const handleReplicateGeneration = async () => { + if (!formState.script.trim()) return; + + setIsGenerating(true); + setError(null); + + try { + // Step 1: Generate prompts using GPT-4o + // Note: Replicate can work without an image, but for consistency we encourage one + updateProgress('Analyzing script with GPT-4o...'); + + const formData = new FormData(); + formData.append('script', formState.script); + formData.append('style', formState.style || 'clean, lifestyle UGC'); + formData.append('jsonFormat', 'standard'); + formData.append('continuationMode', 'true'); + formData.append('voiceType', formState.voiceType || ''); + formData.append('energyLevel', formState.energyLevel || ''); + formData.append('settingMode', 'single'); + formData.append('cameraStyle', formState.cameraStyle || ''); + formData.append('narrativeStyle', formState.narrativeStyle || ''); + + // If image provided, include it for GPT-4o analysis + if (imageFile) { + formData.append('image', imageFile); + } else { + // Create a placeholder image for GPT-4o (it needs one for analysis) + // In production, you might want to handle this differently + const placeholderBlob = new Blob(['placeholder'], { type: 'image/jpeg' }); + formData.append('image', placeholderBlob, 'placeholder.jpg'); + } + + const payload = await generatePrompts(formData); + + if (!payload?.segments?.length) { + throw new Error('No segments generated from script'); + } + + const segments = payload.segments; + updateProgress(`Generated ${segments.length} segments. Starting Replicate generation...`); + startGeneration(segments); + + // Track current reference image (starts with original if provided) + let currentImageFile = imageFile; + const generatedVideos: GeneratedVideo[] = []; + + // Step 2: Generate videos segment by segment with frame continuity + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + const isLastSegment = i === segments.length - 1; + + updateProgress( + `Generating video ${i + 1} of ${segments.length} with Replicate...${i > 0 ? ' (using last frame)' : ''}`, + i, + segments.length + ); + + // Convert structured segment to text prompt for Replicate + // Replicate models typically expect text prompts + const textPrompt = convertSegmentToTextPrompt(segment); + + console.log(`🎬 Segment ${i + 1} prompt:`, textPrompt.substring(0, 100) + '...'); + + // Upload current reference image if available + let imageUrl: string | undefined; + if (currentImageFile) { + updateProgress(`Uploading reference image for segment ${i + 1}...`); + const uploadResult = await uploadImage(currentImageFile); + imageUrl = uploadResult.url; + console.log(`🖼️ Segment ${i + 1} using image: ${i === 0 ? 'original' : 'last frame from previous'}`); + } + + // Generate video with Replicate + updateProgress(`Submitting segment ${i + 1} to Replicate...`); + const generateResult = await replicateGenerate({ + prompt: textPrompt, + imageUrl: imageUrl, + model: formState.model || 'google/veo-3', + aspectRatio: formState.aspectRatio, + }); + + // Wait for completion (polling) + updateProgress(`Processing video ${i + 1}... (this may take 2-5 minutes)`); + const videoUrl = await waitForReplicateVideo(generateResult.id); + + // Download video + updateProgress(`Downloading video ${i + 1}...`); + const videoBlob = await downloadVideo(videoUrl); + const blobUrl = URL.createObjectURL(videoBlob); + + // Get video duration and thumbnails + const videoFile = new File([videoBlob], `segment-${i + 1}.mp4`, { type: 'video/mp4' }); + const duration = await getVideoDuration(videoFile); + const thumbnails = await generateThumbnails(videoFile); + + // Use Whisper to find optimal trim point, extract frame, and get transcription + // This is more accurate than extracting the very last frame + let trimPoint = duration; // Default to full duration + let transcribedText = ''; // What Whisper actually heard + + if (!isLastSegment) { + updateProgress(`Analyzing video ${i + 1} with Whisper for optimal continuity...`); + try { + // Get dialogue from segment for Whisper analysis + const dialogue = segment.action_timeline?.dialogue || textPrompt; + + const whisperResult = await whisperAnalyzeAndExtract({ + video_url: videoUrl, + dialogue: dialogue, + buffer_time: 0.3, + model_size: 'base' + }); + + if (whisperResult.success && whisperResult.frame_base64) { + // Convert base64 frame to File for next segment + const base64Data = whisperResult.frame_base64.split(',')[1] || whisperResult.frame_base64; + const byteCharacters = atob(base64Data); + const byteNumbers = new Array(byteCharacters.length); + for (let j = 0; j < byteCharacters.length; j++) { + byteNumbers[j] = byteCharacters.charCodeAt(j); + } + const byteArray = new Uint8Array(byteNumbers); + const frameBlob = new Blob([byteArray], { type: 'image/jpeg' }); + currentImageFile = new File([frameBlob], `whisper-frame-${i + 1}.jpg`, { type: 'image/jpeg' }); + + // Store trim point for later merge + if (whisperResult.trim_point) { + trimPoint = whisperResult.trim_point; + } + + // Store transcribed text for prompt refinement + if (whisperResult.transcribed_text) { + transcribedText = whisperResult.transcribed_text; + console.log(`📝 Whisper transcription: "${transcribedText.substring(0, 100)}..."`); + } + + console.log(`✅ Whisper: Last word at ${whisperResult.last_word_timestamp?.toFixed(2)}s, frame at ${whisperResult.frame_timestamp?.toFixed(2)}s, trim at ${trimPoint.toFixed(2)}s`); + + // REFINE NEXT SEGMENT PROMPT with frame + transcription + const nextSegment = segments[i + 1]; + if (nextSegment && currentImageFile) { + updateProgress(`Refining segment ${i + 2} prompt with visual and audio context...`); + try { + const { refinePromptWithContext } = await import('@/utils/api'); + const refined = await refinePromptWithContext( + nextSegment, + currentImageFile, + transcribedText, + dialogue + ); + // Update the next segment with refined prompt + segments[i + 1] = refined.refined_prompt as typeof nextSegment; + console.log(`✅ Refined segment ${i + 2} prompt for consistency`); + } catch (refineError) { + console.warn(`⚠️ Prompt refinement failed, using original:`, refineError); + } + } + } else { + // Fallback to simple last frame extraction + console.log(`⚠️ Whisper failed (${whisperResult.error}), falling back to last frame extraction`); + const lastFrameFile = await extractLastFrame(videoBlob); + currentImageFile = lastFrameFile; + } + } catch (frameError) { + console.error(`⚠️ Whisper analysis failed, using fallback:`, frameError); + try { + const lastFrameFile = await extractLastFrame(videoBlob); + currentImageFile = lastFrameFile; + } catch { + // Continue with current image if all extraction fails + } + } + } + + // Add to generated videos with trim metadata + const generatedVideo: GeneratedVideo = { + id: `video-${Date.now()}-${i}`, + url: videoUrl, + blobUrl, + segment, + duration, + thumbnails, + trimPoint, // Store trim point for merge + }; + generatedVideos.push(generatedVideo); + addVideo(generatedVideo); + + updateProgress(`Completed video ${i + 1} of ${segments.length}`, i + 1, segments.length); + } + + // All done! + setStep('completed'); + updateProgress('All videos generated successfully with Replicate!'); + + } catch (err) { + console.error('Replicate generation error:', err); + const errorMessage = err instanceof Error ? err.message : 'Replicate generation failed'; + + // Enable retry mode + setRetryState({ + failedSegmentIndex: generatedVideos.length, // Current segment that failed + error: errorMessage + }); + setStep('configuring'); // Go back to form, but with retry overlay + + } finally { + setIsGenerating(false); + } + }; + + // Helper: Convert structured segment JSON to text prompt for Replicate + // Replicate models typically expect plain text, not structured JSON + const convertSegmentToTextPrompt = (segment: VeoSegment): string => { + const parts: string[] = []; + + // Extract dialogue + const dialogue = segment.action_timeline?.dialogue; + if (dialogue) { + parts.push(`"${dialogue}"`); + } + + // Extract character description + const character = segment.character_description; + if (character?.current_state) { + parts.push(`Character: ${character.current_state}`); + } + + // Extract scene description + const scene = segment.scene_continuity; + if (scene?.environment) { + parts.push(`Scene: ${scene.environment}`); + } + if (scene?.lighting_state) { + parts.push(`Lighting: ${scene.lighting_state}`); + } + if (scene?.camera_position) { + parts.push(`Camera: ${scene.camera_position}`); + } + if (scene?.camera_movement) { + parts.push(`Movement: ${scene.camera_movement}`); + } + + // Extract synchronized actions + const syncedActions = segment.action_timeline?.synchronized_actions; + if (syncedActions) { + const actionsList = Object.entries(syncedActions) + .filter(([, value]) => value) + .map(([key, value]) => `${key}: ${value}`) + .join('; '); + if (actionsList) { + parts.push(`Actions: ${actionsList}`); + } + } + + // Add instruction to not include captions/subtitles + parts.push('Do not include any captions, subtitles, or text overlays in the video'); + + return parts.join('. '); + }; + + // Main submit handler + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (provider === 'kling') { + if (generationMode === 'frame-continuity') { + handleKlingFrameContinuityFlow(); + } else { + handleKlingExtendFlow(); + } + } else { + handleReplicateGeneration(); + } + }; + + const isValid = provider === 'kling' + ? !!imageFile && formState.script.trim().length > 0 + : formState.script.trim().length > 0; + + return ( + + {/* Header */} +
+
+ +

+ + {provider === 'kling' ? 'KIE API' : 'Replicate'} + + Video Generation +

+

+ {provider === 'kling' + ? 'Generate professional UGC videos with AI-powered segmentation' + : 'Create unique videos with open-source models' + } +

+
+
+ + {/* Retry Modal */} + {retryState && ( +
+ +
+ + + +

Generation Failed

+
+ +

+ Error at segment {retryState.failedSegmentIndex + 1}: {retryState.error} +

+ +
+

Edit Segment {retryState.failedSegmentIndex + 1} to fix the issue:

+ +
+ +