Yashwanth commited on
Commit ·
b4a9f77
0
Parent(s):
Fresh start without large files
Browse files- .github/workflows/deploy.yml +44 -0
- .gitignore +13 -0
- DEPLOYMENT.md +341 -0
- Dockerfile +29 -0
- LICENSE +21 -0
- README.md +79 -0
- api/index.py +1001 -0
- debug_ffmpeg.log +113 -0
- diagnose_api.py +47 -0
- error.log +3 -0
- examples/frontend-example.html +492 -0
- examples/postman-collection.json +152 -0
- examples/python-client.py +253 -0
- install_ffmpeg.py +51 -0
- list_models.py +14 -0
- requirements.txt +36 -0
- test_ai_mode.py +63 -0
- test_api.py +110 -0
- test_download_clip.py +33 -0
- test_download_e2e.py +64 -0
- test_nvidia_mode.py +69 -0
- vercel.json +28 -0
.github/workflows/deploy.yml
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Deploy to Vercel
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- main
|
| 7 |
+
pull_request:
|
| 8 |
+
branches:
|
| 9 |
+
- main
|
| 10 |
+
|
| 11 |
+
jobs:
|
| 12 |
+
test:
|
| 13 |
+
runs-on: ubuntu-latest
|
| 14 |
+
steps:
|
| 15 |
+
- uses: actions/checkout@v4
|
| 16 |
+
|
| 17 |
+
- name: Set up Python
|
| 18 |
+
uses: actions/setup-python@v5
|
| 19 |
+
with:
|
| 20 |
+
python-version: '3.9'
|
| 21 |
+
|
| 22 |
+
- name: Install dependencies
|
| 23 |
+
run: |
|
| 24 |
+
python -m pip install --upgrade pip
|
| 25 |
+
pip install -r requirements.txt
|
| 26 |
+
|
| 27 |
+
- name: Run tests
|
| 28 |
+
run: |
|
| 29 |
+
# Add tests here when ready
|
| 30 |
+
python -c "import api.index; print('Import successful')"
|
| 31 |
+
|
| 32 |
+
deploy:
|
| 33 |
+
needs: test
|
| 34 |
+
runs-on: ubuntu-latest
|
| 35 |
+
if: github.ref == 'refs/heads/main'
|
| 36 |
+
steps:
|
| 37 |
+
- uses: actions/checkout@v4
|
| 38 |
+
|
| 39 |
+
- name: Deploy to Vercel
|
| 40 |
+
uses: vercel/action-deploy@v1
|
| 41 |
+
with:
|
| 42 |
+
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
| 43 |
+
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
|
| 44 |
+
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
|
.gitignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
venv/
|
| 5 |
+
.env
|
| 6 |
+
|
| 7 |
+
# Large Binaries (Not needed on repo, Docker installs them)
|
| 8 |
+
ffmpeg/
|
| 9 |
+
yt-dlp.exe
|
| 10 |
+
|
| 11 |
+
# Editor
|
| 12 |
+
.vscode/
|
| 13 |
+
.idea/
|
DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🚀 Deployment Guide
|
| 2 |
+
|
| 3 |
+
Complete guide to deploy the Viral Clip Extractor API to Vercel and GitHub.
|
| 4 |
+
|
| 5 |
+
## Quick Deploy (Recommended)
|
| 6 |
+
|
| 7 |
+
### Option 1: Vercel Dashboard (Easiest)
|
| 8 |
+
|
| 9 |
+
1. **Push to GitHub**
|
| 10 |
+
```bash
|
| 11 |
+
# Create a new repository on GitHub first
|
| 12 |
+
git init
|
| 13 |
+
git add .
|
| 14 |
+
git commit -m "Initial commit"
|
| 15 |
+
git branch -M main
|
| 16 |
+
git remote add origin https://github.com/YOUR_USERNAME/viral-clip-extractor-api.git
|
| 17 |
+
git push -u origin main
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
2. **Deploy on Vercel**
|
| 21 |
+
- Go to [vercel.com](https://vercel.com)
|
| 22 |
+
- Sign up/login with GitHub
|
| 23 |
+
- Click "New Project"
|
| 24 |
+
- Import your repository
|
| 25 |
+
- Vercel auto-detects Python
|
| 26 |
+
- Click "Deploy"
|
| 27 |
+
|
| 28 |
+
3. **Done!** Your API is live at `https://your-project.vercel.app`
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
### Option 2: Vercel CLI
|
| 33 |
+
|
| 34 |
+
1. **Install Vercel CLI**
|
| 35 |
+
```bash
|
| 36 |
+
npm i -g vercel
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
2. **Login**
|
| 40 |
+
```bash
|
| 41 |
+
vercel login
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
3. **Deploy**
|
| 45 |
+
```bash
|
| 46 |
+
cd viral-clip-extractor-api
|
| 47 |
+
vercel --prod
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
4. **Follow prompts** - Vercel will detect Python automatically
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
## GitHub + Vercel Integration (Auto-Deploy)
|
| 55 |
+
|
| 56 |
+
### Setup GitHub Repository
|
| 57 |
+
|
| 58 |
+
1. **Create repository on GitHub**
|
| 59 |
+
- Go to github.com/new
|
| 60 |
+
- Name: `viral-clip-extractor-api`
|
| 61 |
+
- Make it Public or Private
|
| 62 |
+
- Don't initialize with README
|
| 63 |
+
|
| 64 |
+
2. **Push local code**
|
| 65 |
+
```bash
|
| 66 |
+
cd viral-okcomputer/output/viral-clip-api
|
| 67 |
+
git init
|
| 68 |
+
git add .
|
| 69 |
+
git commit -m "Initial commit"
|
| 70 |
+
git branch -M main
|
| 71 |
+
git remote add origin https://github.com/YOUR_USERNAME/viral-clip-extractor-api.git
|
| 72 |
+
git push -u origin main
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
### Setup Vercel Project
|
| 76 |
+
|
| 77 |
+
1. **Import from GitHub**
|
| 78 |
+
- Go to [vercel.com/new](https://vercel.com/new)
|
| 79 |
+
- Select your repository
|
| 80 |
+
- Framework Preset: `Other`
|
| 81 |
+
- Root Directory: `./`
|
| 82 |
+
- Build Command: (leave empty for Python)
|
| 83 |
+
- Output Directory: (leave empty)
|
| 84 |
+
|
| 85 |
+
2. **Environment Variables** (if needed)
|
| 86 |
+
- Click "Environment Variables"
|
| 87 |
+
- Add any required variables
|
| 88 |
+
- Click "Deploy"
|
| 89 |
+
|
| 90 |
+
3. **Auto-Deploy Enabled**
|
| 91 |
+
- Every push to `main` branch auto-deploys
|
| 92 |
+
- Pull requests get preview deployments
|
| 93 |
+
|
| 94 |
+
---
|
| 95 |
+
|
| 96 |
+
## GitHub Actions CI/CD
|
| 97 |
+
|
| 98 |
+
The repository includes a GitHub Actions workflow for automated deployment.
|
| 99 |
+
|
| 100 |
+
### Setup Secrets
|
| 101 |
+
|
| 102 |
+
1. **Get Vercel Tokens**
|
| 103 |
+
```bash
|
| 104 |
+
vercel login
|
| 105 |
+
vercel tokens create
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
2. **Get Project ID**
|
| 109 |
+
```bash
|
| 110 |
+
cd your-project
|
| 111 |
+
vercel
|
| 112 |
+
# Check .vercel/project.json
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
3. **Add to GitHub Secrets**
|
| 116 |
+
- Go to Repository → Settings → Secrets → Actions
|
| 117 |
+
- Add:
|
| 118 |
+
- `VERCEL_TOKEN` - Your token
|
| 119 |
+
- `VERCEL_ORG_ID` - From `.vercel/project.json`
|
| 120 |
+
- `VERCEL_PROJECT_ID` - From `.vercel/project.json`
|
| 121 |
+
|
| 122 |
+
### Workflow Features
|
| 123 |
+
|
| 124 |
+
- ✅ Runs tests on every PR
|
| 125 |
+
- ✅ Auto-deploys on merge to main
|
| 126 |
+
- ✅ Shows deployment status in PRs
|
| 127 |
+
|
| 128 |
+
---
|
| 129 |
+
|
| 130 |
+
## Configuration
|
| 131 |
+
|
| 132 |
+
### Vercel Settings
|
| 133 |
+
|
| 134 |
+
Edit `vercel.json` to customize:
|
| 135 |
+
|
| 136 |
+
```json
|
| 137 |
+
{
|
| 138 |
+
"version": 2,
|
| 139 |
+
"name": "viral-clip-extractor-api",
|
| 140 |
+
"functions": {
|
| 141 |
+
"api/index.py": {
|
| 142 |
+
"maxDuration": 60 // Max 60 seconds (Vercel limit)
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
### Python Runtime
|
| 149 |
+
|
| 150 |
+
Vercel uses Python 3.9 by default. To change:
|
| 151 |
+
|
| 152 |
+
```json
|
| 153 |
+
{
|
| 154 |
+
"builds": [{
|
| 155 |
+
"src": "api/index.py",
|
| 156 |
+
"use": "@vercel/python",
|
| 157 |
+
"config": {
|
| 158 |
+
"runtime": "python3.9"
|
| 159 |
+
}
|
| 160 |
+
}]
|
| 161 |
+
}
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
### Custom Domain
|
| 165 |
+
|
| 166 |
+
1. Go to Vercel Dashboard → Your Project → Settings → Domains
|
| 167 |
+
2. Add your domain
|
| 168 |
+
3. Follow DNS configuration instructions
|
| 169 |
+
|
| 170 |
+
---
|
| 171 |
+
|
| 172 |
+
## Testing Your Deployment
|
| 173 |
+
|
| 174 |
+
### 1. Health Check
|
| 175 |
+
```bash
|
| 176 |
+
curl https://your-project.vercel.app/health
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
Expected:
|
| 180 |
+
```json
|
| 181 |
+
{"status": "healthy"}
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
### 2. API Info
|
| 185 |
+
```bash
|
| 186 |
+
curl https://your-project.vercel.app/
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
### 3. Test Analysis
|
| 190 |
+
```bash
|
| 191 |
+
curl -X POST https://your-project.vercel.app/analyze \
|
| 192 |
+
-H "Content-Type: application/json" \
|
| 193 |
+
-d '{"url": "https://youtube.com/watch?v=dQw4w9WgXcQ", "num_clips": 3}'
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
---
|
| 197 |
+
|
| 198 |
+
## Troubleshooting
|
| 199 |
+
|
| 200 |
+
### Build Failures
|
| 201 |
+
|
| 202 |
+
**Problem**: Build fails with dependency errors
|
| 203 |
+
|
| 204 |
+
**Solution**:
|
| 205 |
+
```bash
|
| 206 |
+
# Update requirements.txt
|
| 207 |
+
pip freeze > requirements.txt
|
| 208 |
+
|
| 209 |
+
# Or manually specify versions
|
| 210 |
+
flask==3.0.3
|
| 211 |
+
yt-dlp==2024.8.6
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
### Timeout Issues
|
| 215 |
+
|
| 216 |
+
**Problem**: 504 Gateway Timeout
|
| 217 |
+
|
| 218 |
+
**Solution**:
|
| 219 |
+
- Reduce `clip_length` or `num_clips` in requests
|
| 220 |
+
- Videos > 2 hours may timeout
|
| 221 |
+
- Consider splitting long videos
|
| 222 |
+
|
| 223 |
+
### Import Errors
|
| 224 |
+
|
| 225 |
+
**Problem**: `ModuleNotFoundError`
|
| 226 |
+
|
| 227 |
+
**Solution**:
|
| 228 |
+
- Ensure all dependencies in `requirements.txt`
|
| 229 |
+
- Check Python version compatibility
|
| 230 |
+
- Verify file structure matches `vercel.json`
|
| 231 |
+
|
| 232 |
+
### CORS Errors
|
| 233 |
+
|
| 234 |
+
**Problem**: Frontend can't access API
|
| 235 |
+
|
| 236 |
+
**Solution**:
|
| 237 |
+
- CORS is enabled by default in `api/index.py`
|
| 238 |
+
- Check `flask-cors` is in requirements.txt
|
| 239 |
+
|
| 240 |
+
---
|
| 241 |
+
|
| 242 |
+
## Monitoring
|
| 243 |
+
|
| 244 |
+
### Vercel Analytics
|
| 245 |
+
|
| 246 |
+
1. Go to Vercel Dashboard → Your Project → Analytics
|
| 247 |
+
2. View:
|
| 248 |
+
- Request count
|
| 249 |
+
- Response times
|
| 250 |
+
- Error rates
|
| 251 |
+
- Bandwidth usage
|
| 252 |
+
|
| 253 |
+
### Logs
|
| 254 |
+
|
| 255 |
+
```bash
|
| 256 |
+
# View live logs
|
| 257 |
+
vercel logs your-project --tail
|
| 258 |
+
```
|
| 259 |
+
|
| 260 |
+
Or in Dashboard → Project → Logs
|
| 261 |
+
|
| 262 |
+
---
|
| 263 |
+
|
| 264 |
+
## Updating Your API
|
| 265 |
+
|
| 266 |
+
### Method 1: Git Push (Auto-Deploy)
|
| 267 |
+
|
| 268 |
+
```bash
|
| 269 |
+
# Make changes
|
| 270 |
+
git add .
|
| 271 |
+
git commit -m "Update feature"
|
| 272 |
+
git push origin main
|
| 273 |
+
# Vercel auto-deploys!
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
### Method 2: Vercel CLI
|
| 277 |
+
|
| 278 |
+
```bash
|
| 279 |
+
vercel --prod
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
---
|
| 283 |
+
|
| 284 |
+
## Environment Variables
|
| 285 |
+
|
| 286 |
+
### Add to Vercel
|
| 287 |
+
|
| 288 |
+
1. Dashboard → Project → Settings → Environment Variables
|
| 289 |
+
2. Or via CLI:
|
| 290 |
+
```bash
|
| 291 |
+
vercel env add VARIABLE_NAME
|
| 292 |
+
```
|
| 293 |
+
|
| 294 |
+
### Use in Code
|
| 295 |
+
|
| 296 |
+
```python
|
| 297 |
+
import os
|
| 298 |
+
|
| 299 |
+
api_key = os.environ.get('API_KEY')
|
| 300 |
+
```
|
| 301 |
+
|
| 302 |
+
---
|
| 303 |
+
|
| 304 |
+
## Security Best Practices
|
| 305 |
+
|
| 306 |
+
1. **Never commit `.env` files**
|
| 307 |
+
- Already in `.gitignore`
|
| 308 |
+
|
| 309 |
+
2. **Use environment variables for secrets**
|
| 310 |
+
- API keys
|
| 311 |
+
- Database URLs
|
| 312 |
+
- Private tokens
|
| 313 |
+
|
| 314 |
+
3. **Enable Vercel Authentication**
|
| 315 |
+
- Dashboard → Project → Settings → Security
|
| 316 |
+
|
| 317 |
+
4. **Rate Limiting**
|
| 318 |
+
- Consider adding Flask-Limiter for production
|
| 319 |
+
|
| 320 |
+
---
|
| 321 |
+
|
| 322 |
+
## Next Steps
|
| 323 |
+
|
| 324 |
+
- [ ] Add custom domain
|
| 325 |
+
- [ ] Set up monitoring/alerts
|
| 326 |
+
- [ ] Add rate limiting
|
| 327 |
+
- [ ] Implement caching
|
| 328 |
+
- [ ] Add more languages support
|
| 329 |
+
- [ ] Create mobile app
|
| 330 |
+
|
| 331 |
+
---
|
| 332 |
+
|
| 333 |
+
## Support
|
| 334 |
+
|
| 335 |
+
- **Vercel Docs**: [vercel.com/docs](https://vercel.com/docs)
|
| 336 |
+
- **Python Runtime**: [vercel.com/docs/functions/serverless-functions/runtimes/python](https://vercel.com/docs/functions/serverless-functions/runtimes/python)
|
| 337 |
+
- **yt-dlp Docs**: [github.com/yt-dlp/yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
| 338 |
+
|
| 339 |
+
---
|
| 340 |
+
|
| 341 |
+
**Happy Deploying! 🚀**
|
Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.9 Slim image
|
| 2 |
+
FROM python:3.9-slim
|
| 3 |
+
|
| 4 |
+
# Install system dependencies (FFmpeg)
|
| 5 |
+
RUN apt-get update && \
|
| 6 |
+
apt-get install -y ffmpeg && \
|
| 7 |
+
apt-get clean && \
|
| 8 |
+
rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Set working directory
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
# Copy requirements first (for caching)
|
| 14 |
+
COPY requirements.txt .
|
| 15 |
+
|
| 16 |
+
# Install Python dependencies
|
| 17 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
+
|
| 19 |
+
# Copy application code
|
| 20 |
+
COPY api/ api/
|
| 21 |
+
|
| 22 |
+
# Set environment variables
|
| 23 |
+
ENV PYTHONUNBUFFERED=1
|
| 24 |
+
|
| 25 |
+
# Expose port (Render sets PORT env var)
|
| 26 |
+
EXPOSE 5000
|
| 27 |
+
|
| 28 |
+
# Run with Gunicorn
|
| 29 |
+
CMD gunicorn -w 4 -b 0.0.0.0:$PORT api.index:app
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 Viral Clip Extractor
|
| 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,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🎬 Viral Clip Extractor API
|
| 2 |
+
|
| 3 |
+
Turn long-form YouTube videos into engaging, viral-ready clips for TikTok, Shorts, and Reels using AI.
|
| 4 |
+
|
| 5 |
+
**Live Demo / Docs:** `https://your-app-url.onrender.com/docs`
|
| 6 |
+
|
| 7 |
+
## ✨ Features
|
| 8 |
+
|
| 9 |
+
- **🧠 Three Powerful Modes:**
|
| 10 |
+
1. **Heuristic (Free/Fast):** Uses keyword analysis and audio/visual cues. Great for high-energy content.
|
| 11 |
+
2. **Gemini AI (Balanced):** Uses Google's Gemini 2.0 Flash for context-aware clipping.
|
| 12 |
+
3. **Nvidia/DeepSeek AI (Advanced):** Uses DeepSeek V3/R1 via Nvidia API for deep reasoning and technical content.
|
| 13 |
+
- **🚀 High Performance:** Optimized `yt-dlp` and `ffmpeg` integration for fast processing.
|
| 14 |
+
- **☁️ Cloud Ready:** Dockerized and configured for [Render](https://render.com) deployment.
|
| 15 |
+
- **🔒 Secure:** Environment variable support for API keys.
|
| 16 |
+
- **📄 Auto-Documentation:** Built-in interactive API docs at `/docs`.
|
| 17 |
+
|
| 18 |
+
## 🛠️ Installation
|
| 19 |
+
|
| 20 |
+
### Option 1: Docker (Recommended)
|
| 21 |
+
```bash
|
| 22 |
+
docker build -t viral-clips .
|
| 23 |
+
docker run -p 5000:5000 --env-file .env viral-clips
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
### Option 2: Local Python
|
| 27 |
+
1. **Install FFmpeg:** Ensure `ffmpeg` is in your system PATH.
|
| 28 |
+
2. **Install Requirements:**
|
| 29 |
+
```bash
|
| 30 |
+
pip install -r requirements.txt
|
| 31 |
+
```
|
| 32 |
+
3. **Run API:**
|
| 33 |
+
```bash
|
| 34 |
+
python api/index.py
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
## 🔑 Configuration
|
| 38 |
+
|
| 39 |
+
Create a `.env` file or set these environment variables in your cloud dashboard:
|
| 40 |
+
|
| 41 |
+
| Variable | Description | Required For |
|
| 42 |
+
| :--- | :--- | :--- |
|
| 43 |
+
| `GEMINI_API_KEY` | Google Gemini API Key | `mode=ai` |
|
| 44 |
+
| `NVIDIA_API_KEY` | Nvidia/DeepSeek API Key | `mode=nvidia` |
|
| 45 |
+
| `PORT` | Server Port (Default: 5000) | Deployment |
|
| 46 |
+
|
| 47 |
+
## 📚 API Usage
|
| 48 |
+
|
| 49 |
+
### Get Clips
|
| 50 |
+
**Endpoint:** `GET /clips`
|
| 51 |
+
|
| 52 |
+
| Parameter | Type | Default | Description |
|
| 53 |
+
| :--- | :--- | :--- | :--- |
|
| 54 |
+
| `url` | string | **Required** | YouTube Video URL |
|
| 55 |
+
| `mode` | string | `heuristic` | `heuristic`, `ai`, or `nvidia` |
|
| 56 |
+
| `num` | int | `5` | Number of clips to generate |
|
| 57 |
+
|
| 58 |
+
**Example:**
|
| 59 |
+
```bash
|
| 60 |
+
curl "http://localhost:5000/clips?url=https://youtu.be/VIDEO_ID&mode=ai"
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
### Download Clip
|
| 64 |
+
**Endpoint:** `GET /download_file`
|
| 65 |
+
|
| 66 |
+
| Parameter | Type | Description |
|
| 67 |
+
| :--- | :--- | :--- |
|
| 68 |
+
| `url` | string | Original YouTube URL |
|
| 69 |
+
| `start` | float | Start time (seconds) |
|
| 70 |
+
| `end` | float | End time (seconds) |
|
| 71 |
+
|
| 72 |
+
## 📝 Credits
|
| 73 |
+
Developed with ❤️ by **yash@dev**
|
| 74 |
+
|
| 75 |
+
Powered by:
|
| 76 |
+
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
| 77 |
+
- [FFmpeg](https://ffmpeg.org/)
|
| 78 |
+
- [Google Gemini](https://ai.google.dev/)
|
| 79 |
+
- [Nvidia DeepSeek](https://build.nvidia.com/)
|
api/index.py
ADDED
|
@@ -0,0 +1,1001 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Viral Clip Extractor API
|
| 3 |
+
Deploy to Vercel for YouTube viral clip analysis
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from flask import Flask, request, jsonify, send_file, after_this_request
|
| 7 |
+
from flask_cors import CORS
|
| 8 |
+
import yt_dlp
|
| 9 |
+
import requests
|
| 10 |
+
import google.generativeai as genai
|
| 11 |
+
from openai import OpenAI
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
+
from io import StringIO
|
| 15 |
+
import os
|
| 16 |
+
import re
|
| 17 |
+
from typing import Dict, List, Optional, Any
|
| 18 |
+
|
| 19 |
+
app = Flask(__name__)
|
| 20 |
+
CORS(app)
|
| 21 |
+
|
| 22 |
+
# ============== CONFIGURATION ==============
|
| 23 |
+
DEFAULT_CLIP_LENGTH = 40
|
| 24 |
+
DEFAULT_WINDOW_STEP = 10
|
| 25 |
+
DEFAULT_MIN_CLIP_LENGTH = 25
|
| 26 |
+
DEFAULT_MAX_CLIP_LENGTH = 60
|
| 27 |
+
|
| 28 |
+
HOOK_WORDS = [
|
| 29 |
+
"wait", "shocking", "unbelievable", "secret", "crazy", "exposed", "omg", "wtf",
|
| 30 |
+
"plot twist", "insane", "cheating", "stunning", "mind blowing", "truth", "lie",
|
| 31 |
+
"revealed", "hidden", "discover", "amazing", "incredible", "must see", "urgent"
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
EMOTION_WORDS = [
|
| 35 |
+
"laugh", "cry", "angry", "shocked", "surprised", "scream", "love", "hate",
|
| 36 |
+
"disgusted", "horrified", "amazed", "excited", "sad", "happy", "furious"
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
# ============== UTILITY FUNCTIONS ==============
|
| 40 |
+
|
| 41 |
+
def extract_video_id(url: str) -> Optional[str]:
|
| 42 |
+
"""Extract YouTube video ID from various URL formats"""
|
| 43 |
+
patterns = [
|
| 44 |
+
r'(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})',
|
| 45 |
+
r'^([a-zA-Z0-9_-]{11})$'
|
| 46 |
+
]
|
| 47 |
+
for pattern in patterns:
|
| 48 |
+
match = re.search(pattern, url)
|
| 49 |
+
if match:
|
| 50 |
+
return match.group(1)
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
def time_to_seconds(t) -> float:
|
| 54 |
+
"""Convert time string to seconds"""
|
| 55 |
+
if isinstance(t, (int, float)):
|
| 56 |
+
return float(t)
|
| 57 |
+
if ':' in str(t):
|
| 58 |
+
parts = list(map(int, str(t).split(':')))
|
| 59 |
+
if len(parts) == 3:
|
| 60 |
+
return parts[0] * 3600 + parts[1] * 60 + parts[2]
|
| 61 |
+
elif len(parts) == 2:
|
| 62 |
+
return parts[0] * 60 + parts[1]
|
| 63 |
+
elif len(parts) == 1:
|
| 64 |
+
return parts[0]
|
| 65 |
+
return float(t)
|
| 66 |
+
|
| 67 |
+
def seconds_to_time(s: float) -> str:
|
| 68 |
+
"""Convert seconds to MM:SS format"""
|
| 69 |
+
minutes = int(s // 60)
|
| 70 |
+
seconds = int(s % 60)
|
| 71 |
+
return f"{minutes}:{seconds:02d}"
|
| 72 |
+
|
| 73 |
+
def parse_vtt_content(vtt_text: str) -> List[Dict]:
|
| 74 |
+
"""Parse VTT subtitle content"""
|
| 75 |
+
segments = []
|
| 76 |
+
lines = vtt_text.strip().split('\n')
|
| 77 |
+
|
| 78 |
+
i = 0
|
| 79 |
+
while i < len(lines) and not '-->' in lines[i]:
|
| 80 |
+
i += 1
|
| 81 |
+
|
| 82 |
+
current_text = []
|
| 83 |
+
current_start = None
|
| 84 |
+
current_end = None
|
| 85 |
+
|
| 86 |
+
while i < len(lines):
|
| 87 |
+
line = lines[i].strip()
|
| 88 |
+
|
| 89 |
+
if '-->' in line:
|
| 90 |
+
if current_text and current_start is not None:
|
| 91 |
+
segments.append({
|
| 92 |
+
'start': current_start,
|
| 93 |
+
'end': current_end,
|
| 94 |
+
'text': ' '.join(current_text)
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
times = line.split('-->')
|
| 98 |
+
if len(times) == 2:
|
| 99 |
+
start_str = times[0].strip().split('.')[0]
|
| 100 |
+
end_str = times[1].strip().split()[0].split('.')[0]
|
| 101 |
+
|
| 102 |
+
current_start = time_to_seconds(start_str)
|
| 103 |
+
current_end = time_to_seconds(end_str)
|
| 104 |
+
current_text = []
|
| 105 |
+
elif line and not line.startswith('NOTE') and not line.startswith('STYLE'):
|
| 106 |
+
clean_line = re.sub(r'<[^>]+>', '', line)
|
| 107 |
+
if clean_line:
|
| 108 |
+
current_text.append(clean_line)
|
| 109 |
+
|
| 110 |
+
i += 1
|
| 111 |
+
|
| 112 |
+
if current_text and current_start is not None:
|
| 113 |
+
segments.append({
|
| 114 |
+
'start': current_start,
|
| 115 |
+
'end': current_end,
|
| 116 |
+
'text': ' '.join(current_text)
|
| 117 |
+
})
|
| 118 |
+
|
| 119 |
+
return segments
|
| 120 |
+
|
| 121 |
+
import sys
|
| 122 |
+
import logging
|
| 123 |
+
|
| 124 |
+
# Configure logging
|
| 125 |
+
logging.basicConfig(level=logging.INFO)
|
| 126 |
+
logger = logging.getLogger(__name__)
|
| 127 |
+
|
| 128 |
+
# ============== CORE CLASS ==============
|
| 129 |
+
|
| 130 |
+
class ViralClipExtractor:
|
| 131 |
+
def __init__(self):
|
| 132 |
+
self.headers = {
|
| 133 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
| 134 |
+
}
|
| 135 |
+
# Robust FFmpeg path resolution
|
| 136 |
+
import shutil
|
| 137 |
+
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 138 |
+
|
| 139 |
+
# 1. Check local ffmpeg folder (Windows/Dev)
|
| 140 |
+
ffmpeg_path_local = os.path.join(project_root, "ffmpeg", "bin", "ffmpeg.exe")
|
| 141 |
+
|
| 142 |
+
# 2. Check system PATH (Linux/Render/Docker)
|
| 143 |
+
ffmpeg_path_system = shutil.which("ffmpeg")
|
| 144 |
+
|
| 145 |
+
ffmpeg_location = None
|
| 146 |
+
if os.path.exists(ffmpeg_path_local):
|
| 147 |
+
ffmpeg_location = ffmpeg_path_local
|
| 148 |
+
ffmpeg_dir = os.path.dirname(ffmpeg_location)
|
| 149 |
+
if ffmpeg_dir not in os.environ["PATH"]:
|
| 150 |
+
os.environ["PATH"] += os.pathsep + ffmpeg_dir
|
| 151 |
+
logger.info(f"Using Local FFmpeg: {ffmpeg_location}")
|
| 152 |
+
elif ffmpeg_path_system:
|
| 153 |
+
# If on system path, we don't need to specify location for yt-dlp usually,
|
| 154 |
+
# but getting the dir is good practice
|
| 155 |
+
ffmpeg_location = ffmpeg_path_system
|
| 156 |
+
logger.info(f"Using System FFmpeg: {ffmpeg_location}")
|
| 157 |
+
else:
|
| 158 |
+
logger.warning("FFmpeg NOT FOUND! Clip cutting will fail.")
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
self.base_ydl_opts = {
|
| 162 |
+
'quiet': False,
|
| 163 |
+
'no_warnings': False,
|
| 164 |
+
# 'extractor_args': {'youtube': {'player_client': ['web']}},
|
| 165 |
+
'http_headers': self.headers,
|
| 166 |
+
# 'ffmpeg_location': ffmpeg_dir # Removed, relying on PATH
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
if ffmpeg_dir:
|
| 170 |
+
logger.info(f"Added FFmpeg to PATH: {ffmpeg_dir}")
|
| 171 |
+
else:
|
| 172 |
+
logger.warning("FFmpeg NOT FOUND! Clip cutting will fail.")
|
| 173 |
+
|
| 174 |
+
def download_clip(self, url: str, start: float, end: float, quality: str = "720") -> Optional[str]:
|
| 175 |
+
"""Download and cut a clip"""
|
| 176 |
+
import uuid
|
| 177 |
+
output_dir = "downloads"
|
| 178 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 179 |
+
clip_id = f"clip_{uuid.uuid4().hex[:8]}"
|
| 180 |
+
output_template = os.path.join(output_dir, f"{clip_id}.%(ext)s")
|
| 181 |
+
|
| 182 |
+
ydl_opts = {
|
| 183 |
+
**self.base_ydl_opts,
|
| 184 |
+
'format': f'bestvideo[height<={quality}]+bestaudio/best[height<={quality}]/best',
|
| 185 |
+
'outtmpl': output_template,
|
| 186 |
+
'download_ranges': lambda info, ydl: [{
|
| 187 |
+
'start_time': start,
|
| 188 |
+
'end_time': end
|
| 189 |
+
}],
|
| 190 |
+
'force_keyframes_at_cuts': True,
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
try:
|
| 194 |
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
| 195 |
+
ydl.download([url])
|
| 196 |
+
|
| 197 |
+
# Find the downloaded file
|
| 198 |
+
for file in os.listdir(output_dir):
|
| 199 |
+
if file.startswith(clip_id):
|
| 200 |
+
return os.path.abspath(os.path.join(output_dir, file))
|
| 201 |
+
return None
|
| 202 |
+
except Exception as e:
|
| 203 |
+
with open("error.log", "a") as f:
|
| 204 |
+
f.write(f"Download Error: {e}\n")
|
| 205 |
+
logger.error(f"Error downloading clip: {e}")
|
| 206 |
+
return None
|
| 207 |
+
|
| 208 |
+
def analyze_with_gemini(self, transcript_segments: List[Dict], api_key: str) -> List[Dict]:
|
| 209 |
+
"""Analyze full transcript using Gemini AI"""
|
| 210 |
+
try:
|
| 211 |
+
genai.configure(api_key=api_key)
|
| 212 |
+
# Using 2.0 Flash as 1.5 is unavailable for this key/region
|
| 213 |
+
model = genai.GenerativeModel('gemini-2.0-flash')
|
| 214 |
+
|
| 215 |
+
# Helper to check if API key is likely valid (heuristic)
|
| 216 |
+
if "YOUR_GEMINI" in api_key:
|
| 217 |
+
raise ValueError("Invalid API Key placeholder used")
|
| 218 |
+
|
| 219 |
+
# Construct prompt
|
| 220 |
+
formatted_text = ""
|
| 221 |
+
for seg in transcript_segments:
|
| 222 |
+
start = int(seg['start'])
|
| 223 |
+
text = seg['text']
|
| 224 |
+
formatted_text += f"[{start}s] {text} "
|
| 225 |
+
|
| 226 |
+
# Truncate
|
| 227 |
+
full_context = formatted_text[:30000] # Safe limit
|
| 228 |
+
|
| 229 |
+
prompt = f"""
|
| 230 |
+
You are a viral content expert. Analyze the video transcript (with timestamps) and identify the top 3-5 most engaging clips for Shorts/TikTok.
|
| 231 |
+
|
| 232 |
+
Transcript:
|
| 233 |
+
{full_context}
|
| 234 |
+
|
| 235 |
+
Return ONLY a raw JSON array (no markdown) of objects:
|
| 236 |
+
[
|
| 237 |
+
{{
|
| 238 |
+
"start": <number_seconds>,
|
| 239 |
+
"end": <number_seconds>,
|
| 240 |
+
"viral_score": <0-100>,
|
| 241 |
+
"reason": "<short explanation>"
|
| 242 |
+
}}
|
| 243 |
+
]
|
| 244 |
+
"""
|
| 245 |
+
|
| 246 |
+
logger.info("Sending request to Gemini AI...")
|
| 247 |
+
response = model.generate_content(prompt)
|
| 248 |
+
text = response.text
|
| 249 |
+
# Clean markdown
|
| 250 |
+
text = re.sub(r"```json\s*", "", text)
|
| 251 |
+
text = re.sub(r"```\s*", "", text)
|
| 252 |
+
return json.loads(text)
|
| 253 |
+
except Exception as e:
|
| 254 |
+
logger.error(f"Gemini API Error: {e}")
|
| 255 |
+
raise e # Create visibility for the error
|
| 256 |
+
|
| 257 |
+
def analyze_with_nvidia(self, transcript_segments: List[Dict], api_key: str) -> List[Dict]:
|
| 258 |
+
"""Analyze full transcript using Nvidia/DeepSeek AI"""
|
| 259 |
+
try:
|
| 260 |
+
client = OpenAI(
|
| 261 |
+
base_url = "https://integrate.api.nvidia.com/v1",
|
| 262 |
+
api_key = api_key
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
# Construct prompt
|
| 266 |
+
formatted_text = ""
|
| 267 |
+
for seg in transcript_segments:
|
| 268 |
+
start = int(seg['start'])
|
| 269 |
+
text = seg['text']
|
| 270 |
+
formatted_text += f"[{start}s] {text} "
|
| 271 |
+
|
| 272 |
+
full_context = formatted_text[:30000]
|
| 273 |
+
|
| 274 |
+
prompt = f"""
|
| 275 |
+
You are a viral content expert. Analyze the video transcript (with timestamps) and identify the top 3-5 most engaging clips for Shorts/TikTok.
|
| 276 |
+
|
| 277 |
+
Transcript:
|
| 278 |
+
{full_context}
|
| 279 |
+
|
| 280 |
+
Return ONLY a raw JSON array (no markdown) of objects:
|
| 281 |
+
[
|
| 282 |
+
{{
|
| 283 |
+
"start": <number_seconds>,
|
| 284 |
+
"end": <number_seconds>,
|
| 285 |
+
"viral_score": <0-100>,
|
| 286 |
+
"reason": "<short explanation>"
|
| 287 |
+
}}
|
| 288 |
+
]
|
| 289 |
+
"""
|
| 290 |
+
|
| 291 |
+
logger.info("Sending request to Nvidia/DeepSeek AI...")
|
| 292 |
+
completion = client.chat.completions.create(
|
| 293 |
+
model="deepseek-ai/deepseek-v3.1-terminus",
|
| 294 |
+
messages=[{"role":"user","content":prompt}],
|
| 295 |
+
temperature=0.2,
|
| 296 |
+
top_p=0.7,
|
| 297 |
+
max_tokens=8192,
|
| 298 |
+
extra_body={"chat_template_kwargs": {"thinking":True}},
|
| 299 |
+
stream=False
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
text = completion.choices[0].message.content
|
| 303 |
+
# Clean markdown
|
| 304 |
+
text = re.sub(r"```json\s*", "", text)
|
| 305 |
+
text = re.sub(r"```\s*", "", text)
|
| 306 |
+
# Remove thinking content
|
| 307 |
+
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
|
| 308 |
+
|
| 309 |
+
return json.loads(text)
|
| 310 |
+
except Exception as e:
|
| 311 |
+
logger.error(f"Nvidia API Error: {e}")
|
| 312 |
+
raise e
|
| 313 |
+
|
| 314 |
+
def extract_video_info(self, url: str) -> Dict:
|
| 315 |
+
"""Get video metadata"""
|
| 316 |
+
logger.info(f"Extracting video info for: {url}")
|
| 317 |
+
ydl_opts = {
|
| 318 |
+
**self.base_ydl_opts,
|
| 319 |
+
'simulate': True,
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
try:
|
| 323 |
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
| 324 |
+
info = ydl.extract_info(url, download=False)
|
| 325 |
+
|
| 326 |
+
chapters = []
|
| 327 |
+
if 'chapters' in info and info['chapters']:
|
| 328 |
+
for ch in info['chapters']:
|
| 329 |
+
chapters.append({
|
| 330 |
+
"start": ch['start_time'],
|
| 331 |
+
"end": ch.get('end_time') or ch['start_time'] + DEFAULT_CLIP_LENGTH,
|
| 332 |
+
"title": ch.get('title', 'Untitled')
|
| 333 |
+
})
|
| 334 |
+
|
| 335 |
+
logger.info(f"Video info extracted: {info.get('title')}")
|
| 336 |
+
return {
|
| 337 |
+
"id": info.get("id"),
|
| 338 |
+
"title": info.get("title"),
|
| 339 |
+
"uploader": info.get("uploader"),
|
| 340 |
+
"duration": info.get("duration"),
|
| 341 |
+
"description": info.get("description", "")[:500],
|
| 342 |
+
"view_count": info.get("view_count"),
|
| 343 |
+
"like_count": info.get("like_count"),
|
| 344 |
+
"chapters": chapters
|
| 345 |
+
}
|
| 346 |
+
except Exception as e:
|
| 347 |
+
logger.error(f"Error extracting video info: {e}")
|
| 348 |
+
raise
|
| 349 |
+
|
| 350 |
+
def fetch_full_transcript(self, url: str) -> List[Dict]:
|
| 351 |
+
"""Fetch full transcript once"""
|
| 352 |
+
logger.info(f"Fetching full transcript for: {url}")
|
| 353 |
+
ydl_opts = {
|
| 354 |
+
**self.base_ydl_opts,
|
| 355 |
+
'skip_download': True,
|
| 356 |
+
'writesubtitles': True,
|
| 357 |
+
'writeautomaticsub': True,
|
| 358 |
+
'subtitleslangs': ['en'],
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
try:
|
| 362 |
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
| 363 |
+
info = ydl.extract_info(url, download=False)
|
| 364 |
+
|
| 365 |
+
sub_url = None
|
| 366 |
+
if 'en' in info.get('subtitles', {}):
|
| 367 |
+
sub_url = info['subtitles']['en'][-1].get('url')
|
| 368 |
+
elif 'en' in info.get('automatic_captions', {}):
|
| 369 |
+
sub_url = info['automatic_captions']['en'][-1].get('url')
|
| 370 |
+
|
| 371 |
+
if not sub_url:
|
| 372 |
+
logger.warning("No transcript found")
|
| 373 |
+
return []
|
| 374 |
+
|
| 375 |
+
response = requests.get(sub_url, headers=self.headers, timeout=15)
|
| 376 |
+
response.raise_for_status()
|
| 377 |
+
sub_text = response.text
|
| 378 |
+
|
| 379 |
+
segments = []
|
| 380 |
+
if sub_url.endswith('.vtt') or 'vtt' in sub_url:
|
| 381 |
+
segments = parse_vtt_content(sub_text)
|
| 382 |
+
elif sub_url.endswith('.srt') or 'srt' in sub_url:
|
| 383 |
+
# Reuse vtt logic as simple fallback
|
| 384 |
+
segments = parse_vtt_content(sub_text)
|
| 385 |
+
|
| 386 |
+
# If we have segments, return them
|
| 387 |
+
if segments:
|
| 388 |
+
return segments
|
| 389 |
+
|
| 390 |
+
# Fallback to json3 if available/needed
|
| 391 |
+
try:
|
| 392 |
+
import json
|
| 393 |
+
data = json.loads(sub_text)
|
| 394 |
+
if 'events' in data:
|
| 395 |
+
for event in data['events']:
|
| 396 |
+
if 'segs' in event:
|
| 397 |
+
start_time = event.get('tStartMs', 0) / 1000
|
| 398 |
+
duration = event.get('dDurationMs', 0) / 1000
|
| 399 |
+
text = ''.join(seg.get('utf8', '') for seg in event['segs'])
|
| 400 |
+
segments.append({
|
| 401 |
+
'start': start_time,
|
| 402 |
+
'end': start_time + duration,
|
| 403 |
+
'text': text
|
| 404 |
+
})
|
| 405 |
+
except:
|
| 406 |
+
pass
|
| 407 |
+
|
| 408 |
+
return segments
|
| 409 |
+
|
| 410 |
+
except Exception as e:
|
| 411 |
+
logger.error(f"Transcript error: {e}")
|
| 412 |
+
return []
|
| 413 |
+
|
| 414 |
+
def get_transcript_text(self, segments: List[Dict], start: float, end: float) -> str:
|
| 415 |
+
"""Filter transcript from pre-fetched segments"""
|
| 416 |
+
relevant = [
|
| 417 |
+
seg["text"] for seg in segments
|
| 418 |
+
if seg["end"] >= start and seg["start"] <= end and seg["text"].strip()
|
| 419 |
+
]
|
| 420 |
+
return " ".join(relevant)
|
| 421 |
+
|
| 422 |
+
def get_transcript(self, url: str, start: float, end: float) -> str:
|
| 423 |
+
"""Legacy method for backward compatibility"""
|
| 424 |
+
segments = self.fetch_full_transcript(url)
|
| 425 |
+
return self.get_transcript_text(segments, start, end)
|
| 426 |
+
|
| 427 |
+
def score_clip(self, url: str, start: float, end: float, transcript_segments: List[Dict] = None) -> Dict:
|
| 428 |
+
"""Calculate viral score for clip"""
|
| 429 |
+
if transcript_segments:
|
| 430 |
+
transcript = self.get_transcript_text(transcript_segments, start, end).lower()
|
| 431 |
+
else:
|
| 432 |
+
# Fallback
|
| 433 |
+
transcript = self.get_transcript(url, start, end).lower()
|
| 434 |
+
|
| 435 |
+
score = 0
|
| 436 |
+
reasons = []
|
| 437 |
+
|
| 438 |
+
# Hook words (up to 50 points)
|
| 439 |
+
hooks = [w for w in HOOK_WORDS if w in transcript]
|
| 440 |
+
hook_score = min(len(hooks) * 10, 50)
|
| 441 |
+
score += hook_score
|
| 442 |
+
if hooks:
|
| 443 |
+
reasons.append(f"Hooks: {', '.join(hooks[:3])}")
|
| 444 |
+
|
| 445 |
+
# Emotion words (up to 30 points)
|
| 446 |
+
emotions = [w for w in EMOTION_WORDS if w in transcript]
|
| 447 |
+
emotion_score = min(len(emotions) * 6, 30)
|
| 448 |
+
score += emotion_score
|
| 449 |
+
if emotions:
|
| 450 |
+
reasons.append(f"Emotion: {', '.join(emotions[:2])}")
|
| 451 |
+
|
| 452 |
+
# Conflict indicators (20 points)
|
| 453 |
+
conflict_words = ["but", "however", "argument", "fight", "wrong", "disagree"]
|
| 454 |
+
if any(w in transcript for w in conflict_words):
|
| 455 |
+
score += 20
|
| 456 |
+
reasons.append("Conflict detected")
|
| 457 |
+
|
| 458 |
+
# Duration bonus
|
| 459 |
+
duration = end - start
|
| 460 |
+
if DEFAULT_MIN_CLIP_LENGTH <= duration <= DEFAULT_MAX_CLIP_LENGTH:
|
| 461 |
+
score += 10
|
| 462 |
+
reasons.append("Optimal duration")
|
| 463 |
+
elif duration < DEFAULT_MIN_CLIP_LENGTH:
|
| 464 |
+
score -= 10
|
| 465 |
+
reasons.append("Short clip")
|
| 466 |
+
elif duration > DEFAULT_MAX_CLIP_LENGTH:
|
| 467 |
+
score -= 5
|
| 468 |
+
reasons.append("Long clip")
|
| 469 |
+
|
| 470 |
+
# Transcript quality bonus
|
| 471 |
+
word_count = len(transcript.split())
|
| 472 |
+
if word_count > 10:
|
| 473 |
+
score += 5
|
| 474 |
+
reasons.append("Good content density")
|
| 475 |
+
|
| 476 |
+
return {
|
| 477 |
+
"start": start,
|
| 478 |
+
"end": end,
|
| 479 |
+
"duration": round(duration, 1),
|
| 480 |
+
"viral_score": max(0, min(score, 100)),
|
| 481 |
+
"reasons": reasons,
|
| 482 |
+
"transcript_preview": transcript[:150] + "..." if len(transcript) > 150 else transcript,
|
| 483 |
+
"word_count": word_count
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
def generate_candidates(self, video_info: Dict, clip_length: int = None, window_step: int = None) -> List[Dict]:
|
| 487 |
+
"""Generate candidate clips"""
|
| 488 |
+
duration = video_info.get("duration", 0)
|
| 489 |
+
clip_length = clip_length or DEFAULT_CLIP_LENGTH
|
| 490 |
+
window_step = window_step or DEFAULT_WINDOW_STEP
|
| 491 |
+
candidates = []
|
| 492 |
+
|
| 493 |
+
# Use chapters if available
|
| 494 |
+
if video_info.get("chapters"):
|
| 495 |
+
logger.info(f"Using {len(video_info['chapters'])} chapters for candidates")
|
| 496 |
+
for ch in video_info["chapters"]:
|
| 497 |
+
clip_start = ch["start"]
|
| 498 |
+
clip_end = min(ch["end"], clip_start + clip_length)
|
| 499 |
+
if clip_end - clip_start >= DEFAULT_MIN_CLIP_LENGTH:
|
| 500 |
+
candidates.append({
|
| 501 |
+
"start": clip_start,
|
| 502 |
+
"end": clip_end,
|
| 503 |
+
"title": ch.get("title", "Untitled")
|
| 504 |
+
})
|
| 505 |
+
else:
|
| 506 |
+
# Sliding windows
|
| 507 |
+
logger.info("Using sliding windows for candidates")
|
| 508 |
+
for start in range(0, int(duration) - clip_length, window_step):
|
| 509 |
+
candidates.append({
|
| 510 |
+
"start": start,
|
| 511 |
+
"end": min(start + clip_length, duration)
|
| 512 |
+
})
|
| 513 |
+
|
| 514 |
+
return candidates
|
| 515 |
+
|
| 516 |
+
# ============== API ROUTES ==============
|
| 517 |
+
|
| 518 |
+
@app.route('/', methods=['GET'])
|
| 519 |
+
def index():
|
| 520 |
+
"""Root endpoint - API info"""
|
| 521 |
+
return jsonify({
|
| 522 |
+
"name": "Viral Clip Extractor API",
|
| 523 |
+
"version": "1.3.0 (AI Enabled)",
|
| 524 |
+
"description": "Extract and analyze viral clips from YouTube videos",
|
| 525 |
+
"quick_start": {
|
| 526 |
+
"endpoint": "/clips",
|
| 527 |
+
"method": "GET",
|
| 528 |
+
"usage": "/clips?url=YOUTUBE_URL&quality=720&seconds=40&num=5&mode=ai&gemini_key=YOUR_KEY",
|
| 529 |
+
"example": "/clips?url=https://youtube.com/watch?v=dQw4w9WgXcQ&seconds=30&num=3"
|
| 530 |
+
},
|
| 531 |
+
"endpoints": {
|
| 532 |
+
"/clips": "GET - Extract clips (Heuristic or AI)",
|
| 533 |
+
"/health": "Health check",
|
| 534 |
+
"/analyze": "POST - Full analysis",
|
| 535 |
+
"/extract": "POST - Extract video info only",
|
| 536 |
+
"/download_file": "GET - Download clip file"
|
| 537 |
+
}
|
| 538 |
+
})
|
| 539 |
+
|
| 540 |
+
@app.route('/health', methods=['GET'])
|
| 541 |
+
def health():
|
| 542 |
+
"""Health check endpoint"""
|
| 543 |
+
return jsonify({
|
| 544 |
+
"status": "healthy",
|
| 545 |
+
"service": "viral-clip-extractor"
|
| 546 |
+
})
|
| 547 |
+
|
| 548 |
+
@app.route('/clips', methods=['GET'])
|
| 549 |
+
def get_clips():
|
| 550 |
+
"""
|
| 551 |
+
Get viral clips (Heuristic, Gemini AI, or Nvidia AI)
|
| 552 |
+
"""
|
| 553 |
+
try:
|
| 554 |
+
url = request.args.get('url')
|
| 555 |
+
if not url:
|
| 556 |
+
return jsonify({"error": "Missing 'url' parameter"}), 400
|
| 557 |
+
|
| 558 |
+
quality = request.args.get('quality', '720')
|
| 559 |
+
seconds = int(request.args.get('seconds', DEFAULT_CLIP_LENGTH))
|
| 560 |
+
num_clips = int(request.args.get('num', 5))
|
| 561 |
+
|
| 562 |
+
mode = request.args.get('mode', 'heuristic') # 'heuristic', 'ai' (defaults to gemini), 'nvidia'
|
| 563 |
+
ai_provider = request.args.get('ai_provider', 'gemini') # 'gemini' or 'nvidia'
|
| 564 |
+
|
| 565 |
+
# Handle API Keys
|
| 566 |
+
nvidia_key = request.args.get('nvidia_key') or os.environ.get('NVIDIA_API_KEY')
|
| 567 |
+
gemini_key = request.args.get('gemini_key') or os.environ.get('GEMINI_API_KEY')
|
| 568 |
+
|
| 569 |
+
# Unified key handling if user passes 'api_key' generic param
|
| 570 |
+
generic_key = request.args.get('api_key')
|
| 571 |
+
if generic_key:
|
| 572 |
+
if mode == 'nvidia' or ai_provider == 'nvidia':
|
| 573 |
+
nvidia_key = generic_key
|
| 574 |
+
else:
|
| 575 |
+
gemini_key = generic_key
|
| 576 |
+
|
| 577 |
+
extractor = ViralClipExtractor()
|
| 578 |
+
|
| 579 |
+
# 1. Get video info
|
| 580 |
+
video_info = extractor.extract_video_info(url)
|
| 581 |
+
video_id = extract_video_id(url)
|
| 582 |
+
|
| 583 |
+
if not video_id:
|
| 584 |
+
return jsonify({"error": "Invalid YouTube URL"}), 400
|
| 585 |
+
|
| 586 |
+
# 2. Fetch transcript ONCE
|
| 587 |
+
transcript_segments = extractor.fetch_full_transcript(url)
|
| 588 |
+
|
| 589 |
+
scored_clips = []
|
| 590 |
+
|
| 591 |
+
if mode == 'ai' or mode == 'nvidia' or request.args.get('ai_provider'):
|
| 592 |
+
# Determine provider
|
| 593 |
+
provider = 'nvidia' if (mode == 'nvidia' or ai_provider == 'nvidia') else 'gemini'
|
| 594 |
+
|
| 595 |
+
try:
|
| 596 |
+
if provider == 'nvidia':
|
| 597 |
+
if not nvidia_key:
|
| 598 |
+
return jsonify({"error": "Nvidia API Key required"}), 400
|
| 599 |
+
logger.info("Using Nvidia DeepSeek for analysis...")
|
| 600 |
+
scored_clips = extractor.analyze_with_nvidia(transcript_segments, nvidia_key)
|
| 601 |
+
else:
|
| 602 |
+
if not gemini_key:
|
| 603 |
+
return jsonify({"error": "Gemini API Key required"}), 400
|
| 604 |
+
logger.info("Using Gemini AI for analysis...")
|
| 605 |
+
scored_clips = extractor.analyze_with_gemini(transcript_segments, gemini_key)
|
| 606 |
+
|
| 607 |
+
# Normalize response
|
| 608 |
+
for clip in scored_clips:
|
| 609 |
+
clip['title'] = clip.get('reason', f'{provider.title()} Selected Clip')
|
| 610 |
+
clip.setdefault('start', 0)
|
| 611 |
+
clip.setdefault('end', clip['start'] + seconds)
|
| 612 |
+
clip.setdefault('viral_score', 80)
|
| 613 |
+
|
| 614 |
+
except Exception as e:
|
| 615 |
+
return jsonify({"error": f"AI Analysis Failed ({provider}): {str(e)}"}), 500
|
| 616 |
+
else:
|
| 617 |
+
# Heuristic
|
| 618 |
+
candidates = extractor.generate_candidates(video_info, seconds, DEFAULT_WINDOW_STEP)
|
| 619 |
+
|
| 620 |
+
logger.info(f"Scoring {len(candidates)} candidates...")
|
| 621 |
+
for i, cand in enumerate(candidates):
|
| 622 |
+
score_data = extractor.score_clip(url, cand['start'], cand['end'], transcript_segments)
|
| 623 |
+
score_data['title'] = cand.get('title', f'Clip {i+1}')
|
| 624 |
+
scored_clips.append(score_data)
|
| 625 |
+
|
| 626 |
+
scored_clips.sort(key=lambda x: x['viral_score'], reverse=True)
|
| 627 |
+
|
| 628 |
+
# Select top non-overlapping clips
|
| 629 |
+
final_clips = []
|
| 630 |
+
used_ranges = []
|
| 631 |
+
|
| 632 |
+
for clip in scored_clips:
|
| 633 |
+
overlaps = False
|
| 634 |
+
for used in used_ranges:
|
| 635 |
+
if not (clip['end'] <= used['start'] or clip['start'] >= used['end']):
|
| 636 |
+
overlaps = True
|
| 637 |
+
break
|
| 638 |
+
|
| 639 |
+
if not overlaps:
|
| 640 |
+
final_clips.append(clip)
|
| 641 |
+
used_ranges.append(clip)
|
| 642 |
+
|
| 643 |
+
if len(final_clips) >= num_clips:
|
| 644 |
+
break
|
| 645 |
+
|
| 646 |
+
import urllib.parse
|
| 647 |
+
encoded_url = urllib.parse.quote(url)
|
| 648 |
+
|
| 649 |
+
for clip in final_clips:
|
| 650 |
+
# Ensure start/end are floats
|
| 651 |
+
clip['start'] = float(clip['start'])
|
| 652 |
+
clip['end'] = float(clip['end'])
|
| 653 |
+
clip['start_formatted'] = seconds_to_time(clip['start'])
|
| 654 |
+
clip['end_formatted'] = seconds_to_time(clip['end'])
|
| 655 |
+
clip['youtube_url'] = f"https://youtube.com/watch?v={video_id}&t={int(clip['start'])}"
|
| 656 |
+
clip['download_link'] = f"/download_file?url={encoded_url}&start={clip['start']}&end={clip['end']}&quality={quality}"
|
| 657 |
+
|
| 658 |
+
return jsonify({
|
| 659 |
+
"success": True,
|
| 660 |
+
"video_id": video_id,
|
| 661 |
+
"video_title": video_info.get('title'),
|
| 662 |
+
"video_duration": video_info.get('duration'),
|
| 663 |
+
"mode": mode,
|
| 664 |
+
"clips": final_clips,
|
| 665 |
+
"clips_count": len(final_clips)
|
| 666 |
+
})
|
| 667 |
+
|
| 668 |
+
except Exception as e:
|
| 669 |
+
logger.error(f"Error in /clips: {e}")
|
| 670 |
+
return jsonify({
|
| 671 |
+
"success": False,
|
| 672 |
+
"error": str(e)
|
| 673 |
+
}), 500
|
| 674 |
+
|
| 675 |
+
@app.route('/extract', methods=['POST'])
|
| 676 |
+
def extract_video():
|
| 677 |
+
"""Extract basic video information"""
|
| 678 |
+
try:
|
| 679 |
+
data = request.get_json()
|
| 680 |
+
if not data or 'url' not in data:
|
| 681 |
+
return jsonify({"error": "Missing 'url' parameter"}), 400
|
| 682 |
+
|
| 683 |
+
url = data['url']
|
| 684 |
+
video_id = extract_video_id(url)
|
| 685 |
+
|
| 686 |
+
if not video_id:
|
| 687 |
+
return jsonify({"error": "Invalid YouTube URL"}), 400
|
| 688 |
+
|
| 689 |
+
extractor = ViralClipExtractor()
|
| 690 |
+
info = extractor.extract_video_info(url)
|
| 691 |
+
|
| 692 |
+
return jsonify({
|
| 693 |
+
"success": True,
|
| 694 |
+
"video_id": video_id,
|
| 695 |
+
"data": info
|
| 696 |
+
})
|
| 697 |
+
|
| 698 |
+
except Exception as e:
|
| 699 |
+
return jsonify({
|
| 700 |
+
"success": False,
|
| 701 |
+
"error": str(e)
|
| 702 |
+
}), 500
|
| 703 |
+
|
| 704 |
+
@app.route('/analyze', methods=['POST'])
|
| 705 |
+
def analyze_video():
|
| 706 |
+
"""Full viral clip analysis"""
|
| 707 |
+
try:
|
| 708 |
+
data = request.get_json()
|
| 709 |
+
if not data or 'url' not in data:
|
| 710 |
+
return jsonify({"error": "Missing 'url' parameter"}), 400
|
| 711 |
+
|
| 712 |
+
url = data['url']
|
| 713 |
+
video_id = extract_video_id(url)
|
| 714 |
+
|
| 715 |
+
if not video_id:
|
| 716 |
+
return jsonify({"error": "Invalid YouTube URL"}), 400
|
| 717 |
+
|
| 718 |
+
num_clips = data.get('num_clips', 5)
|
| 719 |
+
clip_length = data.get('clip_length', DEFAULT_CLIP_LENGTH)
|
| 720 |
+
window_step = data.get('window_step', DEFAULT_WINDOW_STEP)
|
| 721 |
+
quality = data.get('quality', '720')
|
| 722 |
+
mode = data.get('mode', 'heuristic')
|
| 723 |
+
gemini_key = data.get('gemini_key') or os.environ.get('GEMINI_API_KEY')
|
| 724 |
+
|
| 725 |
+
extractor = ViralClipExtractor()
|
| 726 |
+
|
| 727 |
+
# 1. Get video info
|
| 728 |
+
video_info = extractor.extract_video_info(url)
|
| 729 |
+
|
| 730 |
+
# 2. Fetch transcript ONCE
|
| 731 |
+
transcript_segments = extractor.fetch_full_transcript(url)
|
| 732 |
+
|
| 733 |
+
if mode == 'ai':
|
| 734 |
+
if not gemini_key:
|
| 735 |
+
return jsonify({"error": "Gemini API Key required for AI mode"}), 400
|
| 736 |
+
|
| 737 |
+
logger.info("Using Gemini AI for analysis")
|
| 738 |
+
scored_clips = extractor.analyze_with_gemini(transcript_segments, gemini_key)
|
| 739 |
+
|
| 740 |
+
# Normalize AI response
|
| 741 |
+
for clip in scored_clips:
|
| 742 |
+
clip['title'] = clip.get('reason', 'AI Selected Clip')
|
| 743 |
+
clip.setdefault('start', 0)
|
| 744 |
+
clip.setdefault('end', clip['start'] + clip_length)
|
| 745 |
+
clip.setdefault('viral_score', 80)
|
| 746 |
+
else:
|
| 747 |
+
# 3. Generate candidates
|
| 748 |
+
candidates = extractor.generate_candidates(video_info, clip_length, window_step)
|
| 749 |
+
|
| 750 |
+
# 4. Score candidates
|
| 751 |
+
scored_clips = []
|
| 752 |
+
logger.info(f"Scoring {len(candidates)} candidates...")
|
| 753 |
+
for i, cand in enumerate(candidates):
|
| 754 |
+
score_data = extractor.score_clip(url, cand['start'], cand['end'], transcript_segments)
|
| 755 |
+
score_data['title'] = cand.get('title', f'Clip {i+1}')
|
| 756 |
+
scored_clips.append(score_data)
|
| 757 |
+
|
| 758 |
+
# Sort by viral score
|
| 759 |
+
scored_clips.sort(key=lambda x: x['viral_score'], reverse=True)
|
| 760 |
+
|
| 761 |
+
# Select top non-overlapping clips
|
| 762 |
+
final_clips = []
|
| 763 |
+
used_ranges = []
|
| 764 |
+
|
| 765 |
+
for clip in scored_clips:
|
| 766 |
+
overlaps = False
|
| 767 |
+
for used in used_ranges:
|
| 768 |
+
if not (clip['end'] <= used['start'] or clip['start'] >= used['end']):
|
| 769 |
+
overlaps = True
|
| 770 |
+
break
|
| 771 |
+
|
| 772 |
+
if not overlaps:
|
| 773 |
+
final_clips.append(clip)
|
| 774 |
+
used_ranges.append(clip)
|
| 775 |
+
|
| 776 |
+
if len(final_clips) >= num_clips:
|
| 777 |
+
break
|
| 778 |
+
|
| 779 |
+
# Add formatted times and YouTube URLs and Download Links
|
| 780 |
+
import urllib.parse
|
| 781 |
+
encoded_url = urllib.parse.quote(url)
|
| 782 |
+
|
| 783 |
+
for clip in final_clips:
|
| 784 |
+
clip['start'] = float(clip['start'])
|
| 785 |
+
clip['end'] = float(clip['end'])
|
| 786 |
+
clip['start_formatted'] = seconds_to_time(clip['start'])
|
| 787 |
+
clip['end_formatted'] = seconds_to_time(clip['end'])
|
| 788 |
+
clip['youtube_url'] = f"https://youtube.com/watch?v={video_id}&t={int(clip['start'])}"
|
| 789 |
+
clip['download_link'] = f"/download_file?url={encoded_url}&start={clip['start']}&end={clip['end']}&quality={quality}"
|
| 790 |
+
|
| 791 |
+
return jsonify({
|
| 792 |
+
"success": True,
|
| 793 |
+
"video_id": video_id,
|
| 794 |
+
"video_title": video_info.get('title'),
|
| 795 |
+
"video_duration": video_info.get('duration'),
|
| 796 |
+
"clips": final_clips
|
| 797 |
+
})
|
| 798 |
+
|
| 799 |
+
except Exception as e:
|
| 800 |
+
logger.error(f"Error in /analyze: {e}")
|
| 801 |
+
return jsonify({
|
| 802 |
+
"success": False,
|
| 803 |
+
"error": str(e)
|
| 804 |
+
}), 500
|
| 805 |
+
|
| 806 |
+
@app.route('/download_file', methods=['GET'])
|
| 807 |
+
def download_file_endpoint():
|
| 808 |
+
"""Download the actual clip file"""
|
| 809 |
+
try:
|
| 810 |
+
url = request.args.get('url')
|
| 811 |
+
if not url:
|
| 812 |
+
return jsonify({"error": "Missing 'url' parameter"}), 400
|
| 813 |
+
|
| 814 |
+
start = float(request.args.get('start', 0))
|
| 815 |
+
end = float(request.args.get('end', 0))
|
| 816 |
+
quality = request.args.get('quality', '720')
|
| 817 |
+
|
| 818 |
+
if end <= start:
|
| 819 |
+
return jsonify({"error": "Invalid time range"}), 400
|
| 820 |
+
|
| 821 |
+
extractor = ViralClipExtractor()
|
| 822 |
+
file_path = extractor.download_clip(url, start, end, quality)
|
| 823 |
+
|
| 824 |
+
if file_path and os.path.exists(file_path):
|
| 825 |
+
@after_this_request
|
| 826 |
+
def remove_file(response):
|
| 827 |
+
try:
|
| 828 |
+
pass # windows file locking might prevent removal if not closed, but send_file should handle it
|
| 829 |
+
# os.remove(file_path) # Defer removal or use a temp dir cleaner properly
|
| 830 |
+
except Exception as error:
|
| 831 |
+
app.logger.error("Error removing file", error)
|
| 832 |
+
return response
|
| 833 |
+
|
| 834 |
+
return send_file(file_path, as_attachment=True, download_name=os.path.basename(file_path))
|
| 835 |
+
else:
|
| 836 |
+
return jsonify({"error": "Failed to download clip"}), 500
|
| 837 |
+
|
| 838 |
+
except Exception as e:
|
| 839 |
+
logger.error(f"Error in /download: {e}")
|
| 840 |
+
return jsonify({
|
| 841 |
+
"success": False,
|
| 842 |
+
"error": str(e)
|
| 843 |
+
}), 500
|
| 844 |
+
|
| 845 |
+
@app.route('/docs', methods=['GET'])
|
| 846 |
+
def docs():
|
| 847 |
+
"""API Documentation & Credits"""
|
| 848 |
+
html = """
|
| 849 |
+
<!DOCTYPE html>
|
| 850 |
+
<html lang="en">
|
| 851 |
+
<head>
|
| 852 |
+
<meta charset="UTF-8">
|
| 853 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 854 |
+
<title>Viral Clip Extractor API | Documentation</title>
|
| 855 |
+
<style>
|
| 856 |
+
:root { --primary: #3b82f6; --secondary: #8b5cf6; --bg: #0f172a; --text: #f8fafc; --card-bg: #1e293b; }
|
| 857 |
+
body {
|
| 858 |
+
font-family: 'Segoe UI', system-ui, sans-serif;
|
| 859 |
+
line-height: 1.6;
|
| 860 |
+
color: var(--text);
|
| 861 |
+
background: var(--bg);
|
| 862 |
+
max-width: 900px;
|
| 863 |
+
margin: 0 auto;
|
| 864 |
+
padding: 40px 20px;
|
| 865 |
+
opacity: 0;
|
| 866 |
+
animation: fadeIn 0.8s ease-out forwards;
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
@keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
| 870 |
+
@keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.05); } 100% { transform: scale(1); } }
|
| 871 |
+
@keyframes gradient { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } }
|
| 872 |
+
|
| 873 |
+
h1 { font-size: 3rem; margin-bottom: 0.5rem; background: linear-gradient(to right, #60a5fa, #c084fc); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
| 874 |
+
h2 { margin-top: 3rem; border-bottom: 2px solid #334155; padding-bottom: 0.5rem; color: #94a3b8; }
|
| 875 |
+
h3 { color: #60a5fa; margin-top: 0; }
|
| 876 |
+
|
| 877 |
+
.badge { background: linear-gradient(135deg, var(--primary), var(--secondary)); color: white; padding: 4px 12px; border-radius: 20px; font-size: 0.9rem; vertical-align: middle; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.3); }
|
| 878 |
+
|
| 879 |
+
.card {
|
| 880 |
+
background: var(--card-bg);
|
| 881 |
+
padding: 25px;
|
| 882 |
+
border-radius: 16px;
|
| 883 |
+
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3);
|
| 884 |
+
margin-bottom: 25px;
|
| 885 |
+
border: 1px solid #334155;
|
| 886 |
+
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
| 887 |
+
}
|
| 888 |
+
.card:hover { transform: translateY(-5px); box-shadow: 0 20px 25px -5px rgba(0,0,0,0.4); border-color: #60a5fa; }
|
| 889 |
+
|
| 890 |
+
code { font-family: 'Consolas', monospace; background: #0f172a; padding: 4px 8px; border-radius: 6px; color: #f472b6; border: 1px solid #334155; }
|
| 891 |
+
pre { background: #020617; color: #e2e8f0; padding: 20px; border-radius: 12px; overflow-x: auto; font-size: 0.9rem; border: 1px solid #334155; }
|
| 892 |
+
|
| 893 |
+
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
|
| 894 |
+
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #334155; }
|
| 895 |
+
th { color: #94a3b8; font-weight: 600; }
|
| 896 |
+
|
| 897 |
+
.method { font-weight: bold; color: #34d399; }
|
| 898 |
+
.url { color: #cbd5e1; }
|
| 899 |
+
|
| 900 |
+
.warning { background: rgba(249, 115, 22, 0.1); border-left: 4px solid #f97316; padding: 15px; margin: 20px 0; border-radius: 0 8px 8px 0; }
|
| 901 |
+
.tip { background: rgba(34, 197, 94, 0.1); border-left: 4px solid #22c55e; padding: 15px; margin: 20px 0; border-radius: 0 8px 8px 0; }
|
| 902 |
+
|
| 903 |
+
.creator-highlight {
|
| 904 |
+
background: linear-gradient(270deg, #ff00cc, #333399, #60a5fa);
|
| 905 |
+
background-size: 600% 600%;
|
| 906 |
+
-webkit-background-clip: text;
|
| 907 |
+
-webkit-text-fill-color: transparent;
|
| 908 |
+
font-weight: 900;
|
| 909 |
+
font-size: 1.5em;
|
| 910 |
+
animation: gradient 3s ease infinite;
|
| 911 |
+
display: inline-block;
|
| 912 |
+
padding: 0 5px;
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
footer { margin-top: 80px; text-align: center; color: #64748b; font-size: 1.1rem; border-top: 1px solid #334155; padding-top: 40px; }
|
| 916 |
+
</style>
|
| 917 |
+
</head>
|
| 918 |
+
<body>
|
| 919 |
+
<h1>Viral Clip Extractor API <span class="badge">v1.3</span></h1>
|
| 920 |
+
<p class="lead">Transform long-form YouTube videos into engaging viral clips using sophisticated AI analysis.</p>
|
| 921 |
+
|
| 922 |
+
<div class="warning">
|
| 923 |
+
<strong>🔐 Security Best Practice:</strong> Do not pass API keys in URLs for public applications.
|
| 924 |
+
Configure <code>GEMINI_API_KEY</code> and <code>NVIDIA_API_KEY</code> as Environment Variables on your server.
|
| 925 |
+
</div>
|
| 926 |
+
|
| 927 |
+
<h2>🧠 Analysis Modes & Usage</h2>
|
| 928 |
+
|
| 929 |
+
<div class="card">
|
| 930 |
+
<h3>1. Heuristic Mode (Non-AI)</h3>
|
| 931 |
+
<p><strong>Best for:</strong> High-energy content, gaming, reactions. Fast and free.</p>
|
| 932 |
+
<div class="tip">No API Key required.</div>
|
| 933 |
+
<pre>GET /clips?url={youtube_url}&mode=heuristic&num=5</pre>
|
| 934 |
+
</div>
|
| 935 |
+
|
| 936 |
+
<div class="card">
|
| 937 |
+
<h3>2. Gemini AI Mode</h3>
|
| 938 |
+
<p><strong>Best for:</strong> Podcasts, storytelling, general dialogue. Balanced performance.</p>
|
| 939 |
+
<div class="warning">Requires <code>GEMINI_API_KEY</code> environment variable (or param).</div>
|
| 940 |
+
<pre>GET /clips?url={youtube_url}&mode=ai&num=5</pre>
|
| 941 |
+
</div>
|
| 942 |
+
|
| 943 |
+
<div class="card">
|
| 944 |
+
<h3>3. Nvidia/DeepSeek AI Mode</h3>
|
| 945 |
+
<p><strong>Best for:</strong> Technical content, debates, complex reasoning.</p>
|
| 946 |
+
<div class="warning">Requires <code>NVIDIA_API_KEY</code> environment variable (or param).</div>
|
| 947 |
+
<pre>GET /clips?url={youtube_url}&mode=nvidia&num=5</pre>
|
| 948 |
+
</div>
|
| 949 |
+
|
| 950 |
+
<h2>📚 Endpoints</h2>
|
| 951 |
+
|
| 952 |
+
<div class="card">
|
| 953 |
+
<h3>GET /clips</h3>
|
| 954 |
+
<p>Main endpoint to analyze and get clip suggestions.</p>
|
| 955 |
+
|
| 956 |
+
<h4>Parameters</h4>
|
| 957 |
+
<table>
|
| 958 |
+
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
|
| 959 |
+
<tr><td><code>url</code></td><td>string</td><td>Yes</td><td>YouTube Video URL</td></tr>
|
| 960 |
+
<tr><td><code>mode</code></td><td>string</td><td>No</td><td><code>heuristic</code> (default), <code>ai</code>, <code>nvidia</code></td></tr>
|
| 961 |
+
<tr><td><code>num</code></td><td>int</td><td>No</td><td>Number of clips (default: 5)</td></tr>
|
| 962 |
+
<tr><td><code>quality</code></td><td>string</td><td>No</td><td>Video height (e.g., <code>720</code>, <code>1080</code>)</td></tr>
|
| 963 |
+
</table>
|
| 964 |
+
|
| 965 |
+
<h4>Example Response</h4>
|
| 966 |
+
<pre>{
|
| 967 |
+
"success": true,
|
| 968 |
+
"video_title": "Podcast Episode 1",
|
| 969 |
+
"mode": "ai",
|
| 970 |
+
"clips": [
|
| 971 |
+
{
|
| 972 |
+
"title": "Shocking Reveal",
|
| 973 |
+
"start": 120.5,
|
| 974 |
+
"end": 150.0,
|
| 975 |
+
"viral_score": 95,
|
| 976 |
+
"download_link": "/download_file?url=...&start=120.5&end=150"
|
| 977 |
+
}
|
| 978 |
+
]
|
| 979 |
+
}</pre>
|
| 980 |
+
</div>
|
| 981 |
+
|
| 982 |
+
<div class="card">
|
| 983 |
+
<h3>GET /download_file</h3>
|
| 984 |
+
<p>Download a specific clip directly.</p>
|
| 985 |
+
<pre>GET /download_file?url={url}&start={start}&end={end}</pre>
|
| 986 |
+
</div>
|
| 987 |
+
|
| 988 |
+
<footer>
|
| 989 |
+
<p>Developed with ❤️ by <span class="creator-highlight">yash@dev</span></p>
|
| 990 |
+
<p>© 2026 Viral Clip Extractor API</p>
|
| 991 |
+
</footer>
|
| 992 |
+
</body>
|
| 993 |
+
</html>
|
| 994 |
+
"""
|
| 995 |
+
return html
|
| 996 |
+
|
| 997 |
+
# Local development and Production
|
| 998 |
+
if __name__ == '__main__':
|
| 999 |
+
port = int(os.environ.get('PORT', 5000))
|
| 1000 |
+
app.run(debug=True, host='0.0.0.0', port=port)
|
| 1001 |
+
|
debug_ffmpeg.log
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 2 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 3 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 4 |
+
Selected: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 5 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 6 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 7 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 8 |
+
Selected: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 9 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 10 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 11 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 12 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 13 |
+
Using Dir: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 14 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 15 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 16 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 17 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 18 |
+
Using Dir: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 19 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 20 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 21 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 22 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 23 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 24 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 25 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 26 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 27 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 28 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 29 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 30 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 31 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 32 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 33 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 34 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 35 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 36 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 37 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 38 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 39 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 40 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 41 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 42 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 43 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 44 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 45 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 46 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 47 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 48 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 49 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 50 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 51 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 52 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 53 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 54 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 55 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 56 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 57 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 58 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 59 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 60 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 61 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 62 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 63 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 64 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 65 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 66 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 67 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 68 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 69 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 70 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 71 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 72 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 73 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 74 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 75 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 76 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 77 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 78 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 79 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 80 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 81 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 82 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 83 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 84 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 85 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 86 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 87 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 88 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 89 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 90 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 91 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 92 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 93 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 94 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 95 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 96 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 97 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 98 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 99 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 100 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 101 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 102 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 103 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 104 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 105 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 106 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 107 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 108 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
| 109 |
+
Project Root: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor
|
| 110 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 111 |
+
Checking: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe -> True
|
| 112 |
+
Selected File: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin\ffmpeg.exe
|
| 113 |
+
Adding to PATH: C:\Users\YASHWANTH\Downloads\Kimi_Agent_Viral Clip Extractor\ffmpeg\bin
|
diagnose_api.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import requests
|
| 3 |
+
import time
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
BASE_URL = "http://127.0.0.1:5000"
|
| 7 |
+
|
| 8 |
+
def test_health():
|
| 9 |
+
print("Checking /health...")
|
| 10 |
+
try:
|
| 11 |
+
t0 = time.time()
|
| 12 |
+
resp = requests.get(f"{BASE_URL}/health", timeout=5)
|
| 13 |
+
print(f"Health Status: {resp.status_code}")
|
| 14 |
+
print(f"Response: {resp.text}")
|
| 15 |
+
print(f"Time: {time.time() - t0:.2f}s")
|
| 16 |
+
return True
|
| 17 |
+
except Exception as e:
|
| 18 |
+
print(f"Health Check Failed: {e}")
|
| 19 |
+
return False
|
| 20 |
+
|
| 21 |
+
def test_clips():
|
| 22 |
+
print("\nChecking /clips (simplified)...")
|
| 23 |
+
url = "https://youtu.be/uVkFrqugXFQ" # The user's URL
|
| 24 |
+
try:
|
| 25 |
+
t0 = time.time()
|
| 26 |
+
print(f"Requesting clips for {url}...")
|
| 27 |
+
resp = requests.get(f"{BASE_URL}/clips", params={
|
| 28 |
+
"url": url,
|
| 29 |
+
"seconds": 30,
|
| 30 |
+
"num": 2
|
| 31 |
+
}, timeout=60)
|
| 32 |
+
print(f"Clips Status: {resp.status_code}")
|
| 33 |
+
print(f"Time: {time.time() - t0:.2f}s")
|
| 34 |
+
if resp.status_code == 200:
|
| 35 |
+
data = resp.json()
|
| 36 |
+
print(f"Success! Got {len(data.get('clips', []))} clips")
|
| 37 |
+
print(f"Video: {data.get('video_title')}")
|
| 38 |
+
else:
|
| 39 |
+
print(f"Error: {resp.text}")
|
| 40 |
+
except requests.exceptions.Timeout:
|
| 41 |
+
print("❌ Request timed out after 60s")
|
| 42 |
+
except Exception as e:
|
| 43 |
+
print(f"Error: {e}")
|
| 44 |
+
|
| 45 |
+
if __name__ == "__main__":
|
| 46 |
+
if test_health():
|
| 47 |
+
test_clips()
|
error.log
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Download Error: ERROR: You have requested downloading the video partially, but ffmpeg is not installed. Aborting
|
| 2 |
+
Download Error: ERROR: You have requested downloading the video partially, but ffmpeg is not installed. Aborting
|
| 3 |
+
Download Error: ERROR: You have requested downloading the video partially, but ffmpeg is not installed. Aborting
|
examples/frontend-example.html
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Viral Clip Extractor - Frontend Demo</title>
|
| 7 |
+
<style>
|
| 8 |
+
* {
|
| 9 |
+
margin: 0;
|
| 10 |
+
padding: 0;
|
| 11 |
+
box-sizing: border-box;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
body {
|
| 15 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 16 |
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
| 17 |
+
min-height: 100vh;
|
| 18 |
+
color: #fff;
|
| 19 |
+
padding: 20px;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.container {
|
| 23 |
+
max-width: 900px;
|
| 24 |
+
margin: 0 auto;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
h1 {
|
| 28 |
+
text-align: center;
|
| 29 |
+
margin-bottom: 10px;
|
| 30 |
+
font-size: 2.5rem;
|
| 31 |
+
background: linear-gradient(90deg, #ff6b6b, #feca57);
|
| 32 |
+
-webkit-background-clip: text;
|
| 33 |
+
-webkit-text-fill-color: transparent;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.subtitle {
|
| 37 |
+
text-align: center;
|
| 38 |
+
color: #888;
|
| 39 |
+
margin-bottom: 30px;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.input-section {
|
| 43 |
+
background: rgba(255,255,255,0.05);
|
| 44 |
+
border-radius: 16px;
|
| 45 |
+
padding: 25px;
|
| 46 |
+
margin-bottom: 20px;
|
| 47 |
+
border: 1px solid rgba(255,255,255,0.1);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.form-group {
|
| 51 |
+
margin-bottom: 15px;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
label {
|
| 55 |
+
display: block;
|
| 56 |
+
margin-bottom: 8px;
|
| 57 |
+
color: #aaa;
|
| 58 |
+
font-size: 0.9rem;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
input, select {
|
| 62 |
+
width: 100%;
|
| 63 |
+
padding: 12px 16px;
|
| 64 |
+
border: 1px solid rgba(255,255,255,0.2);
|
| 65 |
+
border-radius: 8px;
|
| 66 |
+
background: rgba(0,0,0,0.3);
|
| 67 |
+
color: #fff;
|
| 68 |
+
font-size: 1rem;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
input:focus, select:focus {
|
| 72 |
+
outline: none;
|
| 73 |
+
border-color: #ff6b6b;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.row {
|
| 77 |
+
display: grid;
|
| 78 |
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
| 79 |
+
gap: 15px;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
button {
|
| 83 |
+
width: 100%;
|
| 84 |
+
padding: 14px 24px;
|
| 85 |
+
background: linear-gradient(90deg, #ff6b6b, #feca57);
|
| 86 |
+
border: none;
|
| 87 |
+
border-radius: 8px;
|
| 88 |
+
color: #1a1a2e;
|
| 89 |
+
font-size: 1rem;
|
| 90 |
+
font-weight: 600;
|
| 91 |
+
cursor: pointer;
|
| 92 |
+
transition: transform 0.2s, box-shadow 0.2s;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
button:hover {
|
| 96 |
+
transform: translateY(-2px);
|
| 97 |
+
box-shadow: 0 8px 25px rgba(255,107,107,0.3);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
button:disabled {
|
| 101 |
+
opacity: 0.6;
|
| 102 |
+
cursor: not-allowed;
|
| 103 |
+
transform: none;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.loading {
|
| 107 |
+
text-align: center;
|
| 108 |
+
padding: 40px;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.spinner {
|
| 112 |
+
width: 50px;
|
| 113 |
+
height: 50px;
|
| 114 |
+
border: 3px solid rgba(255,255,255,0.1);
|
| 115 |
+
border-top-color: #ff6b6b;
|
| 116 |
+
border-radius: 50%;
|
| 117 |
+
animation: spin 1s linear infinite;
|
| 118 |
+
margin: 0 auto 20px;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
@keyframes spin {
|
| 122 |
+
to { transform: rotate(360deg); }
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.results {
|
| 126 |
+
display: none;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.video-info {
|
| 130 |
+
background: rgba(255,255,255,0.05);
|
| 131 |
+
border-radius: 12px;
|
| 132 |
+
padding: 20px;
|
| 133 |
+
margin-bottom: 20px;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.video-info h3 {
|
| 137 |
+
margin-bottom: 10px;
|
| 138 |
+
color: #feca57;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.clip-card {
|
| 142 |
+
background: rgba(255,255,255,0.05);
|
| 143 |
+
border-radius: 12px;
|
| 144 |
+
padding: 20px;
|
| 145 |
+
margin-bottom: 15px;
|
| 146 |
+
border-left: 4px solid #ff6b6b;
|
| 147 |
+
transition: transform 0.2s;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.clip-card:hover {
|
| 151 |
+
transform: translateX(5px);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.clip-header {
|
| 155 |
+
display: flex;
|
| 156 |
+
justify-content: space-between;
|
| 157 |
+
align-items: center;
|
| 158 |
+
margin-bottom: 10px;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.clip-rank {
|
| 162 |
+
font-size: 1.5rem;
|
| 163 |
+
font-weight: bold;
|
| 164 |
+
color: #ff6b6b;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.clip-score {
|
| 168 |
+
background: linear-gradient(90deg, #ff6b6b, #feca57);
|
| 169 |
+
padding: 5px 15px;
|
| 170 |
+
border-radius: 20px;
|
| 171 |
+
font-weight: bold;
|
| 172 |
+
color: #1a1a2e;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.clip-time {
|
| 176 |
+
color: #888;
|
| 177 |
+
font-size: 0.9rem;
|
| 178 |
+
margin-bottom: 10px;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.clip-reasons {
|
| 182 |
+
display: flex;
|
| 183 |
+
flex-wrap: wrap;
|
| 184 |
+
gap: 8px;
|
| 185 |
+
margin-bottom: 10px;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.reason-tag {
|
| 189 |
+
background: rgba(255,255,255,0.1);
|
| 190 |
+
padding: 4px 10px;
|
| 191 |
+
border-radius: 12px;
|
| 192 |
+
font-size: 0.8rem;
|
| 193 |
+
color: #aaa;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.transcript-preview {
|
| 197 |
+
color: #888;
|
| 198 |
+
font-style: italic;
|
| 199 |
+
font-size: 0.9rem;
|
| 200 |
+
margin-top: 10px;
|
| 201 |
+
padding-top: 10px;
|
| 202 |
+
border-top: 1px solid rgba(255,255,255,0.1);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.clip-actions {
|
| 206 |
+
margin-top: 15px;
|
| 207 |
+
display: flex;
|
| 208 |
+
gap: 10px;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.btn-small {
|
| 212 |
+
padding: 8px 16px;
|
| 213 |
+
font-size: 0.85rem;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.btn-secondary {
|
| 217 |
+
background: rgba(255,255,255,0.1);
|
| 218 |
+
color: #fff;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
.error {
|
| 222 |
+
background: rgba(255,107,107,0.1);
|
| 223 |
+
border: 1px solid #ff6b6b;
|
| 224 |
+
border-radius: 8px;
|
| 225 |
+
padding: 15px;
|
| 226 |
+
color: #ff6b6b;
|
| 227 |
+
text-align: center;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.api-config {
|
| 231 |
+
margin-bottom: 20px;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.endpoint-selector {
|
| 235 |
+
display: flex;
|
| 236 |
+
gap: 10px;
|
| 237 |
+
margin-bottom: 15px;
|
| 238 |
+
flex-wrap: wrap;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.endpoint-btn {
|
| 242 |
+
padding: 8px 16px;
|
| 243 |
+
background: rgba(255,255,255,0.1);
|
| 244 |
+
border: 1px solid rgba(255,255,255,0.2);
|
| 245 |
+
border-radius: 6px;
|
| 246 |
+
color: #fff;
|
| 247 |
+
cursor: pointer;
|
| 248 |
+
transition: all 0.2s;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.endpoint-btn.active {
|
| 252 |
+
background: #ff6b6b;
|
| 253 |
+
border-color: #ff6b6b;
|
| 254 |
+
}
|
| 255 |
+
</style>
|
| 256 |
+
</head>
|
| 257 |
+
<body>
|
| 258 |
+
<div class="container">
|
| 259 |
+
<h1>🔥 Viral Clip Extractor</h1>
|
| 260 |
+
<p class="subtitle">Extract the most viral moments from any YouTube video</p>
|
| 261 |
+
|
| 262 |
+
<div class="input-section">
|
| 263 |
+
<div class="api-config">
|
| 264 |
+
<label>API Base URL</label>
|
| 265 |
+
<input type="text" id="apiUrl" placeholder="https://your-api.vercel.app" value="">
|
| 266 |
+
</div>
|
| 267 |
+
|
| 268 |
+
<div class="endpoint-selector">
|
| 269 |
+
<button class="endpoint-btn active" data-endpoint="analyze">Analyze</button>
|
| 270 |
+
<button class="endpoint-btn" data-endpoint="extract">Extract Info</button>
|
| 271 |
+
<button class="endpoint-btn" data-endpoint="transcript">Transcript</button>
|
| 272 |
+
</div>
|
| 273 |
+
|
| 274 |
+
<div class="form-group">
|
| 275 |
+
<label>YouTube URL</label>
|
| 276 |
+
<input type="text" id="videoUrl" placeholder="https://youtube.com/watch?v=...">
|
| 277 |
+
</div>
|
| 278 |
+
|
| 279 |
+
<div id="analyzeOptions" class="options-section">
|
| 280 |
+
<div class="row">
|
| 281 |
+
<div class="form-group">
|
| 282 |
+
<label>Number of Clips</label>
|
| 283 |
+
<input type="number" id="numClips" value="5" min="1" max="20">
|
| 284 |
+
</div>
|
| 285 |
+
<div class="form-group">
|
| 286 |
+
<label>Clip Length (sec)</label>
|
| 287 |
+
<input type="number" id="clipLength" value="40" min="10" max="120">
|
| 288 |
+
</div>
|
| 289 |
+
<div class="form-group">
|
| 290 |
+
<label>Quality</label>
|
| 291 |
+
<select id="quality">
|
| 292 |
+
<option value="360">360p</option>
|
| 293 |
+
<option value="720" selected>720p</option>
|
| 294 |
+
<option value="1080">1080p</option>
|
| 295 |
+
</select>
|
| 296 |
+
</div>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
|
| 300 |
+
<div id="transcriptOptions" class="options-section" style="display:none;">
|
| 301 |
+
<div class="row">
|
| 302 |
+
<div class="form-group">
|
| 303 |
+
<label>Start Time (sec)</label>
|
| 304 |
+
<input type="number" id="startTime" value="0" min="0">
|
| 305 |
+
</div>
|
| 306 |
+
<div class="form-group">
|
| 307 |
+
<label>End Time (sec)</label>
|
| 308 |
+
<input type="number" id="endTime" value="60" min="10">
|
| 309 |
+
</div>
|
| 310 |
+
</div>
|
| 311 |
+
</div>
|
| 312 |
+
|
| 313 |
+
<button id="analyzeBtn" onclick="analyzeVideo()">
|
| 314 |
+
🔍 Analyze Video
|
| 315 |
+
</button>
|
| 316 |
+
</div>
|
| 317 |
+
|
| 318 |
+
<div id="loading" class="loading" style="display:none;">
|
| 319 |
+
<div class="spinner"></div>
|
| 320 |
+
<p>Analyzing video for viral clips...</p>
|
| 321 |
+
</div>
|
| 322 |
+
|
| 323 |
+
<div id="error" class="error" style="display:none;"></div>
|
| 324 |
+
|
| 325 |
+
<div id="results" class="results">
|
| 326 |
+
<div id="videoInfo" class="video-info"></div>
|
| 327 |
+
<div id="clipsList"></div>
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
+
|
| 331 |
+
<script>
|
| 332 |
+
let currentEndpoint = 'analyze';
|
| 333 |
+
|
| 334 |
+
// Endpoint selector
|
| 335 |
+
document.querySelectorAll('.endpoint-btn').forEach(btn => {
|
| 336 |
+
btn.addEventListener('click', () => {
|
| 337 |
+
document.querySelectorAll('.endpoint-btn').forEach(b => b.classList.remove('active'));
|
| 338 |
+
btn.classList.add('active');
|
| 339 |
+
currentEndpoint = btn.dataset.endpoint;
|
| 340 |
+
|
| 341 |
+
// Show/hide options
|
| 342 |
+
document.getElementById('analyzeOptions').style.display =
|
| 343 |
+
currentEndpoint === 'analyze' ? 'block' : 'none';
|
| 344 |
+
document.getElementById('transcriptOptions').style.display =
|
| 345 |
+
currentEndpoint === 'transcript' ? 'block' : 'none';
|
| 346 |
+
|
| 347 |
+
// Update button text
|
| 348 |
+
const btnText = {
|
| 349 |
+
'analyze': '🔍 Analyze Video',
|
| 350 |
+
'extract': '📹 Extract Info',
|
| 351 |
+
'transcript': '📝 Get Transcript'
|
| 352 |
+
};
|
| 353 |
+
document.getElementById('analyzeBtn').textContent = btnText[currentEndpoint];
|
| 354 |
+
});
|
| 355 |
+
});
|
| 356 |
+
|
| 357 |
+
async function analyzeVideo() {
|
| 358 |
+
const apiUrl = document.getElementById('apiUrl').value.trim();
|
| 359 |
+
const videoUrl = document.getElementById('videoUrl').value.trim();
|
| 360 |
+
|
| 361 |
+
if (!apiUrl) {
|
| 362 |
+
showError('Please enter your API base URL');
|
| 363 |
+
return;
|
| 364 |
+
}
|
| 365 |
+
if (!videoUrl) {
|
| 366 |
+
showError('Please enter a YouTube URL');
|
| 367 |
+
return;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
// Show loading
|
| 371 |
+
document.getElementById('loading').style.display = 'block';
|
| 372 |
+
document.getElementById('results').style.display = 'none';
|
| 373 |
+
document.getElementById('error').style.display = 'none';
|
| 374 |
+
|
| 375 |
+
const endpoint = `${apiUrl.replace(/\/$/, '')}/${currentEndpoint}`;
|
| 376 |
+
|
| 377 |
+
let body = { url: videoUrl };
|
| 378 |
+
|
| 379 |
+
if (currentEndpoint === 'analyze') {
|
| 380 |
+
body.num_clips = parseInt(document.getElementById('numClips').value);
|
| 381 |
+
body.clip_length = parseInt(document.getElementById('clipLength').value);
|
| 382 |
+
body.quality = document.getElementById('quality').value;
|
| 383 |
+
} else if (currentEndpoint === 'transcript') {
|
| 384 |
+
body.start = parseInt(document.getElementById('startTime').value);
|
| 385 |
+
body.end = parseInt(document.getElementById('endTime').value);
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
try {
|
| 389 |
+
const response = await fetch(endpoint, {
|
| 390 |
+
method: 'POST',
|
| 391 |
+
headers: { 'Content-Type': 'application/json' },
|
| 392 |
+
body: JSON.stringify(body)
|
| 393 |
+
});
|
| 394 |
+
|
| 395 |
+
const data = await response.json();
|
| 396 |
+
|
| 397 |
+
if (!data.success) {
|
| 398 |
+
throw new Error(data.error || 'Unknown error');
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
if (currentEndpoint === 'analyze') {
|
| 402 |
+
displayResults(data);
|
| 403 |
+
} else if (currentEndpoint === 'extract') {
|
| 404 |
+
displayVideoInfo(data.data);
|
| 405 |
+
} else if (currentEndpoint === 'transcript') {
|
| 406 |
+
displayTranscript(data);
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
} catch (err) {
|
| 410 |
+
showError(err.message);
|
| 411 |
+
} finally {
|
| 412 |
+
document.getElementById('loading').style.display = 'none';
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
function displayResults(data) {
|
| 417 |
+
document.getElementById('results').style.display = 'block';
|
| 418 |
+
|
| 419 |
+
// Video info
|
| 420 |
+
const videoInfo = document.getElementById('videoInfo');
|
| 421 |
+
videoInfo.innerHTML = `
|
| 422 |
+
<h3>📹 ${data.video_title}</h3>
|
| 423 |
+
<p>Duration: ${formatTime(data.video_duration)} | Clips found: ${data.clips.length}</p>
|
| 424 |
+
`;
|
| 425 |
+
|
| 426 |
+
// Clips
|
| 427 |
+
const clipsList = document.getElementById('clipsList');
|
| 428 |
+
clipsList.innerHTML = data.clips.map((clip, i) => `
|
| 429 |
+
<div class="clip-card">
|
| 430 |
+
<div class="clip-header">
|
| 431 |
+
<span class="clip-rank">#${i + 1}</span>
|
| 432 |
+
<span class="clip-score">${clip.viral_score}% Viral</span>
|
| 433 |
+
</div>
|
| 434 |
+
<div class="clip-time">
|
| 435 |
+
⏱️ ${clip.start_formatted} - ${clip.end_formatted} (${clip.duration}s)
|
| 436 |
+
</div>
|
| 437 |
+
<div class="clip-reasons">
|
| 438 |
+
${clip.reasons.map(r => `<span class="reason-tag">${r}</span>`).join('')}
|
| 439 |
+
</div>
|
| 440 |
+
${clip.transcript_preview ? `
|
| 441 |
+
<div class="transcript-preview">
|
| 442 |
+
"${clip.transcript_preview}"
|
| 443 |
+
</div>
|
| 444 |
+
` : ''}
|
| 445 |
+
<div class="clip-actions">
|
| 446 |
+
<a href="${clip.youtube_url}" target="_blank" class="btn-small">Open on YouTube</a>
|
| 447 |
+
</div>
|
| 448 |
+
</div>
|
| 449 |
+
`).join('');
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
function displayVideoInfo(data) {
|
| 453 |
+
document.getElementById('results').style.display = 'block';
|
| 454 |
+
document.getElementById('videoInfo').innerHTML = `
|
| 455 |
+
<h3>📹 ${data.title}</h3>
|
| 456 |
+
<p><strong>Channel:</strong> ${data.uploader}</p>
|
| 457 |
+
<p><strong>Duration:</strong> ${formatTime(data.duration)}</p>
|
| 458 |
+
<p><strong>Views:</strong> ${data.view_count?.toLocaleString() || 'N/A'}</p>
|
| 459 |
+
<p><strong>Upload Date:</strong> ${data.upload_date || 'N/A'}</p>
|
| 460 |
+
<p><strong>Chapters:</strong> ${data.chapters?.length || 0}</p>
|
| 461 |
+
`;
|
| 462 |
+
document.getElementById('clipsList').innerHTML = '';
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
function displayTranscript(data) {
|
| 466 |
+
document.getElementById('results').style.display = 'block';
|
| 467 |
+
document.getElementById('videoInfo').innerHTML = `
|
| 468 |
+
<h3>📝 Transcript</h3>
|
| 469 |
+
<p>Time: ${data.start_formatted} - ${data.end_formatted}</p>
|
| 470 |
+
<p>Words: ${data.word_count}</p>
|
| 471 |
+
`;
|
| 472 |
+
document.getElementById('clipsList').innerHTML = `
|
| 473 |
+
<div class="clip-card">
|
| 474 |
+
<p style="line-height: 1.6;">${data.transcript || 'No transcript available'}</p>
|
| 475 |
+
</div>
|
| 476 |
+
`;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
function showError(message) {
|
| 480 |
+
document.getElementById('error').textContent = message;
|
| 481 |
+
document.getElementById('error').style.display = 'block';
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
function formatTime(seconds) {
|
| 485 |
+
if (!seconds) return 'N/A';
|
| 486 |
+
const mins = Math.floor(seconds / 60);
|
| 487 |
+
const secs = seconds % 60;
|
| 488 |
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
| 489 |
+
}
|
| 490 |
+
</script>
|
| 491 |
+
</body>
|
| 492 |
+
</html>
|
examples/postman-collection.json
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"info": {
|
| 3 |
+
"name": "Viral Clip Extractor API",
|
| 4 |
+
"description": "API collection for extracting viral clips from YouTube videos",
|
| 5 |
+
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
| 6 |
+
},
|
| 7 |
+
"item": [
|
| 8 |
+
{
|
| 9 |
+
"name": "Health Check",
|
| 10 |
+
"request": {
|
| 11 |
+
"method": "GET",
|
| 12 |
+
"header": [],
|
| 13 |
+
"url": {
|
| 14 |
+
"raw": "{{base_url}}/health",
|
| 15 |
+
"host": ["{{base_url}}"],
|
| 16 |
+
"path": ["health"]
|
| 17 |
+
},
|
| 18 |
+
"description": "Check if API is running"
|
| 19 |
+
}
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"name": "API Info",
|
| 23 |
+
"request": {
|
| 24 |
+
"method": "GET",
|
| 25 |
+
"header": [],
|
| 26 |
+
"url": {
|
| 27 |
+
"raw": "{{base_url}}",
|
| 28 |
+
"host": ["{{base_url}}"]
|
| 29 |
+
},
|
| 30 |
+
"description": "Get API information"
|
| 31 |
+
}
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"name": "Extract Video Info",
|
| 35 |
+
"request": {
|
| 36 |
+
"method": "POST",
|
| 37 |
+
"header": [
|
| 38 |
+
{
|
| 39 |
+
"key": "Content-Type",
|
| 40 |
+
"value": "application/json"
|
| 41 |
+
}
|
| 42 |
+
],
|
| 43 |
+
"body": {
|
| 44 |
+
"mode": "raw",
|
| 45 |
+
"raw": "{\n \"url\": \"https://youtube.com/watch?v=VIDEO_ID\"\n}"
|
| 46 |
+
},
|
| 47 |
+
"url": {
|
| 48 |
+
"raw": "{{base_url}}/extract",
|
| 49 |
+
"host": ["{{base_url}}"],
|
| 50 |
+
"path": ["extract"]
|
| 51 |
+
},
|
| 52 |
+
"description": "Extract basic video information"
|
| 53 |
+
}
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"name": "Analyze Video (Full)",
|
| 57 |
+
"request": {
|
| 58 |
+
"method": "POST",
|
| 59 |
+
"header": [
|
| 60 |
+
{
|
| 61 |
+
"key": "Content-Type",
|
| 62 |
+
"value": "application/json"
|
| 63 |
+
}
|
| 64 |
+
],
|
| 65 |
+
"body": {
|
| 66 |
+
"mode": "raw",
|
| 67 |
+
"raw": "{\n \"url\": \"https://youtube.com/watch?v=VIDEO_ID\",\n \"num_clips\": 5,\n \"clip_length\": 40,\n \"window_step\": 10,\n \"quality\": \"720\"\n}"
|
| 68 |
+
},
|
| 69 |
+
"url": {
|
| 70 |
+
"raw": "{{base_url}}/analyze",
|
| 71 |
+
"host": ["{{base_url}}"],
|
| 72 |
+
"path": ["analyze"]
|
| 73 |
+
},
|
| 74 |
+
"description": "Full viral clip analysis"
|
| 75 |
+
}
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"name": "Score Time Range",
|
| 79 |
+
"request": {
|
| 80 |
+
"method": "POST",
|
| 81 |
+
"header": [
|
| 82 |
+
{
|
| 83 |
+
"key": "Content-Type",
|
| 84 |
+
"value": "application/json"
|
| 85 |
+
}
|
| 86 |
+
],
|
| 87 |
+
"body": {
|
| 88 |
+
"mode": "raw",
|
| 89 |
+
"raw": "{\n \"url\": \"https://youtube.com/watch?v=VIDEO_ID\",\n \"start\": \"2:00\",\n \"end\": \"2:40\"\n}"
|
| 90 |
+
},
|
| 91 |
+
"url": {
|
| 92 |
+
"raw": "{{base_url}}/score",
|
| 93 |
+
"host": ["{{base_url}}"],
|
| 94 |
+
"path": ["score"]
|
| 95 |
+
},
|
| 96 |
+
"description": "Score a specific time range"
|
| 97 |
+
}
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
"name": "Get Transcript",
|
| 101 |
+
"request": {
|
| 102 |
+
"method": "POST",
|
| 103 |
+
"header": [
|
| 104 |
+
{
|
| 105 |
+
"key": "Content-Type",
|
| 106 |
+
"value": "application/json"
|
| 107 |
+
}
|
| 108 |
+
],
|
| 109 |
+
"body": {
|
| 110 |
+
"mode": "raw",
|
| 111 |
+
"raw": "{\n \"url\": \"https://youtube.com/watch?v=VIDEO_ID\",\n \"start\": 120,\n \"end\": 180\n}"
|
| 112 |
+
},
|
| 113 |
+
"url": {
|
| 114 |
+
"raw": "{{base_url}}/transcript",
|
| 115 |
+
"host": ["{{base_url}}"],
|
| 116 |
+
"path": ["transcript"]
|
| 117 |
+
},
|
| 118 |
+
"description": "Get transcript for a time range"
|
| 119 |
+
}
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"name": "Get Download URL",
|
| 123 |
+
"request": {
|
| 124 |
+
"method": "POST",
|
| 125 |
+
"header": [
|
| 126 |
+
{
|
| 127 |
+
"key": "Content-Type",
|
| 128 |
+
"value": "application/json"
|
| 129 |
+
}
|
| 130 |
+
],
|
| 131 |
+
"body": {
|
| 132 |
+
"mode": "raw",
|
| 133 |
+
"raw": "{\n \"url\": \"https://youtube.com/watch?v=VIDEO_ID\",\n \"start\": \"2:00\",\n \"end\": \"2:40\",\n \"quality\": \"720\"\n}"
|
| 134 |
+
},
|
| 135 |
+
"url": {
|
| 136 |
+
"raw": "{{base_url}}/download",
|
| 137 |
+
"host": ["{{base_url}}"],
|
| 138 |
+
"path": ["download"]
|
| 139 |
+
},
|
| 140 |
+
"description": "Get download URL for a clip"
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
],
|
| 144 |
+
"variable": [
|
| 145 |
+
{
|
| 146 |
+
"key": "base_url",
|
| 147 |
+
"value": "https://your-api.vercel.app",
|
| 148 |
+
"type": "string",
|
| 149 |
+
"description": "Your API base URL"
|
| 150 |
+
}
|
| 151 |
+
]
|
| 152 |
+
}
|
examples/python-client.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Viral Clip Extractor - Python Client Example
|
| 3 |
+
Usage: python python-client.py
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
import json
|
| 8 |
+
|
| 9 |
+
# Configuration
|
| 10 |
+
API_BASE_URL = "https://your-api.vercel.app" # Replace with your API URL
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def analyze_video(url: str, num_clips: int = 5, clip_length: int = 40, quality: str = "720"):
|
| 14 |
+
"""
|
| 15 |
+
Analyze a YouTube video for viral clips
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
url: YouTube video URL
|
| 19 |
+
num_clips: Number of clips to return
|
| 20 |
+
clip_length: Target clip length in seconds
|
| 21 |
+
quality: Video quality (360, 720, 1080)
|
| 22 |
+
"""
|
| 23 |
+
endpoint = f"{API_BASE_URL}/analyze"
|
| 24 |
+
|
| 25 |
+
payload = {
|
| 26 |
+
"url": url,
|
| 27 |
+
"num_clips": num_clips,
|
| 28 |
+
"clip_length": clip_length,
|
| 29 |
+
"quality": quality
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
print(f"🔍 Analyzing: {url}")
|
| 33 |
+
print(f" Clips: {num_clips} | Length: {clip_length}s | Quality: {quality}p")
|
| 34 |
+
print("-" * 60)
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
response = requests.post(endpoint, json=payload, timeout=60)
|
| 38 |
+
response.raise_for_status()
|
| 39 |
+
data = response.json()
|
| 40 |
+
|
| 41 |
+
if not data.get("success"):
|
| 42 |
+
print(f"❌ Error: {data.get('error')}")
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
print(f"✅ Found {len(data['clips'])} viral clips!")
|
| 46 |
+
print(f"📹 Video: {data['video_title']}")
|
| 47 |
+
print()
|
| 48 |
+
|
| 49 |
+
for i, clip in enumerate(data['clips'], 1):
|
| 50 |
+
print(f"CLIP #{i}")
|
| 51 |
+
print(f" Score: {clip['viral_score']}% viral")
|
| 52 |
+
print(f" Time: {clip['start_formatted']} - {clip['end_formatted']}")
|
| 53 |
+
print(f" Reasons: {', '.join(clip['reasons'])}")
|
| 54 |
+
print(f" Preview: {clip['transcript_preview'][:80]}...")
|
| 55 |
+
print(f" URL: {clip['youtube_url']}")
|
| 56 |
+
print()
|
| 57 |
+
|
| 58 |
+
return data
|
| 59 |
+
|
| 60 |
+
except requests.exceptions.RequestException as e:
|
| 61 |
+
print(f"❌ Request failed: {e}")
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def extract_video_info(url: str):
|
| 66 |
+
"""Extract basic video information"""
|
| 67 |
+
endpoint = f"{API_BASE_URL}/extract"
|
| 68 |
+
|
| 69 |
+
print(f"📹 Extracting info: {url}")
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
response = requests.post(endpoint, json={"url": url}, timeout=30)
|
| 73 |
+
response.raise_for_status()
|
| 74 |
+
data = response.json()
|
| 75 |
+
|
| 76 |
+
if data.get("success"):
|
| 77 |
+
info = data["data"]
|
| 78 |
+
print(f"✅ Title: {info['title']}")
|
| 79 |
+
print(f" Channel: {info['uploader']}")
|
| 80 |
+
print(f" Duration: {info['duration']}s")
|
| 81 |
+
print(f" Views: {info.get('view_count', 'N/A')}")
|
| 82 |
+
return info
|
| 83 |
+
else:
|
| 84 |
+
print(f"❌ Error: {data.get('error')}")
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
except requests.exceptions.RequestException as e:
|
| 88 |
+
print(f"❌ Request failed: {e}")
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def get_transcript(url: str, start: int = 0, end: int = 60):
|
| 93 |
+
"""Get transcript for a specific time range"""
|
| 94 |
+
endpoint = f"{API_BASE_URL}/transcript"
|
| 95 |
+
|
| 96 |
+
payload = {
|
| 97 |
+
"url": url,
|
| 98 |
+
"start": start,
|
| 99 |
+
"end": end
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
print(f"📝 Getting transcript: {start}s - {end}s")
|
| 103 |
+
|
| 104 |
+
try:
|
| 105 |
+
response = requests.post(endpoint, json=payload, timeout=30)
|
| 106 |
+
response.raise_for_status()
|
| 107 |
+
data = response.json()
|
| 108 |
+
|
| 109 |
+
if data.get("success"):
|
| 110 |
+
print(f"✅ Transcript ({data['word_count']} words):")
|
| 111 |
+
print(f" {data['transcript'][:200]}...")
|
| 112 |
+
return data
|
| 113 |
+
else:
|
| 114 |
+
print(f"❌ Error: {data.get('error')}")
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
except requests.exceptions.RequestException as e:
|
| 118 |
+
print(f"❌ Request failed: {e}")
|
| 119 |
+
return None
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def score_clip(url: str, start: str, end: str):
|
| 123 |
+
"""Score a specific time range for viral potential"""
|
| 124 |
+
endpoint = f"{API_BASE_URL}/score"
|
| 125 |
+
|
| 126 |
+
payload = {
|
| 127 |
+
"url": url,
|
| 128 |
+
"start": start,
|
| 129 |
+
"end": end
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
print(f"🎯 Scoring clip: {start} - {end}")
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
response = requests.post(endpoint, json=payload, timeout=30)
|
| 136 |
+
response.raise_for_status()
|
| 137 |
+
data = response.json()
|
| 138 |
+
|
| 139 |
+
if data.get("success"):
|
| 140 |
+
clip = data["clip"]
|
| 141 |
+
print(f"✅ Viral Score: {clip['viral_score']}%")
|
| 142 |
+
print(f" Reasons: {', '.join(clip['reasons'])}")
|
| 143 |
+
print(f" Words: {clip['word_count']}")
|
| 144 |
+
return clip
|
| 145 |
+
else:
|
| 146 |
+
print(f"❌ Error: {data.get('error')}")
|
| 147 |
+
return None
|
| 148 |
+
|
| 149 |
+
except requests.exceptions.RequestException as e:
|
| 150 |
+
print(f"❌ Request failed: {e}")
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def get_download_url(url: str, start: str, end: str, quality: str = "720"):
|
| 155 |
+
"""Get download URL for a clip"""
|
| 156 |
+
endpoint = f"{API_BASE_URL}/download"
|
| 157 |
+
|
| 158 |
+
payload = {
|
| 159 |
+
"url": url,
|
| 160 |
+
"start": start,
|
| 161 |
+
"end": end,
|
| 162 |
+
"quality": quality
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
print(f"⬇️ Getting download URL: {start} - {end}")
|
| 166 |
+
|
| 167 |
+
try:
|
| 168 |
+
response = requests.post(endpoint, json=payload, timeout=30)
|
| 169 |
+
response.raise_for_status()
|
| 170 |
+
data = response.json()
|
| 171 |
+
|
| 172 |
+
if data.get("success"):
|
| 173 |
+
info = data["download_info"]
|
| 174 |
+
print(f"✅ Download ready!")
|
| 175 |
+
print(f" URL: {info['download_url'][:80]}...")
|
| 176 |
+
print(f" Formats available: {len(info['formats'])}")
|
| 177 |
+
return info
|
| 178 |
+
else:
|
| 179 |
+
print(f"❌ Error: {data.get('error')}")
|
| 180 |
+
return None
|
| 181 |
+
|
| 182 |
+
except requests.exceptions.RequestException as e:
|
| 183 |
+
print(f"❌ Request failed: {e}")
|
| 184 |
+
return None
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def batch_analyze(urls: list, num_clips: int = 3):
|
| 188 |
+
"""Analyze multiple videos in batch"""
|
| 189 |
+
results = []
|
| 190 |
+
|
| 191 |
+
print(f"🔄 Batch analyzing {len(urls)} videos...")
|
| 192 |
+
print("=" * 60)
|
| 193 |
+
|
| 194 |
+
for i, url in enumerate(urls, 1):
|
| 195 |
+
print(f"\n[{i}/{len(urls)}]")
|
| 196 |
+
result = analyze_video(url, num_clips=num_clips)
|
| 197 |
+
if result:
|
| 198 |
+
results.append(result)
|
| 199 |
+
|
| 200 |
+
print("\n" + "=" * 60)
|
| 201 |
+
print(f"✅ Batch complete! Analyzed {len(results)} videos")
|
| 202 |
+
|
| 203 |
+
return results
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# Example usage
|
| 207 |
+
if __name__ == "__main__":
|
| 208 |
+
# Replace with your API URL
|
| 209 |
+
API_BASE_URL = input("Enter your API base URL: ").strip()
|
| 210 |
+
|
| 211 |
+
# Example YouTube URL (replace with actual URL)
|
| 212 |
+
test_url = input("Enter YouTube URL: ").strip()
|
| 213 |
+
|
| 214 |
+
print("\n" + "=" * 60)
|
| 215 |
+
print("VIRAL CLIP EXTRACTOR - Python Client")
|
| 216 |
+
print("=" * 60 + "\n")
|
| 217 |
+
|
| 218 |
+
# Menu
|
| 219 |
+
print("Choose an option:")
|
| 220 |
+
print("1. Full Analysis (find viral clips)")
|
| 221 |
+
print("2. Extract Video Info")
|
| 222 |
+
print("3. Get Transcript")
|
| 223 |
+
print("4. Score Specific Range")
|
| 224 |
+
print("5. Get Download URL")
|
| 225 |
+
|
| 226 |
+
choice = input("\nEnter choice (1-5): ").strip()
|
| 227 |
+
|
| 228 |
+
if choice == "1":
|
| 229 |
+
num = int(input("Number of clips (default 5): ") or "5")
|
| 230 |
+
length = int(input("Clip length in seconds (default 40): ") or "40")
|
| 231 |
+
analyze_video(test_url, num_clips=num, clip_length=length)
|
| 232 |
+
|
| 233 |
+
elif choice == "2":
|
| 234 |
+
extract_video_info(test_url)
|
| 235 |
+
|
| 236 |
+
elif choice == "3":
|
| 237 |
+
start = int(input("Start time in seconds: "))
|
| 238 |
+
end = int(input("End time in seconds: "))
|
| 239 |
+
get_transcript(test_url, start, end)
|
| 240 |
+
|
| 241 |
+
elif choice == "4":
|
| 242 |
+
start = input("Start time (MM:SS or seconds): ")
|
| 243 |
+
end = input("End time (MM:SS or seconds): ")
|
| 244 |
+
score_clip(test_url, start, end)
|
| 245 |
+
|
| 246 |
+
elif choice == "5":
|
| 247 |
+
start = input("Start time (MM:SS or seconds): ")
|
| 248 |
+
end = input("End time (MM:SS or seconds): ")
|
| 249 |
+
quality = input("Quality (360/720/1080, default 720): ") or "720"
|
| 250 |
+
get_download_url(test_url, start, end, quality)
|
| 251 |
+
|
| 252 |
+
else:
|
| 253 |
+
print("Invalid choice!")
|
install_ffmpeg.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import urllib.request
|
| 3 |
+
import zipfile
|
| 4 |
+
import os
|
| 5 |
+
import shutil
|
| 6 |
+
|
| 7 |
+
FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
|
| 8 |
+
DOWNLOAD_path = "ffmpeg.zip"
|
| 9 |
+
EXTRACT_DIR = "ffmpeg_temp"
|
| 10 |
+
FINAL_DIR = "ffmpeg"
|
| 11 |
+
|
| 12 |
+
def install_ffmpeg():
|
| 13 |
+
print(f"Downloading FFmpeg from {FFMPEG_URL}...")
|
| 14 |
+
try:
|
| 15 |
+
urllib.request.urlretrieve(FFMPEG_URL, DOWNLOAD_path)
|
| 16 |
+
print("Download complete.")
|
| 17 |
+
|
| 18 |
+
print("Extracting...")
|
| 19 |
+
with zipfile.ZipFile(DOWNLOAD_path, 'r') as zip_ref:
|
| 20 |
+
zip_ref.extractall(EXTRACT_DIR)
|
| 21 |
+
|
| 22 |
+
# Move bin folder to top level
|
| 23 |
+
# The zip structure is usually ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe
|
| 24 |
+
# We want ffmpeg/bin/ffmpeg.exe
|
| 25 |
+
|
| 26 |
+
extracted_root = os.path.join(EXTRACT_DIR, os.listdir(EXTRACT_DIR)[0])
|
| 27 |
+
|
| 28 |
+
if os.path.exists(FINAL_DIR):
|
| 29 |
+
print("Removing old installation...")
|
| 30 |
+
shutil.rmtree(FINAL_DIR)
|
| 31 |
+
|
| 32 |
+
shutil.move(extracted_root, FINAL_DIR)
|
| 33 |
+
|
| 34 |
+
# Cleanup
|
| 35 |
+
os.remove(DOWNLOAD_path)
|
| 36 |
+
shutil.rmtree(EXTRACT_DIR)
|
| 37 |
+
|
| 38 |
+
print(f"FFmpeg installed to {os.path.abspath(FINAL_DIR)}")
|
| 39 |
+
|
| 40 |
+
# Verify
|
| 41 |
+
bin_path = os.path.join(FINAL_DIR, "bin", "ffmpeg.exe")
|
| 42 |
+
if os.path.exists(bin_path):
|
| 43 |
+
print(f"✅ Verified: {bin_path}")
|
| 44 |
+
else:
|
| 45 |
+
print("❌ Error: ffmpeg.exe not found in expected path")
|
| 46 |
+
|
| 47 |
+
except Exception as e:
|
| 48 |
+
print(f"Error installing FFmpeg: {e}")
|
| 49 |
+
|
| 50 |
+
if __name__ == "__main__":
|
| 51 |
+
install_ffmpeg()
|
list_models.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import google.generativeai as genai
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
API_KEY = "AIzaSyAOXzvcFroHyNa5UtYRi3c8AGX0MJ1Tb5E"
|
| 6 |
+
genai.configure(api_key=API_KEY)
|
| 7 |
+
|
| 8 |
+
print("Listing available models...")
|
| 9 |
+
try:
|
| 10 |
+
for m in genai.list_models():
|
| 11 |
+
if 'generateContent' in m.supported_generation_methods:
|
| 12 |
+
print(m.name)
|
| 13 |
+
except Exception as e:
|
| 14 |
+
print(f"Error: {e}")
|
requirements.txt
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Viral Clip Extractor API - Dependencies
|
| 2 |
+
# Python 3.9+
|
| 3 |
+
|
| 4 |
+
# Web Framework
|
| 5 |
+
flask==3.0.3
|
| 6 |
+
flask-cors==4.0.1
|
| 7 |
+
werkzeug==3.0.3
|
| 8 |
+
|
| 9 |
+
# YouTube Download
|
| 10 |
+
yt-dlp==2024.8.6
|
| 11 |
+
google-generativeai==0.7.2
|
| 12 |
+
openai==1.61.0 # For Nvidia API
|
| 13 |
+
gunicorn==21.2.0 # Production Server
|
| 14 |
+
|
| 15 |
+
# HTTP Requests
|
| 16 |
+
requests==2.32.3
|
| 17 |
+
urllib3==2.2.2
|
| 18 |
+
certifi==2024.7.4
|
| 19 |
+
charset-normalizer==3.3.2
|
| 20 |
+
idna==3.7
|
| 21 |
+
|
| 22 |
+
# Data Processing
|
| 23 |
+
# (Using built-in json, re, typing modules)
|
| 24 |
+
|
| 25 |
+
# Serverless Support
|
| 26 |
+
# Vercel Python runtime handles these automatically
|
| 27 |
+
# python-dateutil is often useful
|
| 28 |
+
python-dateutil==2.8.2
|
| 29 |
+
|
| 30 |
+
# Optional: For better JSON handling
|
| 31 |
+
# (Using built-in json module)
|
| 32 |
+
|
| 33 |
+
# Development dependencies (not needed for deployment)
|
| 34 |
+
# pytest==8.3.2
|
| 35 |
+
# black==24.8.0
|
| 36 |
+
# flake8==7.1.1
|
test_ai_mode.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import requests
|
| 3 |
+
import os
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
# REPLACE THIS WITH YOUR KEY or set GEMINI_API_KEY env var
|
| 8 |
+
# Get from: https://aistudio.google.com/app/apikey
|
| 9 |
+
DEFAULT_KEY = "AIzaSyAOXzvcFroHyNa5UtYRi3c8AGX0MJ1Tb5E"
|
| 10 |
+
API_KEY = os.environ.get("GEMINI_API_KEY", DEFAULT_KEY)
|
| 11 |
+
|
| 12 |
+
URL = "https://youtu.be/uVkFrqugXFQ"
|
| 13 |
+
BASE_URL = "http://127.0.0.1:5000"
|
| 14 |
+
|
| 15 |
+
def test_ai():
|
| 16 |
+
print("Testing AI Mode...")
|
| 17 |
+
|
| 18 |
+
# Check if key is set
|
| 19 |
+
if "YOUR_GEMINI" in API_KEY:
|
| 20 |
+
print("\nWARNING: You haven't set your Gemini API Key!")
|
| 21 |
+
print("1. Get a key here: https://aistudio.google.com/app/apikey")
|
| 22 |
+
print("2. Open this script and replace 'YOUR_GEMINI_API_KEY_HERE'")
|
| 23 |
+
print(" OR set env var: $env:GEMINI_API_KEY='your_key'")
|
| 24 |
+
return
|
| 25 |
+
|
| 26 |
+
print(f"Analyzing {URL} using Gemini...")
|
| 27 |
+
print("This may take 10-20 seconds...")
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
# Test the GET /clips endpoint with mode=ai
|
| 31 |
+
resp = requests.get(f"{BASE_URL}/clips", params={
|
| 32 |
+
"url": URL,
|
| 33 |
+
"mode": "ai",
|
| 34 |
+
"gemini_key": API_KEY,
|
| 35 |
+
"num": 3,
|
| 36 |
+
"seconds": 30
|
| 37 |
+
}, timeout=120)
|
| 38 |
+
|
| 39 |
+
if resp.status_code == 200:
|
| 40 |
+
data = resp.json()
|
| 41 |
+
print("\nSUCCESS! AI Candidates Found:")
|
| 42 |
+
clips = data.get('clips', [])
|
| 43 |
+
|
| 44 |
+
if not clips:
|
| 45 |
+
print(" (No clips found. AI might have filtered everything or returned empty)")
|
| 46 |
+
|
| 47 |
+
for i, clip in enumerate(clips):
|
| 48 |
+
print(f"\nClip {i+1}: {clip.get('title')}")
|
| 49 |
+
print(f" Score: {clip.get('viral_score')}")
|
| 50 |
+
print(f" Download: {clip.get('download_link')}")
|
| 51 |
+
print(f" Reason: {clip.get('reason')}")
|
| 52 |
+
else:
|
| 53 |
+
print(f"\nError: {resp.status_code}")
|
| 54 |
+
try:
|
| 55 |
+
print(json.dumps(resp.json(), indent=2))
|
| 56 |
+
except:
|
| 57 |
+
print(resp.text)
|
| 58 |
+
|
| 59 |
+
except Exception as e:
|
| 60 |
+
print(f"\nException: {e}")
|
| 61 |
+
|
| 62 |
+
if __name__ == "__main__":
|
| 63 |
+
test_ai()
|
test_api.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test suite for Viral Clip Extractor API
|
| 3 |
+
Run with: python -m pytest test_api.py
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
import json
|
| 8 |
+
from api.index import app, extract_video_id, time_to_seconds, seconds_to_time
|
| 9 |
+
|
| 10 |
+
@pytest.fixture
|
| 11 |
+
def client():
|
| 12 |
+
"""Create test client"""
|
| 13 |
+
app.config['TESTING'] = True
|
| 14 |
+
with app.test_client() as client:
|
| 15 |
+
yield client
|
| 16 |
+
|
| 17 |
+
def test_health_endpoint(client):
|
| 18 |
+
"""Test health check"""
|
| 19 |
+
response = client.get('/health')
|
| 20 |
+
assert response.status_code == 200
|
| 21 |
+
data = json.loads(response.data)
|
| 22 |
+
assert data['status'] == 'healthy'
|
| 23 |
+
|
| 24 |
+
def test_index_endpoint(client):
|
| 25 |
+
"""Test API info endpoint"""
|
| 26 |
+
response = client.get('/')
|
| 27 |
+
assert response.status_code == 200
|
| 28 |
+
data = json.loads(response.data)
|
| 29 |
+
assert 'name' in data
|
| 30 |
+
assert 'endpoints' in data
|
| 31 |
+
|
| 32 |
+
def test_extract_video_id():
|
| 33 |
+
"""Test video ID extraction from various URLs"""
|
| 34 |
+
# Standard URL
|
| 35 |
+
assert extract_video_id('https://youtube.com/watch?v=dQw4w9WgXcQ') == 'dQw4w9WgXcQ'
|
| 36 |
+
# Short URL
|
| 37 |
+
assert extract_video_id('https://youtu.be/dQw4w9WgXcQ') == 'dQw4w9WgXcQ'
|
| 38 |
+
# Embed URL
|
| 39 |
+
assert extract_video_id('https://youtube.com/embed/dQw4w9WgXcQ') == 'dQw4w9WgXcQ'
|
| 40 |
+
# Shorts URL
|
| 41 |
+
assert extract_video_id('https://youtube.com/shorts/dQw4w9WgXcQ') == 'dQw4w9WgXcQ'
|
| 42 |
+
# Just ID
|
| 43 |
+
assert extract_video_id('dQw4w9WgXcQ') == 'dQw4w9WgXcQ'
|
| 44 |
+
# Invalid
|
| 45 |
+
assert extract_video_id('not-a-valid-url') is None
|
| 46 |
+
|
| 47 |
+
def test_time_conversions():
|
| 48 |
+
"""Test time conversion functions"""
|
| 49 |
+
# Seconds to time
|
| 50 |
+
assert seconds_to_time(120) == '2:00'
|
| 51 |
+
assert seconds_to_time(65) == '1:05'
|
| 52 |
+
assert seconds_to_time(0) == '0:00'
|
| 53 |
+
|
| 54 |
+
# Time to seconds
|
| 55 |
+
assert time_to_seconds('2:00') == 120
|
| 56 |
+
assert time_to_seconds('1:30') == 90
|
| 57 |
+
assert time_to_seconds('1:30:45') == 5445
|
| 58 |
+
assert time_to_seconds(120) == 120
|
| 59 |
+
|
| 60 |
+
def test_extract_endpoint_missing_url(client):
|
| 61 |
+
"""Test extract endpoint with missing URL"""
|
| 62 |
+
response = client.post('/extract',
|
| 63 |
+
data=json.dumps({}),
|
| 64 |
+
content_type='application/json')
|
| 65 |
+
assert response.status_code == 400
|
| 66 |
+
data = json.loads(response.data)
|
| 67 |
+
assert 'error' in data
|
| 68 |
+
|
| 69 |
+
def test_extract_endpoint_invalid_url(client):
|
| 70 |
+
"""Test extract endpoint with invalid URL"""
|
| 71 |
+
response = client.post('/extract',
|
| 72 |
+
data=json.dumps({'url': 'invalid-url'}),
|
| 73 |
+
content_type='application/json')
|
| 74 |
+
assert response.status_code == 400
|
| 75 |
+
data = json.loads(response.data)
|
| 76 |
+
assert 'error' in data
|
| 77 |
+
|
| 78 |
+
def test_analyze_endpoint_missing_url(client):
|
| 79 |
+
"""Test analyze endpoint with missing URL"""
|
| 80 |
+
response = client.post('/analyze',
|
| 81 |
+
data=json.dumps({}),
|
| 82 |
+
content_type='application/json')
|
| 83 |
+
assert response.status_code == 400
|
| 84 |
+
|
| 85 |
+
def test_score_endpoint_missing_params(client):
|
| 86 |
+
"""Test score endpoint with missing parameters"""
|
| 87 |
+
response = client.post('/score',
|
| 88 |
+
data=json.dumps({'url': 'https://youtube.com/watch?v=test'}),
|
| 89 |
+
content_type='application/json')
|
| 90 |
+
assert response.status_code == 400
|
| 91 |
+
|
| 92 |
+
def test_transcript_endpoint(client):
|
| 93 |
+
"""Test transcript endpoint structure"""
|
| 94 |
+
response = client.post('/transcript',
|
| 95 |
+
data=json.dumps({
|
| 96 |
+
'url': 'https://youtube.com/watch?v=test',
|
| 97 |
+
'start': 0,
|
| 98 |
+
'end': 60
|
| 99 |
+
}),
|
| 100 |
+
content_type='application/json')
|
| 101 |
+
# Will fail due to invalid video, but tests structure
|
| 102 |
+
assert response.status_code in [400, 500]
|
| 103 |
+
|
| 104 |
+
def test_cors_headers(client):
|
| 105 |
+
"""Test CORS is enabled"""
|
| 106 |
+
response = client.get('/')
|
| 107 |
+
assert 'Access-Control-Allow-Origin' in response.headers
|
| 108 |
+
|
| 109 |
+
if __name__ == '__main__':
|
| 110 |
+
pytest.main([__file__, '-v'])
|
test_download_clip.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from api.index import ViralClipExtractor
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
def test_download():
|
| 6 |
+
extractor = ViralClipExtractor()
|
| 7 |
+
url = "https://youtu.be/uVkFrqugXFQ"
|
| 8 |
+
print("Testing download of 5s clip...")
|
| 9 |
+
try:
|
| 10 |
+
# Try to download 5s segment
|
| 11 |
+
path = extractor.download_clip(url, "0:00", "0:05") # Method doesn't exist yet in my class, I need to add it or test raw yt-dlp
|
| 12 |
+
print(f"Downloaded to: {path}")
|
| 13 |
+
except Exception as e:
|
| 14 |
+
print(f"Error: {e}")
|
| 15 |
+
|
| 16 |
+
if __name__ == "__main__":
|
| 17 |
+
# monkey patch for testing purposes or just write raw yt-dlp code here
|
| 18 |
+
import yt_dlp
|
| 19 |
+
|
| 20 |
+
url = "https://youtu.be/uVkFrqugXFQ"
|
| 21 |
+
ydl_opts = {
|
| 22 |
+
'format': 'bestvideo[height<=720]+bestaudio/best',
|
| 23 |
+
'outtmpl': 'test_clip.%(ext)s',
|
| 24 |
+
'download_ranges': lambda info, ydl: [{'start_time': 0, 'end_time': 5}],
|
| 25 |
+
'force_keyframes_at_cuts': True,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
| 30 |
+
ydl.download([url])
|
| 31 |
+
print("Download success")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
print(f"Download failed: {e}")
|
test_download_e2e.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import requests
|
| 3 |
+
import time
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
BASE_URL = "http://127.0.0.1:5000"
|
| 7 |
+
|
| 8 |
+
def test_e2e_download():
|
| 9 |
+
print("Testing /clips and download link...")
|
| 10 |
+
url = "https://youtu.be/uVkFrqugXFQ"
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
# 1. Get Clips
|
| 14 |
+
print("Fetching clips...")
|
| 15 |
+
resp = requests.get(f"{BASE_URL}/clips", params={
|
| 16 |
+
"url": url,
|
| 17 |
+
"seconds": 5, # Short clip for speed
|
| 18 |
+
"num": 1
|
| 19 |
+
}, timeout=60)
|
| 20 |
+
|
| 21 |
+
if resp.status_code != 200:
|
| 22 |
+
print(f"Failed to get clips: {resp.text}")
|
| 23 |
+
return
|
| 24 |
+
|
| 25 |
+
data = resp.json()
|
| 26 |
+
clips = data.get('clips', [])
|
| 27 |
+
if not clips:
|
| 28 |
+
print("No clips found in response")
|
| 29 |
+
return
|
| 30 |
+
|
| 31 |
+
clip = clips[0]
|
| 32 |
+
download_link = clip.get('download_link')
|
| 33 |
+
if not download_link:
|
| 34 |
+
print("No download_link in clip data")
|
| 35 |
+
print(clip)
|
| 36 |
+
return
|
| 37 |
+
|
| 38 |
+
print(f"Found download link: {download_link}")
|
| 39 |
+
|
| 40 |
+
# 2. Download File
|
| 41 |
+
full_download_url = f"{BASE_URL}{download_link}"
|
| 42 |
+
print(f"Downloading from {full_download_url}...")
|
| 43 |
+
|
| 44 |
+
t0 = time.time()
|
| 45 |
+
file_resp = requests.get(full_download_url, stream=True, timeout=120)
|
| 46 |
+
|
| 47 |
+
if file_resp.status_code == 200:
|
| 48 |
+
filename = "test_clip_downloaded.mp4"
|
| 49 |
+
with open(filename, 'wb') as f:
|
| 50 |
+
for chunk in file_resp.iter_content(chunk_size=8192):
|
| 51 |
+
f.write(chunk)
|
| 52 |
+
|
| 53 |
+
size = os.path.getsize(filename)
|
| 54 |
+
print(f"Downloaded file: {filename} ({size/1024:.2f} KB)")
|
| 55 |
+
print(f"Time: {time.time() - t0:.2f}s")
|
| 56 |
+
else:
|
| 57 |
+
print(f"Failed to download file: {file_resp.status_code}")
|
| 58 |
+
print(file_resp.text)
|
| 59 |
+
|
| 60 |
+
except Exception as e:
|
| 61 |
+
print(f"Error: {e}")
|
| 62 |
+
|
| 63 |
+
if __name__ == "__main__":
|
| 64 |
+
test_e2e_download()
|
test_nvidia_mode.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import requests
|
| 3 |
+
import os
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
# REPLACE THIS WITH YOUR NVIDIA KEY or set NVIDIA_API_KEY env var
|
| 8 |
+
# Get from: https://build.nvidia.com/explore/discover
|
| 9 |
+
DEFAULT_KEY = "YOUR_NVIDIA_API_KEY_HERE"
|
| 10 |
+
API_KEY = os.environ.get("NVIDIA_API_KEY", DEFAULT_KEY)
|
| 11 |
+
|
| 12 |
+
URL = "https://youtu.be/uVkFrqugXFQ"
|
| 13 |
+
BASE_URL = "http://127.0.0.1:5000"
|
| 14 |
+
|
| 15 |
+
def test_nvidia():
|
| 16 |
+
print("Testing Nvidia/DeepSeek Mode...")
|
| 17 |
+
|
| 18 |
+
# Check if key is set (simple length check or placeholder check)
|
| 19 |
+
if "YOUR_NVIDIA" in API_KEY:
|
| 20 |
+
print("\nWARNING: You haven't set your Nvidia API Key!")
|
| 21 |
+
print("1. Get a key here: https://build.nvidia.com/explore/discover")
|
| 22 |
+
print("2. Open this script and replace 'YOUR_NVIDIA_API_KEY_HERE'")
|
| 23 |
+
print(" OR set env var: $env:NVIDIA_API_KEY='your_key'")
|
| 24 |
+
return
|
| 25 |
+
|
| 26 |
+
if API_KEY.startswith("AIza"):
|
| 27 |
+
print("\nERROR: You are using a Google Gemini Key (starts with AIza...)!")
|
| 28 |
+
print(" Nvidia keys usually start with 'nvapi-'.")
|
| 29 |
+
print(" Please use 'mode=ai' for Gemini, or get an Nvidia key.")
|
| 30 |
+
return
|
| 31 |
+
|
| 32 |
+
print(f"Analyzing {URL} using DeepSeek V3 (via Nvidia)...")
|
| 33 |
+
print("This may take 15-30 seconds...")
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
# Test the GET /clips endpoint with mode=nvidia
|
| 37 |
+
resp = requests.get(f"{BASE_URL}/clips", params={
|
| 38 |
+
"url": URL,
|
| 39 |
+
"mode": "nvidia",
|
| 40 |
+
"nvidia_key": API_KEY,
|
| 41 |
+
"num": 3,
|
| 42 |
+
"seconds": 30
|
| 43 |
+
}, timeout=120)
|
| 44 |
+
|
| 45 |
+
if resp.status_code == 200:
|
| 46 |
+
data = resp.json()
|
| 47 |
+
print("\nSUCCESS! AI Candidates Found (DeepSeek):")
|
| 48 |
+
clips = data.get('clips', [])
|
| 49 |
+
|
| 50 |
+
if not clips:
|
| 51 |
+
print(" (No clips found. AI might have filtered everything or returned empty)")
|
| 52 |
+
|
| 53 |
+
for i, clip in enumerate(clips):
|
| 54 |
+
print(f"\nClip {i+1}: {clip.get('title')}")
|
| 55 |
+
print(f" Score: {clip.get('viral_score')}")
|
| 56 |
+
print(f" Download: {clip.get('download_link')}")
|
| 57 |
+
print(f" Reason: {clip.get('reason')}")
|
| 58 |
+
else:
|
| 59 |
+
print(f"\nError: {resp.status_code}")
|
| 60 |
+
try:
|
| 61 |
+
print(json.dumps(resp.json(), indent=2))
|
| 62 |
+
except:
|
| 63 |
+
print(resp.text)
|
| 64 |
+
|
| 65 |
+
except Exception as e:
|
| 66 |
+
print(f"\nException: {e}")
|
| 67 |
+
|
| 68 |
+
if __name__ == "__main__":
|
| 69 |
+
test_nvidia()
|
vercel.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"version": 2,
|
| 3 |
+
"name": "viral-clip-extractor-api",
|
| 4 |
+
"builds": [
|
| 5 |
+
{
|
| 6 |
+
"src": "api/index.py",
|
| 7 |
+
"use": "@vercel/python",
|
| 8 |
+
"config": {
|
| 9 |
+
"maxLambdaSize": "50mb",
|
| 10 |
+
"runtime": "python3.9"
|
| 11 |
+
}
|
| 12 |
+
}
|
| 13 |
+
],
|
| 14 |
+
"routes": [
|
| 15 |
+
{
|
| 16 |
+
"src": "/(.*)",
|
| 17 |
+
"dest": "api/index.py"
|
| 18 |
+
}
|
| 19 |
+
],
|
| 20 |
+
"env": {
|
| 21 |
+
"PYTHONUNBUFFERED": "1"
|
| 22 |
+
},
|
| 23 |
+
"functions": {
|
| 24 |
+
"api/index.py": {
|
| 25 |
+
"maxDuration": 60
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
}
|