Spaces:
Running
Running
Adityahulk
commited on
Commit
·
6fc3143
0
Parent(s):
Restoring repo state for deployment
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .env.example +10 -0
- .gitignore +67 -0
- DEPLOYMENT.md +38 -0
- Dockerfile +45 -0
- LICENSE +21 -0
- README.md +7 -0
- api_client.py +364 -0
- api_server.py +811 -0
- assets/manimator.png +0 -0
- docs/IMPLEMENTATION_COMPLETE.md +162 -0
- docs/IMPLEMENTATION_PLAN.md +293 -0
- ensure_rpc.sql +33 -0
- fix_columns_and_trigger.sql +52 -0
- fix_rls.sql +24 -0
- frontend/.env.example +14 -0
- frontend/.gitignore +30 -0
- frontend/README.md +35 -0
- frontend/eslint.config.mjs +18 -0
- frontend/next-env.d.ts +5 -0
- frontend/next.config.mjs +17 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +39 -0
- frontend/postcss.config.mjs +7 -0
- frontend/public/file.svg +1 -0
- frontend/public/globe.svg +1 -0
- frontend/public/logo-full.jpg +0 -0
- frontend/public/logo-icon.jpg +0 -0
- frontend/public/logo.svg +11 -0
- frontend/public/next.svg +1 -0
- frontend/public/robots.txt +4 -0
- frontend/public/vercel.svg +1 -0
- frontend/public/window.svg +1 -0
- frontend/src/app/api-docs/page.tsx +166 -0
- frontend/src/app/api/auth/sync/route.ts +98 -0
- frontend/src/app/api/generate/route.ts +149 -0
- frontend/src/app/api/jobs/[id]/route.ts +77 -0
- frontend/src/app/api/videos/[id]/route.ts +44 -0
- frontend/src/app/app/page.tsx +12 -0
- frontend/src/app/auth/callback/route.ts +99 -0
- frontend/src/app/billing/page.tsx +157 -0
- frontend/src/app/favicon.ico +0 -0
- frontend/src/app/globals.css +74 -0
- frontend/src/app/layout.tsx +35 -0
- frontend/src/app/page.tsx +105 -0
- frontend/src/app/pricing/page.tsx +123 -0
- frontend/src/app/profile/page.tsx +136 -0
- frontend/src/components/app/VideoGenerator.tsx +602 -0
- frontend/src/components/auth/LoginButton.tsx +40 -0
- frontend/src/components/auth/UserAuth.tsx +124 -0
- frontend/src/components/landing/Footer.tsx +72 -0
.env.example
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Our selected models, replace these with your own choices if you want to modify the models used
|
| 2 |
+
PROMPT_SCENE_GEN_MODEL=groq/llama-3.3-70b-versatile
|
| 3 |
+
PDF_SCENE_GEN_MODEL=gemini/gemini-1.5-flash
|
| 4 |
+
PDF_RETRY_MODEL=gemini/gemini-2.0-flash-exp #Optional, only if you want to retry the PDF generation
|
| 5 |
+
CODE_GEN_MODEL=openrouter/deepseek/deepseek-chat:free
|
| 6 |
+
|
| 7 |
+
# Use the LiteLLM convention of naming the API keys depending on the models you choose
|
| 8 |
+
GROQ_API_KEY=
|
| 9 |
+
OPENROUTER_API_KEY=
|
| 10 |
+
GEMINI_API_KEY=
|
.gitignore
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
/lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
*.egg-info/
|
| 20 |
+
.installed.cfg
|
| 21 |
+
*.egg
|
| 22 |
+
MANIFEST
|
| 23 |
+
|
| 24 |
+
# Virtual environments
|
| 25 |
+
venv/
|
| 26 |
+
ENV/
|
| 27 |
+
env/
|
| 28 |
+
.venv
|
| 29 |
+
|
| 30 |
+
# IDE
|
| 31 |
+
.vscode/
|
| 32 |
+
.idea/
|
| 33 |
+
*.swp
|
| 34 |
+
*.swo
|
| 35 |
+
*~
|
| 36 |
+
|
| 37 |
+
# OS
|
| 38 |
+
.DS_Store
|
| 39 |
+
.DS_Store?
|
| 40 |
+
._*
|
| 41 |
+
.Spotlight-V100
|
| 42 |
+
.Trashes
|
| 43 |
+
ehthumbs.db
|
| 44 |
+
Thumbs.db
|
| 45 |
+
|
| 46 |
+
# Test output files
|
| 47 |
+
*.mp4
|
| 48 |
+
test_*.py
|
| 49 |
+
|
| 50 |
+
# Generated files
|
| 51 |
+
scene_*.py
|
| 52 |
+
scaling_*.py
|
| 53 |
+
generated_*.py
|
| 54 |
+
*.log
|
| 55 |
+
|
| 56 |
+
# Generated directories
|
| 57 |
+
media/
|
| 58 |
+
/jobs/
|
| 59 |
+
jobs_3d/
|
| 60 |
+
|
| 61 |
+
# Environment variables
|
| 62 |
+
.env
|
| 63 |
+
.env.local
|
| 64 |
+
|
| 65 |
+
# Project-specific
|
| 66 |
+
packages.txt
|
| 67 |
+
Error_free_videos.zip
|
DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deployment Configuration Guide
|
| 2 |
+
|
| 3 |
+
## Environment Variables
|
| 4 |
+
|
| 5 |
+
For a secure production deployment, you must set the following environment variables.
|
| 6 |
+
|
| 7 |
+
### 1. Frontend (Next.js)
|
| 8 |
+
These variables should be set in your Vercel project settings or `.env.production`.
|
| 9 |
+
|
| 10 |
+
| Variable | Description | Example Value |
|
| 11 |
+
|----------|-------------|---------------|
|
| 12 |
+
| `NEXTAUTH_URL` | The canonical URL of your site | `https://your-app.com` |
|
| 13 |
+
| `NEXTAUTH_SECRET` | A random string used to hash tokens | `openssl rand -base64 32` |
|
| 14 |
+
| `GOOGLE_CLIENT_ID` | OAuth Client ID from Google Cloud | `123...apps.googleusercontent.com` |
|
| 15 |
+
| `GOOGLE_CLIENT_SECRET` | OAuth Client Secret from Google Cloud | `GOCSPX-...` |
|
| 16 |
+
| `INTERNAL_API_KEY` | **CRITICAL**: Shared secret to talk to Python backend | `long-random-string-shared-with-backend` |
|
| 17 |
+
| `PYTHON_API_URL` | URL of your deployed Python backend | `https://api.your-app.com` |
|
| 18 |
+
| `DATABASE_URL` | Connection string for your production DB (e.g., Postgres) | `postgresql://user:pass@host:5432/db` |
|
| 19 |
+
|
| 20 |
+
> **Note on Database**: Currently, the app uses SQLite (`file:./dev.db`). For production, you should switch the `provider` in `prisma/schema.prisma` to `postgresql` or `mysql` and use a real database URL.
|
| 21 |
+
|
| 22 |
+
### 2. Backend (Python / FastAPI)
|
| 23 |
+
These variables should be set in your backend hosting service (e.g., Railway, Render, AWS).
|
| 24 |
+
|
| 25 |
+
| Variable | Description | Example Value |
|
| 26 |
+
|----------|-------------|---------------|
|
| 27 |
+
| `INTERNAL_API_KEY` | **CRITICAL**: Must match the Frontend key exactly | `long-random-string-shared-with-backend` |
|
| 28 |
+
| `OPENAI_API_KEY` | For generating animation code | `sk-...` |
|
| 29 |
+
| `ELEVENLABS_API_KEY` | For generating voiceovers | `...` |
|
| 30 |
+
| `ANTHROPIC_API_KEY` | (Optional) If using Claude models | `sk-ant-...` |
|
| 31 |
+
| `CODE_GEN_MODEL` | Model to use for code generation | `gpt-4o` or `claude-3-5-sonnet-20240620` |
|
| 32 |
+
|
| 33 |
+
## Security Checklist
|
| 34 |
+
|
| 35 |
+
1. [ ] **Generate a Strong `INTERNAL_API_KEY`**: Use `openssl rand -hex 32` to generate a secure key. Set this on BOTH frontend and backend.
|
| 36 |
+
2. [ ] **HTTPS Everywhere**: Ensure both your frontend and backend are served over HTTPS.
|
| 37 |
+
3. [ ] **Database**: Do not use SQLite in production if you have multiple server instances (serverless). Use a managed Postgres database (e.g., Supabase, Neon, Railway).
|
| 38 |
+
4. [ ] **CORS**: In `api_server.py`, update `allow_origins` to only allow your production frontend domain, not `*` or `localhost`.
|
Dockerfile
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Install system dependencies for Manim and general usage
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
build-essential \
|
| 6 |
+
ffmpeg \
|
| 7 |
+
libcairo2-dev \
|
| 8 |
+
libpango1.0-dev \
|
| 9 |
+
texlive \
|
| 10 |
+
texlive-latex-extra \
|
| 11 |
+
texlive-fonts-recommended \
|
| 12 |
+
dvisvgm \
|
| 13 |
+
pkg-config \
|
| 14 |
+
python3-dev \
|
| 15 |
+
sudo \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
# Create a non-root user (Hugging Face requirement)
|
| 19 |
+
RUN useradd -m -u 1000 user
|
| 20 |
+
USER user
|
| 21 |
+
ENV HOME=/home/user \
|
| 22 |
+
PATH=/home/user/.local/bin:$PATH
|
| 23 |
+
|
| 24 |
+
WORKDIR $HOME/app
|
| 25 |
+
|
| 26 |
+
# Copy requirements first to leverage Docker cache
|
| 27 |
+
COPY --chown=user requirements.txt .
|
| 28 |
+
|
| 29 |
+
# Install Python dependencies
|
| 30 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 31 |
+
|
| 32 |
+
# Copy the rest of the application
|
| 33 |
+
COPY --chown=user . .
|
| 34 |
+
|
| 35 |
+
# Make start script executable
|
| 36 |
+
RUN chmod +x start.sh
|
| 37 |
+
|
| 38 |
+
# Expose the port (Hugging Face Spaces uses 7860 by default)
|
| 39 |
+
EXPOSE 7860
|
| 40 |
+
|
| 41 |
+
# Set environment variables
|
| 42 |
+
ENV PYTHONUNBUFFERED=1
|
| 43 |
+
|
| 44 |
+
# Run the start script
|
| 45 |
+
CMD ["./start.sh"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 HyperCluster
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
title: Vidsimplify AI Video Generator
|
| 2 |
+
emoji: 🎬
|
| 3 |
+
colorFrom: red
|
| 4 |
+
colorTo: purple
|
| 5 |
+
sdk: docker
|
| 6 |
+
pinned: false
|
| 7 |
+
app_port: 7860
|
api_client.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Python client library for Manim Video Generation API
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import requests
|
| 6 |
+
import time
|
| 7 |
+
from typing import Optional, Dict, Any
|
| 8 |
+
from enum import Enum
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class QualityLevel(str, Enum):
|
| 12 |
+
"""Video quality levels"""
|
| 13 |
+
LOW = "low"
|
| 14 |
+
MEDIUM = "medium"
|
| 15 |
+
HIGH = "high"
|
| 16 |
+
ULTRA = "ultra"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class JobStatus(str, Enum):
|
| 20 |
+
"""Job status"""
|
| 21 |
+
PENDING = "pending"
|
| 22 |
+
GENERATING_CODE = "generating_code"
|
| 23 |
+
RENDERING = "rendering"
|
| 24 |
+
COMPLETED = "completed"
|
| 25 |
+
FAILED = "failed"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ManimVideoClient:
|
| 29 |
+
"""Client for Manim Video Generation API"""
|
| 30 |
+
|
| 31 |
+
def __init__(self, base_url: str = "http://localhost:8000"):
|
| 32 |
+
"""
|
| 33 |
+
Initialize client
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
base_url: Base URL of the API server
|
| 37 |
+
"""
|
| 38 |
+
self.base_url = base_url.rstrip('/')
|
| 39 |
+
self.session = requests.Session()
|
| 40 |
+
|
| 41 |
+
def create_video(
|
| 42 |
+
self,
|
| 43 |
+
prompt: str,
|
| 44 |
+
quality: QualityLevel = QualityLevel.HIGH,
|
| 45 |
+
scene_name: Optional[str] = None
|
| 46 |
+
) -> Dict[str, Any]:
|
| 47 |
+
"""
|
| 48 |
+
Create a new video generation job
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
prompt: Detailed animation prompt
|
| 52 |
+
quality: Video quality level
|
| 53 |
+
scene_name: Optional custom scene name
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
Job information including job_id
|
| 57 |
+
|
| 58 |
+
Example:
|
| 59 |
+
>>> client = ManimVideoClient()
|
| 60 |
+
>>> job = client.create_video(
|
| 61 |
+
... prompt="Explain bubble sort with animations",
|
| 62 |
+
... quality=QualityLevel.HIGH
|
| 63 |
+
... )
|
| 64 |
+
>>> print(job['job_id'])
|
| 65 |
+
"""
|
| 66 |
+
response = self.session.post(
|
| 67 |
+
f"{self.base_url}/api/videos",
|
| 68 |
+
json={
|
| 69 |
+
"prompt": prompt,
|
| 70 |
+
"quality": quality.value,
|
| 71 |
+
"scene_name": scene_name
|
| 72 |
+
}
|
| 73 |
+
)
|
| 74 |
+
response.raise_for_status()
|
| 75 |
+
return response.json()
|
| 76 |
+
|
| 77 |
+
def get_status(self, job_id: str) -> Dict[str, Any]:
|
| 78 |
+
"""
|
| 79 |
+
Get job status
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
job_id: Job ID returned from create_video()
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
Job status information
|
| 86 |
+
|
| 87 |
+
Example:
|
| 88 |
+
>>> status = client.get_status(job_id)
|
| 89 |
+
>>> print(status['status'])
|
| 90 |
+
>>> print(status['progress']['percentage'])
|
| 91 |
+
"""
|
| 92 |
+
response = self.session.get(f"{self.base_url}/api/jobs/{job_id}")
|
| 93 |
+
response.raise_for_status()
|
| 94 |
+
return response.json()
|
| 95 |
+
|
| 96 |
+
def wait_for_completion(
|
| 97 |
+
self,
|
| 98 |
+
job_id: str,
|
| 99 |
+
poll_interval: int = 5,
|
| 100 |
+
timeout: Optional[int] = None,
|
| 101 |
+
callback: Optional[callable] = None
|
| 102 |
+
) -> Dict[str, Any]:
|
| 103 |
+
"""
|
| 104 |
+
Wait for job to complete
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
job_id: Job ID
|
| 108 |
+
poll_interval: Seconds between status checks
|
| 109 |
+
timeout: Maximum seconds to wait (None = no timeout)
|
| 110 |
+
callback: Optional function called with status on each poll
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
Final job status
|
| 114 |
+
|
| 115 |
+
Example:
|
| 116 |
+
>>> def progress_callback(status):
|
| 117 |
+
... print(f"{status['progress']['percentage']}%: {status['progress']['message']}")
|
| 118 |
+
>>>
|
| 119 |
+
>>> result = client.wait_for_completion(
|
| 120 |
+
... job_id,
|
| 121 |
+
... callback=progress_callback
|
| 122 |
+
... )
|
| 123 |
+
"""
|
| 124 |
+
start_time = time.time()
|
| 125 |
+
|
| 126 |
+
while True:
|
| 127 |
+
status = self.get_status(job_id)
|
| 128 |
+
|
| 129 |
+
if callback:
|
| 130 |
+
callback(status)
|
| 131 |
+
|
| 132 |
+
if status['status'] in [JobStatus.COMPLETED, JobStatus.FAILED]:
|
| 133 |
+
return status
|
| 134 |
+
|
| 135 |
+
if timeout and (time.time() - start_time > timeout):
|
| 136 |
+
raise TimeoutError(f"Job did not complete within {timeout} seconds")
|
| 137 |
+
|
| 138 |
+
time.sleep(poll_interval)
|
| 139 |
+
|
| 140 |
+
def download_video(self, job_id: str, output_path: str) -> str:
|
| 141 |
+
"""
|
| 142 |
+
Download completed video
|
| 143 |
+
|
| 144 |
+
Args:
|
| 145 |
+
job_id: Job ID
|
| 146 |
+
output_path: Path to save video file
|
| 147 |
+
|
| 148 |
+
Returns:
|
| 149 |
+
Path to downloaded file
|
| 150 |
+
|
| 151 |
+
Example:
|
| 152 |
+
>>> client.download_video(job_id, "my_animation.mp4")
|
| 153 |
+
'my_animation.mp4'
|
| 154 |
+
"""
|
| 155 |
+
response = self.session.get(
|
| 156 |
+
f"{self.base_url}/api/videos/{job_id}",
|
| 157 |
+
stream=True
|
| 158 |
+
)
|
| 159 |
+
response.raise_for_status()
|
| 160 |
+
|
| 161 |
+
with open(output_path, 'wb') as f:
|
| 162 |
+
for chunk in response.iter_content(chunk_size=8192):
|
| 163 |
+
f.write(chunk)
|
| 164 |
+
|
| 165 |
+
return output_path
|
| 166 |
+
|
| 167 |
+
def list_jobs(self, limit: int = 50) -> Dict[str, Any]:
|
| 168 |
+
"""
|
| 169 |
+
List all jobs
|
| 170 |
+
|
| 171 |
+
Args:
|
| 172 |
+
limit: Maximum number of jobs to return
|
| 173 |
+
|
| 174 |
+
Returns:
|
| 175 |
+
Dictionary with job list
|
| 176 |
+
|
| 177 |
+
Example:
|
| 178 |
+
>>> jobs = client.list_jobs(limit=10)
|
| 179 |
+
>>> for job in jobs['jobs']:
|
| 180 |
+
... print(f"{job['job_id']}: {job['status']}")
|
| 181 |
+
"""
|
| 182 |
+
response = self.session.get(
|
| 183 |
+
f"{self.base_url}/api/jobs",
|
| 184 |
+
params={"limit": limit}
|
| 185 |
+
)
|
| 186 |
+
response.raise_for_status()
|
| 187 |
+
return response.json()
|
| 188 |
+
|
| 189 |
+
def delete_job(self, job_id: str) -> Dict[str, Any]:
|
| 190 |
+
"""
|
| 191 |
+
Delete a job and its files
|
| 192 |
+
|
| 193 |
+
Args:
|
| 194 |
+
job_id: Job ID
|
| 195 |
+
|
| 196 |
+
Returns:
|
| 197 |
+
Deletion confirmation
|
| 198 |
+
|
| 199 |
+
Example:
|
| 200 |
+
>>> client.delete_job(job_id)
|
| 201 |
+
{'message': 'Job deleted successfully', 'job_id': '...'}
|
| 202 |
+
"""
|
| 203 |
+
response = self.session.delete(f"{self.base_url}/api/jobs/{job_id}")
|
| 204 |
+
response.raise_for_status()
|
| 205 |
+
return response.json()
|
| 206 |
+
|
| 207 |
+
def health_check(self) -> Dict[str, Any]:
|
| 208 |
+
"""
|
| 209 |
+
Check API health
|
| 210 |
+
|
| 211 |
+
Returns:
|
| 212 |
+
Health status and statistics
|
| 213 |
+
|
| 214 |
+
Example:
|
| 215 |
+
>>> health = client.health_check()
|
| 216 |
+
>>> print(f"Total jobs: {health['jobs']['total']}")
|
| 217 |
+
"""
|
| 218 |
+
response = self.session.get(f"{self.base_url}/health")
|
| 219 |
+
response.raise_for_status()
|
| 220 |
+
return response.json()
|
| 221 |
+
|
| 222 |
+
def generate_and_download(
|
| 223 |
+
self,
|
| 224 |
+
prompt: str,
|
| 225 |
+
output_path: str,
|
| 226 |
+
quality: QualityLevel = QualityLevel.HIGH,
|
| 227 |
+
poll_interval: int = 5,
|
| 228 |
+
progress_callback: Optional[callable] = None
|
| 229 |
+
) -> Dict[str, Any]:
|
| 230 |
+
"""
|
| 231 |
+
Complete workflow: create, wait, and download
|
| 232 |
+
|
| 233 |
+
Args:
|
| 234 |
+
prompt: Animation prompt
|
| 235 |
+
output_path: Where to save video
|
| 236 |
+
quality: Video quality
|
| 237 |
+
poll_interval: Status check interval
|
| 238 |
+
progress_callback: Optional progress callback
|
| 239 |
+
|
| 240 |
+
Returns:
|
| 241 |
+
Final job status
|
| 242 |
+
|
| 243 |
+
Example:
|
| 244 |
+
>>> def show_progress(status):
|
| 245 |
+
... pct = status['progress']['percentage']
|
| 246 |
+
... msg = status['progress']['message']
|
| 247 |
+
... print(f"[{pct}%] {msg}")
|
| 248 |
+
>>>
|
| 249 |
+
>>> client.generate_and_download(
|
| 250 |
+
... prompt="Explain merge sort",
|
| 251 |
+
... output_path="merge_sort.mp4",
|
| 252 |
+
... progress_callback=show_progress
|
| 253 |
+
... )
|
| 254 |
+
"""
|
| 255 |
+
# Create job
|
| 256 |
+
job = self.create_video(prompt, quality)
|
| 257 |
+
job_id = job['job_id']
|
| 258 |
+
|
| 259 |
+
print(f"✓ Job created: {job_id}")
|
| 260 |
+
|
| 261 |
+
# Wait for completion
|
| 262 |
+
result = self.wait_for_completion(
|
| 263 |
+
job_id,
|
| 264 |
+
poll_interval=poll_interval,
|
| 265 |
+
callback=progress_callback
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
if result['status'] == JobStatus.COMPLETED:
|
| 269 |
+
# Download video
|
| 270 |
+
self.download_video(job_id, output_path)
|
| 271 |
+
print(f"✓ Video downloaded: {output_path}")
|
| 272 |
+
return result
|
| 273 |
+
else:
|
| 274 |
+
raise Exception(f"Job failed: {result.get('error', 'Unknown error')}")
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
# ============================================================================
|
| 278 |
+
# CLI Tool
|
| 279 |
+
# ============================================================================
|
| 280 |
+
|
| 281 |
+
def main():
|
| 282 |
+
"""Command-line interface"""
|
| 283 |
+
import argparse
|
| 284 |
+
|
| 285 |
+
parser = argparse.ArgumentParser(
|
| 286 |
+
description="Manim Video Generation API Client"
|
| 287 |
+
)
|
| 288 |
+
parser.add_argument(
|
| 289 |
+
"command",
|
| 290 |
+
choices=["create", "status", "download", "list", "generate"],
|
| 291 |
+
help="Command to execute"
|
| 292 |
+
)
|
| 293 |
+
parser.add_argument("--prompt", help="Animation prompt")
|
| 294 |
+
parser.add_argument("--job-id", help="Job ID")
|
| 295 |
+
parser.add_argument("--output", "-o", help="Output file path")
|
| 296 |
+
parser.add_argument(
|
| 297 |
+
"--quality",
|
| 298 |
+
choices=["low", "medium", "high", "ultra"],
|
| 299 |
+
default="high",
|
| 300 |
+
help="Video quality"
|
| 301 |
+
)
|
| 302 |
+
parser.add_argument(
|
| 303 |
+
"--url",
|
| 304 |
+
default="http://localhost:8000",
|
| 305 |
+
help="API base URL"
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
args = parser.parse_args()
|
| 309 |
+
client = ManimVideoClient(args.url)
|
| 310 |
+
|
| 311 |
+
if args.command == "create":
|
| 312 |
+
if not args.prompt:
|
| 313 |
+
print("Error: --prompt required")
|
| 314 |
+
return
|
| 315 |
+
|
| 316 |
+
job = client.create_video(args.prompt, QualityLevel(args.quality))
|
| 317 |
+
print(f"Job created: {job['job_id']}")
|
| 318 |
+
print(f"Status: {job['status']}")
|
| 319 |
+
|
| 320 |
+
elif args.command == "status":
|
| 321 |
+
if not args.job_id:
|
| 322 |
+
print("Error: --job-id required")
|
| 323 |
+
return
|
| 324 |
+
|
| 325 |
+
status = client.get_status(args.job_id)
|
| 326 |
+
print(f"Job ID: {status['job_id']}")
|
| 327 |
+
print(f"Status: {status['status']}")
|
| 328 |
+
print(f"Progress: {status['progress']['percentage']}%")
|
| 329 |
+
print(f"Message: {status['progress']['message']}")
|
| 330 |
+
|
| 331 |
+
elif args.command == "download":
|
| 332 |
+
if not args.job_id or not args.output:
|
| 333 |
+
print("Error: --job-id and --output required")
|
| 334 |
+
return
|
| 335 |
+
|
| 336 |
+
path = client.download_video(args.job_id, args.output)
|
| 337 |
+
print(f"Downloaded: {path}")
|
| 338 |
+
|
| 339 |
+
elif args.command == "list":
|
| 340 |
+
jobs = client.list_jobs()
|
| 341 |
+
print(f"Total jobs: {jobs['total']}")
|
| 342 |
+
for job in jobs['jobs']:
|
| 343 |
+
print(f" {job['job_id']}: {job['status']}")
|
| 344 |
+
|
| 345 |
+
elif args.command == "generate":
|
| 346 |
+
if not args.prompt or not args.output:
|
| 347 |
+
print("Error: --prompt and --output required")
|
| 348 |
+
return
|
| 349 |
+
|
| 350 |
+
def progress(status):
|
| 351 |
+
pct = status['progress']['percentage']
|
| 352 |
+
msg = status['progress']['message']
|
| 353 |
+
print(f"[{pct:3d}%] {msg}")
|
| 354 |
+
|
| 355 |
+
client.generate_and_download(
|
| 356 |
+
args.prompt,
|
| 357 |
+
args.output,
|
| 358 |
+
QualityLevel(args.quality),
|
| 359 |
+
progress_callback=progress
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
if __name__ == "__main__":
|
| 364 |
+
main()
|
api_server.py
ADDED
|
@@ -0,0 +1,811 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unified FastAPI Video Generation Server
|
| 3 |
+
Supports text prompts, PDFs, and URLs for all animation categories.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
| 7 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
+
from typing import Optional, List, Dict, Any, Literal, Union
|
| 10 |
+
from enum import Enum
|
| 11 |
+
import uuid
|
| 12 |
+
import os
|
| 13 |
+
import json
|
| 14 |
+
import subprocess
|
| 15 |
+
import shutil
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
import asyncio
|
| 19 |
+
import logging
|
| 20 |
+
import base64
|
| 21 |
+
|
| 22 |
+
# Load environment variables from .env file
|
| 23 |
+
from dotenv import load_dotenv
|
| 24 |
+
load_dotenv()
|
| 25 |
+
|
| 26 |
+
from manimator.api.animation_generation import generate_animation_response
|
| 27 |
+
from manimator.utils.code_fixer import CodeFixer
|
| 28 |
+
# from manimator.api.input_processor import process_input
|
| 29 |
+
|
| 30 |
+
# Configure logging
|
| 31 |
+
logging.basicConfig(
|
| 32 |
+
level=logging.INFO,
|
| 33 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 34 |
+
)
|
| 35 |
+
logger = logging.getLogger("api_server_unified")
|
| 36 |
+
|
| 37 |
+
# ============================================================================
|
| 38 |
+
# Configuration
|
| 39 |
+
# ============================================================================
|
| 40 |
+
|
| 41 |
+
class Config:
|
| 42 |
+
"""Application configuration"""
|
| 43 |
+
BASE_DIR = Path(__file__).parent
|
| 44 |
+
JOBS_DIR = BASE_DIR / "jobs"
|
| 45 |
+
VIDEOS_DIR = BASE_DIR / "media" / "videos"
|
| 46 |
+
MAX_JOB_AGE_DAYS = 7
|
| 47 |
+
|
| 48 |
+
# Ensure directories exist
|
| 49 |
+
JOBS_DIR.mkdir(exist_ok=True)
|
| 50 |
+
VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ============================================================================
|
| 54 |
+
# Models
|
| 55 |
+
# ============================================================================
|
| 56 |
+
|
| 57 |
+
class JobStatus(str, Enum):
|
| 58 |
+
"""Job status enumeration"""
|
| 59 |
+
PENDING = "pending"
|
| 60 |
+
GENERATING_CODE = "generating_code"
|
| 61 |
+
RENDERING = "rendering"
|
| 62 |
+
COMPLETED = "completed"
|
| 63 |
+
FAILED = "failed"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class QualityLevel(str, Enum):
|
| 67 |
+
"""Video quality levels"""
|
| 68 |
+
LOW = "low" # 480p15
|
| 69 |
+
MEDIUM = "medium" # 720p30
|
| 70 |
+
HIGH = "high" # 1080p60
|
| 71 |
+
ULTRA = "ultra" # 4K60
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class AnimationCategory(str, Enum):
|
| 75 |
+
"""Animation categories"""
|
| 76 |
+
TECH_SYSTEM = "tech_system"
|
| 77 |
+
PRODUCT_STARTUP = "product_startup"
|
| 78 |
+
MATHEMATICAL = "mathematical"
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
QUALITY_FLAGS = {
|
| 82 |
+
QualityLevel.LOW: "-pql",
|
| 83 |
+
QualityLevel.MEDIUM: "-pqm",
|
| 84 |
+
QualityLevel.HIGH: "-pqh",
|
| 85 |
+
QualityLevel.ULTRA: "-pqk",
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class VideoRequest(BaseModel):
|
| 90 |
+
"""Request model for video generation"""
|
| 91 |
+
input_type: Literal["text", "pdf", "url"] = Field(..., description="Type of input")
|
| 92 |
+
input_data: Union[str, bytes] = Field(..., description="Input data (text prompt, base64 PDF, or URL)")
|
| 93 |
+
quality: QualityLevel = Field(default=QualityLevel.HIGH, description="Video quality level")
|
| 94 |
+
category: AnimationCategory = Field(default=AnimationCategory.MATHEMATICAL, description="Animation category")
|
| 95 |
+
scene_name: Optional[str] = Field(default=None, description="Custom scene class name")
|
| 96 |
+
|
| 97 |
+
class Config:
|
| 98 |
+
json_schema_extra = {
|
| 99 |
+
"example": {
|
| 100 |
+
"input_type": "text",
|
| 101 |
+
"input_data": "Explain how a distributed system handles requests",
|
| 102 |
+
"quality": "high",
|
| 103 |
+
"category": "tech_system"
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class JobResponse(BaseModel):
|
| 109 |
+
"""Response model for job creation"""
|
| 110 |
+
job_id: str
|
| 111 |
+
status: JobStatus
|
| 112 |
+
message: str
|
| 113 |
+
created_at: str
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class JobStatusResponse(BaseModel):
|
| 117 |
+
"""Response model for job status"""
|
| 118 |
+
job_id: str
|
| 119 |
+
status: JobStatus
|
| 120 |
+
category: str
|
| 121 |
+
progress: Dict[str, Any]
|
| 122 |
+
created_at: str
|
| 123 |
+
updated_at: str
|
| 124 |
+
error: Optional[str] = None
|
| 125 |
+
video_url: Optional[str] = None
|
| 126 |
+
duration: Optional[float] = None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ============================================================================
|
| 130 |
+
# Job Manager
|
| 131 |
+
# ============================================================================
|
| 132 |
+
|
| 133 |
+
class JobManager:
|
| 134 |
+
"""Manages video generation jobs"""
|
| 135 |
+
|
| 136 |
+
def __init__(self):
|
| 137 |
+
self.jobs: Dict[str, Dict] = {}
|
| 138 |
+
self._load_existing_jobs()
|
| 139 |
+
self._cleanup_task = None
|
| 140 |
+
|
| 141 |
+
def _load_existing_jobs(self):
|
| 142 |
+
"""Load existing jobs from disk"""
|
| 143 |
+
for job_file in Config.JOBS_DIR.glob("*.json"):
|
| 144 |
+
try:
|
| 145 |
+
with open(job_file) as f:
|
| 146 |
+
job_data = json.load(f)
|
| 147 |
+
self.jobs[job_data["job_id"]] = job_data
|
| 148 |
+
except Exception as e:
|
| 149 |
+
logger.error(f"Error loading job {job_file}: {e}")
|
| 150 |
+
|
| 151 |
+
def create_job(
|
| 152 |
+
self,
|
| 153 |
+
input_type: str,
|
| 154 |
+
input_data: Union[str, bytes],
|
| 155 |
+
quality: QualityLevel,
|
| 156 |
+
category: AnimationCategory,
|
| 157 |
+
scene_name: Optional[str] = None
|
| 158 |
+
) -> str:
|
| 159 |
+
"""Create a new job"""
|
| 160 |
+
job_id = str(uuid.uuid4())
|
| 161 |
+
|
| 162 |
+
if not scene_name:
|
| 163 |
+
scene_name = f"Scene_{uuid.uuid4().hex[:8]}"
|
| 164 |
+
|
| 165 |
+
job_data = {
|
| 166 |
+
"job_id": job_id,
|
| 167 |
+
"status": JobStatus.PENDING,
|
| 168 |
+
"input_type": input_type,
|
| 169 |
+
"quality": quality,
|
| 170 |
+
"category": category.value,
|
| 171 |
+
"scene_name": scene_name,
|
| 172 |
+
"created_at": datetime.now().isoformat(),
|
| 173 |
+
"updated_at": datetime.now().isoformat(),
|
| 174 |
+
"progress": {
|
| 175 |
+
"stage": "queued",
|
| 176 |
+
"percentage": 0,
|
| 177 |
+
"message": "Job queued for processing"
|
| 178 |
+
},
|
| 179 |
+
"error": None,
|
| 180 |
+
"video_path": None,
|
| 181 |
+
"code_path": None,
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
# Store input data (truncate if too long for display)
|
| 185 |
+
if input_type == "text":
|
| 186 |
+
job_data["input_preview"] = str(input_data)[:200] + "..." if len(str(input_data)) > 200 else str(input_data)
|
| 187 |
+
elif input_type == "url":
|
| 188 |
+
job_data["input_preview"] = str(input_data)
|
| 189 |
+
else:
|
| 190 |
+
job_data["input_preview"] = "[PDF file]"
|
| 191 |
+
|
| 192 |
+
self.jobs[job_id] = job_data
|
| 193 |
+
self._save_job(job_id)
|
| 194 |
+
return job_id
|
| 195 |
+
|
| 196 |
+
def update_job(self, job_id: str, **kwargs):
|
| 197 |
+
"""Update job data"""
|
| 198 |
+
if job_id not in self.jobs:
|
| 199 |
+
return
|
| 200 |
+
|
| 201 |
+
self.jobs[job_id].update(kwargs)
|
| 202 |
+
self.jobs[job_id]["updated_at"] = datetime.now().isoformat()
|
| 203 |
+
self._save_job(job_id)
|
| 204 |
+
|
| 205 |
+
def get_job(self, job_id: str) -> Optional[Dict]:
|
| 206 |
+
"""Get job by ID"""
|
| 207 |
+
return self.jobs.get(job_id)
|
| 208 |
+
|
| 209 |
+
def _save_job(self, job_id: str):
|
| 210 |
+
"""Save job to disk"""
|
| 211 |
+
# Ensure directory exists
|
| 212 |
+
Config.JOBS_DIR.mkdir(parents=True, exist_ok=True)
|
| 213 |
+
job_file = Config.JOBS_DIR / f"{job_id}.json"
|
| 214 |
+
with open(job_file, 'w') as f:
|
| 215 |
+
json.dump(self.jobs[job_id], f, indent=2)
|
| 216 |
+
|
| 217 |
+
def list_jobs(self, limit: int = 50) -> List[Dict]:
|
| 218 |
+
"""List recent jobs"""
|
| 219 |
+
jobs = sorted(
|
| 220 |
+
self.jobs.values(),
|
| 221 |
+
key=lambda x: x["created_at"],
|
| 222 |
+
reverse=True
|
| 223 |
+
)
|
| 224 |
+
return jobs[:limit]
|
| 225 |
+
|
| 226 |
+
def start_periodic_cleanup(self):
|
| 227 |
+
"""Start periodic cleanup task (call this after event loop is running)"""
|
| 228 |
+
if self._cleanup_task is None:
|
| 229 |
+
self._cleanup_task = asyncio.create_task(self._periodic_cleanup())
|
| 230 |
+
|
| 231 |
+
async def _periodic_cleanup(self):
|
| 232 |
+
"""Periodic cleanup of old jobs and voiceover cache"""
|
| 233 |
+
import time
|
| 234 |
+
# Wait a bit before starting cleanup
|
| 235 |
+
await asyncio.sleep(60)
|
| 236 |
+
while True:
|
| 237 |
+
try:
|
| 238 |
+
await asyncio.sleep(3600) # Run every hour
|
| 239 |
+
await self._cleanup_old_jobs()
|
| 240 |
+
await self._cleanup_old_voiceovers()
|
| 241 |
+
except Exception as e:
|
| 242 |
+
logger.warning(f"Periodic cleanup error: {e}")
|
| 243 |
+
|
| 244 |
+
async def _cleanup_old_jobs(self):
|
| 245 |
+
"""Remove old job files and their associated data"""
|
| 246 |
+
cutoff_date = datetime.now().timestamp() - (Config.MAX_JOB_AGE_DAYS * 24 * 3600)
|
| 247 |
+
removed_count = 0
|
| 248 |
+
|
| 249 |
+
for job_id, job_data in list(self.jobs.items()):
|
| 250 |
+
try:
|
| 251 |
+
job_time = datetime.fromisoformat(job_data["created_at"]).timestamp()
|
| 252 |
+
if job_time < cutoff_date:
|
| 253 |
+
# Remove job file
|
| 254 |
+
job_file = Config.JOBS_DIR / f"{job_id}.json"
|
| 255 |
+
if job_file.exists():
|
| 256 |
+
job_file.unlink()
|
| 257 |
+
|
| 258 |
+
# Remove from memory
|
| 259 |
+
del self.jobs[job_id]
|
| 260 |
+
removed_count += 1
|
| 261 |
+
except Exception as e:
|
| 262 |
+
logger.warning(f"Error cleaning up job {job_id[:8]}: {e}")
|
| 263 |
+
|
| 264 |
+
if removed_count > 0:
|
| 265 |
+
logger.info(f"🧹 Cleaned up {removed_count} old jobs")
|
| 266 |
+
|
| 267 |
+
async def _cleanup_old_voiceovers(self):
|
| 268 |
+
"""Clean up old voiceover cache files (keep recent ones)"""
|
| 269 |
+
import time
|
| 270 |
+
try:
|
| 271 |
+
# Clean both ElevenLabs and gTTS cache
|
| 272 |
+
for service_dir in ["elevenlabs", "gtts"]:
|
| 273 |
+
voiceover_dir = Config.BASE_DIR / "media" / "voiceover" / service_dir
|
| 274 |
+
if not voiceover_dir.exists():
|
| 275 |
+
continue
|
| 276 |
+
|
| 277 |
+
# Keep voiceover files from last 7 days
|
| 278 |
+
cutoff_time = time.time() - (7 * 24 * 3600)
|
| 279 |
+
removed_count = 0
|
| 280 |
+
|
| 281 |
+
for voice_file in voiceover_dir.glob("*.mp3"):
|
| 282 |
+
try:
|
| 283 |
+
if voice_file.stat().st_mtime < cutoff_time:
|
| 284 |
+
voice_file.unlink()
|
| 285 |
+
removed_count += 1
|
| 286 |
+
except Exception:
|
| 287 |
+
pass
|
| 288 |
+
|
| 289 |
+
if removed_count > 0:
|
| 290 |
+
logger.info(f"🧹 Cleaned up {removed_count} old {service_dir} voiceover files")
|
| 291 |
+
except Exception as e:
|
| 292 |
+
logger.warning(f"Error cleaning up voiceovers: {e}")
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
# ============================================================================
|
| 296 |
+
# Video Generator
|
| 297 |
+
# ============================================================================
|
| 298 |
+
|
| 299 |
+
class VideoGenerator:
|
| 300 |
+
"""Handles video generation workflow"""
|
| 301 |
+
|
| 302 |
+
def __init__(self, job_manager: JobManager):
|
| 303 |
+
self.job_manager = job_manager
|
| 304 |
+
|
| 305 |
+
async def generate_video(self, job_id: str):
|
| 306 |
+
"""Generate video for a job"""
|
| 307 |
+
job = self.job_manager.get_job(job_id)
|
| 308 |
+
if not job:
|
| 309 |
+
return
|
| 310 |
+
|
| 311 |
+
logger.info(f"🎬 Starting video generation for job {job_id[:8]}...")
|
| 312 |
+
|
| 313 |
+
try:
|
| 314 |
+
# Stage 2: Generate Manim code
|
| 315 |
+
logger.info(f"🤖 Generating Manim code for job {job_id[:8]}...")
|
| 316 |
+
self.job_manager.update_job(
|
| 317 |
+
job_id,
|
| 318 |
+
status=JobStatus.GENERATING_CODE,
|
| 319 |
+
progress={
|
| 320 |
+
"stage": "generating_code",
|
| 321 |
+
"percentage": 30,
|
| 322 |
+
"message": "Generating Manim code using AI..."
|
| 323 |
+
}
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
# Pass raw input to generation function which now handles processing
|
| 327 |
+
code = generate_animation_response(
|
| 328 |
+
input_data=job.get("input_data", ""),
|
| 329 |
+
input_type=job["input_type"],
|
| 330 |
+
category=job["category"]
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
logger.info(f"✅ Code generation complete for job {job_id[:8]}...")
|
| 334 |
+
|
| 335 |
+
# Save code
|
| 336 |
+
code_file = Config.BASE_DIR / f"scene_{job_id}.py"
|
| 337 |
+
with open(code_file, 'w') as f:
|
| 338 |
+
f.write(code)
|
| 339 |
+
|
| 340 |
+
logger.info(f"💾 Code saved to {code_file.name}")
|
| 341 |
+
|
| 342 |
+
# Ensure voiceover directories exist
|
| 343 |
+
voiceover_dir = Config.BASE_DIR / "media" / "voiceover"
|
| 344 |
+
(voiceover_dir / "elevenlabs").mkdir(parents=True, exist_ok=True)
|
| 345 |
+
(voiceover_dir / "gtts").mkdir(parents=True, exist_ok=True)
|
| 346 |
+
|
| 347 |
+
self.job_manager.update_job(
|
| 348 |
+
job_id,
|
| 349 |
+
code_path=str(code_file),
|
| 350 |
+
progress={
|
| 351 |
+
"stage": "code_generated",
|
| 352 |
+
"percentage": 50,
|
| 353 |
+
"message": "Code generated successfully"
|
| 354 |
+
}
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
# Stage 3: Render video with Self-Healing Loop
|
| 358 |
+
fixer = CodeFixer()
|
| 359 |
+
max_retries = 3
|
| 360 |
+
video_path = None
|
| 361 |
+
|
| 362 |
+
for attempt in range(max_retries):
|
| 363 |
+
try:
|
| 364 |
+
logger.info(f"🎥 Starting Manim rendering for job {job_id[:8]} (Attempt {attempt+1}/{max_retries})...")
|
| 365 |
+
|
| 366 |
+
self.job_manager.update_job(
|
| 367 |
+
job_id,
|
| 368 |
+
status=JobStatus.RENDERING,
|
| 369 |
+
progress={
|
| 370 |
+
"stage": "rendering",
|
| 371 |
+
"percentage": 60 + (attempt * 5),
|
| 372 |
+
"message": f"Rendering video (Attempt {attempt+1})..."
|
| 373 |
+
}
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
video_path = await self._render_video(
|
| 377 |
+
code_file,
|
| 378 |
+
job["scene_name"],
|
| 379 |
+
QualityLevel(job["quality"])
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
# If successful, break the loop
|
| 383 |
+
break
|
| 384 |
+
|
| 385 |
+
except Exception as e:
|
| 386 |
+
error_msg = str(e)
|
| 387 |
+
logger.warning(f"⚠️ Rendering failed on attempt {attempt+1}: {error_msg}")
|
| 388 |
+
|
| 389 |
+
if attempt < max_retries - 1:
|
| 390 |
+
# Try to fix the code
|
| 391 |
+
logger.info(f"🔧 Attempting to auto-fix code for job {job_id[:8]}...")
|
| 392 |
+
|
| 393 |
+
self.job_manager.update_job(
|
| 394 |
+
job_id,
|
| 395 |
+
progress={
|
| 396 |
+
"stage": "fixing_code",
|
| 397 |
+
"percentage": 60 + (attempt * 5),
|
| 398 |
+
"message": f"Fixing rendering error..."
|
| 399 |
+
}
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
# Read current code
|
| 403 |
+
with open(code_file, 'r') as f:
|
| 404 |
+
current_code = f.read()
|
| 405 |
+
|
| 406 |
+
# Fix code using LLM
|
| 407 |
+
fixed_code = fixer.fix_runtime_error(current_code, error_msg)
|
| 408 |
+
|
| 409 |
+
# Save fixed code
|
| 410 |
+
with open(code_file, 'w') as f:
|
| 411 |
+
f.write(fixed_code)
|
| 412 |
+
|
| 413 |
+
logger.info(f"💾 Saved fixed code for job {job_id[:8]}")
|
| 414 |
+
else:
|
| 415 |
+
# Out of retries, re-raise exception
|
| 416 |
+
raise e
|
| 417 |
+
|
| 418 |
+
logger.info(f"✅ Video rendering complete for job {job_id[:8]}...")
|
| 419 |
+
|
| 420 |
+
# Stage 4: Complete
|
| 421 |
+
self.job_manager.update_job(
|
| 422 |
+
job_id,
|
| 423 |
+
status=JobStatus.COMPLETED,
|
| 424 |
+
video_path=str(video_path),
|
| 425 |
+
progress={
|
| 426 |
+
"stage": "completed",
|
| 427 |
+
"percentage": 100,
|
| 428 |
+
"message": "Video generation completed successfully!"
|
| 429 |
+
}
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
# Stage 5: Cleanup intermediate files in background
|
| 433 |
+
asyncio.create_task(self._cleanup_intermediate_files(job_id, code_file, video_path))
|
| 434 |
+
|
| 435 |
+
except Exception as e:
|
| 436 |
+
logger.error(f"❌ Error generating video for job {job_id[:8]}: {str(e)}")
|
| 437 |
+
self.job_manager.update_job(
|
| 438 |
+
job_id,
|
| 439 |
+
status=JobStatus.FAILED,
|
| 440 |
+
error=str(e),
|
| 441 |
+
progress={
|
| 442 |
+
"stage": "failed",
|
| 443 |
+
"percentage": 0,
|
| 444 |
+
"message": f"Error: {str(e)}"
|
| 445 |
+
}
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
async def _render_video(self, code_file: Path, scene_name: str, quality: QualityLevel) -> Path:
|
| 449 |
+
"""Render Manim scene to video"""
|
| 450 |
+
quality_flag = QUALITY_FLAGS[quality]
|
| 451 |
+
|
| 452 |
+
# Ensure ALL media directories exist before rendering
|
| 453 |
+
media_dir = Config.BASE_DIR / "media"
|
| 454 |
+
voiceover_dir = media_dir / "voiceover" / "elevenlabs"
|
| 455 |
+
voiceover_dir.mkdir(parents=True, exist_ok=True)
|
| 456 |
+
|
| 457 |
+
# Also create gTTS cache directory in case of fallback
|
| 458 |
+
gtts_dir = media_dir / "voiceover" / "gtts"
|
| 459 |
+
gtts_dir.mkdir(parents=True, exist_ok=True)
|
| 460 |
+
|
| 461 |
+
# Create videos directory structure
|
| 462 |
+
Config.VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
|
| 463 |
+
|
| 464 |
+
cmd = [
|
| 465 |
+
"manim",
|
| 466 |
+
quality_flag,
|
| 467 |
+
"--media_dir",
|
| 468 |
+
str(Config.BASE_DIR / "media"),
|
| 469 |
+
str(code_file),
|
| 470 |
+
scene_name,
|
| 471 |
+
]
|
| 472 |
+
|
| 473 |
+
# Set working directory to base dir to ensure relative paths work
|
| 474 |
+
env = os.environ.copy()
|
| 475 |
+
# Set MEDIA_DIR as absolute path to help voiceover services find cache directory
|
| 476 |
+
env["MEDIA_DIR"] = str(media_dir.resolve())
|
| 477 |
+
|
| 478 |
+
process = await asyncio.create_subprocess_exec(
|
| 479 |
+
*cmd,
|
| 480 |
+
stdout=asyncio.subprocess.PIPE,
|
| 481 |
+
stderr=asyncio.subprocess.PIPE,
|
| 482 |
+
cwd=str(Config.BASE_DIR),
|
| 483 |
+
env=env
|
| 484 |
+
)
|
| 485 |
+
|
| 486 |
+
stdout, stderr = await process.communicate()
|
| 487 |
+
|
| 488 |
+
if process.returncode != 0:
|
| 489 |
+
error_output = stderr.decode()[-500:] if stderr else "Unknown error"
|
| 490 |
+
raise Exception(f"Manim rendering failed: {error_output}")
|
| 491 |
+
|
| 492 |
+
# Find generated video
|
| 493 |
+
quality_dir = {
|
| 494 |
+
QualityLevel.LOW: "480p15",
|
| 495 |
+
QualityLevel.MEDIUM: "720p30",
|
| 496 |
+
QualityLevel.HIGH: "1080p60",
|
| 497 |
+
QualityLevel.ULTRA: "2160p60"
|
| 498 |
+
}[quality]
|
| 499 |
+
|
| 500 |
+
video_dir = Config.VIDEOS_DIR / code_file.stem / quality_dir
|
| 501 |
+
video_files = list(video_dir.glob("*.mp4"))
|
| 502 |
+
|
| 503 |
+
if not video_files:
|
| 504 |
+
raise Exception(f"No video file found in {video_dir}")
|
| 505 |
+
|
| 506 |
+
video_path = video_files[0]
|
| 507 |
+
logger.info(f"📹 Found video: {video_path.name}")
|
| 508 |
+
|
| 509 |
+
return video_path
|
| 510 |
+
|
| 511 |
+
async def _cleanup_intermediate_files(self, job_id: str, code_file: Path, final_video_path: Path):
|
| 512 |
+
"""
|
| 513 |
+
Clean up intermediate files in background after video is successfully created.
|
| 514 |
+
Removes: scene code files, partial videos, voiceover files (keeps final video).
|
| 515 |
+
|
| 516 |
+
Args:
|
| 517 |
+
job_id: Job ID
|
| 518 |
+
code_file: Path to generated scene code file
|
| 519 |
+
final_video_path: Path to final rendered video (keep this)
|
| 520 |
+
"""
|
| 521 |
+
try:
|
| 522 |
+
logger.info(f"🧹 Starting cleanup for job {job_id[:8]}...")
|
| 523 |
+
|
| 524 |
+
# 1. Remove scene code file
|
| 525 |
+
if code_file.exists():
|
| 526 |
+
try:
|
| 527 |
+
code_file.unlink()
|
| 528 |
+
logger.info(f" ✅ Removed scene code: {code_file.name}")
|
| 529 |
+
except Exception as e:
|
| 530 |
+
logger.warning(f" ⚠️ Could not remove scene code: {e}")
|
| 531 |
+
|
| 532 |
+
# 2. Remove partial video files (keep only final video)
|
| 533 |
+
# Find all video files in the scene directory
|
| 534 |
+
scene_video_dir = Config.VIDEOS_DIR / code_file.stem
|
| 535 |
+
if scene_video_dir.exists():
|
| 536 |
+
# Keep only the final video, remove all other quality versions and partial files
|
| 537 |
+
final_video_name = final_video_path.name
|
| 538 |
+
for quality_dir in scene_video_dir.iterdir():
|
| 539 |
+
if quality_dir.is_dir():
|
| 540 |
+
for video_file in quality_dir.glob("*.mp4"):
|
| 541 |
+
# Keep only the final video file
|
| 542 |
+
if video_file.name != final_video_name:
|
| 543 |
+
try:
|
| 544 |
+
video_file.unlink()
|
| 545 |
+
logger.info(f" ✅ Removed partial video: {video_file.name}")
|
| 546 |
+
except Exception as e:
|
| 547 |
+
logger.warning(f" ⚠️ Could not remove partial video: {e}")
|
| 548 |
+
|
| 549 |
+
# Remove partial movie files directory if exists
|
| 550 |
+
partial_dir = quality_dir / "partial_movie_files"
|
| 551 |
+
if partial_dir.exists():
|
| 552 |
+
try:
|
| 553 |
+
shutil.rmtree(partial_dir)
|
| 554 |
+
logger.info(f" ✅ Removed partial movie files directory")
|
| 555 |
+
except Exception as e:
|
| 556 |
+
logger.warning(f" ⚠️ Could not remove partial files: {e}")
|
| 557 |
+
|
| 558 |
+
# 3. Remove voiceover files for this job (they're cached, so safe to remove)
|
| 559 |
+
# Voiceover files are cached by text hash, so we can't easily identify job-specific ones
|
| 560 |
+
# Instead, we'll clean up old voiceover files periodically (not per-job)
|
| 561 |
+
# This is handled by a separate cleanup task
|
| 562 |
+
|
| 563 |
+
# 4. Remove any temporary files in media directory for this scene
|
| 564 |
+
scene_media_dir = Config.BASE_DIR / "media" / "videos" / code_file.stem
|
| 565 |
+
if scene_media_dir.exists():
|
| 566 |
+
# Remove text SVGs, images, etc. but keep the final video directory structure
|
| 567 |
+
for item in scene_media_dir.iterdir():
|
| 568 |
+
if item.is_file() and item.suffix in ['.svg', '.png', '.jpg', '.txt', '.srt']:
|
| 569 |
+
try:
|
| 570 |
+
item.unlink()
|
| 571 |
+
logger.info(f" ✅ Removed temporary file: {item.name}")
|
| 572 |
+
except Exception as e:
|
| 573 |
+
logger.warning(f" ⚠️ Could not remove temp file: {e}")
|
| 574 |
+
|
| 575 |
+
logger.info(f"✅ Cleanup completed for job {job_id[:8]}")
|
| 576 |
+
|
| 577 |
+
except Exception as e:
|
| 578 |
+
# Don't fail the job if cleanup fails
|
| 579 |
+
logger.warning(f"⚠️ Cleanup error for job {job_id[:8]}: {e}")
|
| 580 |
+
|
| 581 |
+
|
| 582 |
+
# ============================================================================
|
| 583 |
+
# FastAPI Application
|
| 584 |
+
# ============================================================================
|
| 585 |
+
|
| 586 |
+
app = FastAPI(
|
| 587 |
+
title="Unified Manim Video Generation API",
|
| 588 |
+
description="Generate educational animation videos from text, PDFs, or URLs",
|
| 589 |
+
version="2.0.0",
|
| 590 |
+
docs_url="/docs",
|
| 591 |
+
redoc_url="/redoc"
|
| 592 |
+
)
|
| 593 |
+
|
| 594 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 595 |
+
|
| 596 |
+
app.add_middleware(
|
| 597 |
+
CORSMiddleware,
|
| 598 |
+
allow_origins=["http://localhost:3000", "http://localhost:8000"],
|
| 599 |
+
allow_credentials=True,
|
| 600 |
+
allow_methods=["*"],
|
| 601 |
+
allow_headers=["*"],
|
| 602 |
+
)
|
| 603 |
+
|
| 604 |
+
# Security
|
| 605 |
+
from fastapi import Security, HTTPException, status
|
| 606 |
+
from fastapi.security.api_key import APIKeyHeader
|
| 607 |
+
|
| 608 |
+
API_KEY_NAME = "X-API-KEY"
|
| 609 |
+
API_KEY = os.getenv("INTERNAL_API_KEY")
|
| 610 |
+
|
| 611 |
+
if not API_KEY:
|
| 612 |
+
logger.warning("⚠️ INTERNAL_API_KEY is not set. API is insecure or will fail auth checks.")
|
| 613 |
+
|
| 614 |
+
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=True)
|
| 615 |
+
|
| 616 |
+
async def get_api_key(api_key_header: str = Security(api_key_header)):
|
| 617 |
+
if not API_KEY:
|
| 618 |
+
raise HTTPException(
|
| 619 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 620 |
+
detail="Server security configuration error"
|
| 621 |
+
)
|
| 622 |
+
|
| 623 |
+
if api_key_header != API_KEY:
|
| 624 |
+
raise HTTPException(
|
| 625 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 626 |
+
detail="Could not validate credentials"
|
| 627 |
+
)
|
| 628 |
+
return api_key_header
|
| 629 |
+
|
| 630 |
+
# Initialize managers
|
| 631 |
+
job_manager = JobManager()
|
| 632 |
+
video_generator = VideoGenerator(job_manager)
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
@app.on_event("startup")
|
| 636 |
+
async def startup_event():
|
| 637 |
+
"""Startup event - initialize background tasks"""
|
| 638 |
+
job_manager.start_periodic_cleanup()
|
| 639 |
+
logger.info("✅ Background cleanup tasks started")
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
# ============================================================================
|
| 643 |
+
# Endpoints
|
| 644 |
+
# ============================================================================
|
| 645 |
+
|
| 646 |
+
@app.get("/")
|
| 647 |
+
async def root():
|
| 648 |
+
"""API root endpoint"""
|
| 649 |
+
return {
|
| 650 |
+
"name": "Unified Manim Video Generation API",
|
| 651 |
+
"version": "2.0.0",
|
| 652 |
+
"description": "Supports text prompts, PDFs, and URLs",
|
| 653 |
+
"categories": ["tech_system", "product_startup", "mathematical"],
|
| 654 |
+
"input_types": ["text", "pdf", "url"],
|
| 655 |
+
"endpoints": {
|
| 656 |
+
"docs": "/docs",
|
| 657 |
+
"create_video": "POST /api/videos",
|
| 658 |
+
"get_status": "GET /api/jobs/{job_id}",
|
| 659 |
+
"download_video": "GET /api/videos/{job_id}",
|
| 660 |
+
"list_jobs": "GET /api/jobs"
|
| 661 |
+
}
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
|
| 665 |
+
@app.post("/api/videos", response_model=JobResponse)
|
| 666 |
+
async def create_video(request: VideoRequest, background_tasks: BackgroundTasks):
|
| 667 |
+
"""
|
| 668 |
+
Create a new video generation job
|
| 669 |
+
|
| 670 |
+
Supports three input types:
|
| 671 |
+
- text: Plain text prompt
|
| 672 |
+
- pdf: Base64 encoded PDF file
|
| 673 |
+
- url: URL to scrape content from
|
| 674 |
+
"""
|
| 675 |
+
# Store input data in job
|
| 676 |
+
input_data = request.input_data
|
| 677 |
+
|
| 678 |
+
# Create job
|
| 679 |
+
job_id = job_manager.create_job(
|
| 680 |
+
input_type=request.input_type,
|
| 681 |
+
input_data=input_data,
|
| 682 |
+
quality=request.quality,
|
| 683 |
+
category=request.category,
|
| 684 |
+
scene_name=request.scene_name
|
| 685 |
+
)
|
| 686 |
+
|
| 687 |
+
# Store full input data for processing
|
| 688 |
+
job_manager.jobs[job_id]["input_data"] = input_data
|
| 689 |
+
|
| 690 |
+
logger.info(f"📝 New job created: {job_id} (type: {request.input_type}, category: {request.category})")
|
| 691 |
+
|
| 692 |
+
# Start generation in background
|
| 693 |
+
background_tasks.add_task(video_generator.generate_video, job_id)
|
| 694 |
+
|
| 695 |
+
return JobResponse(
|
| 696 |
+
job_id=job_id,
|
| 697 |
+
status=JobStatus.PENDING,
|
| 698 |
+
message="Job created successfully. Video generation started.",
|
| 699 |
+
created_at=datetime.now().isoformat()
|
| 700 |
+
)
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
@app.get("/api/jobs/{job_id}", response_model=JobStatusResponse)
|
| 704 |
+
async def get_job_status(job_id: str):
|
| 705 |
+
"""Get the status of a video generation job"""
|
| 706 |
+
job = job_manager.get_job(job_id)
|
| 707 |
+
|
| 708 |
+
if not job:
|
| 709 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 710 |
+
|
| 711 |
+
video_url = None
|
| 712 |
+
duration = None
|
| 713 |
+
|
| 714 |
+
if job["status"] == JobStatus.COMPLETED and job.get("video_path"):
|
| 715 |
+
video_url = f"/api/videos/{job_id}"
|
| 716 |
+
|
| 717 |
+
# Get video duration if available
|
| 718 |
+
try:
|
| 719 |
+
video_path = Path(job["video_path"])
|
| 720 |
+
if video_path.exists():
|
| 721 |
+
result = subprocess.run(
|
| 722 |
+
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
| 723 |
+
"-of", "default=noprint_wrappers=1:nokey=1", str(video_path)],
|
| 724 |
+
capture_output=True,
|
| 725 |
+
text=True
|
| 726 |
+
)
|
| 727 |
+
duration = float(result.stdout.strip())
|
| 728 |
+
except:
|
| 729 |
+
pass
|
| 730 |
+
|
| 731 |
+
return JobStatusResponse(
|
| 732 |
+
job_id=job_id,
|
| 733 |
+
status=job["status"],
|
| 734 |
+
category=job["category"],
|
| 735 |
+
progress=job["progress"],
|
| 736 |
+
created_at=job["created_at"],
|
| 737 |
+
updated_at=job["updated_at"],
|
| 738 |
+
error=job.get("error"),
|
| 739 |
+
video_url=video_url,
|
| 740 |
+
duration=duration
|
| 741 |
+
)
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
@app.get("/api/videos/{job_id}")
|
| 745 |
+
async def download_video(job_id: str):
|
| 746 |
+
"""Download the generated video file"""
|
| 747 |
+
job = job_manager.get_job(job_id)
|
| 748 |
+
|
| 749 |
+
if not job:
|
| 750 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 751 |
+
|
| 752 |
+
if job["status"] != JobStatus.COMPLETED:
|
| 753 |
+
raise HTTPException(
|
| 754 |
+
status_code=400,
|
| 755 |
+
detail=f"Video not ready. Status: {job['status']}"
|
| 756 |
+
)
|
| 757 |
+
|
| 758 |
+
video_path = Path(job["video_path"])
|
| 759 |
+
if not video_path.exists():
|
| 760 |
+
raise HTTPException(status_code=404, detail="Video file not found")
|
| 761 |
+
|
| 762 |
+
return FileResponse(
|
| 763 |
+
video_path,
|
| 764 |
+
media_type="video/mp4",
|
| 765 |
+
filename=f"animation_{job_id[:8]}.mp4"
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
@app.get("/api/jobs")
|
| 770 |
+
async def list_jobs(limit: int = 50):
|
| 771 |
+
"""List recent jobs"""
|
| 772 |
+
jobs = job_manager.list_jobs(limit)
|
| 773 |
+
return {"jobs": jobs, "total": len(jobs)}
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
@app.get("/health")
|
| 777 |
+
async def health_check():
|
| 778 |
+
"""Health check endpoint"""
|
| 779 |
+
return {
|
| 780 |
+
"status": "healthy",
|
| 781 |
+
"version": "2.0.0",
|
| 782 |
+
"jobs": {
|
| 783 |
+
"total": len(job_manager.jobs),
|
| 784 |
+
"pending": sum(1 for j in job_manager.jobs.values() if j["status"] == JobStatus.PENDING),
|
| 785 |
+
"processing": sum(1 for j in job_manager.jobs.values() if j["status"] in [JobStatus.GENERATING_CODE, JobStatus.RENDERING]),
|
| 786 |
+
"completed": sum(1 for j in job_manager.jobs.values() if j["status"] == JobStatus.COMPLETED),
|
| 787 |
+
"failed": sum(1 for j in job_manager.jobs.values() if j["status"] == JobStatus.FAILED)
|
| 788 |
+
}
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
# ============================================================================
|
| 793 |
+
# Main
|
| 794 |
+
# ============================================================================
|
| 795 |
+
|
| 796 |
+
if __name__ == "__main__":
|
| 797 |
+
import uvicorn
|
| 798 |
+
|
| 799 |
+
print("🚀 Starting Unified Manim Video Generation API Server...")
|
| 800 |
+
print("📚 API Documentation: http://localhost:8000/docs")
|
| 801 |
+
print("🔍 ReDoc Documentation: http://localhost:8000/redoc")
|
| 802 |
+
print("✨ Supports: Text, PDF, and URL inputs")
|
| 803 |
+
print("🎨 Categories: Tech System, Product Startup, Mathematical")
|
| 804 |
+
|
| 805 |
+
uvicorn.run(
|
| 806 |
+
"api_server:app",
|
| 807 |
+
host="0.0.0.0",
|
| 808 |
+
port=8003,
|
| 809 |
+
reload=False,
|
| 810 |
+
log_level="info"
|
| 811 |
+
)
|
assets/manimator.png
ADDED
|
docs/IMPLEMENTATION_COMPLETE.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Implementation Complete ✅
|
| 2 |
+
|
| 3 |
+
All phases of the enhanced video generation system have been implemented.
|
| 4 |
+
|
| 5 |
+
## What's New
|
| 6 |
+
|
| 7 |
+
### 1. **Unified Input Support**
|
| 8 |
+
- ✅ Text prompts
|
| 9 |
+
- ✅ PDF files (base64 encoded)
|
| 10 |
+
- ✅ URL scraping (blogs, documentation, etc.)
|
| 11 |
+
|
| 12 |
+
### 2. **Category-Specific Visual Themes**
|
| 13 |
+
- ✅ **Tech System**: Dark blue background (#0a0e27), professional diagrams
|
| 14 |
+
- ✅ **Product Startup**: White background (#ffffff), modern gradients
|
| 15 |
+
- ✅ **Research/Mathematical**: Dark background (#1e1e1e), educational style
|
| 16 |
+
|
| 17 |
+
### 3. **Robust Code Validation & Auto-Fix**
|
| 18 |
+
- ✅ Pre-render syntax validation
|
| 19 |
+
- ✅ Import checking
|
| 20 |
+
- ✅ Structure validation
|
| 21 |
+
- ✅ Auto-fix common issues
|
| 22 |
+
- ✅ Retry with fallback models
|
| 23 |
+
|
| 24 |
+
### 4. **Unified API Server**
|
| 25 |
+
- ✅ Single endpoint for all input types
|
| 26 |
+
- ✅ Async job processing
|
| 27 |
+
- ✅ Progress tracking
|
| 28 |
+
- ✅ Error handling
|
| 29 |
+
|
| 30 |
+
## New Files Created
|
| 31 |
+
|
| 32 |
+
### Core Components
|
| 33 |
+
- `manimator/services/web_scraper.py` - Web content scraping
|
| 34 |
+
- `manimator/api/input_processor.py` - Unified input handler
|
| 35 |
+
- `manimator/utils/visual_themes.py` - Theme configurations
|
| 36 |
+
- `manimator/utils/theme_injector.py` - Theme code injection
|
| 37 |
+
- `manimator/utils/code_validator.py` - Code validation
|
| 38 |
+
- `manimator/utils/code_fixer.py` - Auto-fix functionality
|
| 39 |
+
- `manimator/utils/validation_pipeline.py` - Complete validation pipeline
|
| 40 |
+
- `api_server_unified.py` - New unified API server
|
| 41 |
+
|
| 42 |
+
### Modified Files
|
| 43 |
+
- `manimator/api/scene_description.py` - Added URL processing
|
| 44 |
+
- `manimator/api/animation_generation.py` - Added validation & retry logic
|
| 45 |
+
- `manimator/utils/system_prompts.py` - Enhanced with theme instructions
|
| 46 |
+
- `pyproject.toml` - Added new dependencies
|
| 47 |
+
|
| 48 |
+
## Dependencies Added
|
| 49 |
+
|
| 50 |
+
```toml
|
| 51 |
+
beautifulsoup4 = "^4.12.0"
|
| 52 |
+
requests = "^2.31.0"
|
| 53 |
+
readability-lxml = "^0.8.1"
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
## How to Use
|
| 57 |
+
|
| 58 |
+
### Start the Unified API Server
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
python api_server_unified.py
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
Server runs on `http://localhost:8000`
|
| 65 |
+
|
| 66 |
+
### API Endpoints
|
| 67 |
+
|
| 68 |
+
#### Create Video
|
| 69 |
+
```bash
|
| 70 |
+
POST /api/videos
|
| 71 |
+
{
|
| 72 |
+
"input_type": "text|pdf|url",
|
| 73 |
+
"input_data": "...",
|
| 74 |
+
"quality": "low|medium|high|ultra",
|
| 75 |
+
"category": "tech_system|product_startup|mathematical"
|
| 76 |
+
}
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
#### Check Job Status
|
| 80 |
+
```bash
|
| 81 |
+
GET /api/jobs/{job_id}
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
#### Download Video
|
| 85 |
+
```bash
|
| 86 |
+
GET /api/videos/{job_id}
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
### Example Requests
|
| 90 |
+
|
| 91 |
+
**Text Input:**
|
| 92 |
+
```json
|
| 93 |
+
{
|
| 94 |
+
"input_type": "text",
|
| 95 |
+
"input_data": "Explain how a distributed system handles requests",
|
| 96 |
+
"category": "tech_system",
|
| 97 |
+
"quality": "high"
|
| 98 |
+
}
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
**URL Input:**
|
| 102 |
+
```json
|
| 103 |
+
{
|
| 104 |
+
"input_type": "url",
|
| 105 |
+
"input_data": "https://example.com/blog/post",
|
| 106 |
+
"category": "product_startup",
|
| 107 |
+
"quality": "medium"
|
| 108 |
+
}
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
**PDF Input:**
|
| 112 |
+
```json
|
| 113 |
+
{
|
| 114 |
+
"input_type": "pdf",
|
| 115 |
+
"input_data": "base64_encoded_pdf_string",
|
| 116 |
+
"category": "mathematical",
|
| 117 |
+
"quality": "high"
|
| 118 |
+
}
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
## Features
|
| 122 |
+
|
| 123 |
+
### Visual Themes
|
| 124 |
+
Each category has distinct visual styling:
|
| 125 |
+
- **Tech**: Dark professional backgrounds, architecture diagrams
|
| 126 |
+
- **Product**: Light modern backgrounds, UI elements, gradients
|
| 127 |
+
- **Research**: Dark educational backgrounds, mathematical equations
|
| 128 |
+
|
| 129 |
+
### Error Handling
|
| 130 |
+
- Automatic code validation before rendering
|
| 131 |
+
- Auto-fix for common issues (missing imports, undefined colors, etc.)
|
| 132 |
+
- Retry with fallback models if generation fails
|
| 133 |
+
- Detailed error messages
|
| 134 |
+
|
| 135 |
+
### Input Processing
|
| 136 |
+
- **Text**: Direct prompt processing
|
| 137 |
+
- **PDF**: Base64 decoding and content extraction
|
| 138 |
+
- **URL**: Web scraping with content extraction
|
| 139 |
+
|
| 140 |
+
## Next Steps
|
| 141 |
+
|
| 142 |
+
1. Install new dependencies:
|
| 143 |
+
```bash
|
| 144 |
+
poetry install
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
2. Test the unified server:
|
| 148 |
+
```bash
|
| 149 |
+
python api_server_unified.py
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
3. Try different input types and categories
|
| 153 |
+
|
| 154 |
+
4. Monitor job status and video generation
|
| 155 |
+
|
| 156 |
+
## Notes
|
| 157 |
+
|
| 158 |
+
- The unified server replaces the need for separate 2D/3D servers
|
| 159 |
+
- All categories use the same code generation pipeline with different themes
|
| 160 |
+
- Web scraping respects robots.txt and handles authentication errors gracefully
|
| 161 |
+
- Code validation prevents most rendering failures
|
| 162 |
+
|
docs/IMPLEMENTATION_PLAN.md
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Implementation Plan: Enhanced Video Generation System
|
| 2 |
+
|
| 3 |
+
## Phase 1: Unified Input Processor
|
| 4 |
+
|
| 5 |
+
### 1.1 Create Web Content Scraper
|
| 6 |
+
**File**: `manimator/utils/web_scraper.py`
|
| 7 |
+
- Use `requests` + `beautifulsoup4` for HTML parsing
|
| 8 |
+
- Extract main content, remove navigation/ads
|
| 9 |
+
- Support: blogs, documentation sites, Medium, Dev.to, etc.
|
| 10 |
+
- Handle authentication-required pages (return error with clear message)
|
| 11 |
+
- Extract text content and structure (headings, paragraphs, code blocks)
|
| 12 |
+
- Convert to structured format similar to PDF processing
|
| 13 |
+
|
| 14 |
+
**Dependencies to add**:
|
| 15 |
+
```python
|
| 16 |
+
beautifulsoup4>=4.12.0
|
| 17 |
+
requests>=2.31.0
|
| 18 |
+
readability-lxml>=0.8.1 # For cleaner content extraction
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
### 1.2 Unified Input Handler
|
| 22 |
+
**File**: `manimator/api/input_processor.py`
|
| 23 |
+
- Single entry point: `process_input(input_type, input_data, category)`
|
| 24 |
+
- Input types: `text`, `pdf`, `url`
|
| 25 |
+
- Route to appropriate processor:
|
| 26 |
+
- Text → `process_prompt_scene()`
|
| 27 |
+
- PDF → `process_pdf_prompt()`
|
| 28 |
+
- URL → new `process_url_content()`
|
| 29 |
+
- Return standardized scene description format
|
| 30 |
+
|
| 31 |
+
### 1.3 URL Content Processor
|
| 32 |
+
**File**: `manimator/api/scene_description.py` (extend existing)
|
| 33 |
+
- Function: `process_url_content(url: str) -> str`
|
| 34 |
+
- Scrape web content
|
| 35 |
+
- Extract main text (similar to PDF processing)
|
| 36 |
+
- Generate scene description using LLM with web content context
|
| 37 |
+
- Handle errors: invalid URLs, access denied, parsing failures
|
| 38 |
+
|
| 39 |
+
---
|
| 40 |
+
|
| 41 |
+
## Phase 2: Category-Specific Visual Themes
|
| 42 |
+
|
| 43 |
+
### 2.1 Theme Configuration System
|
| 44 |
+
**File**: `manimator/utils/visual_themes.py`
|
| 45 |
+
- Define theme configs for each category:
|
| 46 |
+
```python
|
| 47 |
+
TECH_THEME = {
|
| 48 |
+
"background_color": "#0a0e27", # Dark blue
|
| 49 |
+
"accent_colors": [BLUE, GREEN, ORANGE, RED, PURPLE],
|
| 50 |
+
"text_color": WHITE,
|
| 51 |
+
"component_style": "rounded_rectangles",
|
| 52 |
+
"animation_style": "professional",
|
| 53 |
+
"voice_id": "Adam"
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
PRODUCT_THEME = {
|
| 57 |
+
"background_color": "#ffffff", # White/light
|
| 58 |
+
"accent_colors": [ORANGE, BLUE, PURPLE, GREEN],
|
| 59 |
+
"text_color": "#1a1a1a",
|
| 60 |
+
"component_style": "modern_gradients",
|
| 61 |
+
"animation_style": "engaging",
|
| 62 |
+
"voice_id": "Bella"
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
RESEARCH_THEME = {
|
| 66 |
+
"background_color": "#1e1e1e", # Dark
|
| 67 |
+
"accent_colors": [BLUE, GREEN, YELLOW, RED],
|
| 68 |
+
"text_color": WHITE,
|
| 69 |
+
"component_style": "mathematical",
|
| 70 |
+
"animation_style": "educational",
|
| 71 |
+
"voice_id": "Rachel"
|
| 72 |
+
}
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
### 2.2 Theme Injection into System Prompts
|
| 76 |
+
**File**: `manimator/utils/system_prompts.py` (modify)
|
| 77 |
+
- Update `get_system_prompt(category)` to include theme instructions
|
| 78 |
+
- Add theme-specific code snippets to each prompt:
|
| 79 |
+
- Tech: Dark background setup, component colors
|
| 80 |
+
- Product: Light background, gradient examples
|
| 81 |
+
- Research: Dark background, equation styling
|
| 82 |
+
- Include background setup code in each prompt template
|
| 83 |
+
|
| 84 |
+
### 2.3 Background Setup Code Generator
|
| 85 |
+
**File**: `manimator/utils/theme_injector.py`
|
| 86 |
+
- Function: `inject_theme_setup(code: str, category: str) -> str`
|
| 87 |
+
- Parse generated code
|
| 88 |
+
- Insert background setup at start of `construct()` method:
|
| 89 |
+
```python
|
| 90 |
+
# Tech theme
|
| 91 |
+
self.camera.background_color = "#0a0e27"
|
| 92 |
+
|
| 93 |
+
# Product theme
|
| 94 |
+
self.camera.background_color = "#ffffff"
|
| 95 |
+
|
| 96 |
+
# Research theme
|
| 97 |
+
self.camera.background_color = "#1e1e1e"
|
| 98 |
+
```
|
| 99 |
+
- Ensure theme colors are used consistently
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## Phase 3: Enhanced Code Validation & Error Handling
|
| 104 |
+
|
| 105 |
+
### 3.1 Pre-Render Code Validator
|
| 106 |
+
**File**: `manimator/utils/code_validator.py`
|
| 107 |
+
- Function: `validate_code(code: str) -> Tuple[bool, List[str]]`
|
| 108 |
+
- Checks:
|
| 109 |
+
- Valid Python syntax (use `ast.parse()`)
|
| 110 |
+
- Required imports present (`from manim import *`, `VoiceoverScene`, `ElevenLabsService`)
|
| 111 |
+
- Scene class inherits from `VoiceoverScene`
|
| 112 |
+
- `construct()` method exists
|
| 113 |
+
- Voiceover service initialized
|
| 114 |
+
- No undefined variables/colors
|
| 115 |
+
- No overlapping object warnings (spatial analysis)
|
| 116 |
+
- Return: (is_valid, list_of_errors)
|
| 117 |
+
|
| 118 |
+
### 3.2 Code Fixer
|
| 119 |
+
**File**: `manimator/utils/code_fixer.py`
|
| 120 |
+
- Function: `auto_fix_code(code: str, errors: List[str]) -> str`
|
| 121 |
+
- Auto-fixes:
|
| 122 |
+
- Missing imports (add if not present)
|
| 123 |
+
- Undefined colors (use existing `fix_undefined_colors()`)
|
| 124 |
+
- Missing voiceover setup (inject if missing)
|
| 125 |
+
- Syntax errors (try to fix common issues)
|
| 126 |
+
- Use existing `code_postprocessor.py` functions
|
| 127 |
+
- Chain fixes until valid or max attempts
|
| 128 |
+
|
| 129 |
+
### 3.3 Retry Logic with Model Fallback
|
| 130 |
+
**File**: `manimator/api/animation_generation.py` (modify)
|
| 131 |
+
- Enhanced `generate_animation_response()`:
|
| 132 |
+
- Try generation with primary model
|
| 133 |
+
- Validate code
|
| 134 |
+
- If invalid, try auto-fix
|
| 135 |
+
- If still invalid, retry with different model (fallback)
|
| 136 |
+
- Max 3 attempts total
|
| 137 |
+
- Return best valid code or raise clear error
|
| 138 |
+
|
| 139 |
+
### 3.4 Render Error Handler
|
| 140 |
+
**File**: `manimator/utils/schema.py` (modify `ManimProcessor`)
|
| 141 |
+
- Enhanced `render_scene()`:
|
| 142 |
+
- Capture full error output
|
| 143 |
+
- Parse common Manim errors:
|
| 144 |
+
- LaTeX errors → suggest fixes
|
| 145 |
+
- Import errors → auto-add imports
|
| 146 |
+
- Scene not found → validate class name
|
| 147 |
+
- Return detailed error messages
|
| 148 |
+
- Attempt auto-fix and re-render if possible
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
## Phase 4: Unified API Server
|
| 153 |
+
|
| 154 |
+
### 4.1 New Unified API Server
|
| 155 |
+
**File**: `api_server_unified.py`
|
| 156 |
+
- Single server handling all input types and categories
|
| 157 |
+
- Endpoints:
|
| 158 |
+
- `POST /api/videos` - Create video (text/PDF/URL)
|
| 159 |
+
- `GET /api/jobs/{job_id}` - Check status
|
| 160 |
+
- `GET /api/videos/{job_id}` - Download video
|
| 161 |
+
- `GET /api/jobs` - List jobs
|
| 162 |
+
- Request model:
|
| 163 |
+
```python
|
| 164 |
+
class VideoRequest(BaseModel):
|
| 165 |
+
input_type: Literal["text", "pdf", "url"]
|
| 166 |
+
input_data: str # text prompt, PDF bytes (base64), or URL
|
| 167 |
+
category: Literal["tech_system", "product_startup", "mathematical"]
|
| 168 |
+
quality: QualityLevel
|
| 169 |
+
scene_name: Optional[str] = None
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
### 4.2 Input Router
|
| 173 |
+
**File**: `manimator/api/input_router.py`
|
| 174 |
+
- Route based on `input_type`:
|
| 175 |
+
- `text` → `process_prompt_scene()`
|
| 176 |
+
- `pdf` → `process_pdf_prompt()` (decode base64)
|
| 177 |
+
- `url` → `process_url_content()`
|
| 178 |
+
- All return scene description → pass to `generate_animation_response()`
|
| 179 |
+
|
| 180 |
+
### 4.3 Job Manager Enhancement
|
| 181 |
+
**File**: `api_server_unified.py` (extend existing JobManager)
|
| 182 |
+
- Track input type and category
|
| 183 |
+
- Store theme used
|
| 184 |
+
- Better error messages with category context
|
| 185 |
+
|
| 186 |
+
---
|
| 187 |
+
|
| 188 |
+
## Phase 5: System Prompts Enhancement
|
| 189 |
+
|
| 190 |
+
### 5.1 Category-Specific Prompt Templates
|
| 191 |
+
**File**: `manimator/utils/system_prompts.py` (enhance existing)
|
| 192 |
+
- **Tech System Prompt**:
|
| 193 |
+
- Emphasize architecture diagrams
|
| 194 |
+
- Component-based visuals
|
| 195 |
+
- Dark background setup
|
| 196 |
+
- Professional color scheme
|
| 197 |
+
- Data flow animations
|
| 198 |
+
|
| 199 |
+
- **Product Startup Prompt**:
|
| 200 |
+
- Modern UI elements
|
| 201 |
+
- Gradient backgrounds
|
| 202 |
+
- Light/colorful theme
|
| 203 |
+
- Feature showcases
|
| 204 |
+
- Statistics displays
|
| 205 |
+
|
| 206 |
+
- **Research/Mathematical Prompt**:
|
| 207 |
+
- Equation-heavy
|
| 208 |
+
- Dark background
|
| 209 |
+
- Step-by-step proofs
|
| 210 |
+
- Graph visualizations
|
| 211 |
+
- Educational pacing
|
| 212 |
+
|
| 213 |
+
### 5.2 Few-Shot Examples Update
|
| 214 |
+
**File**: `manimator/few_shot/few_shot_prompts.py`
|
| 215 |
+
- Add category-specific examples:
|
| 216 |
+
- Tech: System architecture example
|
| 217 |
+
- Product: Feature demo example
|
| 218 |
+
- Research: Mathematical proof example
|
| 219 |
+
- Include theme setup in examples
|
| 220 |
+
|
| 221 |
+
---
|
| 222 |
+
|
| 223 |
+
## Phase 6: Testing & Validation Pipeline
|
| 224 |
+
|
| 225 |
+
### 6.1 Code Validation Pipeline
|
| 226 |
+
**File**: `manimator/utils/validation_pipeline.py`
|
| 227 |
+
- Pre-render checks:
|
| 228 |
+
1. Syntax validation
|
| 229 |
+
2. Import validation
|
| 230 |
+
3. Structure validation
|
| 231 |
+
4. Theme compliance
|
| 232 |
+
5. Auto-fix attempts
|
| 233 |
+
- Post-render checks:
|
| 234 |
+
1. Video file exists
|
| 235 |
+
2. Video duration > 0
|
| 236 |
+
3. Video is playable
|
| 237 |
+
|
| 238 |
+
### 6.2 Error Recovery
|
| 239 |
+
- If validation fails → auto-fix → re-validate
|
| 240 |
+
- If auto-fix fails → retry generation with different model
|
| 241 |
+
- If all fails → return detailed error to user
|
| 242 |
+
|
| 243 |
+
---
|
| 244 |
+
|
| 245 |
+
## Implementation Order
|
| 246 |
+
|
| 247 |
+
1. **Week 1**: Phase 1 (Input Processor) + Phase 2 (Themes)
|
| 248 |
+
2. **Week 2**: Phase 3 (Validation) + Phase 5 (Prompts)
|
| 249 |
+
3. **Week 3**: Phase 4 (Unified API) + Phase 6 (Testing)
|
| 250 |
+
|
| 251 |
+
---
|
| 252 |
+
|
| 253 |
+
## File Structure Changes
|
| 254 |
+
|
| 255 |
+
```
|
| 256 |
+
manimator/
|
| 257 |
+
├── api/
|
| 258 |
+
│ ├── animation_generation.py (existing, enhance)
|
| 259 |
+
│ ├── scene_description.py (existing, extend)
|
| 260 |
+
│ ├── input_processor.py (NEW)
|
| 261 |
+
│ └── input_router.py (NEW)
|
| 262 |
+
├── utils/
|
| 263 |
+
│ ├── code_postprocessor.py (existing)
|
| 264 |
+
│ ├── code_validator.py (NEW)
|
| 265 |
+
│ ├── code_fixer.py (NEW)
|
| 266 |
+
│ ├── visual_themes.py (NEW)
|
| 267 |
+
│ ├── theme_injector.py (NEW)
|
| 268 |
+
│ └── validation_pipeline.py (NEW)
|
| 269 |
+
└── services/
|
| 270 |
+
└── web_scraper.py (NEW)
|
| 271 |
+
|
| 272 |
+
api_server_unified.py (NEW - main server)
|
| 273 |
+
```
|
| 274 |
+
|
| 275 |
+
---
|
| 276 |
+
|
| 277 |
+
## Dependencies to Add
|
| 278 |
+
|
| 279 |
+
```toml
|
| 280 |
+
beautifulsoup4 = "^4.12.0"
|
| 281 |
+
requests = "^2.31.0"
|
| 282 |
+
readability-lxml = "^0.8.1"
|
| 283 |
+
```
|
| 284 |
+
|
| 285 |
+
---
|
| 286 |
+
|
| 287 |
+
## Key Success Metrics
|
| 288 |
+
|
| 289 |
+
1. **Reliability**: < 5% code generation failures
|
| 290 |
+
2. **Visual Differentiation**: Clear visual distinction between categories
|
| 291 |
+
3. **Error Recovery**: 80%+ of errors auto-fixed
|
| 292 |
+
4. **Input Support**: All 3 input types working (text/PDF/URL)
|
| 293 |
+
|
ensure_rpc.sql
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
create or replace function public.handle_user_login(
|
| 2 |
+
user_email text,
|
| 3 |
+
user_full_name text,
|
| 4 |
+
user_avatar_url text
|
| 5 |
+
)
|
| 6 |
+
returns void
|
| 7 |
+
language plpgsql
|
| 8 |
+
security definer
|
| 9 |
+
as $$
|
| 10 |
+
declare
|
| 11 |
+
current_user_id uuid;
|
| 12 |
+
begin
|
| 13 |
+
current_user_id := auth.uid();
|
| 14 |
+
if current_user_id is null then
|
| 15 |
+
raise exception 'Not authenticated';
|
| 16 |
+
end if;
|
| 17 |
+
|
| 18 |
+
insert into public.users (id, email, full_name, avatar_url, credits)
|
| 19 |
+
values (
|
| 20 |
+
current_user_id,
|
| 21 |
+
user_email,
|
| 22 |
+
user_full_name,
|
| 23 |
+
user_avatar_url,
|
| 24 |
+
5
|
| 25 |
+
)
|
| 26 |
+
on conflict (id) do update set
|
| 27 |
+
email = excluded.email,
|
| 28 |
+
full_name = excluded.full_name,
|
| 29 |
+
avatar_url = excluded.avatar_url;
|
| 30 |
+
end;
|
| 31 |
+
$$;
|
| 32 |
+
|
| 33 |
+
grant execute on function public.handle_user_login to authenticated;
|
fix_columns_and_trigger.sql
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 1. Add missing columns if they don't exist
|
| 2 |
+
DO $$
|
| 3 |
+
BEGIN
|
| 4 |
+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'full_name') THEN
|
| 5 |
+
ALTER TABLE public.users ADD COLUMN full_name text;
|
| 6 |
+
END IF;
|
| 7 |
+
|
| 8 |
+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'avatar_url') THEN
|
| 9 |
+
ALTER TABLE public.users ADD COLUMN avatar_url text;
|
| 10 |
+
END IF;
|
| 11 |
+
|
| 12 |
+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'credits') THEN
|
| 13 |
+
ALTER TABLE public.users ADD COLUMN credits integer default 5;
|
| 14 |
+
END IF;
|
| 15 |
+
END $$;
|
| 16 |
+
|
| 17 |
+
-- 2. Create the Trigger Function (Updated to be safe)
|
| 18 |
+
create or replace function public.handle_new_user()
|
| 19 |
+
returns trigger as $$
|
| 20 |
+
begin
|
| 21 |
+
insert into public.users (id, email, full_name, avatar_url, credits)
|
| 22 |
+
values (
|
| 23 |
+
new.id,
|
| 24 |
+
new.email,
|
| 25 |
+
new.raw_user_meta_data->>'full_name',
|
| 26 |
+
new.raw_user_meta_data->>'avatar_url',
|
| 27 |
+
5
|
| 28 |
+
)
|
| 29 |
+
ON CONFLICT (id) DO UPDATE SET
|
| 30 |
+
email = EXCLUDED.email,
|
| 31 |
+
full_name = EXCLUDED.full_name,
|
| 32 |
+
avatar_url = EXCLUDED.avatar_url;
|
| 33 |
+
return new;
|
| 34 |
+
end;
|
| 35 |
+
$$ language plpgsql security definer;
|
| 36 |
+
|
| 37 |
+
-- 3. Create the Trigger
|
| 38 |
+
drop trigger if exists on_auth_user_created on auth.users;
|
| 39 |
+
create trigger on_auth_user_created
|
| 40 |
+
after insert on auth.users
|
| 41 |
+
for each row execute procedure public.handle_new_user();
|
| 42 |
+
|
| 43 |
+
-- 4. Backfill existing users
|
| 44 |
+
insert into public.users (id, email, full_name, avatar_url, credits)
|
| 45 |
+
select
|
| 46 |
+
id,
|
| 47 |
+
email,
|
| 48 |
+
raw_user_meta_data->>'full_name',
|
| 49 |
+
raw_user_meta_data->>'avatar_url',
|
| 50 |
+
5
|
| 51 |
+
from auth.users
|
| 52 |
+
where id not in (select id from public.users);
|
fix_rls.sql
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Enable RLS
|
| 2 |
+
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
|
| 3 |
+
|
| 4 |
+
-- Allow users to insert their own profile
|
| 5 |
+
CREATE POLICY "Users can insert their own profile"
|
| 6 |
+
ON public.users
|
| 7 |
+
FOR INSERT
|
| 8 |
+
WITH CHECK (auth.uid() = id);
|
| 9 |
+
|
| 10 |
+
-- Allow users to view their own profile
|
| 11 |
+
CREATE POLICY "Users can view their own profile"
|
| 12 |
+
ON public.users
|
| 13 |
+
FOR SELECT
|
| 14 |
+
USING (auth.uid() = id);
|
| 15 |
+
|
| 16 |
+
-- Allow users to update their own profile
|
| 17 |
+
CREATE POLICY "Users can update their own profile"
|
| 18 |
+
ON public.users
|
| 19 |
+
FOR UPDATE
|
| 20 |
+
USING (auth.uid() = id);
|
| 21 |
+
|
| 22 |
+
-- Grant access to authenticated users
|
| 23 |
+
GRANT ALL ON public.users TO authenticated;
|
| 24 |
+
GRANT ALL ON public.users TO service_role;
|
frontend/.env.example
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Google Authentication
|
| 2 |
+
GOOGLE_CLIENT_ID=your_google_client_id_here
|
| 3 |
+
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
|
| 4 |
+
NEXTAUTH_URL=http://localhost:3000
|
| 5 |
+
NEXTAUTH_SECRET=generate_a_random_string_here
|
| 6 |
+
|
| 7 |
+
# Internal Security
|
| 8 |
+
INTERNAL_API_KEY=secure-internal-key-123
|
| 9 |
+
|
| 10 |
+
# Database (Supabase / Postgres)
|
| 11 |
+
# Connect to port 6543 (Transaction Pooler) for DATABASE_URL
|
| 12 |
+
DATABASE_URL="postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres?pgbouncer=true"
|
| 13 |
+
# Connect to port 5432 (Session) for DIRECT_URL (Used for migrations)
|
| 14 |
+
DIRECT_URL="postgresql://postgres.[project-ref]:[password]@aws-0-[region].supabase.co:5432/postgres"
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# dependencies
|
| 2 |
+
/node_modules
|
| 3 |
+
/.pnp
|
| 4 |
+
.pnp.js
|
| 5 |
+
|
| 6 |
+
# testing
|
| 7 |
+
/coverage
|
| 8 |
+
|
| 9 |
+
# next.js
|
| 10 |
+
/.next/
|
| 11 |
+
/out/
|
| 12 |
+
|
| 13 |
+
# production
|
| 14 |
+
/build
|
| 15 |
+
|
| 16 |
+
# misc
|
| 17 |
+
.DS_Store
|
| 18 |
+
*.pem
|
| 19 |
+
|
| 20 |
+
# debug
|
| 21 |
+
npm-debug.log*
|
| 22 |
+
yarn-debug.log*
|
| 23 |
+
yarn-error.log*
|
| 24 |
+
.pnpm-debug.log*
|
| 25 |
+
|
| 26 |
+
# local env files
|
| 27 |
+
.env*.local
|
| 28 |
+
|
| 29 |
+
# vercel
|
| 30 |
+
.vercel
|
frontend/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# VidSimplify Frontend
|
| 2 |
+
|
| 3 |
+
This is the Next.js frontend for the VidSimplify video generation platform.
|
| 4 |
+
|
| 5 |
+
## 🚀 Getting Started
|
| 6 |
+
|
| 7 |
+
1. **Install Dependencies**:
|
| 8 |
+
```bash
|
| 9 |
+
npm install
|
| 10 |
+
```
|
| 11 |
+
|
| 12 |
+
2. **Run Development Server**:
|
| 13 |
+
```bash
|
| 14 |
+
npm run dev
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
3. **Open in Browser**:
|
| 18 |
+
Navigate to [http://localhost:3000](http://localhost:3000).
|
| 19 |
+
|
| 20 |
+
## 🏗️ Architecture
|
| 21 |
+
|
| 22 |
+
- **Framework**: Next.js 14 (App Router)
|
| 23 |
+
- **Styling**: Tailwind CSS
|
| 24 |
+
- **Icons**: Lucide React
|
| 25 |
+
- **Animations**: Framer Motion
|
| 26 |
+
- **API Client**: `src/lib/api.ts` connects to the Python backend at `http://localhost:8000`.
|
| 27 |
+
|
| 28 |
+
## 📄 Pages
|
| 29 |
+
|
| 30 |
+
- `/` - Landing page with features and demo.
|
| 31 |
+
- `/app` - Main application for generating videos.
|
| 32 |
+
|
| 33 |
+
## 🔧 Configuration
|
| 34 |
+
|
| 35 |
+
The API URL is configured in `src/lib/api.ts`. Default is `http://localhost:8000`.
|
frontend/eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
frontend/next-env.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="next" />
|
| 2 |
+
/// <reference types="next/image-types/global" />
|
| 3 |
+
|
| 4 |
+
// NOTE: This file should not be edited
|
| 5 |
+
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
frontend/next.config.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
images: {
|
| 4 |
+
remotePatterns: [
|
| 5 |
+
{
|
| 6 |
+
protocol: 'https',
|
| 7 |
+
hostname: 'lh3.googleusercontent.com',
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
protocol: 'https',
|
| 11 |
+
hostname: 'googleusercontent.com',
|
| 12 |
+
}
|
| 13 |
+
],
|
| 14 |
+
},
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export default nextConfig;
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@radix-ui/react-avatar": "^1.1.11",
|
| 13 |
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
| 14 |
+
"@radix-ui/react-slot": "^1.2.4",
|
| 15 |
+
"@radix-ui/react-tabs": "^1.1.13",
|
| 16 |
+
"@supabase/auth-helpers-nextjs": "^0.15.0",
|
| 17 |
+
"@supabase/ssr": "^0.8.0",
|
| 18 |
+
"@supabase/supabase-js": "^2.86.0",
|
| 19 |
+
"class-variance-authority": "^0.7.1",
|
| 20 |
+
"clsx": "^2.1.1",
|
| 21 |
+
"framer-motion": "^12.23.24",
|
| 22 |
+
"lucide-react": "^0.554.0",
|
| 23 |
+
"next": "^14.2.3",
|
| 24 |
+
"react": "^18.3.1",
|
| 25 |
+
"react-dom": "^18.3.1",
|
| 26 |
+
"tailwind-merge": "^3.4.0"
|
| 27 |
+
},
|
| 28 |
+
"devDependencies": {
|
| 29 |
+
"@tailwindcss/postcss": "^4",
|
| 30 |
+
"@types/node": "^20",
|
| 31 |
+
"@types/react": "^18.3.27",
|
| 32 |
+
"@types/react-dom": "^18.3.7",
|
| 33 |
+
"babel-plugin-react-compiler": "1.0.0",
|
| 34 |
+
"eslint": "^9",
|
| 35 |
+
"eslint-config-next": "16.0.3",
|
| 36 |
+
"tailwindcss": "^4",
|
| 37 |
+
"typescript": "^5"
|
| 38 |
+
}
|
| 39 |
+
}
|
frontend/postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
frontend/public/file.svg
ADDED
|
|
frontend/public/globe.svg
ADDED
|
|
frontend/public/logo-full.jpg
ADDED
|
frontend/public/logo-icon.jpg
ADDED
|
|
frontend/public/logo.svg
ADDED
|
|
frontend/public/next.svg
ADDED
|
|
frontend/public/robots.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
User-agent: *
|
| 2 |
+
Allow: /
|
| 3 |
+
Disallow: /app/
|
| 4 |
+
Disallow: /api/
|
frontend/public/vercel.svg
ADDED
|
|
frontend/public/window.svg
ADDED
|
|
frontend/src/app/api-docs/page.tsx
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { Navbar } from "@/components/landing/Navbar";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 6 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
| 7 |
+
import { Check, Copy, Terminal, Play, BarChart3, Coins } from "lucide-react";
|
| 8 |
+
|
| 9 |
+
export default function ApiDocs() {
|
| 10 |
+
return (
|
| 11 |
+
<main className="min-h-screen bg-slate-950">
|
| 12 |
+
<Navbar />
|
| 13 |
+
|
| 14 |
+
<div className="pt-24 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
| 15 |
+
<div className="text-center mb-16">
|
| 16 |
+
<h1 className="text-4xl font-bold text-white mb-4">VidSimplify API</h1>
|
| 17 |
+
<p className="text-xl text-slate-400 max-w-2xl mx-auto">
|
| 18 |
+
Integrate precision animation generation directly into your product.
|
| 19 |
+
Simple, RESTful, and scalable.
|
| 20 |
+
</p>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
<div className="grid lg:grid-cols-3 gap-8">
|
| 24 |
+
{/* Main Documentation Area */}
|
| 25 |
+
<div className="lg:col-span-2 space-y-8">
|
| 26 |
+
<Card className="bg-slate-900 border-slate-800">
|
| 27 |
+
<CardHeader className="p-4 sm:p-6">
|
| 28 |
+
<CardTitle className="text-white flex items-center gap-2">
|
| 29 |
+
<Terminal className="h-5 w-5 text-blue-400" />
|
| 30 |
+
Quick Start
|
| 31 |
+
</CardTitle>
|
| 32 |
+
<CardDescription className="text-slate-400">
|
| 33 |
+
Generate your first video in 3 steps
|
| 34 |
+
</CardDescription>
|
| 35 |
+
</CardHeader>
|
| 36 |
+
<CardContent className="p-4 sm:p-6">
|
| 37 |
+
<Tabs defaultValue="python" className="w-full">
|
| 38 |
+
<TabsList className="bg-slate-950 border border-slate-800 mb-4">
|
| 39 |
+
<TabsTrigger value="python">Python</TabsTrigger>
|
| 40 |
+
<TabsTrigger value="curl">cURL</TabsTrigger>
|
| 41 |
+
<TabsTrigger value="node">Node.js</TabsTrigger>
|
| 42 |
+
</TabsList>
|
| 43 |
+
|
| 44 |
+
<TabsContent value="python" className="space-y-4">
|
| 45 |
+
<div className="bg-slate-950 p-4 rounded-lg border border-slate-800 font-mono text-sm text-slate-300 overflow-x-auto">
|
| 46 |
+
<div className="flex justify-between items-start mb-2">
|
| 47 |
+
<span className="text-slate-500"># 1. Create a video job</span>
|
| 48 |
+
<Copy className="h-4 w-4 text-slate-600 cursor-pointer hover:text-white" />
|
| 49 |
+
</div>
|
| 50 |
+
<pre className="text-blue-300">import <span className="text-white">requests</span></pre>
|
| 51 |
+
<pre className="mt-2">
|
| 52 |
+
{`response = requests.post(
|
| 53 |
+
"https://api.vidsimplify.com/v1/videos",
|
| 54 |
+
headers={"Authorization": "Bearer YOUR_API_KEY"},
|
| 55 |
+
json={
|
| 56 |
+
"prompt": "Explain database sharding",
|
| 57 |
+
"category": "tech_system",
|
| 58 |
+
"duration_minutes": 5
|
| 59 |
+
}
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
job_id = response.json()["job_id"]
|
| 63 |
+
print(f"Job started: {job_id}")`}
|
| 64 |
+
</pre>
|
| 65 |
+
</div>
|
| 66 |
+
</TabsContent>
|
| 67 |
+
|
| 68 |
+
<TabsContent value="curl">
|
| 69 |
+
<div className="bg-slate-950 p-4 rounded-lg border border-slate-800 font-mono text-sm text-slate-300 overflow-x-auto">
|
| 70 |
+
<pre>
|
| 71 |
+
{`curl -X POST https://api.vidsimplify.com/v1/videos \\
|
| 72 |
+
-H "Authorization: Bearer YOUR_API_KEY" \\
|
| 73 |
+
-H "Content-Type: application/json" \\
|
| 74 |
+
-d '{
|
| 75 |
+
"prompt": "Explain database sharding",
|
| 76 |
+
"category": "tech_system"
|
| 77 |
+
}'`}
|
| 78 |
+
</pre>
|
| 79 |
+
</div>
|
| 80 |
+
</TabsContent>
|
| 81 |
+
</Tabs>
|
| 82 |
+
</CardContent>
|
| 83 |
+
</Card>
|
| 84 |
+
|
| 85 |
+
<Card className="bg-slate-900 border-slate-800">
|
| 86 |
+
<CardHeader className="p-4 sm:p-6">
|
| 87 |
+
<CardTitle className="text-white">Endpoints</CardTitle>
|
| 88 |
+
</CardHeader>
|
| 89 |
+
<CardContent className="space-y-4 p-4 sm:p-6">
|
| 90 |
+
{[
|
| 91 |
+
{ method: "POST", path: "/v1/videos", desc: "Create a new video generation job" },
|
| 92 |
+
{ method: "GET", path: "/v1/jobs/{job_id}", desc: "Check generation status and progress" },
|
| 93 |
+
{ method: "GET", path: "/v1/videos/{video_id}/download", desc: "Get secure download URL" },
|
| 94 |
+
{ method: "GET", path: "/v1/usage", desc: "Get current billing period usage" }
|
| 95 |
+
].map((ep, i) => (
|
| 96 |
+
<div key={i} className="flex flex-col md:flex-row md:items-center justify-between p-3 rounded bg-slate-950/50 border border-slate-800/50 gap-2 md:gap-0">
|
| 97 |
+
<div className="flex items-center gap-3">
|
| 98 |
+
<span className={`px-2 py-1 rounded text-xs font-bold ${ep.method === "POST" ? "bg-blue-900/30 text-blue-400" : "bg-green-900/30 text-green-400"
|
| 99 |
+
}`}>
|
| 100 |
+
{ep.method}
|
| 101 |
+
</span>
|
| 102 |
+
<span className="font-mono text-sm text-slate-300 break-all">{ep.path}</span>
|
| 103 |
+
</div>
|
| 104 |
+
<span className="text-sm text-slate-500">{ep.desc}</span>
|
| 105 |
+
</div>
|
| 106 |
+
))}
|
| 107 |
+
</CardContent>
|
| 108 |
+
</Card>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
{/* Sidebar / Dashboard Simulation */}
|
| 112 |
+
<div className="space-y-6">
|
| 113 |
+
<Card className="bg-slate-900 border-slate-800">
|
| 114 |
+
<CardHeader>
|
| 115 |
+
<CardTitle className="text-white flex items-center gap-2">
|
| 116 |
+
<BarChart3 className="h-5 w-5 text-green-400" />
|
| 117 |
+
Usage & Cost
|
| 118 |
+
</CardTitle>
|
| 119 |
+
</CardHeader>
|
| 120 |
+
<CardContent>
|
| 121 |
+
<div className="space-y-6">
|
| 122 |
+
<div>
|
| 123 |
+
<div className="flex justify-between text-sm mb-2">
|
| 124 |
+
<span className="text-slate-400">API Calls</span>
|
| 125 |
+
<span className="text-white font-medium">8,432 / 10,000</span>
|
| 126 |
+
</div>
|
| 127 |
+
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
|
| 128 |
+
<div className="h-full w-[84%] bg-blue-500"></div>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
<div>
|
| 133 |
+
<div className="flex justify-between text-sm mb-2">
|
| 134 |
+
<span className="text-slate-400">Video Minutes</span>
|
| 135 |
+
<span className="text-white font-medium">450 / 500</span>
|
| 136 |
+
</div>
|
| 137 |
+
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
|
| 138 |
+
<div className="h-full w-[90%] bg-purple-500"></div>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<div className="pt-4 border-t border-slate-800">
|
| 143 |
+
<div className="flex justify-between items-center">
|
| 144 |
+
<span className="text-slate-400">Current Bill</span>
|
| 145 |
+
<span className="text-2xl font-bold text-white">$45.00</span>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
</CardContent>
|
| 150 |
+
</Card>
|
| 151 |
+
|
| 152 |
+
<div className="bg-gradient-to-br from-blue-900/20 to-violet-900/20 border border-blue-500/20 rounded-xl p-6">
|
| 153 |
+
<h3 className="text-lg font-semibold text-white mb-2">Enterprise Plan</h3>
|
| 154 |
+
<p className="text-sm text-slate-400 mb-4">
|
| 155 |
+
Need higher limits or dedicated rendering clusters?
|
| 156 |
+
</p>
|
| 157 |
+
<Button className="w-full bg-white text-slate-900 hover:bg-slate-200">
|
| 158 |
+
Contact Sales
|
| 159 |
+
</Button>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
</main>
|
| 165 |
+
);
|
| 166 |
+
}
|
frontend/src/app/api/auth/sync/route.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createServerClient, type CookieOptions } from '@supabase/ssr'
|
| 2 |
+
import { cookies } from 'next/headers'
|
| 3 |
+
import { NextResponse } from 'next/server'
|
| 4 |
+
|
| 5 |
+
export async function POST() {
|
| 6 |
+
console.log("\n🔄 === SYNC API CALLED ===");
|
| 7 |
+
const cookieStore = cookies()
|
| 8 |
+
|
| 9 |
+
// Debug: Log all cookies to see if auth token is present
|
| 10 |
+
const allCookies = cookieStore.getAll().map(c => c.name);
|
| 11 |
+
console.log("Cookies received:", allCookies);
|
| 12 |
+
|
| 13 |
+
// Create authenticated Supabase client
|
| 14 |
+
const supabase = createServerClient(
|
| 15 |
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
| 16 |
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
| 17 |
+
{
|
| 18 |
+
cookies: {
|
| 19 |
+
get(name: string) {
|
| 20 |
+
return cookieStore.get(name)?.value
|
| 21 |
+
},
|
| 22 |
+
set(name: string, value: string, options: CookieOptions) {
|
| 23 |
+
try {
|
| 24 |
+
cookieStore.set({ name, value, ...options })
|
| 25 |
+
} catch (error) {
|
| 26 |
+
// Route handlers can't set cookies in some Next.js versions/contexts,
|
| 27 |
+
// but we only need to READ them for auth here.
|
| 28 |
+
}
|
| 29 |
+
},
|
| 30 |
+
remove(name: string, options: CookieOptions) {
|
| 31 |
+
try {
|
| 32 |
+
cookieStore.delete({ name, ...options })
|
| 33 |
+
} catch (error) {
|
| 34 |
+
// Ignore
|
| 35 |
+
}
|
| 36 |
+
},
|
| 37 |
+
},
|
| 38 |
+
}
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
// Get current user
|
| 42 |
+
console.log("Getting current user...");
|
| 43 |
+
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
| 44 |
+
|
| 45 |
+
if (authError || !user) {
|
| 46 |
+
console.error("❌ Not authenticated:", authError?.message);
|
| 47 |
+
return NextResponse.json({ error: 'Not authenticated', details: authError?.message }, { status: 401 })
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
console.log("✅ User authenticated:", user.id);
|
| 51 |
+
|
| 52 |
+
try {
|
| 53 |
+
// Check if user exists
|
| 54 |
+
console.log("Checking if user exists in DB...");
|
| 55 |
+
const { data: existingUser, error: fetchError } = await supabase
|
| 56 |
+
.from('users')
|
| 57 |
+
.select('id')
|
| 58 |
+
.eq('id', user.id)
|
| 59 |
+
.single()
|
| 60 |
+
|
| 61 |
+
console.log("Fetch result:", { existingUser, fetchError });
|
| 62 |
+
|
| 63 |
+
if (!existingUser) {
|
| 64 |
+
console.log(`🔄 User ${user.id} not found. Inserting...`)
|
| 65 |
+
|
| 66 |
+
const insertData = {
|
| 67 |
+
id: user.id,
|
| 68 |
+
email: user.email,
|
| 69 |
+
full_name: user.user_metadata.full_name || null,
|
| 70 |
+
avatar_url: user.user_metadata.avatar_url || null,
|
| 71 |
+
credits: 5
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
console.log("Insert data:", insertData);
|
| 75 |
+
|
| 76 |
+
// Insert user using the AUTHENTICATED client (acting as the user)
|
| 77 |
+
const { data: insertResult, error: insertError } = await supabase
|
| 78 |
+
.from('users')
|
| 79 |
+
.insert(insertData)
|
| 80 |
+
.select()
|
| 81 |
+
|
| 82 |
+
if (insertError) {
|
| 83 |
+
console.error('❌ Sync insert error:', insertError)
|
| 84 |
+
return NextResponse.json({ error: insertError.message }, { status: 500 })
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
console.log("✅ User created successfully:", insertResult);
|
| 88 |
+
return NextResponse.json({ status: 'created', user: insertResult })
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
console.log("✅ User already exists");
|
| 92 |
+
return NextResponse.json({ status: 'exists' })
|
| 93 |
+
|
| 94 |
+
} catch (err) {
|
| 95 |
+
console.error('❌ Sync error:', err)
|
| 96 |
+
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
|
| 97 |
+
}
|
| 98 |
+
}
|
frontend/src/app/api/generate/route.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { NextResponse } from "next/server";
|
| 3 |
+
import { createServerClient, type CookieOptions } from '@supabase/ssr'
|
| 4 |
+
import { cookies } from "next/headers";
|
| 5 |
+
import { supabase as supabaseAdmin } from "@/lib/supabase";
|
| 6 |
+
|
| 7 |
+
const PYTHON_API_URL = process.env.PYTHON_API_URL || "http://127.0.0.1:8000";
|
| 8 |
+
|
| 9 |
+
const SHOWCASE_KEYWORDS = [
|
| 10 |
+
"database sharding",
|
| 11 |
+
"kafka",
|
| 12 |
+
"transformers",
|
| 13 |
+
"quantum entanglement",
|
| 14 |
+
"netflix",
|
| 15 |
+
"black hole",
|
| 16 |
+
"sorting algorithms",
|
| 17 |
+
"uber",
|
| 18 |
+
"quicksort",
|
| 19 |
+
"bubble sort"
|
| 20 |
+
];
|
| 21 |
+
|
| 22 |
+
export async function POST(req: Request) {
|
| 23 |
+
try {
|
| 24 |
+
// Get request body first to check for demo mode
|
| 25 |
+
const body = await req.json();
|
| 26 |
+
const { input_data, input_type, category, quality } = body;
|
| 27 |
+
|
| 28 |
+
// Check for showcase prompt (Demo Mode) - Bypass Auth and Credits
|
| 29 |
+
const lowerInput = (input_data || "").toLowerCase();
|
| 30 |
+
const isShowcase = SHOWCASE_KEYWORDS.some(keyword => lowerInput.includes(keyword));
|
| 31 |
+
|
| 32 |
+
if (isShowcase) {
|
| 33 |
+
console.log("🎨 Demo Mode activated for prompt:", input_data);
|
| 34 |
+
|
| 35 |
+
// Return a fake job ID encoded with timestamp to track progress
|
| 36 |
+
const timestamp = Date.now();
|
| 37 |
+
return NextResponse.json({
|
| 38 |
+
job_id: `demo-${timestamp}`,
|
| 39 |
+
status: "pending",
|
| 40 |
+
message: "Job started (Demo)"
|
| 41 |
+
});
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const cookieStore = cookies() // Removed await for Next.js 14 compatibility
|
| 45 |
+
|
| 46 |
+
// Create authenticated Supabase client
|
| 47 |
+
const supabase = createServerClient(
|
| 48 |
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
| 49 |
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
| 50 |
+
{
|
| 51 |
+
cookies: {
|
| 52 |
+
getAll() {
|
| 53 |
+
return cookieStore.getAll()
|
| 54 |
+
},
|
| 55 |
+
setAll(cookiesToSet) {
|
| 56 |
+
cookiesToSet.forEach(({ name, value, options }) => {
|
| 57 |
+
cookieStore.set(name, value, options)
|
| 58 |
+
})
|
| 59 |
+
},
|
| 60 |
+
},
|
| 61 |
+
}
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
// Get current user
|
| 65 |
+
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
| 66 |
+
|
| 67 |
+
if (authError || !user) {
|
| 68 |
+
return NextResponse.json(
|
| 69 |
+
{ error: "Unauthorized" },
|
| 70 |
+
{ status: 401 }
|
| 71 |
+
);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// Check credits
|
| 75 |
+
const { data: userData, error: userError } = await supabaseAdmin
|
| 76 |
+
.from("users")
|
| 77 |
+
.select("credits")
|
| 78 |
+
.eq("id", user.id)
|
| 79 |
+
.single();
|
| 80 |
+
|
| 81 |
+
if (userError || !userData) {
|
| 82 |
+
return NextResponse.json(
|
| 83 |
+
{ error: "User not found" },
|
| 84 |
+
{ status: 404 }
|
| 85 |
+
);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
if (userData.credits < 1) {
|
| 89 |
+
return NextResponse.json(
|
| 90 |
+
{ error: "Insufficient credits" },
|
| 91 |
+
{ status: 402 }
|
| 92 |
+
);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// Real Generation Flow
|
| 96 |
+
// Deduct credit
|
| 97 |
+
const { error: updateError } = await supabaseAdmin
|
| 98 |
+
.from("users")
|
| 99 |
+
.update({ credits: userData.credits - 1 })
|
| 100 |
+
.eq("id", user.id);
|
| 101 |
+
|
| 102 |
+
if (updateError) {
|
| 103 |
+
return NextResponse.json(
|
| 104 |
+
{ error: "Failed to deduct credits" },
|
| 105 |
+
{ status: 500 }
|
| 106 |
+
);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// Call Python API
|
| 110 |
+
const response = await fetch(`${PYTHON_API_URL}/api/generate`, {
|
| 111 |
+
method: "POST",
|
| 112 |
+
headers: {
|
| 113 |
+
"Content-Type": "application/json",
|
| 114 |
+
"X-API-Key": process.env.INTERNAL_API_KEY || "",
|
| 115 |
+
},
|
| 116 |
+
body: JSON.stringify({
|
| 117 |
+
input_data,
|
| 118 |
+
input_type,
|
| 119 |
+
category,
|
| 120 |
+
quality,
|
| 121 |
+
user_id: user.id
|
| 122 |
+
}),
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
if (!response.ok) {
|
| 126 |
+
// Refund credit on failure
|
| 127 |
+
await supabaseAdmin
|
| 128 |
+
.from("users")
|
| 129 |
+
.update({ credits: userData.credits })
|
| 130 |
+
.eq("id", user.id);
|
| 131 |
+
|
| 132 |
+
const error = await response.json();
|
| 133 |
+
return NextResponse.json(
|
| 134 |
+
{ error: error.detail || "Generation failed" },
|
| 135 |
+
{ status: response.status }
|
| 136 |
+
);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const data = await response.json();
|
| 140 |
+
return NextResponse.json(data);
|
| 141 |
+
|
| 142 |
+
} catch (error) {
|
| 143 |
+
console.error("Generate error:", error);
|
| 144 |
+
return NextResponse.json(
|
| 145 |
+
{ error: "Internal server error" },
|
| 146 |
+
{ status: 500 }
|
| 147 |
+
);
|
| 148 |
+
}
|
| 149 |
+
}
|
frontend/src/app/api/jobs/[id]/route.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
const PYTHON_API_URL = process.env.PYTHON_API_URL || "http://127.0.0.1:8000";
|
| 4 |
+
const DEMO_VIDEO_URL = "https://storage.googleapis.com/vidsimplify/GeneratedScene.mp4";
|
| 5 |
+
|
| 6 |
+
export async function GET(
|
| 7 |
+
req: Request,
|
| 8 |
+
{ params }: { params: { id: string } }
|
| 9 |
+
) {
|
| 10 |
+
const jobId = params.id;
|
| 11 |
+
|
| 12 |
+
// Handle Demo Jobs
|
| 13 |
+
if (jobId.startsWith("demo-")) {
|
| 14 |
+
const timestamp = parseInt(jobId.split("-")[1]);
|
| 15 |
+
const now = Date.now();
|
| 16 |
+
const elapsed = (now - timestamp) / 1000; // seconds
|
| 17 |
+
|
| 18 |
+
if (elapsed < 3) {
|
| 19 |
+
return NextResponse.json({
|
| 20 |
+
job_id: jobId,
|
| 21 |
+
status: "pending",
|
| 22 |
+
progress: { percentage: 10, message: "Initializing system..." },
|
| 23 |
+
created_at: new Date(timestamp).toISOString()
|
| 24 |
+
});
|
| 25 |
+
} else if (elapsed < 6) {
|
| 26 |
+
return NextResponse.json({
|
| 27 |
+
job_id: jobId,
|
| 28 |
+
status: "generating_code",
|
| 29 |
+
progress: { percentage: 40, message: "Generating Manim script..." },
|
| 30 |
+
created_at: new Date(timestamp).toISOString()
|
| 31 |
+
});
|
| 32 |
+
} else if (elapsed < 10) {
|
| 33 |
+
return NextResponse.json({
|
| 34 |
+
job_id: jobId,
|
| 35 |
+
status: "rendering",
|
| 36 |
+
progress: { percentage: 70, message: "Rendering animation frames..." },
|
| 37 |
+
created_at: new Date(timestamp).toISOString()
|
| 38 |
+
});
|
| 39 |
+
} else if (elapsed < 14) {
|
| 40 |
+
return NextResponse.json({
|
| 41 |
+
job_id: jobId,
|
| 42 |
+
status: "rendering",
|
| 43 |
+
progress: { percentage: 90, message: "Finalizing video..." },
|
| 44 |
+
created_at: new Date(timestamp).toISOString()
|
| 45 |
+
});
|
| 46 |
+
} else {
|
| 47 |
+
return NextResponse.json({
|
| 48 |
+
job_id: jobId,
|
| 49 |
+
status: "completed",
|
| 50 |
+
progress: { percentage: 100, message: "Completed" },
|
| 51 |
+
output_url: DEMO_VIDEO_URL,
|
| 52 |
+
created_at: new Date(timestamp).toISOString()
|
| 53 |
+
});
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Handle Real Jobs - Proxy to Python Backend
|
| 58 |
+
try {
|
| 59 |
+
const response = await fetch(`${PYTHON_API_URL}/api/jobs/${jobId}`);
|
| 60 |
+
|
| 61 |
+
if (!response.ok) {
|
| 62 |
+
return NextResponse.json(
|
| 63 |
+
{ error: "Failed to fetch job status" },
|
| 64 |
+
{ status: response.status }
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const data = await response.json();
|
| 69 |
+
return NextResponse.json(data);
|
| 70 |
+
} catch (error) {
|
| 71 |
+
console.error("Proxy error:", error);
|
| 72 |
+
return NextResponse.json(
|
| 73 |
+
{ error: "Internal server error" },
|
| 74 |
+
{ status: 500 }
|
| 75 |
+
);
|
| 76 |
+
}
|
| 77 |
+
}
|
frontend/src/app/api/videos/[id]/route.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
const PYTHON_API_URL = process.env.PYTHON_API_URL || "http://127.0.0.1:8000";
|
| 4 |
+
const DEMO_VIDEO_URL = "https://storage.googleapis.com/vidsimplify/GeneratedScene.mp4";
|
| 5 |
+
|
| 6 |
+
export async function GET(
|
| 7 |
+
req: Request,
|
| 8 |
+
{ params }: { params: { id: string } }
|
| 9 |
+
) {
|
| 10 |
+
const jobId = params.id;
|
| 11 |
+
|
| 12 |
+
// Handle Demo Jobs
|
| 13 |
+
if (jobId.startsWith("demo-")) {
|
| 14 |
+
return NextResponse.redirect(DEMO_VIDEO_URL);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// Handle Real Jobs - Proxy to Python Backend
|
| 18 |
+
try {
|
| 19 |
+
const response = await fetch(`${PYTHON_API_URL}/api/videos/${jobId}`);
|
| 20 |
+
|
| 21 |
+
if (!response.ok) {
|
| 22 |
+
return NextResponse.json(
|
| 23 |
+
{ error: "Failed to fetch video" },
|
| 24 |
+
{ status: response.status }
|
| 25 |
+
);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Forward the video content
|
| 29 |
+
const blob = await response.blob();
|
| 30 |
+
const headers = new Headers(response.headers);
|
| 31 |
+
|
| 32 |
+
return new NextResponse(blob, {
|
| 33 |
+
status: 200,
|
| 34 |
+
headers: headers
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
} catch (error) {
|
| 38 |
+
console.error("Proxy error:", error);
|
| 39 |
+
return NextResponse.json(
|
| 40 |
+
{ error: "Internal server error" },
|
| 41 |
+
{ status: 500 }
|
| 42 |
+
);
|
| 43 |
+
}
|
| 44 |
+
}
|
frontend/src/app/app/page.tsx
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Suspense } from "react";
|
| 2 |
+
import { VideoGenerator } from "@/components/app/VideoGenerator";
|
| 3 |
+
|
| 4 |
+
export const dynamic = "force-dynamic";
|
| 5 |
+
|
| 6 |
+
export default function AppPage() {
|
| 7 |
+
return (
|
| 8 |
+
<Suspense fallback={<div className="flex items-center justify-center h-screen bg-slate-950 text-slate-400">Loading...</div>}>
|
| 9 |
+
<VideoGenerator />
|
| 10 |
+
</Suspense>
|
| 11 |
+
);
|
| 12 |
+
}
|
frontend/src/app/auth/callback/route.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createServerClient, type CookieOptions } from '@supabase/ssr'
|
| 2 |
+
import { createClient } from '@supabase/supabase-js'
|
| 3 |
+
import { cookies } from 'next/headers'
|
| 4 |
+
import { NextResponse } from 'next/server'
|
| 5 |
+
|
| 6 |
+
export async function GET(request: Request) {
|
| 7 |
+
console.log("\n=== AUTH CALLBACK START ===");
|
| 8 |
+
const { searchParams, origin } = new URL(request.url)
|
| 9 |
+
const code = searchParams.get('code')
|
| 10 |
+
const next = searchParams.get('next') ?? '/'
|
| 11 |
+
|
| 12 |
+
console.log("Code present:", !!code);
|
| 13 |
+
console.log("Redirect target:", next);
|
| 14 |
+
|
| 15 |
+
if (code) {
|
| 16 |
+
const cookieStore = await cookies()
|
| 17 |
+
|
| 18 |
+
const supabase = createServerClient(
|
| 19 |
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
| 20 |
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
| 21 |
+
{
|
| 22 |
+
cookies: {
|
| 23 |
+
getAll() {
|
| 24 |
+
return cookieStore.getAll()
|
| 25 |
+
},
|
| 26 |
+
setAll(cookiesToSet) {
|
| 27 |
+
cookiesToSet.forEach(({ name, value, options }) => {
|
| 28 |
+
cookieStore.set(name, value, options)
|
| 29 |
+
})
|
| 30 |
+
},
|
| 31 |
+
},
|
| 32 |
+
}
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
console.log("Exchanging code for session...");
|
| 36 |
+
const { data: { session }, error } = await supabase.auth.exchangeCodeForSession(code)
|
| 37 |
+
|
| 38 |
+
if (error) {
|
| 39 |
+
console.error('❌ Auth exchange error:', error)
|
| 40 |
+
return NextResponse.redirect(`${origin}/auth/error?error=${encodeURIComponent(error.message)}`)
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if (session?.user) {
|
| 44 |
+
console.log("✅ Auth successful. User ID:", session.user.id);
|
| 45 |
+
console.log("User email:", session.user.email);
|
| 46 |
+
|
| 47 |
+
// SYNC USER TO DATABASE
|
| 48 |
+
console.log("Checking if user exists in DB...");
|
| 49 |
+
|
| 50 |
+
// Use the authenticated supabase client (has the session)
|
| 51 |
+
const { data: existingUser, error: fetchError } = await supabase
|
| 52 |
+
.from('users')
|
| 53 |
+
.select('id')
|
| 54 |
+
.eq('id', session.user.id)
|
| 55 |
+
.single();
|
| 56 |
+
|
| 57 |
+
console.log("Fetch result:", { existingUser, fetchError });
|
| 58 |
+
|
| 59 |
+
if (fetchError && fetchError.code !== 'PGRST116') {
|
| 60 |
+
console.error("❌ Error checking user existence:", fetchError);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
if (!existingUser) {
|
| 64 |
+
console.log("🔄 User not found in DB. Attempting insert...");
|
| 65 |
+
|
| 66 |
+
const insertData = {
|
| 67 |
+
id: session.user.id,
|
| 68 |
+
email: session.user.email,
|
| 69 |
+
full_name: session.user.user_metadata.full_name || null,
|
| 70 |
+
avatar_url: session.user.user_metadata.avatar_url || null,
|
| 71 |
+
credits: 5
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
console.log("Insert data:", insertData);
|
| 75 |
+
|
| 76 |
+
const { data: insertResult, error: insertError } = await supabase
|
| 77 |
+
.from('users')
|
| 78 |
+
.insert(insertData)
|
| 79 |
+
.select();
|
| 80 |
+
|
| 81 |
+
if (insertError) {
|
| 82 |
+
console.error("❌ INSERT FAILED:", insertError);
|
| 83 |
+
console.error("Error code:", insertError.code);
|
| 84 |
+
console.error("Error message:", insertError.message);
|
| 85 |
+
console.error("Error details:", insertError.details);
|
| 86 |
+
console.error("Error hint:", insertError.hint);
|
| 87 |
+
} else {
|
| 88 |
+
console.log("✅ Successfully inserted user:", insertResult);
|
| 89 |
+
}
|
| 90 |
+
} else {
|
| 91 |
+
console.log("✅ User already exists in DB");
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
console.log("Redirecting to:", `${origin}${next}`);
|
| 97 |
+
console.log("=== AUTH CALLBACK END ===\n");
|
| 98 |
+
return NextResponse.redirect(`${origin}${next}`)
|
| 99 |
+
}
|
frontend/src/app/billing/page.tsx
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
+
import { supabaseClient } from "@/lib/supabase-client";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 7 |
+
import { Loader2, CreditCard, Zap, CheckCircle, AlertCircle } from "lucide-react";
|
| 8 |
+
import { useRouter } from "next/navigation";
|
| 9 |
+
import Link from "next/link";
|
| 10 |
+
|
| 11 |
+
export default function BillingPage() {
|
| 12 |
+
const [user, setUser] = useState<any>(null);
|
| 13 |
+
const [credits, setCredits] = useState(0);
|
| 14 |
+
const [loading, setLoading] = useState(true);
|
| 15 |
+
const router = useRouter();
|
| 16 |
+
const supabase = supabaseClient;
|
| 17 |
+
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
const getData = async () => {
|
| 20 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 21 |
+
if (!user) {
|
| 22 |
+
router.push("/auth/signin");
|
| 23 |
+
return;
|
| 24 |
+
}
|
| 25 |
+
setUser(user);
|
| 26 |
+
|
| 27 |
+
// Fetch user credits only
|
| 28 |
+
const { data: userData } = await supabase
|
| 29 |
+
.from("users")
|
| 30 |
+
.select("credits")
|
| 31 |
+
.eq("id", user.id)
|
| 32 |
+
.single();
|
| 33 |
+
|
| 34 |
+
setCredits(userData?.credits ?? 0);
|
| 35 |
+
setLoading(false);
|
| 36 |
+
};
|
| 37 |
+
getData();
|
| 38 |
+
}, [supabase, router]);
|
| 39 |
+
|
| 40 |
+
if (loading) {
|
| 41 |
+
return (
|
| 42 |
+
<div className="flex h-screen items-center justify-center bg-slate-950">
|
| 43 |
+
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
| 44 |
+
</div>
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Static plan info for now
|
| 49 |
+
const plan = {
|
| 50 |
+
name: "Free",
|
| 51 |
+
price: "$0",
|
| 52 |
+
features: [
|
| 53 |
+
"5 video credits per month",
|
| 54 |
+
"720p video quality",
|
| 55 |
+
"Standard generation speed"
|
| 56 |
+
]
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
return (
|
| 60 |
+
<div className="min-h-screen bg-slate-950 text-slate-200 py-12 px-4 sm:px-6 lg:px-8">
|
| 61 |
+
<div className="max-w-4xl mx-auto space-y-8">
|
| 62 |
+
<div>
|
| 63 |
+
<h1 className="text-3xl font-bold text-white">Billing & Usage</h1>
|
| 64 |
+
<p className="text-slate-400 mt-2">Manage your subscription and view credit usage.</p>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<div className="grid gap-6 md:grid-cols-2">
|
| 68 |
+
{/* Current Plan */}
|
| 69 |
+
<Card className="bg-slate-900 border-slate-800">
|
| 70 |
+
<CardHeader>
|
| 71 |
+
<CardTitle className="text-white flex items-center gap-2">
|
| 72 |
+
<CreditCard className="h-5 w-5 text-blue-500" />
|
| 73 |
+
Current Plan
|
| 74 |
+
</CardTitle>
|
| 75 |
+
<CardDescription className="text-slate-400">
|
| 76 |
+
You are currently on the <span className="text-white font-medium">{plan.name} Tier</span>.
|
| 77 |
+
</CardDescription>
|
| 78 |
+
</CardHeader>
|
| 79 |
+
<CardContent className="space-y-6">
|
| 80 |
+
<div className="flex items-center justify-between p-4 bg-slate-950/50 rounded-lg border border-slate-800">
|
| 81 |
+
<div>
|
| 82 |
+
<p className="text-sm font-medium text-slate-300">{plan.name} Plan</p>
|
| 83 |
+
<p className="text-2xl font-bold text-white">{plan.price}<span className="text-sm text-slate-500 font-normal">/month</span></p>
|
| 84 |
+
</div>
|
| 85 |
+
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div className="space-y-2">
|
| 89 |
+
{plan.features.map((feature: string) => (
|
| 90 |
+
<div key={feature} className="flex items-center gap-2 text-sm text-slate-300">
|
| 91 |
+
<CheckCircle className="h-4 w-4 text-green-500" />
|
| 92 |
+
<span>{feature}</span>
|
| 93 |
+
</div>
|
| 94 |
+
))}
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<Button asChild className="w-full bg-gradient-to-r from-blue-600 to-violet-600 hover:from-blue-500 hover:to-violet-500 text-white border-0">
|
| 98 |
+
<Link href="/pricing">Upgrade Plan</Link>
|
| 99 |
+
</Button>
|
| 100 |
+
</CardContent>
|
| 101 |
+
</Card>
|
| 102 |
+
|
| 103 |
+
{/* Credit Usage */}
|
| 104 |
+
<Card className="bg-slate-900 border-slate-800">
|
| 105 |
+
<CardHeader>
|
| 106 |
+
<CardTitle className="text-white flex items-center gap-2">
|
| 107 |
+
<Zap className="h-5 w-5 text-yellow-500" />
|
| 108 |
+
Credit Usage
|
| 109 |
+
</CardTitle>
|
| 110 |
+
<CardDescription className="text-slate-400">
|
| 111 |
+
Your monthly generation credits.
|
| 112 |
+
</CardDescription>
|
| 113 |
+
</CardHeader>
|
| 114 |
+
<CardContent className="space-y-6">
|
| 115 |
+
<div className="space-y-2">
|
| 116 |
+
<div className="flex justify-between text-sm">
|
| 117 |
+
<span className="text-slate-300">Credits Remaining</span>
|
| 118 |
+
<span className="text-white font-mono">{credits} / 5</span>
|
| 119 |
+
</div>
|
| 120 |
+
<div className="h-3 bg-slate-950 rounded-full overflow-hidden border border-slate-800">
|
| 121 |
+
<div
|
| 122 |
+
className="h-full bg-gradient-to-r from-blue-500 to-violet-500 transition-all duration-500"
|
| 123 |
+
style={{ width: `${Math.min((credits / 5) * 100, 100)}%` }}
|
| 124 |
+
/>
|
| 125 |
+
</div>
|
| 126 |
+
<p className="text-xs text-slate-500 pt-1">
|
| 127 |
+
Credits reset on the 1st of every month.
|
| 128 |
+
</p>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
{credits === 0 && (
|
| 132 |
+
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex gap-3">
|
| 133 |
+
<AlertCircle className="h-5 w-5 text-red-400 shrink-0" />
|
| 134 |
+
<p className="text-sm text-red-300">
|
| 135 |
+
You have run out of credits. Upgrade your plan to continue generating videos.
|
| 136 |
+
</p>
|
| 137 |
+
</div>
|
| 138 |
+
)}
|
| 139 |
+
</CardContent>
|
| 140 |
+
</Card>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
{/* Invoice History Placeholder */}
|
| 144 |
+
<Card className="bg-slate-900 border-slate-800">
|
| 145 |
+
<CardHeader>
|
| 146 |
+
<CardTitle className="text-white">Billing History</CardTitle>
|
| 147 |
+
</CardHeader>
|
| 148 |
+
<CardContent>
|
| 149 |
+
<div className="text-center py-8 text-slate-500 text-sm">
|
| 150 |
+
No invoices found.
|
| 151 |
+
</div>
|
| 152 |
+
</CardContent>
|
| 153 |
+
</Card>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
);
|
| 157 |
+
}
|
frontend/src/app/favicon.ico
ADDED
|
|
frontend/src/app/globals.css
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--background: #020617;
|
| 5 |
+
--foreground: #f8fafc;
|
| 6 |
+
--card: #020617;
|
| 7 |
+
--card-foreground: #f8fafc;
|
| 8 |
+
--popover: #020617;
|
| 9 |
+
--popover-foreground: #f8fafc;
|
| 10 |
+
--primary: #2563eb;
|
| 11 |
+
--primary-foreground: #f8fafc;
|
| 12 |
+
--secondary: #1e293b;
|
| 13 |
+
--secondary-foreground: #f8fafc;
|
| 14 |
+
--muted: #1e293b;
|
| 15 |
+
--muted-foreground: #94a3b8;
|
| 16 |
+
--accent: #1e293b;
|
| 17 |
+
--accent-foreground: #f8fafc;
|
| 18 |
+
--destructive: #7f1d1d;
|
| 19 |
+
--destructive-foreground: #f8fafc;
|
| 20 |
+
--border: #1e293b;
|
| 21 |
+
--input: #1e293b;
|
| 22 |
+
--ring: #1d4ed8;
|
| 23 |
+
--radius: 0.5rem;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
@theme inline {
|
| 27 |
+
--color-background: var(--background);
|
| 28 |
+
--color-foreground: var(--foreground);
|
| 29 |
+
--color-card: var(--card);
|
| 30 |
+
--color-card-foreground: var(--card-foreground);
|
| 31 |
+
--color-popover: var(--popover);
|
| 32 |
+
--color-popover-foreground: var(--popover-foreground);
|
| 33 |
+
--color-primary: var(--primary);
|
| 34 |
+
--color-primary-foreground: var(--primary-foreground);
|
| 35 |
+
--color-secondary: var(--secondary);
|
| 36 |
+
--color-secondary-foreground: var(--secondary-foreground);
|
| 37 |
+
--color-muted: var(--muted);
|
| 38 |
+
--color-muted-foreground: var(--muted-foreground);
|
| 39 |
+
--color-accent: var(--accent);
|
| 40 |
+
--color-accent-foreground: var(--accent-foreground);
|
| 41 |
+
--color-destructive: var(--destructive);
|
| 42 |
+
--color-destructive-foreground: var(--destructive-foreground);
|
| 43 |
+
--color-border: var(--border);
|
| 44 |
+
--color-input: var(--input);
|
| 45 |
+
--color-ring: var(--ring);
|
| 46 |
+
--radius-sm: calc(var(--radius) - 2px);
|
| 47 |
+
--radius-md: calc(var(--radius) - 2px);
|
| 48 |
+
--radius-lg: var(--radius);
|
| 49 |
+
--font-sans: var(--font-inter);
|
| 50 |
+
--font-mono: monospace;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
body {
|
| 54 |
+
background: var(--background);
|
| 55 |
+
color: var(--foreground);
|
| 56 |
+
font-family: Arial, Helvetica, sans-serif;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/* Strictly hide scrollbars for everything */
|
| 60 |
+
::-webkit-scrollbar {
|
| 61 |
+
display: none !important;
|
| 62 |
+
width: 0 !important;
|
| 63 |
+
height: 0 !important;
|
| 64 |
+
background: transparent !important;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
html,
|
| 68 |
+
body,
|
| 69 |
+
* {
|
| 70 |
+
-ms-overflow-style: none !important;
|
| 71 |
+
/* IE and Edge */
|
| 72 |
+
scrollbar-width: none !important;
|
| 73 |
+
/* Firefox */
|
| 74 |
+
}
|
frontend/src/app/layout.tsx
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Inter } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
import { cn } from "@/lib/utils";
|
| 5 |
+
|
| 6 |
+
const inter = Inter({
|
| 7 |
+
subsets: ["latin"],
|
| 8 |
+
variable: "--font-inter",
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
export const metadata: Metadata = {
|
| 12 |
+
title: "VidSimplify - AI-Powered Animation Generator",
|
| 13 |
+
description: "Transform text, PDFs, and URLs into stunning educational animations powered by AI and Manim",
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
// ... imports
|
| 19 |
+
|
| 20 |
+
export default function RootLayout({
|
| 21 |
+
children,
|
| 22 |
+
}: Readonly<{
|
| 23 |
+
children: React.ReactNode;
|
| 24 |
+
}>) {
|
| 25 |
+
return (
|
| 26 |
+
<html lang="en">
|
| 27 |
+
<body className={cn(
|
| 28 |
+
"min-h-screen bg-slate-950 font-sans antialiased",
|
| 29 |
+
inter.variable
|
| 30 |
+
)}>
|
| 31 |
+
{children}
|
| 32 |
+
</body>
|
| 33 |
+
</html>
|
| 34 |
+
);
|
| 35 |
+
}
|
frontend/src/app/page.tsx
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Navbar } from "@/components/landing/Navbar";
|
| 2 |
+
import { Hero } from "@/components/landing/Hero";
|
| 3 |
+
import { Showcase } from "@/components/landing/Showcase";
|
| 4 |
+
import { Footer } from "@/components/landing/Footer";
|
| 5 |
+
import { Check, Edit, Layers, Zap } from "lucide-react";
|
| 6 |
+
|
| 7 |
+
export default function Home() {
|
| 8 |
+
return (
|
| 9 |
+
<main className="min-h-screen bg-slate-950">
|
| 10 |
+
<Navbar />
|
| 11 |
+
<Hero />
|
| 12 |
+
<Showcase />
|
| 13 |
+
|
| 14 |
+
{/* Problem vs Solution Section */}
|
| 15 |
+
<section className="py-24 bg-slate-900">
|
| 16 |
+
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
| 17 |
+
<div className="grid md:grid-cols-2 gap-16 items-center">
|
| 18 |
+
<div>
|
| 19 |
+
<h2 className="text-3xl font-bold text-white mb-6">
|
| 20 |
+
The Problem with <span className="text-red-400">Random AI Video</span>
|
| 21 |
+
</h2>
|
| 22 |
+
<ul className="space-y-4">
|
| 23 |
+
{[
|
| 24 |
+
"Hallucinated facts and visuals",
|
| 25 |
+
"Inconsistent style and branding",
|
| 26 |
+
"Impossible to edit specific details",
|
| 27 |
+
"Black box generation process"
|
| 28 |
+
].map((item, i) => (
|
| 29 |
+
<li key={i} className="flex items-center text-slate-400">
|
| 30 |
+
<div className="h-2 w-2 rounded-full bg-red-500 mr-3" />
|
| 31 |
+
{item}
|
| 32 |
+
</li>
|
| 33 |
+
))}
|
| 34 |
+
</ul>
|
| 35 |
+
</div>
|
| 36 |
+
<div>
|
| 37 |
+
<h2 className="text-3xl font-bold text-white mb-6">
|
| 38 |
+
The <span className="text-blue-400">VidSimplify</span> Solution
|
| 39 |
+
</h2>
|
| 40 |
+
<ul className="space-y-4">
|
| 41 |
+
{[
|
| 42 |
+
"Mathematically precise animations",
|
| 43 |
+
"Granular control to edit every detail",
|
| 44 |
+
"Consistent, professional styling",
|
| 45 |
+
"Explainable and transparent generation"
|
| 46 |
+
].map((item, i) => (
|
| 47 |
+
<li key={i} className="flex items-center text-slate-300">
|
| 48 |
+
<div className="h-6 w-6 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center mr-3">
|
| 49 |
+
<Check className="h-4 w-4" />
|
| 50 |
+
</div>
|
| 51 |
+
{item}
|
| 52 |
+
</li>
|
| 53 |
+
))}
|
| 54 |
+
</ul>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
</section>
|
| 59 |
+
|
| 60 |
+
{/* Features Grid */}
|
| 61 |
+
<section id="features" className="py-24 bg-slate-950">
|
| 62 |
+
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
| 63 |
+
<div className="text-center mb-16">
|
| 64 |
+
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">
|
| 65 |
+
Engineered for Complexity
|
| 66 |
+
</h2>
|
| 67 |
+
<p className="mt-4 text-lg text-slate-400 max-w-2xl mx-auto">
|
| 68 |
+
In a growing complex world, we need tools that can explain the working of any system, research, or product in depth.
|
| 69 |
+
</p>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
| 73 |
+
{[
|
| 74 |
+
{
|
| 75 |
+
title: "Multi-Dimensional",
|
| 76 |
+
description: "Generate 2D and 3D visualizations that capture every angle of your concept.",
|
| 77 |
+
icon: <Layers className="h-8 w-8 text-blue-400" />
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
title: "Fully Editable",
|
| 81 |
+
description: "Don't like a color? Want to change a speed? Tweak parameters instantly.",
|
| 82 |
+
icon: <Edit className="h-8 w-8 text-violet-400" />
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
title: "Instant Repurposing",
|
| 86 |
+
description: "Generate once, then tweak for different formats, audiences, and platforms.",
|
| 87 |
+
icon: <Zap className="h-8 w-8 text-indigo-400" />
|
| 88 |
+
}
|
| 89 |
+
].map((feature, i) => (
|
| 90 |
+
<div key={i} className="bg-slate-900/50 border border-white/5 p-8 rounded-2xl hover:bg-slate-900 transition-colors">
|
| 91 |
+
<div className="mb-6 bg-slate-800/50 w-16 h-16 rounded-xl flex items-center justify-center border border-white/5">
|
| 92 |
+
{feature.icon}
|
| 93 |
+
</div>
|
| 94 |
+
<h3 className="text-xl font-semibold text-white mb-3">{feature.title}</h3>
|
| 95 |
+
<p className="text-slate-400 leading-relaxed">{feature.description}</p>
|
| 96 |
+
</div>
|
| 97 |
+
))}
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
</section>
|
| 101 |
+
|
| 102 |
+
<Footer />
|
| 103 |
+
</main>
|
| 104 |
+
);
|
| 105 |
+
}
|
frontend/src/app/pricing/page.tsx
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { Check } from "lucide-react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import Link from "next/link";
|
| 6 |
+
import { cn } from "@/lib/utils";
|
| 7 |
+
|
| 8 |
+
export default function PricingPage() {
|
| 9 |
+
const tiers = [
|
| 10 |
+
{
|
| 11 |
+
name: "Free",
|
| 12 |
+
price: "$0",
|
| 13 |
+
description: "Perfect for experimenting and personal projects.",
|
| 14 |
+
features: [
|
| 15 |
+
"5 video credits per month",
|
| 16 |
+
"720p video quality",
|
| 17 |
+
"Standard generation speed",
|
| 18 |
+
"Public community access",
|
| 19 |
+
],
|
| 20 |
+
cta: "Get Started",
|
| 21 |
+
href: "/app",
|
| 22 |
+
popular: false,
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
name: "Pro",
|
| 26 |
+
price: "$29",
|
| 27 |
+
description: "For content creators and professionals.",
|
| 28 |
+
features: [
|
| 29 |
+
"50 video credits per month",
|
| 30 |
+
"1080p HD video quality",
|
| 31 |
+
"Fast generation speed",
|
| 32 |
+
"Priority support",
|
| 33 |
+
"Commercial usage rights",
|
| 34 |
+
"Remove watermarks",
|
| 35 |
+
],
|
| 36 |
+
cta: "Upgrade to Pro",
|
| 37 |
+
href: "/auth/signin",
|
| 38 |
+
popular: true,
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
name: "Enterprise",
|
| 42 |
+
price: "Custom",
|
| 43 |
+
description: "For teams and high-volume needs.",
|
| 44 |
+
features: [
|
| 45 |
+
"Unlimited video credits",
|
| 46 |
+
"4K video quality",
|
| 47 |
+
"Instant generation",
|
| 48 |
+
"Dedicated account manager",
|
| 49 |
+
"Custom branding & templates",
|
| 50 |
+
"API access",
|
| 51 |
+
],
|
| 52 |
+
cta: "Contact Sales",
|
| 53 |
+
href: "mailto:sales@vidsimplify.com",
|
| 54 |
+
popular: false,
|
| 55 |
+
},
|
| 56 |
+
];
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div className="min-h-screen bg-slate-950 text-slate-200 py-24 px-4 sm:px-6 lg:px-8">
|
| 60 |
+
<div className="max-w-7xl mx-auto">
|
| 61 |
+
<div className="text-center mb-16">
|
| 62 |
+
<h1 className="text-4xl font-bold tracking-tight text-white sm:text-5xl mb-4">
|
| 63 |
+
Simple, transparent pricing
|
| 64 |
+
</h1>
|
| 65 |
+
<p className="text-xl text-slate-400 max-w-2xl mx-auto">
|
| 66 |
+
Choose the plan that best fits your needs. All plans include access to our core AI generation features.
|
| 67 |
+
</p>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
| 71 |
+
{tiers.map((tier) => (
|
| 72 |
+
<div
|
| 73 |
+
key={tier.name}
|
| 74 |
+
className={cn(
|
| 75 |
+
"relative flex flex-col p-8 bg-slate-900/50 backdrop-blur-sm border rounded-2xl transition-all duration-200 hover:scale-105",
|
| 76 |
+
tier.popular
|
| 77 |
+
? "border-blue-500/50 shadow-2xl shadow-blue-500/10 z-10 scale-105"
|
| 78 |
+
: "border-white/10 hover:border-white/20"
|
| 79 |
+
)}
|
| 80 |
+
>
|
| 81 |
+
{tier.popular && (
|
| 82 |
+
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-gradient-to-r from-blue-600 to-violet-600 text-white text-sm font-medium rounded-full shadow-lg">
|
| 83 |
+
Most Popular
|
| 84 |
+
</div>
|
| 85 |
+
)}
|
| 86 |
+
|
| 87 |
+
<div className="mb-8">
|
| 88 |
+
<h3 className="text-lg font-semibold text-white mb-2">{tier.name}</h3>
|
| 89 |
+
<div className="flex items-baseline gap-1">
|
| 90 |
+
<span className="text-4xl font-bold text-white">{tier.price}</span>
|
| 91 |
+
{tier.price !== "Custom" && <span className="text-slate-500">/month</span>}
|
| 92 |
+
</div>
|
| 93 |
+
<p className="mt-4 text-sm text-slate-400">{tier.description}</p>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<ul className="space-y-4 mb-8 flex-1">
|
| 97 |
+
{tier.features.map((feature) => (
|
| 98 |
+
<li key={feature} className="flex items-start gap-3 text-sm text-slate-300">
|
| 99 |
+
<Check className="h-5 w-5 text-blue-500 shrink-0" />
|
| 100 |
+
<span>{feature}</span>
|
| 101 |
+
</li>
|
| 102 |
+
))}
|
| 103 |
+
</ul>
|
| 104 |
+
|
| 105 |
+
<Button
|
| 106 |
+
asChild
|
| 107 |
+
className={cn(
|
| 108 |
+
"w-full",
|
| 109 |
+
tier.popular
|
| 110 |
+
? "bg-blue-600 hover:bg-blue-700 text-white"
|
| 111 |
+
: "bg-slate-800 hover:bg-slate-700 text-white"
|
| 112 |
+
)}
|
| 113 |
+
variant={tier.popular ? "default" : "outline"}
|
| 114 |
+
>
|
| 115 |
+
<Link href={tier.href}>{tier.cta}</Link>
|
| 116 |
+
</Button>
|
| 117 |
+
</div>
|
| 118 |
+
))}
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
);
|
| 123 |
+
}
|
frontend/src/app/profile/page.tsx
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
+
import { supabaseClient } from "@/lib/supabase-client";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Input } from "@/components/ui/input";
|
| 7 |
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
| 8 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 9 |
+
import { Loader2, User, Mail, Save } from "lucide-react";
|
| 10 |
+
import { useRouter } from "next/navigation";
|
| 11 |
+
|
| 12 |
+
export default function ProfilePage() {
|
| 13 |
+
const [user, setUser] = useState<any>(null);
|
| 14 |
+
const [loading, setLoading] = useState(true);
|
| 15 |
+
const [fullName, setFullName] = useState("");
|
| 16 |
+
const [saving, setSaving] = useState(false);
|
| 17 |
+
const router = useRouter();
|
| 18 |
+
const supabase = supabaseClient;
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
const getUser = async () => {
|
| 22 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 23 |
+
if (!user) {
|
| 24 |
+
router.push("/auth/signin");
|
| 25 |
+
return;
|
| 26 |
+
}
|
| 27 |
+
setUser(user);
|
| 28 |
+
setFullName(user.user_metadata?.full_name || "");
|
| 29 |
+
setLoading(false);
|
| 30 |
+
};
|
| 31 |
+
getUser();
|
| 32 |
+
}, [supabase, router]);
|
| 33 |
+
|
| 34 |
+
const handleUpdateProfile = async () => {
|
| 35 |
+
setSaving(true);
|
| 36 |
+
const { error } = await supabase.auth.updateUser({
|
| 37 |
+
data: { full_name: fullName },
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
if (error) {
|
| 41 |
+
alert("Error updating profile");
|
| 42 |
+
} else {
|
| 43 |
+
alert("Profile updated successfully");
|
| 44 |
+
router.refresh();
|
| 45 |
+
}
|
| 46 |
+
setSaving(false);
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
if (loading) {
|
| 50 |
+
return (
|
| 51 |
+
<div className="flex h-screen items-center justify-center bg-slate-950">
|
| 52 |
+
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
| 53 |
+
</div>
|
| 54 |
+
);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
return (
|
| 58 |
+
<div className="min-h-screen bg-slate-950 text-slate-200 py-12 px-4 sm:px-6 lg:px-8">
|
| 59 |
+
<div className="max-w-2xl mx-auto space-y-8">
|
| 60 |
+
<div>
|
| 61 |
+
<h1 className="text-3xl font-bold text-white">Profile Settings</h1>
|
| 62 |
+
<p className="text-slate-400 mt-2">Manage your account settings and preferences.</p>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<Card className="bg-slate-900 border-slate-800">
|
| 66 |
+
<CardHeader>
|
| 67 |
+
<CardTitle className="text-white">Personal Information</CardTitle>
|
| 68 |
+
<CardDescription className="text-slate-400">
|
| 69 |
+
Update your personal details here.
|
| 70 |
+
</CardDescription>
|
| 71 |
+
</CardHeader>
|
| 72 |
+
<CardContent className="space-y-6">
|
| 73 |
+
<div className="flex items-center gap-6">
|
| 74 |
+
<Avatar className="h-20 w-20 border-2 border-slate-700">
|
| 75 |
+
<AvatarImage src={user?.user_metadata?.avatar_url} />
|
| 76 |
+
<AvatarFallback className="text-lg bg-slate-800 text-slate-300">
|
| 77 |
+
{user?.email?.charAt(0).toUpperCase()}
|
| 78 |
+
</AvatarFallback>
|
| 79 |
+
</Avatar>
|
| 80 |
+
<div>
|
| 81 |
+
<p className="text-sm font-medium text-slate-300 mb-1">Profile Picture</p>
|
| 82 |
+
<p className="text-xs text-slate-500">
|
| 83 |
+
Managed by your identity provider (Google).
|
| 84 |
+
</p>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div className="space-y-2">
|
| 89 |
+
<label className="text-sm font-medium text-slate-300">Full Name</label>
|
| 90 |
+
<div className="relative">
|
| 91 |
+
<User className="absolute left-3 top-3 h-4 w-4 text-slate-500" />
|
| 92 |
+
<Input
|
| 93 |
+
value={fullName}
|
| 94 |
+
onChange={(e) => setFullName(e.target.value)}
|
| 95 |
+
className="pl-9 bg-slate-950 border-slate-700 text-slate-200 focus:border-blue-500"
|
| 96 |
+
/>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div className="space-y-2">
|
| 101 |
+
<label className="text-sm font-medium text-slate-300">Email Address</label>
|
| 102 |
+
<div className="relative">
|
| 103 |
+
<Mail className="absolute left-3 top-3 h-4 w-4 text-slate-500" />
|
| 104 |
+
<Input
|
| 105 |
+
value={user?.email}
|
| 106 |
+
disabled
|
| 107 |
+
className="pl-9 bg-slate-950/50 border-slate-800 text-slate-500 cursor-not-allowed"
|
| 108 |
+
/>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<div className="pt-4">
|
| 113 |
+
<Button
|
| 114 |
+
onClick={handleUpdateProfile}
|
| 115 |
+
disabled={saving}
|
| 116 |
+
className="bg-blue-600 hover:bg-blue-700 text-white"
|
| 117 |
+
>
|
| 118 |
+
{saving ? (
|
| 119 |
+
<>
|
| 120 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 121 |
+
Saving...
|
| 122 |
+
</>
|
| 123 |
+
) : (
|
| 124 |
+
<>
|
| 125 |
+
<Save className="mr-2 h-4 w-4" />
|
| 126 |
+
Save Changes
|
| 127 |
+
</>
|
| 128 |
+
)}
|
| 129 |
+
</Button>
|
| 130 |
+
</div>
|
| 131 |
+
</CardContent>
|
| 132 |
+
</Card>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
);
|
| 136 |
+
}
|
frontend/src/components/app/VideoGenerator.tsx
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from "react";
|
| 4 |
+
import { useSearchParams } from "next/navigation";
|
| 5 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 6 |
+
import { Loader2, Upload, Link as LinkIcon, FileText, CheckCircle, AlertCircle, Download, Play, Layout, Clock, Settings, Menu, X, Video, ChevronRight, Sparkles } from "lucide-react";
|
| 7 |
+
import { Button } from "@/components/ui/button";
|
| 8 |
+
import { Input, Textarea } from "@/components/ui/input";
|
| 9 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 10 |
+
import { api, type InputType, type Category, type JobStatus } from "@/lib/api";
|
| 11 |
+
import { cn } from "@/lib/utils";
|
| 12 |
+
import Link from "next/link";
|
| 13 |
+
import { supabaseClient } from "@/lib/supabase-client";
|
| 14 |
+
|
| 15 |
+
export function VideoGenerator() {
|
| 16 |
+
const searchParams = useSearchParams();
|
| 17 |
+
const [inputType, setInputType] = useState<InputType>("text");
|
| 18 |
+
const [category, setCategory] = useState<Category>("tech_system");
|
| 19 |
+
const [content, setContent] = useState("");
|
| 20 |
+
const [isGenerating, setIsGenerating] = useState(false);
|
| 21 |
+
const [currentJob, setCurrentJob] = useState<JobStatus | null>(null);
|
| 22 |
+
const [error, setError] = useState<string | null>(null);
|
| 23 |
+
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
| 24 |
+
const [credits, setCredits] = useState(0);
|
| 25 |
+
const [demoVideoUrl, setDemoVideoUrl] = useState<string | null>(null);
|
| 26 |
+
|
| 27 |
+
const supabase = supabaseClient;
|
| 28 |
+
|
| 29 |
+
// Fetch credits
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
const fetchCredits = async () => {
|
| 32 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 33 |
+
if (user) {
|
| 34 |
+
const { data } = await supabase
|
| 35 |
+
.from("users")
|
| 36 |
+
.select("credits")
|
| 37 |
+
.eq("id", user.id)
|
| 38 |
+
.single();
|
| 39 |
+
setCredits(data?.credits ?? 0);
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
fetchCredits();
|
| 43 |
+
}, [supabase]);
|
| 44 |
+
|
| 45 |
+
// Initialize from URL params
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
const promptParam = searchParams.get("prompt");
|
| 48 |
+
const categoryParam = searchParams.get("category");
|
| 49 |
+
const demoUrlParam = searchParams.get("demoVideoUrl");
|
| 50 |
+
|
| 51 |
+
if (promptParam) {
|
| 52 |
+
setContent(promptParam);
|
| 53 |
+
setInputType("text");
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
if (categoryParam && ["tech_system", "product_startup", "mathematical"].includes(categoryParam)) {
|
| 57 |
+
setCategory(categoryParam as Category);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
if (demoUrlParam) {
|
| 61 |
+
setDemoVideoUrl(demoUrlParam);
|
| 62 |
+
// Removed auto-start: simulateGeneration(demoUrlParam);
|
| 63 |
+
} else {
|
| 64 |
+
setDemoVideoUrl(null);
|
| 65 |
+
}
|
| 66 |
+
}, [searchParams]);
|
| 67 |
+
|
| 68 |
+
const simulateGeneration = (videoUrl: string) => {
|
| 69 |
+
setIsGenerating(true);
|
| 70 |
+
const jobId = "demo-" + Date.now();
|
| 71 |
+
|
| 72 |
+
const steps = [
|
| 73 |
+
{ percentage: 10, message: "Analyzing Input..." },
|
| 74 |
+
{ percentage: 30, message: "Generating Script..." },
|
| 75 |
+
{ percentage: 60, message: "Validating Code..." },
|
| 76 |
+
{ percentage: 85, message: "Rendering Frames..." },
|
| 77 |
+
{ percentage: 100, message: "Finalizing..." }
|
| 78 |
+
];
|
| 79 |
+
|
| 80 |
+
let step = 0;
|
| 81 |
+
// Clear any existing job
|
| 82 |
+
setCurrentJob({
|
| 83 |
+
job_id: jobId,
|
| 84 |
+
status: "pending",
|
| 85 |
+
progress: { percentage: 0, message: "Initializing..." },
|
| 86 |
+
created_at: new Date().toISOString()
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
const interval = setInterval(() => {
|
| 90 |
+
if (step >= steps.length) {
|
| 91 |
+
clearInterval(interval);
|
| 92 |
+
setCurrentJob({
|
| 93 |
+
job_id: jobId,
|
| 94 |
+
status: "completed",
|
| 95 |
+
progress: { percentage: 100, message: "Completed" },
|
| 96 |
+
created_at: new Date().toISOString()
|
| 97 |
+
});
|
| 98 |
+
setIsGenerating(false);
|
| 99 |
+
return;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
setCurrentJob({
|
| 103 |
+
job_id: jobId,
|
| 104 |
+
status: "rendering",
|
| 105 |
+
progress: steps[step],
|
| 106 |
+
created_at: new Date().toISOString()
|
| 107 |
+
});
|
| 108 |
+
step++;
|
| 109 |
+
}, 1000); // 1 second per step for a nice flow
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
// Poll for job status
|
| 113 |
+
useEffect(() => {
|
| 114 |
+
let interval: NodeJS.Timeout;
|
| 115 |
+
|
| 116 |
+
// Only poll if it's NOT a demo job (demo jobs start with "demo-")
|
| 117 |
+
if (currentJob && ["pending", "generating_code", "rendering"].includes(currentJob.status) && !currentJob.job_id.startsWith("demo-")) {
|
| 118 |
+
interval = setInterval(async () => {
|
| 119 |
+
try {
|
| 120 |
+
const status = await api.getJobStatus(currentJob.job_id);
|
| 121 |
+
setCurrentJob(status);
|
| 122 |
+
|
| 123 |
+
if (status.status === "failed") {
|
| 124 |
+
setError(status.error || "Job failed");
|
| 125 |
+
setIsGenerating(false);
|
| 126 |
+
} else if (status.status === "completed") {
|
| 127 |
+
setIsGenerating(false);
|
| 128 |
+
}
|
| 129 |
+
} catch (e) {
|
| 130 |
+
console.error("Polling error", e);
|
| 131 |
+
}
|
| 132 |
+
}, 2000);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
return () => clearInterval(interval);
|
| 136 |
+
}, [currentJob]);
|
| 137 |
+
|
| 138 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 139 |
+
e.preventDefault();
|
| 140 |
+
if (!content) return;
|
| 141 |
+
|
| 142 |
+
// If we have a demo video URL, simulate the generation instead of calling the API
|
| 143 |
+
if (demoVideoUrl) {
|
| 144 |
+
simulateGeneration(demoVideoUrl);
|
| 145 |
+
return;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
setIsGenerating(true);
|
| 149 |
+
setError(null);
|
| 150 |
+
setCurrentJob(null);
|
| 151 |
+
// setDemoVideoUrl(null); // Don't clear it here, we might want to keep it if user retries?
|
| 152 |
+
// Actually, if it's a real generation, we should probably clear it.
|
| 153 |
+
// But wait, if we are here, demoVideoUrl is NULL (because of the check above).
|
| 154 |
+
// So we don't need to clear it.
|
| 155 |
+
|
| 156 |
+
try {
|
| 157 |
+
const job = await api.createVideo(content, inputType, category);
|
| 158 |
+
setCurrentJob({
|
| 159 |
+
job_id: job.job_id,
|
| 160 |
+
status: "pending",
|
| 161 |
+
progress: { percentage: 0, message: "Initializing system..." },
|
| 162 |
+
created_at: new Date().toISOString()
|
| 163 |
+
});
|
| 164 |
+
// Optimistically update credits
|
| 165 |
+
setCredits(prev => Math.max(0, prev - 1));
|
| 166 |
+
} catch (e: any) {
|
| 167 |
+
setError(e.message);
|
| 168 |
+
setIsGenerating(false);
|
| 169 |
+
}
|
| 170 |
+
};
|
| 171 |
+
|
| 172 |
+
const categories: { value: Category; label: string; description: string }[] = [
|
| 173 |
+
{ value: "tech_system", label: "Tech & Systems", description: "Architecture, Data Flow, APIs" },
|
| 174 |
+
{ value: "product_startup", label: "Product Demo", description: "Features, Value Prop, UI/UX" },
|
| 175 |
+
{ value: "mathematical", label: "Math & Research", description: "Equations, Graphs, Concepts" },
|
| 176 |
+
];
|
| 177 |
+
|
| 178 |
+
const SHOWCASE_KEYWORDS = [
|
| 179 |
+
"database sharding",
|
| 180 |
+
"kafka",
|
| 181 |
+
"transformers",
|
| 182 |
+
"quantum entanglement",
|
| 183 |
+
"netflix",
|
| 184 |
+
"black hole",
|
| 185 |
+
"sorting algorithms",
|
| 186 |
+
"uber",
|
| 187 |
+
"sorting",
|
| 188 |
+
"bubble sort",
|
| 189 |
+
"url shortener"
|
| 190 |
+
];
|
| 191 |
+
|
| 192 |
+
const isShowcasePrompt = SHOWCASE_KEYWORDS.some(keyword => (content || "").toLowerCase().includes(keyword));
|
| 193 |
+
const canEdit = credits > 0;
|
| 194 |
+
const canGenerate = canEdit || isShowcasePrompt;
|
| 195 |
+
|
| 196 |
+
return (
|
| 197 |
+
<div className="flex h-screen bg-slate-950 overflow-hidden">
|
| 198 |
+
{/* Mobile Sidebar Overlay */}
|
| 199 |
+
<AnimatePresence>
|
| 200 |
+
{isSidebarOpen && (
|
| 201 |
+
<motion.div
|
| 202 |
+
initial={{ opacity: 0 }}
|
| 203 |
+
animate={{ opacity: 1 }}
|
| 204 |
+
exit={{ opacity: 0 }}
|
| 205 |
+
onClick={() => setIsSidebarOpen(false)}
|
| 206 |
+
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden"
|
| 207 |
+
/>
|
| 208 |
+
)}
|
| 209 |
+
</AnimatePresence>
|
| 210 |
+
|
| 211 |
+
{/* Sidebar */}
|
| 212 |
+
<motion.div
|
| 213 |
+
className={cn(
|
| 214 |
+
"fixed inset-y-0 left-0 z-50 w-72 bg-slate-900/50 backdrop-blur-xl border-r border-white/5 flex flex-col transition-transform duration-300 md:translate-x-0 md:static",
|
| 215 |
+
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
|
| 216 |
+
)}
|
| 217 |
+
>
|
| 218 |
+
<div className="p-6 border-b border-white/5 flex items-center justify-between">
|
| 219 |
+
<Link href="/" className="flex items-center gap-2">
|
| 220 |
+
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-violet-600">
|
| 221 |
+
<Video className="h-4 w-4 text-white" />
|
| 222 |
+
</div>
|
| 223 |
+
<span className="text-lg font-bold text-white">VidSimplify</span>
|
| 224 |
+
</Link>
|
| 225 |
+
<button onClick={() => setIsSidebarOpen(false)} className="md:hidden text-slate-400 hover:text-white">
|
| 226 |
+
<X className="h-5 w-5" />
|
| 227 |
+
</button>
|
| 228 |
+
</div>
|
| 229 |
+
|
| 230 |
+
<div className="flex-1 py-6 px-4 space-y-2 overflow-y-auto custom-scrollbar">
|
| 231 |
+
<Button variant="ghost" className="w-full justify-start text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 hover:text-blue-300 font-medium">
|
| 232 |
+
<Layout className="mr-3 h-4 w-4" />
|
| 233 |
+
Create New
|
| 234 |
+
</Button>
|
| 235 |
+
<Button variant="ghost" className="w-full justify-start text-slate-400 hover:text-white hover:bg-white/5">
|
| 236 |
+
<Clock className="mr-3 h-4 w-4" />
|
| 237 |
+
History
|
| 238 |
+
</Button>
|
| 239 |
+
<Button variant="ghost" className="w-full justify-start text-slate-400 hover:text-white hover:bg-white/5">
|
| 240 |
+
<Settings className="mr-3 h-4 w-4" />
|
| 241 |
+
Settings
|
| 242 |
+
</Button>
|
| 243 |
+
</div>
|
| 244 |
+
|
| 245 |
+
<div className="p-4 border-t border-white/5 bg-slate-900/50">
|
| 246 |
+
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-4 border border-white/5">
|
| 247 |
+
<div className="flex justify-between items-center mb-2">
|
| 248 |
+
<div className="text-xs font-medium text-slate-300">Credits</div>
|
| 249 |
+
<div className="text-xs font-mono text-blue-400">{credits} / 5</div>
|
| 250 |
+
</div>
|
| 251 |
+
<div className="h-1.5 bg-slate-700/50 rounded-full overflow-hidden mb-3">
|
| 252 |
+
<div
|
| 253 |
+
className="h-full bg-gradient-to-r from-blue-500 to-violet-500 rounded-full transition-all duration-500"
|
| 254 |
+
style={{ width: `${Math.min((credits / 5) * 100, 100)}%` }}
|
| 255 |
+
></div>
|
| 256 |
+
</div>
|
| 257 |
+
<Button
|
| 258 |
+
size="sm"
|
| 259 |
+
variant="outline"
|
| 260 |
+
className="w-full text-xs h-8 border-slate-700 hover:bg-slate-800 text-slate-300"
|
| 261 |
+
onClick={() => window.open("https://calendly.com/aditya-vidsimplify/demo-call", "_blank")}
|
| 262 |
+
>
|
| 263 |
+
Upgrade Plan
|
| 264 |
+
</Button>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
</motion.div>
|
| 268 |
+
|
| 269 |
+
{/* Main Content */}
|
| 270 |
+
<div className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
|
| 271 |
+
{/* App Header */}
|
| 272 |
+
<header className="h-16 border-b border-white/5 bg-slate-950/50 backdrop-blur-md flex items-center justify-between px-4 sm:px-8 z-30">
|
| 273 |
+
<div className="flex items-center gap-4">
|
| 274 |
+
<button onClick={() => setIsSidebarOpen(true)} className="md:hidden text-slate-400 hover:text-white p-1">
|
| 275 |
+
<Menu className="h-6 w-6" />
|
| 276 |
+
</button>
|
| 277 |
+
<h1 className="text-lg font-semibold text-white flex items-center gap-2">
|
| 278 |
+
<span className="text-slate-500 font-normal">Project /</span> Untitled Animation
|
| 279 |
+
</h1>
|
| 280 |
+
</div>
|
| 281 |
+
<div className="flex items-center gap-4">
|
| 282 |
+
<Button
|
| 283 |
+
variant="ghost"
|
| 284 |
+
size="sm"
|
| 285 |
+
className="hidden sm:flex text-slate-400 hover:text-white font-medium"
|
| 286 |
+
onClick={() => window.open("https://calendly.com/aditya-vidsimplify/demo-call", "_blank")}
|
| 287 |
+
>
|
| 288 |
+
Book a Call
|
| 289 |
+
</Button>
|
| 290 |
+
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-green-500/10 border border-green-500/20 text-xs font-medium text-green-400">
|
| 291 |
+
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
| 292 |
+
System Operational
|
| 293 |
+
</div>
|
| 294 |
+
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-500 to-violet-500 border border-white/20 shadow-lg" />
|
| 295 |
+
</div>
|
| 296 |
+
</header>
|
| 297 |
+
|
| 298 |
+
<main className="flex-1 overflow-y-auto p-4 sm:p-8 custom-scrollbar">
|
| 299 |
+
<div className="max-w-6xl mx-auto">
|
| 300 |
+
<div className="grid lg:grid-cols-12 gap-8">
|
| 301 |
+
|
| 302 |
+
{/* Left Column: Input Configuration */}
|
| 303 |
+
<div className="lg:col-span-7 space-y-8">
|
| 304 |
+
<div>
|
| 305 |
+
<h2 className="text-2xl font-bold text-white mb-2">Configure Generation</h2>
|
| 306 |
+
<p className="text-slate-400">Define the parameters for your AI-generated animation.</p>
|
| 307 |
+
</div>
|
| 308 |
+
|
| 309 |
+
<form onSubmit={handleSubmit} className="space-y-8">
|
| 310 |
+
{/* Input Source */}
|
| 311 |
+
<div className="space-y-4">
|
| 312 |
+
<label className="text-sm font-medium text-slate-300 uppercase tracking-wider">Input Source</label>
|
| 313 |
+
<div className="grid grid-cols-3 gap-4">
|
| 314 |
+
{[
|
| 315 |
+
{ id: "text", icon: FileText, label: "Text Prompt" },
|
| 316 |
+
{ id: "url", icon: LinkIcon, label: "URL / Blog" },
|
| 317 |
+
{ id: "pdf", icon: Upload, label: "PDF Document" }
|
| 318 |
+
].map((type) => (
|
| 319 |
+
<button
|
| 320 |
+
key={type.id}
|
| 321 |
+
type="button"
|
| 322 |
+
onClick={() => setInputType(type.id as InputType)}
|
| 323 |
+
className={cn(
|
| 324 |
+
"flex flex-col items-center justify-center p-4 rounded-xl border transition-all duration-200",
|
| 325 |
+
inputType === type.id
|
| 326 |
+
? "bg-blue-600/10 border-blue-500/50 text-blue-400 shadow-[0_0_20px_rgba(59,130,246,0.15)]"
|
| 327 |
+
: "bg-slate-900/50 border-white/5 text-slate-400 hover:bg-slate-800 hover:border-white/10"
|
| 328 |
+
)}
|
| 329 |
+
>
|
| 330 |
+
<type.icon className="w-6 h-6 mb-2" />
|
| 331 |
+
<span className="text-sm font-medium">{type.label}</span>
|
| 332 |
+
</button>
|
| 333 |
+
))}
|
| 334 |
+
</div>
|
| 335 |
+
</div>
|
| 336 |
+
|
| 337 |
+
{/* Animation Style */}
|
| 338 |
+
<div className="space-y-4">
|
| 339 |
+
<label className="text-sm font-medium text-slate-300 uppercase tracking-wider">Visual Style</label>
|
| 340 |
+
<div className="grid gap-3">
|
| 341 |
+
{categories.map((cat) => (
|
| 342 |
+
<button
|
| 343 |
+
key={cat.value}
|
| 344 |
+
type="button"
|
| 345 |
+
onClick={() => setCategory(cat.value)}
|
| 346 |
+
className={cn(
|
| 347 |
+
"flex items-center p-4 rounded-xl border text-left transition-all duration-200",
|
| 348 |
+
category === cat.value
|
| 349 |
+
? "bg-violet-600/10 border-violet-500/50 shadow-[0_0_20px_rgba(139,92,246,0.15)]"
|
| 350 |
+
: "bg-slate-900/50 border-white/5 hover:bg-slate-800 hover:border-white/10"
|
| 351 |
+
)}
|
| 352 |
+
>
|
| 353 |
+
<div className={cn(
|
| 354 |
+
"w-10 h-10 rounded-lg flex items-center justify-center mr-4 transition-colors",
|
| 355 |
+
category === cat.value ? "bg-violet-500/20 text-violet-400" : "bg-slate-800 text-slate-400"
|
| 356 |
+
)}>
|
| 357 |
+
{cat.value === 'tech_system' && <Layout className="w-5 h-5" />}
|
| 358 |
+
{cat.value === 'product_startup' && <Sparkles className="w-5 h-5" />}
|
| 359 |
+
{cat.value === 'mathematical' && <Clock className="w-5 h-5" />}
|
| 360 |
+
</div>
|
| 361 |
+
<div>
|
| 362 |
+
<div className={cn("font-medium", category === cat.value ? "text-violet-300" : "text-slate-200")}>
|
| 363 |
+
{cat.label}
|
| 364 |
+
</div>
|
| 365 |
+
<div className="text-xs text-slate-500 mt-0.5">{cat.description}</div>
|
| 366 |
+
</div>
|
| 367 |
+
{category === cat.value && (
|
| 368 |
+
<div className="ml-auto text-violet-400">
|
| 369 |
+
<CheckCircle className="w-5 h-5" />
|
| 370 |
+
</div>
|
| 371 |
+
)}
|
| 372 |
+
</button>
|
| 373 |
+
))}
|
| 374 |
+
</div>
|
| 375 |
+
</div>
|
| 376 |
+
|
| 377 |
+
{/* Content Input */}
|
| 378 |
+
<div className="space-y-4">
|
| 379 |
+
<label className="text-sm font-medium text-slate-300 uppercase tracking-wider">
|
| 380 |
+
{inputType === "text" ? "Description" : inputType === "url" ? "Source URL" : "Upload File"}
|
| 381 |
+
</label>
|
| 382 |
+
|
| 383 |
+
<div className="relative group">
|
| 384 |
+
<div className="absolute -inset-0.5 bg-gradient-to-r from-blue-500 to-violet-500 rounded-xl opacity-0 group-hover:opacity-20 transition duration-500 blur"></div>
|
| 385 |
+
{inputType === "text" ? (
|
| 386 |
+
<Textarea
|
| 387 |
+
placeholder="Explain the concept of neural networks using a simple analogy..."
|
| 388 |
+
className="relative min-h-[200px] text-base resize-none bg-slate-900/80 border-white/10 text-slate-200 focus:border-blue-500/50 focus:ring-blue-500/20 rounded-xl p-4"
|
| 389 |
+
value={content}
|
| 390 |
+
onChange={(e) => setContent(e.target.value)}
|
| 391 |
+
disabled={!canEdit}
|
| 392 |
+
/>
|
| 393 |
+
) : inputType === "url" ? (
|
| 394 |
+
<Input
|
| 395 |
+
placeholder="https://example.com/article"
|
| 396 |
+
value={content}
|
| 397 |
+
onChange={(e) => setContent(e.target.value)}
|
| 398 |
+
className="relative h-12 bg-slate-900/80 border-white/10 text-slate-200 focus:border-blue-500/50 focus:ring-blue-500/20 rounded-xl px-4"
|
| 399 |
+
disabled={!canEdit}
|
| 400 |
+
/>
|
| 401 |
+
) : (
|
| 402 |
+
<div className={cn(
|
| 403 |
+
"relative border-2 border-dashed border-slate-700 rounded-xl p-12 text-center transition-all",
|
| 404 |
+
canEdit ? "hover:border-blue-500/50 hover:bg-slate-900/50 cursor-pointer" : "opacity-50 cursor-not-allowed"
|
| 405 |
+
)}>
|
| 406 |
+
<Input
|
| 407 |
+
type="file"
|
| 408 |
+
accept=".pdf"
|
| 409 |
+
onChange={async (e) => {
|
| 410 |
+
const file = e.target.files?.[0];
|
| 411 |
+
if (file) {
|
| 412 |
+
const reader = new FileReader();
|
| 413 |
+
reader.onload = (event) => {
|
| 414 |
+
const base64 = event.target?.result as string;
|
| 415 |
+
const base64Content = base64.split(',')[1];
|
| 416 |
+
setContent(base64Content);
|
| 417 |
+
};
|
| 418 |
+
reader.readAsDataURL(file);
|
| 419 |
+
}
|
| 420 |
+
}}
|
| 421 |
+
className="hidden"
|
| 422 |
+
id="pdf-upload"
|
| 423 |
+
disabled={!canEdit}
|
| 424 |
+
/>
|
| 425 |
+
<label htmlFor="pdf-upload" className={cn("w-full h-full block", canEdit ? "cursor-pointer" : "cursor-not-allowed")}>
|
| 426 |
+
<div className="w-16 h-16 bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
|
| 427 |
+
<Upload className="h-8 w-8 text-slate-400 group-hover:text-blue-400 transition-colors" />
|
| 428 |
+
</div>
|
| 429 |
+
<p className="text-lg font-medium text-slate-300 mb-2">Click to upload PDF</p>
|
| 430 |
+
<p className="text-sm text-slate-500">Maximum file size 10MB</p>
|
| 431 |
+
</label>
|
| 432 |
+
{content && (
|
| 433 |
+
<div className="absolute top-4 right-4 flex items-center gap-2 text-green-400 text-xs bg-green-500/10 py-1.5 px-3 rounded-full border border-green-500/20">
|
| 434 |
+
<CheckCircle className="h-3 w-3" />
|
| 435 |
+
<span>Ready</span>
|
| 436 |
+
</div>
|
| 437 |
+
)}
|
| 438 |
+
</div>
|
| 439 |
+
)}
|
| 440 |
+
</div>
|
| 441 |
+
</div>
|
| 442 |
+
|
| 443 |
+
<Button
|
| 444 |
+
type="submit"
|
| 445 |
+
size="lg"
|
| 446 |
+
className="w-full h-16 text-lg font-semibold bg-gradient-to-r from-blue-600 to-violet-600 hover:from-blue-500 hover:to-violet-500 text-white shadow-lg shadow-blue-500/25 rounded-xl transition-all hover:scale-[1.02] active:scale-[0.98]"
|
| 447 |
+
disabled={isGenerating || !content || !canGenerate}
|
| 448 |
+
>
|
| 449 |
+
{isGenerating ? (
|
| 450 |
+
<div className="flex items-center gap-3">
|
| 451 |
+
<Loader2 className="h-6 w-6 animate-spin" />
|
| 452 |
+
<span>Processing Request...</span>
|
| 453 |
+
</div>
|
| 454 |
+
) : (
|
| 455 |
+
<div className="flex items-center gap-3">
|
| 456 |
+
<Play className="h-6 w-6 fill-current" />
|
| 457 |
+
<span>Generate Animation</span>
|
| 458 |
+
</div>
|
| 459 |
+
)}
|
| 460 |
+
</Button>
|
| 461 |
+
</form>
|
| 462 |
+
</div>
|
| 463 |
+
|
| 464 |
+
{/* Right Column: Preview & Status */}
|
| 465 |
+
<div className="lg:col-span-5 space-y-8">
|
| 466 |
+
<div>
|
| 467 |
+
<h2 className="text-2xl font-bold text-white mb-2">Live Preview</h2>
|
| 468 |
+
<p className="text-slate-400">Real-time generation status and output.</p>
|
| 469 |
+
</div>
|
| 470 |
+
|
| 471 |
+
<div className="sticky top-8">
|
| 472 |
+
<AnimatePresence mode="wait">
|
| 473 |
+
{currentJob ? (
|
| 474 |
+
<motion.div
|
| 475 |
+
initial={{ opacity: 0, y: 20 }}
|
| 476 |
+
animate={{ opacity: 1, y: 0 }}
|
| 477 |
+
exit={{ opacity: 0, y: -20 }}
|
| 478 |
+
className="space-y-6"
|
| 479 |
+
>
|
| 480 |
+
<Card className="bg-slate-900 border-white/10 overflow-hidden shadow-2xl">
|
| 481 |
+
<CardHeader className="border-b border-white/5 bg-slate-950/30 py-4">
|
| 482 |
+
<div className="flex items-center justify-between">
|
| 483 |
+
<CardTitle className="text-sm font-medium text-slate-300 flex items-center gap-2">
|
| 484 |
+
{currentJob.status === "completed" ? (
|
| 485 |
+
<span className="flex items-center gap-2 text-green-400">
|
| 486 |
+
<CheckCircle className="h-4 w-4" /> Completed
|
| 487 |
+
</span>
|
| 488 |
+
) : currentJob.status === "failed" ? (
|
| 489 |
+
<span className="flex items-center gap-2 text-red-400">
|
| 490 |
+
<AlertCircle className="h-4 w-4" /> Failed
|
| 491 |
+
</span>
|
| 492 |
+
) : (
|
| 493 |
+
<span className="flex items-center gap-2 text-blue-400">
|
| 494 |
+
<Loader2 className="h-4 w-4 animate-spin" /> Processing
|
| 495 |
+
</span>
|
| 496 |
+
)}
|
| 497 |
+
</CardTitle>
|
| 498 |
+
<div className="text-xs font-mono text-slate-500">{currentJob.job_id.slice(0, 8)}</div>
|
| 499 |
+
</div>
|
| 500 |
+
</CardHeader>
|
| 501 |
+
|
| 502 |
+
<CardContent className="p-0">
|
| 503 |
+
{/* Video Player or Progress State */}
|
| 504 |
+
<div className="aspect-video bg-black relative group">
|
| 505 |
+
{currentJob.status === "completed" ? (
|
| 506 |
+
<video
|
| 507 |
+
key={demoVideoUrl || currentJob.job_id}
|
| 508 |
+
src={demoVideoUrl || api.getVideoUrl(currentJob.job_id)}
|
| 509 |
+
controls
|
| 510 |
+
className="w-full h-full"
|
| 511 |
+
poster="/placeholder-video.jpg"
|
| 512 |
+
/>
|
| 513 |
+
) : (
|
| 514 |
+
<div className="absolute inset-0 flex flex-col items-center justify-center p-8 text-center">
|
| 515 |
+
<div className="relative w-24 h-24 mb-6">
|
| 516 |
+
<div className="absolute inset-0 rounded-full border-4 border-slate-800"></div>
|
| 517 |
+
<div className="absolute inset-0 rounded-full border-4 border-t-blue-500 border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
|
| 518 |
+
<div className="absolute inset-4 rounded-full bg-slate-800/50 backdrop-blur flex items-center justify-center">
|
| 519 |
+
<span className="text-sm font-bold text-white">{currentJob.progress.percentage}%</span>
|
| 520 |
+
</div>
|
| 521 |
+
</div>
|
| 522 |
+
<h3 className="text-lg font-medium text-white mb-2">Generating Animation</h3>
|
| 523 |
+
<p className="text-sm text-slate-400 max-w-xs mx-auto animate-pulse">
|
| 524 |
+
{currentJob.progress.message}
|
| 525 |
+
</p>
|
| 526 |
+
</div>
|
| 527 |
+
)}
|
| 528 |
+
</div>
|
| 529 |
+
|
| 530 |
+
{/* Actions */}
|
| 531 |
+
{currentJob.status === "completed" && (
|
| 532 |
+
<div className="p-4 bg-slate-900 border-t border-white/5">
|
| 533 |
+
<Button className="w-full bg-white text-slate-900 hover:bg-slate-200 font-medium" asChild>
|
| 534 |
+
<a href={demoVideoUrl || api.getVideoUrl(currentJob.job_id)} download>
|
| 535 |
+
<Download className="mr-2 h-4 w-4" />
|
| 536 |
+
Download MP4 (1080p)
|
| 537 |
+
</a>
|
| 538 |
+
</Button>
|
| 539 |
+
</div>
|
| 540 |
+
)}
|
| 541 |
+
|
| 542 |
+
{/* Error Message */}
|
| 543 |
+
{error && (
|
| 544 |
+
<div className="p-4 bg-red-500/10 border-t border-red-500/20">
|
| 545 |
+
<p className="text-sm text-red-400 flex items-start gap-2">
|
| 546 |
+
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
| 547 |
+
{error}
|
| 548 |
+
</p>
|
| 549 |
+
</div>
|
| 550 |
+
)}
|
| 551 |
+
</CardContent>
|
| 552 |
+
</Card>
|
| 553 |
+
|
| 554 |
+
{/* Process Steps (Visual Decoration) */}
|
| 555 |
+
{currentJob.status !== "completed" && currentJob.status !== "failed" && (
|
| 556 |
+
<div className="space-y-3">
|
| 557 |
+
{["Analyzing Input", "Generating Script", "Validating Code", "Rendering Frames"].map((step, i) => {
|
| 558 |
+
const currentStepIndex = Math.floor((currentJob.progress.percentage / 100) * 4);
|
| 559 |
+
const isActive = i === currentStepIndex;
|
| 560 |
+
const isCompleted = i < currentStepIndex;
|
| 561 |
+
|
| 562 |
+
return (
|
| 563 |
+
<div key={step} className="flex items-center gap-3 text-sm">
|
| 564 |
+
<div className={cn(
|
| 565 |
+
"w-6 h-6 rounded-full flex items-center justify-center border transition-colors",
|
| 566 |
+
isCompleted ? "bg-green-500 border-green-500 text-slate-900" :
|
| 567 |
+
isActive ? "border-blue-500 text-blue-500" : "border-slate-700 text-slate-700"
|
| 568 |
+
)}>
|
| 569 |
+
{isCompleted ? <CheckCircle className="w-4 h-4" /> : <div className={cn("w-2 h-2 rounded-full", isActive ? "bg-blue-500 animate-pulse" : "bg-slate-700")} />}
|
| 570 |
+
</div>
|
| 571 |
+
<span className={cn(
|
| 572 |
+
"transition-colors",
|
| 573 |
+
isCompleted ? "text-slate-300" :
|
| 574 |
+
isActive ? "text-white font-medium" : "text-slate-600"
|
| 575 |
+
)}>{step}</span>
|
| 576 |
+
</div>
|
| 577 |
+
);
|
| 578 |
+
})}
|
| 579 |
+
</div>
|
| 580 |
+
)}
|
| 581 |
+
</motion.div>
|
| 582 |
+
) : (
|
| 583 |
+
<div className="h-[400px] rounded-2xl border-2 border-dashed border-slate-800 bg-slate-900/30 flex flex-col items-center justify-center text-slate-500 p-8 text-center">
|
| 584 |
+
<div className="w-20 h-20 rounded-full bg-slate-800/50 flex items-center justify-center mb-6">
|
| 585 |
+
<Video className="h-10 w-10 opacity-50" />
|
| 586 |
+
</div>
|
| 587 |
+
<h3 className="text-lg font-medium text-slate-300 mb-2">Ready to Generate</h3>
|
| 588 |
+
<p className="max-w-xs text-sm">
|
| 589 |
+
Configure your animation parameters on the left and click generate to see the magic happen.
|
| 590 |
+
</p>
|
| 591 |
+
</div>
|
| 592 |
+
)}
|
| 593 |
+
</AnimatePresence>
|
| 594 |
+
</div>
|
| 595 |
+
</div>
|
| 596 |
+
</div>
|
| 597 |
+
</div>
|
| 598 |
+
</main>
|
| 599 |
+
</div>
|
| 600 |
+
</div>
|
| 601 |
+
);
|
| 602 |
+
}
|
frontend/src/components/auth/LoginButton.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { Button } from "@/components/ui/button";
|
| 4 |
+
import { supabaseClient } from "@/lib/supabase-client";
|
| 5 |
+
import { cn } from "@/lib/utils";
|
| 6 |
+
|
| 7 |
+
interface LoginButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
| 8 |
+
text?: string;
|
| 9 |
+
variant?: "default" | "outline" | "secondary" | "ghost";
|
| 10 |
+
redirectTo?: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function LoginButton({
|
| 14 |
+
text = "Sign In",
|
| 15 |
+
variant = "default",
|
| 16 |
+
className,
|
| 17 |
+
redirectTo = "/",
|
| 18 |
+
...props
|
| 19 |
+
}: LoginButtonProps) {
|
| 20 |
+
|
| 21 |
+
const handleLogin = async () => {
|
| 22 |
+
await supabaseClient.auth.signInWithOAuth({
|
| 23 |
+
provider: "google",
|
| 24 |
+
options: {
|
| 25 |
+
redirectTo: `${location.origin}/auth/callback?next=${redirectTo}`,
|
| 26 |
+
},
|
| 27 |
+
});
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<Button
|
| 32 |
+
variant={variant}
|
| 33 |
+
className={cn(className)}
|
| 34 |
+
onClick={handleLogin}
|
| 35 |
+
{...props}
|
| 36 |
+
>
|
| 37 |
+
{text}
|
| 38 |
+
</Button>
|
| 39 |
+
);
|
| 40 |
+
}
|
frontend/src/components/auth/UserAuth.tsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import {
|
| 6 |
+
DropdownMenu,
|
| 7 |
+
DropdownMenuContent,
|
| 8 |
+
DropdownMenuItem,
|
| 9 |
+
DropdownMenuLabel,
|
| 10 |
+
DropdownMenuSeparator,
|
| 11 |
+
DropdownMenuTrigger,
|
| 12 |
+
} from "@/components/ui/dropdown-menu";
|
| 13 |
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
| 14 |
+
import { LogOut, User, CreditCard } from "lucide-react";
|
| 15 |
+
import Link from "next/link";
|
| 16 |
+
import { useRouter } from "next/navigation";
|
| 17 |
+
import { supabaseClient } from "@/lib/supabase-client";
|
| 18 |
+
import { LoginButton } from "./LoginButton";
|
| 19 |
+
import { cn } from "@/lib/utils";
|
| 20 |
+
|
| 21 |
+
export function UserAuth() {
|
| 22 |
+
const [user, setUser] = useState<any>(null);
|
| 23 |
+
const supabase = supabaseClient;
|
| 24 |
+
const router = useRouter();
|
| 25 |
+
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
const getUser = async () => {
|
| 28 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 29 |
+
setUser(user);
|
| 30 |
+
if (user) {
|
| 31 |
+
console.log("🔄 UserAuth: Calling RPC handle_user_login on mount");
|
| 32 |
+
await supabase.rpc('handle_user_login', {
|
| 33 |
+
user_email: user.email,
|
| 34 |
+
user_full_name: user.user_metadata.full_name || '',
|
| 35 |
+
user_avatar_url: user.user_metadata.avatar_url || ''
|
| 36 |
+
}).then(({ error }) => {
|
| 37 |
+
if (error) console.error("❌ RPC Error:", error);
|
| 38 |
+
else console.log("✅ RPC Success: User synced");
|
| 39 |
+
});
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
getUser();
|
| 43 |
+
|
| 44 |
+
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => {
|
| 45 |
+
setUser(session?.user ?? null);
|
| 46 |
+
if (session?.user) {
|
| 47 |
+
console.log("🔄 UserAuth: Calling RPC handle_user_login on auth change");
|
| 48 |
+
await supabase.rpc('handle_user_login', {
|
| 49 |
+
user_email: session.user.email,
|
| 50 |
+
user_full_name: session.user.user_metadata.full_name || '',
|
| 51 |
+
user_avatar_url: session.user.user_metadata.avatar_url || ''
|
| 52 |
+
}).then(({ error }) => {
|
| 53 |
+
if (error) console.error("❌ RPC Error:", error);
|
| 54 |
+
else {
|
| 55 |
+
console.log("✅ RPC Success: User synced");
|
| 56 |
+
router.refresh();
|
| 57 |
+
}
|
| 58 |
+
});
|
| 59 |
+
}
|
| 60 |
+
router.refresh();
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
return () => subscription.unsubscribe();
|
| 64 |
+
}, [supabase, router]);
|
| 65 |
+
|
| 66 |
+
const handleLogin = async () => {
|
| 67 |
+
await supabase.auth.signInWithOAuth({
|
| 68 |
+
provider: "google",
|
| 69 |
+
options: {
|
| 70 |
+
redirectTo: `${location.origin}/auth/callback`,
|
| 71 |
+
},
|
| 72 |
+
});
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
const handleLogout = async () => {
|
| 76 |
+
await supabase.auth.signOut();
|
| 77 |
+
router.refresh();
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
if (!user) {
|
| 81 |
+
return (
|
| 82 |
+
<LoginButton text="Sign In" className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-6" />
|
| 83 |
+
);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
return (
|
| 87 |
+
<DropdownMenu>
|
| 88 |
+
<DropdownMenuTrigger asChild>
|
| 89 |
+
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
| 90 |
+
<Avatar className="h-10 w-10 border border-slate-700">
|
| 91 |
+
<AvatarImage src={user.user_metadata?.avatar_url} alt={user.user_metadata?.full_name} />
|
| 92 |
+
<AvatarFallback>{user.email?.charAt(0).toUpperCase()}</AvatarFallback>
|
| 93 |
+
</Avatar>
|
| 94 |
+
</Button>
|
| 95 |
+
</DropdownMenuTrigger>
|
| 96 |
+
<DropdownMenuContent className="w-56 bg-slate-900 border-slate-800 text-slate-200" align="end" forceMount>
|
| 97 |
+
<DropdownMenuLabel className="font-normal">
|
| 98 |
+
<div className="flex flex-col space-y-1">
|
| 99 |
+
<p className="text-sm font-medium leading-none text-white">{user.user_metadata?.full_name}</p>
|
| 100 |
+
<p className="text-xs leading-none text-slate-400">{user.email}</p>
|
| 101 |
+
</div>
|
| 102 |
+
</DropdownMenuLabel>
|
| 103 |
+
<DropdownMenuSeparator className="bg-slate-800" />
|
| 104 |
+
<DropdownMenuItem asChild>
|
| 105 |
+
<Link href="/profile" className="cursor-pointer hover:bg-slate-800 focus:bg-slate-800">
|
| 106 |
+
<User className="mr-2 h-4 w-4" />
|
| 107 |
+
<span>Profile</span>
|
| 108 |
+
</Link>
|
| 109 |
+
</DropdownMenuItem>
|
| 110 |
+
<DropdownMenuItem asChild>
|
| 111 |
+
<Link href="/billing" className="cursor-pointer hover:bg-slate-800 focus:bg-slate-800">
|
| 112 |
+
<CreditCard className="mr-2 h-4 w-4" />
|
| 113 |
+
<span>Billing</span>
|
| 114 |
+
</Link>
|
| 115 |
+
</DropdownMenuItem>
|
| 116 |
+
<DropdownMenuSeparator className="bg-slate-800" />
|
| 117 |
+
<DropdownMenuItem onClick={handleLogout} className="cursor-pointer text-red-400 hover:bg-red-950/30 focus:bg-red-950/30 hover:text-red-300 focus:text-red-300">
|
| 118 |
+
<LogOut className="mr-2 h-4 w-4" />
|
| 119 |
+
<span>Log out</span>
|
| 120 |
+
</DropdownMenuItem>
|
| 121 |
+
</DropdownMenuContent>
|
| 122 |
+
</DropdownMenu>
|
| 123 |
+
);
|
| 124 |
+
}
|
frontend/src/components/landing/Footer.tsx
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Link from "next/link";
|
| 2 |
+
import { Video, Github, Twitter, Linkedin } from "lucide-react";
|
| 3 |
+
|
| 4 |
+
export function Footer() {
|
| 5 |
+
return (
|
| 6 |
+
<footer className="bg-slate-950 border-t border-white/5 pt-16 pb-8">
|
| 7 |
+
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
| 8 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 mb-16">
|
| 9 |
+
<div className="col-span-1 md:col-span-1">
|
| 10 |
+
<Link href="/" className="flex items-center gap-2 mb-6">
|
| 11 |
+
<img src="/logo.svg" alt="Logo" className="h-8 w-8" />
|
| 12 |
+
<span className="text-lg font-bold text-white">VidSimplify</span>
|
| 13 |
+
</Link>
|
| 14 |
+
<p className="text-slate-400 text-sm leading-relaxed mb-6">
|
| 15 |
+
Precision animation engine for the modern web. Explain complex systems with mathematical accuracy.
|
| 16 |
+
</p>
|
| 17 |
+
<div className="flex gap-4">
|
| 18 |
+
<Link href="#" className="text-slate-400 hover:text-white transition-colors">
|
| 19 |
+
<Twitter className="h-5 w-5" />
|
| 20 |
+
</Link>
|
| 21 |
+
<Link href="#" className="text-slate-400 hover:text-white transition-colors">
|
| 22 |
+
<Github className="h-5 w-5" />
|
| 23 |
+
</Link>
|
| 24 |
+
<Link href="#" className="text-slate-400 hover:text-white transition-colors">
|
| 25 |
+
<Linkedin className="h-5 w-5" />
|
| 26 |
+
</Link>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<div>
|
| 31 |
+
<h3 className="text-white font-semibold mb-4">Product</h3>
|
| 32 |
+
<ul className="space-y-3 text-sm text-slate-400">
|
| 33 |
+
<li><Link href="#features" className="hover:text-blue-400 transition-colors">Features</Link></li>
|
| 34 |
+
<li><Link href="#showcase" className="hover:text-blue-400 transition-colors">Showcase</Link></li>
|
| 35 |
+
<li><Link href="/api-docs" className="hover:text-blue-400 transition-colors">API</Link></li>
|
| 36 |
+
<li><Link href="#" className="hover:text-blue-400 transition-colors">Pricing</Link></li>
|
| 37 |
+
</ul>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div>
|
| 41 |
+
<h3 className="text-white font-semibold mb-4">Resources</h3>
|
| 42 |
+
<ul className="space-y-3 text-sm text-slate-400">
|
| 43 |
+
<li><Link href="#" className="hover:text-blue-400 transition-colors">Documentation</Link></li>
|
| 44 |
+
<li><Link href="#" className="hover:text-blue-400 transition-colors">Blog</Link></li>
|
| 45 |
+
<li><Link href="#" className="hover:text-blue-400 transition-colors">Community</Link></li>
|
| 46 |
+
<li><Link href="#" className="hover:text-blue-400 transition-colors">Help Center</Link></li>
|
| 47 |
+
</ul>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
<div>
|
| 51 |
+
<h3 className="text-white font-semibold mb-4">Legal</h3>
|
| 52 |
+
<ul className="space-y-3 text-sm text-slate-400">
|
| 53 |
+
<li><Link href="#" className="hover:text-blue-400 transition-colors">Privacy Policy</Link></li>
|
| 54 |
+
<li><Link href="#" className="hover:text-blue-400 transition-colors">Terms of Service</Link></li>
|
| 55 |
+
<li><Link href="#" className="hover:text-blue-400 transition-colors">Cookie Policy</Link></li>
|
| 56 |
+
</ul>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<div className="border-t border-white/5 pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
|
| 61 |
+
<p className="text-slate-500 text-sm">
|
| 62 |
+
© {new Date().getFullYear()} VidSimplify Inc. All rights reserved.
|
| 63 |
+
</p>
|
| 64 |
+
<div className="flex items-center gap-2 text-sm text-slate-500">
|
| 65 |
+
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
| 66 |
+
All Systems Operational
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</footer>
|
| 71 |
+
);
|
| 72 |
+
}
|