Commit ·
7fa9d90
0
Parent(s):
Initial commit: NCAkit with Story Reels module
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +57 -0
- .gitattributes +1 -0
- Dockerfile +41 -0
- README.md +110 -0
- __pycache__/app.cpython-313.pyc +0 -0
- __pycache__/config.cpython-313.pyc +0 -0
- app.py +115 -0
- config.py +115 -0
- core/__init__.py +1 -0
- core/__pycache__/module_registry.cpython-313.pyc +0 -0
- core/module_registry.py +145 -0
- core/utils/__init__.py +1 -0
- modules/__init__.py +1 -0
- modules/_template/__init__.py +2 -0
- modules/_template/module.py +38 -0
- modules/_template/router.py +24 -0
- modules/_template/schemas.py +20 -0
- modules/story_reels/__init__.py +97 -0
- modules/story_reels/__pycache__/__init__.cpython-313.pyc +0 -0
- modules/story_reels/__pycache__/router.cpython-313.pyc +0 -0
- modules/story_reels/__pycache__/schemas.cpython-313.pyc +0 -0
- modules/story_reels/router.py +117 -0
- modules/story_reels/schemas.py +114 -0
- modules/story_reels/services/__init__.py +1 -0
- modules/story_reels/services/__pycache__/cloudflare_client.cpython-313.pyc +0 -0
- modules/story_reels/services/__pycache__/nvidia_client.cpython-313.pyc +0 -0
- modules/story_reels/services/__pycache__/prompt_builder.cpython-313.pyc +0 -0
- modules/story_reels/services/__pycache__/script_generator.cpython-313.pyc +0 -0
- modules/story_reels/services/__pycache__/srt_parser.cpython-313.pyc +0 -0
- modules/story_reels/services/__pycache__/story_creator.cpython-313.pyc +0 -0
- modules/story_reels/services/cloudflare_client.py +198 -0
- modules/story_reels/services/nvidia_client.py +235 -0
- modules/story_reels/services/prompt_builder.py +147 -0
- modules/story_reels/services/script_generator.py +256 -0
- modules/story_reels/services/srt_parser.py +214 -0
- modules/story_reels/services/story_creator.py +583 -0
- modules/video_creator/__init__.py +81 -0
- modules/video_creator/__pycache__/__init__.cpython-313.pyc +0 -0
- modules/video_creator/__pycache__/router.cpython-313.pyc +0 -0
- modules/video_creator/router.py +130 -0
- modules/video_creator/schemas.py +143 -0
- modules/video_creator/services/__init__.py +0 -0
- modules/video_creator/services/__pycache__/__init__.cpython-313.pyc +0 -0
- modules/video_creator/services/__pycache__/short_creator.cpython-313.pyc +0 -0
- modules/video_creator/services/libraries/__init__.py +0 -0
- modules/video_creator/services/libraries/__pycache__/__init__.cpython-313.pyc +0 -0
- modules/video_creator/services/libraries/__pycache__/tts_client.cpython-313.pyc +0 -0
- modules/video_creator/services/libraries/__pycache__/whisper_client.cpython-313.pyc +0 -0
- modules/video_creator/services/libraries/ffmpeg_utils.py +191 -0
- modules/video_creator/services/libraries/pexels_client.py +223 -0
.env.example
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NCAkit Environment Configuration
|
| 2 |
+
# Copy this file to .env and fill in your values
|
| 3 |
+
|
| 4 |
+
# ===================
|
| 5 |
+
# Video Creator Module
|
| 6 |
+
# ===================
|
| 7 |
+
|
| 8 |
+
# Pexels API key for background videos (Required)
|
| 9 |
+
# Get from: https://www.pexels.com/api/
|
| 10 |
+
PEXELS_API_KEY=your_pexels_api_key_here
|
| 11 |
+
|
| 12 |
+
# Kokoro TTS endpoint URL (Required)
|
| 13 |
+
# Example: https://your-username-kokoro-tts.hf.space
|
| 14 |
+
HF_TTS=https://your-tts-endpoint.hf.space
|
| 15 |
+
|
| 16 |
+
# Whisper model for captions (Optional, default: tiny.en)
|
| 17 |
+
# Options: tiny.en, base.en, small.en, medium.en, large
|
| 18 |
+
WHISPER_MODEL=tiny.en
|
| 19 |
+
|
| 20 |
+
# ===================
|
| 21 |
+
# Server Configuration
|
| 22 |
+
# ===================
|
| 23 |
+
|
| 24 |
+
# Server port (Optional, default: 8880)
|
| 25 |
+
PORT=8880
|
| 26 |
+
|
| 27 |
+
# Log level (Optional, default: info)
|
| 28 |
+
# Options: debug, info, warning, error
|
| 29 |
+
LOG_LEVEL=info
|
| 30 |
+
|
| 31 |
+
# Running in Docker? (Optional, default: false)
|
| 32 |
+
DOCKER=false
|
| 33 |
+
|
| 34 |
+
# Custom data directory (Optional)
|
| 35 |
+
# DATA_DIR_PATH=/path/to/data
|
| 36 |
+
|
| 37 |
+
# ===================
|
| 38 |
+
# Add new module configs below
|
| 39 |
+
# ===================
|
| 40 |
+
|
| 41 |
+
# ===================
|
| 42 |
+
# Story Reels Module (Image Generation)
|
| 43 |
+
# ===================
|
| 44 |
+
|
| 45 |
+
# NVIDIA API Key (PRIMARY - stable-diffusion-3-medium)
|
| 46 |
+
# Get from: https://build.nvidia.com/
|
| 47 |
+
NVIDIA_API_KEY=nvapi-your_key_here
|
| 48 |
+
|
| 49 |
+
# Cloudflare Worker URL (FALLBACK)
|
| 50 |
+
CF_URL=https://image-api.yourworker.workers.dev
|
| 51 |
+
|
| 52 |
+
# Cloudflare API Key (FALLBACK)
|
| 53 |
+
CF_API=your_api_key_here
|
| 54 |
+
|
| 55 |
+
# Gemini API Key (Required for AI script generation)
|
| 56 |
+
# Get from: https://aistudio.google.com/apikey
|
| 57 |
+
GEMINI_API_KEY=your_gemini_api_key_here
|
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NCAkit Docker Configuration for Hugging Face Spaces
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Install system dependencies
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
+
ffmpeg \
|
| 7 |
+
libsndfile1 \
|
| 8 |
+
git \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
# Create non-root user for HF Spaces
|
| 12 |
+
RUN useradd -m -u 1000 user
|
| 13 |
+
USER user
|
| 14 |
+
ENV HOME=/home/user \
|
| 15 |
+
PATH=/home/user/.local/bin:$PATH
|
| 16 |
+
|
| 17 |
+
# Set working directory
|
| 18 |
+
WORKDIR $HOME/app
|
| 19 |
+
|
| 20 |
+
# Copy requirements first for caching
|
| 21 |
+
COPY --chown=user requirements.txt .
|
| 22 |
+
RUN pip install --no-cache-dir --user -r requirements.txt
|
| 23 |
+
|
| 24 |
+
# Copy application code
|
| 25 |
+
COPY --chown=user . .
|
| 26 |
+
|
| 27 |
+
# Create data directories
|
| 28 |
+
RUN mkdir -p $HOME/app/data $HOME/app/videos $HOME/app/temp
|
| 29 |
+
|
| 30 |
+
# Environment for HF Spaces
|
| 31 |
+
ENV DOCKER=true
|
| 32 |
+
ENV PORT=8880
|
| 33 |
+
ENV LOG_LEVEL=info
|
| 34 |
+
ENV DATA_DIR=$HOME/app/data
|
| 35 |
+
ENV VIDEOS_DIR=$HOME/app/videos
|
| 36 |
+
|
| 37 |
+
# Expose REST API port
|
| 38 |
+
EXPOSE 8880
|
| 39 |
+
|
| 40 |
+
# Run the application
|
| 41 |
+
CMD ["python", "app.py"]
|
README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NCAkit - Neural Content Automation Toolkit 🤖
|
| 2 |
+
|
| 3 |
+
A modular Python toolkit for content automation, featuring video creation, text-to-speech, and more.
|
| 4 |
+
|
| 5 |
+
## ✨ Features
|
| 6 |
+
|
| 7 |
+
- 🎬 **Video Creator** - Short-form videos with TTS, captions, and music
|
| 8 |
+
- 🔌 **Modular Architecture** - Easy to add new features
|
| 9 |
+
- 🌐 **REST API** - FastAPI with auto-generated docs
|
| 10 |
+
- 🚀 **Ready for Deployment** - Docker & Hugging Face Spaces
|
| 11 |
+
|
| 12 |
+
## 🏗️ Project Structure
|
| 13 |
+
|
| 14 |
+
```
|
| 15 |
+
NCAkit/
|
| 16 |
+
├── app.py # Main FastAPI application
|
| 17 |
+
├── config.py # Unified configuration
|
| 18 |
+
├── requirements.txt # All dependencies
|
| 19 |
+
├── Dockerfile # Docker deployment
|
| 20 |
+
│
|
| 21 |
+
├── core/ # Shared infrastructure
|
| 22 |
+
│ ├── module_registry.py # Auto module discovery
|
| 23 |
+
│ └── utils/ # Shared utilities
|
| 24 |
+
│
|
| 25 |
+
├── modules/ # Feature modules
|
| 26 |
+
│ ├── video_creator/ # Video creation module
|
| 27 |
+
│ │ ├── router.py # API endpoints
|
| 28 |
+
│ │ ├── schemas.py # Pydantic models
|
| 29 |
+
│ │ └── services/ # Core logic
|
| 30 |
+
│ └── _template/ # Template for new modules
|
| 31 |
+
│
|
| 32 |
+
└── static/ # Web UI & assets
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
## 🚀 Quick Start
|
| 36 |
+
|
| 37 |
+
### Install
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
cd NCAkit
|
| 41 |
+
pip install -r requirements.txt
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
### Configure
|
| 45 |
+
|
| 46 |
+
```bash
|
| 47 |
+
cp .env.example .env
|
| 48 |
+
# Edit .env with your API keys
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
### Run
|
| 52 |
+
|
| 53 |
+
```bash
|
| 54 |
+
python app.py
|
| 55 |
+
# Or: uvicorn app:app --host 0.0.0.0 --port 8880 --reload
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
### Access
|
| 59 |
+
|
| 60 |
+
- **Web UI**: http://localhost:8880
|
| 61 |
+
- **API Docs**: http://localhost:8880/docs
|
| 62 |
+
- **Modules**: http://localhost:8880/api/modules
|
| 63 |
+
|
| 64 |
+
## 📡 API Endpoints
|
| 65 |
+
|
| 66 |
+
| Module | Endpoint | Method | Description |
|
| 67 |
+
|--------|----------|--------|-------------|
|
| 68 |
+
| System | `/health` | GET | Health check |
|
| 69 |
+
| System | `/api/modules` | GET | List modules |
|
| 70 |
+
| Video | `/api/video/short-video` | POST | Create video |
|
| 71 |
+
| Video | `/api/video/short-video/{id}/status` | GET | Check status |
|
| 72 |
+
| Video | `/api/video/short-video/{id}` | GET | Download video |
|
| 73 |
+
|
| 74 |
+
## 🔧 Adding New Modules
|
| 75 |
+
|
| 76 |
+
1. Copy `modules/_template/` to `modules/your_module/`
|
| 77 |
+
2. Update `MODULE_NAME`, `MODULE_PREFIX` in `__init__.py`
|
| 78 |
+
3. Implement router and services
|
| 79 |
+
4. Restart server - auto-discovered!
|
| 80 |
+
|
| 81 |
+
```python
|
| 82 |
+
# modules/your_module/__init__.py
|
| 83 |
+
MODULE_NAME = "your_module"
|
| 84 |
+
MODULE_PREFIX = "/api/your-feature"
|
| 85 |
+
|
| 86 |
+
def register(app, config):
|
| 87 |
+
from .router import router
|
| 88 |
+
app.include_router(router, prefix=MODULE_PREFIX)
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
## 🐳 Docker
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
docker build -t ncakit .
|
| 95 |
+
docker run -p 8880:8880 --env-file .env ncakit
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
## ⚙️ Environment Variables
|
| 99 |
+
|
| 100 |
+
| Variable | Required | Default | Module |
|
| 101 |
+
|----------|----------|---------|--------|
|
| 102 |
+
| `PEXELS_API_KEY` | ✅ | - | Video Creator |
|
| 103 |
+
| `HF_TTS` | ✅ | - | Video Creator |
|
| 104 |
+
| `WHISPER_MODEL` | ❌ | tiny.en | Video Creator |
|
| 105 |
+
| `PORT` | ❌ | 8880 | Server |
|
| 106 |
+
| `LOG_LEVEL` | ❌ | info | Server |
|
| 107 |
+
|
| 108 |
+
## 📄 License
|
| 109 |
+
|
| 110 |
+
MIT
|
__pycache__/app.cpython-313.pyc
ADDED
|
Binary file (4.3 kB). View file
|
|
|
__pycache__/config.cpython-313.pyc
ADDED
|
Binary file (4.78 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
NCAkit - Neural Content Automation Toolkit
|
| 3 |
+
Main FastAPI Application with Modular Architecture
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import FastAPI
|
| 6 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
+
from fastapi.staticfiles import StaticFiles
|
| 8 |
+
from fastapi.responses import FileResponse
|
| 9 |
+
import logging
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import sys
|
| 12 |
+
|
| 13 |
+
from config import config
|
| 14 |
+
from core.module_registry import registry
|
| 15 |
+
|
| 16 |
+
# Setup logging
|
| 17 |
+
logging.basicConfig(
|
| 18 |
+
level=config.log_level.upper(),
|
| 19 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 20 |
+
handlers=[logging.StreamHandler(sys.stdout)]
|
| 21 |
+
)
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
# Create FastAPI app
|
| 25 |
+
app = FastAPI(
|
| 26 |
+
title="NCAkit - Neural Content Automation Toolkit",
|
| 27 |
+
description="""
|
| 28 |
+
# NCAkit REST API
|
| 29 |
+
|
| 30 |
+
A modular toolkit for content automation with multiple feature modules.
|
| 31 |
+
|
| 32 |
+
## Available Modules
|
| 33 |
+
|
| 34 |
+
- 🎬 **Video Creator** - Create short-form videos with TTS, captions, and music
|
| 35 |
+
- 📱 More modules coming soon...
|
| 36 |
+
|
| 37 |
+
## How It Works
|
| 38 |
+
|
| 39 |
+
1. Each module has its own API prefix (e.g., `/api/video/`)
|
| 40 |
+
2. Modules are auto-discovered and registered on startup
|
| 41 |
+
3. Check `/api/modules` for list of available modules
|
| 42 |
+
""",
|
| 43 |
+
version="1.0.0",
|
| 44 |
+
contact={
|
| 45 |
+
"name": "NCAkit",
|
| 46 |
+
"url": "https://github.com/your-repo/ncakit"
|
| 47 |
+
}
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# Add CORS middleware
|
| 51 |
+
app.add_middleware(
|
| 52 |
+
CORSMiddleware,
|
| 53 |
+
allow_origins=["*"],
|
| 54 |
+
allow_credentials=True,
|
| 55 |
+
allow_methods=["*"],
|
| 56 |
+
allow_headers=["*"],
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@app.on_event("startup")
|
| 61 |
+
async def startup_event():
|
| 62 |
+
"""Initialize all modules on startup"""
|
| 63 |
+
logger.info("Starting NCAkit...")
|
| 64 |
+
|
| 65 |
+
# Ensure directories exist
|
| 66 |
+
config.ensure_directories()
|
| 67 |
+
|
| 68 |
+
# Register all modules
|
| 69 |
+
num_modules = registry.register_all(app, config)
|
| 70 |
+
logger.info(f"Loaded {num_modules} module(s)")
|
| 71 |
+
|
| 72 |
+
logger.info(f"NCAkit started successfully on port {config.port}")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@app.get("/health", tags=["System"])
|
| 76 |
+
async def health_check():
|
| 77 |
+
"""Health check endpoint"""
|
| 78 |
+
return {"status": "ok", "toolkit": "ncakit"}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@app.get("/api/modules", tags=["System"])
|
| 82 |
+
async def list_modules():
|
| 83 |
+
"""List all available modules"""
|
| 84 |
+
return {
|
| 85 |
+
"modules": registry.list_modules()
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@app.get("/")
|
| 90 |
+
async def read_root():
|
| 91 |
+
"""Serve the web UI"""
|
| 92 |
+
static_path = Path(__file__).parent / "static" / "index.html"
|
| 93 |
+
if static_path.exists():
|
| 94 |
+
return FileResponse(static_path)
|
| 95 |
+
return {
|
| 96 |
+
"message": "NCAkit - Neural Content Automation Toolkit",
|
| 97 |
+
"docs": "/docs",
|
| 98 |
+
"modules": "/api/modules"
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# Mount static files if they exist
|
| 103 |
+
static_dir = Path(__file__).parent / "static"
|
| 104 |
+
if static_dir.exists():
|
| 105 |
+
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
if __name__ == "__main__":
|
| 109 |
+
import uvicorn
|
| 110 |
+
uvicorn.run(
|
| 111 |
+
"app:app",
|
| 112 |
+
host="0.0.0.0",
|
| 113 |
+
port=config.port,
|
| 114 |
+
log_level=config.log_level.lower()
|
| 115 |
+
)
|
config.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Base Configuration for NCAkit
|
| 3 |
+
Provides centralized configuration management for all modules.
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from pydantic_settings import BaseSettings
|
| 8 |
+
from typing import Optional, Dict, Any
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class BaseConfig(BaseSettings):
|
| 12 |
+
"""
|
| 13 |
+
Base configuration class that all module configs should extend.
|
| 14 |
+
Provides common settings and utilities.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
# Server Configuration
|
| 18 |
+
port: int = 8880
|
| 19 |
+
log_level: str = "info"
|
| 20 |
+
debug: bool = False
|
| 21 |
+
|
| 22 |
+
# Environment
|
| 23 |
+
docker: bool = False
|
| 24 |
+
dev: bool = False
|
| 25 |
+
data_dir_path: Optional[str] = None
|
| 26 |
+
|
| 27 |
+
class Config:
|
| 28 |
+
env_file = ".env"
|
| 29 |
+
case_sensitive = False
|
| 30 |
+
extra = "ignore"
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def base_data_dir(self) -> Path:
|
| 34 |
+
"""Get the base data directory path"""
|
| 35 |
+
if self.data_dir_path:
|
| 36 |
+
return Path(self.data_dir_path)
|
| 37 |
+
|
| 38 |
+
if self.docker:
|
| 39 |
+
return Path("/data")
|
| 40 |
+
|
| 41 |
+
# For local development
|
| 42 |
+
home = Path.home()
|
| 43 |
+
return home / ".ncakit"
|
| 44 |
+
|
| 45 |
+
def ensure_base_directories(self):
|
| 46 |
+
"""Ensure base directories exist"""
|
| 47 |
+
self.base_data_dir.mkdir(parents=True, exist_ok=True)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class NCAkitConfig(BaseConfig):
|
| 51 |
+
"""
|
| 52 |
+
Main NCAkit configuration.
|
| 53 |
+
Aggregates all module-specific settings.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
# ===================
|
| 57 |
+
# Video Creator Module Config
|
| 58 |
+
# ===================
|
| 59 |
+
pexels_api_key: Optional[str] = None
|
| 60 |
+
hf_tts: Optional[str] = None
|
| 61 |
+
whisper_model: str = "tiny.en"
|
| 62 |
+
whisper_verbose: bool = False
|
| 63 |
+
concurrency: int = 1
|
| 64 |
+
video_cache_size_in_bytes: int = 2684354560 # 2.5GB
|
| 65 |
+
|
| 66 |
+
# ===================
|
| 67 |
+
# Add new module configs here
|
| 68 |
+
# Example:
|
| 69 |
+
# openai_api_key: Optional[str] = None
|
| 70 |
+
# ===================
|
| 71 |
+
|
| 72 |
+
# ===================
|
| 73 |
+
# Story Reels Module Config
|
| 74 |
+
# ===================
|
| 75 |
+
nvidia_api_key: Optional[str] = None # NVIDIA API key (primary)
|
| 76 |
+
cf_url: Optional[str] = None # Cloudflare Worker URL (fallback)
|
| 77 |
+
cf_api: Optional[str] = None # Cloudflare API key (fallback)
|
| 78 |
+
gemini_api_key: Optional[str] = None # For AI script generation
|
| 79 |
+
|
| 80 |
+
@property
|
| 81 |
+
def videos_dir_path(self) -> Path:
|
| 82 |
+
"""Directory for storing generated videos"""
|
| 83 |
+
path = self.base_data_dir / "videos"
|
| 84 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
return path
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def temp_dir_path(self) -> Path:
|
| 89 |
+
"""Directory for temporary files"""
|
| 90 |
+
path = self.base_data_dir / "temp"
|
| 91 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 92 |
+
return path
|
| 93 |
+
|
| 94 |
+
@property
|
| 95 |
+
def whisper_model_dir(self) -> Path:
|
| 96 |
+
"""Directory for Whisper models"""
|
| 97 |
+
path = self.base_data_dir / "whisper_models"
|
| 98 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 99 |
+
return path
|
| 100 |
+
|
| 101 |
+
@property
|
| 102 |
+
def music_dir_path(self) -> Path:
|
| 103 |
+
"""Directory for music files"""
|
| 104 |
+
return Path(__file__).parent / "static" / "music"
|
| 105 |
+
|
| 106 |
+
def ensure_directories(self):
|
| 107 |
+
"""Ensure all required directories exist"""
|
| 108 |
+
self.ensure_base_directories()
|
| 109 |
+
self.videos_dir_path.mkdir(parents=True, exist_ok=True)
|
| 110 |
+
self.temp_dir_path.mkdir(parents=True, exist_ok=True)
|
| 111 |
+
self.whisper_model_dir.mkdir(parents=True, exist_ok=True)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# Global config instance
|
| 115 |
+
config = NCAkitConfig()
|
core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# NCAkit - Neural Content Automation Toolkit
|
core/__pycache__/module_registry.cpython-313.pyc
ADDED
|
Binary file (6.54 kB). View file
|
|
|
core/module_registry.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Module Registry for NCAkit
|
| 3 |
+
Handles automatic discovery and registration of feature modules.
|
| 4 |
+
"""
|
| 5 |
+
import importlib
|
| 6 |
+
import pkgutil
|
| 7 |
+
import logging
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import List, Dict, Any, Callable
|
| 10 |
+
from fastapi import FastAPI
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ModuleInfo:
|
| 16 |
+
"""Information about a registered module"""
|
| 17 |
+
def __init__(
|
| 18 |
+
self,
|
| 19 |
+
name: str,
|
| 20 |
+
prefix: str,
|
| 21 |
+
description: str = "",
|
| 22 |
+
register_fn: Callable = None
|
| 23 |
+
):
|
| 24 |
+
self.name = name
|
| 25 |
+
self.prefix = prefix
|
| 26 |
+
self.description = description
|
| 27 |
+
self.register_fn = register_fn
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ModuleRegistry:
|
| 31 |
+
"""
|
| 32 |
+
Centralized registry for all NCAkit modules.
|
| 33 |
+
|
| 34 |
+
Each module must have an __init__.py with:
|
| 35 |
+
- MODULE_NAME: str
|
| 36 |
+
- MODULE_PREFIX: str
|
| 37 |
+
- MODULE_DESCRIPTION: str (optional)
|
| 38 |
+
- register(app, config): function
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
def __init__(self):
|
| 42 |
+
self._modules: Dict[str, ModuleInfo] = {}
|
| 43 |
+
self._initialized: bool = False
|
| 44 |
+
|
| 45 |
+
def discover_modules(self, modules_package: str = "modules") -> List[str]:
|
| 46 |
+
"""
|
| 47 |
+
Discover all available modules in the modules package.
|
| 48 |
+
Returns list of module names.
|
| 49 |
+
"""
|
| 50 |
+
discovered = []
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
package = importlib.import_module(modules_package)
|
| 54 |
+
package_path = Path(package.__file__).parent
|
| 55 |
+
|
| 56 |
+
for finder, name, is_pkg in pkgutil.iter_modules([str(package_path)]):
|
| 57 |
+
# Skip private/template modules
|
| 58 |
+
if name.startswith('_'):
|
| 59 |
+
continue
|
| 60 |
+
|
| 61 |
+
if is_pkg:
|
| 62 |
+
discovered.append(name)
|
| 63 |
+
logger.debug(f"Discovered module: {name}")
|
| 64 |
+
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error(f"Error discovering modules: {e}")
|
| 67 |
+
|
| 68 |
+
return discovered
|
| 69 |
+
|
| 70 |
+
def load_module(self, module_name: str, modules_package: str = "modules") -> ModuleInfo | None:
|
| 71 |
+
"""Load a single module and return its info"""
|
| 72 |
+
try:
|
| 73 |
+
full_module_name = f"{modules_package}.{module_name}"
|
| 74 |
+
module = importlib.import_module(full_module_name)
|
| 75 |
+
|
| 76 |
+
# Check required attributes
|
| 77 |
+
if not hasattr(module, 'register'):
|
| 78 |
+
logger.warning(f"Module {module_name} has no register function, skipping")
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
# Get module metadata
|
| 82 |
+
name = getattr(module, 'MODULE_NAME', module_name)
|
| 83 |
+
prefix = getattr(module, 'MODULE_PREFIX', f"/api/{module_name}")
|
| 84 |
+
description = getattr(module, 'MODULE_DESCRIPTION', "")
|
| 85 |
+
|
| 86 |
+
info = ModuleInfo(
|
| 87 |
+
name=name,
|
| 88 |
+
prefix=prefix,
|
| 89 |
+
description=description,
|
| 90 |
+
register_fn=module.register
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
self._modules[name] = info
|
| 94 |
+
logger.info(f"Loaded module: {name} (prefix: {prefix})")
|
| 95 |
+
return info
|
| 96 |
+
|
| 97 |
+
except Exception as e:
|
| 98 |
+
logger.error(f"Failed to load module {module_name}: {e}")
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
def register_all(self, app: FastAPI, config: Any) -> int:
|
| 102 |
+
"""
|
| 103 |
+
Register all discovered modules with the FastAPI app.
|
| 104 |
+
Returns number of successfully registered modules.
|
| 105 |
+
"""
|
| 106 |
+
if self._initialized:
|
| 107 |
+
logger.warning("Modules already initialized")
|
| 108 |
+
return len(self._modules)
|
| 109 |
+
|
| 110 |
+
# Discover modules
|
| 111 |
+
module_names = self.discover_modules()
|
| 112 |
+
|
| 113 |
+
registered = 0
|
| 114 |
+
for name in module_names:
|
| 115 |
+
info = self.load_module(name)
|
| 116 |
+
if info and info.register_fn:
|
| 117 |
+
try:
|
| 118 |
+
info.register_fn(app, config)
|
| 119 |
+
registered += 1
|
| 120 |
+
logger.info(f"Registered module: {info.name}")
|
| 121 |
+
except Exception as e:
|
| 122 |
+
logger.error(f"Failed to register module {name}: {e}")
|
| 123 |
+
|
| 124 |
+
self._initialized = True
|
| 125 |
+
logger.info(f"Registered {registered}/{len(module_names)} modules")
|
| 126 |
+
return registered
|
| 127 |
+
|
| 128 |
+
def get_module(self, name: str) -> ModuleInfo | None:
|
| 129 |
+
"""Get info about a specific module"""
|
| 130 |
+
return self._modules.get(name)
|
| 131 |
+
|
| 132 |
+
def list_modules(self) -> List[Dict[str, str]]:
|
| 133 |
+
"""List all registered modules"""
|
| 134 |
+
return [
|
| 135 |
+
{
|
| 136 |
+
"name": info.name,
|
| 137 |
+
"prefix": info.prefix,
|
| 138 |
+
"description": info.description
|
| 139 |
+
}
|
| 140 |
+
for info in self._modules.values()
|
| 141 |
+
]
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# Global registry instance
|
| 145 |
+
registry = ModuleRegistry()
|
core/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Core Utilities
|
modules/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# NCAkit Modules
|
modules/_template/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Module Template - DO NOT USE DIRECTLY
|
| 2 |
+
# Copy this folder to create a new module
|
modules/_template/module.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Module Template for NCAkit
|
| 3 |
+
Copy this folder and rename to create a new module.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
1. Copy _template folder to modules/your_module_name/
|
| 7 |
+
2. Update MODULE_NAME, MODULE_PREFIX, MODULE_DESCRIPTION
|
| 8 |
+
3. Implement your router and services
|
| 9 |
+
4. The module will be auto-discovered on startup
|
| 10 |
+
"""
|
| 11 |
+
from fastapi import FastAPI
|
| 12 |
+
|
| 13 |
+
# ===================
|
| 14 |
+
# Module Metadata
|
| 15 |
+
# ===================
|
| 16 |
+
MODULE_NAME = "template"
|
| 17 |
+
MODULE_PREFIX = "/api/template"
|
| 18 |
+
MODULE_DESCRIPTION = "Template module - copy and modify for your feature"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def register(app: FastAPI, config):
|
| 22 |
+
"""
|
| 23 |
+
Register this module with the main FastAPI app.
|
| 24 |
+
Called automatically by module_registry.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
app: FastAPI application instance
|
| 28 |
+
config: NCAkitConfig instance with all settings
|
| 29 |
+
"""
|
| 30 |
+
from .router import router
|
| 31 |
+
|
| 32 |
+
# You can initialize services here and attach to app.state
|
| 33 |
+
# Example:
|
| 34 |
+
# from .services import MyService
|
| 35 |
+
# app.state.my_service = MyService(config)
|
| 36 |
+
|
| 37 |
+
# Register the router
|
| 38 |
+
app.include_router(router, prefix=MODULE_PREFIX, tags=[MODULE_NAME])
|
modules/_template/router.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Template Router - Define your API endpoints here
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import APIRouter
|
| 5 |
+
|
| 6 |
+
router = APIRouter()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@router.get("/")
|
| 10 |
+
async def template_root():
|
| 11 |
+
"""Example endpoint - replace with your implementation"""
|
| 12 |
+
return {"message": "Template module is working!"}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@router.get("/example")
|
| 16 |
+
async def example_endpoint():
|
| 17 |
+
"""Another example endpoint"""
|
| 18 |
+
return {"data": "This is example data"}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# Add more endpoints as needed
|
| 22 |
+
# @router.post("/create")
|
| 23 |
+
# async def create_something(request: YourRequestModel):
|
| 24 |
+
# ...
|
modules/_template/schemas.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Template Schemas - Define your Pydantic models here
|
| 3 |
+
"""
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
from typing import Optional, List
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ExampleRequest(BaseModel):
|
| 9 |
+
"""Example request model"""
|
| 10 |
+
name: str = Field(..., description="Name field")
|
| 11 |
+
value: Optional[int] = Field(None, description="Optional value")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ExampleResponse(BaseModel):
|
| 15 |
+
"""Example response model"""
|
| 16 |
+
success: bool
|
| 17 |
+
data: dict
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# Add more models as needed
|
modules/story_reels/__init__.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Story Reels Module for NCAkit
|
| 3 |
+
Character-consistent story video generation using Cloudflare AI.
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import FastAPI
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
# Module Metadata
|
| 9 |
+
MODULE_NAME = "story_reels"
|
| 10 |
+
MODULE_PREFIX = "/api/story"
|
| 11 |
+
MODULE_DESCRIPTION = "Generate character-consistent story videos from text scripts"
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def register(app: FastAPI, config):
|
| 17 |
+
"""
|
| 18 |
+
Register the story reels module with FastAPI.
|
| 19 |
+
Initializes all services and adds routes.
|
| 20 |
+
"""
|
| 21 |
+
from .router import router, set_story_creator
|
| 22 |
+
from .services.cloudflare_client import CloudflareClient
|
| 23 |
+
from .services.script_generator import ScriptGenerator
|
| 24 |
+
from .services.story_creator import StoryCreator
|
| 25 |
+
|
| 26 |
+
logger.info("Registering story_reels module...")
|
| 27 |
+
|
| 28 |
+
# Validate configs
|
| 29 |
+
|
| 30 |
+
if not config.gemini_api_key:
|
| 31 |
+
logger.warning("GEMINI_API_KEY missing! AI script generation will fail.")
|
| 32 |
+
|
| 33 |
+
# Reuse TTS client from video_creator if available
|
| 34 |
+
tts_client = getattr(app.state, 'tts_client', None)
|
| 35 |
+
whisper_client = getattr(app.state, 'whisper_client', None)
|
| 36 |
+
|
| 37 |
+
# If video_creator not loaded, initialize our own clients
|
| 38 |
+
if not tts_client:
|
| 39 |
+
logger.info("Initializing TTS client for story_reels...")
|
| 40 |
+
from modules.video_creator.services.libraries.tts_client import TTSClient
|
| 41 |
+
tts_client = TTSClient(config.hf_tts)
|
| 42 |
+
app.state.tts_client = tts_client
|
| 43 |
+
|
| 44 |
+
if not whisper_client:
|
| 45 |
+
logger.info("Initializing Whisper client for story_reels...")
|
| 46 |
+
from modules.video_creator.services.libraries.whisper_client import WhisperClient
|
| 47 |
+
whisper_client = WhisperClient(
|
| 48 |
+
model_name=config.whisper_model,
|
| 49 |
+
model_dir=config.whisper_model_dir
|
| 50 |
+
)
|
| 51 |
+
app.state.whisper_client = whisper_client
|
| 52 |
+
|
| 53 |
+
# Initialize Script Generator (Gemini)
|
| 54 |
+
logger.info("Initializing script generator (Gemini)...")
|
| 55 |
+
script_generator = ScriptGenerator(config.gemini_api_key or "")
|
| 56 |
+
|
| 57 |
+
# Initialize NVIDIA client (PRIMARY)
|
| 58 |
+
nvidia_client = None
|
| 59 |
+
if config.nvidia_api_key:
|
| 60 |
+
logger.info("Initializing NVIDIA client (primary)...")
|
| 61 |
+
from .services.nvidia_client import NvidiaClient
|
| 62 |
+
nvidia_client = NvidiaClient(config.nvidia_api_key)
|
| 63 |
+
else:
|
| 64 |
+
logger.warning("NVIDIA_API_KEY missing! Using Cloudflare only.")
|
| 65 |
+
|
| 66 |
+
# Initialize Cloudflare client (FALLBACK)
|
| 67 |
+
cloudflare_client = None
|
| 68 |
+
if config.cf_url and config.cf_api:
|
| 69 |
+
logger.info("Initializing Cloudflare client (fallback)...")
|
| 70 |
+
cloudflare_client = CloudflareClient(
|
| 71 |
+
api_url=config.cf_url,
|
| 72 |
+
api_key=config.cf_api
|
| 73 |
+
)
|
| 74 |
+
else:
|
| 75 |
+
logger.warning("CF_URL or CF_API missing! No fallback available.")
|
| 76 |
+
|
| 77 |
+
# Initialize story creator
|
| 78 |
+
logger.info("Initializing story creator...")
|
| 79 |
+
story_creator = StoryCreator(
|
| 80 |
+
config=config,
|
| 81 |
+
tts_client=tts_client,
|
| 82 |
+
whisper_client=whisper_client,
|
| 83 |
+
nvidia_client=nvidia_client,
|
| 84 |
+
cloudflare_client=cloudflare_client,
|
| 85 |
+
script_generator=script_generator
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Set the global story creator in the router
|
| 89 |
+
set_story_creator(story_creator)
|
| 90 |
+
|
| 91 |
+
# Store in app state
|
| 92 |
+
app.state.story_creator = story_creator
|
| 93 |
+
|
| 94 |
+
# Register routes
|
| 95 |
+
app.include_router(router, prefix=MODULE_PREFIX, tags=["Story Reels"])
|
| 96 |
+
|
| 97 |
+
logger.info("story_reels module registered successfully!")
|
modules/story_reels/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (3.79 kB). View file
|
|
|
modules/story_reels/__pycache__/router.cpython-313.pyc
ADDED
|
Binary file (4.27 kB). View file
|
|
|
modules/story_reels/__pycache__/schemas.cpython-313.pyc
ADDED
|
Binary file (5.59 kB). View file
|
|
|
modules/story_reels/router.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Story Reels Router - API Endpoints
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import APIRouter, HTTPException
|
| 5 |
+
from fastapi.responses import FileResponse
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
from .schemas import (
|
| 9 |
+
GenerateVideoRequest,
|
| 10 |
+
GenerateVideoResponse,
|
| 11 |
+
VideoStatusResponse,
|
| 12 |
+
PreviewResponse,
|
| 13 |
+
JobStatus
|
| 14 |
+
)
|
| 15 |
+
from .services.story_creator import StoryCreator
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
# Will be set during module registration
|
| 20 |
+
story_creator: StoryCreator = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def set_story_creator(creator: StoryCreator):
|
| 24 |
+
"""Set the global story creator instance"""
|
| 25 |
+
global story_creator
|
| 26 |
+
story_creator = creator
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
router = APIRouter()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.post("/generate",
|
| 33 |
+
response_model=GenerateVideoResponse,
|
| 34 |
+
status_code=201,
|
| 35 |
+
summary="Generate story video",
|
| 36 |
+
description="Generate a character-consistent story video from script"
|
| 37 |
+
)
|
| 38 |
+
async def generate_video(request: GenerateVideoRequest):
|
| 39 |
+
"""
|
| 40 |
+
Main video generation endpoint.
|
| 41 |
+
|
| 42 |
+
- Converts script to speech (TTS)
|
| 43 |
+
- Generates captions (Whisper)
|
| 44 |
+
- Creates character-consistent images (Cloudflare)
|
| 45 |
+
- Composes final video (MoviePy)
|
| 46 |
+
"""
|
| 47 |
+
try:
|
| 48 |
+
logger.info(f"Generating video for topic: {request.topic}")
|
| 49 |
+
|
| 50 |
+
job_id = story_creator.add_to_queue(
|
| 51 |
+
topic=request.topic,
|
| 52 |
+
script=request.script,
|
| 53 |
+
character_profile=request.character_profile,
|
| 54 |
+
voice=request.voice
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
return GenerateVideoResponse(
|
| 58 |
+
job_id=job_id,
|
| 59 |
+
status=JobStatus.queued,
|
| 60 |
+
message="Video generation started"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
except Exception as e:
|
| 64 |
+
logger.error(f"Error starting generation: {e}", exc_info=True)
|
| 65 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@router.get("/status/{job_id}",
|
| 69 |
+
response_model=VideoStatusResponse,
|
| 70 |
+
summary="Get job status",
|
| 71 |
+
description="Check the processing status of a video generation job"
|
| 72 |
+
)
|
| 73 |
+
async def get_status(job_id: str):
|
| 74 |
+
"""Get video generation status"""
|
| 75 |
+
status = story_creator.get_status(job_id)
|
| 76 |
+
return VideoStatusResponse(**status)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@router.get("/preview/{job_id}/{scene_id}",
|
| 80 |
+
response_model=PreviewResponse,
|
| 81 |
+
summary="Get scene preview",
|
| 82 |
+
description="Get preview of a generated scene image"
|
| 83 |
+
)
|
| 84 |
+
async def get_preview(job_id: str, scene_id: int):
|
| 85 |
+
"""Get scene preview"""
|
| 86 |
+
scene = story_creator.get_preview(job_id, scene_id)
|
| 87 |
+
|
| 88 |
+
if not scene:
|
| 89 |
+
raise HTTPException(status_code=404, detail="Scene not found")
|
| 90 |
+
|
| 91 |
+
return PreviewResponse(
|
| 92 |
+
scene_id=scene["scene_id"],
|
| 93 |
+
image_url=scene["image_path"],
|
| 94 |
+
prompt=scene["prompt"]
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@router.get("/download/{job_id}",
|
| 99 |
+
summary="Download video",
|
| 100 |
+
description="Download the generated video file",
|
| 101 |
+
responses={
|
| 102 |
+
200: {"description": "Video file", "content": {"video/mp4": {}}},
|
| 103 |
+
404: {"description": "Video not found"}
|
| 104 |
+
}
|
| 105 |
+
)
|
| 106 |
+
async def download_video(job_id: str):
|
| 107 |
+
"""Download generated video"""
|
| 108 |
+
video_path = story_creator.get_video_path(job_id)
|
| 109 |
+
|
| 110 |
+
if not video_path or not video_path.exists():
|
| 111 |
+
raise HTTPException(status_code=404, detail="Video not found")
|
| 112 |
+
|
| 113 |
+
return FileResponse(
|
| 114 |
+
video_path,
|
| 115 |
+
media_type="video/mp4",
|
| 116 |
+
filename=f"story_{job_id}.mp4"
|
| 117 |
+
)
|
modules/story_reels/schemas.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Story Reels Pydantic Schemas
|
| 3 |
+
Character-consistent video generation from text scripts
|
| 4 |
+
"""
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
from typing import List, Optional
|
| 7 |
+
from enum import Enum
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class StyleEnum(str, Enum):
|
| 11 |
+
"""Available image styles"""
|
| 12 |
+
semi_realistic = "semi-realistic"
|
| 13 |
+
anime = "anime"
|
| 14 |
+
cartoon = "cartoon"
|
| 15 |
+
realistic = "realistic"
|
| 16 |
+
watercolor = "watercolor"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class CameraEnum(str, Enum):
|
| 20 |
+
"""Camera shot types"""
|
| 21 |
+
close_up = "close-up"
|
| 22 |
+
medium = "medium shot"
|
| 23 |
+
wide = "wide shot"
|
| 24 |
+
side = "side view"
|
| 25 |
+
front = "front view"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class JobStatus(str, Enum):
|
| 29 |
+
"""Job processing status"""
|
| 30 |
+
queued = "queued"
|
| 31 |
+
processing = "processing"
|
| 32 |
+
generating_audio = "generating_audio"
|
| 33 |
+
generating_images = "generating_images"
|
| 34 |
+
composing_video = "composing_video"
|
| 35 |
+
ready = "ready"
|
| 36 |
+
failed = "failed"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ===================
|
| 40 |
+
# Character Profile
|
| 41 |
+
# ===================
|
| 42 |
+
|
| 43 |
+
class CharacterProfile(BaseModel):
|
| 44 |
+
"""Character definition for consistency"""
|
| 45 |
+
name: str = Field(..., description="Character name")
|
| 46 |
+
age: str = Field("25", description="Character age")
|
| 47 |
+
gender: str = Field("male", description="male/female")
|
| 48 |
+
hair: str = Field("short black hair", description="Hair description")
|
| 49 |
+
skin: str = Field("light brown", description="Skin tone")
|
| 50 |
+
face: str = Field("", description="Face features (optional)")
|
| 51 |
+
clothes: str = Field("casual clothes", description="Clothing description")
|
| 52 |
+
style: StyleEnum = Field(StyleEnum.semi_realistic, description="Art style")
|
| 53 |
+
seed: int = Field(432891, description="Fixed seed for consistency")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ===================
|
| 57 |
+
# Scene
|
| 58 |
+
# ===================
|
| 59 |
+
|
| 60 |
+
class SceneInput(BaseModel):
|
| 61 |
+
"""Scene from script segment"""
|
| 62 |
+
scene_id: int
|
| 63 |
+
scene_text: str = Field(..., description="Scene description")
|
| 64 |
+
camera: CameraEnum = Field(CameraEnum.medium, description="Camera angle")
|
| 65 |
+
pose: str = Field("standing", description="Character pose")
|
| 66 |
+
lighting: str = Field("natural light", description="Lighting description")
|
| 67 |
+
duration: float = Field(4.0, description="Scene duration in seconds")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class GeneratedScene(BaseModel):
|
| 71 |
+
"""Scene with generated content"""
|
| 72 |
+
scene_id: int
|
| 73 |
+
prompt: str
|
| 74 |
+
image_url: str
|
| 75 |
+
duration: float
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ===================
|
| 79 |
+
# API Request/Response
|
| 80 |
+
# ===================
|
| 81 |
+
|
| 82 |
+
class GenerateVideoRequest(BaseModel):
|
| 83 |
+
"""Main video generation request"""
|
| 84 |
+
topic: str = Field(..., description="Video topic/title")
|
| 85 |
+
script: str = Field("", description="Full story script (optional - auto-generated if empty)")
|
| 86 |
+
character_profile: Optional[CharacterProfile] = Field(
|
| 87 |
+
default=None,
|
| 88 |
+
description="Character profile for consistency (optional)"
|
| 89 |
+
)
|
| 90 |
+
voice: str = Field("af_heart", description="TTS voice")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class GenerateVideoResponse(BaseModel):
|
| 94 |
+
"""Response after starting generation"""
|
| 95 |
+
job_id: str
|
| 96 |
+
status: JobStatus = JobStatus.queued
|
| 97 |
+
message: str = "Video generation started"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class VideoStatusResponse(BaseModel):
|
| 101 |
+
"""Job status response"""
|
| 102 |
+
job_id: str
|
| 103 |
+
status: JobStatus
|
| 104 |
+
progress: int = Field(0, description="Progress 0-100")
|
| 105 |
+
video_url: Optional[str] = None
|
| 106 |
+
duration: Optional[float] = None
|
| 107 |
+
error: Optional[str] = None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class PreviewResponse(BaseModel):
|
| 111 |
+
"""Scene preview response"""
|
| 112 |
+
scene_id: int
|
| 113 |
+
image_url: str
|
| 114 |
+
prompt: str
|
modules/story_reels/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Story Reels Services
|
modules/story_reels/services/__pycache__/cloudflare_client.cpython-313.pyc
ADDED
|
Binary file (7.43 kB). View file
|
|
|
modules/story_reels/services/__pycache__/nvidia_client.cpython-313.pyc
ADDED
|
Binary file (9.05 kB). View file
|
|
|
modules/story_reels/services/__pycache__/prompt_builder.cpython-313.pyc
ADDED
|
Binary file (6.05 kB). View file
|
|
|
modules/story_reels/services/__pycache__/script_generator.cpython-313.pyc
ADDED
|
Binary file (9.94 kB). View file
|
|
|
modules/story_reels/services/__pycache__/srt_parser.cpython-313.pyc
ADDED
|
Binary file (8.95 kB). View file
|
|
|
modules/story_reels/services/__pycache__/story_creator.cpython-313.pyc
ADDED
|
Binary file (22 kB). View file
|
|
|
modules/story_reels/services/cloudflare_client.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cloudflare Workers AI Client
|
| 3 |
+
Text-to-image generation with character consistency
|
| 4 |
+
Uses custom Cloudflare Worker endpoint
|
| 5 |
+
"""
|
| 6 |
+
import logging
|
| 7 |
+
import time
|
| 8 |
+
import requests
|
| 9 |
+
from typing import Optional, List, Dict
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class CloudflareClient:
|
| 16 |
+
"""
|
| 17 |
+
Client for Cloudflare Workers AI image generation.
|
| 18 |
+
Uses custom worker endpoint for image generation.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
# Default model
|
| 22 |
+
DEFAULT_MODEL = "@cf/stabilityai/stable-diffusion-xl-base-1.0"
|
| 23 |
+
|
| 24 |
+
def __init__(self, api_url: str, api_key: str):
|
| 25 |
+
"""
|
| 26 |
+
Initialize Cloudflare client.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
api_url: Custom Cloudflare Worker URL (CF_URL)
|
| 30 |
+
api_key: API key for authentication (CF_API)
|
| 31 |
+
"""
|
| 32 |
+
self.api_url = api_url
|
| 33 |
+
self.api_key = api_key
|
| 34 |
+
|
| 35 |
+
def generate_image(
|
| 36 |
+
self,
|
| 37 |
+
prompt: str,
|
| 38 |
+
seed: Optional[int] = None,
|
| 39 |
+
width: int = 1080,
|
| 40 |
+
height: int = 1920,
|
| 41 |
+
quality: int = 90
|
| 42 |
+
) -> bytes:
|
| 43 |
+
"""
|
| 44 |
+
Generate image from prompt.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
prompt: Text prompt for image generation
|
| 48 |
+
seed: Fixed seed for reproducibility
|
| 49 |
+
width: Image width (9:16 portrait = 1080)
|
| 50 |
+
height: Image height (9:16 portrait = 1920)
|
| 51 |
+
quality: Image quality (1-100)
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
Image bytes (PNG format)
|
| 55 |
+
"""
|
| 56 |
+
headers = {
|
| 57 |
+
"Authorization": f"Bearer {self.api_key}",
|
| 58 |
+
"Content-Type": "application/json"
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
payload = {
|
| 62 |
+
"prompt": prompt,
|
| 63 |
+
"model": self.DEFAULT_MODEL,
|
| 64 |
+
"width": width,
|
| 65 |
+
"height": height,
|
| 66 |
+
"format": "png",
|
| 67 |
+
"quality": quality,
|
| 68 |
+
"download": True
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
# Add seed for consistency if provided
|
| 72 |
+
if seed is not None:
|
| 73 |
+
payload["seed"] = seed
|
| 74 |
+
|
| 75 |
+
logger.debug(f"Generating image with prompt: {prompt[:100]}...")
|
| 76 |
+
|
| 77 |
+
try:
|
| 78 |
+
response = requests.post(
|
| 79 |
+
self.api_url,
|
| 80 |
+
headers=headers,
|
| 81 |
+
json=payload,
|
| 82 |
+
timeout=120
|
| 83 |
+
)
|
| 84 |
+
response.raise_for_status()
|
| 85 |
+
|
| 86 |
+
# Worker returns raw image bytes
|
| 87 |
+
return response.content
|
| 88 |
+
|
| 89 |
+
except requests.exceptions.RequestException as e:
|
| 90 |
+
logger.error(f"Cloudflare API error: {e}")
|
| 91 |
+
if hasattr(e, 'response') and e.response is not None:
|
| 92 |
+
logger.error(f"Response: {e.response.text[:500]}")
|
| 93 |
+
raise Exception(f"Image generation failed: {e}")
|
| 94 |
+
|
| 95 |
+
def generate_and_save(
|
| 96 |
+
self,
|
| 97 |
+
prompt: str,
|
| 98 |
+
output_path: Path,
|
| 99 |
+
seed: Optional[int] = None,
|
| 100 |
+
**kwargs
|
| 101 |
+
) -> Path:
|
| 102 |
+
"""Generate image and save to file"""
|
| 103 |
+
image_bytes = self.generate_image(prompt, seed=seed, **kwargs)
|
| 104 |
+
|
| 105 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 106 |
+
output_path.write_bytes(image_bytes)
|
| 107 |
+
|
| 108 |
+
logger.info(f"Saved image to {output_path}")
|
| 109 |
+
return output_path
|
| 110 |
+
|
| 111 |
+
@staticmethod
|
| 112 |
+
def test_connection(api_url: str, api_key: str) -> bool:
|
| 113 |
+
"""Test API connection"""
|
| 114 |
+
try:
|
| 115 |
+
client = CloudflareClient(api_url, api_key)
|
| 116 |
+
client.generate_image("test", width=256, height=256)
|
| 117 |
+
return True
|
| 118 |
+
except Exception as e:
|
| 119 |
+
logger.error(f"Connection test failed: {e}")
|
| 120 |
+
return False
|
| 121 |
+
|
| 122 |
+
def generate_batch(
|
| 123 |
+
self,
|
| 124 |
+
prompts: List[tuple],
|
| 125 |
+
output_dir: Path,
|
| 126 |
+
seed: Optional[int] = None,
|
| 127 |
+
batch_size: int = 5,
|
| 128 |
+
delay_seconds: float = 1.0,
|
| 129 |
+
**kwargs
|
| 130 |
+
) -> List[Dict]:
|
| 131 |
+
"""
|
| 132 |
+
Generate images in batches to save API credits.
|
| 133 |
+
|
| 134 |
+
Pattern: Generate 5, wait, next 5, wait...
|
| 135 |
+
|
| 136 |
+
Args:
|
| 137 |
+
prompts: List of (prompt_id, prompt_text) tuples
|
| 138 |
+
output_dir: Directory to save images
|
| 139 |
+
seed: Fixed seed for character consistency
|
| 140 |
+
batch_size: Images per batch (default 5)
|
| 141 |
+
delay_seconds: Delay between images in batch (default 1s)
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
List of generated image info dicts
|
| 145 |
+
"""
|
| 146 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 147 |
+
generated = []
|
| 148 |
+
total = len(prompts)
|
| 149 |
+
|
| 150 |
+
# Split into batches of 5
|
| 151 |
+
for batch_start in range(0, total, batch_size):
|
| 152 |
+
batch_end = min(batch_start + batch_size, total)
|
| 153 |
+
batch = prompts[batch_start:batch_end]
|
| 154 |
+
|
| 155 |
+
logger.info(f"Processing batch {batch_start//batch_size + 1}: images {batch_start+1}-{batch_end} of {total}")
|
| 156 |
+
|
| 157 |
+
# Process each image in the batch with 1s delay
|
| 158 |
+
for i, (prompt_id, prompt_text) in enumerate(batch):
|
| 159 |
+
try:
|
| 160 |
+
output_path = output_dir / f"scene_{prompt_id:03d}.png"
|
| 161 |
+
|
| 162 |
+
# Generate and save
|
| 163 |
+
self.generate_and_save(
|
| 164 |
+
prompt=prompt_text,
|
| 165 |
+
output_path=output_path,
|
| 166 |
+
seed=seed,
|
| 167 |
+
**kwargs
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
generated.append({
|
| 171 |
+
"id": prompt_id,
|
| 172 |
+
"path": str(output_path),
|
| 173 |
+
"prompt": prompt_text
|
| 174 |
+
})
|
| 175 |
+
|
| 176 |
+
logger.debug(f"Generated image {prompt_id}/{total}")
|
| 177 |
+
|
| 178 |
+
# Delay between images (not after last one in batch)
|
| 179 |
+
if i < len(batch) - 1:
|
| 180 |
+
time.sleep(delay_seconds)
|
| 181 |
+
|
| 182 |
+
except Exception as e:
|
| 183 |
+
logger.error(f"Failed to generate image {prompt_id}: {e}")
|
| 184 |
+
generated.append({
|
| 185 |
+
"id": prompt_id,
|
| 186 |
+
"path": None,
|
| 187 |
+
"error": str(e)
|
| 188 |
+
})
|
| 189 |
+
|
| 190 |
+
# Batch complete - small pause before next batch
|
| 191 |
+
if batch_end < total:
|
| 192 |
+
logger.info(f"Batch complete. Waiting before next batch...")
|
| 193 |
+
time.sleep(delay_seconds * 2)
|
| 194 |
+
|
| 195 |
+
successful = len([g for g in generated if g.get("path")])
|
| 196 |
+
logger.info(f"Batch generation complete: {successful}/{total} images generated")
|
| 197 |
+
|
| 198 |
+
return generated
|
modules/story_reels/services/nvidia_client.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
NVIDIA Image Generation Client
|
| 3 |
+
Uses Stable Diffusion 3 Medium for high-quality 9:16 images
|
| 4 |
+
FIRST CHOICE - Falls back to Cloudflare on error
|
| 5 |
+
"""
|
| 6 |
+
import logging
|
| 7 |
+
import time
|
| 8 |
+
import requests
|
| 9 |
+
import base64
|
| 10 |
+
from typing import Optional, List, Dict
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class NvidiaClient:
|
| 17 |
+
"""
|
| 18 |
+
Client for NVIDIA AI image generation.
|
| 19 |
+
Uses stable-diffusion-3-medium with 9:16 aspect ratio.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
# Fixed model - stable-diffusion-3-medium
|
| 23 |
+
INVOKE_URL = "https://ai.api.nvidia.com/v1/genai/stabilityai/stable-diffusion-3-medium"
|
| 24 |
+
|
| 25 |
+
def __init__(self, api_key: str):
|
| 26 |
+
"""
|
| 27 |
+
Initialize NVIDIA client.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
api_key: NVIDIA API key (nvapi-xxx)
|
| 31 |
+
"""
|
| 32 |
+
self.api_key = api_key
|
| 33 |
+
self.headers = {
|
| 34 |
+
"Authorization": f"Bearer {api_key}",
|
| 35 |
+
"Accept": "application/json",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
def generate_image(
|
| 39 |
+
self,
|
| 40 |
+
prompt: str,
|
| 41 |
+
seed: int = 0,
|
| 42 |
+
steps: int = 50,
|
| 43 |
+
cfg_scale: float = 5
|
| 44 |
+
) -> bytes:
|
| 45 |
+
"""
|
| 46 |
+
Generate image from prompt.
|
| 47 |
+
|
| 48 |
+
Args:
|
| 49 |
+
prompt: Text prompt for image generation
|
| 50 |
+
seed: Random seed for reproducibility
|
| 51 |
+
steps: Number of diffusion steps (default 50)
|
| 52 |
+
cfg_scale: Guidance scale (default 5)
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
Image bytes (PNG format)
|
| 56 |
+
"""
|
| 57 |
+
# Stable Diffusion 3 Medium payload - 9:16 aspect ratio
|
| 58 |
+
payload = {
|
| 59 |
+
"prompt": prompt,
|
| 60 |
+
"cfg_scale": cfg_scale,
|
| 61 |
+
"aspect_ratio": "9:16", # Portrait for reels
|
| 62 |
+
"seed": seed,
|
| 63 |
+
"steps": steps,
|
| 64 |
+
"negative_prompt": ""
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
logger.debug(f"NVIDIA generating image with prompt: {prompt[:100]}...")
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
response = requests.post(
|
| 71 |
+
self.INVOKE_URL,
|
| 72 |
+
headers=self.headers,
|
| 73 |
+
json=payload,
|
| 74 |
+
timeout=120
|
| 75 |
+
)
|
| 76 |
+
response.raise_for_status()
|
| 77 |
+
response_body = response.json()
|
| 78 |
+
|
| 79 |
+
# Extract base64 - handle multiple response formats
|
| 80 |
+
image_b64 = None
|
| 81 |
+
|
| 82 |
+
# Format 1: Direct image field
|
| 83 |
+
if isinstance(response_body, dict) and "image" in response_body:
|
| 84 |
+
image_b64 = response_body["image"]
|
| 85 |
+
|
| 86 |
+
# Format 2: Artifacts array with base64 field
|
| 87 |
+
elif isinstance(response_body, dict) and "artifacts" in response_body:
|
| 88 |
+
artifacts = response_body.get("artifacts")
|
| 89 |
+
if artifacts and isinstance(artifacts, list) and len(artifacts) > 0:
|
| 90 |
+
image_b64 = artifacts[0].get("base64")
|
| 91 |
+
|
| 92 |
+
# Format 3: Array with image_b64 field
|
| 93 |
+
elif isinstance(response_body, list) and len(response_body) > 0:
|
| 94 |
+
if "image_b64" in response_body[0]:
|
| 95 |
+
image_b64 = response_body[0].get("image_b64")
|
| 96 |
+
elif "base64" in response_body[0]:
|
| 97 |
+
image_b64 = response_body[0].get("base64")
|
| 98 |
+
|
| 99 |
+
if image_b64:
|
| 100 |
+
# Decode base64 to bytes
|
| 101 |
+
image_data = base64.b64decode(image_b64)
|
| 102 |
+
logger.info(f"NVIDIA image generated successfully")
|
| 103 |
+
return image_data
|
| 104 |
+
else:
|
| 105 |
+
logger.error(f"NVIDIA: Could not find image data. Keys: {response_body.keys() if isinstance(response_body, dict) else 'list'}")
|
| 106 |
+
raise Exception("No image data in NVIDIA response")
|
| 107 |
+
|
| 108 |
+
except requests.exceptions.RequestException as e:
|
| 109 |
+
logger.error(f"NVIDIA API error: {e}")
|
| 110 |
+
raise Exception(f"NVIDIA image generation failed: {e}")
|
| 111 |
+
|
| 112 |
+
def generate_and_save(
|
| 113 |
+
self,
|
| 114 |
+
prompt: str,
|
| 115 |
+
output_path: Path,
|
| 116 |
+
seed: int = 0,
|
| 117 |
+
**kwargs
|
| 118 |
+
) -> Path:
|
| 119 |
+
"""Generate image and save to file"""
|
| 120 |
+
image_bytes = self.generate_image(prompt, seed=seed, **kwargs)
|
| 121 |
+
|
| 122 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 123 |
+
output_path.write_bytes(image_bytes)
|
| 124 |
+
|
| 125 |
+
logger.info(f"Saved NVIDIA image to {output_path}")
|
| 126 |
+
return output_path
|
| 127 |
+
|
| 128 |
+
@staticmethod
|
| 129 |
+
def test_connection(api_key: str) -> bool:
|
| 130 |
+
"""Test API connection"""
|
| 131 |
+
try:
|
| 132 |
+
client = NvidiaClient(api_key)
|
| 133 |
+
client.generate_image("test", steps=10)
|
| 134 |
+
return True
|
| 135 |
+
except Exception as e:
|
| 136 |
+
logger.error(f"NVIDIA connection test failed: {e}")
|
| 137 |
+
return False
|
| 138 |
+
|
| 139 |
+
def generate_batch(
|
| 140 |
+
self,
|
| 141 |
+
prompts: List[tuple],
|
| 142 |
+
output_dir: Path,
|
| 143 |
+
seed: int = 0,
|
| 144 |
+
batch_size: int = 5,
|
| 145 |
+
delay_seconds: float = 1.0,
|
| 146 |
+
fallback_client=None,
|
| 147 |
+
**kwargs
|
| 148 |
+
) -> List[Dict]:
|
| 149 |
+
"""
|
| 150 |
+
Generate images in batches with fallback support.
|
| 151 |
+
|
| 152 |
+
Pattern: Generate 5, wait, next 5...
|
| 153 |
+
If NVIDIA fails, try Cloudflare fallback.
|
| 154 |
+
|
| 155 |
+
Args:
|
| 156 |
+
prompts: List of (prompt_id, prompt_text) tuples
|
| 157 |
+
output_dir: Directory to save images
|
| 158 |
+
seed: Fixed seed for character consistency
|
| 159 |
+
batch_size: Images per batch (default 5)
|
| 160 |
+
delay_seconds: Delay between images (default 1s)
|
| 161 |
+
fallback_client: Cloudflare client for fallback
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
List of generated image info dicts
|
| 165 |
+
"""
|
| 166 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 167 |
+
generated = []
|
| 168 |
+
total = len(prompts)
|
| 169 |
+
|
| 170 |
+
for batch_start in range(0, total, batch_size):
|
| 171 |
+
batch_end = min(batch_start + batch_size, total)
|
| 172 |
+
batch = prompts[batch_start:batch_end]
|
| 173 |
+
|
| 174 |
+
logger.info(f"NVIDIA batch {batch_start//batch_size + 1}: images {batch_start+1}-{batch_end} of {total}")
|
| 175 |
+
|
| 176 |
+
for i, (prompt_id, prompt_text) in enumerate(batch):
|
| 177 |
+
output_path = output_dir / f"scene_{prompt_id:03d}.png"
|
| 178 |
+
success = False
|
| 179 |
+
|
| 180 |
+
# Try NVIDIA first
|
| 181 |
+
try:
|
| 182 |
+
self.generate_and_save(
|
| 183 |
+
prompt=prompt_text,
|
| 184 |
+
output_path=output_path,
|
| 185 |
+
seed=seed,
|
| 186 |
+
**kwargs
|
| 187 |
+
)
|
| 188 |
+
success = True
|
| 189 |
+
logger.debug(f"NVIDIA: Generated image {prompt_id}/{total}")
|
| 190 |
+
|
| 191 |
+
except Exception as e:
|
| 192 |
+
logger.warning(f"NVIDIA failed for image {prompt_id}: {e}")
|
| 193 |
+
|
| 194 |
+
# Fallback to Cloudflare
|
| 195 |
+
if fallback_client:
|
| 196 |
+
try:
|
| 197 |
+
logger.info(f"Falling back to Cloudflare for image {prompt_id}")
|
| 198 |
+
fallback_client.generate_and_save(
|
| 199 |
+
prompt=prompt_text,
|
| 200 |
+
output_path=output_path,
|
| 201 |
+
seed=seed,
|
| 202 |
+
width=1080,
|
| 203 |
+
height=1920
|
| 204 |
+
)
|
| 205 |
+
success = True
|
| 206 |
+
logger.info(f"Cloudflare fallback successful for image {prompt_id}")
|
| 207 |
+
except Exception as cf_e:
|
| 208 |
+
logger.error(f"Cloudflare fallback also failed: {cf_e}")
|
| 209 |
+
|
| 210 |
+
if success:
|
| 211 |
+
generated.append({
|
| 212 |
+
"id": prompt_id,
|
| 213 |
+
"path": str(output_path),
|
| 214 |
+
"prompt": prompt_text
|
| 215 |
+
})
|
| 216 |
+
else:
|
| 217 |
+
generated.append({
|
| 218 |
+
"id": prompt_id,
|
| 219 |
+
"path": None,
|
| 220 |
+
"error": "Both NVIDIA and Cloudflare failed"
|
| 221 |
+
})
|
| 222 |
+
|
| 223 |
+
# Delay between images
|
| 224 |
+
if i < len(batch) - 1:
|
| 225 |
+
time.sleep(delay_seconds)
|
| 226 |
+
|
| 227 |
+
# Batch complete - pause before next
|
| 228 |
+
if batch_end < total:
|
| 229 |
+
logger.info("Batch complete. Waiting before next batch...")
|
| 230 |
+
time.sleep(delay_seconds * 2)
|
| 231 |
+
|
| 232 |
+
successful = len([g for g in generated if g.get("path")])
|
| 233 |
+
logger.info(f"Batch complete: {successful}/{total} images generated")
|
| 234 |
+
|
| 235 |
+
return generated
|
modules/story_reels/services/prompt_builder.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prompt Builder for Character-Consistent Images
|
| 3 |
+
Builds detailed prompts with character profile injection
|
| 4 |
+
"""
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Optional, List
|
| 7 |
+
from ..schemas import CharacterProfile, SceneInput, CameraEnum
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class PromptBuilder:
|
| 13 |
+
"""
|
| 14 |
+
Builds prompts for image generation with character consistency.
|
| 15 |
+
|
| 16 |
+
Strategy:
|
| 17 |
+
1. Fixed character description in every prompt
|
| 18 |
+
2. Same seed across all images
|
| 19 |
+
3. Consistency keywords
|
| 20 |
+
4. Style anchor
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
# Consistency keywords to add
|
| 24 |
+
CONSISTENCY_KEYWORDS = [
|
| 25 |
+
"same character throughout",
|
| 26 |
+
"consistent appearance",
|
| 27 |
+
"consistent face",
|
| 28 |
+
"character reference"
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
def __init__(self, character_profile: Optional[CharacterProfile] = None):
|
| 32 |
+
self.character = character_profile
|
| 33 |
+
|
| 34 |
+
def build_character_description(self) -> str:
|
| 35 |
+
"""Build detailed character description string"""
|
| 36 |
+
if not self.character:
|
| 37 |
+
return ""
|
| 38 |
+
|
| 39 |
+
parts = []
|
| 40 |
+
|
| 41 |
+
# Name and basics
|
| 42 |
+
if self.character.name:
|
| 43 |
+
parts.append(f"a character named {self.character.name}")
|
| 44 |
+
|
| 45 |
+
# Age and gender
|
| 46 |
+
if self.character.age and self.character.gender:
|
| 47 |
+
parts.append(f"{self.character.age} year old {self.character.gender}")
|
| 48 |
+
|
| 49 |
+
# Physical features
|
| 50 |
+
if self.character.hair:
|
| 51 |
+
parts.append(self.character.hair)
|
| 52 |
+
if self.character.skin:
|
| 53 |
+
parts.append(f"{self.character.skin} skin")
|
| 54 |
+
if self.character.face:
|
| 55 |
+
parts.append(self.character.face)
|
| 56 |
+
|
| 57 |
+
# Clothing
|
| 58 |
+
if self.character.clothes:
|
| 59 |
+
parts.append(f"wearing {self.character.clothes}")
|
| 60 |
+
|
| 61 |
+
return ", ".join(parts)
|
| 62 |
+
|
| 63 |
+
def build_scene_prompt(self, scene: SceneInput) -> str:
|
| 64 |
+
"""
|
| 65 |
+
Build full prompt for a scene.
|
| 66 |
+
|
| 67 |
+
Format:
|
| 68 |
+
[style], [character description], [scene text], [camera], [lighting], [consistency keywords]
|
| 69 |
+
"""
|
| 70 |
+
parts = []
|
| 71 |
+
|
| 72 |
+
# 1. Style anchor (important for consistency)
|
| 73 |
+
if self.character and self.character.style:
|
| 74 |
+
parts.append(f"{self.character.style.value} style artwork")
|
| 75 |
+
else:
|
| 76 |
+
parts.append("semi-realistic style artwork")
|
| 77 |
+
|
| 78 |
+
# 2. Character description (injected for consistency)
|
| 79 |
+
char_desc = self.build_character_description()
|
| 80 |
+
if char_desc:
|
| 81 |
+
parts.append(char_desc)
|
| 82 |
+
|
| 83 |
+
# 3. Scene description
|
| 84 |
+
parts.append(scene.scene_text)
|
| 85 |
+
|
| 86 |
+
# 4. Camera angle
|
| 87 |
+
parts.append(scene.camera.value)
|
| 88 |
+
|
| 89 |
+
# 5. Pose if specified
|
| 90 |
+
if scene.pose:
|
| 91 |
+
parts.append(f"{scene.pose} pose")
|
| 92 |
+
|
| 93 |
+
# 6. Lighting
|
| 94 |
+
if scene.lighting:
|
| 95 |
+
parts.append(scene.lighting)
|
| 96 |
+
|
| 97 |
+
# 7. Consistency keywords
|
| 98 |
+
parts.extend(self.CONSISTENCY_KEYWORDS[:2])
|
| 99 |
+
|
| 100 |
+
# 8. Quality keywords
|
| 101 |
+
parts.extend([
|
| 102 |
+
"high quality",
|
| 103 |
+
"detailed",
|
| 104 |
+
"professional illustration"
|
| 105 |
+
])
|
| 106 |
+
|
| 107 |
+
prompt = ", ".join(parts)
|
| 108 |
+
logger.debug(f"Built prompt: {prompt[:150]}...")
|
| 109 |
+
|
| 110 |
+
return prompt
|
| 111 |
+
|
| 112 |
+
def build_prompts_for_scenes(self, scenes: List[SceneInput]) -> List[str]:
|
| 113 |
+
"""Build prompts for all scenes"""
|
| 114 |
+
return [self.build_scene_prompt(scene) for scene in scenes]
|
| 115 |
+
|
| 116 |
+
@staticmethod
|
| 117 |
+
def create_scenes_from_segments(
|
| 118 |
+
segments: List[dict],
|
| 119 |
+
default_camera: CameraEnum = CameraEnum.medium
|
| 120 |
+
) -> List[SceneInput]:
|
| 121 |
+
"""
|
| 122 |
+
Create SceneInput objects from SRT segments.
|
| 123 |
+
|
| 124 |
+
Args:
|
| 125 |
+
segments: List of {text, start_ms, end_ms, duration}
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
List of SceneInput objects
|
| 129 |
+
"""
|
| 130 |
+
scenes = []
|
| 131 |
+
|
| 132 |
+
for i, seg in enumerate(segments):
|
| 133 |
+
duration = seg.get('duration', 4.0)
|
| 134 |
+
if isinstance(duration, int):
|
| 135 |
+
duration = duration / 1000 # Convert ms to seconds
|
| 136 |
+
|
| 137 |
+
scene = SceneInput(
|
| 138 |
+
scene_id=i + 1,
|
| 139 |
+
scene_text=seg.get('text', ''),
|
| 140 |
+
camera=default_camera,
|
| 141 |
+
pose="natural pose",
|
| 142 |
+
lighting="natural lighting",
|
| 143 |
+
duration=duration
|
| 144 |
+
)
|
| 145 |
+
scenes.append(scene)
|
| 146 |
+
|
| 147 |
+
return scenes
|
modules/story_reels/services/script_generator.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Script Generator using Gemini API
|
| 3 |
+
Generates story scripts from topics for TTS narration
|
| 4 |
+
"""
|
| 5 |
+
import logging
|
| 6 |
+
import requests
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ScriptGenerator:
|
| 13 |
+
"""
|
| 14 |
+
Generates story scripts using Google Gemini API.
|
| 15 |
+
|
| 16 |
+
Features:
|
| 17 |
+
- Topic → Full narration script (<=1000 chars)
|
| 18 |
+
- Character-aware script generation
|
| 19 |
+
- Optimized for TTS output
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"
|
| 23 |
+
|
| 24 |
+
# System prompt for script generation
|
| 25 |
+
SYSTEM_PROMPT = """You are a professional script writer for short-form video content (TikTok, Reels, Shorts).
|
| 26 |
+
|
| 27 |
+
RULES:
|
| 28 |
+
1. Write a narration script for the given topic
|
| 29 |
+
2. Maximum 1000 characters (STRICT LIMIT)
|
| 30 |
+
3. Write in a natural, engaging voice
|
| 31 |
+
4. Focus on storytelling - beginning, middle, end
|
| 32 |
+
5. Use simple, clear sentences for TTS
|
| 33 |
+
6. NO emojis, NO hashtags, NO special formatting
|
| 34 |
+
7. Output ONLY the script text, nothing else
|
| 35 |
+
|
| 36 |
+
If a character is provided, write the story from their perspective or about them."""
|
| 37 |
+
|
| 38 |
+
def __init__(self, api_key: str):
|
| 39 |
+
self.api_key = api_key
|
| 40 |
+
|
| 41 |
+
def generate_script(
|
| 42 |
+
self,
|
| 43 |
+
topic: str,
|
| 44 |
+
character_name: Optional[str] = None,
|
| 45 |
+
max_chars: int = 1000
|
| 46 |
+
) -> str:
|
| 47 |
+
"""
|
| 48 |
+
Generate a story script from topic.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
topic: Story topic/idea
|
| 52 |
+
character_name: Optional character name to include
|
| 53 |
+
max_chars: Maximum character limit (default 1000)
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
Generated script text
|
| 57 |
+
"""
|
| 58 |
+
# Build the prompt
|
| 59 |
+
user_prompt = f"Topic: {topic}"
|
| 60 |
+
|
| 61 |
+
if character_name:
|
| 62 |
+
user_prompt += f"\nMain Character: {character_name}"
|
| 63 |
+
|
| 64 |
+
user_prompt += f"\n\nWrite a short narration script (max {max_chars} characters)."
|
| 65 |
+
|
| 66 |
+
logger.info(f"Generating script for topic: {topic[:50]}...")
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
response = requests.post(
|
| 70 |
+
f"{self.GEMINI_API_URL}?key={self.api_key}",
|
| 71 |
+
headers={"Content-Type": "application/json"},
|
| 72 |
+
json={
|
| 73 |
+
"contents": [
|
| 74 |
+
{
|
| 75 |
+
"role": "user",
|
| 76 |
+
"parts": [{"text": self.SYSTEM_PROMPT + "\n\n" + user_prompt}]
|
| 77 |
+
}
|
| 78 |
+
],
|
| 79 |
+
"generationConfig": {
|
| 80 |
+
"temperature": 0.7,
|
| 81 |
+
"maxOutputTokens": 500,
|
| 82 |
+
"topP": 0.9
|
| 83 |
+
}
|
| 84 |
+
},
|
| 85 |
+
timeout=30
|
| 86 |
+
)
|
| 87 |
+
response.raise_for_status()
|
| 88 |
+
|
| 89 |
+
data = response.json()
|
| 90 |
+
|
| 91 |
+
# Extract text from response
|
| 92 |
+
script = data["candidates"][0]["content"]["parts"][0]["text"]
|
| 93 |
+
|
| 94 |
+
# Enforce character limit
|
| 95 |
+
if len(script) > max_chars:
|
| 96 |
+
script = script[:max_chars].rsplit(' ', 1)[0] + "."
|
| 97 |
+
|
| 98 |
+
logger.info(f"Generated script: {len(script)} chars")
|
| 99 |
+
return script.strip()
|
| 100 |
+
|
| 101 |
+
except requests.exceptions.RequestException as e:
|
| 102 |
+
logger.error(f"Gemini API error: {e}")
|
| 103 |
+
raise Exception(f"Script generation failed: {e}")
|
| 104 |
+
except (KeyError, IndexError) as e:
|
| 105 |
+
logger.error(f"Failed to parse Gemini response: {e}")
|
| 106 |
+
raise Exception("Invalid response from Gemini API")
|
| 107 |
+
|
| 108 |
+
@staticmethod
|
| 109 |
+
def test_connection(api_key: str) -> bool:
|
| 110 |
+
"""Test API connection"""
|
| 111 |
+
try:
|
| 112 |
+
gen = ScriptGenerator(api_key)
|
| 113 |
+
gen.generate_script("test", max_chars=50)
|
| 114 |
+
return True
|
| 115 |
+
except:
|
| 116 |
+
return False
|
| 117 |
+
|
| 118 |
+
# System prompt for image prompt generation
|
| 119 |
+
IMAGE_PROMPT_SYSTEM = """You are an expert at creating detailed image prompts for AI image generation.
|
| 120 |
+
|
| 121 |
+
Your task: Generate detailed image prompts for each 2-second scene of a story video.
|
| 122 |
+
|
| 123 |
+
CONTEXT:
|
| 124 |
+
- Full story script is provided so you understand the narrative
|
| 125 |
+
- Each 2-second chunk needs a visual prompt
|
| 126 |
+
- Character profile (if provided) must be consistent in EVERY prompt
|
| 127 |
+
- Images should tell the story visually
|
| 128 |
+
|
| 129 |
+
RULES FOR PROMPTS:
|
| 130 |
+
1. Be detailed and specific (50-100 words each)
|
| 131 |
+
2. Include: scene description, character pose/action, camera angle, lighting, mood
|
| 132 |
+
3. Add style keywords at the end (semi-realistic, detailed, high quality)
|
| 133 |
+
4. DO NOT include text/dialogue in prompts
|
| 134 |
+
5. Keep character appearance CONSISTENT across all prompts
|
| 135 |
+
6. Use cinematographic language (close-up, wide shot, etc.)
|
| 136 |
+
|
| 137 |
+
OUTPUT FORMAT:
|
| 138 |
+
Return ONLY valid JSON array, no markdown, no explanation:
|
| 139 |
+
[
|
| 140 |
+
{"chunk_id": 1, "prompt": "detailed prompt here..."},
|
| 141 |
+
{"chunk_id": 2, "prompt": "detailed prompt here..."}
|
| 142 |
+
]"""
|
| 143 |
+
|
| 144 |
+
def generate_image_prompts(
|
| 145 |
+
self,
|
| 146 |
+
full_script: str,
|
| 147 |
+
chunks: list,
|
| 148 |
+
character_profile: dict = None,
|
| 149 |
+
max_batch: int = 30
|
| 150 |
+
) -> list:
|
| 151 |
+
"""
|
| 152 |
+
Generate detailed image prompts for all 2-second chunks.
|
| 153 |
+
|
| 154 |
+
Args:
|
| 155 |
+
full_script: Complete narration script (for context)
|
| 156 |
+
chunks: List of {chunk_id, text, duration} from SRTParser
|
| 157 |
+
character_profile: Optional character dict
|
| 158 |
+
max_batch: Max chunks per API call (default 30)
|
| 159 |
+
|
| 160 |
+
Returns:
|
| 161 |
+
List of {chunk_id, prompt} dicts
|
| 162 |
+
"""
|
| 163 |
+
import json
|
| 164 |
+
|
| 165 |
+
all_prompts = []
|
| 166 |
+
total_chunks = len(chunks)
|
| 167 |
+
|
| 168 |
+
# Split into batches if too many chunks
|
| 169 |
+
for batch_start in range(0, total_chunks, max_batch):
|
| 170 |
+
batch_end = min(batch_start + max_batch, total_chunks)
|
| 171 |
+
batch_chunks = chunks[batch_start:batch_end]
|
| 172 |
+
|
| 173 |
+
logger.info(f"Generating prompts for chunks {batch_start+1}-{batch_end} of {total_chunks}")
|
| 174 |
+
|
| 175 |
+
# Build user prompt
|
| 176 |
+
user_prompt = f"""FULL STORY SCRIPT:
|
| 177 |
+
{full_script}
|
| 178 |
+
|
| 179 |
+
"""
|
| 180 |
+
if character_profile:
|
| 181 |
+
user_prompt += f"""CHARACTER PROFILE:
|
| 182 |
+
- Name: {character_profile.get('name', 'Main character')}
|
| 183 |
+
- Age: {character_profile.get('age', '25')}
|
| 184 |
+
- Gender: {character_profile.get('gender', 'male')}
|
| 185 |
+
- Hair: {character_profile.get('hair', 'short black hair')}
|
| 186 |
+
- Skin: {character_profile.get('skin', 'light skin')}
|
| 187 |
+
- Clothes: {character_profile.get('clothes', 'casual clothes')}
|
| 188 |
+
- Style: {character_profile.get('style', 'semi-realistic')}
|
| 189 |
+
|
| 190 |
+
IMPORTANT: Include this character description in EVERY prompt!
|
| 191 |
+
|
| 192 |
+
"""
|
| 193 |
+
|
| 194 |
+
user_prompt += "2-SECOND CHUNKS TO GENERATE PROMPTS FOR:\n"
|
| 195 |
+
for chunk in batch_chunks:
|
| 196 |
+
user_prompt += f"- Chunk {chunk['chunk_id']}: \"{chunk['text']}\"\n"
|
| 197 |
+
|
| 198 |
+
user_prompt += "\nGenerate detailed image prompts for each chunk. Return ONLY JSON array."
|
| 199 |
+
|
| 200 |
+
try:
|
| 201 |
+
response = requests.post(
|
| 202 |
+
f"{self.GEMINI_API_URL}?key={self.api_key}",
|
| 203 |
+
headers={"Content-Type": "application/json"},
|
| 204 |
+
json={
|
| 205 |
+
"contents": [
|
| 206 |
+
{
|
| 207 |
+
"role": "user",
|
| 208 |
+
"parts": [{"text": self.IMAGE_PROMPT_SYSTEM + "\n\n" + user_prompt}]
|
| 209 |
+
}
|
| 210 |
+
],
|
| 211 |
+
"generationConfig": {
|
| 212 |
+
"temperature": 0.7,
|
| 213 |
+
"maxOutputTokens": 4000,
|
| 214 |
+
"topP": 0.9
|
| 215 |
+
}
|
| 216 |
+
},
|
| 217 |
+
timeout=60
|
| 218 |
+
)
|
| 219 |
+
response.raise_for_status()
|
| 220 |
+
|
| 221 |
+
data = response.json()
|
| 222 |
+
text = data["candidates"][0]["content"]["parts"][0]["text"]
|
| 223 |
+
|
| 224 |
+
# Clean response - remove markdown if present
|
| 225 |
+
text = text.strip()
|
| 226 |
+
if text.startswith("```"):
|
| 227 |
+
text = text.split("```")[1]
|
| 228 |
+
if text.startswith("json"):
|
| 229 |
+
text = text[4:]
|
| 230 |
+
text = text.strip()
|
| 231 |
+
|
| 232 |
+
# Parse JSON
|
| 233 |
+
batch_prompts = json.loads(text)
|
| 234 |
+
all_prompts.extend(batch_prompts)
|
| 235 |
+
|
| 236 |
+
logger.info(f"Generated {len(batch_prompts)} prompts in batch")
|
| 237 |
+
|
| 238 |
+
except json.JSONDecodeError as e:
|
| 239 |
+
logger.error(f"Failed to parse JSON response: {e}")
|
| 240 |
+
# Fallback: create simple prompts
|
| 241 |
+
for chunk in batch_chunks:
|
| 242 |
+
all_prompts.append({
|
| 243 |
+
"chunk_id": chunk["chunk_id"],
|
| 244 |
+
"prompt": f"{chunk['text']}, semi-realistic style, high quality, detailed"
|
| 245 |
+
})
|
| 246 |
+
except Exception as e:
|
| 247 |
+
logger.error(f"Gemini API error: {e}")
|
| 248 |
+
# Fallback
|
| 249 |
+
for chunk in batch_chunks:
|
| 250 |
+
all_prompts.append({
|
| 251 |
+
"chunk_id": chunk["chunk_id"],
|
| 252 |
+
"prompt": f"{chunk['text']}, semi-realistic style, high quality"
|
| 253 |
+
})
|
| 254 |
+
|
| 255 |
+
logger.info(f"Generated {len(all_prompts)} total image prompts")
|
| 256 |
+
return all_prompts
|
modules/story_reels/services/srt_parser.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SRT Parser for Story Reels
|
| 3 |
+
Parses SRT segments and calculates scene durations
|
| 4 |
+
"""
|
| 5 |
+
import re
|
| 6 |
+
import logging
|
| 7 |
+
from typing import List, Dict
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import math
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class SRTParser:
|
| 15 |
+
"""
|
| 16 |
+
Parses SRT files and calculates image counts based on 2s rule.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
# SRT timestamp regex
|
| 20 |
+
TIMESTAMP_PATTERN = re.compile(
|
| 21 |
+
r'(\d{2}):(\d{2}):(\d{2}),(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2}),(\d{3})'
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
@staticmethod
|
| 25 |
+
def parse_timestamp(h: str, m: str, s: str, ms: str) -> int:
|
| 26 |
+
"""Convert timestamp to milliseconds"""
|
| 27 |
+
return int(h) * 3600000 + int(m) * 60000 + int(s) * 1000 + int(ms)
|
| 28 |
+
|
| 29 |
+
@classmethod
|
| 30 |
+
def parse_srt_content(cls, srt_content: str) -> List[Dict]:
|
| 31 |
+
"""
|
| 32 |
+
Parse SRT content into segments.
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
List of {text, start_ms, end_ms, duration_ms, image_count}
|
| 36 |
+
"""
|
| 37 |
+
segments = []
|
| 38 |
+
blocks = srt_content.strip().split('\n\n')
|
| 39 |
+
|
| 40 |
+
for block in blocks:
|
| 41 |
+
lines = block.strip().split('\n')
|
| 42 |
+
if len(lines) < 3:
|
| 43 |
+
continue
|
| 44 |
+
|
| 45 |
+
# Skip sequence number (line 0)
|
| 46 |
+
timestamp_line = lines[1]
|
| 47 |
+
text_lines = lines[2:]
|
| 48 |
+
|
| 49 |
+
match = cls.TIMESTAMP_PATTERN.match(timestamp_line)
|
| 50 |
+
if not match:
|
| 51 |
+
continue
|
| 52 |
+
|
| 53 |
+
start_ms = cls.parse_timestamp(*match.groups()[:4])
|
| 54 |
+
end_ms = cls.parse_timestamp(*match.groups()[4:])
|
| 55 |
+
duration_ms = end_ms - start_ms
|
| 56 |
+
|
| 57 |
+
# Calculate image count (2 seconds per image)
|
| 58 |
+
duration_s = duration_ms / 1000
|
| 59 |
+
image_count = max(1, math.ceil(duration_s / 2))
|
| 60 |
+
|
| 61 |
+
segments.append({
|
| 62 |
+
'text': ' '.join(text_lines),
|
| 63 |
+
'start_ms': start_ms,
|
| 64 |
+
'end_ms': end_ms,
|
| 65 |
+
'duration_ms': duration_ms,
|
| 66 |
+
'duration': duration_s,
|
| 67 |
+
'image_count': image_count
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
logger.info(f"Parsed {len(segments)} SRT segments")
|
| 71 |
+
return segments
|
| 72 |
+
|
| 73 |
+
@classmethod
|
| 74 |
+
def parse_srt_file(cls, srt_path: Path) -> List[Dict]:
|
| 75 |
+
"""Parse SRT file"""
|
| 76 |
+
content = srt_path.read_text(encoding='utf-8')
|
| 77 |
+
return cls.parse_srt_content(content)
|
| 78 |
+
|
| 79 |
+
@staticmethod
|
| 80 |
+
def calculate_total_images(segments: List[Dict]) -> int:
|
| 81 |
+
"""Calculate total images needed"""
|
| 82 |
+
return sum(seg.get('image_count', 1) for seg in segments)
|
| 83 |
+
|
| 84 |
+
@staticmethod
|
| 85 |
+
def calculate_total_duration(segments: List[Dict]) -> float:
|
| 86 |
+
"""Calculate total duration in seconds"""
|
| 87 |
+
return sum(seg.get('duration', 0) for seg in segments)
|
| 88 |
+
|
| 89 |
+
@classmethod
|
| 90 |
+
def segments_from_captions(cls, captions: List[Dict]) -> List[Dict]:
|
| 91 |
+
"""
|
| 92 |
+
Convert Whisper captions to segments.
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
captions: List from WhisperClient [{text, startMs, endMs}]
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
Segments with image_count calculated
|
| 99 |
+
"""
|
| 100 |
+
segments = []
|
| 101 |
+
|
| 102 |
+
for cap in captions:
|
| 103 |
+
start_ms = cap.get('startMs', 0)
|
| 104 |
+
end_ms = cap.get('endMs', 0)
|
| 105 |
+
duration_ms = end_ms - start_ms
|
| 106 |
+
duration_s = duration_ms / 1000
|
| 107 |
+
|
| 108 |
+
segments.append({
|
| 109 |
+
'text': cap.get('text', ''),
|
| 110 |
+
'start_ms': start_ms,
|
| 111 |
+
'end_ms': end_ms,
|
| 112 |
+
'duration_ms': duration_ms,
|
| 113 |
+
'duration': duration_s,
|
| 114 |
+
'image_count': max(1, math.ceil(duration_s / 2))
|
| 115 |
+
})
|
| 116 |
+
|
| 117 |
+
return segments
|
| 118 |
+
|
| 119 |
+
@classmethod
|
| 120 |
+
def create_2s_chunks(cls, captions: List[Dict], total_duration: float) -> List[Dict]:
|
| 121 |
+
"""
|
| 122 |
+
Create 2-second chunks for image generation.
|
| 123 |
+
|
| 124 |
+
This is SEPARATE from .srt captions:
|
| 125 |
+
- .srt = original Whisper captions (for video subtitles)
|
| 126 |
+
- 2s chunks = for image prompt generation
|
| 127 |
+
|
| 128 |
+
Args:
|
| 129 |
+
captions: Original Whisper captions
|
| 130 |
+
total_duration: Total audio duration in seconds
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
List of 2-second chunks with text for image prompts
|
| 134 |
+
"""
|
| 135 |
+
# Flatten all caption text with timing
|
| 136 |
+
all_words = []
|
| 137 |
+
for cap in captions:
|
| 138 |
+
start_ms = cap.get('startMs', 0)
|
| 139 |
+
end_ms = cap.get('endMs', 0)
|
| 140 |
+
text = cap.get('text', '').strip()
|
| 141 |
+
if text:
|
| 142 |
+
all_words.append({
|
| 143 |
+
'text': text,
|
| 144 |
+
'start_ms': start_ms,
|
| 145 |
+
'end_ms': end_ms
|
| 146 |
+
})
|
| 147 |
+
|
| 148 |
+
# Calculate number of 2-second chunks
|
| 149 |
+
num_chunks = max(1, math.ceil(total_duration / 2))
|
| 150 |
+
chunk_duration_ms = 2000 # 2 seconds
|
| 151 |
+
|
| 152 |
+
chunks = []
|
| 153 |
+
|
| 154 |
+
for i in range(num_chunks):
|
| 155 |
+
chunk_start = i * chunk_duration_ms
|
| 156 |
+
chunk_end = min((i + 1) * chunk_duration_ms, int(total_duration * 1000))
|
| 157 |
+
|
| 158 |
+
# Last chunk might be shorter
|
| 159 |
+
actual_duration = (chunk_end - chunk_start) / 1000
|
| 160 |
+
|
| 161 |
+
# Find words that fall within this chunk
|
| 162 |
+
chunk_texts = []
|
| 163 |
+
for word in all_words:
|
| 164 |
+
# Word overlaps with chunk
|
| 165 |
+
if word['end_ms'] > chunk_start and word['start_ms'] < chunk_end:
|
| 166 |
+
chunk_texts.append(word['text'])
|
| 167 |
+
|
| 168 |
+
# Combine texts for this chunk
|
| 169 |
+
chunk_text = ' '.join(chunk_texts) if chunk_texts else f"Scene {i + 1}"
|
| 170 |
+
|
| 171 |
+
chunks.append({
|
| 172 |
+
'chunk_id': i + 1,
|
| 173 |
+
'text': chunk_text,
|
| 174 |
+
'start_ms': chunk_start,
|
| 175 |
+
'end_ms': chunk_end,
|
| 176 |
+
'duration': actual_duration
|
| 177 |
+
})
|
| 178 |
+
|
| 179 |
+
logger.info(f"Created {len(chunks)} x 2-second chunks for image generation")
|
| 180 |
+
return chunks
|
| 181 |
+
|
| 182 |
+
@staticmethod
|
| 183 |
+
def generate_srt_content(captions: List[Dict]) -> str:
|
| 184 |
+
"""
|
| 185 |
+
Generate .srt file content from Whisper captions.
|
| 186 |
+
This will be embedded in the final video.
|
| 187 |
+
|
| 188 |
+
Args:
|
| 189 |
+
captions: Original Whisper captions
|
| 190 |
+
|
| 191 |
+
Returns:
|
| 192 |
+
SRT formatted string
|
| 193 |
+
"""
|
| 194 |
+
srt_lines = []
|
| 195 |
+
|
| 196 |
+
for i, cap in enumerate(captions, 1):
|
| 197 |
+
start_ms = cap.get('startMs', 0)
|
| 198 |
+
end_ms = cap.get('endMs', 0)
|
| 199 |
+
text = cap.get('text', '').strip()
|
| 200 |
+
|
| 201 |
+
# Format timestamps: HH:MM:SS,mmm
|
| 202 |
+
def format_time(ms):
|
| 203 |
+
hours = ms // 3600000
|
| 204 |
+
minutes = (ms % 3600000) // 60000
|
| 205 |
+
seconds = (ms % 60000) // 1000
|
| 206 |
+
millis = ms % 1000
|
| 207 |
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}"
|
| 208 |
+
|
| 209 |
+
srt_lines.append(str(i))
|
| 210 |
+
srt_lines.append(f"{format_time(start_ms)} --> {format_time(end_ms)}")
|
| 211 |
+
srt_lines.append(text)
|
| 212 |
+
srt_lines.append("")
|
| 213 |
+
|
| 214 |
+
return '\n'.join(srt_lines)
|
modules/story_reels/services/story_creator.py
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Story Creator - Main Pipeline Orchestrator
|
| 3 |
+
Coordinates TTS, Whisper, Cloudflare, and MoviePy
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
import uuid
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Dict, List, Optional
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
from ..schemas import (
|
| 13 |
+
CharacterProfile,
|
| 14 |
+
SceneInput,
|
| 15 |
+
JobStatus,
|
| 16 |
+
GeneratedScene
|
| 17 |
+
)
|
| 18 |
+
from .cloudflare_client import CloudflareClient
|
| 19 |
+
from .prompt_builder import PromptBuilder
|
| 20 |
+
from .srt_parser import SRTParser
|
| 21 |
+
from .script_generator import ScriptGenerator
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class StoryCreator:
|
| 27 |
+
"""
|
| 28 |
+
Main orchestrator for story-to-video pipeline.
|
| 29 |
+
|
| 30 |
+
Pipeline:
|
| 31 |
+
1. Script → TTS → voice.mp3
|
| 32 |
+
2. voice.mp3 → Whisper → segments
|
| 33 |
+
3. Segments → PromptBuilder → prompts
|
| 34 |
+
4. Prompts → Cloudflare → images
|
| 35 |
+
5. Images + Audio → MoviePy → video
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
def __init__(
|
| 39 |
+
self,
|
| 40 |
+
config,
|
| 41 |
+
tts_client,
|
| 42 |
+
whisper_client,
|
| 43 |
+
nvidia_client=None, # PRIMARY
|
| 44 |
+
cloudflare_client=None, # FALLBACK
|
| 45 |
+
script_generator: ScriptGenerator = None
|
| 46 |
+
):
|
| 47 |
+
self.config = config
|
| 48 |
+
self.tts = tts_client
|
| 49 |
+
self.whisper = whisper_client
|
| 50 |
+
self.nvidia = nvidia_client # PRIMARY
|
| 51 |
+
self.cloudflare = cloudflare_client # FALLBACK
|
| 52 |
+
self.script_gen = script_generator
|
| 53 |
+
|
| 54 |
+
# Job tracking
|
| 55 |
+
self.jobs: Dict[str, Dict] = {}
|
| 56 |
+
self.queue: List[Dict] = []
|
| 57 |
+
self.processing = False
|
| 58 |
+
|
| 59 |
+
def add_to_queue(
|
| 60 |
+
self,
|
| 61 |
+
topic: str,
|
| 62 |
+
script: str,
|
| 63 |
+
character_profile: Optional[CharacterProfile] = None,
|
| 64 |
+
voice: str = "af_heart"
|
| 65 |
+
) -> str:
|
| 66 |
+
"""
|
| 67 |
+
Add story to generation queue.
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
job_id for tracking
|
| 71 |
+
"""
|
| 72 |
+
job_id = str(uuid.uuid4()).replace('-', '')[:16]
|
| 73 |
+
|
| 74 |
+
job = {
|
| 75 |
+
"id": job_id,
|
| 76 |
+
"topic": topic,
|
| 77 |
+
"script": script,
|
| 78 |
+
"character": character_profile,
|
| 79 |
+
"voice": voice,
|
| 80 |
+
"status": JobStatus.queued,
|
| 81 |
+
"progress": 0,
|
| 82 |
+
"created_at": datetime.now().isoformat(),
|
| 83 |
+
"video_url": None,
|
| 84 |
+
"duration": None,
|
| 85 |
+
"error": None,
|
| 86 |
+
"scenes": []
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
self.jobs[job_id] = job
|
| 90 |
+
self.queue.append(job)
|
| 91 |
+
|
| 92 |
+
logger.info(f"Added job {job_id} to queue. Queue length: {len(self.queue)}")
|
| 93 |
+
|
| 94 |
+
# Start processing if not already running
|
| 95 |
+
if not self.processing:
|
| 96 |
+
asyncio.create_task(self.process_queue())
|
| 97 |
+
|
| 98 |
+
return job_id
|
| 99 |
+
|
| 100 |
+
async def process_queue(self):
|
| 101 |
+
"""Process jobs in queue"""
|
| 102 |
+
if self.processing:
|
| 103 |
+
return
|
| 104 |
+
|
| 105 |
+
self.processing = True
|
| 106 |
+
|
| 107 |
+
try:
|
| 108 |
+
while self.queue:
|
| 109 |
+
job = self.queue[0]
|
| 110 |
+
job_id = job["id"]
|
| 111 |
+
|
| 112 |
+
logger.info(f"Processing job {job_id}")
|
| 113 |
+
|
| 114 |
+
try:
|
| 115 |
+
await self._process_job(job)
|
| 116 |
+
job["status"] = JobStatus.ready
|
| 117 |
+
job["progress"] = 100
|
| 118 |
+
logger.info(f"Job {job_id} completed successfully")
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error(f"Job {job_id} failed: {e}", exc_info=True)
|
| 121 |
+
job["status"] = JobStatus.failed
|
| 122 |
+
job["error"] = str(e)
|
| 123 |
+
finally:
|
| 124 |
+
self.queue.pop(0)
|
| 125 |
+
finally:
|
| 126 |
+
self.processing = False
|
| 127 |
+
|
| 128 |
+
async def _process_job(self, job: Dict):
|
| 129 |
+
"""Process a single job through the pipeline"""
|
| 130 |
+
job_id = job["id"]
|
| 131 |
+
temp_dir = self.config.temp_dir_path / job_id
|
| 132 |
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
| 133 |
+
|
| 134 |
+
temp_files = []
|
| 135 |
+
|
| 136 |
+
try:
|
| 137 |
+
# ====================
|
| 138 |
+
# Step 0: Generate Script (if not provided)
|
| 139 |
+
# ====================
|
| 140 |
+
script = job["script"]
|
| 141 |
+
|
| 142 |
+
if not script or script.strip() == "":
|
| 143 |
+
logger.info(f"[{job_id}] Generating script from topic using Gemini...")
|
| 144 |
+
job["progress"] = 5
|
| 145 |
+
|
| 146 |
+
char_name = job["character"].name if job["character"] else None
|
| 147 |
+
script = self.script_gen.generate_script(
|
| 148 |
+
topic=job["topic"],
|
| 149 |
+
character_name=char_name,
|
| 150 |
+
max_chars=1000
|
| 151 |
+
)
|
| 152 |
+
job["script"] = script
|
| 153 |
+
logger.info(f"[{job_id}] Generated script: {len(script)} chars")
|
| 154 |
+
|
| 155 |
+
# ====================
|
| 156 |
+
# Step 1: Generate TTS
|
| 157 |
+
# ====================
|
| 158 |
+
job["status"] = JobStatus.generating_audio
|
| 159 |
+
job["progress"] = 10
|
| 160 |
+
|
| 161 |
+
logger.info(f"[{job_id}] Generating TTS audio...")
|
| 162 |
+
|
| 163 |
+
audio_data, tts_duration = await self.tts.generate(
|
| 164 |
+
script,
|
| 165 |
+
job["voice"]
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
wav_path = temp_dir / "voice.wav"
|
| 169 |
+
mp3_path = temp_dir / "voice.mp3"
|
| 170 |
+
temp_files.extend([wav_path, mp3_path])
|
| 171 |
+
|
| 172 |
+
# Import FFmpegUtils from video_creator
|
| 173 |
+
from modules.video_creator.services.libraries.ffmpeg_utils import FFmpegUtils
|
| 174 |
+
|
| 175 |
+
FFmpegUtils.save_audio_as_wav(audio_data, wav_path)
|
| 176 |
+
FFmpegUtils.save_audio_as_mp3(audio_data, mp3_path)
|
| 177 |
+
|
| 178 |
+
# Get actual duration
|
| 179 |
+
audio_duration = FFmpegUtils.get_video_duration(wav_path)
|
| 180 |
+
logger.info(f"[{job_id}] Audio generated: {audio_duration:.2f}s")
|
| 181 |
+
|
| 182 |
+
job["progress"] = 25
|
| 183 |
+
|
| 184 |
+
# ====================
|
| 185 |
+
# Step 2: Generate Captions (Whisper)
|
| 186 |
+
# ====================
|
| 187 |
+
logger.info(f"[{job_id}] Generating captions with Whisper...")
|
| 188 |
+
|
| 189 |
+
captions = self.whisper.create_captions(str(wav_path))
|
| 190 |
+
captions_dict = [c.dict() for c in captions]
|
| 191 |
+
|
| 192 |
+
# OUTPUT 1: .srt content (for video subtitles)
|
| 193 |
+
srt_content = SRTParser.generate_srt_content(captions_dict)
|
| 194 |
+
srt_path = temp_dir / "voice.srt"
|
| 195 |
+
srt_path.write_text(srt_content, encoding='utf-8')
|
| 196 |
+
temp_files.append(srt_path)
|
| 197 |
+
logger.info(f"[{job_id}] Generated .srt with {len(captions)} captions")
|
| 198 |
+
|
| 199 |
+
# OUTPUT 2: 2-second chunks (for image prompts)
|
| 200 |
+
image_chunks = SRTParser.create_2s_chunks(captions_dict, audio_duration)
|
| 201 |
+
logger.info(f"[{job_id}] Created {len(image_chunks)} x 2s chunks for images")
|
| 202 |
+
|
| 203 |
+
job["progress"] = 40
|
| 204 |
+
job["srt_path"] = str(srt_path)
|
| 205 |
+
|
| 206 |
+
# ====================
|
| 207 |
+
# Step 3: Generate Image Prompts using AI
|
| 208 |
+
# ====================
|
| 209 |
+
job["status"] = JobStatus.generating_images
|
| 210 |
+
logger.info(f"[{job_id}] Generating AI-powered image prompts...")
|
| 211 |
+
|
| 212 |
+
# Convert character profile to dict if exists
|
| 213 |
+
char_dict = None
|
| 214 |
+
if job["character"]:
|
| 215 |
+
char_dict = {
|
| 216 |
+
"name": job["character"].name,
|
| 217 |
+
"age": job["character"].age,
|
| 218 |
+
"gender": job["character"].gender,
|
| 219 |
+
"hair": job["character"].hair,
|
| 220 |
+
"skin": job["character"].skin,
|
| 221 |
+
"clothes": job["character"].clothes,
|
| 222 |
+
"style": job["character"].style.value if hasattr(job["character"].style, 'value') else str(job["character"].style)
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
# Generate all image prompts at once using Gemini
|
| 226 |
+
# Input: Full script (context) + 2s chunks → Output: JSON array of prompts
|
| 227 |
+
ai_prompts = self.script_gen.generate_image_prompts(
|
| 228 |
+
full_script=script,
|
| 229 |
+
chunks=image_chunks,
|
| 230 |
+
character_profile=char_dict
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
logger.info(f"[{job_id}] AI generated {len(ai_prompts)} image prompts")
|
| 234 |
+
|
| 235 |
+
job["progress"] = 50
|
| 236 |
+
|
| 237 |
+
# ====================
|
| 238 |
+
# Step 4: Generate Images (PARALLEL - NVIDIA + Cloudflare)
|
| 239 |
+
# ====================
|
| 240 |
+
# If both APIs available: split images 50/50 for 2x speed
|
| 241 |
+
# 1 second delay between each request (rate limit safe)
|
| 242 |
+
|
| 243 |
+
seed = job["character"].seed if job["character"] else 432891
|
| 244 |
+
|
| 245 |
+
# Build prompts list from AI-generated prompts
|
| 246 |
+
prompts_list = []
|
| 247 |
+
for p in ai_prompts:
|
| 248 |
+
prompts_list.append((p["chunk_id"], p["prompt"]))
|
| 249 |
+
|
| 250 |
+
total_images = len(prompts_list)
|
| 251 |
+
logger.info(f"[{job_id}] Generating {total_images} images...")
|
| 252 |
+
|
| 253 |
+
# Check which APIs are available
|
| 254 |
+
has_nvidia = self.nvidia is not None
|
| 255 |
+
has_cloudflare = self.cloudflare is not None
|
| 256 |
+
|
| 257 |
+
if has_nvidia and has_cloudflare:
|
| 258 |
+
# PARALLEL MODE: NVIDIA 70%, Cloudflare 30% (NVIDIA has better quality)
|
| 259 |
+
logger.info(f"[{job_id}] Parallel mode: NVIDIA 70% + Cloudflare 30%")
|
| 260 |
+
|
| 261 |
+
import threading
|
| 262 |
+
|
| 263 |
+
# Split: first 70% to NVIDIA, remaining 30% to Cloudflare
|
| 264 |
+
nvidia_count = int(total_images * 0.7)
|
| 265 |
+
if nvidia_count == 0:
|
| 266 |
+
nvidia_count = 1
|
| 267 |
+
|
| 268 |
+
nvidia_prompts = prompts_list[:nvidia_count]
|
| 269 |
+
cloudflare_prompts = prompts_list[nvidia_count:]
|
| 270 |
+
|
| 271 |
+
# Get indices
|
| 272 |
+
nvidia_indices = [p[0] for p in nvidia_prompts]
|
| 273 |
+
cloudflare_indices = [p[0] for p in cloudflare_prompts]
|
| 274 |
+
|
| 275 |
+
nvidia_results = []
|
| 276 |
+
cloudflare_results = []
|
| 277 |
+
|
| 278 |
+
def nvidia_worker():
|
| 279 |
+
"""NVIDIA: 5 requests → wait → next 5"""
|
| 280 |
+
nonlocal nvidia_results
|
| 281 |
+
batch_size = 5
|
| 282 |
+
for batch_start in range(0, len(nvidia_prompts), batch_size):
|
| 283 |
+
batch = nvidia_prompts[batch_start:batch_start + batch_size]
|
| 284 |
+
logger.info(f"NVIDIA batch {batch_start//batch_size + 1}: {len(batch)} images")
|
| 285 |
+
|
| 286 |
+
for orig_idx, prompt in batch:
|
| 287 |
+
try:
|
| 288 |
+
output_path = temp_dir / f"scene_{orig_idx:03d}.png"
|
| 289 |
+
self.nvidia.generate_and_save(prompt, output_path, seed=seed)
|
| 290 |
+
nvidia_results.append({"id": orig_idx, "path": str(output_path), "prompt": prompt})
|
| 291 |
+
logger.debug(f"NVIDIA: {orig_idx}")
|
| 292 |
+
except Exception as e:
|
| 293 |
+
logger.error(f"NVIDIA failed {orig_idx}: {e}")
|
| 294 |
+
nvidia_results.append({"id": orig_idx, "path": None, "error": str(e)})
|
| 295 |
+
time.sleep(1.0) # 1s delay between requests in same batch
|
| 296 |
+
|
| 297 |
+
# Batch complete - wait before next batch
|
| 298 |
+
if batch_start + batch_size < len(nvidia_prompts):
|
| 299 |
+
logger.info("NVIDIA batch complete, waiting...")
|
| 300 |
+
time.sleep(2.0)
|
| 301 |
+
|
| 302 |
+
def cloudflare_worker():
|
| 303 |
+
"""Cloudflare: 5 requests → wait → next 5"""
|
| 304 |
+
nonlocal cloudflare_results
|
| 305 |
+
batch_size = 5
|
| 306 |
+
for batch_start in range(0, len(cloudflare_prompts), batch_size):
|
| 307 |
+
batch = cloudflare_prompts[batch_start:batch_start + batch_size]
|
| 308 |
+
logger.info(f"Cloudflare batch {batch_start//batch_size + 1}: {len(batch)} images")
|
| 309 |
+
|
| 310 |
+
for orig_idx, prompt in batch:
|
| 311 |
+
try:
|
| 312 |
+
output_path = temp_dir / f"scene_{orig_idx:03d}.png"
|
| 313 |
+
self.cloudflare.generate_and_save(prompt, output_path, seed=seed, width=1080, height=1920)
|
| 314 |
+
cloudflare_results.append({"id": orig_idx, "path": str(output_path), "prompt": prompt})
|
| 315 |
+
logger.debug(f"Cloudflare: {orig_idx}")
|
| 316 |
+
except Exception as e:
|
| 317 |
+
logger.error(f"Cloudflare failed {orig_idx}: {e}")
|
| 318 |
+
cloudflare_results.append({"id": orig_idx, "path": None, "error": str(e)})
|
| 319 |
+
time.sleep(1.0) # 1s delay between requests in same batch
|
| 320 |
+
|
| 321 |
+
# Batch complete - wait before next batch
|
| 322 |
+
if batch_start + batch_size < len(cloudflare_prompts):
|
| 323 |
+
logger.info("Cloudflare batch complete, waiting...")
|
| 324 |
+
time.sleep(2.0)
|
| 325 |
+
|
| 326 |
+
# Run both in parallel (each has its own batch counter)
|
| 327 |
+
t1 = threading.Thread(target=nvidia_worker)
|
| 328 |
+
t2 = threading.Thread(target=cloudflare_worker)
|
| 329 |
+
t1.start()
|
| 330 |
+
t2.start()
|
| 331 |
+
t1.join()
|
| 332 |
+
t2.join()
|
| 333 |
+
|
| 334 |
+
# Combine results
|
| 335 |
+
batch_results = nvidia_results + cloudflare_results
|
| 336 |
+
batch_results.sort(key=lambda x: x["id"])
|
| 337 |
+
|
| 338 |
+
elif has_nvidia:
|
| 339 |
+
# NVIDIA only with 1s delay
|
| 340 |
+
logger.info(f"[{job_id}] NVIDIA only mode")
|
| 341 |
+
batch_results = self.nvidia.generate_batch(
|
| 342 |
+
prompts=prompts_list,
|
| 343 |
+
output_dir=temp_dir,
|
| 344 |
+
seed=seed,
|
| 345 |
+
batch_size=5,
|
| 346 |
+
delay_seconds=1.0
|
| 347 |
+
)
|
| 348 |
+
elif has_cloudflare:
|
| 349 |
+
# Cloudflare only with 1s delay
|
| 350 |
+
logger.info(f"[{job_id}] Cloudflare only mode")
|
| 351 |
+
batch_results = self.cloudflare.generate_batch(
|
| 352 |
+
prompts=prompts_list,
|
| 353 |
+
output_dir=temp_dir,
|
| 354 |
+
seed=seed,
|
| 355 |
+
batch_size=5,
|
| 356 |
+
delay_seconds=1.0,
|
| 357 |
+
width=1080,
|
| 358 |
+
height=1920
|
| 359 |
+
)
|
| 360 |
+
else:
|
| 361 |
+
raise Exception("No image generation client available!")
|
| 362 |
+
|
| 363 |
+
# Build generated_scenes from batch results
|
| 364 |
+
generated_scenes = []
|
| 365 |
+
for result in batch_results:
|
| 366 |
+
if result.get("path"):
|
| 367 |
+
temp_files.append(Path(result["path"]))
|
| 368 |
+
|
| 369 |
+
# Find matching chunk for duration
|
| 370 |
+
scene_duration = 2.0
|
| 371 |
+
for chunk in image_chunks:
|
| 372 |
+
if chunk['chunk_id'] == result["id"]:
|
| 373 |
+
scene_duration = chunk['duration']
|
| 374 |
+
break
|
| 375 |
+
|
| 376 |
+
generated_scenes.append({
|
| 377 |
+
"scene_id": result["id"],
|
| 378 |
+
"prompt": result["prompt"],
|
| 379 |
+
"image_path": result["path"],
|
| 380 |
+
"duration": scene_duration
|
| 381 |
+
})
|
| 382 |
+
|
| 383 |
+
logger.info(f"[{job_id}] Generated {len(generated_scenes)}/{len(ai_prompts)} images")
|
| 384 |
+
|
| 385 |
+
job["scenes"] = generated_scenes
|
| 386 |
+
job["progress"] = 80
|
| 387 |
+
|
| 388 |
+
# ====================
|
| 389 |
+
# Step 5: Compose Video
|
| 390 |
+
# ====================
|
| 391 |
+
job["status"] = JobStatus.composing_video
|
| 392 |
+
logger.info(f"[{job_id}] Composing final video...")
|
| 393 |
+
|
| 394 |
+
output_path = self.config.videos_dir_path / f"{job_id}.mp4"
|
| 395 |
+
|
| 396 |
+
await self._compose_video(
|
| 397 |
+
scenes=generated_scenes,
|
| 398 |
+
audio_path=mp3_path,
|
| 399 |
+
output_path=output_path
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
job["video_url"] = str(output_path)
|
| 403 |
+
job["duration"] = audio_duration
|
| 404 |
+
job["progress"] = 100
|
| 405 |
+
|
| 406 |
+
logger.info(f"[{job_id}] Video saved to {output_path}")
|
| 407 |
+
|
| 408 |
+
finally:
|
| 409 |
+
# Cleanup temp files
|
| 410 |
+
for temp_file in temp_files:
|
| 411 |
+
if temp_file.exists():
|
| 412 |
+
try:
|
| 413 |
+
temp_file.unlink()
|
| 414 |
+
except:
|
| 415 |
+
pass
|
| 416 |
+
|
| 417 |
+
# Remove temp directory
|
| 418 |
+
if temp_dir.exists():
|
| 419 |
+
try:
|
| 420 |
+
temp_dir.rmdir()
|
| 421 |
+
except:
|
| 422 |
+
pass
|
| 423 |
+
|
| 424 |
+
async def _compose_video(
|
| 425 |
+
self,
|
| 426 |
+
scenes: List[Dict],
|
| 427 |
+
audio_path: Path,
|
| 428 |
+
output_path: Path
|
| 429 |
+
):
|
| 430 |
+
"""
|
| 431 |
+
Compose video from images and audio using MoviePy.
|
| 432 |
+
|
| 433 |
+
Effects:
|
| 434 |
+
- Crossfade transitions (0.3s) between scenes
|
| 435 |
+
- Subtle Ken Burns zoom (1.05x) for dynamic feel
|
| 436 |
+
- Fade in at start, fade out at end
|
| 437 |
+
"""
|
| 438 |
+
from moviepy.editor import (
|
| 439 |
+
ImageClip,
|
| 440 |
+
AudioFileClip,
|
| 441 |
+
concatenate_videoclips,
|
| 442 |
+
CompositeVideoClip,
|
| 443 |
+
vfx
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
# Constants
|
| 447 |
+
CROSSFADE_DURATION = 0.3 # Transition duration
|
| 448 |
+
ZOOM_FACTOR = 1.05 # Subtle zoom (1.05 = 5% zoom)
|
| 449 |
+
FADE_DURATION = 0.5 # Fade in/out duration
|
| 450 |
+
TARGET_HEIGHT = 1920 # Portrait
|
| 451 |
+
TARGET_WIDTH = 1080
|
| 452 |
+
|
| 453 |
+
# Load audio first to get exact duration
|
| 454 |
+
audio = AudioFileClip(str(audio_path))
|
| 455 |
+
audio_duration = audio.duration
|
| 456 |
+
|
| 457 |
+
# Create video clips from images with effects
|
| 458 |
+
clips = []
|
| 459 |
+
total_video_duration = 0
|
| 460 |
+
total_scenes = len(scenes)
|
| 461 |
+
|
| 462 |
+
for i, scene in enumerate(scenes):
|
| 463 |
+
image_path = scene["image_path"]
|
| 464 |
+
duration = scene["duration"]
|
| 465 |
+
|
| 466 |
+
# For the last clip, adjust duration to match audio
|
| 467 |
+
if i == total_scenes - 1:
|
| 468 |
+
remaining = audio_duration - total_video_duration
|
| 469 |
+
if remaining > 0:
|
| 470 |
+
duration = remaining
|
| 471 |
+
|
| 472 |
+
# Create image clip
|
| 473 |
+
clip = ImageClip(image_path).set_duration(duration)
|
| 474 |
+
|
| 475 |
+
# Resize to portrait (1080x1920)
|
| 476 |
+
clip = clip.resize(height=TARGET_HEIGHT)
|
| 477 |
+
|
| 478 |
+
# Scene position-based effects
|
| 479 |
+
# Hook (first 2 clips): Zoom OUT (start big, end normal) - grabs attention
|
| 480 |
+
# Middle clips: Subtle zoom IN (Ken Burns)
|
| 481 |
+
# Outro (last clip): Static with fade out
|
| 482 |
+
|
| 483 |
+
if i < 2:
|
| 484 |
+
# HOOK: Zoom OUT effect (1.1 → 1.0) - dynamic attention grabber
|
| 485 |
+
def make_zoom_out(t, clip_duration=duration):
|
| 486 |
+
zoom = 1.1 - (0.1 * (t / clip_duration)) # 1.1 to 1.0
|
| 487 |
+
return zoom
|
| 488 |
+
clip = clip.resize(lambda t: make_zoom_out(t))
|
| 489 |
+
|
| 490 |
+
elif i < total_scenes - 1:
|
| 491 |
+
# MIDDLE: Ken Burns zoom IN (1.0 → 1.05)
|
| 492 |
+
def make_zoom_in(t, clip_duration=duration):
|
| 493 |
+
zoom = 1.0 + (ZOOM_FACTOR - 1.0) * (t / clip_duration)
|
| 494 |
+
return zoom
|
| 495 |
+
clip = clip.resize(lambda t: make_zoom_in(t))
|
| 496 |
+
|
| 497 |
+
# Last clip stays static (no zoom)
|
| 498 |
+
|
| 499 |
+
# Center crop after zoom (to maintain 1080x1920)
|
| 500 |
+
clip = clip.resize(width=TARGET_WIDTH, height=TARGET_HEIGHT)
|
| 501 |
+
|
| 502 |
+
# Transitions: crossfade for smooth scene changes (NOT on first 2 clips)
|
| 503 |
+
# Hook clips: NO crossfade, clean direct cut
|
| 504 |
+
if i >= 2 and duration > CROSSFADE_DURATION:
|
| 505 |
+
clip = clip.crossfadein(CROSSFADE_DURATION)
|
| 506 |
+
|
| 507 |
+
# NO fade in for Hook (first 2 clips) - start immediately visible!
|
| 508 |
+
# Only fade out at the very end
|
| 509 |
+
if i == total_scenes - 1:
|
| 510 |
+
clip = clip.fadeout(FADE_DURATION)
|
| 511 |
+
|
| 512 |
+
clips.append(clip)
|
| 513 |
+
total_video_duration += duration
|
| 514 |
+
|
| 515 |
+
# Concatenate with crossfade transitions
|
| 516 |
+
if len(clips) > 1:
|
| 517 |
+
video = concatenate_videoclips(clips, method="compose", padding=-CROSSFADE_DURATION)
|
| 518 |
+
else:
|
| 519 |
+
video = clips[0]
|
| 520 |
+
|
| 521 |
+
# Final safety: match video length to audio exactly
|
| 522 |
+
if abs(video.duration - audio_duration) > 0.1:
|
| 523 |
+
if video.duration > audio_duration:
|
| 524 |
+
video = video.subclip(0, audio_duration)
|
| 525 |
+
|
| 526 |
+
video = video.set_audio(audio)
|
| 527 |
+
|
| 528 |
+
# Write final video
|
| 529 |
+
logger.info(f"Writing video with effects: crossfade={CROSSFADE_DURATION}s, zoom={ZOOM_FACTOR}x")
|
| 530 |
+
video.write_videofile(
|
| 531 |
+
str(output_path),
|
| 532 |
+
fps=24,
|
| 533 |
+
codec='libx264',
|
| 534 |
+
audio_codec='aac',
|
| 535 |
+
threads=4,
|
| 536 |
+
preset='medium'
|
| 537 |
+
)
|
| 538 |
+
|
| 539 |
+
# Cleanup
|
| 540 |
+
video.close()
|
| 541 |
+
audio.close()
|
| 542 |
+
|
| 543 |
+
def get_status(self, job_id: str) -> Dict:
|
| 544 |
+
"""Get job status"""
|
| 545 |
+
job = self.jobs.get(job_id)
|
| 546 |
+
|
| 547 |
+
if not job:
|
| 548 |
+
return {
|
| 549 |
+
"job_id": job_id,
|
| 550 |
+
"status": JobStatus.failed,
|
| 551 |
+
"error": "Job not found"
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
return {
|
| 555 |
+
"job_id": job_id,
|
| 556 |
+
"status": job["status"],
|
| 557 |
+
"progress": job["progress"],
|
| 558 |
+
"video_url": job.get("video_url"),
|
| 559 |
+
"duration": job.get("duration"),
|
| 560 |
+
"error": job.get("error")
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
def get_preview(self, job_id: str, scene_id: int) -> Optional[Dict]:
|
| 564 |
+
"""Get scene preview"""
|
| 565 |
+
job = self.jobs.get(job_id)
|
| 566 |
+
|
| 567 |
+
if not job or not job.get("scenes"):
|
| 568 |
+
return None
|
| 569 |
+
|
| 570 |
+
for scene in job["scenes"]:
|
| 571 |
+
if scene["scene_id"] == scene_id:
|
| 572 |
+
return scene
|
| 573 |
+
|
| 574 |
+
return None
|
| 575 |
+
|
| 576 |
+
def get_video_path(self, job_id: str) -> Optional[Path]:
|
| 577 |
+
"""Get video file path"""
|
| 578 |
+
job = self.jobs.get(job_id)
|
| 579 |
+
|
| 580 |
+
if not job or not job.get("video_url"):
|
| 581 |
+
return None
|
| 582 |
+
|
| 583 |
+
return Path(job["video_url"])
|
modules/video_creator/__init__.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Video Creator Module for NCAkit
|
| 3 |
+
Creates short-form videos with TTS, captions, background videos, and music.
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import FastAPI
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
# Module Metadata
|
| 9 |
+
MODULE_NAME = "video_creator"
|
| 10 |
+
MODULE_PREFIX = "/api/video"
|
| 11 |
+
MODULE_DESCRIPTION = "Create short-form videos with TTS, captions, and background music"
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def register(app: FastAPI, config):
|
| 17 |
+
"""
|
| 18 |
+
Register the video creator module with FastAPI.
|
| 19 |
+
Initializes all services and adds routes.
|
| 20 |
+
"""
|
| 21 |
+
from .router import router, set_short_creator
|
| 22 |
+
from .services.libraries.tts_client import TTSClient
|
| 23 |
+
from .services.libraries.whisper_client import WhisperClient
|
| 24 |
+
from .services.libraries.pexels_client import PexelsClient
|
| 25 |
+
from .services.music_manager import MusicManager
|
| 26 |
+
from .services.short_creator import ShortCreator
|
| 27 |
+
|
| 28 |
+
logger.info("Registering video_creator module...")
|
| 29 |
+
|
| 30 |
+
# Validate environment variables
|
| 31 |
+
if not config.pexels_api_key:
|
| 32 |
+
logger.warning("PEXELS_API_KEY is missing! Video generation will fail.")
|
| 33 |
+
|
| 34 |
+
if not config.hf_tts:
|
| 35 |
+
logger.warning("HF_TTS is missing! TTS will fail.")
|
| 36 |
+
|
| 37 |
+
# Initialize TTS client
|
| 38 |
+
logger.info("Initializing TTS client...")
|
| 39 |
+
tts_client = TTSClient(config.hf_tts)
|
| 40 |
+
|
| 41 |
+
# Initialize Whisper client
|
| 42 |
+
logger.info("Initializing Whisper client...")
|
| 43 |
+
whisper_client = WhisperClient(
|
| 44 |
+
model_name=config.whisper_model,
|
| 45 |
+
model_dir=config.whisper_model_dir
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# Initialize Pexels client
|
| 49 |
+
logger.info("Initializing Pexels client...")
|
| 50 |
+
pexels_client = PexelsClient(config.pexels_api_key)
|
| 51 |
+
|
| 52 |
+
# Initialize music manager
|
| 53 |
+
logger.info("Initializing music manager...")
|
| 54 |
+
music_manager = MusicManager(config.music_dir_path)
|
| 55 |
+
try:
|
| 56 |
+
music_manager.ensure_music_files_exist()
|
| 57 |
+
except FileNotFoundError as e:
|
| 58 |
+
logger.error(f"Music setup error: {e}")
|
| 59 |
+
logger.warning("Creating empty music directory")
|
| 60 |
+
config.music_dir_path.mkdir(parents=True, exist_ok=True)
|
| 61 |
+
|
| 62 |
+
# Initialize short creator
|
| 63 |
+
logger.info("Initializing short creator...")
|
| 64 |
+
short_creator = ShortCreator(
|
| 65 |
+
config=config,
|
| 66 |
+
tts_client=tts_client,
|
| 67 |
+
whisper_client=whisper_client,
|
| 68 |
+
pexels_client=pexels_client,
|
| 69 |
+
music_manager=music_manager
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# Set the global short creator in the router
|
| 73 |
+
set_short_creator(short_creator)
|
| 74 |
+
|
| 75 |
+
# Store in app state for access from other modules if needed
|
| 76 |
+
app.state.video_creator = short_creator
|
| 77 |
+
|
| 78 |
+
# Register routes
|
| 79 |
+
app.include_router(router, prefix=MODULE_PREFIX, tags=["Video Creator"])
|
| 80 |
+
|
| 81 |
+
logger.info("video_creator module registered successfully!")
|
modules/video_creator/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (3.34 kB). View file
|
|
|
modules/video_creator/__pycache__/router.cpython-313.pyc
ADDED
|
Binary file (5.3 kB). View file
|
|
|
modules/video_creator/router.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Video Creator Router - API Endpoints
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import APIRouter, HTTPException
|
| 5 |
+
from fastapi.responses import FileResponse
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
from .schemas import (
|
| 9 |
+
CreateVideoRequest,
|
| 10 |
+
CreateVideoResponse,
|
| 11 |
+
VideoStatusResponse,
|
| 12 |
+
VideoListResponse,
|
| 13 |
+
VideoListItem
|
| 14 |
+
)
|
| 15 |
+
from .services.short_creator import ShortCreator
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
# This will be set when the module registers
|
| 20 |
+
short_creator: ShortCreator = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def set_short_creator(creator: ShortCreator):
|
| 24 |
+
"""Set the global short creator instance"""
|
| 25 |
+
global short_creator
|
| 26 |
+
short_creator = creator
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
router = APIRouter()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.post("/short-video",
|
| 33 |
+
response_model=CreateVideoResponse,
|
| 34 |
+
status_code=201,
|
| 35 |
+
summary="Create a new video",
|
| 36 |
+
description="Create a new short video from text scenes. Returns a video ID to track progress."
|
| 37 |
+
)
|
| 38 |
+
async def create_short_video(request: CreateVideoRequest):
|
| 39 |
+
"""Create a new short video"""
|
| 40 |
+
try:
|
| 41 |
+
logger.info(f"Creating short video with {len(request.scenes)} scenes")
|
| 42 |
+
|
| 43 |
+
video_id = short_creator.add_to_queue(
|
| 44 |
+
request.scenes,
|
| 45 |
+
request.config
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
return CreateVideoResponse(videoId=video_id)
|
| 49 |
+
|
| 50 |
+
except Exception as e:
|
| 51 |
+
logger.error(f"Error creating video: {e}", exc_info=True)
|
| 52 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.get("/short-video/{video_id}/status",
|
| 56 |
+
response_model=VideoStatusResponse,
|
| 57 |
+
summary="Get video status",
|
| 58 |
+
description="Check the processing status of a video (processing, ready, or failed)"
|
| 59 |
+
)
|
| 60 |
+
async def get_video_status(video_id: str):
|
| 61 |
+
"""Get the status of a video"""
|
| 62 |
+
status = short_creator.get_status(video_id)
|
| 63 |
+
return VideoStatusResponse(status=status)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@router.get("/short-video/{video_id}",
|
| 67 |
+
summary="Download video",
|
| 68 |
+
description="Download the generated video file (MP4 format)",
|
| 69 |
+
responses={
|
| 70 |
+
200: {"description": "Video file", "content": {"video/mp4": {}}},
|
| 71 |
+
404: {"description": "Video not found"}
|
| 72 |
+
}
|
| 73 |
+
)
|
| 74 |
+
async def get_video(video_id: str):
|
| 75 |
+
"""Download/stream a video"""
|
| 76 |
+
video_path = short_creator.get_video_path(video_id)
|
| 77 |
+
|
| 78 |
+
if not video_path.exists():
|
| 79 |
+
raise HTTPException(status_code=404, detail="Video not found")
|
| 80 |
+
|
| 81 |
+
return FileResponse(
|
| 82 |
+
video_path,
|
| 83 |
+
media_type="video/mp4",
|
| 84 |
+
filename=f"{video_id}.mp4"
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@router.get("/short-videos",
|
| 89 |
+
response_model=VideoListResponse,
|
| 90 |
+
summary="List all videos",
|
| 91 |
+
description="Get a list of all videos with their current status"
|
| 92 |
+
)
|
| 93 |
+
async def list_videos():
|
| 94 |
+
"""List all videos"""
|
| 95 |
+
videos = short_creator.list_all_videos()
|
| 96 |
+
return VideoListResponse(
|
| 97 |
+
videos=[VideoListItem(**v) for v in videos]
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@router.delete("/short-video/{video_id}",
|
| 102 |
+
summary="Delete video",
|
| 103 |
+
description="Delete a video by its ID"
|
| 104 |
+
)
|
| 105 |
+
async def delete_video(video_id: str):
|
| 106 |
+
"""Delete a video"""
|
| 107 |
+
try:
|
| 108 |
+
short_creator.delete_video(video_id)
|
| 109 |
+
return {"success": True}
|
| 110 |
+
except Exception as e:
|
| 111 |
+
logger.error(f"Error deleting video: {e}")
|
| 112 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@router.get("/voices",
|
| 116 |
+
summary="List TTS voices",
|
| 117 |
+
description="Get all available text-to-speech voice options"
|
| 118 |
+
)
|
| 119 |
+
async def get_voices():
|
| 120 |
+
"""List available TTS voices"""
|
| 121 |
+
return short_creator.get_available_voices()
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@router.get("/music-tags",
|
| 125 |
+
summary="List music moods",
|
| 126 |
+
description="Get all available background music mood options"
|
| 127 |
+
)
|
| 128 |
+
async def get_music_tags():
|
| 129 |
+
"""List available music moods"""
|
| 130 |
+
return short_creator.get_available_music_tags()
|
modules/video_creator/schemas.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import List, Optional, Literal
|
| 3 |
+
from enum import Enum
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class VoiceEnum(str, Enum):
|
| 7 |
+
"""Available TTS voices"""
|
| 8 |
+
af_heart = "af_heart"
|
| 9 |
+
af_alloy = "af_alloy"
|
| 10 |
+
af_aoede = "af_aoede"
|
| 11 |
+
af_bella = "af_bella"
|
| 12 |
+
af_jessica = "af_jessica"
|
| 13 |
+
af_kore = "af_kore"
|
| 14 |
+
af_nicole = "af_nicole"
|
| 15 |
+
af_nova = "af_nova"
|
| 16 |
+
af_river = "af_river"
|
| 17 |
+
af_sarah = "af_sarah"
|
| 18 |
+
af_sky = "af_sky"
|
| 19 |
+
am_adam = "am_adam"
|
| 20 |
+
am_echo = "am_echo"
|
| 21 |
+
am_eric = "am_eric"
|
| 22 |
+
am_fenrir = "am_fenrir"
|
| 23 |
+
am_liam = "am_liam"
|
| 24 |
+
am_michael = "am_michael"
|
| 25 |
+
am_onyx = "am_onyx"
|
| 26 |
+
am_puck = "am_puck"
|
| 27 |
+
am_santa = "am_santa"
|
| 28 |
+
bf_emma = "bf_emma"
|
| 29 |
+
bf_isabella = "bf_isabella"
|
| 30 |
+
bm_george = "bm_george"
|
| 31 |
+
bm_lewis = "bm_lewis"
|
| 32 |
+
bf_alice = "bf_alice"
|
| 33 |
+
bf_lily = "bf_lily"
|
| 34 |
+
bm_daniel = "bm_daniel"
|
| 35 |
+
bm_fable = "bm_fable"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class MusicMoodEnum(str, Enum):
|
| 39 |
+
"""Available music moods"""
|
| 40 |
+
sad = "sad"
|
| 41 |
+
melancholic = "melancholic"
|
| 42 |
+
happy = "happy"
|
| 43 |
+
euphoric = "euphoric/high"
|
| 44 |
+
excited = "excited"
|
| 45 |
+
chill = "chill"
|
| 46 |
+
uneasy = "uneasy"
|
| 47 |
+
angry = "angry"
|
| 48 |
+
dark = "dark"
|
| 49 |
+
hopeful = "hopeful"
|
| 50 |
+
contemplative = "contemplative"
|
| 51 |
+
funny = "funny/quirky"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class OrientationEnum(str, Enum):
|
| 55 |
+
"""Video orientation"""
|
| 56 |
+
portrait = "portrait"
|
| 57 |
+
landscape = "landscape"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class CaptionPositionEnum(str, Enum):
|
| 61 |
+
"""Caption position on video"""
|
| 62 |
+
top = "top"
|
| 63 |
+
center = "center"
|
| 64 |
+
bottom = "bottom"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class MusicVolumeEnum(str, Enum):
|
| 68 |
+
"""Music volume level"""
|
| 69 |
+
low = "low"
|
| 70 |
+
medium = "medium"
|
| 71 |
+
high = "high"
|
| 72 |
+
muted = "muted"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class VideoStatus(str, Enum):
|
| 76 |
+
"""Video processing status"""
|
| 77 |
+
processing = "processing"
|
| 78 |
+
ready = "ready"
|
| 79 |
+
failed = "failed"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class SceneInput(BaseModel):
|
| 83 |
+
"""Input for a single scene in the video"""
|
| 84 |
+
text: str = Field(..., description="Text to be narrated in this scene")
|
| 85 |
+
searchTerms: List[str] = Field(..., description="Keywords for finding background video", alias="searchTerms")
|
| 86 |
+
|
| 87 |
+
class Config:
|
| 88 |
+
populate_by_name = True
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class RenderConfig(BaseModel):
|
| 92 |
+
"""Configuration for video rendering"""
|
| 93 |
+
paddingBack: Optional[int] = Field(0, description="End screen duration in milliseconds")
|
| 94 |
+
music: Optional[MusicMoodEnum] = Field(None, description="Background music mood")
|
| 95 |
+
captionPosition: CaptionPositionEnum = Field(CaptionPositionEnum.bottom, description="Caption position")
|
| 96 |
+
captionBackgroundColor: str = Field("blue", description="Caption background color")
|
| 97 |
+
voice: VoiceEnum = Field(VoiceEnum.af_heart, description="TTS voice")
|
| 98 |
+
orientation: OrientationEnum = Field(OrientationEnum.portrait, description="Video orientation")
|
| 99 |
+
musicVolume: MusicVolumeEnum = Field(MusicVolumeEnum.high, description="Background music volume")
|
| 100 |
+
|
| 101 |
+
class Config:
|
| 102 |
+
populate_by_name = True
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class CreateVideoRequest(BaseModel):
|
| 106 |
+
"""Request to create a short video"""
|
| 107 |
+
scenes: List[SceneInput] = Field(..., min_length=1, description="List of scenes for the video")
|
| 108 |
+
config: Optional[RenderConfig] = Field(default_factory=RenderConfig, description="Render configuration")
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class CreateVideoResponse(BaseModel):
|
| 112 |
+
"""Response after creating a video"""
|
| 113 |
+
videoId: str = Field(..., description="Unique ID for the created video")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class VideoStatusResponse(BaseModel):
|
| 117 |
+
"""Response for video status check"""
|
| 118 |
+
status: VideoStatus = Field(..., description="Current status of the video")
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class VideoListItem(BaseModel):
|
| 122 |
+
"""Single video in the list"""
|
| 123 |
+
id: str
|
| 124 |
+
status: VideoStatus
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class VideoListResponse(BaseModel):
|
| 128 |
+
"""Response for listing all videos"""
|
| 129 |
+
videos: List[VideoListItem]
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class Caption(BaseModel):
|
| 133 |
+
"""Caption with timing information"""
|
| 134 |
+
text: str
|
| 135 |
+
startMs: int
|
| 136 |
+
endMs: int
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class Scene(BaseModel):
|
| 140 |
+
"""Processed scene with all media"""
|
| 141 |
+
captions: List[Caption]
|
| 142 |
+
video: str # Path to video file
|
| 143 |
+
audio: dict # Audio info with 'url' and 'duration'
|
modules/video_creator/services/__init__.py
ADDED
|
File without changes
|
modules/video_creator/services/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (164 Bytes). View file
|
|
|
modules/video_creator/services/__pycache__/short_creator.cpython-313.pyc
ADDED
|
Binary file (12.2 kB). View file
|
|
|
modules/video_creator/services/libraries/__init__.py
ADDED
|
File without changes
|
modules/video_creator/services/libraries/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (174 Bytes). View file
|
|
|
modules/video_creator/services/libraries/__pycache__/tts_client.cpython-313.pyc
ADDED
|
Binary file (5.22 kB). View file
|
|
|
modules/video_creator/services/libraries/__pycache__/whisper_client.cpython-313.pyc
ADDED
|
Binary file (3.56 kB). View file
|
|
|
modules/video_creator/services/libraries/ffmpeg_utils.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import subprocess
|
| 2 |
+
import logging
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class FFmpegUtils:
|
| 9 |
+
"""Utilities for audio and video processing with FFmpeg"""
|
| 10 |
+
|
| 11 |
+
@staticmethod
|
| 12 |
+
def save_audio_as_wav(audio_data: bytes, output_path: Path):
|
| 13 |
+
"""
|
| 14 |
+
Save audio data as WAV file (normalized for Whisper)
|
| 15 |
+
|
| 16 |
+
Args:
|
| 17 |
+
audio_data: Raw audio bytes (WAV format from TTS)
|
| 18 |
+
output_path: Where to save the normalized WAV
|
| 19 |
+
"""
|
| 20 |
+
logger.debug(f"Saving normalized WAV to {output_path}")
|
| 21 |
+
|
| 22 |
+
# Write input data to temp file
|
| 23 |
+
temp_input = output_path.parent / f"temp_{output_path.name}"
|
| 24 |
+
temp_input.write_bytes(audio_data)
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
# Normalize audio for Whisper (16kHz, mono, 16-bit PCM)
|
| 28 |
+
subprocess.run([
|
| 29 |
+
"ffmpeg",
|
| 30 |
+
"-i", str(temp_input),
|
| 31 |
+
"-ar", "16000", # 16kHz sample rate
|
| 32 |
+
"-ac", "1", # Mono
|
| 33 |
+
"-sample_fmt", "s16", # 16-bit PCM
|
| 34 |
+
"-y", # Overwrite
|
| 35 |
+
str(output_path)
|
| 36 |
+
], check=True, capture_output=True)
|
| 37 |
+
|
| 38 |
+
logger.debug(f"Saved normalized WAV: {output_path}")
|
| 39 |
+
finally:
|
| 40 |
+
# Clean up temp file
|
| 41 |
+
if temp_input.exists():
|
| 42 |
+
temp_input.unlink()
|
| 43 |
+
|
| 44 |
+
@staticmethod
|
| 45 |
+
def save_audio_as_mp3(audio_data: bytes, output_path: Path):
|
| 46 |
+
"""
|
| 47 |
+
Convert audio data to MP3
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
audio_data: Raw audio bytes (WAV format from TTS)
|
| 51 |
+
output_path: Where to save the MP3
|
| 52 |
+
"""
|
| 53 |
+
logger.debug(f"Converting to MP3: {output_path}")
|
| 54 |
+
|
| 55 |
+
# Write input data to temp file
|
| 56 |
+
temp_input = output_path.parent / f"temp_{output_path.name}.wav"
|
| 57 |
+
temp_input.write_bytes(audio_data)
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
# Convert to MP3
|
| 61 |
+
subprocess.run([
|
| 62 |
+
"ffmpeg",
|
| 63 |
+
"-i", str(temp_input),
|
| 64 |
+
"-codec:a", "libmp3lame",
|
| 65 |
+
"-qscale:a", "2", # High quality
|
| 66 |
+
"-y", # Overwrite
|
| 67 |
+
str(output_path)
|
| 68 |
+
], check=True, capture_output=True)
|
| 69 |
+
|
| 70 |
+
logger.debug(f"Saved MP3: {output_path}")
|
| 71 |
+
finally:
|
| 72 |
+
if temp_input.exists():
|
| 73 |
+
temp_input.unlink()
|
| 74 |
+
|
| 75 |
+
@staticmethod
|
| 76 |
+
def get_video_duration(file_path: Path) -> float:
|
| 77 |
+
"""
|
| 78 |
+
Get duration of video file in seconds using ffprobe
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
file_path: Path to video file
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
Duration in seconds
|
| 85 |
+
"""
|
| 86 |
+
try:
|
| 87 |
+
cmd = [
|
| 88 |
+
"ffprobe",
|
| 89 |
+
"-v", "error",
|
| 90 |
+
"-show_entries", "format=duration",
|
| 91 |
+
"-of", "default=noprint_wrappers=1:nokey=1",
|
| 92 |
+
str(file_path)
|
| 93 |
+
]
|
| 94 |
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
| 95 |
+
return float(result.stdout.strip())
|
| 96 |
+
except Exception as e:
|
| 97 |
+
logger.error(f"Failed to get video duration for {file_path}: {e}")
|
| 98 |
+
return 0.0
|
| 99 |
+
|
| 100 |
+
@staticmethod
|
| 101 |
+
def normalize_video(input_path: Path, output_path: Path):
|
| 102 |
+
"""
|
| 103 |
+
Normalize video to standard format (H.264, 30fps, AAC) to fix seeking/black screen issues.
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
input_path: Path to source video
|
| 107 |
+
output_path: Path to save normalized video
|
| 108 |
+
"""
|
| 109 |
+
logger.debug(f"Normalizing video: {input_path} -> {output_path}")
|
| 110 |
+
|
| 111 |
+
try:
|
| 112 |
+
cmd = [
|
| 113 |
+
"ffmpeg",
|
| 114 |
+
"-i", str(input_path),
|
| 115 |
+
"-c:v", "libx264",
|
| 116 |
+
"-preset", "fast",
|
| 117 |
+
"-r", "30",
|
| 118 |
+
"-c:a", "aac",
|
| 119 |
+
"-pix_fmt", "yuv420p",
|
| 120 |
+
"-y",
|
| 121 |
+
str(output_path)
|
| 122 |
+
]
|
| 123 |
+
|
| 124 |
+
subprocess.run(cmd, check=True, capture_output=True)
|
| 125 |
+
logger.debug(f"Normalized video saved to {output_path}")
|
| 126 |
+
|
| 127 |
+
except subprocess.CalledProcessError as e:
|
| 128 |
+
logger.error(f"Failed to normalize video {input_path}: {e.stderr.decode()}")
|
| 129 |
+
raise e
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.error(f"Error normalizing video {input_path}: {e}")
|
| 132 |
+
raise e
|
| 133 |
+
|
| 134 |
+
@staticmethod
|
| 135 |
+
def cut_video(input_path: Path, output_path: Path, start_time: float, duration: float):
|
| 136 |
+
"""
|
| 137 |
+
Cut a segment from a video file using FFmpeg.
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
input_path: Source video
|
| 141 |
+
output_path: Destination for the segment
|
| 142 |
+
start_time: Start time in seconds
|
| 143 |
+
duration: Duration of the segment in seconds
|
| 144 |
+
"""
|
| 145 |
+
try:
|
| 146 |
+
cmd = [
|
| 147 |
+
"ffmpeg",
|
| 148 |
+
"-ss", str(start_time),
|
| 149 |
+
"-i", str(input_path),
|
| 150 |
+
"-t", str(duration),
|
| 151 |
+
"-c:v", "libx264",
|
| 152 |
+
"-preset", "fast",
|
| 153 |
+
"-c:a", "aac",
|
| 154 |
+
"-y",
|
| 155 |
+
str(output_path)
|
| 156 |
+
]
|
| 157 |
+
|
| 158 |
+
subprocess.run(cmd, check=True, capture_output=True)
|
| 159 |
+
|
| 160 |
+
except subprocess.CalledProcessError as e:
|
| 161 |
+
logger.error(f"Failed to cut video {input_path}: {e.stderr.decode()}")
|
| 162 |
+
raise e
|
| 163 |
+
|
| 164 |
+
@staticmethod
|
| 165 |
+
def image_to_video(input_path: Path, output_path: Path, duration: float):
|
| 166 |
+
"""
|
| 167 |
+
Convert image to video of specific duration
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
input_path: Path to source image (jpg, png, etc.)
|
| 171 |
+
output_path: Path to save the output video
|
| 172 |
+
duration: Duration of the video in seconds
|
| 173 |
+
"""
|
| 174 |
+
try:
|
| 175 |
+
cmd = [
|
| 176 |
+
"ffmpeg",
|
| 177 |
+
"-loop", "1",
|
| 178 |
+
"-i", str(input_path),
|
| 179 |
+
"-t", str(duration),
|
| 180 |
+
"-c:v", "libx264",
|
| 181 |
+
"-pix_fmt", "yuv420p",
|
| 182 |
+
"-vf", "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2",
|
| 183 |
+
"-r", "30",
|
| 184 |
+
"-y",
|
| 185 |
+
str(output_path)
|
| 186 |
+
]
|
| 187 |
+
subprocess.run(cmd, check=True, capture_output=True)
|
| 188 |
+
logger.debug(f"Created video from image: {output_path}")
|
| 189 |
+
except subprocess.CalledProcessError as e:
|
| 190 |
+
logger.error(f"Failed to convert image to video: {e.stderr.decode()}")
|
| 191 |
+
raise e
|
modules/video_creator/services/libraries/pexels_client.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
import logging
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import random
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class PexelsClient:
|
| 11 |
+
"""Client for Pexels API to fetch background videos"""
|
| 12 |
+
|
| 13 |
+
def __init__(self, api_key: str):
|
| 14 |
+
"""
|
| 15 |
+
Initialize Pexels client
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
api_key: Pexels API key
|
| 19 |
+
"""
|
| 20 |
+
self.api_key = api_key
|
| 21 |
+
self.base_url = "https://api.pexels.com/videos"
|
| 22 |
+
self.headers = {"Authorization": api_key}
|
| 23 |
+
self.joker_terms = ["nature", "globe", "space", "ocean"]
|
| 24 |
+
|
| 25 |
+
def find_video(
|
| 26 |
+
self,
|
| 27 |
+
search_terms: List[str],
|
| 28 |
+
duration: float,
|
| 29 |
+
exclude_ids: Optional[List[int]] = None,
|
| 30 |
+
orientation: str = "portrait"
|
| 31 |
+
) -> dict:
|
| 32 |
+
"""
|
| 33 |
+
Find a suitable video from Pexels
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
search_terms: Keywords to search for
|
| 37 |
+
duration: Required video duration in seconds
|
| 38 |
+
exclude_ids: List of video IDs to exclude
|
| 39 |
+
orientation: 'portrait' or 'landscape'
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
Dict with 'id' and 'url' of the selected video
|
| 43 |
+
"""
|
| 44 |
+
exclude_ids = exclude_ids or []
|
| 45 |
+
|
| 46 |
+
# Try user-provided search terms first
|
| 47 |
+
for term in search_terms:
|
| 48 |
+
video = self._search_and_select(term, duration, exclude_ids, orientation)
|
| 49 |
+
if video:
|
| 50 |
+
return video
|
| 51 |
+
|
| 52 |
+
# Fall back to joker terms
|
| 53 |
+
logger.info(f"No videos found for {search_terms}, using joker terms")
|
| 54 |
+
for term in self.joker_terms:
|
| 55 |
+
video = self._search_and_select(term, duration, exclude_ids, orientation)
|
| 56 |
+
if video:
|
| 57 |
+
return video
|
| 58 |
+
|
| 59 |
+
raise Exception("No suitable videos found on Pexels")
|
| 60 |
+
|
| 61 |
+
def _search_and_select(
|
| 62 |
+
self,
|
| 63 |
+
query: str,
|
| 64 |
+
min_duration: float,
|
| 65 |
+
exclude_ids: List[int],
|
| 66 |
+
orientation: str
|
| 67 |
+
) -> Optional[dict]:
|
| 68 |
+
"""Search for videos and select a suitable one"""
|
| 69 |
+
try:
|
| 70 |
+
logger.debug(f"Searching Pexels for: {query} ({orientation})")
|
| 71 |
+
|
| 72 |
+
response = requests.get(
|
| 73 |
+
f"{self.base_url}/search",
|
| 74 |
+
headers=self.headers,
|
| 75 |
+
params={
|
| 76 |
+
"query": query,
|
| 77 |
+
"orientation": orientation,
|
| 78 |
+
"per_page": 15,
|
| 79 |
+
"size": "medium" # Good balance of quality and file size
|
| 80 |
+
},
|
| 81 |
+
timeout=10
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
if response.status_code != 200:
|
| 85 |
+
logger.warning(f"Pexels API error: {response.status_code}")
|
| 86 |
+
return None
|
| 87 |
+
|
| 88 |
+
data = response.json()
|
| 89 |
+
videos = data.get("videos", [])
|
| 90 |
+
|
| 91 |
+
if not videos:
|
| 92 |
+
logger.debug(f"No videos found for query: {query}")
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
# Filter suitable videos
|
| 96 |
+
suitable_videos = []
|
| 97 |
+
for video in videos:
|
| 98 |
+
if video["id"] in exclude_ids:
|
| 99 |
+
continue
|
| 100 |
+
|
| 101 |
+
# Get video file URL (HD or SD)
|
| 102 |
+
video_files = video.get("video_files", [])
|
| 103 |
+
if not video_files:
|
| 104 |
+
continue
|
| 105 |
+
|
| 106 |
+
# Sort by quality and find a good match
|
| 107 |
+
video_files = sorted(
|
| 108 |
+
video_files,
|
| 109 |
+
key=lambda x: x.get("width", 0) * x.get("height", 0),
|
| 110 |
+
reverse=True
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Find appropriate quality based on orientation
|
| 114 |
+
target_width = 1080 if orientation == "portrait" else 1920
|
| 115 |
+
target_height = 1920 if orientation == "portrait" else 1080
|
| 116 |
+
|
| 117 |
+
selected_file = None
|
| 118 |
+
for vf in video_files:
|
| 119 |
+
# Look for files close to our target resolution
|
| 120 |
+
if vf.get("width") and vf.get("height"):
|
| 121 |
+
if (abs(vf["width"] - target_width) < 300 and
|
| 122 |
+
abs(vf["height"] - target_height) < 300):
|
| 123 |
+
selected_file = vf
|
| 124 |
+
break
|
| 125 |
+
|
| 126 |
+
# Fallback to highest quality if no exact match
|
| 127 |
+
if not selected_file and video_files:
|
| 128 |
+
selected_file = video_files[0]
|
| 129 |
+
|
| 130 |
+
if selected_file and selected_file.get("link"):
|
| 131 |
+
suitable_videos.append({
|
| 132 |
+
"id": video["id"],
|
| 133 |
+
"url": selected_file["link"],
|
| 134 |
+
"duration": video.get("duration", 0)
|
| 135 |
+
})
|
| 136 |
+
|
| 137 |
+
if not suitable_videos:
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
# Filter by duration if possible
|
| 141 |
+
# Try to find videos that are at least 50% of the requested duration
|
| 142 |
+
# to avoid stitching too many tiny clips
|
| 143 |
+
duration_threshold = min(min_duration * 0.5, 15) # Cap at 15s requirement
|
| 144 |
+
long_enough_videos = [v for v in suitable_videos if v["duration"] >= duration_threshold]
|
| 145 |
+
|
| 146 |
+
if long_enough_videos:
|
| 147 |
+
selected = random.choice(long_enough_videos)
|
| 148 |
+
logger.info(f"Selected Pexels video ID {selected['id']} (duration: {selected['duration']}s) for query '{query}'")
|
| 149 |
+
return selected
|
| 150 |
+
|
| 151 |
+
# Fallback to any suitable video
|
| 152 |
+
selected = random.choice(suitable_videos)
|
| 153 |
+
logger.info(f"Selected Pexels video ID {selected['id']} (duration: {selected['duration']}s) for query '{query}' (fallback)")
|
| 154 |
+
return selected
|
| 155 |
+
|
| 156 |
+
except Exception as e:
|
| 157 |
+
logger.error(f"Error searching Pexels: {e}")
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
def find_photo(
|
| 161 |
+
self,
|
| 162 |
+
query: str,
|
| 163 |
+
orientation: str = "portrait"
|
| 164 |
+
) -> Optional[dict]:
|
| 165 |
+
"""
|
| 166 |
+
Find a suitable photo from Pexels
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
query: Search term
|
| 170 |
+
orientation: 'portrait' or 'landscape'
|
| 171 |
+
|
| 172 |
+
Returns:
|
| 173 |
+
Dict with 'id' and 'url' of the photo
|
| 174 |
+
"""
|
| 175 |
+
try:
|
| 176 |
+
logger.debug(f"Searching Pexels for photo: {query} ({orientation})")
|
| 177 |
+
|
| 178 |
+
# Pexels Photo API endpoint
|
| 179 |
+
url = "https://api.pexels.com/v1/search"
|
| 180 |
+
|
| 181 |
+
response = requests.get(
|
| 182 |
+
url,
|
| 183 |
+
headers=self.headers,
|
| 184 |
+
params={
|
| 185 |
+
"query": query,
|
| 186 |
+
"orientation": orientation,
|
| 187 |
+
"per_page": 15,
|
| 188 |
+
"size": "large"
|
| 189 |
+
},
|
| 190 |
+
timeout=10
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
if response.status_code != 200:
|
| 194 |
+
logger.warning(f"Pexels Photo API error: {response.status_code}")
|
| 195 |
+
return None
|
| 196 |
+
|
| 197 |
+
data = response.json()
|
| 198 |
+
photos = data.get("photos", [])
|
| 199 |
+
|
| 200 |
+
if not photos:
|
| 201 |
+
logger.debug(f"No photos found for query: {query}")
|
| 202 |
+
return None
|
| 203 |
+
|
| 204 |
+
# Select a random photo
|
| 205 |
+
photo = random.choice(photos)
|
| 206 |
+
|
| 207 |
+
# Get URL (prefer original or large2x)
|
| 208 |
+
src = photo.get("src", {})
|
| 209 |
+
url = src.get("original") or src.get("large2x") or src.get("large")
|
| 210 |
+
|
| 211 |
+
if not url:
|
| 212 |
+
return None
|
| 213 |
+
|
| 214 |
+
logger.info(f"Selected Pexels photo ID {photo['id']} for query '{query}'")
|
| 215 |
+
return {
|
| 216 |
+
"id": photo["id"],
|
| 217 |
+
"url": url,
|
| 218 |
+
"type": "photo"
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
except Exception as e:
|
| 222 |
+
logger.error(f"Error searching Pexels photos: {e}")
|
| 223 |
+
return None
|