ismdrobiul489 commited on
Commit
7fa9d90
·
0 Parent(s):

Initial commit: NCAkit with Story Reels module

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +57 -0
  2. .gitattributes +1 -0
  3. Dockerfile +41 -0
  4. README.md +110 -0
  5. __pycache__/app.cpython-313.pyc +0 -0
  6. __pycache__/config.cpython-313.pyc +0 -0
  7. app.py +115 -0
  8. config.py +115 -0
  9. core/__init__.py +1 -0
  10. core/__pycache__/module_registry.cpython-313.pyc +0 -0
  11. core/module_registry.py +145 -0
  12. core/utils/__init__.py +1 -0
  13. modules/__init__.py +1 -0
  14. modules/_template/__init__.py +2 -0
  15. modules/_template/module.py +38 -0
  16. modules/_template/router.py +24 -0
  17. modules/_template/schemas.py +20 -0
  18. modules/story_reels/__init__.py +97 -0
  19. modules/story_reels/__pycache__/__init__.cpython-313.pyc +0 -0
  20. modules/story_reels/__pycache__/router.cpython-313.pyc +0 -0
  21. modules/story_reels/__pycache__/schemas.cpython-313.pyc +0 -0
  22. modules/story_reels/router.py +117 -0
  23. modules/story_reels/schemas.py +114 -0
  24. modules/story_reels/services/__init__.py +1 -0
  25. modules/story_reels/services/__pycache__/cloudflare_client.cpython-313.pyc +0 -0
  26. modules/story_reels/services/__pycache__/nvidia_client.cpython-313.pyc +0 -0
  27. modules/story_reels/services/__pycache__/prompt_builder.cpython-313.pyc +0 -0
  28. modules/story_reels/services/__pycache__/script_generator.cpython-313.pyc +0 -0
  29. modules/story_reels/services/__pycache__/srt_parser.cpython-313.pyc +0 -0
  30. modules/story_reels/services/__pycache__/story_creator.cpython-313.pyc +0 -0
  31. modules/story_reels/services/cloudflare_client.py +198 -0
  32. modules/story_reels/services/nvidia_client.py +235 -0
  33. modules/story_reels/services/prompt_builder.py +147 -0
  34. modules/story_reels/services/script_generator.py +256 -0
  35. modules/story_reels/services/srt_parser.py +214 -0
  36. modules/story_reels/services/story_creator.py +583 -0
  37. modules/video_creator/__init__.py +81 -0
  38. modules/video_creator/__pycache__/__init__.cpython-313.pyc +0 -0
  39. modules/video_creator/__pycache__/router.cpython-313.pyc +0 -0
  40. modules/video_creator/router.py +130 -0
  41. modules/video_creator/schemas.py +143 -0
  42. modules/video_creator/services/__init__.py +0 -0
  43. modules/video_creator/services/__pycache__/__init__.cpython-313.pyc +0 -0
  44. modules/video_creator/services/__pycache__/short_creator.cpython-313.pyc +0 -0
  45. modules/video_creator/services/libraries/__init__.py +0 -0
  46. modules/video_creator/services/libraries/__pycache__/__init__.cpython-313.pyc +0 -0
  47. modules/video_creator/services/libraries/__pycache__/tts_client.cpython-313.pyc +0 -0
  48. modules/video_creator/services/libraries/__pycache__/whisper_client.cpython-313.pyc +0 -0
  49. modules/video_creator/services/libraries/ffmpeg_utils.py +191 -0
  50. 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