Upload 205 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +4 -0
- services/ffmpeg_automation/.gitattributes +35 -0
- services/ffmpeg_automation/Dockerfile +30 -0
- services/ffmpeg_automation/README.md +217 -0
- services/ffmpeg_automation/app.py +2215 -0
- services/ffmpeg_automation/requirements.txt +6 -0
- services/ktts/.gitattributes +35 -0
- services/ktts/.gitignore +171 -0
- services/ktts/Dockerfile +22 -0
- services/ktts/README.md +9 -0
- services/ktts/README_git.md +94 -0
- services/ktts/app.py +130 -0
- services/ktts/assets/edu_note.wav +3 -0
- services/ktts/assets/fun_fact.wav +3 -0
- services/ktts/assets/thanks.wav +3 -0
- services/ktts/example.ipynb +0 -0
- services/ktts/gradio_demo.png +3 -0
- services/ktts/inference.py +25 -0
- services/ktts/models/__init__.py +2 -0
- services/ktts/models/kokoro.py +125 -0
- services/ktts/models/tokenizer.py +238 -0
- services/ktts/requirements.txt +9 -0
- services/ktts/voices/af.pt +3 -0
- services/ktts/voices/af_bella.pt +3 -0
- services/ktts/voices/af_nicole.pt +3 -0
- services/ktts/voices/af_sarah.pt +3 -0
- services/ktts/voices/af_sky.pt +3 -0
- services/ktts/voices/am_adam.pt +3 -0
- services/ktts/voices/am_michael.pt +3 -0
- services/ktts/voices/bf_emma.pt +3 -0
- services/ktts/voices/bf_isabella.pt +3 -0
- services/ktts/voices/bm_george.pt +3 -0
- services/ktts/voices/bm_lewis.pt +3 -0
- services/ktts/weights/.gitkeep +0 -0
- services/ktts/weights/kokoro-quant.onnx +3 -0
- services/ktts/weights/kokoro-v0_19.onnx +3 -0
- services/musicgen/.gitattributes +35 -0
- services/musicgen/Dockerfile +21 -0
- services/musicgen/README.md +10 -0
- services/musicgen/app.py +74 -0
- services/musicgen/generate.py +58 -0
- services/musicgen/requirements.txt +10 -0
- services/musicgen/storage.py +24 -0
- services/musicgen/styles.py +21 -0
- services/render_engine/.gitattributes +35 -0
- services/render_engine/.gitignore +29 -0
- services/render_engine/Dockerfile +40 -0
- services/render_engine/README.md +365 -0
- services/render_engine/api.py +1314 -0
- services/render_engine/app.py +376 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
services/ktts/assets/edu_note.wav filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
services/ktts/assets/fun_fact.wav filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
services/ktts/assets/thanks.wav filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
services/ktts/gradio_demo.png filter=lfs diff=lfs merge=lfs -text
|
services/ffmpeg_automation/.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
services/ffmpeg_automation/Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
# Install system binaries and fonts
|
| 4 |
+
RUN apt-get update && apt-get install -y \
|
| 5 |
+
ffmpeg \
|
| 6 |
+
wget \
|
| 7 |
+
libmagic1 \
|
| 8 |
+
fonts-dejavu-core \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
# Create temp dir and set permissions for HF user
|
| 14 |
+
RUN mkdir -p /app/temp && chmod 777 /app/temp
|
| 15 |
+
|
| 16 |
+
COPY requirements.txt .
|
| 17 |
+
RUN pip install --no-cache-dir -U yt-dlp && \
|
| 18 |
+
pip install --no-cache-dir -r requirements.txt
|
| 19 |
+
|
| 20 |
+
COPY . .
|
| 21 |
+
|
| 22 |
+
# Set permissions for the entire app dir to avoid runtime write issues
|
| 23 |
+
RUN chmod -R 777 /app
|
| 24 |
+
|
| 25 |
+
ENV PYTHONUNBUFFERED=1
|
| 26 |
+
ENV TEMP_DIR=/app/temp
|
| 27 |
+
|
| 28 |
+
EXPOSE 7860
|
| 29 |
+
|
| 30 |
+
CMD ["python", "app.py"]
|
services/ffmpeg_automation/README.md
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Ffmpeg
|
| 3 |
+
emoji: 👀
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Basyx FFmpeg Automation Hub
|
| 12 |
+
|
| 13 |
+
FastAPI + Gradio app for common FFmpeg media tasks. It can run directly in
|
| 14 |
+
Docker/Hugging Face Spaces and exposes both a browser UI and HTTP API.
|
| 15 |
+
|
| 16 |
+
## Features
|
| 17 |
+
|
| 18 |
+
- Task-specific Gradio controls for video, audio, subtitle, image, and social presets.
|
| 19 |
+
- File and URL inputs, including `yt-dlp` downloads.
|
| 20 |
+
- Input validation with `python-magic`, upload size limits, and temp-file cleanup.
|
| 21 |
+
- Synchronous execution at `/execute/{task_id}`.
|
| 22 |
+
- Background jobs at `/jobs/{task_id}` with `/status/{job_id}` and `/download/{job_id}`.
|
| 23 |
+
- Recent output history at `/history` with per-item download URLs.
|
| 24 |
+
- Configurable FFmpeg options: CRF, preset, resolution, audio bitrate, trim times,
|
| 25 |
+
aspect ratio, GIF settings, watermark settings, speed, frame rate, and text styling.
|
| 26 |
+
- Persistent SQLite job/history state.
|
| 27 |
+
- FFmpeg execution timeout, production readiness checks, and URL download limits.
|
| 28 |
+
- Faceless short-video automation for quote cards, story slides, image narration,
|
| 29 |
+
b-roll narration, and vertical montage generation.
|
| 30 |
+
- TikTok/Reels mini-series packaging with numbered episodes, recap cards, and
|
| 31 |
+
ZIP exports containing publish-order manifests.
|
| 32 |
+
|
| 33 |
+
## Tasks
|
| 34 |
+
|
| 35 |
+
Call `GET /tasks` to list the current task registry with file requirements.
|
| 36 |
+
|
| 37 |
+
Current tasks:
|
| 38 |
+
|
| 39 |
+
- `normalize`
|
| 40 |
+
- `extract_audio`
|
| 41 |
+
- `resize_916`
|
| 42 |
+
- `add_subtitles`
|
| 43 |
+
- `burn_lyrics`
|
| 44 |
+
- `text_overlay`
|
| 45 |
+
- `merge_music`
|
| 46 |
+
- `thumbnail`
|
| 47 |
+
- `watermark`
|
| 48 |
+
- `compress`
|
| 49 |
+
- `batch_compress`
|
| 50 |
+
- `make_gif`
|
| 51 |
+
- `tiktok_lyrics`
|
| 52 |
+
- `tiktok_pro_reframer`
|
| 53 |
+
- `reels_blur_fit`
|
| 54 |
+
- `reels_safe_caption`
|
| 55 |
+
- `reels_hook_title`
|
| 56 |
+
- `reels_progress_bar`
|
| 57 |
+
- `reels_loop`
|
| 58 |
+
- `reels_subtitle_safe`
|
| 59 |
+
- `reels_reaction_stack`
|
| 60 |
+
- `reels_audio_duck`
|
| 61 |
+
- `faceless_quote_card`
|
| 62 |
+
- `faceless_story_pages`
|
| 63 |
+
- `faceless_image_narration`
|
| 64 |
+
- `faceless_video_narration`
|
| 65 |
+
- `faceless_broll_montage`
|
| 66 |
+
- `series_split_pack`
|
| 67 |
+
- `series_episode_badge`
|
| 68 |
+
- `series_batch_pack`
|
| 69 |
+
- `series_recap_card`
|
| 70 |
+
- `concat`
|
| 71 |
+
- `slideshow`
|
| 72 |
+
- `trim`
|
| 73 |
+
- `crop_aspect`
|
| 74 |
+
- `waveform`
|
| 75 |
+
- `extract_frames`
|
| 76 |
+
- `add_intro_outro`
|
| 77 |
+
- `speed`
|
| 78 |
+
- `remove_audio`
|
| 79 |
+
- `replace_audio`
|
| 80 |
+
|
| 81 |
+
## API Examples
|
| 82 |
+
|
| 83 |
+
Run a task immediately and download the returned file:
|
| 84 |
+
|
| 85 |
+
```bash
|
| 86 |
+
curl -X POST \
|
| 87 |
+
-F "files=@input.mp4" \
|
| 88 |
+
-F "resolution=720p" \
|
| 89 |
+
-F "crf=28" \
|
| 90 |
+
http://localhost:7860/execute/compress \
|
| 91 |
+
--output compressed.mp4
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
Run a background job:
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
curl -X POST \
|
| 98 |
+
-F "files=@input.mp4" \
|
| 99 |
+
-F "text=Launch caption" \
|
| 100 |
+
http://localhost:7860/jobs/text_overlay
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
Check the job:
|
| 104 |
+
|
| 105 |
+
```bash
|
| 106 |
+
curl http://localhost:7860/status/<job_id>
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
Download a completed background job:
|
| 110 |
+
|
| 111 |
+
```bash
|
| 112 |
+
curl http://localhost:7860/download/<job_id> --output result.mp4
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
Use a URL input:
|
| 116 |
+
|
| 117 |
+
```bash
|
| 118 |
+
curl -X POST \
|
| 119 |
+
-F "url=https://example.com/video.mp4" \
|
| 120 |
+
http://localhost:7860/execute/thumbnail \
|
| 121 |
+
--output thumbnail.jpg
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
## n8n Inputs
|
| 125 |
+
|
| 126 |
+
Use `POST /n8n/execute/{task_id}` for immediate file output or
|
| 127 |
+
`POST /n8n/jobs/{task_id}` for background jobs. These endpoints accept common
|
| 128 |
+
n8n HTTP Request node payload styles:
|
| 129 |
+
|
| 130 |
+
- multipart form-data with any binary field name
|
| 131 |
+
- form-data URL fields: `url`, `urls`, `file_url`, `source_url`, `download_url`
|
| 132 |
+
- JSON base64 files in `files`, `binary`, or `data`
|
| 133 |
+
- JSON URL lists
|
| 134 |
+
- raw binary body for single-file tasks, with optional `X-Filename` header
|
| 135 |
+
|
| 136 |
+
Multipart binary from n8n:
|
| 137 |
+
|
| 138 |
+
```bash
|
| 139 |
+
curl -X POST \
|
| 140 |
+
-F "myBinary=@input.mp4" \
|
| 141 |
+
-F "text=Episode 1" \
|
| 142 |
+
http://localhost:7860/n8n/execute/series_episode_badge \
|
| 143 |
+
--output episode.mp4
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
JSON base64:
|
| 147 |
+
|
| 148 |
+
```json
|
| 149 |
+
{
|
| 150 |
+
"text": "Episode title",
|
| 151 |
+
"files": [
|
| 152 |
+
{
|
| 153 |
+
"fileName": "input.mp4",
|
| 154 |
+
"mimeType": "video/mp4",
|
| 155 |
+
"data": "<base64>"
|
| 156 |
+
}
|
| 157 |
+
]
|
| 158 |
+
}
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
JSON URLs:
|
| 162 |
+
|
| 163 |
+
```json
|
| 164 |
+
{
|
| 165 |
+
"urls": ["https://example.com/input.mp4"],
|
| 166 |
+
"text": "Mini series title",
|
| 167 |
+
"duration": "60"
|
| 168 |
+
}
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
Raw binary:
|
| 172 |
+
|
| 173 |
+
```bash
|
| 174 |
+
curl -X POST \
|
| 175 |
+
-H "Content-Type: application/octet-stream" \
|
| 176 |
+
-H "X-Filename: input.mp4" \
|
| 177 |
+
--data-binary @input.mp4 \
|
| 178 |
+
http://localhost:7860/n8n/execute/reels_blur_fit \
|
| 179 |
+
--output output.mp4
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
## Configuration
|
| 183 |
+
|
| 184 |
+
Environment variables:
|
| 185 |
+
|
| 186 |
+
- `TEMP_DIR`: working directory for uploads and outputs. Default: `temp`.
|
| 187 |
+
- `STATE_DB_PATH`: SQLite path for job/history state. Default: `TEMP_DIR/state.sqlite3`.
|
| 188 |
+
- `FONT_PATH`: font used by FFmpeg `drawtext`. Default:
|
| 189 |
+
`/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf`.
|
| 190 |
+
- `MAX_UPLOAD_MB`: max upload size per file. Default: `500`.
|
| 191 |
+
- `MAX_URL_DOWNLOAD_MB`: max URL download size. Default: same as `MAX_UPLOAD_MB`.
|
| 192 |
+
- `ALLOW_URL_INPUTS`: enable/disable URL downloads. Default: `true`.
|
| 193 |
+
- `FFMPEG_TIMEOUT_SECONDS`: max runtime for an FFmpeg command. Default: `1800`.
|
| 194 |
+
- `FILE_TTL_SECONDS`: temp file lifetime. Default: `3600`.
|
| 195 |
+
- `HISTORY_LIMIT`: number of recent output records to keep. Default: `50`.
|
| 196 |
+
|
| 197 |
+
## Production Checks
|
| 198 |
+
|
| 199 |
+
- `GET /healthz`: process health and dependency check details.
|
| 200 |
+
- `GET /readyz`: returns `503` if required runtime dependencies are missing.
|
| 201 |
+
|
| 202 |
+
Required runtime dependencies:
|
| 203 |
+
|
| 204 |
+
- `ffmpeg`
|
| 205 |
+
- `ffprobe`
|
| 206 |
+
- writable `TEMP_DIR`
|
| 207 |
+
- writable SQLite state database
|
| 208 |
+
- readable `FONT_PATH`
|
| 209 |
+
|
| 210 |
+
## Local Run
|
| 211 |
+
|
| 212 |
+
```bash
|
| 213 |
+
pip install -r requirements.txt
|
| 214 |
+
python app.py
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
The app listens on `http://localhost:7860`.
|
services/ffmpeg_automation/app.py
ADDED
|
@@ -0,0 +1,2215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import binascii
|
| 3 |
+
import os
|
| 4 |
+
import shutil
|
| 5 |
+
import sqlite3
|
| 6 |
+
import subprocess
|
| 7 |
+
import textwrap
|
| 8 |
+
import threading
|
| 9 |
+
import time
|
| 10 |
+
import uuid
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from typing import Callable, Dict, List, Optional
|
| 13 |
+
|
| 14 |
+
import gradio as gr
|
| 15 |
+
import magic
|
| 16 |
+
import uvicorn
|
| 17 |
+
import yt_dlp
|
| 18 |
+
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, Request, UploadFile
|
| 19 |
+
from fastapi.responses import FileResponse
|
| 20 |
+
|
| 21 |
+
app = FastAPI(title="Basyx FFmpeg Automation Hub")
|
| 22 |
+
|
| 23 |
+
TEMP_DIR = os.getenv("TEMP_DIR", "temp")
|
| 24 |
+
FONT_PATH = os.getenv(
|
| 25 |
+
"FONT_PATH", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
| 26 |
+
)
|
| 27 |
+
MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "500"))
|
| 28 |
+
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
|
| 29 |
+
FILE_TTL_SECONDS = int(os.getenv("FILE_TTL_SECONDS", "3600"))
|
| 30 |
+
HISTORY_LIMIT = int(os.getenv("HISTORY_LIMIT", "50"))
|
| 31 |
+
FFMPEG_TIMEOUT_SECONDS = int(os.getenv("FFMPEG_TIMEOUT_SECONDS", "1800"))
|
| 32 |
+
MAX_URL_DOWNLOAD_MB = int(os.getenv("MAX_URL_DOWNLOAD_MB", str(MAX_UPLOAD_MB)))
|
| 33 |
+
MAX_URL_DOWNLOAD_BYTES = MAX_URL_DOWNLOAD_MB * 1024 * 1024
|
| 34 |
+
ALLOW_URL_INPUTS = os.getenv("ALLOW_URL_INPUTS", "true").lower() in {"1", "true", "yes"}
|
| 35 |
+
STATE_DB_PATH = os.getenv("STATE_DB_PATH", os.path.join(TEMP_DIR, "state.sqlite3"))
|
| 36 |
+
OPTION_FIELDS = [
|
| 37 |
+
"text",
|
| 38 |
+
"start_time",
|
| 39 |
+
"end_time",
|
| 40 |
+
"duration",
|
| 41 |
+
"aspect_ratio",
|
| 42 |
+
"resolution",
|
| 43 |
+
"crf",
|
| 44 |
+
"preset",
|
| 45 |
+
"audio_bitrate",
|
| 46 |
+
"volume",
|
| 47 |
+
"position",
|
| 48 |
+
"opacity",
|
| 49 |
+
"fps",
|
| 50 |
+
"width",
|
| 51 |
+
"speed",
|
| 52 |
+
"timestamp",
|
| 53 |
+
"image_duration",
|
| 54 |
+
"frame_rate",
|
| 55 |
+
"font_size",
|
| 56 |
+
"wave_color",
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
os.makedirs(TEMP_DIR, exist_ok=True)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass(frozen=True)
|
| 63 |
+
class TaskDefinition:
|
| 64 |
+
label: str
|
| 65 |
+
description: str
|
| 66 |
+
category: str
|
| 67 |
+
output_ext: str
|
| 68 |
+
min_files: int
|
| 69 |
+
max_files: Optional[int]
|
| 70 |
+
accepted_types: List[str]
|
| 71 |
+
builder: Optional[Callable[[List[str], Dict[str, str], str], List[str]]] = None
|
| 72 |
+
runner: Optional[Callable[[List[str], Dict[str, str], str], str]] = None
|
| 73 |
+
file_types: Optional[List[List[str]]] = None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
JOBS: Dict[str, Dict[str, object]] = {}
|
| 77 |
+
HISTORY: List[Dict[str, object]] = []
|
| 78 |
+
STATE_LOCK = threading.Lock()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def db_connect():
|
| 82 |
+
connection = sqlite3.connect(STATE_DB_PATH, timeout=30)
|
| 83 |
+
connection.row_factory = sqlite3.Row
|
| 84 |
+
return connection
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def init_state_store() -> None:
|
| 88 |
+
with db_connect() as connection:
|
| 89 |
+
connection.execute(
|
| 90 |
+
"""
|
| 91 |
+
CREATE TABLE IF NOT EXISTS jobs (
|
| 92 |
+
job_id TEXT PRIMARY KEY,
|
| 93 |
+
task_id TEXT NOT NULL,
|
| 94 |
+
status TEXT NOT NULL,
|
| 95 |
+
message TEXT NOT NULL,
|
| 96 |
+
output TEXT,
|
| 97 |
+
created_at INTEGER NOT NULL,
|
| 98 |
+
updated_at INTEGER NOT NULL
|
| 99 |
+
)
|
| 100 |
+
"""
|
| 101 |
+
)
|
| 102 |
+
connection.execute(
|
| 103 |
+
"""
|
| 104 |
+
CREATE TABLE IF NOT EXISTS history (
|
| 105 |
+
history_id TEXT PRIMARY KEY,
|
| 106 |
+
job_id TEXT NOT NULL,
|
| 107 |
+
task_id TEXT NOT NULL,
|
| 108 |
+
task TEXT NOT NULL,
|
| 109 |
+
output TEXT NOT NULL,
|
| 110 |
+
filename TEXT NOT NULL,
|
| 111 |
+
size INTEGER NOT NULL,
|
| 112 |
+
created_at INTEGER NOT NULL
|
| 113 |
+
)
|
| 114 |
+
"""
|
| 115 |
+
)
|
| 116 |
+
connection.execute(
|
| 117 |
+
"CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC)"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def load_state_store() -> None:
|
| 122 |
+
with STATE_LOCK, db_connect() as connection:
|
| 123 |
+
JOBS.clear()
|
| 124 |
+
for row in connection.execute("SELECT * FROM jobs ORDER BY created_at DESC"):
|
| 125 |
+
JOBS[row["job_id"]] = dict(row)
|
| 126 |
+
HISTORY.clear()
|
| 127 |
+
for row in connection.execute(
|
| 128 |
+
"SELECT * FROM history ORDER BY created_at DESC LIMIT ?", (HISTORY_LIMIT,)
|
| 129 |
+
):
|
| 130 |
+
item = dict(row)
|
| 131 |
+
item["download_url"] = f"/history/{item['history_id']}/download"
|
| 132 |
+
HISTORY.append(item)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def persist_job(job: Dict[str, object]) -> None:
|
| 136 |
+
now = int(time.time())
|
| 137 |
+
with db_connect() as connection:
|
| 138 |
+
connection.execute(
|
| 139 |
+
"""
|
| 140 |
+
INSERT INTO jobs (job_id, task_id, status, message, output, created_at, updated_at)
|
| 141 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 142 |
+
ON CONFLICT(job_id) DO UPDATE SET
|
| 143 |
+
status=excluded.status,
|
| 144 |
+
message=excluded.message,
|
| 145 |
+
output=excluded.output,
|
| 146 |
+
updated_at=excluded.updated_at
|
| 147 |
+
""",
|
| 148 |
+
(
|
| 149 |
+
str(job["job_id"]),
|
| 150 |
+
str(job["task_id"]),
|
| 151 |
+
str(job["status"]),
|
| 152 |
+
str(job["message"]),
|
| 153 |
+
str(job.get("output") or ""),
|
| 154 |
+
int(job.get("created_at") or now),
|
| 155 |
+
now,
|
| 156 |
+
),
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def persist_history(item: Dict[str, object]) -> None:
|
| 161 |
+
with db_connect() as connection:
|
| 162 |
+
connection.execute(
|
| 163 |
+
"""
|
| 164 |
+
INSERT OR REPLACE INTO history
|
| 165 |
+
(history_id, job_id, task_id, task, output, filename, size, created_at)
|
| 166 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 167 |
+
""",
|
| 168 |
+
(
|
| 169 |
+
str(item["history_id"]),
|
| 170 |
+
str(item["job_id"]),
|
| 171 |
+
str(item["task_id"]),
|
| 172 |
+
str(item["task"]),
|
| 173 |
+
str(item["output"]),
|
| 174 |
+
str(item["filename"]),
|
| 175 |
+
int(item["size"]),
|
| 176 |
+
int(item["created_at"]),
|
| 177 |
+
),
|
| 178 |
+
)
|
| 179 |
+
stale_ids = [
|
| 180 |
+
row["history_id"]
|
| 181 |
+
for row in connection.execute(
|
| 182 |
+
"""
|
| 183 |
+
SELECT history_id FROM history
|
| 184 |
+
ORDER BY created_at DESC
|
| 185 |
+
LIMIT -1 OFFSET ?
|
| 186 |
+
""",
|
| 187 |
+
(HISTORY_LIMIT,),
|
| 188 |
+
)
|
| 189 |
+
]
|
| 190 |
+
if stale_ids:
|
| 191 |
+
connection.executemany(
|
| 192 |
+
"DELETE FROM history WHERE history_id = ?",
|
| 193 |
+
[(history_id,) for history_id in stale_ids],
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
init_state_store()
|
| 198 |
+
load_state_store()
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def ffmpeg_escape(value: str) -> str:
|
| 202 |
+
return (
|
| 203 |
+
value.replace("\\", "\\\\")
|
| 204 |
+
.replace(":", "\\:")
|
| 205 |
+
.replace("'", "\\'")
|
| 206 |
+
.replace("\n", "\\n")
|
| 207 |
+
.replace("%", "\\%")
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def filter_path(path: str) -> str:
|
| 212 |
+
return ffmpeg_escape(os.path.abspath(path))
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def ffconcat_path(path: str) -> str:
|
| 216 |
+
return os.path.abspath(path).replace("\\", "\\\\").replace("'", "\\'")
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def drawtext_file_path(path: str) -> str:
|
| 220 |
+
return filter_path(path)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def remove_file_quietly(path: str) -> None:
|
| 224 |
+
try:
|
| 225 |
+
os.remove(path)
|
| 226 |
+
except FileNotFoundError:
|
| 227 |
+
return
|
| 228 |
+
except OSError:
|
| 229 |
+
return
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def clamp_int(value: str, default: int, minimum: int, maximum: int) -> int:
|
| 233 |
+
try:
|
| 234 |
+
parsed = int(float(value))
|
| 235 |
+
except (TypeError, ValueError):
|
| 236 |
+
parsed = default
|
| 237 |
+
return max(minimum, min(maximum, parsed))
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def clamp_float(value: str, default: float, minimum: float, maximum: float) -> float:
|
| 241 |
+
try:
|
| 242 |
+
parsed = float(value)
|
| 243 |
+
except (TypeError, ValueError):
|
| 244 |
+
parsed = default
|
| 245 |
+
return max(minimum, min(maximum, parsed))
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def dimensions(preset: str) -> tuple[int, int]:
|
| 249 |
+
presets = {
|
| 250 |
+
"9:16": (1080, 1920),
|
| 251 |
+
"16:9": (1920, 1080),
|
| 252 |
+
"1:1": (1080, 1080),
|
| 253 |
+
"4:5": (1080, 1350),
|
| 254 |
+
}
|
| 255 |
+
return presets.get(preset, presets["9:16"])
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def scaled_crop_filter(preset: str) -> str:
|
| 259 |
+
width, height = dimensions(preset)
|
| 260 |
+
return (
|
| 261 |
+
f"scale={width}:{height}:force_original_aspect_ratio=increase,"
|
| 262 |
+
f"crop={width}:{height}"
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def output_scale_filter(size: str) -> str:
|
| 267 |
+
sizes = {"480p": 480, "720p": 720, "1080p": 1080}
|
| 268 |
+
height = sizes.get(size, 720)
|
| 269 |
+
return f"scale=-2:{height}"
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def write_wrapped_text_file(text: str, width: int = 24, max_lines: Optional[int] = None) -> str:
|
| 273 |
+
cleaned = " ".join((text or "").split())
|
| 274 |
+
if not cleaned:
|
| 275 |
+
cleaned = "Your faceless video"
|
| 276 |
+
lines = textwrap.wrap(cleaned, width=width, break_long_words=False)
|
| 277 |
+
if max_lines:
|
| 278 |
+
lines = lines[:max_lines]
|
| 279 |
+
path = os.path.abspath(os.path.join(TEMP_DIR, f"text_{uuid.uuid4().hex[:8]}.txt"))
|
| 280 |
+
with open(path, "w", encoding="utf-8") as handle:
|
| 281 |
+
handle.write("\n".join(lines))
|
| 282 |
+
return path
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def split_story_pages(text: str, words_per_page: int = 22) -> List[str]:
|
| 286 |
+
words = (text or "").split()
|
| 287 |
+
if not words:
|
| 288 |
+
words = ["Add", "story", "text", "to", "generate", "faceless", "video", "slides."]
|
| 289 |
+
pages = [
|
| 290 |
+
" ".join(words[index : index + words_per_page])
|
| 291 |
+
for index in range(0, len(words), words_per_page)
|
| 292 |
+
]
|
| 293 |
+
return pages[:20]
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def production_checks() -> Dict[str, Dict[str, object]]:
|
| 297 |
+
checks = {
|
| 298 |
+
"ffmpeg": {"ok": shutil.which("ffmpeg") is not None},
|
| 299 |
+
"ffprobe": {"ok": shutil.which("ffprobe") is not None},
|
| 300 |
+
"font": {"ok": os.path.exists(FONT_PATH), "path": FONT_PATH},
|
| 301 |
+
"temp_dir": {
|
| 302 |
+
"ok": os.path.isdir(TEMP_DIR) and os.access(TEMP_DIR, os.W_OK),
|
| 303 |
+
"path": TEMP_DIR,
|
| 304 |
+
},
|
| 305 |
+
"state_db": {
|
| 306 |
+
"ok": os.path.exists(STATE_DB_PATH) and os.access(STATE_DB_PATH, os.W_OK),
|
| 307 |
+
"path": STATE_DB_PATH,
|
| 308 |
+
},
|
| 309 |
+
}
|
| 310 |
+
checks["url_inputs"] = {"ok": True, "enabled": ALLOW_URL_INPUTS}
|
| 311 |
+
return checks
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def production_ready() -> bool:
|
| 315 |
+
required = ["ffmpeg", "ffprobe", "font", "temp_dir", "state_db"]
|
| 316 |
+
checks = production_checks()
|
| 317 |
+
return all(bool(checks[name]["ok"]) for name in required)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def add_video_quality(cmd: List[str], opts: Dict[str, str]) -> List[str]:
|
| 321 |
+
crf = str(clamp_int(opts.get("crf", "23"), 23, 12, 35))
|
| 322 |
+
preset = opts.get("preset") or "fast"
|
| 323 |
+
if preset not in {"ultrafast", "veryfast", "fast", "medium", "slow"}:
|
| 324 |
+
preset = "fast"
|
| 325 |
+
return cmd + ["-c:v", "libx264", "-preset", preset, "-crf", crf, "-c:a", "aac"]
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def run_command(cmd: List[str]) -> subprocess.CompletedProcess:
|
| 329 |
+
try:
|
| 330 |
+
return subprocess.run(
|
| 331 |
+
cmd,
|
| 332 |
+
check=True,
|
| 333 |
+
capture_output=True,
|
| 334 |
+
text=True,
|
| 335 |
+
timeout=FFMPEG_TIMEOUT_SECONDS,
|
| 336 |
+
)
|
| 337 |
+
except subprocess.TimeoutExpired as exc:
|
| 338 |
+
raise HTTPException(
|
| 339 |
+
status_code=504,
|
| 340 |
+
detail=f"FFmpeg timed out after {FFMPEG_TIMEOUT_SECONDS} seconds.",
|
| 341 |
+
) from exc
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def media_duration_seconds(path: str) -> float:
|
| 345 |
+
result = run_command(
|
| 346 |
+
[
|
| 347 |
+
"ffprobe",
|
| 348 |
+
"-v",
|
| 349 |
+
"error",
|
| 350 |
+
"-show_entries",
|
| 351 |
+
"format=duration",
|
| 352 |
+
"-of",
|
| 353 |
+
"default=noprint_wrappers=1:nokey=1",
|
| 354 |
+
path,
|
| 355 |
+
]
|
| 356 |
+
)
|
| 357 |
+
try:
|
| 358 |
+
duration = float(result.stdout.strip())
|
| 359 |
+
except ValueError as exc:
|
| 360 |
+
raise HTTPException(status_code=500, detail="Could not read media duration.") from exc
|
| 361 |
+
if duration <= 0:
|
| 362 |
+
raise HTTPException(status_code=500, detail="Media duration is not usable.")
|
| 363 |
+
return duration
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def build_normalize(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 367 |
+
cmd = ["ffmpeg", "-i", paths[0]]
|
| 368 |
+
return add_video_quality(cmd, opts) + ["-y", out]
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def build_extract_audio(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 372 |
+
bitrate = str(clamp_int(opts.get("audio_bitrate", "192"), 192, 64, 320))
|
| 373 |
+
return [
|
| 374 |
+
"ffmpeg",
|
| 375 |
+
"-i",
|
| 376 |
+
paths[0],
|
| 377 |
+
"-vn",
|
| 378 |
+
"-acodec",
|
| 379 |
+
"libmp3lame",
|
| 380 |
+
"-ar",
|
| 381 |
+
"44100",
|
| 382 |
+
"-ac",
|
| 383 |
+
"2",
|
| 384 |
+
"-b:a",
|
| 385 |
+
f"{bitrate}k",
|
| 386 |
+
"-y",
|
| 387 |
+
out,
|
| 388 |
+
]
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def build_resize(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 392 |
+
vf = scaled_crop_filter(opts.get("aspect_ratio", "9:16"))
|
| 393 |
+
return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out]
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def build_subtitles(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 397 |
+
subtitle_filter = f"subtitles='{filter_path(paths[1])}'"
|
| 398 |
+
return ["ffmpeg", "-i", paths[0], "-vf", subtitle_filter, "-c:a", "copy", "-y", out]
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def build_burn_lyrics(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 402 |
+
style = (
|
| 403 |
+
"Fontname=DejaVu Sans,Fontsize=24,PrimaryColour=&H00FFFF,"
|
| 404 |
+
"OutlineColour=&H000000,BorderStyle=3,Outline=1,Shadow=1,Alignment=2"
|
| 405 |
+
)
|
| 406 |
+
vf = f"subtitles='{filter_path(paths[1])}':force_style='{style}'"
|
| 407 |
+
return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out]
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def build_text_overlay(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 411 |
+
text = ffmpeg_escape(opts.get("text", ""))
|
| 412 |
+
font_size = clamp_int(opts.get("font_size", "60"), 60, 18, 160)
|
| 413 |
+
vf = (
|
| 414 |
+
f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{text}':"
|
| 415 |
+
f"fontcolor=white:fontsize={font_size}:box=1:boxcolor=black@0.45:"
|
| 416 |
+
"boxborderw=16:x=(w-text_w)/2:y=h-200"
|
| 417 |
+
)
|
| 418 |
+
return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out]
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def build_merge_music(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 422 |
+
volume = clamp_float(opts.get("volume", "0.3"), 0.3, 0.0, 2.0)
|
| 423 |
+
return [
|
| 424 |
+
"ffmpeg",
|
| 425 |
+
"-i",
|
| 426 |
+
paths[0],
|
| 427 |
+
"-i",
|
| 428 |
+
paths[1],
|
| 429 |
+
"-filter_complex",
|
| 430 |
+
f"[1:a]volume={volume}[a1]",
|
| 431 |
+
"-map",
|
| 432 |
+
"0:v",
|
| 433 |
+
"-map",
|
| 434 |
+
"[a1]",
|
| 435 |
+
"-c:v",
|
| 436 |
+
"copy",
|
| 437 |
+
"-shortest",
|
| 438 |
+
"-y",
|
| 439 |
+
out,
|
| 440 |
+
]
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def build_thumbnail(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 444 |
+
timestamp = opts.get("timestamp") or "00:00:02"
|
| 445 |
+
return ["ffmpeg", "-i", paths[0], "-ss", timestamp, "-vframes", "1", "-y", out]
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
def build_watermark(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 449 |
+
positions = {
|
| 450 |
+
"bottom-right": "W-w-20:H-h-20",
|
| 451 |
+
"bottom-left": "20:H-h-20",
|
| 452 |
+
"top-right": "W-w-20:20",
|
| 453 |
+
"top-left": "20:20",
|
| 454 |
+
"center": "(W-w)/2:(H-h)/2",
|
| 455 |
+
}
|
| 456 |
+
position = positions.get(opts.get("position"), positions["bottom-right"])
|
| 457 |
+
opacity = clamp_float(opts.get("opacity", "1"), 1, 0.1, 1.0)
|
| 458 |
+
overlay = f"[1:v]format=rgba,colorchannelmixer=aa={opacity}[wm];[0:v][wm]overlay={position}"
|
| 459 |
+
return ["ffmpeg", "-i", paths[0], "-i", paths[1], "-filter_complex", overlay, "-y", out]
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def build_compress(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 463 |
+
vf = output_scale_filter(opts.get("resolution", "720p"))
|
| 464 |
+
cmd = ["ffmpeg", "-i", paths[0], "-vf", vf]
|
| 465 |
+
return add_video_quality(cmd, opts) + ["-y", out]
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def build_gif(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 469 |
+
duration = str(clamp_int(opts.get("duration", "5"), 5, 1, 60))
|
| 470 |
+
fps = str(clamp_int(opts.get("fps", "10"), 10, 5, 30))
|
| 471 |
+
width = str(clamp_int(opts.get("width", "480"), 480, 240, 1080))
|
| 472 |
+
vf = f"fps={fps},scale={width}:-1:flags=lanczos"
|
| 473 |
+
return ["ffmpeg", "-i", paths[0], "-t", duration, "-vf", vf, "-y", out]
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def build_tiktok_lyrics(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 477 |
+
text = ffmpeg_escape(opts.get("text", ""))
|
| 478 |
+
duration = str(clamp_int(opts.get("duration", "15"), 15, 1, 180))
|
| 479 |
+
vf = (
|
| 480 |
+
scaled_crop_filter("9:16")
|
| 481 |
+
+ ","
|
| 482 |
+
+ f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{text}':"
|
| 483 |
+
"fontcolor=white:fontsize=50:box=1:boxcolor=black@0.5:"
|
| 484 |
+
"boxborderw=20:line_spacing=15:x=(w-text_w)/2:y=(h-text_h)/2"
|
| 485 |
+
)
|
| 486 |
+
return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-t", duration, "-y", out]
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def build_tiktok_pro(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 490 |
+
text = ffmpeg_escape(opts.get("text", ""))
|
| 491 |
+
vf = (
|
| 492 |
+
scaled_crop_filter("9:16")
|
| 493 |
+
+ ","
|
| 494 |
+
+ f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{text}':"
|
| 495 |
+
"fontcolor=white:fontsize=48:box=1:boxcolor=black@0.4:"
|
| 496 |
+
"boxborderw=14:x=(w-text_w)/2:y=h-200,"
|
| 497 |
+
"eq=contrast=1.15:brightness=0.04"
|
| 498 |
+
)
|
| 499 |
+
return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out]
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def build_reels_blur_fit(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 503 |
+
filter_complex = (
|
| 504 |
+
"[0:v]scale=1080:1920:force_original_aspect_ratio=increase,"
|
| 505 |
+
"crop=1080:1920,gblur=sigma=24,eq=brightness=-0.08[bg];"
|
| 506 |
+
"[0:v]scale=1080:1920:force_original_aspect_ratio=decrease[fg];"
|
| 507 |
+
"[bg][fg]overlay=(W-w)/2:(H-h)/2,setsar=1[v]"
|
| 508 |
+
)
|
| 509 |
+
return [
|
| 510 |
+
"ffmpeg",
|
| 511 |
+
"-i",
|
| 512 |
+
paths[0],
|
| 513 |
+
"-filter_complex",
|
| 514 |
+
filter_complex,
|
| 515 |
+
"-map",
|
| 516 |
+
"[v]",
|
| 517 |
+
"-map",
|
| 518 |
+
"0:a?",
|
| 519 |
+
"-c:v",
|
| 520 |
+
"libx264",
|
| 521 |
+
"-preset",
|
| 522 |
+
opts.get("preset") or "fast",
|
| 523 |
+
"-crf",
|
| 524 |
+
str(clamp_int(opts.get("crf", "23"), 23, 12, 35)),
|
| 525 |
+
"-c:a",
|
| 526 |
+
"aac",
|
| 527 |
+
"-y",
|
| 528 |
+
out,
|
| 529 |
+
]
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def build_reels_safe_caption(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 533 |
+
text = ffmpeg_escape(opts.get("text", ""))
|
| 534 |
+
font_size = clamp_int(opts.get("font_size", "58"), 58, 24, 140)
|
| 535 |
+
vf = (
|
| 536 |
+
scaled_crop_filter("9:16")
|
| 537 |
+
+ ","
|
| 538 |
+
+ f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{text}':"
|
| 539 |
+
f"fontcolor=white:fontsize={font_size}:line_spacing=10:"
|
| 540 |
+
"box=1:boxcolor=black@0.55:boxborderw=22:"
|
| 541 |
+
"x=(w-text_w)/2:y=h-430"
|
| 542 |
+
)
|
| 543 |
+
return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out]
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
def build_reels_hook_title(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 547 |
+
text = ffmpeg_escape(opts.get("text", ""))
|
| 548 |
+
font_size = clamp_int(opts.get("font_size", "64"), 64, 28, 150)
|
| 549 |
+
vf = (
|
| 550 |
+
scaled_crop_filter("9:16")
|
| 551 |
+
+ ",drawbox=x=0:y=95:w=iw:h=210:color=black@0.62:t=fill,"
|
| 552 |
+
+ f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{text}':"
|
| 553 |
+
f"fontcolor=white:fontsize={font_size}:line_spacing=8:"
|
| 554 |
+
"x=(w-text_w)/2:y=145"
|
| 555 |
+
)
|
| 556 |
+
return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out]
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
def build_reels_progress_bar(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 560 |
+
duration = clamp_float(opts.get("duration", "30"), 30, 1, 180)
|
| 561 |
+
vf = (
|
| 562 |
+
scaled_crop_filter("9:16")
|
| 563 |
+
+ ",drawbox=x=0:y=0:w=iw:h=10:color=black@0.35:t=fill,"
|
| 564 |
+
+ f"drawbox=x=0:y=0:w='min(iw,iw*t/{duration:.3f})':h=10:"
|
| 565 |
+
"color=white@0.95:t=fill"
|
| 566 |
+
)
|
| 567 |
+
return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-t", f"{duration:.3f}", "-y", out]
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
def build_reels_loop(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 571 |
+
duration = clamp_float(opts.get("duration", "15"), 15, 1, 180)
|
| 572 |
+
return [
|
| 573 |
+
"ffmpeg",
|
| 574 |
+
"-stream_loop",
|
| 575 |
+
"-1",
|
| 576 |
+
"-i",
|
| 577 |
+
paths[0],
|
| 578 |
+
"-t",
|
| 579 |
+
f"{duration:.3f}",
|
| 580 |
+
"-vf",
|
| 581 |
+
scaled_crop_filter("9:16"),
|
| 582 |
+
"-c:v",
|
| 583 |
+
"libx264",
|
| 584 |
+
"-preset",
|
| 585 |
+
opts.get("preset") or "fast",
|
| 586 |
+
"-crf",
|
| 587 |
+
str(clamp_int(opts.get("crf", "23"), 23, 12, 35)),
|
| 588 |
+
"-c:a",
|
| 589 |
+
"aac",
|
| 590 |
+
"-y",
|
| 591 |
+
out,
|
| 592 |
+
]
|
| 593 |
+
|
| 594 |
+
|
| 595 |
+
def build_reels_subtitle_safe(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 596 |
+
vf = (
|
| 597 |
+
scaled_crop_filter("9:16")
|
| 598 |
+
+ ",subtitles='"
|
| 599 |
+
+ filter_path(paths[1])
|
| 600 |
+
+ "':force_style='Fontname=DejaVu Sans,Fontsize=28,"
|
| 601 |
+
+ "PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
|
| 602 |
+
+ "BorderStyle=3,Outline=1,Shadow=0,Alignment=2,MarginV=250'"
|
| 603 |
+
)
|
| 604 |
+
return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out]
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
def build_reels_reaction_stack(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 608 |
+
filter_complex = (
|
| 609 |
+
"[0:v]scale=1080:960:force_original_aspect_ratio=increase,"
|
| 610 |
+
"crop=1080:960,setsar=1[top];"
|
| 611 |
+
"[1:v]scale=1080:960:force_original_aspect_ratio=increase,"
|
| 612 |
+
"crop=1080:960,setsar=1[bottom];"
|
| 613 |
+
"[top][bottom]vstack=inputs=2[v]"
|
| 614 |
+
)
|
| 615 |
+
return [
|
| 616 |
+
"ffmpeg",
|
| 617 |
+
"-i",
|
| 618 |
+
paths[0],
|
| 619 |
+
"-i",
|
| 620 |
+
paths[1],
|
| 621 |
+
"-filter_complex",
|
| 622 |
+
filter_complex,
|
| 623 |
+
"-map",
|
| 624 |
+
"[v]",
|
| 625 |
+
"-map",
|
| 626 |
+
"0:a?",
|
| 627 |
+
"-shortest",
|
| 628 |
+
"-c:v",
|
| 629 |
+
"libx264",
|
| 630 |
+
"-preset",
|
| 631 |
+
opts.get("preset") or "fast",
|
| 632 |
+
"-crf",
|
| 633 |
+
str(clamp_int(opts.get("crf", "23"), 23, 12, 35)),
|
| 634 |
+
"-c:a",
|
| 635 |
+
"aac",
|
| 636 |
+
"-y",
|
| 637 |
+
out,
|
| 638 |
+
]
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
def build_reels_audio_duck(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 642 |
+
music_volume = clamp_float(opts.get("volume", "0.18"), 0.18, 0.0, 1.0)
|
| 643 |
+
filter_complex = (
|
| 644 |
+
f"[1:a]volume={music_volume}[music];"
|
| 645 |
+
"[0:a][music]amix=inputs=2:duration=first:dropout_transition=2[a]"
|
| 646 |
+
)
|
| 647 |
+
return [
|
| 648 |
+
"ffmpeg",
|
| 649 |
+
"-i",
|
| 650 |
+
paths[0],
|
| 651 |
+
"-i",
|
| 652 |
+
paths[1],
|
| 653 |
+
"-filter_complex",
|
| 654 |
+
filter_complex,
|
| 655 |
+
"-map",
|
| 656 |
+
"0:v",
|
| 657 |
+
"-map",
|
| 658 |
+
"[a]",
|
| 659 |
+
"-c:v",
|
| 660 |
+
"copy",
|
| 661 |
+
"-c:a",
|
| 662 |
+
"aac",
|
| 663 |
+
"-shortest",
|
| 664 |
+
"-y",
|
| 665 |
+
out,
|
| 666 |
+
]
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
def build_faceless_quote_card(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 670 |
+
duration = clamp_float(opts.get("duration", "12"), 12, 3, 180)
|
| 671 |
+
font_size = clamp_int(opts.get("font_size", "68"), 68, 28, 140)
|
| 672 |
+
text_path = write_wrapped_text_file(opts.get("text", ""), width=22, max_lines=9)
|
| 673 |
+
vf = (
|
| 674 |
+
"drawbox=x=0:y=0:w=iw:h=ih:color=0x111827@1:t=fill,"
|
| 675 |
+
"drawbox=x=70:y=190:w=940:h=1540:color=0x0f766e@0.22:t=fill,"
|
| 676 |
+
f"drawtext=fontfile='{filter_path(FONT_PATH)}':"
|
| 677 |
+
f"textfile='{drawtext_file_path(text_path)}':fontcolor=white:"
|
| 678 |
+
f"fontsize={font_size}:line_spacing=14:x=(w-text_w)/2:y=(h-text_h)/2"
|
| 679 |
+
)
|
| 680 |
+
return [
|
| 681 |
+
"ffmpeg",
|
| 682 |
+
"-f",
|
| 683 |
+
"lavfi",
|
| 684 |
+
"-i",
|
| 685 |
+
f"color=c=0x111827:s=1080x1920:r=30:d={duration:.3f}",
|
| 686 |
+
"-f",
|
| 687 |
+
"lavfi",
|
| 688 |
+
"-i",
|
| 689 |
+
"anullsrc=channel_layout=stereo:sample_rate=44100",
|
| 690 |
+
"-vf",
|
| 691 |
+
vf,
|
| 692 |
+
"-map",
|
| 693 |
+
"0:v",
|
| 694 |
+
"-map",
|
| 695 |
+
"1:a",
|
| 696 |
+
"-t",
|
| 697 |
+
f"{duration:.3f}",
|
| 698 |
+
"-c:v",
|
| 699 |
+
"libx264",
|
| 700 |
+
"-pix_fmt",
|
| 701 |
+
"yuv420p",
|
| 702 |
+
"-c:a",
|
| 703 |
+
"aac",
|
| 704 |
+
"-shortest",
|
| 705 |
+
"-y",
|
| 706 |
+
out,
|
| 707 |
+
]
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
def build_faceless_image_narration(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 711 |
+
text_path = write_wrapped_text_file(opts.get("text", ""), width=24, max_lines=6)
|
| 712 |
+
font_size = clamp_int(opts.get("font_size", "54"), 54, 24, 110)
|
| 713 |
+
vf = (
|
| 714 |
+
"scale=1200:-1,zoompan=z='min(zoom+0.0009,1.12)':"
|
| 715 |
+
"d=1800:s=1080x1920:fps=30,"
|
| 716 |
+
f"drawtext=fontfile='{filter_path(FONT_PATH)}':"
|
| 717 |
+
f"textfile='{drawtext_file_path(text_path)}':fontcolor=white:"
|
| 718 |
+
f"fontsize={font_size}:line_spacing=10:box=1:boxcolor=black@0.52:"
|
| 719 |
+
"boxborderw=22:x=(w-text_w)/2:y=h-520"
|
| 720 |
+
)
|
| 721 |
+
return [
|
| 722 |
+
"ffmpeg",
|
| 723 |
+
"-loop",
|
| 724 |
+
"1",
|
| 725 |
+
"-i",
|
| 726 |
+
paths[0],
|
| 727 |
+
"-i",
|
| 728 |
+
paths[1],
|
| 729 |
+
"-vf",
|
| 730 |
+
vf,
|
| 731 |
+
"-map",
|
| 732 |
+
"0:v",
|
| 733 |
+
"-map",
|
| 734 |
+
"1:a",
|
| 735 |
+
"-c:v",
|
| 736 |
+
"libx264",
|
| 737 |
+
"-preset",
|
| 738 |
+
opts.get("preset") or "fast",
|
| 739 |
+
"-crf",
|
| 740 |
+
str(clamp_int(opts.get("crf", "23"), 23, 12, 35)),
|
| 741 |
+
"-c:a",
|
| 742 |
+
"aac",
|
| 743 |
+
"-shortest",
|
| 744 |
+
"-pix_fmt",
|
| 745 |
+
"yuv420p",
|
| 746 |
+
"-y",
|
| 747 |
+
out,
|
| 748 |
+
]
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
def build_faceless_video_narration(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 752 |
+
text_path = write_wrapped_text_file(opts.get("text", ""), width=24, max_lines=6)
|
| 753 |
+
font_size = clamp_int(opts.get("font_size", "52"), 52, 24, 110)
|
| 754 |
+
filter_complex = (
|
| 755 |
+
"[0:v]scale=1080:1920:force_original_aspect_ratio=increase,"
|
| 756 |
+
"crop=1080:1920,setsar=1,"
|
| 757 |
+
f"drawtext=fontfile='{filter_path(FONT_PATH)}':"
|
| 758 |
+
f"textfile='{drawtext_file_path(text_path)}':fontcolor=white:"
|
| 759 |
+
f"fontsize={font_size}:line_spacing=10:box=1:boxcolor=black@0.5:"
|
| 760 |
+
"boxborderw=20:x=(w-text_w)/2:y=h-500[v]"
|
| 761 |
+
)
|
| 762 |
+
return [
|
| 763 |
+
"ffmpeg",
|
| 764 |
+
"-i",
|
| 765 |
+
paths[0],
|
| 766 |
+
"-i",
|
| 767 |
+
paths[1],
|
| 768 |
+
"-filter_complex",
|
| 769 |
+
filter_complex,
|
| 770 |
+
"-map",
|
| 771 |
+
"[v]",
|
| 772 |
+
"-map",
|
| 773 |
+
"1:a",
|
| 774 |
+
"-c:v",
|
| 775 |
+
"libx264",
|
| 776 |
+
"-preset",
|
| 777 |
+
opts.get("preset") or "fast",
|
| 778 |
+
"-crf",
|
| 779 |
+
str(clamp_int(opts.get("crf", "23"), 23, 12, 35)),
|
| 780 |
+
"-c:a",
|
| 781 |
+
"aac",
|
| 782 |
+
"-shortest",
|
| 783 |
+
"-y",
|
| 784 |
+
out,
|
| 785 |
+
]
|
| 786 |
+
|
| 787 |
+
|
| 788 |
+
def run_faceless_story_pages(paths: List[str], options: Dict[str, str], out: str) -> str:
|
| 789 |
+
pages = split_story_pages(options.get("text", ""), words_per_page=24)
|
| 790 |
+
seconds = clamp_float(options.get("image_duration", "3"), 3, 1, 12)
|
| 791 |
+
work_dir = os.path.join(TEMP_DIR, f"story_{uuid.uuid4().hex[:8]}")
|
| 792 |
+
os.makedirs(work_dir, exist_ok=True)
|
| 793 |
+
list_path = os.path.join(work_dir, "concat.txt")
|
| 794 |
+
try:
|
| 795 |
+
with open(list_path, "w", encoding="utf-8") as concat_file:
|
| 796 |
+
for index, page in enumerate(pages, start=1):
|
| 797 |
+
clip_path = os.path.join(work_dir, f"page_{index:03d}.mp4")
|
| 798 |
+
text_path = write_wrapped_text_file(page, width=24, max_lines=8)
|
| 799 |
+
vf = (
|
| 800 |
+
"drawbox=x=0:y=0:w=iw:h=ih:color=0x0f172a@1:t=fill,"
|
| 801 |
+
f"drawtext=fontfile='{filter_path(FONT_PATH)}':"
|
| 802 |
+
f"textfile='{drawtext_file_path(text_path)}':fontcolor=white:"
|
| 803 |
+
"fontsize=62:line_spacing=12:x=(w-text_w)/2:y=(h-text_h)/2,"
|
| 804 |
+
f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{index}/{len(pages)}':"
|
| 805 |
+
"fontcolor=white@0.62:fontsize=34:x=(w-text_w)/2:y=h-150"
|
| 806 |
+
)
|
| 807 |
+
cmd = [
|
| 808 |
+
"ffmpeg",
|
| 809 |
+
"-f",
|
| 810 |
+
"lavfi",
|
| 811 |
+
"-i",
|
| 812 |
+
f"color=c=0x0f172a:s=1080x1920:r=30:d={seconds:.3f}",
|
| 813 |
+
"-f",
|
| 814 |
+
"lavfi",
|
| 815 |
+
"-i",
|
| 816 |
+
"anullsrc=channel_layout=stereo:sample_rate=44100",
|
| 817 |
+
"-vf",
|
| 818 |
+
vf,
|
| 819 |
+
"-map",
|
| 820 |
+
"0:v",
|
| 821 |
+
"-map",
|
| 822 |
+
"1:a",
|
| 823 |
+
"-t",
|
| 824 |
+
f"{seconds:.3f}",
|
| 825 |
+
"-c:v",
|
| 826 |
+
"libx264",
|
| 827 |
+
"-pix_fmt",
|
| 828 |
+
"yuv420p",
|
| 829 |
+
"-c:a",
|
| 830 |
+
"aac",
|
| 831 |
+
"-y",
|
| 832 |
+
clip_path,
|
| 833 |
+
]
|
| 834 |
+
run_command(cmd)
|
| 835 |
+
concat_file.write(f"file '{ffconcat_path(clip_path)}'\n")
|
| 836 |
+
run_command(["ffmpeg", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", "-y", out])
|
| 837 |
+
return out
|
| 838 |
+
finally:
|
| 839 |
+
shutil.rmtree(work_dir, ignore_errors=True)
|
| 840 |
+
|
| 841 |
+
|
| 842 |
+
def run_faceless_broll_montage(paths: List[str], options: Dict[str, str], out: str) -> str:
|
| 843 |
+
clip_seconds = clamp_float(options.get("image_duration", "2.5"), 2.5, 1, 8)
|
| 844 |
+
max_clips = clamp_int(options.get("duration", "12"), 12, 1, 60)
|
| 845 |
+
work_dir = os.path.join(TEMP_DIR, f"broll_{uuid.uuid4().hex[:8]}")
|
| 846 |
+
os.makedirs(work_dir, exist_ok=True)
|
| 847 |
+
list_path = os.path.join(work_dir, "concat.txt")
|
| 848 |
+
try:
|
| 849 |
+
with open(list_path, "w", encoding="utf-8") as concat_file:
|
| 850 |
+
for index, path in enumerate(paths[:max_clips], start=1):
|
| 851 |
+
clip_path = os.path.join(work_dir, f"clip_{index:03d}.mp4")
|
| 852 |
+
cmd = [
|
| 853 |
+
"ffmpeg",
|
| 854 |
+
"-i",
|
| 855 |
+
path,
|
| 856 |
+
"-t",
|
| 857 |
+
f"{clip_seconds:.3f}",
|
| 858 |
+
"-vf",
|
| 859 |
+
scaled_crop_filter("9:16") + ",setsar=1",
|
| 860 |
+
"-an",
|
| 861 |
+
"-c:v",
|
| 862 |
+
"libx264",
|
| 863 |
+
"-preset",
|
| 864 |
+
options.get("preset") or "fast",
|
| 865 |
+
"-crf",
|
| 866 |
+
str(clamp_int(options.get("crf", "23"), 23, 12, 35)),
|
| 867 |
+
"-pix_fmt",
|
| 868 |
+
"yuv420p",
|
| 869 |
+
"-y",
|
| 870 |
+
clip_path,
|
| 871 |
+
]
|
| 872 |
+
run_command(cmd)
|
| 873 |
+
concat_file.write(f"file '{ffconcat_path(clip_path)}'\n")
|
| 874 |
+
run_command(["ffmpeg", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", "-y", out])
|
| 875 |
+
return out
|
| 876 |
+
finally:
|
| 877 |
+
shutil.rmtree(work_dir, ignore_errors=True)
|
| 878 |
+
|
| 879 |
+
|
| 880 |
+
def build_series_episode_badge(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 881 |
+
title = ffmpeg_escape(opts.get("text", "Mini Series"))
|
| 882 |
+
font_size = clamp_int(opts.get("font_size", "48"), 48, 24, 110)
|
| 883 |
+
vf = (
|
| 884 |
+
scaled_crop_filter("9:16")
|
| 885 |
+
+ ",drawbox=x=36:y=86:w=360:h=92:color=black@0.68:t=fill,"
|
| 886 |
+
+ f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{title}':"
|
| 887 |
+
f"fontcolor=white:fontsize={font_size}:x=60:y=106,"
|
| 888 |
+
+ "drawbox=x=36:y=h-238:w=1008:h=116:color=black@0.44:t=fill"
|
| 889 |
+
)
|
| 890 |
+
return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out]
|
| 891 |
+
|
| 892 |
+
|
| 893 |
+
def build_series_recap_card(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 894 |
+
duration = clamp_float(opts.get("duration", "5"), 5, 2, 30)
|
| 895 |
+
text_path = write_wrapped_text_file(opts.get("text", "Previously in this series"), width=22, max_lines=7)
|
| 896 |
+
vf = (
|
| 897 |
+
"drawbox=x=0:y=0:w=iw:h=ih:color=0x111827@1:t=fill,"
|
| 898 |
+
"drawbox=x=70:y=260:w=940:h=1180:color=0x7c2d12@0.32:t=fill,"
|
| 899 |
+
f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='PREVIOUSLY':"
|
| 900 |
+
"fontcolor=white@0.72:fontsize=46:x=(w-text_w)/2:y=350,"
|
| 901 |
+
f"drawtext=fontfile='{filter_path(FONT_PATH)}':textfile='{drawtext_file_path(text_path)}':"
|
| 902 |
+
"fontcolor=white:fontsize=68:line_spacing=14:x=(w-text_w)/2:y=(h-text_h)/2"
|
| 903 |
+
)
|
| 904 |
+
return [
|
| 905 |
+
"ffmpeg",
|
| 906 |
+
"-f",
|
| 907 |
+
"lavfi",
|
| 908 |
+
"-i",
|
| 909 |
+
f"color=c=0x111827:s=1080x1920:r=30:d={duration:.3f}",
|
| 910 |
+
"-f",
|
| 911 |
+
"lavfi",
|
| 912 |
+
"-i",
|
| 913 |
+
"anullsrc=channel_layout=stereo:sample_rate=44100",
|
| 914 |
+
"-vf",
|
| 915 |
+
vf,
|
| 916 |
+
"-map",
|
| 917 |
+
"0:v",
|
| 918 |
+
"-map",
|
| 919 |
+
"1:a",
|
| 920 |
+
"-t",
|
| 921 |
+
f"{duration:.3f}",
|
| 922 |
+
"-c:v",
|
| 923 |
+
"libx264",
|
| 924 |
+
"-pix_fmt",
|
| 925 |
+
"yuv420p",
|
| 926 |
+
"-c:a",
|
| 927 |
+
"aac",
|
| 928 |
+
"-shortest",
|
| 929 |
+
"-y",
|
| 930 |
+
out,
|
| 931 |
+
]
|
| 932 |
+
|
| 933 |
+
|
| 934 |
+
def run_series_split_pack(paths: List[str], options: Dict[str, str], out: str) -> str:
|
| 935 |
+
segment_seconds = clamp_float(options.get("duration", "60"), 60, 15, 180)
|
| 936 |
+
title = options.get("text", "Mini Series")
|
| 937 |
+
duration = media_duration_seconds(paths[0])
|
| 938 |
+
episode_count = min(100, int((duration + segment_seconds - 0.001) // segment_seconds))
|
| 939 |
+
work_dir = os.path.join(TEMP_DIR, f"series_{uuid.uuid4().hex[:8]}")
|
| 940 |
+
os.makedirs(work_dir, exist_ok=True)
|
| 941 |
+
manifest_path = os.path.join(work_dir, "manifest.txt")
|
| 942 |
+
try:
|
| 943 |
+
with open(manifest_path, "w", encoding="utf-8") as manifest:
|
| 944 |
+
manifest.write(f"series_title={title}\n")
|
| 945 |
+
manifest.write(f"episode_count={episode_count}\n")
|
| 946 |
+
manifest.write(f"episode_seconds={segment_seconds:.3f}\n\n")
|
| 947 |
+
for episode in range(1, episode_count + 1):
|
| 948 |
+
start = (episode - 1) * segment_seconds
|
| 949 |
+
remaining = max(0.1, duration - start)
|
| 950 |
+
clip_duration = min(segment_seconds, remaining)
|
| 951 |
+
episode_out = os.path.join(work_dir, f"episode_{episode:03d}_of_{episode_count:03d}.mp4")
|
| 952 |
+
label = ffmpeg_escape(f"PART {episode}/{episode_count}")
|
| 953 |
+
caption = ffmpeg_escape(title)
|
| 954 |
+
vf = (
|
| 955 |
+
scaled_crop_filter("9:16")
|
| 956 |
+
+ ",drawbox=x=36:y=86:w=390:h=92:color=black@0.68:t=fill,"
|
| 957 |
+
+ f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{label}':"
|
| 958 |
+
"fontcolor=white:fontsize=46:x=58:y=108,"
|
| 959 |
+
+ "drawbox=x=36:y=h-238:w=1008:h=116:color=black@0.48:t=fill,"
|
| 960 |
+
+ f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{caption}':"
|
| 961 |
+
"fontcolor=white:fontsize=42:x=(w-text_w)/2:y=h-205"
|
| 962 |
+
)
|
| 963 |
+
cmd = [
|
| 964 |
+
"ffmpeg",
|
| 965 |
+
"-ss",
|
| 966 |
+
f"{start:.3f}",
|
| 967 |
+
"-i",
|
| 968 |
+
paths[0],
|
| 969 |
+
"-t",
|
| 970 |
+
f"{clip_duration:.3f}",
|
| 971 |
+
"-vf",
|
| 972 |
+
vf,
|
| 973 |
+
"-c:v",
|
| 974 |
+
"libx264",
|
| 975 |
+
"-preset",
|
| 976 |
+
options.get("preset") or "fast",
|
| 977 |
+
"-crf",
|
| 978 |
+
str(clamp_int(options.get("crf", "23"), 23, 12, 35)),
|
| 979 |
+
"-c:a",
|
| 980 |
+
"aac",
|
| 981 |
+
"-y",
|
| 982 |
+
episode_out,
|
| 983 |
+
]
|
| 984 |
+
run_command(cmd)
|
| 985 |
+
manifest.write(f"{episode_out}\n")
|
| 986 |
+
shutil.make_archive(out[:-4], "zip", work_dir)
|
| 987 |
+
return out
|
| 988 |
+
finally:
|
| 989 |
+
shutil.rmtree(work_dir, ignore_errors=True)
|
| 990 |
+
|
| 991 |
+
|
| 992 |
+
def run_series_batch_pack(paths: List[str], options: Dict[str, str], out: str) -> str:
|
| 993 |
+
title = options.get("text", "Mini Series")
|
| 994 |
+
work_dir = os.path.join(TEMP_DIR, f"series_batch_{uuid.uuid4().hex[:8]}")
|
| 995 |
+
os.makedirs(work_dir, exist_ok=True)
|
| 996 |
+
manifest_path = os.path.join(work_dir, "manifest.txt")
|
| 997 |
+
try:
|
| 998 |
+
with open(manifest_path, "w", encoding="utf-8") as manifest:
|
| 999 |
+
manifest.write(f"series_title={title}\n")
|
| 1000 |
+
manifest.write(f"episode_count={len(paths)}\n\n")
|
| 1001 |
+
for episode, path in enumerate(paths, start=1):
|
| 1002 |
+
episode_out = os.path.join(work_dir, f"episode_{episode:03d}_of_{len(paths):03d}.mp4")
|
| 1003 |
+
label = ffmpeg_escape(f"PART {episode}/{len(paths)}")
|
| 1004 |
+
caption = ffmpeg_escape(title)
|
| 1005 |
+
vf = (
|
| 1006 |
+
scaled_crop_filter("9:16")
|
| 1007 |
+
+ ",drawbox=x=36:y=86:w=390:h=92:color=black@0.68:t=fill,"
|
| 1008 |
+
+ f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{label}':"
|
| 1009 |
+
"fontcolor=white:fontsize=46:x=58:y=108,"
|
| 1010 |
+
+ "drawbox=x=36:y=h-238:w=1008:h=116:color=black@0.48:t=fill,"
|
| 1011 |
+
+ f"drawtext=fontfile='{filter_path(FONT_PATH)}':text='{caption}':"
|
| 1012 |
+
"fontcolor=white:fontsize=42:x=(w-text_w)/2:y=h-205"
|
| 1013 |
+
)
|
| 1014 |
+
cmd = [
|
| 1015 |
+
"ffmpeg",
|
| 1016 |
+
"-i",
|
| 1017 |
+
path,
|
| 1018 |
+
"-vf",
|
| 1019 |
+
vf,
|
| 1020 |
+
"-c:v",
|
| 1021 |
+
"libx264",
|
| 1022 |
+
"-preset",
|
| 1023 |
+
options.get("preset") or "fast",
|
| 1024 |
+
"-crf",
|
| 1025 |
+
str(clamp_int(options.get("crf", "23"), 23, 12, 35)),
|
| 1026 |
+
"-c:a",
|
| 1027 |
+
"aac",
|
| 1028 |
+
"-y",
|
| 1029 |
+
episode_out,
|
| 1030 |
+
]
|
| 1031 |
+
run_command(cmd)
|
| 1032 |
+
manifest.write(f"{episode_out}\n")
|
| 1033 |
+
shutil.make_archive(out[:-4], "zip", work_dir)
|
| 1034 |
+
return out
|
| 1035 |
+
finally:
|
| 1036 |
+
shutil.rmtree(work_dir, ignore_errors=True)
|
| 1037 |
+
|
| 1038 |
+
|
| 1039 |
+
def build_concat(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 1040 |
+
job_id = uuid.uuid4().hex[:8]
|
| 1041 |
+
list_path = os.path.join(TEMP_DIR, f"list_{job_id}.txt")
|
| 1042 |
+
with open(list_path, "w", encoding="utf-8") as handle:
|
| 1043 |
+
for path in paths:
|
| 1044 |
+
quoted = ffconcat_path(path)
|
| 1045 |
+
handle.write(f"file '{quoted}'\n")
|
| 1046 |
+
return ["ffmpeg", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", "-y", out]
|
| 1047 |
+
|
| 1048 |
+
|
| 1049 |
+
def build_slideshow(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 1050 |
+
duration = str(clamp_int(opts.get("image_duration", "3"), 3, 1, 15))
|
| 1051 |
+
job_id = uuid.uuid4().hex[:8]
|
| 1052 |
+
list_path = os.path.join(TEMP_DIR, f"slides_{job_id}.txt")
|
| 1053 |
+
with open(list_path, "w", encoding="utf-8") as handle:
|
| 1054 |
+
for path in paths:
|
| 1055 |
+
quoted = ffconcat_path(path)
|
| 1056 |
+
handle.write(f"file '{quoted}'\nduration {duration}\n")
|
| 1057 |
+
quoted = ffconcat_path(paths[-1])
|
| 1058 |
+
handle.write(f"file '{quoted}'\n")
|
| 1059 |
+
return [
|
| 1060 |
+
"ffmpeg",
|
| 1061 |
+
"-f",
|
| 1062 |
+
"concat",
|
| 1063 |
+
"-safe",
|
| 1064 |
+
"0",
|
| 1065 |
+
"-i",
|
| 1066 |
+
list_path,
|
| 1067 |
+
"-vsync",
|
| 1068 |
+
"vfr",
|
| 1069 |
+
"-pix_fmt",
|
| 1070 |
+
"yuv420p",
|
| 1071 |
+
"-y",
|
| 1072 |
+
out,
|
| 1073 |
+
]
|
| 1074 |
+
|
| 1075 |
+
|
| 1076 |
+
def build_trim(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 1077 |
+
start = opts.get("start_time") or "00:00:00"
|
| 1078 |
+
end = opts.get("end_time") or ""
|
| 1079 |
+
cmd = ["ffmpeg", "-ss", start, "-i", paths[0]]
|
| 1080 |
+
if end:
|
| 1081 |
+
cmd += ["-to", end]
|
| 1082 |
+
return cmd + ["-c", "copy", "-y", out]
|
| 1083 |
+
|
| 1084 |
+
|
| 1085 |
+
def build_crop(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 1086 |
+
vf = scaled_crop_filter(opts.get("aspect_ratio", "1:1"))
|
| 1087 |
+
return ["ffmpeg", "-i", paths[0], "-vf", vf, "-c:a", "copy", "-y", out]
|
| 1088 |
+
|
| 1089 |
+
|
| 1090 |
+
def build_waveform(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 1091 |
+
color = opts.get("wave_color") or "00e5ff"
|
| 1092 |
+
if not all(ch in "0123456789abcdefABCDEF" for ch in color) or len(color) != 6:
|
| 1093 |
+
color = "00e5ff"
|
| 1094 |
+
return [
|
| 1095 |
+
"ffmpeg",
|
| 1096 |
+
"-i",
|
| 1097 |
+
paths[0],
|
| 1098 |
+
"-filter_complex",
|
| 1099 |
+
f"[0:a]showwaves=s=1280x720:mode=line:colors=#{color}[v]",
|
| 1100 |
+
"-map",
|
| 1101 |
+
"[v]",
|
| 1102 |
+
"-map",
|
| 1103 |
+
"0:a",
|
| 1104 |
+
"-c:v",
|
| 1105 |
+
"libx264",
|
| 1106 |
+
"-pix_fmt",
|
| 1107 |
+
"yuv420p",
|
| 1108 |
+
"-c:a",
|
| 1109 |
+
"aac",
|
| 1110 |
+
"-shortest",
|
| 1111 |
+
"-y",
|
| 1112 |
+
out,
|
| 1113 |
+
]
|
| 1114 |
+
|
| 1115 |
+
|
| 1116 |
+
def build_extract_frames(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 1117 |
+
fps = str(clamp_float(opts.get("frame_rate", "1"), 1, 0.1, 30))
|
| 1118 |
+
pattern = out.replace(".zip", "_%04d.jpg")
|
| 1119 |
+
return ["ffmpeg", "-i", paths[0], "-vf", f"fps={fps}", "-y", pattern]
|
| 1120 |
+
|
| 1121 |
+
|
| 1122 |
+
def build_add_intro_outro(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 1123 |
+
clips = paths[:]
|
| 1124 |
+
return build_concat(clips, opts, out)
|
| 1125 |
+
|
| 1126 |
+
|
| 1127 |
+
def build_speed(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 1128 |
+
speed = clamp_float(opts.get("speed", "1.25"), 1.25, 0.25, 4.0)
|
| 1129 |
+
setpts = 1 / speed
|
| 1130 |
+
atempo_parts = []
|
| 1131 |
+
remaining = speed
|
| 1132 |
+
while remaining > 2.0:
|
| 1133 |
+
atempo_parts.append("atempo=2.0")
|
| 1134 |
+
remaining /= 2.0
|
| 1135 |
+
while remaining < 0.5:
|
| 1136 |
+
atempo_parts.append("atempo=0.5")
|
| 1137 |
+
remaining /= 0.5
|
| 1138 |
+
atempo_parts.append(f"atempo={remaining:.4f}")
|
| 1139 |
+
filter_complex = f"[0:v]setpts={setpts:.6f}*PTS[v];[0:a]{','.join(atempo_parts)}[a]"
|
| 1140 |
+
return [
|
| 1141 |
+
"ffmpeg",
|
| 1142 |
+
"-i",
|
| 1143 |
+
paths[0],
|
| 1144 |
+
"-filter_complex",
|
| 1145 |
+
filter_complex,
|
| 1146 |
+
"-map",
|
| 1147 |
+
"[v]",
|
| 1148 |
+
"-map",
|
| 1149 |
+
"[a]",
|
| 1150 |
+
"-y",
|
| 1151 |
+
out,
|
| 1152 |
+
]
|
| 1153 |
+
|
| 1154 |
+
|
| 1155 |
+
def build_remove_audio(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 1156 |
+
return ["ffmpeg", "-i", paths[0], "-c:v", "copy", "-an", "-y", out]
|
| 1157 |
+
|
| 1158 |
+
|
| 1159 |
+
def build_replace_audio(paths: List[str], opts: Dict[str, str], out: str) -> List[str]:
|
| 1160 |
+
return [
|
| 1161 |
+
"ffmpeg",
|
| 1162 |
+
"-i",
|
| 1163 |
+
paths[0],
|
| 1164 |
+
"-i",
|
| 1165 |
+
paths[1],
|
| 1166 |
+
"-map",
|
| 1167 |
+
"0:v",
|
| 1168 |
+
"-map",
|
| 1169 |
+
"1:a",
|
| 1170 |
+
"-c:v",
|
| 1171 |
+
"copy",
|
| 1172 |
+
"-c:a",
|
| 1173 |
+
"aac",
|
| 1174 |
+
"-shortest",
|
| 1175 |
+
"-y",
|
| 1176 |
+
out,
|
| 1177 |
+
]
|
| 1178 |
+
|
| 1179 |
+
|
| 1180 |
+
def run_batch_compress(paths: List[str], options: Dict[str, str], out: str) -> str:
|
| 1181 |
+
archive_dir = os.path.join(TEMP_DIR, f"batch_{uuid.uuid4().hex[:8]}")
|
| 1182 |
+
os.makedirs(archive_dir, exist_ok=True)
|
| 1183 |
+
try:
|
| 1184 |
+
for index, path in enumerate(paths, start=1):
|
| 1185 |
+
name, _ext = os.path.splitext(os.path.basename(path))
|
| 1186 |
+
item_out = os.path.join(archive_dir, f"{index:02d}_{name}_compressed.mp4")
|
| 1187 |
+
cmd = build_compress([path], options, item_out)
|
| 1188 |
+
run_command(cmd)
|
| 1189 |
+
shutil.make_archive(out[:-4], "zip", archive_dir)
|
| 1190 |
+
return out
|
| 1191 |
+
finally:
|
| 1192 |
+
shutil.rmtree(archive_dir, ignore_errors=True)
|
| 1193 |
+
|
| 1194 |
+
|
| 1195 |
+
TASKS: Dict[str, TaskDefinition] = {
|
| 1196 |
+
"normalize": TaskDefinition("Normalize MP4", "Convert to H.264/AAC MP4.", "Video", "mp4", 1, 1, ["video"], build_normalize),
|
| 1197 |
+
"extract_audio": TaskDefinition("Extract Audio", "Export MP3 audio from a video.", "Audio", "mp3", 1, 1, ["video", "audio"], build_extract_audio),
|
| 1198 |
+
"resize_916": TaskDefinition("Resize 9:16", "Crop video for vertical platforms.", "Social", "mp4", 1, 1, ["video"], build_resize),
|
| 1199 |
+
"add_subtitles": TaskDefinition("Add Subtitles", "Burn an SRT/ASS subtitle file into video.", "Subtitles", "mp4", 2, 2, ["video", "text"], build_subtitles, file_types=[["video"], ["text"]]),
|
| 1200 |
+
"burn_lyrics": TaskDefinition("Burn Lyrics", "Burn styled lyric subtitles into video.", "Subtitles", "mp4", 2, 2, ["video", "text"], build_burn_lyrics, file_types=[["video"], ["text"]]),
|
| 1201 |
+
"text_overlay": TaskDefinition("Text Overlay", "Add caption text to video.", "Social", "mp4", 1, 1, ["video"], build_text_overlay),
|
| 1202 |
+
"merge_music": TaskDefinition("Merge Music", "Add background music to video.", "Audio", "mp4", 2, 2, ["video", "audio"], build_merge_music, file_types=[["video"], ["audio"]]),
|
| 1203 |
+
"thumbnail": TaskDefinition("Thumbnail", "Export one JPG frame.", "Image", "jpg", 1, 1, ["video"], build_thumbnail),
|
| 1204 |
+
"watermark": TaskDefinition("Watermark", "Overlay a logo/image on video.", "Branding", "mp4", 2, 2, ["video", "image"], build_watermark, file_types=[["video"], ["image"]]),
|
| 1205 |
+
"compress": TaskDefinition("Compress", "Reduce video size using CRF and scale.", "Video", "mp4", 1, 1, ["video"], build_compress),
|
| 1206 |
+
"batch_compress": TaskDefinition("Batch Compress", "Compress multiple videos and return a ZIP.", "Video", "zip", 1, None, ["video"], runner=run_batch_compress),
|
| 1207 |
+
"make_gif": TaskDefinition("Make GIF", "Create a short GIF clip.", "Image", "gif", 1, 1, ["video"], build_gif),
|
| 1208 |
+
"tiktok_lyrics": TaskDefinition("TikTok Lyrics", "Vertical lyric clip with boxed center text.", "Social", "mp4", 1, 1, ["video"], build_tiktok_lyrics),
|
| 1209 |
+
"tiktok_pro_reframer": TaskDefinition("TikTok Pro Reframer", "Vertical crop, text, and contrast pass.", "Social", "mp4", 1, 1, ["video"], build_tiktok_pro),
|
| 1210 |
+
"reels_blur_fit": TaskDefinition("Reels Blur Fit", "Fit any video into 9:16 with a blurred background.", "Social", "mp4", 1, 1, ["video"], build_reels_blur_fit),
|
| 1211 |
+
"reels_safe_caption": TaskDefinition("Reels Safe Caption", "Add lower-third caption text inside Reels and Shorts safe zones.", "Social", "mp4", 1, 1, ["video"], build_reels_safe_caption),
|
| 1212 |
+
"reels_hook_title": TaskDefinition("Reels Hook Title", "Add a bold top hook/title banner to a vertical clip.", "Social", "mp4", 1, 1, ["video"], build_reels_hook_title),
|
| 1213 |
+
"reels_progress_bar": TaskDefinition("Reels Progress Bar", "Add a top progress bar and optional duration trim.", "Social", "mp4", 1, 1, ["video"], build_reels_progress_bar),
|
| 1214 |
+
"reels_loop": TaskDefinition("Reels Loop", "Loop a clip to a target vertical Shorts/Reels duration.", "Social", "mp4", 1, 1, ["video"], build_reels_loop),
|
| 1215 |
+
"reels_subtitle_safe": TaskDefinition("Reels Subtitle Safe", "Burn subtitles with mobile-safe lower margins.", "Social", "mp4", 2, 2, ["video", "text"], build_reels_subtitle_safe, file_types=[["video"], ["text"]]),
|
| 1216 |
+
"reels_reaction_stack": TaskDefinition("Reels Reaction Stack", "Stack two videos vertically for reaction-style Shorts.", "Social", "mp4", 2, 2, ["video"], build_reels_reaction_stack),
|
| 1217 |
+
"reels_audio_duck": TaskDefinition("Reels Audio Duck", "Mix background music under the original video audio.", "Social", "mp4", 2, 2, ["video", "audio"], build_reels_audio_duck, file_types=[["video"], ["audio"]]),
|
| 1218 |
+
"faceless_quote_card": TaskDefinition("Faceless Quote Card", "Generate a vertical text-only quote short from caption text.", "Faceless", "mp4", 0, 0, [], build_faceless_quote_card),
|
| 1219 |
+
"faceless_story_pages": TaskDefinition("Faceless Story Pages", "Split long text into timed vertical story slides.", "Faceless", "mp4", 0, 0, [], runner=run_faceless_story_pages),
|
| 1220 |
+
"faceless_image_narration": TaskDefinition("Faceless Image Narration", "Create a Ken Burns image short with narration audio and caption text.", "Faceless", "mp4", 2, 2, ["image", "audio"], build_faceless_image_narration, file_types=[["image"], ["audio"]]),
|
| 1221 |
+
"faceless_video_narration": TaskDefinition("Faceless Video Narration", "Create a vertical b-roll short with narration audio and caption text.", "Faceless", "mp4", 2, 2, ["video", "audio"], build_faceless_video_narration, file_types=[["video"], ["audio"]]),
|
| 1222 |
+
"faceless_broll_montage": TaskDefinition("Faceless B-roll Montage", "Create a silent vertical montage from multiple b-roll videos.", "Faceless", "mp4", 2, None, ["video"], runner=run_faceless_broll_montage),
|
| 1223 |
+
"series_split_pack": TaskDefinition("Mini Series Split Pack", "Split one long video into numbered vertical TikTok/Reels episodes and return a ZIP.", "Series", "zip", 1, 1, ["video"], runner=run_series_split_pack),
|
| 1224 |
+
"series_episode_badge": TaskDefinition("Mini Series Episode Badge", "Add a series title and episode-safe badge area to one vertical clip.", "Series", "mp4", 1, 1, ["video"], build_series_episode_badge),
|
| 1225 |
+
"series_batch_pack": TaskDefinition("Mini Series Batch Pack", "Number multiple clips as a TikTok/Reels mini-series and return a ZIP.", "Series", "zip", 2, None, ["video"], runner=run_series_batch_pack),
|
| 1226 |
+
"series_recap_card": TaskDefinition("Mini Series Recap Card", "Generate a short text-only recap card for the next episode.", "Series", "mp4", 0, 0, [], build_series_recap_card),
|
| 1227 |
+
"concat": TaskDefinition("Concat Videos", "Join clips in upload order.", "Video", "mp4", 2, None, ["video"], build_concat),
|
| 1228 |
+
"slideshow": TaskDefinition("Slideshow", "Create a video from images.", "Image", "mp4", 2, None, ["image"], build_slideshow),
|
| 1229 |
+
"trim": TaskDefinition("Trim", "Cut a clip by start/end time.", "Video", "mp4", 1, 1, ["video", "audio"], build_trim),
|
| 1230 |
+
"crop_aspect": TaskDefinition("Crop Aspect", "Crop to 1:1, 4:5, 16:9, or 9:16.", "Video", "mp4", 1, 1, ["video"], build_crop),
|
| 1231 |
+
"waveform": TaskDefinition("Waveform Video", "Create a waveform video from audio.", "Audio", "mp4", 1, 1, ["audio", "video"], build_waveform),
|
| 1232 |
+
"extract_frames": TaskDefinition("Extract Frames", "Export JPG frames and return a ZIP.", "Image", "zip", 1, 1, ["video"], build_extract_frames),
|
| 1233 |
+
"add_intro_outro": TaskDefinition("Add Intro/Outro", "Join intro, main clip, and outro.", "Video", "mp4", 2, 3, ["video"], build_add_intro_outro),
|
| 1234 |
+
"speed": TaskDefinition("Speed Change", "Speed up or slow down video and audio.", "Video", "mp4", 1, 1, ["video"], build_speed),
|
| 1235 |
+
"remove_audio": TaskDefinition("Remove Audio", "Export video without audio.", "Audio", "mp4", 1, 1, ["video"], build_remove_audio),
|
| 1236 |
+
"replace_audio": TaskDefinition("Replace Audio", "Replace video audio with another file.", "Audio", "mp4", 2, 2, ["video", "audio"], build_replace_audio, file_types=[["video"], ["audio"]]),
|
| 1237 |
+
}
|
| 1238 |
+
|
| 1239 |
+
|
| 1240 |
+
def cleanup_worker():
|
| 1241 |
+
while True:
|
| 1242 |
+
now = time.time()
|
| 1243 |
+
expired_outputs = []
|
| 1244 |
+
for name in os.listdir(TEMP_DIR):
|
| 1245 |
+
path = os.path.join(TEMP_DIR, name)
|
| 1246 |
+
try:
|
| 1247 |
+
if os.path.abspath(path) == os.path.abspath(STATE_DB_PATH):
|
| 1248 |
+
continue
|
| 1249 |
+
if os.path.isfile(path) and now - os.path.getmtime(path) > FILE_TTL_SECONDS:
|
| 1250 |
+
expired_outputs.append(os.path.abspath(path))
|
| 1251 |
+
remove_file_quietly(path)
|
| 1252 |
+
except OSError:
|
| 1253 |
+
continue
|
| 1254 |
+
if expired_outputs:
|
| 1255 |
+
with db_connect() as connection:
|
| 1256 |
+
connection.executemany(
|
| 1257 |
+
"DELETE FROM history WHERE output = ?",
|
| 1258 |
+
[(path,) for path in expired_outputs],
|
| 1259 |
+
)
|
| 1260 |
+
load_state_store()
|
| 1261 |
+
time.sleep(600)
|
| 1262 |
+
|
| 1263 |
+
|
| 1264 |
+
threading.Thread(target=cleanup_worker, daemon=True).start()
|
| 1265 |
+
|
| 1266 |
+
|
| 1267 |
+
def file_category(path: str) -> str:
|
| 1268 |
+
try:
|
| 1269 |
+
mime_type = magic.from_file(path, mime=True) or ""
|
| 1270 |
+
except Exception:
|
| 1271 |
+
mime_type = ""
|
| 1272 |
+
if mime_type.startswith("video/"):
|
| 1273 |
+
return "video"
|
| 1274 |
+
if mime_type.startswith("audio/"):
|
| 1275 |
+
return "audio"
|
| 1276 |
+
if mime_type.startswith("image/"):
|
| 1277 |
+
return "image"
|
| 1278 |
+
if mime_type in {"text/plain", "application/x-subrip"} or path.lower().endswith(
|
| 1279 |
+
(".srt", ".ass", ".vtt")
|
| 1280 |
+
):
|
| 1281 |
+
return "text"
|
| 1282 |
+
return "unknown"
|
| 1283 |
+
|
| 1284 |
+
|
| 1285 |
+
def validate_files(task_id: str, paths: List[str]) -> None:
|
| 1286 |
+
task = get_task(task_id)
|
| 1287 |
+
if len(paths) < task.min_files:
|
| 1288 |
+
raise HTTPException(
|
| 1289 |
+
status_code=400,
|
| 1290 |
+
detail=f"{task.label} requires at least {task.min_files} file(s).",
|
| 1291 |
+
)
|
| 1292 |
+
if task.max_files is not None and len(paths) > task.max_files:
|
| 1293 |
+
raise HTTPException(
|
| 1294 |
+
status_code=400,
|
| 1295 |
+
detail=f"{task.label} accepts at most {task.max_files} file(s).",
|
| 1296 |
+
)
|
| 1297 |
+
for index, path in enumerate(paths):
|
| 1298 |
+
if not os.path.exists(path):
|
| 1299 |
+
raise HTTPException(status_code=400, detail=f"Input not found: {path}")
|
| 1300 |
+
if os.path.getsize(path) > MAX_UPLOAD_BYTES:
|
| 1301 |
+
raise HTTPException(
|
| 1302 |
+
status_code=413,
|
| 1303 |
+
detail=f"{os.path.basename(path)} exceeds {MAX_UPLOAD_MB} MB.",
|
| 1304 |
+
)
|
| 1305 |
+
category = file_category(path)
|
| 1306 |
+
expected_types = (
|
| 1307 |
+
task.file_types[index]
|
| 1308 |
+
if task.file_types and index < len(task.file_types)
|
| 1309 |
+
else task.accepted_types
|
| 1310 |
+
)
|
| 1311 |
+
if category not in expected_types:
|
| 1312 |
+
raise HTTPException(
|
| 1313 |
+
status_code=400,
|
| 1314 |
+
detail=(
|
| 1315 |
+
f"{os.path.basename(path)} is {category}; "
|
| 1316 |
+
f"{task.label} expects {', '.join(expected_types)}."
|
| 1317 |
+
),
|
| 1318 |
+
)
|
| 1319 |
+
|
| 1320 |
+
|
| 1321 |
+
def get_task(task_id: str) -> TaskDefinition:
|
| 1322 |
+
task = TASKS.get(task_id)
|
| 1323 |
+
if not task:
|
| 1324 |
+
raise HTTPException(status_code=404, detail=f"Unknown task: {task_id}")
|
| 1325 |
+
return task
|
| 1326 |
+
|
| 1327 |
+
|
| 1328 |
+
def output_path(task_id: str, ext: str, job_id: Optional[str] = None) -> str:
|
| 1329 |
+
safe_job_id = job_id or uuid.uuid4().hex[:8]
|
| 1330 |
+
return os.path.abspath(os.path.join(TEMP_DIR, f"{task_id}_{safe_job_id}.{ext}"))
|
| 1331 |
+
|
| 1332 |
+
|
| 1333 |
+
def zip_frames(pattern_prefix: str, zip_path: str) -> str:
|
| 1334 |
+
frame_dir = os.path.dirname(pattern_prefix)
|
| 1335 |
+
prefix = os.path.basename(pattern_prefix).split("_%04d")[0]
|
| 1336 |
+
archive_base = zip_path[:-4]
|
| 1337 |
+
frame_paths = [
|
| 1338 |
+
os.path.join(frame_dir, name)
|
| 1339 |
+
for name in os.listdir(frame_dir)
|
| 1340 |
+
if name.startswith(prefix) and name.endswith(".jpg")
|
| 1341 |
+
]
|
| 1342 |
+
if not frame_paths:
|
| 1343 |
+
raise HTTPException(status_code=500, detail="No frames were generated.")
|
| 1344 |
+
temp_archive_dir = os.path.join(TEMP_DIR, f"frames_{uuid.uuid4().hex[:8]}")
|
| 1345 |
+
os.makedirs(temp_archive_dir, exist_ok=True)
|
| 1346 |
+
try:
|
| 1347 |
+
for frame_path in frame_paths:
|
| 1348 |
+
shutil.copy2(frame_path, os.path.join(temp_archive_dir, os.path.basename(frame_path)))
|
| 1349 |
+
shutil.make_archive(archive_base, "zip", temp_archive_dir)
|
| 1350 |
+
finally:
|
| 1351 |
+
shutil.rmtree(temp_archive_dir, ignore_errors=True)
|
| 1352 |
+
return zip_path
|
| 1353 |
+
|
| 1354 |
+
|
| 1355 |
+
def run_ffmpeg(task_id: str, paths: List[str], options: Optional[Dict[str, str]] = None) -> str:
|
| 1356 |
+
task = get_task(task_id)
|
| 1357 |
+
opts = options or {}
|
| 1358 |
+
validate_files(task_id, paths)
|
| 1359 |
+
job_id = uuid.uuid4().hex[:8]
|
| 1360 |
+
out = output_path(task_id, task.output_ext, job_id)
|
| 1361 |
+
if task.runner:
|
| 1362 |
+
try:
|
| 1363 |
+
return task.runner(paths, opts, out)
|
| 1364 |
+
except subprocess.CalledProcessError as exc:
|
| 1365 |
+
stderr = exc.stderr.strip()[-2000:] or "FFmpeg failed without stderr."
|
| 1366 |
+
raise HTTPException(status_code=500, detail=stderr)
|
| 1367 |
+
if not task.builder:
|
| 1368 |
+
raise HTTPException(status_code=500, detail=f"{task_id} is not executable.")
|
| 1369 |
+
cmd = task.builder(paths, opts, out)
|
| 1370 |
+
try:
|
| 1371 |
+
run_command(cmd)
|
| 1372 |
+
if task_id == "extract_frames":
|
| 1373 |
+
out = zip_frames(out.replace(".zip", "_%04d.jpg"), out)
|
| 1374 |
+
return out
|
| 1375 |
+
except subprocess.CalledProcessError as exc:
|
| 1376 |
+
stderr = exc.stderr.strip()[-2000:] or "FFmpeg failed without stderr."
|
| 1377 |
+
raise HTTPException(status_code=500, detail=stderr)
|
| 1378 |
+
|
| 1379 |
+
|
| 1380 |
+
def record_history(job_id: str, task_id: str, output: str) -> None:
|
| 1381 |
+
task = get_task(task_id)
|
| 1382 |
+
history_id = uuid.uuid4().hex[:12]
|
| 1383 |
+
item = {
|
| 1384 |
+
"history_id": history_id,
|
| 1385 |
+
"job_id": job_id,
|
| 1386 |
+
"task_id": task_id,
|
| 1387 |
+
"task": task.label,
|
| 1388 |
+
"output": output,
|
| 1389 |
+
"filename": os.path.basename(output),
|
| 1390 |
+
"size": os.path.getsize(output) if os.path.exists(output) else 0,
|
| 1391 |
+
"created_at": int(time.time()),
|
| 1392 |
+
"download_url": f"/history/{history_id}/download",
|
| 1393 |
+
}
|
| 1394 |
+
with STATE_LOCK:
|
| 1395 |
+
HISTORY.insert(0, item)
|
| 1396 |
+
del HISTORY[HISTORY_LIMIT:]
|
| 1397 |
+
persist_history(item)
|
| 1398 |
+
|
| 1399 |
+
|
| 1400 |
+
def update_job(job_id: str, **updates: object) -> None:
|
| 1401 |
+
with STATE_LOCK:
|
| 1402 |
+
job = JOBS.get(job_id)
|
| 1403 |
+
if not job:
|
| 1404 |
+
return
|
| 1405 |
+
job.update(updates)
|
| 1406 |
+
snapshot = dict(job)
|
| 1407 |
+
persist_job(snapshot)
|
| 1408 |
+
|
| 1409 |
+
|
| 1410 |
+
def execute_job(job_id: str, task_id: str, paths: List[str], options: Dict[str, str]) -> None:
|
| 1411 |
+
update_job(job_id, status="running", message="Running FFmpeg")
|
| 1412 |
+
try:
|
| 1413 |
+
output = run_ffmpeg(task_id, paths, options)
|
| 1414 |
+
record_history(job_id, task_id, output)
|
| 1415 |
+
update_job(job_id, status="complete", message="Complete", output=output)
|
| 1416 |
+
except HTTPException as exc:
|
| 1417 |
+
update_job(job_id, status="failed", message=exc.detail)
|
| 1418 |
+
except Exception as exc:
|
| 1419 |
+
update_job(job_id, status="failed", message=str(exc))
|
| 1420 |
+
|
| 1421 |
+
|
| 1422 |
+
async def save_uploads(files: Optional[List[UploadFile]]) -> List[str]:
|
| 1423 |
+
saved = []
|
| 1424 |
+
if not files:
|
| 1425 |
+
return saved
|
| 1426 |
+
for upload in files:
|
| 1427 |
+
filename = os.path.basename(upload.filename or "upload.bin")
|
| 1428 |
+
path = os.path.join(TEMP_DIR, f"{uuid.uuid4().hex}_{filename}")
|
| 1429 |
+
size = 0
|
| 1430 |
+
with open(path, "wb") as handle:
|
| 1431 |
+
while chunk := await upload.read(1024 * 1024):
|
| 1432 |
+
size += len(chunk)
|
| 1433 |
+
if size > MAX_UPLOAD_BYTES:
|
| 1434 |
+
handle.close()
|
| 1435 |
+
remove_file_quietly(path)
|
| 1436 |
+
raise HTTPException(
|
| 1437 |
+
status_code=413,
|
| 1438 |
+
detail=f"{filename} exceeds {MAX_UPLOAD_MB} MB.",
|
| 1439 |
+
)
|
| 1440 |
+
handle.write(chunk)
|
| 1441 |
+
saved.append(path)
|
| 1442 |
+
return saved
|
| 1443 |
+
|
| 1444 |
+
|
| 1445 |
+
def safe_upload_name(filename: str, fallback: str = "upload.bin") -> str:
|
| 1446 |
+
cleaned = os.path.basename(filename or fallback).strip()
|
| 1447 |
+
return cleaned or fallback
|
| 1448 |
+
|
| 1449 |
+
|
| 1450 |
+
def save_bytes_file(content: bytes, filename: str) -> str:
|
| 1451 |
+
if len(content) > MAX_UPLOAD_BYTES:
|
| 1452 |
+
raise HTTPException(
|
| 1453 |
+
status_code=413,
|
| 1454 |
+
detail=f"{safe_upload_name(filename)} exceeds {MAX_UPLOAD_MB} MB.",
|
| 1455 |
+
)
|
| 1456 |
+
path = os.path.abspath(
|
| 1457 |
+
os.path.join(TEMP_DIR, f"{uuid.uuid4().hex}_{safe_upload_name(filename)}")
|
| 1458 |
+
)
|
| 1459 |
+
with open(path, "wb") as handle:
|
| 1460 |
+
handle.write(content)
|
| 1461 |
+
return path
|
| 1462 |
+
|
| 1463 |
+
|
| 1464 |
+
def decode_base64_content(value: str) -> bytes:
|
| 1465 |
+
if "," in value and value.lstrip().startswith("data:"):
|
| 1466 |
+
value = value.split(",", 1)[1]
|
| 1467 |
+
try:
|
| 1468 |
+
return base64.b64decode(value, validate=True)
|
| 1469 |
+
except (binascii.Error, ValueError) as exc:
|
| 1470 |
+
raise HTTPException(status_code=400, detail="Invalid base64 file content.") from exc
|
| 1471 |
+
|
| 1472 |
+
|
| 1473 |
+
def collect_base64_files(payload: object) -> List[str]:
|
| 1474 |
+
saved = []
|
| 1475 |
+
|
| 1476 |
+
def walk(value: object, inherited_name: str = "n8n-upload.bin") -> None:
|
| 1477 |
+
if isinstance(value, list):
|
| 1478 |
+
for item in value:
|
| 1479 |
+
walk(item, inherited_name)
|
| 1480 |
+
return
|
| 1481 |
+
if not isinstance(value, dict):
|
| 1482 |
+
return
|
| 1483 |
+
|
| 1484 |
+
filename = str(
|
| 1485 |
+
value.get("fileName")
|
| 1486 |
+
or value.get("filename")
|
| 1487 |
+
or value.get("name")
|
| 1488 |
+
or inherited_name
|
| 1489 |
+
)
|
| 1490 |
+
content = (
|
| 1491 |
+
value.get("content_base64")
|
| 1492 |
+
or value.get("base64")
|
| 1493 |
+
or value.get("content")
|
| 1494 |
+
or value.get("data")
|
| 1495 |
+
)
|
| 1496 |
+
if isinstance(content, str) and (
|
| 1497 |
+
value.get("mimeType")
|
| 1498 |
+
or value.get("mime_type")
|
| 1499 |
+
or value.get("fileName")
|
| 1500 |
+
or value.get("filename")
|
| 1501 |
+
or value.get("base64")
|
| 1502 |
+
or value.get("content_base64")
|
| 1503 |
+
):
|
| 1504 |
+
saved.append(save_bytes_file(decode_base64_content(content), filename))
|
| 1505 |
+
return
|
| 1506 |
+
|
| 1507 |
+
for key, nested in value.items():
|
| 1508 |
+
walk(nested, str(key))
|
| 1509 |
+
|
| 1510 |
+
walk(payload)
|
| 1511 |
+
return saved
|
| 1512 |
+
|
| 1513 |
+
|
| 1514 |
+
def split_url_values(value: object) -> List[str]:
|
| 1515 |
+
if not value:
|
| 1516 |
+
return list()
|
| 1517 |
+
if isinstance(value, list):
|
| 1518 |
+
urls = []
|
| 1519 |
+
for item in value:
|
| 1520 |
+
urls.extend(split_url_values(item))
|
| 1521 |
+
return urls
|
| 1522 |
+
if not isinstance(value, str):
|
| 1523 |
+
return list()
|
| 1524 |
+
chunks = value.replace(",", "\n").splitlines()
|
| 1525 |
+
return [chunk.strip() for chunk in chunks if chunk.strip()]
|
| 1526 |
+
|
| 1527 |
+
|
| 1528 |
+
def options_from_mapping(values: Dict[str, object]) -> Dict[str, str]:
|
| 1529 |
+
nested_options = values.get("options")
|
| 1530 |
+
merged = dict(values)
|
| 1531 |
+
if isinstance(nested_options, dict):
|
| 1532 |
+
merged.update(nested_options)
|
| 1533 |
+
return {
|
| 1534 |
+
field: "" if merged.get(field) is None else str(merged.get(field, ""))
|
| 1535 |
+
for field in OPTION_FIELDS
|
| 1536 |
+
}
|
| 1537 |
+
|
| 1538 |
+
|
| 1539 |
+
async def collect_n8n_inputs(request: Request) -> tuple[List[str], Dict[str, str]]:
|
| 1540 |
+
content_type = request.headers.get("content-type", "").lower()
|
| 1541 |
+
saved: List[str] = []
|
| 1542 |
+
values: Dict[str, object] = {}
|
| 1543 |
+
|
| 1544 |
+
if "multipart/form-data" in content_type:
|
| 1545 |
+
form = await request.form()
|
| 1546 |
+
uploads = []
|
| 1547 |
+
urls = []
|
| 1548 |
+
for key, value in form.multi_items():
|
| 1549 |
+
if hasattr(value, "filename") and hasattr(value, "read"):
|
| 1550 |
+
uploads.append(value)
|
| 1551 |
+
else:
|
| 1552 |
+
values[key] = value
|
| 1553 |
+
if key in {"url", "urls", "file_url", "source_url", "download_url"}:
|
| 1554 |
+
urls.extend(split_url_values(value))
|
| 1555 |
+
saved.extend(await save_uploads(uploads))
|
| 1556 |
+
for url in urls:
|
| 1557 |
+
saved.append(download_url(url))
|
| 1558 |
+
return saved, options_from_mapping(values)
|
| 1559 |
+
|
| 1560 |
+
if "application/json" in content_type:
|
| 1561 |
+
payload = await request.json()
|
| 1562 |
+
if not isinstance(payload, dict):
|
| 1563 |
+
raise HTTPException(status_code=400, detail="JSON body must be an object.")
|
| 1564 |
+
values.update(payload)
|
| 1565 |
+
for key in ("url", "urls", "file_url", "source_url", "download_url"):
|
| 1566 |
+
for url in split_url_values(payload.get(key)):
|
| 1567 |
+
saved.append(download_url(url))
|
| 1568 |
+
saved.extend(collect_base64_files(payload.get("files", [])))
|
| 1569 |
+
saved.extend(collect_base64_files(payload.get("binary", {})))
|
| 1570 |
+
saved.extend(collect_base64_files(payload.get("data", {})))
|
| 1571 |
+
if not saved:
|
| 1572 |
+
saved.extend(collect_base64_files(payload))
|
| 1573 |
+
return saved, options_from_mapping(values)
|
| 1574 |
+
|
| 1575 |
+
body = await request.body()
|
| 1576 |
+
if body:
|
| 1577 |
+
filename = (
|
| 1578 |
+
request.headers.get("x-filename")
|
| 1579 |
+
or request.headers.get("x-file-name")
|
| 1580 |
+
or "n8n-upload.bin"
|
| 1581 |
+
)
|
| 1582 |
+
saved.append(save_bytes_file(body, filename))
|
| 1583 |
+
return saved, options_from_mapping(dict(request.query_params))
|
| 1584 |
+
|
| 1585 |
+
|
| 1586 |
+
def download_url(url: str) -> str:
|
| 1587 |
+
if not ALLOW_URL_INPUTS:
|
| 1588 |
+
raise HTTPException(status_code=403, detail="URL inputs are disabled.")
|
| 1589 |
+
outtmpl = os.path.join(TEMP_DIR, f"{uuid.uuid4().hex}.%(ext)s")
|
| 1590 |
+
ydl_opts = {
|
| 1591 |
+
"outtmpl": outtmpl,
|
| 1592 |
+
"format": "bestvideo+bestaudio/best",
|
| 1593 |
+
"max_filesize": MAX_URL_DOWNLOAD_BYTES,
|
| 1594 |
+
"noplaylist": True,
|
| 1595 |
+
"quiet": True,
|
| 1596 |
+
"no_warnings": True,
|
| 1597 |
+
}
|
| 1598 |
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
| 1599 |
+
info = ydl.extract_info(url, download=True)
|
| 1600 |
+
path = ydl.prepare_filename(info)
|
| 1601 |
+
if not os.path.exists(path):
|
| 1602 |
+
raise HTTPException(status_code=502, detail="URL download did not produce a file.")
|
| 1603 |
+
if os.path.getsize(path) > MAX_URL_DOWNLOAD_BYTES:
|
| 1604 |
+
remove_file_quietly(path)
|
| 1605 |
+
raise HTTPException(
|
| 1606 |
+
status_code=413,
|
| 1607 |
+
detail=f"Downloaded media exceeds {MAX_URL_DOWNLOAD_MB} MB.",
|
| 1608 |
+
)
|
| 1609 |
+
return path
|
| 1610 |
+
|
| 1611 |
+
|
| 1612 |
+
def options_from_form(
|
| 1613 |
+
text: str,
|
| 1614 |
+
start_time: str,
|
| 1615 |
+
end_time: str,
|
| 1616 |
+
duration: str,
|
| 1617 |
+
aspect_ratio: str,
|
| 1618 |
+
resolution: str,
|
| 1619 |
+
crf: str,
|
| 1620 |
+
preset: str,
|
| 1621 |
+
audio_bitrate: str,
|
| 1622 |
+
volume: str,
|
| 1623 |
+
position: str,
|
| 1624 |
+
opacity: str,
|
| 1625 |
+
fps: str,
|
| 1626 |
+
width: str,
|
| 1627 |
+
speed: str,
|
| 1628 |
+
timestamp: str,
|
| 1629 |
+
image_duration: str,
|
| 1630 |
+
frame_rate: str,
|
| 1631 |
+
font_size: str,
|
| 1632 |
+
wave_color: str,
|
| 1633 |
+
) -> Dict[str, str]:
|
| 1634 |
+
return {
|
| 1635 |
+
"text": text or "",
|
| 1636 |
+
"start_time": start_time or "",
|
| 1637 |
+
"end_time": end_time or "",
|
| 1638 |
+
"duration": duration or "",
|
| 1639 |
+
"aspect_ratio": aspect_ratio or "",
|
| 1640 |
+
"resolution": resolution or "",
|
| 1641 |
+
"crf": crf or "",
|
| 1642 |
+
"preset": preset or "",
|
| 1643 |
+
"audio_bitrate": audio_bitrate or "",
|
| 1644 |
+
"volume": volume or "",
|
| 1645 |
+
"position": position or "",
|
| 1646 |
+
"opacity": opacity or "",
|
| 1647 |
+
"fps": fps or "",
|
| 1648 |
+
"width": width or "",
|
| 1649 |
+
"speed": speed or "",
|
| 1650 |
+
"timestamp": timestamp or "",
|
| 1651 |
+
"image_duration": image_duration or "",
|
| 1652 |
+
"frame_rate": frame_rate or "",
|
| 1653 |
+
"font_size": font_size or "",
|
| 1654 |
+
"wave_color": wave_color or "",
|
| 1655 |
+
}
|
| 1656 |
+
|
| 1657 |
+
|
| 1658 |
+
@app.get("/healthz")
|
| 1659 |
+
def healthz():
|
| 1660 |
+
return {"status": "ok", "checks": production_checks()}
|
| 1661 |
+
|
| 1662 |
+
|
| 1663 |
+
@app.get("/readyz")
|
| 1664 |
+
def readyz():
|
| 1665 |
+
checks = production_checks()
|
| 1666 |
+
if not production_ready():
|
| 1667 |
+
raise HTTPException(status_code=503, detail={"status": "not_ready", "checks": checks})
|
| 1668 |
+
return {"status": "ready", "checks": checks}
|
| 1669 |
+
|
| 1670 |
+
|
| 1671 |
+
@app.get("/tasks")
|
| 1672 |
+
def list_tasks():
|
| 1673 |
+
return {
|
| 1674 |
+
task_id: {
|
| 1675 |
+
"label": task.label,
|
| 1676 |
+
"description": task.description,
|
| 1677 |
+
"category": task.category,
|
| 1678 |
+
"output_ext": task.output_ext,
|
| 1679 |
+
"min_files": task.min_files,
|
| 1680 |
+
"max_files": task.max_files,
|
| 1681 |
+
"accepted_types": task.accepted_types,
|
| 1682 |
+
"file_types": task.file_types,
|
| 1683 |
+
}
|
| 1684 |
+
for task_id, task in TASKS.items()
|
| 1685 |
+
}
|
| 1686 |
+
|
| 1687 |
+
|
| 1688 |
+
@app.post("/n8n/execute/{task_id}")
|
| 1689 |
+
async def n8n_execute(task_id: str, request: Request):
|
| 1690 |
+
task = get_task(task_id)
|
| 1691 |
+
saved, options = await collect_n8n_inputs(request)
|
| 1692 |
+
if not saved and task.min_files > 0:
|
| 1693 |
+
raise HTTPException(status_code=400, detail="No n8n binary, base64 file, raw body, or URL input provided.")
|
| 1694 |
+
output = run_ffmpeg(task_id, saved, options)
|
| 1695 |
+
record_history(uuid.uuid4().hex[:8], task_id, output)
|
| 1696 |
+
return FileResponse(output, filename=os.path.basename(output))
|
| 1697 |
+
|
| 1698 |
+
|
| 1699 |
+
@app.post("/n8n/jobs/{task_id}")
|
| 1700 |
+
async def n8n_create_job(task_id: str, request: Request, background_tasks: BackgroundTasks):
|
| 1701 |
+
task = get_task(task_id)
|
| 1702 |
+
saved, options = await collect_n8n_inputs(request)
|
| 1703 |
+
if not saved and task.min_files > 0:
|
| 1704 |
+
raise HTTPException(status_code=400, detail="No n8n binary, base64 file, raw body, or URL input provided.")
|
| 1705 |
+
job_id = uuid.uuid4().hex[:12]
|
| 1706 |
+
job = {
|
| 1707 |
+
"job_id": job_id,
|
| 1708 |
+
"task_id": task_id,
|
| 1709 |
+
"status": "queued",
|
| 1710 |
+
"message": "Queued",
|
| 1711 |
+
"output": None,
|
| 1712 |
+
"created_at": int(time.time()),
|
| 1713 |
+
}
|
| 1714 |
+
with STATE_LOCK:
|
| 1715 |
+
JOBS[job_id] = job
|
| 1716 |
+
persist_job(job)
|
| 1717 |
+
background_tasks.add_task(execute_job, job_id, task_id, saved, options)
|
| 1718 |
+
return {"job_id": job_id, "status_url": f"/status/{job_id}"}
|
| 1719 |
+
|
| 1720 |
+
|
| 1721 |
+
@app.post("/execute/{task_id}")
|
| 1722 |
+
async def api_call(
|
| 1723 |
+
task_id: str,
|
| 1724 |
+
files: List[UploadFile] = File(None),
|
| 1725 |
+
url: Optional[str] = Form(None),
|
| 1726 |
+
text: str = Form(""),
|
| 1727 |
+
start_time: str = Form(""),
|
| 1728 |
+
end_time: str = Form(""),
|
| 1729 |
+
duration: str = Form(""),
|
| 1730 |
+
aspect_ratio: str = Form(""),
|
| 1731 |
+
resolution: str = Form(""),
|
| 1732 |
+
crf: str = Form(""),
|
| 1733 |
+
preset: str = Form(""),
|
| 1734 |
+
audio_bitrate: str = Form(""),
|
| 1735 |
+
volume: str = Form(""),
|
| 1736 |
+
position: str = Form(""),
|
| 1737 |
+
opacity: str = Form(""),
|
| 1738 |
+
fps: str = Form(""),
|
| 1739 |
+
width: str = Form(""),
|
| 1740 |
+
speed: str = Form(""),
|
| 1741 |
+
timestamp: str = Form(""),
|
| 1742 |
+
image_duration: str = Form(""),
|
| 1743 |
+
frame_rate: str = Form(""),
|
| 1744 |
+
font_size: str = Form(""),
|
| 1745 |
+
wave_color: str = Form(""),
|
| 1746 |
+
):
|
| 1747 |
+
task = get_task(task_id)
|
| 1748 |
+
saved = []
|
| 1749 |
+
if url:
|
| 1750 |
+
saved.append(download_url(url))
|
| 1751 |
+
saved.extend(await save_uploads(files))
|
| 1752 |
+
if not saved and task.min_files > 0:
|
| 1753 |
+
raise HTTPException(status_code=400, detail="No input provided")
|
| 1754 |
+
options = options_from_form(
|
| 1755 |
+
text,
|
| 1756 |
+
start_time,
|
| 1757 |
+
end_time,
|
| 1758 |
+
duration,
|
| 1759 |
+
aspect_ratio,
|
| 1760 |
+
resolution,
|
| 1761 |
+
crf,
|
| 1762 |
+
preset,
|
| 1763 |
+
audio_bitrate,
|
| 1764 |
+
volume,
|
| 1765 |
+
position,
|
| 1766 |
+
opacity,
|
| 1767 |
+
fps,
|
| 1768 |
+
width,
|
| 1769 |
+
speed,
|
| 1770 |
+
timestamp,
|
| 1771 |
+
image_duration,
|
| 1772 |
+
frame_rate,
|
| 1773 |
+
font_size,
|
| 1774 |
+
wave_color,
|
| 1775 |
+
)
|
| 1776 |
+
output = run_ffmpeg(task_id, saved, options)
|
| 1777 |
+
record_history(uuid.uuid4().hex[:8], task_id, output)
|
| 1778 |
+
return FileResponse(output, filename=os.path.basename(output))
|
| 1779 |
+
|
| 1780 |
+
|
| 1781 |
+
@app.post("/jobs/{task_id}")
|
| 1782 |
+
async def create_job(
|
| 1783 |
+
task_id: str,
|
| 1784 |
+
background_tasks: BackgroundTasks,
|
| 1785 |
+
files: List[UploadFile] = File(None),
|
| 1786 |
+
url: Optional[str] = Form(None),
|
| 1787 |
+
text: str = Form(""),
|
| 1788 |
+
start_time: str = Form(""),
|
| 1789 |
+
end_time: str = Form(""),
|
| 1790 |
+
duration: str = Form(""),
|
| 1791 |
+
aspect_ratio: str = Form(""),
|
| 1792 |
+
resolution: str = Form(""),
|
| 1793 |
+
crf: str = Form(""),
|
| 1794 |
+
preset: str = Form(""),
|
| 1795 |
+
audio_bitrate: str = Form(""),
|
| 1796 |
+
volume: str = Form(""),
|
| 1797 |
+
position: str = Form(""),
|
| 1798 |
+
opacity: str = Form(""),
|
| 1799 |
+
fps: str = Form(""),
|
| 1800 |
+
width: str = Form(""),
|
| 1801 |
+
speed: str = Form(""),
|
| 1802 |
+
timestamp: str = Form(""),
|
| 1803 |
+
image_duration: str = Form(""),
|
| 1804 |
+
frame_rate: str = Form(""),
|
| 1805 |
+
font_size: str = Form(""),
|
| 1806 |
+
wave_color: str = Form(""),
|
| 1807 |
+
):
|
| 1808 |
+
task = get_task(task_id)
|
| 1809 |
+
saved = []
|
| 1810 |
+
if url:
|
| 1811 |
+
saved.append(download_url(url))
|
| 1812 |
+
saved.extend(await save_uploads(files))
|
| 1813 |
+
if not saved and task.min_files > 0:
|
| 1814 |
+
raise HTTPException(status_code=400, detail="No input provided")
|
| 1815 |
+
options = options_from_form(
|
| 1816 |
+
text,
|
| 1817 |
+
start_time,
|
| 1818 |
+
end_time,
|
| 1819 |
+
duration,
|
| 1820 |
+
aspect_ratio,
|
| 1821 |
+
resolution,
|
| 1822 |
+
crf,
|
| 1823 |
+
preset,
|
| 1824 |
+
audio_bitrate,
|
| 1825 |
+
volume,
|
| 1826 |
+
position,
|
| 1827 |
+
opacity,
|
| 1828 |
+
fps,
|
| 1829 |
+
width,
|
| 1830 |
+
speed,
|
| 1831 |
+
timestamp,
|
| 1832 |
+
image_duration,
|
| 1833 |
+
frame_rate,
|
| 1834 |
+
font_size,
|
| 1835 |
+
wave_color,
|
| 1836 |
+
)
|
| 1837 |
+
job_id = uuid.uuid4().hex[:12]
|
| 1838 |
+
job = {
|
| 1839 |
+
"job_id": job_id,
|
| 1840 |
+
"task_id": task_id,
|
| 1841 |
+
"status": "queued",
|
| 1842 |
+
"message": "Queued",
|
| 1843 |
+
"output": None,
|
| 1844 |
+
"created_at": int(time.time()),
|
| 1845 |
+
}
|
| 1846 |
+
with STATE_LOCK:
|
| 1847 |
+
JOBS[job_id] = job
|
| 1848 |
+
persist_job(job)
|
| 1849 |
+
background_tasks.add_task(execute_job, job_id, task_id, saved, options)
|
| 1850 |
+
return {"job_id": job_id, "status_url": f"/status/{job_id}"}
|
| 1851 |
+
|
| 1852 |
+
|
| 1853 |
+
@app.get("/status/{job_id}")
|
| 1854 |
+
def job_status(job_id: str):
|
| 1855 |
+
with STATE_LOCK:
|
| 1856 |
+
job = JOBS.get(job_id)
|
| 1857 |
+
if not job:
|
| 1858 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 1859 |
+
data = dict(job)
|
| 1860 |
+
if data.get("output"):
|
| 1861 |
+
data["download_url"] = f"/download/{job_id}"
|
| 1862 |
+
return data
|
| 1863 |
+
|
| 1864 |
+
|
| 1865 |
+
@app.get("/download/{job_id}")
|
| 1866 |
+
def download_job(job_id: str):
|
| 1867 |
+
with STATE_LOCK:
|
| 1868 |
+
job = JOBS.get(job_id)
|
| 1869 |
+
if not job:
|
| 1870 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 1871 |
+
output = job.get("output")
|
| 1872 |
+
if not output or not os.path.exists(str(output)):
|
| 1873 |
+
raise HTTPException(status_code=404, detail="Output not available")
|
| 1874 |
+
return FileResponse(str(output), filename=os.path.basename(str(output)))
|
| 1875 |
+
|
| 1876 |
+
|
| 1877 |
+
@app.get("/history")
|
| 1878 |
+
def history():
|
| 1879 |
+
with STATE_LOCK:
|
| 1880 |
+
return [
|
| 1881 |
+
{key: value for key, value in item.items() if key != "output"}
|
| 1882 |
+
for item in HISTORY
|
| 1883 |
+
]
|
| 1884 |
+
|
| 1885 |
+
|
| 1886 |
+
@app.get("/history/{history_id}/download")
|
| 1887 |
+
def download_history_item(history_id: str):
|
| 1888 |
+
with STATE_LOCK:
|
| 1889 |
+
item = next(
|
| 1890 |
+
(entry for entry in HISTORY if entry.get("history_id") == history_id), None
|
| 1891 |
+
)
|
| 1892 |
+
if not item:
|
| 1893 |
+
raise HTTPException(status_code=404, detail="History item not found")
|
| 1894 |
+
output = str(item["output"])
|
| 1895 |
+
if not os.path.exists(output):
|
| 1896 |
+
raise HTTPException(status_code=404, detail="Output expired")
|
| 1897 |
+
return FileResponse(output, filename=os.path.basename(output))
|
| 1898 |
+
|
| 1899 |
+
|
| 1900 |
+
def task_choices() -> List[str]:
|
| 1901 |
+
return [f"{task.label} ({task_id})" for task_id, task in TASKS.items()]
|
| 1902 |
+
|
| 1903 |
+
|
| 1904 |
+
def selected_task_id(choice: str) -> str:
|
| 1905 |
+
if not choice:
|
| 1906 |
+
return "normalize"
|
| 1907 |
+
if "(" in choice and choice.endswith(")"):
|
| 1908 |
+
return choice.rsplit("(", 1)[1][:-1]
|
| 1909 |
+
return choice if choice in TASKS else "normalize"
|
| 1910 |
+
|
| 1911 |
+
|
| 1912 |
+
def describe_task(choice: str) -> str:
|
| 1913 |
+
task_id = selected_task_id(choice)
|
| 1914 |
+
task = get_task(task_id)
|
| 1915 |
+
max_files = "unlimited" if task.max_files is None else str(task.max_files)
|
| 1916 |
+
return (
|
| 1917 |
+
f"{task.description}\n\n"
|
| 1918 |
+
f"Files: {task.min_files}-{max_files}. "
|
| 1919 |
+
f"Accepted: {', '.join(task.accepted_types)}. "
|
| 1920 |
+
f"Output: .{task.output_ext}"
|
| 1921 |
+
)
|
| 1922 |
+
|
| 1923 |
+
|
| 1924 |
+
def option_visibility(choice: str):
|
| 1925 |
+
task_id = selected_task_id(choice)
|
| 1926 |
+
text_tasks = {
|
| 1927 |
+
"text_overlay",
|
| 1928 |
+
"tiktok_lyrics",
|
| 1929 |
+
"tiktok_pro_reframer",
|
| 1930 |
+
"reels_safe_caption",
|
| 1931 |
+
"reels_hook_title",
|
| 1932 |
+
"faceless_quote_card",
|
| 1933 |
+
"faceless_story_pages",
|
| 1934 |
+
"faceless_image_narration",
|
| 1935 |
+
"faceless_video_narration",
|
| 1936 |
+
"series_split_pack",
|
| 1937 |
+
"series_episode_badge",
|
| 1938 |
+
"series_batch_pack",
|
| 1939 |
+
"series_recap_card",
|
| 1940 |
+
}
|
| 1941 |
+
duration_tasks = {
|
| 1942 |
+
"make_gif",
|
| 1943 |
+
"tiktok_lyrics",
|
| 1944 |
+
"reels_progress_bar",
|
| 1945 |
+
"reels_loop",
|
| 1946 |
+
"faceless_quote_card",
|
| 1947 |
+
"faceless_broll_montage",
|
| 1948 |
+
"series_split_pack",
|
| 1949 |
+
"series_recap_card",
|
| 1950 |
+
}
|
| 1951 |
+
quality_tasks = {
|
| 1952 |
+
"normalize",
|
| 1953 |
+
"compress",
|
| 1954 |
+
"batch_compress",
|
| 1955 |
+
"reels_blur_fit",
|
| 1956 |
+
"reels_loop",
|
| 1957 |
+
"reels_reaction_stack",
|
| 1958 |
+
"faceless_image_narration",
|
| 1959 |
+
"faceless_video_narration",
|
| 1960 |
+
"faceless_broll_montage",
|
| 1961 |
+
"series_split_pack",
|
| 1962 |
+
"series_batch_pack",
|
| 1963 |
+
}
|
| 1964 |
+
audio_tasks = {"extract_audio", "merge_music", "reels_audio_duck"}
|
| 1965 |
+
font_tasks = {
|
| 1966 |
+
"text_overlay",
|
| 1967 |
+
"tiktok_lyrics",
|
| 1968 |
+
"tiktok_pro_reframer",
|
| 1969 |
+
"reels_safe_caption",
|
| 1970 |
+
"reels_hook_title",
|
| 1971 |
+
"faceless_quote_card",
|
| 1972 |
+
"faceless_story_pages",
|
| 1973 |
+
"faceless_image_narration",
|
| 1974 |
+
"faceless_video_narration",
|
| 1975 |
+
"series_episode_badge",
|
| 1976 |
+
"series_recap_card",
|
| 1977 |
+
}
|
| 1978 |
+
visible = {
|
| 1979 |
+
"text": task_id in text_tasks,
|
| 1980 |
+
"trim": task_id == "trim",
|
| 1981 |
+
"duration": task_id in duration_tasks,
|
| 1982 |
+
"timestamp": task_id == "thumbnail",
|
| 1983 |
+
"aspect": task_id in {"resize_916", "crop_aspect"},
|
| 1984 |
+
"resolution": task_id == "compress",
|
| 1985 |
+
"quality": task_id in quality_tasks,
|
| 1986 |
+
"audio": task_id in audio_tasks,
|
| 1987 |
+
"watermark": task_id == "watermark",
|
| 1988 |
+
"gif": task_id == "make_gif",
|
| 1989 |
+
"speed": task_id == "speed",
|
| 1990 |
+
"slideshow": task_id in {"slideshow", "faceless_story_pages", "faceless_broll_montage"},
|
| 1991 |
+
"frames": task_id == "extract_frames",
|
| 1992 |
+
"font": task_id in font_tasks,
|
| 1993 |
+
"wave": task_id == "waveform",
|
| 1994 |
+
}
|
| 1995 |
+
return [
|
| 1996 |
+
describe_task(choice),
|
| 1997 |
+
gr.update(visible=visible["text"]),
|
| 1998 |
+
gr.update(visible=visible["trim"]),
|
| 1999 |
+
gr.update(visible=visible["duration"]),
|
| 2000 |
+
gr.update(visible=visible["timestamp"]),
|
| 2001 |
+
gr.update(visible=visible["aspect"]),
|
| 2002 |
+
gr.update(visible=visible["resolution"]),
|
| 2003 |
+
gr.update(visible=visible["quality"]),
|
| 2004 |
+
gr.update(visible=visible["audio"]),
|
| 2005 |
+
gr.update(visible=visible["watermark"]),
|
| 2006 |
+
gr.update(visible=visible["gif"]),
|
| 2007 |
+
gr.update(visible=visible["speed"]),
|
| 2008 |
+
gr.update(visible=visible["slideshow"]),
|
| 2009 |
+
gr.update(visible=visible["frames"]),
|
| 2010 |
+
gr.update(visible=visible["font"]),
|
| 2011 |
+
gr.update(visible=visible["wave"]),
|
| 2012 |
+
]
|
| 2013 |
+
|
| 2014 |
+
|
| 2015 |
+
def ui_handler(
|
| 2016 |
+
files,
|
| 2017 |
+
url,
|
| 2018 |
+
task_choice,
|
| 2019 |
+
text,
|
| 2020 |
+
start_time,
|
| 2021 |
+
end_time,
|
| 2022 |
+
duration,
|
| 2023 |
+
aspect_ratio,
|
| 2024 |
+
resolution,
|
| 2025 |
+
crf,
|
| 2026 |
+
preset,
|
| 2027 |
+
audio_bitrate,
|
| 2028 |
+
volume,
|
| 2029 |
+
position,
|
| 2030 |
+
opacity,
|
| 2031 |
+
fps,
|
| 2032 |
+
width,
|
| 2033 |
+
speed,
|
| 2034 |
+
timestamp,
|
| 2035 |
+
image_duration,
|
| 2036 |
+
frame_rate,
|
| 2037 |
+
font_size,
|
| 2038 |
+
wave_color,
|
| 2039 |
+
):
|
| 2040 |
+
task_id = selected_task_id(task_choice)
|
| 2041 |
+
task = get_task(task_id)
|
| 2042 |
+
paths = []
|
| 2043 |
+
if url:
|
| 2044 |
+
paths.append(download_url(url))
|
| 2045 |
+
if files:
|
| 2046 |
+
paths.extend([file.name for file in files])
|
| 2047 |
+
if not paths and task.min_files > 0:
|
| 2048 |
+
raise gr.Error("Upload files or provide a URL.")
|
| 2049 |
+
options = options_from_form(
|
| 2050 |
+
text,
|
| 2051 |
+
start_time,
|
| 2052 |
+
end_time,
|
| 2053 |
+
duration,
|
| 2054 |
+
aspect_ratio,
|
| 2055 |
+
resolution,
|
| 2056 |
+
crf,
|
| 2057 |
+
preset,
|
| 2058 |
+
audio_bitrate,
|
| 2059 |
+
volume,
|
| 2060 |
+
position,
|
| 2061 |
+
opacity,
|
| 2062 |
+
fps,
|
| 2063 |
+
width,
|
| 2064 |
+
speed,
|
| 2065 |
+
timestamp,
|
| 2066 |
+
image_duration,
|
| 2067 |
+
frame_rate,
|
| 2068 |
+
font_size,
|
| 2069 |
+
wave_color,
|
| 2070 |
+
)
|
| 2071 |
+
try:
|
| 2072 |
+
output = run_ffmpeg(task_id, paths, options)
|
| 2073 |
+
record_history(uuid.uuid4().hex[:8], task_id, output)
|
| 2074 |
+
return output, history_table()
|
| 2075 |
+
except HTTPException as exc:
|
| 2076 |
+
raise gr.Error(str(exc.detail))
|
| 2077 |
+
|
| 2078 |
+
|
| 2079 |
+
def history_table():
|
| 2080 |
+
with STATE_LOCK:
|
| 2081 |
+
rows = [
|
| 2082 |
+
[
|
| 2083 |
+
item["task"],
|
| 2084 |
+
item["filename"],
|
| 2085 |
+
item["size"],
|
| 2086 |
+
time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(item["created_at"])),
|
| 2087 |
+
]
|
| 2088 |
+
for item in HISTORY[:10]
|
| 2089 |
+
]
|
| 2090 |
+
return rows
|
| 2091 |
+
|
| 2092 |
+
|
| 2093 |
+
with gr.Blocks(title="Basyx FFmpeg Automation Hub") as ui:
|
| 2094 |
+
gr.Markdown("# Basyx FFmpeg Automation Hub")
|
| 2095 |
+
gr.Markdown("Upload media, choose a task, tune the options, and download the output.")
|
| 2096 |
+
with gr.Row():
|
| 2097 |
+
with gr.Column(scale=1):
|
| 2098 |
+
task_input = gr.Dropdown(
|
| 2099 |
+
choices=task_choices(),
|
| 2100 |
+
value=task_choices()[0],
|
| 2101 |
+
label="Task",
|
| 2102 |
+
)
|
| 2103 |
+
task_info = gr.Textbox(
|
| 2104 |
+
label="Task requirements", value=describe_task(task_choices()[0]), lines=4
|
| 2105 |
+
)
|
| 2106 |
+
file_input = gr.File(label="Files", file_count="multiple")
|
| 2107 |
+
url_input = gr.Textbox(label="Media URL")
|
| 2108 |
+
with gr.Column(scale=1):
|
| 2109 |
+
text_input = gr.Textbox(label="Text / Caption / Lyrics", lines=3, visible=False)
|
| 2110 |
+
with gr.Row(visible=False) as trim_row:
|
| 2111 |
+
start_input = gr.Textbox(label="Start", value="00:00:00")
|
| 2112 |
+
end_input = gr.Textbox(label="End")
|
| 2113 |
+
with gr.Row(visible=False) as duration_row:
|
| 2114 |
+
duration_input = gr.Number(label="Duration seconds", value=5, precision=0)
|
| 2115 |
+
with gr.Row(visible=False) as timestamp_row:
|
| 2116 |
+
timestamp_input = gr.Textbox(label="Thumbnail timestamp", value="00:00:02")
|
| 2117 |
+
with gr.Row(visible=False) as aspect_row:
|
| 2118 |
+
aspect_input = gr.Dropdown(["9:16", "16:9", "1:1", "4:5"], label="Aspect", value="9:16")
|
| 2119 |
+
with gr.Row(visible=False) as resolution_row:
|
| 2120 |
+
resolution_input = gr.Dropdown(["480p", "720p", "1080p"], label="Resolution", value="720p")
|
| 2121 |
+
with gr.Row(visible=True) as quality_row:
|
| 2122 |
+
crf_input = gr.Slider(12, 35, value=23, step=1, label="CRF")
|
| 2123 |
+
preset_input = gr.Dropdown(
|
| 2124 |
+
["ultrafast", "veryfast", "fast", "medium", "slow"],
|
| 2125 |
+
label="Preset",
|
| 2126 |
+
value="fast",
|
| 2127 |
+
)
|
| 2128 |
+
with gr.Row(visible=False) as audio_row:
|
| 2129 |
+
audio_bitrate_input = gr.Slider(64, 320, value=192, step=16, label="Audio kbps")
|
| 2130 |
+
volume_input = gr.Slider(0, 2, value=0.3, step=0.05, label="Music volume")
|
| 2131 |
+
with gr.Row(visible=False) as watermark_row:
|
| 2132 |
+
position_input = gr.Dropdown(
|
| 2133 |
+
["bottom-right", "bottom-left", "top-right", "top-left", "center"],
|
| 2134 |
+
label="Watermark position",
|
| 2135 |
+
value="bottom-right",
|
| 2136 |
+
)
|
| 2137 |
+
opacity_input = gr.Slider(0.1, 1, value=1, step=0.05, label="Watermark opacity")
|
| 2138 |
+
with gr.Row(visible=False) as gif_row:
|
| 2139 |
+
fps_input = gr.Slider(5, 30, value=10, step=1, label="GIF FPS")
|
| 2140 |
+
width_input = gr.Slider(240, 1080, value=480, step=20, label="GIF width")
|
| 2141 |
+
with gr.Row(visible=False) as speed_row:
|
| 2142 |
+
speed_input = gr.Slider(0.25, 4, value=1.25, step=0.05, label="Speed")
|
| 2143 |
+
with gr.Row(visible=False) as slideshow_row:
|
| 2144 |
+
image_duration_input = gr.Slider(1, 15, value=3, step=1, label="Slide seconds")
|
| 2145 |
+
with gr.Row(visible=False) as frames_row:
|
| 2146 |
+
frame_rate_input = gr.Slider(0.1, 30, value=1, step=0.1, label="Frame FPS")
|
| 2147 |
+
with gr.Row(visible=False) as font_row:
|
| 2148 |
+
font_size_input = gr.Slider(18, 160, value=60, step=1, label="Font size")
|
| 2149 |
+
wave_color_input = gr.Textbox(label="Wave color hex", value="00e5ff", visible=False)
|
| 2150 |
+
run_button = gr.Button("Run")
|
| 2151 |
+
output_file = gr.File(label="Output")
|
| 2152 |
+
history_output = gr.Dataframe(
|
| 2153 |
+
headers=["Task", "Filename", "Bytes", "Created"],
|
| 2154 |
+
datatype=["str", "str", "number", "str"],
|
| 2155 |
+
label="Recent outputs",
|
| 2156 |
+
value=history_table,
|
| 2157 |
+
)
|
| 2158 |
+
|
| 2159 |
+
task_input.change(
|
| 2160 |
+
option_visibility,
|
| 2161 |
+
inputs=task_input,
|
| 2162 |
+
outputs=[
|
| 2163 |
+
task_info,
|
| 2164 |
+
text_input,
|
| 2165 |
+
trim_row,
|
| 2166 |
+
duration_row,
|
| 2167 |
+
timestamp_row,
|
| 2168 |
+
aspect_row,
|
| 2169 |
+
resolution_row,
|
| 2170 |
+
quality_row,
|
| 2171 |
+
audio_row,
|
| 2172 |
+
watermark_row,
|
| 2173 |
+
gif_row,
|
| 2174 |
+
speed_row,
|
| 2175 |
+
slideshow_row,
|
| 2176 |
+
frames_row,
|
| 2177 |
+
font_row,
|
| 2178 |
+
wave_color_input,
|
| 2179 |
+
],
|
| 2180 |
+
)
|
| 2181 |
+
run_button.click(
|
| 2182 |
+
ui_handler,
|
| 2183 |
+
inputs=[
|
| 2184 |
+
file_input,
|
| 2185 |
+
url_input,
|
| 2186 |
+
task_input,
|
| 2187 |
+
text_input,
|
| 2188 |
+
start_input,
|
| 2189 |
+
end_input,
|
| 2190 |
+
duration_input,
|
| 2191 |
+
aspect_input,
|
| 2192 |
+
resolution_input,
|
| 2193 |
+
crf_input,
|
| 2194 |
+
preset_input,
|
| 2195 |
+
audio_bitrate_input,
|
| 2196 |
+
volume_input,
|
| 2197 |
+
position_input,
|
| 2198 |
+
opacity_input,
|
| 2199 |
+
fps_input,
|
| 2200 |
+
width_input,
|
| 2201 |
+
speed_input,
|
| 2202 |
+
timestamp_input,
|
| 2203 |
+
image_duration_input,
|
| 2204 |
+
frame_rate_input,
|
| 2205 |
+
font_size_input,
|
| 2206 |
+
wave_color_input,
|
| 2207 |
+
],
|
| 2208 |
+
outputs=[output_file, history_output],
|
| 2209 |
+
)
|
| 2210 |
+
|
| 2211 |
+
app = gr.mount_gradio_app(app, ui, path="/")
|
| 2212 |
+
|
| 2213 |
+
|
| 2214 |
+
if __name__ == "__main__":
|
| 2215 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
services/ffmpeg_automation/requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
gradio
|
| 4 |
+
python-multipart
|
| 5 |
+
python-magic
|
| 6 |
+
yt-dlp
|
services/ktts/.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
services/ktts/.gitignore
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Distribution / packaging
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
parts/
|
| 20 |
+
sdist/
|
| 21 |
+
var/
|
| 22 |
+
wheels/
|
| 23 |
+
share/python-wheels/
|
| 24 |
+
*.egg-info/
|
| 25 |
+
.installed.cfg
|
| 26 |
+
*.egg
|
| 27 |
+
MANIFEST
|
| 28 |
+
|
| 29 |
+
# PyInstaller
|
| 30 |
+
# Usually these files are written by a python script from a template
|
| 31 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 32 |
+
*.manifest
|
| 33 |
+
*.spec
|
| 34 |
+
|
| 35 |
+
# Installer logs
|
| 36 |
+
pip-log.txt
|
| 37 |
+
pip-delete-this-directory.txt
|
| 38 |
+
|
| 39 |
+
# Unit test / coverage reports
|
| 40 |
+
htmlcov/
|
| 41 |
+
.tox/
|
| 42 |
+
.nox/
|
| 43 |
+
.coverage
|
| 44 |
+
.coverage.*
|
| 45 |
+
.cache
|
| 46 |
+
nosetests.xml
|
| 47 |
+
coverage.xml
|
| 48 |
+
*.cover
|
| 49 |
+
*.py,cover
|
| 50 |
+
.hypothesis/
|
| 51 |
+
.pytest_cache/
|
| 52 |
+
cover/
|
| 53 |
+
|
| 54 |
+
# Translations
|
| 55 |
+
*.mo
|
| 56 |
+
*.pot
|
| 57 |
+
|
| 58 |
+
# Django stuff:
|
| 59 |
+
*.log
|
| 60 |
+
local_settings.py
|
| 61 |
+
db.sqlite3
|
| 62 |
+
db.sqlite3-journal
|
| 63 |
+
|
| 64 |
+
# Flask stuff:
|
| 65 |
+
instance/
|
| 66 |
+
.webassets-cache
|
| 67 |
+
|
| 68 |
+
# Scrapy stuff:
|
| 69 |
+
.scrapy
|
| 70 |
+
|
| 71 |
+
# Sphinx documentation
|
| 72 |
+
docs/_build/
|
| 73 |
+
|
| 74 |
+
# PyBuilder
|
| 75 |
+
.pybuilder/
|
| 76 |
+
target/
|
| 77 |
+
|
| 78 |
+
# Jupyter Notebook
|
| 79 |
+
.ipynb_checkpoints
|
| 80 |
+
|
| 81 |
+
# IPython
|
| 82 |
+
profile_default/
|
| 83 |
+
ipython_config.py
|
| 84 |
+
|
| 85 |
+
# pyenv
|
| 86 |
+
# For a library or package, you might want to ignore these files since the code is
|
| 87 |
+
# intended to run in multiple environments; otherwise, check them in:
|
| 88 |
+
# .python-version
|
| 89 |
+
|
| 90 |
+
# pipenv
|
| 91 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 92 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 93 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 94 |
+
# install all needed dependencies.
|
| 95 |
+
#Pipfile.lock
|
| 96 |
+
|
| 97 |
+
# UV
|
| 98 |
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
| 99 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 100 |
+
# commonly ignored for libraries.
|
| 101 |
+
#uv.lock
|
| 102 |
+
|
| 103 |
+
# poetry
|
| 104 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
| 105 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 106 |
+
# commonly ignored for libraries.
|
| 107 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
| 108 |
+
#poetry.lock
|
| 109 |
+
|
| 110 |
+
# pdm
|
| 111 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 112 |
+
#pdm.lock
|
| 113 |
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
| 114 |
+
# in version control.
|
| 115 |
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
| 116 |
+
.pdm.toml
|
| 117 |
+
.pdm-python
|
| 118 |
+
.pdm-build/
|
| 119 |
+
|
| 120 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
| 121 |
+
__pypackages__/
|
| 122 |
+
|
| 123 |
+
# Celery stuff
|
| 124 |
+
celerybeat-schedule
|
| 125 |
+
celerybeat.pid
|
| 126 |
+
|
| 127 |
+
# SageMath parsed files
|
| 128 |
+
*.sage.py
|
| 129 |
+
|
| 130 |
+
# Environments
|
| 131 |
+
.env
|
| 132 |
+
.venv
|
| 133 |
+
env/
|
| 134 |
+
venv/
|
| 135 |
+
ENV/
|
| 136 |
+
env.bak/
|
| 137 |
+
venv.bak/
|
| 138 |
+
|
| 139 |
+
# Spyder project settings
|
| 140 |
+
.spyderproject
|
| 141 |
+
.spyproject
|
| 142 |
+
|
| 143 |
+
# Rope project settings
|
| 144 |
+
.ropeproject
|
| 145 |
+
|
| 146 |
+
# mkdocs documentation
|
| 147 |
+
/site
|
| 148 |
+
|
| 149 |
+
# mypy
|
| 150 |
+
.mypy_cache/
|
| 151 |
+
.dmypy.json
|
| 152 |
+
dmypy.json
|
| 153 |
+
|
| 154 |
+
# Pyre type checker
|
| 155 |
+
.pyre/
|
| 156 |
+
|
| 157 |
+
# pytype static type analyzer
|
| 158 |
+
.pytype/
|
| 159 |
+
|
| 160 |
+
# Cython debug symbols
|
| 161 |
+
cython_debug/
|
| 162 |
+
|
| 163 |
+
# PyCharm
|
| 164 |
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
| 165 |
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
| 166 |
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 167 |
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 168 |
+
#.idea/
|
| 169 |
+
|
| 170 |
+
# PyPI configuration file
|
| 171 |
+
.pypirc
|
services/ktts/Dockerfile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Base image with Python and essential libraries
|
| 2 |
+
FROM python:3.8-slim
|
| 3 |
+
|
| 4 |
+
# Install system-level dependencies
|
| 5 |
+
RUN apt-get update && \
|
| 6 |
+
apt-get install -y espeak && \
|
| 7 |
+
apt-get clean && rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
# Set the working directory
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
# Copy your files into the container
|
| 13 |
+
COPY . .
|
| 14 |
+
|
| 15 |
+
# Install Python dependencies
|
| 16 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 17 |
+
|
| 18 |
+
# Expose the port
|
| 19 |
+
EXPOSE 7860
|
| 20 |
+
|
| 21 |
+
# Run your app
|
| 22 |
+
CMD ["python", "app.py"]
|
services/ktts/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: "Kokoro-82M TTS ONNX"
|
| 3 |
+
emoji: "💡"
|
| 4 |
+
colorFrom: "blue"
|
| 5 |
+
colorTo: "green"
|
| 6 |
+
sdk: "docker"
|
| 7 |
+
app_file: app.py
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
services/ktts/README_git.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Kokoro-82M ONNX Runtime Inference
|
| 2 |
+
|
| 3 |
+

|
| 4 |
+
[](https://github.com/yakhyo/kokoro-82m-onnx/stargazers)
|
| 5 |
+
[](https://github.com/yakhyo/kokoro-82m-onnx)
|
| 6 |
+
|
| 7 |
+
This repository contains minimal code and resources for inference using the **Kokoro-82M** model. The repository supports inference using **ONNX Runtime**.
|
| 8 |
+
|
| 9 |
+
<table>
|
| 10 |
+
<tr>
|
| 11 |
+
<td>Machine learning models rely on large datasets and complex algorithms to identify patterns and make predictions.</td>
|
| 12 |
+
<td>Did you know that honey never spoils? Archaeologists have found pots of honey in ancient Egyptian tombs that are over 3,000 years old and still edible!</td>
|
| 13 |
+
</tr>
|
| 14 |
+
<tr>
|
| 15 |
+
<td align="center">
|
| 16 |
+
<video controls autoplay loop src="https://github.com/user-attachments/assets/a8e9bfb7-777a-4b44-901c-c79c39c02c6f" ></video>
|
| 17 |
+
</td>
|
| 18 |
+
<td align="center">
|
| 19 |
+
<video controls autoplay loop src="https://github.com/user-attachments/assets/358723ad-c0ab-44a3-90cc-64d89c042c9a" ></video>
|
| 20 |
+
</td>
|
| 21 |
+
</tr>
|
| 22 |
+
</table>
|
| 23 |
+
|
| 24 |
+
## Features
|
| 25 |
+
|
| 26 |
+
- **ONNX Runtime Inference**: Kokoro-82M (v0_19) Minimal ONNX Runtime Inference code. It supports `en-us` and `en-gb`.
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## Installation
|
| 31 |
+
|
| 32 |
+
1. Clone the repository:
|
| 33 |
+
|
| 34 |
+
```bash
|
| 35 |
+
git clone https://github.com/yakhyo/kokoro-82m.git
|
| 36 |
+
cd kokoro-82m
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
2. Install dependencies:
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
pip install -r requirements.txt
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
3. Install `espeak` for text-to-speech functionality:
|
| 46 |
+
Linux:
|
| 47 |
+
```bash
|
| 48 |
+
apt-get install espeak -y
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## Usage
|
| 54 |
+
|
| 55 |
+
### Download ONNX Model
|
| 56 |
+
|
| 57 |
+
[click to download](https://github.com/yakhyo/kokoro-82m/releases/download/v0.0.1/kokoro-v0_19.onnx)
|
| 58 |
+
|
| 59 |
+
### Jupyter Notebook Inference Example
|
| 60 |
+
|
| 61 |
+
Run inference using the jupyter notebook:
|
| 62 |
+
|
| 63 |
+
[example.ipynb](example.ipynb)
|
| 64 |
+
|
| 65 |
+
### CLI Inference
|
| 66 |
+
|
| 67 |
+
Specify input text and model weights in `inference.py` then run:
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
python inference.py
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
### Gradio App
|
| 74 |
+
|
| 75 |
+
Run below start Gradio App
|
| 76 |
+
```bash
|
| 77 |
+
python app.py
|
| 78 |
+
```
|
| 79 |
+
<div>
|
| 80 |
+
<img src="gradio_demo.png", width="100%>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## License
|
| 86 |
+
|
| 87 |
+
This project is licensed under the [MIT License](LICENSE).
|
| 88 |
+
Model weights licensed under the [Apache 2.0](#license)
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
## Acknowledgments
|
| 93 |
+
|
| 94 |
+
- https://huggingface.co/hexgrad/Kokoro-82M
|
services/ktts/app.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import gradio as gr
|
| 4 |
+
import tempfile
|
| 5 |
+
import soundfile as sf
|
| 6 |
+
from models import Tokenizer, Kokoro
|
| 7 |
+
from fastapi import FastAPI, Request
|
| 8 |
+
from fastapi.responses import FileResponse
|
| 9 |
+
import uvicorn
|
| 10 |
+
|
| 11 |
+
# --- EXISTING LOGIC (UNCHANGED) ---
|
| 12 |
+
|
| 13 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 14 |
+
tokenizer_cache = None
|
| 15 |
+
kokoro_cache = {}
|
| 16 |
+
model_status = "not_loaded"
|
| 17 |
+
model_error = None
|
| 18 |
+
|
| 19 |
+
def get_style_vector_choices(directory="voices"):
|
| 20 |
+
directory_path = BASE_DIR / directory
|
| 21 |
+
return [file.name for file in directory_path.iterdir() if file.suffix == ".pt"]
|
| 22 |
+
|
| 23 |
+
def get_onnx_models(directory="weights"):
|
| 24 |
+
directory_path = BASE_DIR / directory
|
| 25 |
+
return [file.name for file in directory_path.iterdir() if file.suffix == ".onnx"]
|
| 26 |
+
|
| 27 |
+
def local_tts(
|
| 28 |
+
text: str,
|
| 29 |
+
model_path: str,
|
| 30 |
+
style_vector: str,
|
| 31 |
+
output_file_format: str = "wav",
|
| 32 |
+
speed: float = 1.0
|
| 33 |
+
):
|
| 34 |
+
global tokenizer_cache, model_status, model_error
|
| 35 |
+
|
| 36 |
+
if len(text) > 0:
|
| 37 |
+
try:
|
| 38 |
+
style_vector_path = str(BASE_DIR / "voices" / style_vector)
|
| 39 |
+
model_path_full = str(BASE_DIR / "weights" / model_path)
|
| 40 |
+
cache_key = (model_path_full, style_vector_path)
|
| 41 |
+
|
| 42 |
+
if tokenizer_cache is None:
|
| 43 |
+
tokenizer_cache = Tokenizer()
|
| 44 |
+
|
| 45 |
+
if cache_key not in kokoro_cache:
|
| 46 |
+
model_status = "loading"
|
| 47 |
+
model_error = None
|
| 48 |
+
kokoro_cache[cache_key] = Kokoro(model_path_full, style_vector_path, tokenizer=tokenizer_cache, lang='en-us')
|
| 49 |
+
model_status = "ready"
|
| 50 |
+
|
| 51 |
+
inference = kokoro_cache[cache_key]
|
| 52 |
+
|
| 53 |
+
audio, sample_rate = inference.generate_audio(text, speed=speed)
|
| 54 |
+
|
| 55 |
+
with tempfile.NamedTemporaryFile(suffix=f".{output_file_format}", delete=False) as temp_file:
|
| 56 |
+
sf.write(temp_file.name, audio, sample_rate)
|
| 57 |
+
temp_file_path = temp_file.name
|
| 58 |
+
|
| 59 |
+
return temp_file_path
|
| 60 |
+
|
| 61 |
+
except Exception as e:
|
| 62 |
+
model_status = "failed"
|
| 63 |
+
model_error = str(e)
|
| 64 |
+
raise gr.Error(f"An error occurred during TTS inference: {str(e)}")
|
| 65 |
+
else:
|
| 66 |
+
raise gr.Error("Input text cannot be empty.")
|
| 67 |
+
|
| 68 |
+
style_vector_choices = get_style_vector_choices()
|
| 69 |
+
onnx_models_choices = get_onnx_models()
|
| 70 |
+
|
| 71 |
+
sample_outputs = [
|
| 72 |
+
("Educational Note", "Machine learning models rely on large datasets and complex algorithms to identify patterns and make predictions.", str(BASE_DIR / "assets" / "edu_note.wav")),
|
| 73 |
+
("Fun Fact", "Did you know that honey never spoils? Archaeologists have found pots of honey in ancient Egyptian tombs that are over 3,000 years old and still edible!", str(BASE_DIR / "assets" / "fun_fact.wav")),
|
| 74 |
+
("Thanks", "Thank you for listening to this audio. It was generated by the Kokoro TTS model.", str(BASE_DIR / "assets" / "thanks.wav"))
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
example_texts = [
|
| 78 |
+
["Machine learning models rely on large datasets and complex algorithms to identify patterns and make predictions."],
|
| 79 |
+
["Did you know that honey never spoils? Archaeologists have found pots of honey in ancient Egyptian tombs that are over 3,000 years old and still edible!"],
|
| 80 |
+
["Thank you for listening to this audio. It was generated by the Kokoro TTS model."]
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
# --- GRADIO INTERFACE (UNCHANGED) ---
|
| 84 |
+
|
| 85 |
+
with gr.Blocks() as demo:
|
| 86 |
+
gr.Markdown("## <center> Kokoro TTS ONNX Inference | [GitHub Link](https://github.com/yakhyo/kokoro-onnx) </center>")
|
| 87 |
+
with gr.Row(variant="panel"):
|
| 88 |
+
model_path = gr.Dropdown(choices=onnx_models_choices, label="ONNX Model Path", value=onnx_models_choices[0])
|
| 89 |
+
style_vector = gr.Dropdown(choices=style_vector_choices, label="Style Vector", value=style_vector_choices[0])
|
| 90 |
+
output_file_format = gr.Dropdown(choices=["wav", "mp3"], label="Output Format", value="wav")
|
| 91 |
+
speed = gr.Slider(minimum=0.5, maximum=2.0, value=1.0, step=0.1, label="Speed")
|
| 92 |
+
|
| 93 |
+
text = gr.Textbox(label="Input Text", placeholder="Enter text to convert to speech.")
|
| 94 |
+
btn = gr.Button("Generate Speech")
|
| 95 |
+
output_audio = gr.Audio(label="Generated Audio", type="filepath")
|
| 96 |
+
|
| 97 |
+
btn.click(fn=local_tts, inputs=[text, model_path, style_vector, output_file_format, speed], outputs=output_audio)
|
| 98 |
+
|
| 99 |
+
gr.Examples(examples=example_texts, inputs=[text], label="Click an example to populate the input text")
|
| 100 |
+
gr.Markdown("### Sample Texts and Audio")
|
| 101 |
+
for topic, sample_text, sample_audio in sample_outputs:
|
| 102 |
+
with gr.Row():
|
| 103 |
+
gr.Textbox(value=sample_text, label=topic, interactive=False)
|
| 104 |
+
gr.Audio(value=sample_audio, label="Example Audio", type="filepath", interactive=False)
|
| 105 |
+
|
| 106 |
+
# --- FASTAPI WRAPPER & STARTUP ---
|
| 107 |
+
|
| 108 |
+
app = FastAPI()
|
| 109 |
+
|
| 110 |
+
@app.post("/v1/audio/speech")
|
| 111 |
+
async def api_speech(request: Request):
|
| 112 |
+
"""
|
| 113 |
+
OpenAI-compatible /v1/audio/speech endpoint for automation.
|
| 114 |
+
Expects JSON: {"input": "text", "voice": "voice_file.pt", "model": "model_file.onnx"}
|
| 115 |
+
"""
|
| 116 |
+
data = await request.json()
|
| 117 |
+
input_text = data.get("input", "")
|
| 118 |
+
m_path = data.get("model", onnx_models_choices[0])
|
| 119 |
+
s_vec = data.get("voice", style_vector_choices[0])
|
| 120 |
+
spd = float(data.get("speed", 1.0))
|
| 121 |
+
|
| 122 |
+
file_path = local_tts(input_text, m_path, s_vec, speed=spd)
|
| 123 |
+
return FileResponse(file_path, media_type="audio/wav")
|
| 124 |
+
|
| 125 |
+
# Mount Gradio into the FastAPI app
|
| 126 |
+
app = gr.mount_gradio_app(app, demo, path="/")
|
| 127 |
+
|
| 128 |
+
if __name__ == "__main__":
|
| 129 |
+
# Runs on port 7860 as expected by the Dockerfile/Hugging Face
|
| 130 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
services/ktts/assets/edu_note.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:880362273fe0348d8e73e9cf99cbe58573ccaac1e51628e92d4a362c8199d399
|
| 3 |
+
size 416444
|
services/ktts/assets/fun_fact.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:245b8db3fc90671b02baa477ce0c2d08754b225ae95a853687191ad126de562e
|
| 3 |
+
size 496844
|
services/ktts/assets/thanks.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d04843d86bff241ff659cf6c6931641797411dbf3b1532eee83914058bbc5a64
|
| 3 |
+
size 288044
|
services/ktts/example.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
services/ktts/gradio_demo.png
ADDED
|
Git LFS Details
|
services/ktts/inference.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import soundfile as sf
|
| 2 |
+
|
| 3 |
+
from models import Tokenizer, Kokoro
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def main():
|
| 7 |
+
model_path = "weights/kokoro-v0_19.onnx"
|
| 8 |
+
style_vector_path = "voices/af.pt"
|
| 9 |
+
output_filename = "test_out.wav"
|
| 10 |
+
tokenizer = Tokenizer()
|
| 11 |
+
|
| 12 |
+
text = (
|
| 13 |
+
"This approach ensures the entire text is processed without exceeding the token limit and outputs seamless audio for the full input. Let me know if you need further assistance!"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
inference = Kokoro(model_path, style_vector_path, tokenizer=tokenizer, lang='en-us')
|
| 17 |
+
audio, sample_rate = inference.generate_audio(text, speed=1.0)
|
| 18 |
+
|
| 19 |
+
# Save the audio to a file
|
| 20 |
+
sf.write(output_filename, audio, sample_rate)
|
| 21 |
+
print(f"Audio saved to {output_filename}")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
if __name__ == "__main__":
|
| 25 |
+
main()
|
services/ktts/models/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .kokoro import Kokoro
|
| 2 |
+
from .tokenizer import Tokenizer
|
services/ktts/models/kokoro.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import numpy as np
|
| 3 |
+
import onnxruntime as ort
|
| 4 |
+
|
| 5 |
+
TOKEN_LIMIT = 510
|
| 6 |
+
SAMPLE_RATE = 24_000
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Kokoro:
|
| 10 |
+
def __init__(self, model_path: str, style_vector_path: str, tokenizer, lang: str = 'en-us') -> None:
|
| 11 |
+
"""
|
| 12 |
+
Initializes the ONNXInference class.
|
| 13 |
+
|
| 14 |
+
Args:
|
| 15 |
+
model_path (str): Path to the ONNX model file.
|
| 16 |
+
style_vector_path (str): Path to the style vector file.
|
| 17 |
+
lang (str): Language code for the tokenizer.
|
| 18 |
+
"""
|
| 19 |
+
self.sess = ort.InferenceSession(model_path)
|
| 20 |
+
self.style_vector_path = style_vector_path
|
| 21 |
+
self.tokenizer = tokenizer
|
| 22 |
+
self.lang = lang
|
| 23 |
+
|
| 24 |
+
def preprocess(self, text):
|
| 25 |
+
"""
|
| 26 |
+
Converts input text to tokenized numerical IDs and loads the style vector.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
text (str): Input text to preprocess.
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
tuple: Tokenized input and corresponding style vector.
|
| 33 |
+
"""
|
| 34 |
+
# Convert text to phonemes and tokenize
|
| 35 |
+
phonemes = self.tokenizer.phonemize(text, lang=self.lang)
|
| 36 |
+
tokenized_phonemes = self.tokenizer.tokenize(phonemes)
|
| 37 |
+
|
| 38 |
+
if not tokenized_phonemes:
|
| 39 |
+
raise ValueError("No tokens found after tokenization")
|
| 40 |
+
|
| 41 |
+
style_vector = torch.load(self.style_vector_path, weights_only=True)
|
| 42 |
+
|
| 43 |
+
if len(tokenized_phonemes) > TOKEN_LIMIT:
|
| 44 |
+
token_chunks = self.split_into_chunks(tokenized_phonemes)
|
| 45 |
+
|
| 46 |
+
tokens_list = []
|
| 47 |
+
styles_list = []
|
| 48 |
+
|
| 49 |
+
for chunk in token_chunks:
|
| 50 |
+
token_chunk = [[0, *chunk, 0]]
|
| 51 |
+
style_chunk = style_vector[len(chunk)].numpy()
|
| 52 |
+
|
| 53 |
+
tokens_list.append(token_chunk)
|
| 54 |
+
styles_list.append(style_chunk)
|
| 55 |
+
|
| 56 |
+
return tokens_list, styles_list
|
| 57 |
+
|
| 58 |
+
style_vector = style_vector[len(tokenized_phonemes)].numpy()
|
| 59 |
+
tokenized_phonemes = [[0, *tokenized_phonemes, 0]]
|
| 60 |
+
|
| 61 |
+
return tokenized_phonemes, style_vector
|
| 62 |
+
|
| 63 |
+
@staticmethod
|
| 64 |
+
def split_into_chunks(tokens):
|
| 65 |
+
"""
|
| 66 |
+
Splits a list of tokens into chunks of size TOKEN_LIMIT.
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
tokens (list): List of tokens to split.
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
list: List of token chunks.
|
| 73 |
+
"""
|
| 74 |
+
tokens_chunks = []
|
| 75 |
+
for i in range(0, len(tokens), TOKEN_LIMIT):
|
| 76 |
+
tokens_chunks.append(tokens[i:i+TOKEN_LIMIT])
|
| 77 |
+
return tokens_chunks
|
| 78 |
+
|
| 79 |
+
def infer(self, tokens, style_vector, speed=1.0):
|
| 80 |
+
"""
|
| 81 |
+
Runs inference using the ONNX model.
|
| 82 |
+
|
| 83 |
+
Args:
|
| 84 |
+
tokens (list): Tokenized input for the model.
|
| 85 |
+
style_vector (numpy.ndarray): Style vector for the model.
|
| 86 |
+
speed (float): Speed parameter for inference.
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
numpy.ndarray: Generated audio data.
|
| 90 |
+
"""
|
| 91 |
+
# Perform inference
|
| 92 |
+
audio = self.sess.run(
|
| 93 |
+
None,
|
| 94 |
+
{
|
| 95 |
+
'tokens': tokens,
|
| 96 |
+
'style': style_vector,
|
| 97 |
+
'speed': np.array([speed], dtype=np.float32),
|
| 98 |
+
}
|
| 99 |
+
)[0]
|
| 100 |
+
return audio
|
| 101 |
+
|
| 102 |
+
def generate_audio(self, text, speed=1.0):
|
| 103 |
+
"""
|
| 104 |
+
Full pipeline: preprocess, infer, and save the generated audio.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
text (str): Input text to generate audio from.
|
| 108 |
+
speed (float): Speed parameter for inference.
|
| 109 |
+
"""
|
| 110 |
+
# Preprocess text
|
| 111 |
+
tokenized_data, styles_data = self.preprocess(text)
|
| 112 |
+
|
| 113 |
+
audio_segments = []
|
| 114 |
+
if len(tokenized_data) > 1: # list of token chunks
|
| 115 |
+
for token_chunk, style_chunk in zip(tokenized_data, styles_data):
|
| 116 |
+
audio = self.infer(token_chunk, style_chunk, speed=speed)
|
| 117 |
+
audio_segments.append(audio)
|
| 118 |
+
else: # single token less than input limit
|
| 119 |
+
# Run inference
|
| 120 |
+
audio = self.infer(tokenized_data, styles_data, speed=speed)
|
| 121 |
+
audio_segments.append(audio)
|
| 122 |
+
|
| 123 |
+
full_audio = np.concatenate(audio_segments)
|
| 124 |
+
|
| 125 |
+
return full_audio, SAMPLE_RATE
|
services/ktts/models/tokenizer.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from phonemizer import backend
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class Tokenizer:
|
| 7 |
+
def __init__(self):
|
| 8 |
+
self.VOCAB = self._get_vocab()
|
| 9 |
+
self.phonemizers = {
|
| 10 |
+
'en-us': backend.EspeakBackend(language='en-us', preserve_punctuation=True, with_stress=True),
|
| 11 |
+
'en-gb': backend.EspeakBackend(language='en-gb', preserve_punctuation=True, with_stress=True),
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
def _get_vocab():
|
| 16 |
+
"""
|
| 17 |
+
Generates a mapping of symbols to integer indices for tokenization.
|
| 18 |
+
|
| 19 |
+
Returns:
|
| 20 |
+
dict: A dictionary where keys are symbols and values are unique integer indices.
|
| 21 |
+
"""
|
| 22 |
+
# Define the symbols
|
| 23 |
+
_pad = "$"
|
| 24 |
+
_punctuation = ';:,.!?¡¿—…"«»“” '
|
| 25 |
+
_letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
| 26 |
+
_letters_ipa = (
|
| 27 |
+
"ɑɐɒæɓʙβɔɕçɗɖðʤəɘɚɛɜɝɞɟʄɡɠɢʛɦɧħɥʜɨɪʝɭɬɫɮʟɱɯɰŋɳɲɴøɵɸθœɶʘɹɺɾɻʀʁɽʂʃʈʧʉʊʋⱱʌɣɤʍχʎʏʑʐʒʔʡʕʢǀǁǂǃˈˌːˑʼʴʰʱʲʷˠˤ˞↓↑→↗↘'̩'ᵻ"
|
| 28 |
+
)
|
| 29 |
+
symbols = [_pad] + list(_punctuation) + list(_letters) + list(_letters_ipa)
|
| 30 |
+
|
| 31 |
+
# Create a dictionary mapping each symbol to its index
|
| 32 |
+
return {symbol: index for index, symbol in enumerate(symbols)}
|
| 33 |
+
|
| 34 |
+
@staticmethod
|
| 35 |
+
def split_num(num: re.Match) -> str:
|
| 36 |
+
"""
|
| 37 |
+
Processes numeric strings, formatting them as time, years, or other representations.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
num (re.Match): A regex match object representing the numeric string.
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
str: A formatted string based on the numeric input.
|
| 44 |
+
"""
|
| 45 |
+
num = num.group()
|
| 46 |
+
|
| 47 |
+
# Handle time (e.g., "12:30")
|
| 48 |
+
if ':' in num:
|
| 49 |
+
hours, minutes = map(int, num.split(':'))
|
| 50 |
+
if minutes == 0:
|
| 51 |
+
return f"{hours} o'clock"
|
| 52 |
+
elif minutes < 10:
|
| 53 |
+
return f'{hours} oh {minutes}'
|
| 54 |
+
return f'{hours} {minutes}'
|
| 55 |
+
|
| 56 |
+
# Handle years or general numeric cases
|
| 57 |
+
year = int(num[:4])
|
| 58 |
+
if year < 1100 or year % 1000 < 10:
|
| 59 |
+
return num
|
| 60 |
+
|
| 61 |
+
left, right = num[:2], int(num[2:4])
|
| 62 |
+
suffix = 's' if num.endswith('s') else ''
|
| 63 |
+
|
| 64 |
+
# Format years
|
| 65 |
+
if 100 <= year % 1000 <= 999:
|
| 66 |
+
if right == 0:
|
| 67 |
+
return f'{left} hundred{suffix}'
|
| 68 |
+
elif right < 10:
|
| 69 |
+
return f'{left} oh {right}{suffix}'
|
| 70 |
+
return f'{left} {right}{suffix}'
|
| 71 |
+
|
| 72 |
+
@staticmethod
|
| 73 |
+
def flip_money(match: re.Match) -> str:
|
| 74 |
+
"""
|
| 75 |
+
Converts monetary values to a textual representation.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
m (re.Match): A regex match object representing the monetary value.
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
str: A formatted string describing the monetary value.
|
| 82 |
+
"""
|
| 83 |
+
m = m.group()
|
| 84 |
+
currency = 'dollar' if m[0] == '$' else 'pound'
|
| 85 |
+
|
| 86 |
+
# Handle whole amounts (e.g., "$10", "£20")
|
| 87 |
+
if '.' not in m:
|
| 88 |
+
singular = '' if m[1:] == '1' else 's'
|
| 89 |
+
return f'{m[1:]} {currency}{singular}'
|
| 90 |
+
|
| 91 |
+
# Handle amounts with decimals (e.g., "$10.50", "£5.25")
|
| 92 |
+
whole, cents = m[1:].split('.')
|
| 93 |
+
singular = '' if whole == '1' else 's'
|
| 94 |
+
cents = int(cents.ljust(2, '0')) # Ensure 2 decimal places
|
| 95 |
+
coins = f"cent{'' if cents == 1 else 's'}" if m[0] == '$' else ('penny' if cents == 1 else 'pence')
|
| 96 |
+
return f'{whole} {currency}{singular} and {cents} {coins}'
|
| 97 |
+
|
| 98 |
+
@staticmethod
|
| 99 |
+
def point_num(match):
|
| 100 |
+
whole, fractional = match.group().split('.')
|
| 101 |
+
return ' point '.join([whole, ' '.join(fractional)])
|
| 102 |
+
|
| 103 |
+
def normalize_text(self, text: str) -> str:
|
| 104 |
+
"""
|
| 105 |
+
Normalizes input text by replacing special characters, punctuation, and applying custom transformations.
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
text (str): Input text to normalize.
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
str: Normalized text.
|
| 112 |
+
"""
|
| 113 |
+
# Replace specific characters with standardized versions
|
| 114 |
+
replacements = {
|
| 115 |
+
chr(8216): "'", # Left single quotation mark
|
| 116 |
+
chr(8217): "'", # Right single quotation mark
|
| 117 |
+
'«': chr(8220), # Left double angle quotation mark to left double quotation mark
|
| 118 |
+
'»': chr(8221), # Right double angle quotation mark to right double quotation mark
|
| 119 |
+
chr(8220): '"', # Left double quotation mark
|
| 120 |
+
chr(8221): '"', # Right double quotation mark
|
| 121 |
+
'(': '«', # Replace parentheses with angle quotation marks
|
| 122 |
+
')': '»'
|
| 123 |
+
}
|
| 124 |
+
for old, new in replacements.items():
|
| 125 |
+
text = text.replace(old, new)
|
| 126 |
+
|
| 127 |
+
# Replace punctuation and add spaces
|
| 128 |
+
punctuation_replacements = {
|
| 129 |
+
'、': ',',
|
| 130 |
+
'。': '.',
|
| 131 |
+
'!': '!',
|
| 132 |
+
',': ',',
|
| 133 |
+
':': ':',
|
| 134 |
+
';': ';',
|
| 135 |
+
'?': '?',
|
| 136 |
+
}
|
| 137 |
+
for old, new in punctuation_replacements.items():
|
| 138 |
+
text = text.replace(old, new + ' ')
|
| 139 |
+
|
| 140 |
+
# Apply regex-based replacements
|
| 141 |
+
text = re.sub(r'[^\S\n]', ' ', text)
|
| 142 |
+
text = re.sub(r' +', ' ', text)
|
| 143 |
+
text = re.sub(r'(?<=\n) +(?=\n)', '', text)
|
| 144 |
+
|
| 145 |
+
# Expand abbreviations and handle special cases
|
| 146 |
+
abbreviation_patterns = [
|
| 147 |
+
(r'\bD[Rr]\.(?= [A-Z])', 'Doctor'),
|
| 148 |
+
(r'\b(?:Mr\.|MR\.(?= [A-Z]))', 'Mister'),
|
| 149 |
+
(r'\b(?:Ms\.|MS\.(?= [A-Z]))', 'Miss'),
|
| 150 |
+
(r'\b(?:Mrs\.|MRS\.(?= [A-Z]))', 'Mrs'),
|
| 151 |
+
(r'\betc\.(?! [A-Z])', 'etc'),
|
| 152 |
+
(r'(?i)\b(y)eah?\b', r"\1e'a"),
|
| 153 |
+
]
|
| 154 |
+
for pattern, replacement in abbreviation_patterns:
|
| 155 |
+
text = re.sub(pattern, replacement, text)
|
| 156 |
+
|
| 157 |
+
# Handle numbers and monetary values
|
| 158 |
+
text = re.sub(r'\d*\.\d+|\b\d{4}s?\b|(?<!:)\b(?:[1-9]|1[0-2]):[0-5]\d\b(?!:)', self.split_num, text)
|
| 159 |
+
text = re.sub(r'(?<=\d),(?=\d)', '', text) # Remove commas from numbers
|
| 160 |
+
text = re.sub(
|
| 161 |
+
r'(?i)[$£]\d+(?:\.\d+)?(?: hundred| thousand| (?:[bm]|tr)illion)*\b|[$£]\d+\.\d\d?\b',
|
| 162 |
+
self.flip_money,
|
| 163 |
+
text
|
| 164 |
+
)
|
| 165 |
+
text = re.sub(r'\d*\.\d+', self.point_num, text)
|
| 166 |
+
text = re.sub(r'(?<=\d)-(?=\d)', ' to ', text)
|
| 167 |
+
|
| 168 |
+
# Handle possessives and specific letter cases
|
| 169 |
+
text = re.sub(r'(?<=\d)S', ' S', text)
|
| 170 |
+
text = re.sub(r"(?<=[BCDFGHJ-NP-TV-Z])'?s\b", "'S", text)
|
| 171 |
+
text = re.sub(r"(?<=X')S\b", 's', text)
|
| 172 |
+
|
| 173 |
+
# Handle abbreviations with dots
|
| 174 |
+
text = re.sub(r'(?:[A-Za-z]\.){2,} [a-z]', lambda m: m.group().replace('.', '-'), text)
|
| 175 |
+
text = re.sub(r'(?i)(?<=[A-Z])\.(?=[A-Z])', '-', text)
|
| 176 |
+
|
| 177 |
+
return text.strip()
|
| 178 |
+
|
| 179 |
+
def tokenize(self, phonemes: str) -> List[int]:
|
| 180 |
+
"""
|
| 181 |
+
Tokenizes a given string into a list of indices based on VOCAB.
|
| 182 |
+
|
| 183 |
+
Args:
|
| 184 |
+
text (str): Input string to tokenize.
|
| 185 |
+
|
| 186 |
+
Returns:
|
| 187 |
+
list: A list of integer indices corresponding to the characters in the input string.
|
| 188 |
+
"""
|
| 189 |
+
return [self.VOCAB[x] for x in phonemes if x in self.VOCAB]
|
| 190 |
+
|
| 191 |
+
def phonemize(self, text: str, lang: str = 'en-us', normalize: bool = True) -> str:
|
| 192 |
+
"""
|
| 193 |
+
Converts text to phonemes using the specified language phonemizer and applies normalization.
|
| 194 |
+
|
| 195 |
+
Args:
|
| 196 |
+
text (str): Input text to be phonemized.
|
| 197 |
+
lang (str): Language identifier ('en-us' or 'en-gb') for selecting the phonemizer.
|
| 198 |
+
normalize (bool): Whether to normalize the text before phonemization.
|
| 199 |
+
|
| 200 |
+
Returns:
|
| 201 |
+
str: A processed string of phonemes.
|
| 202 |
+
"""
|
| 203 |
+
# Normalize text if required
|
| 204 |
+
if normalize:
|
| 205 |
+
text = self.normalize_text(text)
|
| 206 |
+
|
| 207 |
+
# Generate phonemes using the specified phonemizer
|
| 208 |
+
if lang not in self.phonemizers:
|
| 209 |
+
print(f"Language '{lang}' not supported. Defaulting to 'en-us'.")
|
| 210 |
+
lang = 'en-us'
|
| 211 |
+
|
| 212 |
+
phonemes = self.phonemizers[lang].phonemize([text])
|
| 213 |
+
phonemes = phonemes[0] if phonemes else ''
|
| 214 |
+
|
| 215 |
+
# Apply custom phoneme replacements
|
| 216 |
+
replacements = {
|
| 217 |
+
'kəkˈoːɹoʊ': 'kˈoʊkəɹoʊ',
|
| 218 |
+
'kəkˈɔːɹəʊ': 'kˈəʊkəɹəʊ',
|
| 219 |
+
'ʲ': 'j',
|
| 220 |
+
'r': 'ɹ',
|
| 221 |
+
'x': 'k',
|
| 222 |
+
'ɬ': 'l',
|
| 223 |
+
}
|
| 224 |
+
for old, new in replacements.items():
|
| 225 |
+
phonemes = phonemes.replace(old, new)
|
| 226 |
+
|
| 227 |
+
# Apply regex-based replacements
|
| 228 |
+
phonemes = re.sub(r'(?<=[a-zɹː])(?=hˈʌndɹɪd)', ' ', phonemes)
|
| 229 |
+
phonemes = re.sub(r' z(?=[;:,.!?¡¿—…"«»“” ]|$)', 'z', phonemes)
|
| 230 |
+
|
| 231 |
+
# Additional language-specific rules
|
| 232 |
+
if lang == 'a':
|
| 233 |
+
phonemes = re.sub(r'(?<=nˈaɪn)ti(?!ː)', 'di', phonemes)
|
| 234 |
+
|
| 235 |
+
# Filter out characters not in VOCAB
|
| 236 |
+
phonemes = ''.join(filter(lambda p: p in self.VOCAB, phonemes))
|
| 237 |
+
|
| 238 |
+
return phonemes.strip()
|
services/ktts/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 2 |
+
torch
|
| 3 |
+
torchvision
|
| 4 |
+
gradio
|
| 5 |
+
phonemizer
|
| 6 |
+
soundfile
|
| 7 |
+
onnxruntime
|
| 8 |
+
fastapi
|
| 9 |
+
uvicorn
|
services/ktts/voices/af.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:03d6457d8d31306c7c4e7afc5040b446e6cb556b40fd80d07fbdbc8dc75d300c
|
| 3 |
+
size 131
|
services/ktts/voices/af_bella.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:04339167efe7fe6dedf2eedfcdd357bbe3f4451619fcb67c0feea0708da0bb31
|
| 3 |
+
size 131
|
services/ktts/voices/af_nicole.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4ca02cd41d325f5445b14805a3ec6985cae55a21cec91635f8c2038a5b288383
|
| 3 |
+
size 131
|
services/ktts/voices/af_sarah.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2a6f3c43e3645896c7f0d57968f23d5ef5860b764bd4abb6d75076a1e7fda2e9
|
| 3 |
+
size 131
|
services/ktts/voices/af_sky.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ab87dbbf5b86022f6c01aed43cb4bcd221fcb95241c1d1e67e27dde2978a59c5
|
| 3 |
+
size 131
|
services/ktts/voices/am_adam.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fd49625a3f7fba1b2ca0cd84b817bc0531fdc94bd256953c3636e8afb09df2f4
|
| 3 |
+
size 131
|
services/ktts/voices/am_michael.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:bba4bc7701cefde36070eaf65d20a689dae85282efc79c8d5770933a56c2ff76
|
| 3 |
+
size 131
|
services/ktts/voices/bf_emma.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d5c84df49c195eb467a7a2e3f5f5e25624808f751db021fd5776495e1b9bb585
|
| 3 |
+
size 131
|
services/ktts/voices/bf_isabella.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:cc23980551bb88178169bbdcfe3f10d69fc8b8ded9fdb0ec1123a3294b95266d
|
| 3 |
+
size 131
|
services/ktts/voices/bm_george.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:27dff07c3aaf98f311475d2d3212f377325ab49b0ff566100424a3301252bbb2
|
| 3 |
+
size 131
|
services/ktts/voices/bm_lewis.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c9d076bfb89ada363657fea4d40d67f4174929dc771fdc245547397714fd33dc
|
| 3 |
+
size 131
|
services/ktts/weights/.gitkeep
ADDED
|
File without changes
|
services/ktts/weights/kokoro-quant.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6fa3c2d4e9dabfb2929b555c425c1c56f8333d46c47de8668de365a3d158de83
|
| 3 |
+
size 134
|
services/ktts/weights/kokoro-v0_19.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d6bd36e46e7e6e1f118d2ae1a8ff807a24c06cdae309f3c4f34230ad9eb37652
|
| 3 |
+
size 134
|
services/musicgen/.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
services/musicgen/Dockerfile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# System deps
|
| 6 |
+
RUN apt-get update && apt-get install -y \
|
| 7 |
+
git \
|
| 8 |
+
ffmpeg \
|
| 9 |
+
libsndfile1 \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
COPY requirements.txt .
|
| 13 |
+
|
| 14 |
+
RUN pip install --upgrade pip
|
| 15 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 16 |
+
|
| 17 |
+
COPY . .
|
| 18 |
+
|
| 19 |
+
EXPOSE 7860
|
| 20 |
+
|
| 21 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
services/musicgen/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: MusicGen
|
| 3 |
+
emoji: 📚
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
services/musicgen/app.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.responses import FileResponse
|
| 3 |
+
import gradio as gr
|
| 4 |
+
from generate import generate_music
|
| 5 |
+
|
| 6 |
+
# ======================
|
| 7 |
+
# FASTAPI BACKEND
|
| 8 |
+
# ======================
|
| 9 |
+
|
| 10 |
+
api = FastAPI(title="AI Background Music Generator")
|
| 11 |
+
|
| 12 |
+
@api.get("/health")
|
| 13 |
+
def health():
|
| 14 |
+
return {"status": "running"}
|
| 15 |
+
|
| 16 |
+
@api.post("/generate")
|
| 17 |
+
async def api_generate(prompt: str, duration: int = 10):
|
| 18 |
+
file_path = generate_music(prompt, duration)
|
| 19 |
+
|
| 20 |
+
return FileResponse(
|
| 21 |
+
path=file_path,
|
| 22 |
+
media_type="audio/wav",
|
| 23 |
+
filename="music.wav"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# ======================
|
| 27 |
+
# GRADIO UI
|
| 28 |
+
# ======================
|
| 29 |
+
|
| 30 |
+
def ui_generate(prompt, duration):
|
| 31 |
+
audio = generate_music(prompt, duration)
|
| 32 |
+
return audio
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
with gr.Blocks(
|
| 36 |
+
title="AI Background Music Generator",
|
| 37 |
+
theme=gr.themes.Soft()
|
| 38 |
+
) as demo:
|
| 39 |
+
|
| 40 |
+
gr.Markdown("""
|
| 41 |
+
# 🎵 AI Background Music Generator
|
| 42 |
+
Generate royalty-free background music instantly.
|
| 43 |
+
""")
|
| 44 |
+
|
| 45 |
+
prompt = gr.Textbox(
|
| 46 |
+
label="Music Description",
|
| 47 |
+
placeholder="Soft cinematic nasheed style..."
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
duration = gr.Slider(
|
| 51 |
+
minimum=5,
|
| 52 |
+
maximum=60,
|
| 53 |
+
value=10,
|
| 54 |
+
step=1,
|
| 55 |
+
label="Duration (seconds)"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
btn = gr.Button("Generate")
|
| 59 |
+
|
| 60 |
+
output = gr.Audio(type="filepath")
|
| 61 |
+
|
| 62 |
+
btn.click(
|
| 63 |
+
fn=ui_generate,
|
| 64 |
+
inputs=[prompt, duration],
|
| 65 |
+
outputs=output
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ======================
|
| 70 |
+
# IMPORTANT FIX
|
| 71 |
+
# ======================
|
| 72 |
+
# Mount GRADIO as ROOT APP
|
| 73 |
+
|
| 74 |
+
app = gr.mount_gradio_app(api, demo, path="/")
|
services/musicgen/generate.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
from transformers import AutoProcessor, MusicgenForConditionalGeneration
|
| 3 |
+
import soundfile as sf
|
| 4 |
+
import uuid
|
| 5 |
+
|
| 6 |
+
MODEL_NAME = "facebook/musicgen-small"
|
| 7 |
+
|
| 8 |
+
processor = None
|
| 9 |
+
model = None
|
| 10 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 11 |
+
model_status = "not_loaded"
|
| 12 |
+
model_error = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _get_model():
|
| 16 |
+
global processor, model, model_status, model_error
|
| 17 |
+
|
| 18 |
+
if processor is None or model is None:
|
| 19 |
+
try:
|
| 20 |
+
model_status = "loading"
|
| 21 |
+
model_error = None
|
| 22 |
+
print("Loading MusicGen model...")
|
| 23 |
+
processor = AutoProcessor.from_pretrained(MODEL_NAME)
|
| 24 |
+
model = MusicgenForConditionalGeneration.from_pretrained(MODEL_NAME)
|
| 25 |
+
model.to(device)
|
| 26 |
+
model_status = "ready"
|
| 27 |
+
except Exception as exc:
|
| 28 |
+
model_status = "failed"
|
| 29 |
+
model_error = str(exc)
|
| 30 |
+
raise
|
| 31 |
+
|
| 32 |
+
return processor, model
|
| 33 |
+
|
| 34 |
+
def generate_music(prompt, duration):
|
| 35 |
+
processor, model = _get_model()
|
| 36 |
+
|
| 37 |
+
inputs = processor(
|
| 38 |
+
text=[prompt],
|
| 39 |
+
padding=True,
|
| 40 |
+
return_tensors="pt"
|
| 41 |
+
).to(device)
|
| 42 |
+
|
| 43 |
+
audio_values = model.generate(
|
| 44 |
+
**inputs,
|
| 45 |
+
max_new_tokens=int(duration * 50)
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
filename = f"/tmp/{uuid.uuid4()}.wav"
|
| 49 |
+
|
| 50 |
+
sampling_rate = model.config.audio_encoder.sampling_rate
|
| 51 |
+
|
| 52 |
+
sf.write(
|
| 53 |
+
filename,
|
| 54 |
+
audio_values[0, 0].cpu().numpy(),
|
| 55 |
+
sampling_rate
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
return filename
|
services/musicgen/requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
gradio
|
| 4 |
+
torch
|
| 5 |
+
torchaudio
|
| 6 |
+
transformers
|
| 7 |
+
accelerate
|
| 8 |
+
soundfile
|
| 9 |
+
scipy
|
| 10 |
+
numpy
|
services/musicgen/storage.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from huggingface_hub import HfApi
|
| 3 |
+
|
| 4 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 5 |
+
REPO_ID = "basyx/music-storage"
|
| 6 |
+
|
| 7 |
+
api = HfApi()
|
| 8 |
+
|
| 9 |
+
def upload_audio(filepath):
|
| 10 |
+
|
| 11 |
+
if not HF_TOKEN:
|
| 12 |
+
return None
|
| 13 |
+
|
| 14 |
+
filename = os.path.basename(filepath)
|
| 15 |
+
|
| 16 |
+
api.upload_file(
|
| 17 |
+
path_or_fileobj=filepath,
|
| 18 |
+
path_in_repo=filename,
|
| 19 |
+
repo_id=REPO_ID,
|
| 20 |
+
repo_type="dataset",
|
| 21 |
+
token=HF_TOKEN
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
return f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{filename}"
|
services/musicgen/styles.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
STYLES = {
|
| 2 |
+
|
| 3 |
+
"nasheed": """
|
| 4 |
+
Islamic vocal style nasheed,
|
| 5 |
+
spiritual atmosphere,
|
| 6 |
+
no musical instruments,
|
| 7 |
+
warm emotional tone
|
| 8 |
+
""",
|
| 9 |
+
|
| 10 |
+
"cinematic": """
|
| 11 |
+
epic cinematic orchestral background,
|
| 12 |
+
film score mood,
|
| 13 |
+
emotional and inspirational
|
| 14 |
+
""",
|
| 15 |
+
|
| 16 |
+
"lofi": """
|
| 17 |
+
lofi chill background music,
|
| 18 |
+
soft ambience,
|
| 19 |
+
relaxed study atmosphere
|
| 20 |
+
"""
|
| 21 |
+
}
|
services/render_engine/.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
services/render_engine/.gitignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
|
| 5 |
+
temp/
|
| 6 |
+
exports/
|
| 7 |
+
jobs/
|
| 8 |
+
models/
|
| 9 |
+
storage/
|
| 10 |
+
|
| 11 |
+
.env
|
| 12 |
+
.env.*
|
| 13 |
+
!.env.example
|
| 14 |
+
|
| 15 |
+
node_modules/
|
| 16 |
+
.next/
|
| 17 |
+
out/
|
| 18 |
+
dist/
|
| 19 |
+
coverage/
|
| 20 |
+
*.tsbuildinfo
|
| 21 |
+
|
| 22 |
+
frontend/node_modules/
|
| 23 |
+
frontend/.next/
|
| 24 |
+
frontend/out/
|
| 25 |
+
frontend/.vercel/
|
| 26 |
+
frontend/.env
|
| 27 |
+
frontend/.env.*
|
| 28 |
+
!frontend/.env.example
|
| 29 |
+
|
services/render_engine/Dockerfile
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 4 |
+
TEMP_DIR=/app/temp \
|
| 5 |
+
EXPORTS_DIR=/app/exports \
|
| 6 |
+
JOBS_DIR=/app/jobs \
|
| 7 |
+
STORAGE_DIR=/app/storage \
|
| 8 |
+
AVA2LON_BASE_DIR=/app \
|
| 9 |
+
BASYX_BASE_DIR=/app \
|
| 10 |
+
MAX_RENDER_WORKERS=1 \
|
| 11 |
+
FFMPEG_TIMEOUT_SECONDS=900 \
|
| 12 |
+
DOWNLOAD_TIMEOUT_SECONDS=60 \
|
| 13 |
+
MAX_DOWNLOAD_BYTES=524288000 \
|
| 14 |
+
ALLOW_PRIVATE_ASSET_URLS=false \
|
| 15 |
+
WHISPER_MODEL_SIZE=tiny \
|
| 16 |
+
WHISPER_COMPUTE_TYPE=int8 \
|
| 17 |
+
WHISPER_DEVICE=cpu \
|
| 18 |
+
WHISPER_MODEL_DIR=/app/models \
|
| 19 |
+
MAX_RETRIES=3 \
|
| 20 |
+
JOB_RETENTION_SECONDS=86400
|
| 21 |
+
|
| 22 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 23 |
+
ffmpeg \
|
| 24 |
+
fonts-dejavu-core \
|
| 25 |
+
ca-certificates \
|
| 26 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 27 |
+
|
| 28 |
+
WORKDIR /app
|
| 29 |
+
|
| 30 |
+
COPY requirements.txt .
|
| 31 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 32 |
+
|
| 33 |
+
COPY . .
|
| 34 |
+
|
| 35 |
+
RUN mkdir -p /app/temp /app/exports /app/jobs /app/models /app/storage \
|
| 36 |
+
&& chmod -R 777 /app/temp /app/exports /app/jobs /app/models /app/storage
|
| 37 |
+
|
| 38 |
+
EXPOSE 7860
|
| 39 |
+
|
| 40 |
+
CMD ["python", "app.py"]
|
services/render_engine/README.md
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Basyx FFmpeg Rendering Engine
|
| 3 |
+
emoji: 🎬
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# Basyx FFmpeg Rendering Engine
|
| 13 |
+
|
| 14 |
+
Basyx FFmpeg is a CPU-first rendering backend for short-form video automation. It is designed for Hugging Face Docker Spaces running on 2-8 CPU cores with 8-16 GB RAM and no GPU. The engine uses FFmpeg and FFprobe subprocess pipelines and never loads complete videos into Python memory.
|
| 15 |
+
|
| 16 |
+
## Architecture
|
| 17 |
+
|
| 18 |
+
```mermaid
|
| 19 |
+
flowchart TD
|
| 20 |
+
API["FastAPI REST API"] --> Jobs["Job Manager"]
|
| 21 |
+
Dashboard["Gradio Operator Dashboard"] --> Jobs
|
| 22 |
+
Jobs --> Engine["Render Engine"]
|
| 23 |
+
Engine --> Assets["Asset Probe + Metadata Cache"]
|
| 24 |
+
Engine --> Ingest["Remote URL + Upload Ingestion"]
|
| 25 |
+
Engine --> Normalize["Normalization"]
|
| 26 |
+
Engine --> Scenes["Scene Timeline"]
|
| 27 |
+
Engine --> Subtitles["SRT / ASS Subtitle Engine"]
|
| 28 |
+
Engine --> Transitions["Transition Filter Builder"]
|
| 29 |
+
Engine --> Audio["Voiceover + Ducking Mixer"]
|
| 30 |
+
Engine --> Exports["Export Manager"]
|
| 31 |
+
Ingest --> FFmpeg["FFmpeg / FFprobe"]
|
| 32 |
+
Normalize --> FFmpeg
|
| 33 |
+
Scenes --> FFmpeg
|
| 34 |
+
Subtitles --> FFmpeg
|
| 35 |
+
Transitions --> FFmpeg
|
| 36 |
+
Audio --> FFmpeg
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
Package layout:
|
| 40 |
+
|
| 41 |
+
```text
|
| 42 |
+
renderer/
|
| 43 |
+
core/ settings, models, ingestion, orchestration
|
| 44 |
+
ffmpeg/ command builder, runner, probing, normalization
|
| 45 |
+
subtitles/ SRT and ASS generation
|
| 46 |
+
transitions/ xfade graph generation
|
| 47 |
+
audio/ voiceover and music ducking
|
| 48 |
+
scenes/ timeline validation
|
| 49 |
+
templates/ caption templates
|
| 50 |
+
exports/ final deliverables
|
| 51 |
+
jobs/ durable job records and retries
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
## REST API
|
| 55 |
+
|
| 56 |
+
All API endpoints are served at the Space root. Asset fields may be absolute container paths or public `http`/`https` URLs. Remote assets are downloaded into a job-local temp directory with timeout and size limits before rendering.
|
| 57 |
+
|
| 58 |
+
### `POST /render`
|
| 59 |
+
|
| 60 |
+
Submit one render job.
|
| 61 |
+
|
| 62 |
+
```json
|
| 63 |
+
{
|
| 64 |
+
"template": "tiktok_classic",
|
| 65 |
+
"output_name": "campaign_clip.mp4",
|
| 66 |
+
"voiceover": "https://cdn.example.com/voiceover.wav",
|
| 67 |
+
"background_music": "https://cdn.example.com/music.mp3",
|
| 68 |
+
"subtitle_format": "ass",
|
| 69 |
+
"auto_subtitles": true,
|
| 70 |
+
"subtitle_language": "en",
|
| 71 |
+
"whisper_model_size": "tiny",
|
| 72 |
+
"preset": "tiktok_9_16_fast",
|
| 73 |
+
"callback_url": "https://n8n.example.com/webhook/render-complete",
|
| 74 |
+
"export_target": "local",
|
| 75 |
+
"audio_normalize": true,
|
| 76 |
+
"preview": false,
|
| 77 |
+
"normalize": true,
|
| 78 |
+
"scenes": [
|
| 79 |
+
{
|
| 80 |
+
"start": 0,
|
| 81 |
+
"duration": 5,
|
| 82 |
+
"media": "https://cdn.example.com/scene1.mp4",
|
| 83 |
+
"caption": "Launch faster with automated rendering",
|
| 84 |
+
"transition": "fade"
|
| 85 |
+
}
|
| 86 |
+
]
|
| 87 |
+
}
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
Response:
|
| 91 |
+
|
| 92 |
+
```json
|
| 93 |
+
{
|
| 94 |
+
"job_id": "job_abc123",
|
| 95 |
+
"status_url": "/status/job_abc123",
|
| 96 |
+
"download_url": "/download/job_abc123?token=..."
|
| 97 |
+
}
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
### `GET /presets`
|
| 101 |
+
|
| 102 |
+
Returns available render presets and caption templates. Presets let n8n submit compact requests such as `tiktok_9_16_fast`, `youtube_shorts_hd`, `podcast_square`, `reels_with_subtitles`, and `draft_preview`.
|
| 103 |
+
|
| 104 |
+
### `POST /render/ai-reels`
|
| 105 |
+
|
| 106 |
+
Submit a single-call AI Reels job using an existing voiceover and asset list. TTS is intentionally provider-pluggable in v1; the production path requires a supplied voiceover.
|
| 107 |
+
|
| 108 |
+
```json
|
| 109 |
+
{
|
| 110 |
+
"script": "Launch faster with automated rendering.",
|
| 111 |
+
"voiceover": "https://cdn.example.com/voiceover.wav",
|
| 112 |
+
"assets": ["https://cdn.example.com/scene1.jpg", "https://cdn.example.com/scene2.mp4"],
|
| 113 |
+
"template": "youtube_shorts",
|
| 114 |
+
"output_name": "ai_reel.mp4"
|
| 115 |
+
}
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
### `POST /render/batch`
|
| 119 |
+
|
| 120 |
+
Submit multiple render jobs. The worker pool defaults to one active render to avoid RAM exhaustion.
|
| 121 |
+
|
| 122 |
+
```json
|
| 123 |
+
{
|
| 124 |
+
"jobs": [
|
| 125 |
+
{
|
| 126 |
+
"template": "modern_minimal",
|
| 127 |
+
"output_name": "clip_a.mp4",
|
| 128 |
+
"scenes": [{"start": 0, "duration": 3, "media": "https://cdn.example.com/a.mp4"}]
|
| 129 |
+
}
|
| 130 |
+
]
|
| 131 |
+
}
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
### `POST /scene-builder`
|
| 135 |
+
|
| 136 |
+
Build a timeline from a script and asset list.
|
| 137 |
+
|
| 138 |
+
```json
|
| 139 |
+
{
|
| 140 |
+
"script": "One script can be split across several short scenes.",
|
| 141 |
+
"assets": ["https://cdn.example.com/a.mp4", "https://cdn.example.com/b.jpg"],
|
| 142 |
+
"duration": 8,
|
| 143 |
+
"transition": "fade"
|
| 144 |
+
}
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
### `POST /assets/upload`
|
| 148 |
+
|
| 149 |
+
Upload files from automation tools such as n8n and receive staged paths that can be used in `/render`.
|
| 150 |
+
|
| 151 |
+
Multipart form field:
|
| 152 |
+
|
| 153 |
+
- `files`: one or more images, videos, GIFs, or audio files.
|
| 154 |
+
|
| 155 |
+
Response:
|
| 156 |
+
|
| 157 |
+
```json
|
| 158 |
+
{
|
| 159 |
+
"assets": [
|
| 160 |
+
{
|
| 161 |
+
"filename": "clip.mp4",
|
| 162 |
+
"path": "/app/temp/uploads/abc/clip.mp4",
|
| 163 |
+
"reference": "upload://clip.mp4"
|
| 164 |
+
}
|
| 165 |
+
]
|
| 166 |
+
}
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
### `POST /render/upload`
|
| 170 |
+
|
| 171 |
+
Submit a render job and files in one multipart request.
|
| 172 |
+
|
| 173 |
+
Multipart fields:
|
| 174 |
+
|
| 175 |
+
- `request_json`: render payload JSON.
|
| 176 |
+
- `files`: uploaded files referenced by `upload://filename`.
|
| 177 |
+
|
| 178 |
+
Example `request_json`:
|
| 179 |
+
|
| 180 |
+
```json
|
| 181 |
+
{
|
| 182 |
+
"template": "tiktok_classic",
|
| 183 |
+
"output_name": "n8n_clip.mp4",
|
| 184 |
+
"scenes": [
|
| 185 |
+
{
|
| 186 |
+
"start": 0,
|
| 187 |
+
"duration": 5,
|
| 188 |
+
"media": "upload://clip.mp4",
|
| 189 |
+
"caption": "Rendered from n8n"
|
| 190 |
+
}
|
| 191 |
+
],
|
| 192 |
+
"voiceover": "upload://voice.wav"
|
| 193 |
+
}
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
### `POST /render/ai-reels/upload`
|
| 197 |
+
|
| 198 |
+
Multipart AI Reels endpoint. Use `upload://filename` inside `voiceover`, `background_music`, and `assets`.
|
| 199 |
+
|
| 200 |
+
### `POST /transcribe`
|
| 201 |
+
|
| 202 |
+
Transcribe local or remote audio/video with faster-whisper.
|
| 203 |
+
|
| 204 |
+
```json
|
| 205 |
+
{
|
| 206 |
+
"audio": "https://cdn.example.com/voiceover.wav",
|
| 207 |
+
"model_size": "tiny",
|
| 208 |
+
"language": "en",
|
| 209 |
+
"task": "transcribe",
|
| 210 |
+
"word_timestamps": true,
|
| 211 |
+
"vad_filter": true
|
| 212 |
+
}
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
Response includes `text`, detected `language`, segment timings, and optional word timings. Use this endpoint from n8n when you want subtitles before rendering.
|
| 216 |
+
|
| 217 |
+
### `POST /transcribe/upload`
|
| 218 |
+
|
| 219 |
+
Multipart faster-whisper endpoint for direct n8n file uploads.
|
| 220 |
+
|
| 221 |
+
Multipart fields:
|
| 222 |
+
|
| 223 |
+
- `file`: audio or video file.
|
| 224 |
+
- `model_size`: optional Whisper model, default `WHISPER_MODEL_SIZE`.
|
| 225 |
+
- `language`: optional ISO language code.
|
| 226 |
+
- `word_timestamps`: optional boolean, default `true`.
|
| 227 |
+
|
| 228 |
+
### `POST /subtitles`
|
| 229 |
+
|
| 230 |
+
Generate SRT or ASS files from timed events.
|
| 231 |
+
|
| 232 |
+
```json
|
| 233 |
+
{
|
| 234 |
+
"format": "ass",
|
| 235 |
+
"template": "tiktok_zoom",
|
| 236 |
+
"events": [
|
| 237 |
+
{"start": 0, "end": 1.2, "text": "Hello world"}
|
| 238 |
+
]
|
| 239 |
+
}
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
### `GET /status/{job_id}`
|
| 243 |
+
|
| 244 |
+
Returns job state, logs, FFmpeg commands, metrics, output path, and failure reason.
|
| 245 |
+
|
| 246 |
+
States: `PENDING`, `RUNNING`, `FAILED`, `COMPLETED`.
|
| 247 |
+
|
| 248 |
+
### `GET /download/{job_id}`
|
| 249 |
+
|
| 250 |
+
Returns the completed MP4 deliverable. Downloads require the signed token returned by render submission. If the job is still running, the endpoint returns `409`.
|
| 251 |
+
|
| 252 |
+
### `POST /cancel/{job_id}`
|
| 253 |
+
|
| 254 |
+
Requests job cancellation. Pending jobs are marked `CANCELLED`; running jobs are marked `CANCEL_REQUESTED` and stop before the next guarded execution point.
|
| 255 |
+
|
| 256 |
+
### `POST /admin/cleanup`
|
| 257 |
+
|
| 258 |
+
Deletes expired job records, old exports, and staged upload folders. Default retention is controlled by `JOB_RETENTION_SECONDS`.
|
| 259 |
+
|
| 260 |
+
### `POST /inspect`
|
| 261 |
+
|
| 262 |
+
Probe one local asset path and return MIME type, duration, codecs, bitrate, resolution, FPS, stream list, and cache metadata.
|
| 263 |
+
|
| 264 |
+
## n8n Workflow Integration
|
| 265 |
+
|
| 266 |
+
Recommended URL-based flow:
|
| 267 |
+
|
| 268 |
+
1. Use an HTTP Request node with `POST https://YOUR-SPACE.hf.space/render`.
|
| 269 |
+
2. Pass public file URLs from S3, Supabase Storage, Cloudinary, Google Drive direct-download links, or another CDN in `scenes[].media`, `voiceover`, and `background_music`.
|
| 270 |
+
3. Set `auto_subtitles` to `true` when you want faster-whisper captions generated from `voiceover`.
|
| 271 |
+
4. Add `callback_url` to receive a POST when the job completes or fails.
|
| 272 |
+
5. Poll `GET https://YOUR-SPACE.hf.space/status/{{$json.job_id}}` only if you do not use callbacks.
|
| 273 |
+
6. Download the final MP4 from the signed `download_url`.
|
| 274 |
+
|
| 275 |
+
Multipart flow:
|
| 276 |
+
|
| 277 |
+
1. Use an HTTP Request node set to multipart form-data.
|
| 278 |
+
2. Send `request_json` as a text field.
|
| 279 |
+
3. Attach files under the `files` field.
|
| 280 |
+
4. Reference those files in JSON as `upload://exact-filename.ext`.
|
| 281 |
+
|
| 282 |
+
Security defaults:
|
| 283 |
+
|
| 284 |
+
- Only `http` and `https` remote assets are accepted.
|
| 285 |
+
- Private, localhost, link-local, and multicast hosts are blocked by default.
|
| 286 |
+
- Set `ALLOW_PRIVATE_ASSET_URLS=true` only for trusted self-hosted deployments.
|
| 287 |
+
- `MAX_DOWNLOAD_BYTES` limits remote downloads and multipart upload totals.
|
| 288 |
+
- `WHISPER_MODEL_SIZE=tiny` and `WHISPER_COMPUTE_TYPE=int8` are the recommended CPU defaults.
|
| 289 |
+
- `BASYX_SIGNING_SECRET` should be set as a Space secret before public deployment.
|
| 290 |
+
|
| 291 |
+
## Operator Dashboard
|
| 292 |
+
|
| 293 |
+
The dashboard is available at `/dashboard`. It provides production operator tools for render submission, batch submission, AI Reels submission, job status, logs, downloads, and asset inspection.
|
| 294 |
+
|
| 295 |
+
## Caption Templates
|
| 296 |
+
|
| 297 |
+
Built-in templates:
|
| 298 |
+
|
| 299 |
+
- `tiktok_classic`
|
| 300 |
+
- `tiktok_zoom`
|
| 301 |
+
- `alex_hormozi`
|
| 302 |
+
- `modern_minimal`
|
| 303 |
+
- `youtube_shorts`
|
| 304 |
+
- `podcast_style`
|
| 305 |
+
- `news_style`
|
| 306 |
+
|
| 307 |
+
Templates are selected per request and do not require code changes.
|
| 308 |
+
|
| 309 |
+
## CPU And RAM Tuning
|
| 310 |
+
|
| 311 |
+
- Keep `MAX_RENDER_WORKERS=1` for 8 GB RAM deployments.
|
| 312 |
+
- Use `OUTPUT_PRESET=veryfast` or `ultrafast` for faster CPU rendering.
|
| 313 |
+
- Use `OUTPUT_CRF=23-28` to balance quality and file size.
|
| 314 |
+
- Keep source assets near the target duration to reduce normalization work.
|
| 315 |
+
- Prefer pre-trimmed voiceovers and assets for batch workloads.
|
| 316 |
+
- Start with `WHISPER_MODEL_SIZE=tiny` or `base` on free/CPU Spaces.
|
| 317 |
+
- Use `preview=true` for low-resolution draft renders.
|
| 318 |
+
- Use `audio_normalize=true` for voiceover or podcast-style content.
|
| 319 |
+
|
| 320 |
+
## Reliability
|
| 321 |
+
|
| 322 |
+
- FFmpeg subprocesses are killed after `FFMPEG_TIMEOUT_SECONDS`.
|
| 323 |
+
- Remote asset downloads are killed after `DOWNLOAD_TIMEOUT_SECONDS`.
|
| 324 |
+
- Remote and multipart input size is capped by `MAX_DOWNLOAD_BYTES`.
|
| 325 |
+
- faster-whisper models are loaded lazily and cached under `WHISPER_MODEL_DIR`.
|
| 326 |
+
- Webhook callbacks are best-effort and callback failures are written to job logs.
|
| 327 |
+
- `export_target=local` copies completed renders to `STORAGE_DIR`.
|
| 328 |
+
- `export_target=https://...` uploads the completed MP4 with HTTP `PUT`, which works with presigned URLs from S3, Supabase Storage, and similar providers.
|
| 329 |
+
- Jobs retry up to `MAX_RETRIES`.
|
| 330 |
+
- Intermediate files are created under `TEMP_DIR` and removed after export.
|
| 331 |
+
- Job records retain command history, logs, render time, output size, and failure reasons.
|
| 332 |
+
|
| 333 |
+
## Docker Deployment
|
| 334 |
+
|
| 335 |
+
Build locally:
|
| 336 |
+
|
| 337 |
+
```bash
|
| 338 |
+
docker build -t basyx-ffmpeg .
|
| 339 |
+
docker run --rm -p 7860:7860 basyx-ffmpeg
|
| 340 |
+
```
|
| 341 |
+
|
| 342 |
+
Open:
|
| 343 |
+
|
| 344 |
+
- API: `http://localhost:7860/health`
|
| 345 |
+
- Dashboard: `http://localhost:7860/dashboard`
|
| 346 |
+
|
| 347 |
+
## Hugging Face Spaces Deployment
|
| 348 |
+
|
| 349 |
+
Create a Docker Space, push this repository, and keep the README metadata above. The container listens on port `7860`, and the Space exposes the FastAPI service plus the dashboard.
|
| 350 |
+
|
| 351 |
+
## Testing
|
| 352 |
+
|
| 353 |
+
Run:
|
| 354 |
+
|
| 355 |
+
```bash
|
| 356 |
+
pytest --cov=renderer --cov-report=term-missing
|
| 357 |
+
```
|
| 358 |
+
|
| 359 |
+
Integration tests use mocked FFmpeg/FFprobe where possible and small generated media where necessary.
|
| 360 |
+
|
| 361 |
+
## Known v1 Limits
|
| 362 |
+
|
| 363 |
+
- Bundled TTS is not included. The AI Reels endpoint requires a supplied voiceover and keeps narration generation behind a provider interface for future integration.
|
| 364 |
+
- Advanced caption animation is implemented through ASS effects and FFmpeg-compatible filter behavior, not GPU animation layers.
|
| 365 |
+
- Batch rendering is intentionally sequential by default for constrained CPU/RAM Spaces.
|
services/render_engine/api.py
ADDED
|
@@ -0,0 +1,1314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import shutil
|
| 5 |
+
import uuid
|
| 6 |
+
import zipfile
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile
|
| 11 |
+
from fastapi.responses import FileResponse
|
| 12 |
+
from pydantic import BaseModel, Field
|
| 13 |
+
|
| 14 |
+
from renderer.core.config import Settings
|
| 15 |
+
from renderer.core.ingest import stage_upload
|
| 16 |
+
from renderer.core.models import AIReelsRequest, RenderRequest, Scene
|
| 17 |
+
from renderer.core.security import verify_download_token
|
| 18 |
+
from renderer.core.utils import safe_filename
|
| 19 |
+
from renderer.jobs import JobManager
|
| 20 |
+
from renderer.platform import PlatformProcessor, supported_toolkit_tasks
|
| 21 |
+
from renderer.scenes import Timeline
|
| 22 |
+
from renderer.studio import (
|
| 23 |
+
ProjectStore,
|
| 24 |
+
StudioTaskProcessor,
|
| 25 |
+
add_effect,
|
| 26 |
+
add_filter,
|
| 27 |
+
add_keyframe,
|
| 28 |
+
add_transition,
|
| 29 |
+
capability_catalog,
|
| 30 |
+
)
|
| 31 |
+
from renderer import RenderEngine
|
| 32 |
+
from renderer.subtitles import SubtitleEvent, SubtitleGenerator
|
| 33 |
+
from renderer.templates import (
|
| 34 |
+
apply_creative_style,
|
| 35 |
+
apply_preset,
|
| 36 |
+
creative_style_metadata,
|
| 37 |
+
get_creative_style,
|
| 38 |
+
list_creative_styles,
|
| 39 |
+
list_platform_profiles,
|
| 40 |
+
list_presets,
|
| 41 |
+
list_scene_effects,
|
| 42 |
+
list_templates,
|
| 43 |
+
platform_profile_metadata,
|
| 44 |
+
scene_effect_metadata,
|
| 45 |
+
)
|
| 46 |
+
from renderer.transitions import TransitionBuilder
|
| 47 |
+
|
| 48 |
+
settings = Settings()
|
| 49 |
+
settings.ensure_dirs()
|
| 50 |
+
job_manager = JobManager(settings)
|
| 51 |
+
project_manager = ProjectStore(settings)
|
| 52 |
+
api = FastAPI(title="Ava2lon Studio AI", version="2.0.0")
|
| 53 |
+
|
| 54 |
+
API_KEY_HEADER = "X-API-Key"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _verify_api_key(api_key: str | None = Header(default=None, alias=API_KEY_HEADER)) -> None:
|
| 58 |
+
configured = getattr(settings, "api_key", "")
|
| 59 |
+
if configured and api_key != configured:
|
| 60 |
+
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
| 61 |
+
|
| 62 |
+
def _submission_response(job_id: str) -> dict[str, str]:
|
| 63 |
+
download_url = f"/download/{job_id}"
|
| 64 |
+
try:
|
| 65 |
+
record = job_manager.get(job_id)
|
| 66 |
+
except KeyError:
|
| 67 |
+
token = None
|
| 68 |
+
else:
|
| 69 |
+
token = record.download_token
|
| 70 |
+
if token:
|
| 71 |
+
download_url = f"{download_url}?token={token}"
|
| 72 |
+
return {"job_id": job_id, "status_url": f"/status/{job_id}", "download_url": download_url}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class ScenePayload(BaseModel):
|
| 76 |
+
start: float = Field(ge=0)
|
| 77 |
+
duration: float = Field(gt=0)
|
| 78 |
+
media: str
|
| 79 |
+
caption: str = ""
|
| 80 |
+
transition: str = "fade"
|
| 81 |
+
background: str = "blur"
|
| 82 |
+
layout: str = "fill"
|
| 83 |
+
effect: str | None = None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class RenderPayload(BaseModel):
|
| 87 |
+
scenes: list[ScenePayload]
|
| 88 |
+
template: str = "tiktok_classic"
|
| 89 |
+
preset: str | None = None
|
| 90 |
+
creative_style: str | None = None
|
| 91 |
+
platform: str | None = None
|
| 92 |
+
output_name: str = "render.mp4"
|
| 93 |
+
voiceover: str | None = None
|
| 94 |
+
background_music: str | None = None
|
| 95 |
+
music_volume: float = Field(default=0.316, ge=0, le=2)
|
| 96 |
+
music_fade_in: float = Field(default=0.0, ge=0)
|
| 97 |
+
music_fade_out: float = Field(default=0.0, ge=0)
|
| 98 |
+
music_loop: bool = True
|
| 99 |
+
music_start: float = Field(default=0.0, ge=0)
|
| 100 |
+
music_ducking: bool = True
|
| 101 |
+
voice_volume: float = Field(default=1.0, ge=0, le=2)
|
| 102 |
+
subtitle_format: str = "ass"
|
| 103 |
+
auto_subtitles: bool = False
|
| 104 |
+
subtitle_language: str | None = None
|
| 105 |
+
whisper_model_size: str | None = None
|
| 106 |
+
preview: bool = False
|
| 107 |
+
audio_normalize: bool = False
|
| 108 |
+
watermark: str | None = None
|
| 109 |
+
watermark_position: str = "bottom-right"
|
| 110 |
+
intro: str | None = None
|
| 111 |
+
outro: str | None = None
|
| 112 |
+
callback_url: str | None = None
|
| 113 |
+
export_target: str | None = None
|
| 114 |
+
priority: int = 0
|
| 115 |
+
scheduled_at: float | None = None
|
| 116 |
+
normalize: bool = True
|
| 117 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class AIReelsPayload(BaseModel):
|
| 121 |
+
script: str
|
| 122 |
+
voiceover: str
|
| 123 |
+
assets: list[str]
|
| 124 |
+
template: str = "tiktok_classic"
|
| 125 |
+
creative_style: str | None = None
|
| 126 |
+
platform: str | None = None
|
| 127 |
+
output_name: str = "ai_reel.mp4"
|
| 128 |
+
background_music: str | None = None
|
| 129 |
+
music_volume: float = Field(default=0.316, ge=0, le=2)
|
| 130 |
+
music_fade_in: float = Field(default=0.0, ge=0)
|
| 131 |
+
music_fade_out: float = Field(default=0.0, ge=0)
|
| 132 |
+
music_loop: bool = True
|
| 133 |
+
music_start: float = Field(default=0.0, ge=0)
|
| 134 |
+
music_ducking: bool = True
|
| 135 |
+
voice_volume: float = Field(default=1.0, ge=0, le=2)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class BatchPayload(BaseModel):
|
| 139 |
+
jobs: list[RenderPayload]
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class UploadedAsset(BaseModel):
|
| 143 |
+
filename: str
|
| 144 |
+
path: str
|
| 145 |
+
reference: str
|
| 146 |
+
kind: str = "other"
|
| 147 |
+
metadata: dict[str, Any] | None = None
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class TranscribePayload(BaseModel):
|
| 151 |
+
audio: str
|
| 152 |
+
model_size: str | None = None
|
| 153 |
+
language: str | None = None
|
| 154 |
+
task: str = "transcribe"
|
| 155 |
+
beam_size: int = Field(default=5, ge=1, le=10)
|
| 156 |
+
vad_filter: bool = True
|
| 157 |
+
word_timestamps: bool = True
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class SubtitlePayload(BaseModel):
|
| 161 |
+
events: list[dict[str, Any]]
|
| 162 |
+
format: str = "srt"
|
| 163 |
+
template: str = "tiktok_classic"
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class SceneBuildPayload(BaseModel):
|
| 167 |
+
script: str
|
| 168 |
+
assets: list[str]
|
| 169 |
+
duration: float | None = None
|
| 170 |
+
transition: str = "fade"
|
| 171 |
+
creative_style: str | None = None
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
class IngestSourcePayload(BaseModel):
|
| 175 |
+
url: str
|
| 176 |
+
type: str | None = None
|
| 177 |
+
name: str | None = None
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
class IngestPayload(BaseModel):
|
| 181 |
+
sources: list[IngestSourcePayload]
|
| 182 |
+
callback_url: str | None = None
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class AnalyzePayload(BaseModel):
|
| 186 |
+
media: str
|
| 187 |
+
transcript: str = ""
|
| 188 |
+
platform: str | None = None
|
| 189 |
+
callback_url: str | None = None
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
class ClipsPayload(BaseModel):
|
| 193 |
+
media: str
|
| 194 |
+
clips: list[dict[str, Any]] | None = None
|
| 195 |
+
callback_url: str | None = None
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
class ToolkitPayload(BaseModel):
|
| 199 |
+
task: str
|
| 200 |
+
input: str | None = None
|
| 201 |
+
media: str | None = None
|
| 202 |
+
output_name: str | None = None
|
| 203 |
+
params: dict[str, Any] = Field(default_factory=dict)
|
| 204 |
+
callback_url: str | None = None
|
| 205 |
+
export_target: str | None = None
|
| 206 |
+
model_config = {"extra": "allow"}
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
class ThumbnailPayload(BaseModel):
|
| 210 |
+
media: str
|
| 211 |
+
text: str = ""
|
| 212 |
+
timestamp: float | None = None
|
| 213 |
+
template: str = "bold"
|
| 214 |
+
callback_url: str | None = None
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
class MetadataPayload(BaseModel):
|
| 218 |
+
topic: str = ""
|
| 219 |
+
transcript: str = ""
|
| 220 |
+
platform: str | None = None
|
| 221 |
+
callback_url: str | None = None
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
class PublishPayload(BaseModel):
|
| 225 |
+
media: str | None = None
|
| 226 |
+
asset: str | None = None
|
| 227 |
+
title: str | None = None
|
| 228 |
+
description: str | None = None
|
| 229 |
+
platforms: list[str] = Field(default_factory=list)
|
| 230 |
+
platform: str | None = None
|
| 231 |
+
scheduled_at: str | None = None
|
| 232 |
+
draft: bool = True
|
| 233 |
+
callback_url: str | None = None
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
class ProjectPayload(BaseModel):
|
| 237 |
+
name: str
|
| 238 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 239 |
+
export_settings: dict[str, Any] = Field(default_factory=dict)
|
| 240 |
+
template: dict[str, Any] | None = None
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
class ProjectSavePayload(BaseModel):
|
| 244 |
+
project_id: str | None = None
|
| 245 |
+
project: dict[str, Any]
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
class ProjectAssetPayload(BaseModel):
|
| 249 |
+
project_id: str
|
| 250 |
+
asset: dict[str, Any]
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
class TimelineAddPayload(BaseModel):
|
| 254 |
+
project_id: str
|
| 255 |
+
track_type: str = "video"
|
| 256 |
+
track_id: str | None = None
|
| 257 |
+
item: dict[str, Any] = Field(default_factory=dict)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
class TimelineOperationPayload(BaseModel):
|
| 261 |
+
project_id: str
|
| 262 |
+
operation: str = "drag"
|
| 263 |
+
item_id: str | None = None
|
| 264 |
+
params: dict[str, Any] = Field(default_factory=dict)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
class EffectApplyPayload(BaseModel):
|
| 268 |
+
project_id: str | None = None
|
| 269 |
+
target_id: str | None = None
|
| 270 |
+
item_id: str | None = None
|
| 271 |
+
effect: str
|
| 272 |
+
params: dict[str, Any] = Field(default_factory=dict)
|
| 273 |
+
callback_url: str | None = None
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
class FilterApplyPayload(BaseModel):
|
| 277 |
+
project_id: str | None = None
|
| 278 |
+
target_id: str | None = None
|
| 279 |
+
item_id: str | None = None
|
| 280 |
+
filter: str
|
| 281 |
+
params: dict[str, Any] = Field(default_factory=dict)
|
| 282 |
+
lut: str | None = None
|
| 283 |
+
callback_url: str | None = None
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
class TransitionAddPayload(BaseModel):
|
| 287 |
+
project_id: str | None = None
|
| 288 |
+
from_item_id: str | None = None
|
| 289 |
+
to_item_id: str | None = None
|
| 290 |
+
target_id: str | None = None
|
| 291 |
+
transition: str
|
| 292 |
+
duration: float = Field(default=0.45, gt=0)
|
| 293 |
+
params: dict[str, Any] = Field(default_factory=dict)
|
| 294 |
+
callback_url: str | None = None
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
class KeyframePayload(BaseModel):
|
| 298 |
+
project_id: str
|
| 299 |
+
target_id: str
|
| 300 |
+
property: str
|
| 301 |
+
time: float = Field(ge=0)
|
| 302 |
+
value: Any
|
| 303 |
+
easing: str = "linear"
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
class GenerationPayload(BaseModel):
|
| 307 |
+
prompt: str | None = None
|
| 308 |
+
text: str | None = None
|
| 309 |
+
media: str | None = None
|
| 310 |
+
provider: str | None = None
|
| 311 |
+
callback_url: str | None = None
|
| 312 |
+
export_target: str | None = None
|
| 313 |
+
params: dict[str, Any] = Field(default_factory=dict)
|
| 314 |
+
model_config = {"extra": "allow"}
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
class CaptionGeneratePayload(BaseModel):
|
| 318 |
+
media: str | None = None
|
| 319 |
+
audio: str | None = None
|
| 320 |
+
text: str | None = None
|
| 321 |
+
transcript: str | None = None
|
| 322 |
+
events: list[dict[str, Any]] | None = None
|
| 323 |
+
template: str = "capcut"
|
| 324 |
+
language: str | None = None
|
| 325 |
+
engine: str = "whisper"
|
| 326 |
+
word_timestamps: bool = True
|
| 327 |
+
emoji_insertion: bool = False
|
| 328 |
+
speaker_detection: bool = False
|
| 329 |
+
karaoke: bool = True
|
| 330 |
+
animated: bool = True
|
| 331 |
+
callback_url: str | None = None
|
| 332 |
+
model_config = {"extra": "allow"}
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
class AIToolPayload(BaseModel):
|
| 336 |
+
project_id: str | None = None
|
| 337 |
+
media: str | None = None
|
| 338 |
+
transcript: str | None = None
|
| 339 |
+
text: str | None = None
|
| 340 |
+
platform: str | None = None
|
| 341 |
+
callback_url: str | None = None
|
| 342 |
+
params: dict[str, Any] = Field(default_factory=dict)
|
| 343 |
+
model_config = {"extra": "allow"}
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
class ProjectRenderPayload(BaseModel):
|
| 347 |
+
output_name: str = "project_render.mp4"
|
| 348 |
+
template: str | None = None
|
| 349 |
+
preset: str | None = None
|
| 350 |
+
creative_style: str | None = None
|
| 351 |
+
platform: str | None = None
|
| 352 |
+
callback_url: str | None = None
|
| 353 |
+
export_target: str | None = None
|
| 354 |
+
preview: bool = False
|
| 355 |
+
normalize: bool = True
|
| 356 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
@api.get("/health")
|
| 360 |
+
def health() -> dict[str, str]:
|
| 361 |
+
return {"status": "ok"}
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
@api.get("/monitor")
|
| 365 |
+
def monitor() -> dict[str, Any]:
|
| 366 |
+
return _monitor_payload()
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
@api.get("/queue")
|
| 370 |
+
def queue() -> dict[str, Any]:
|
| 371 |
+
return job_manager.summary()
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
@api.get("/workers")
|
| 375 |
+
def workers() -> dict[str, Any]:
|
| 376 |
+
return {
|
| 377 |
+
"max_workers": settings.max_workers,
|
| 378 |
+
"ffmpeg_timeout_seconds": settings.ffmpeg_timeout_seconds,
|
| 379 |
+
"whisper_device": settings.whisper_device,
|
| 380 |
+
"whisper_model_size": settings.whisper_model_size,
|
| 381 |
+
"toolkit_tasks": supported_toolkit_tasks(),
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
@api.get("/presets")
|
| 386 |
+
def presets() -> dict[str, Any]:
|
| 387 |
+
return {
|
| 388 |
+
"presets": list_presets(),
|
| 389 |
+
"caption_templates": list_templates(),
|
| 390 |
+
"platforms": list_platform_profiles(),
|
| 391 |
+
"creative_styles": list_creative_styles(),
|
| 392 |
+
"scene_effects": list_scene_effects(),
|
| 393 |
+
"transitions": TransitionBuilder().list_transitions(),
|
| 394 |
+
"creative_style_metadata": creative_style_metadata(),
|
| 395 |
+
"scene_effect_metadata": scene_effect_metadata(),
|
| 396 |
+
"toolkit_tasks": supported_toolkit_tasks(),
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
@api.get("/platforms")
|
| 401 |
+
def platforms() -> dict[str, dict[str, Any]]:
|
| 402 |
+
return {"platforms": platform_profile_metadata()}
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
@api.get("/toolkit/tasks")
|
| 406 |
+
def toolkit_tasks() -> dict[str, list[str]]:
|
| 407 |
+
return {"tasks": supported_toolkit_tasks()}
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
@api.get("/capabilities")
|
| 411 |
+
def capabilities() -> dict[str, Any]:
|
| 412 |
+
catalog = capability_catalog()
|
| 413 |
+
catalog["runtime"] = {
|
| 414 |
+
"toolkit_tasks": supported_toolkit_tasks(),
|
| 415 |
+
"caption_templates": list_templates(),
|
| 416 |
+
"creative_styles": list_creative_styles(),
|
| 417 |
+
"scene_effects": list_scene_effects(),
|
| 418 |
+
"platforms": list_platform_profiles(),
|
| 419 |
+
"transitions": TransitionBuilder().list_transitions(),
|
| 420 |
+
}
|
| 421 |
+
return catalog
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
@api.get("/effects")
|
| 425 |
+
def effects() -> dict[str, Any]:
|
| 426 |
+
catalog = capability_catalog()
|
| 427 |
+
return {"effects": catalog["effects"], "scene_effects": scene_effect_metadata()}
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
@api.get("/filters")
|
| 431 |
+
def filters() -> dict[str, Any]:
|
| 432 |
+
return {"filters": capability_catalog()["filters"]}
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
@api.get("/transitions")
|
| 436 |
+
def transitions() -> dict[str, Any]:
|
| 437 |
+
return {"families": capability_catalog()["transitions"], "ffmpeg": TransitionBuilder().list_transitions()}
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
@api.get("/templates/catalog")
|
| 441 |
+
def templates_catalog() -> dict[str, Any]:
|
| 442 |
+
return {
|
| 443 |
+
"categories": capability_catalog()["templates"],
|
| 444 |
+
"caption_templates": list_templates(),
|
| 445 |
+
"render_presets": list_presets(),
|
| 446 |
+
"creative_styles": creative_style_metadata(),
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
@api.get("/projects")
|
| 451 |
+
def list_projects() -> dict[str, list[dict[str, Any]]]:
|
| 452 |
+
return {"projects": project_manager.list()}
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
@api.post("/projects", dependencies=[Depends(_verify_api_key)])
|
| 456 |
+
def create_project(payload: ProjectPayload) -> dict[str, Any]:
|
| 457 |
+
project = project_manager.create(payload.name, metadata=payload.metadata, template=payload.template)
|
| 458 |
+
if payload.export_settings:
|
| 459 |
+
project["export_settings"].update(payload.export_settings)
|
| 460 |
+
project = project_manager.save(project["id"], project)
|
| 461 |
+
return {"project": project}
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
@api.post("/project/create", dependencies=[Depends(_verify_api_key)])
|
| 465 |
+
def project_create(payload: ProjectPayload) -> dict[str, Any]:
|
| 466 |
+
return create_project(payload)
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
@api.get("/project/{project_id}")
|
| 470 |
+
def project_get(project_id: str) -> dict[str, Any]:
|
| 471 |
+
try:
|
| 472 |
+
return {"project": project_manager.get(project_id)}
|
| 473 |
+
except KeyError as exc:
|
| 474 |
+
raise HTTPException(status_code=404, detail="Project not found") from exc
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
@api.post("/project/save", dependencies=[Depends(_verify_api_key)])
|
| 478 |
+
def project_save(payload: ProjectSavePayload) -> dict[str, Any]:
|
| 479 |
+
project_id = payload.project_id or str(payload.project.get("id") or safe_filename(payload.project.get("name", "project")))
|
| 480 |
+
return {"project": project_manager.save(project_id, payload.project)}
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
@api.post("/project/assets/add", dependencies=[Depends(_verify_api_key)])
|
| 484 |
+
def project_asset_add(payload: ProjectAssetPayload) -> dict[str, Any]:
|
| 485 |
+
try:
|
| 486 |
+
return {"project": project_manager.add_asset(payload.project_id, payload.asset)}
|
| 487 |
+
except KeyError as exc:
|
| 488 |
+
raise HTTPException(status_code=404, detail="Project not found") from exc
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
@api.post("/timeline/add", dependencies=[Depends(_verify_api_key)])
|
| 492 |
+
def timeline_add(payload: TimelineAddPayload) -> dict[str, Any]:
|
| 493 |
+
try:
|
| 494 |
+
project = project_manager.add_to_timeline(payload.project_id, payload.item, payload.track_type, payload.track_id)
|
| 495 |
+
return {"project": project}
|
| 496 |
+
except KeyError as exc:
|
| 497 |
+
raise HTTPException(status_code=404, detail="Project not found") from exc
|
| 498 |
+
except ValueError as exc:
|
| 499 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
@api.post("/timeline/operation", dependencies=[Depends(_verify_api_key)])
|
| 503 |
+
def timeline_operation(payload: TimelineOperationPayload) -> dict[str, Any]:
|
| 504 |
+
try:
|
| 505 |
+
project = project_manager.timeline_operation(payload.project_id, payload.operation, payload.item_id, payload.params)
|
| 506 |
+
return {"project": project}
|
| 507 |
+
except KeyError as exc:
|
| 508 |
+
raise HTTPException(status_code=404, detail="Timeline item or project not found") from exc
|
| 509 |
+
except ValueError as exc:
|
| 510 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
@api.post("/timeline/split", dependencies=[Depends(_verify_api_key)])
|
| 514 |
+
def timeline_split(payload: TimelineOperationPayload) -> dict[str, Any]:
|
| 515 |
+
payload.operation = "split"
|
| 516 |
+
return timeline_operation(payload)
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
@api.post("/timeline/trim", dependencies=[Depends(_verify_api_key)])
|
| 520 |
+
def timeline_trim(payload: TimelineOperationPayload) -> dict[str, Any]:
|
| 521 |
+
payload.operation = "trim"
|
| 522 |
+
return timeline_operation(payload)
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
@api.post("/timeline/ripple-delete", dependencies=[Depends(_verify_api_key)])
|
| 526 |
+
def timeline_ripple_delete(payload: TimelineOperationPayload) -> dict[str, Any]:
|
| 527 |
+
payload.operation = "ripple_delete"
|
| 528 |
+
return timeline_operation(payload)
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
@api.post("/timeline/insert", dependencies=[Depends(_verify_api_key)])
|
| 532 |
+
def timeline_insert(payload: TimelineOperationPayload) -> dict[str, Any]:
|
| 533 |
+
payload.operation = "insert"
|
| 534 |
+
return timeline_operation(payload)
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
@api.post("/timeline/replace", dependencies=[Depends(_verify_api_key)])
|
| 538 |
+
def timeline_replace(payload: TimelineOperationPayload) -> dict[str, Any]:
|
| 539 |
+
payload.operation = "replace"
|
| 540 |
+
return timeline_operation(payload)
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
@api.post("/timeline/group", dependencies=[Depends(_verify_api_key)])
|
| 544 |
+
def timeline_group(payload: TimelineOperationPayload) -> dict[str, Any]:
|
| 545 |
+
payload.operation = "group"
|
| 546 |
+
return timeline_operation(payload)
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
@api.post("/timeline/lock", dependencies=[Depends(_verify_api_key)])
|
| 550 |
+
def timeline_lock(payload: TimelineOperationPayload) -> dict[str, Any]:
|
| 551 |
+
payload.operation = "lock"
|
| 552 |
+
return timeline_operation(payload)
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
@api.post("/timeline/hide", dependencies=[Depends(_verify_api_key)])
|
| 556 |
+
def timeline_hide(payload: TimelineOperationPayload) -> dict[str, Any]:
|
| 557 |
+
payload.operation = "hide"
|
| 558 |
+
return timeline_operation(payload)
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
@api.post("/timeline/duplicate", dependencies=[Depends(_verify_api_key)])
|
| 562 |
+
def timeline_duplicate(payload: TimelineOperationPayload) -> dict[str, Any]:
|
| 563 |
+
payload.operation = "duplicate"
|
| 564 |
+
return timeline_operation(payload)
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
@api.post("/effect/apply", dependencies=[Depends(_verify_api_key)])
|
| 568 |
+
def effect_apply(payload: EffectApplyPayload) -> dict[str, Any]:
|
| 569 |
+
target_id = payload.target_id or payload.item_id
|
| 570 |
+
if payload.project_id and target_id:
|
| 571 |
+
try:
|
| 572 |
+
project = project_manager.get(payload.project_id)
|
| 573 |
+
record = add_effect(project, target_id, payload.effect, payload.params)
|
| 574 |
+
project = project_manager.save(payload.project_id, project)
|
| 575 |
+
return {"effect": record, "project": project}
|
| 576 |
+
except KeyError as exc:
|
| 577 |
+
raise HTTPException(status_code=404, detail="Project or target item not found") from exc
|
| 578 |
+
data = {"task": payload.effect, "input": target_id, "params": payload.params, "callback_url": payload.callback_url}
|
| 579 |
+
job_id = job_manager.submit_task(
|
| 580 |
+
lambda task_id, log: StudioTaskProcessor(settings, log=log).ai_tool("auto_edit", data, task_id),
|
| 581 |
+
callback_url=payload.callback_url,
|
| 582 |
+
)
|
| 583 |
+
return _submission_response(job_id)
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
@api.post("/filter/apply", dependencies=[Depends(_verify_api_key)])
|
| 587 |
+
def filter_apply(payload: FilterApplyPayload) -> dict[str, Any]:
|
| 588 |
+
target_id = payload.target_id or payload.item_id
|
| 589 |
+
params = dict(payload.params)
|
| 590 |
+
if payload.lut:
|
| 591 |
+
params["lut"] = payload.lut
|
| 592 |
+
if payload.project_id and target_id:
|
| 593 |
+
try:
|
| 594 |
+
project = project_manager.get(payload.project_id)
|
| 595 |
+
record = add_filter(project, target_id, payload.filter, params)
|
| 596 |
+
project = project_manager.save(payload.project_id, project)
|
| 597 |
+
return {"filter": record, "project": project}
|
| 598 |
+
except KeyError as exc:
|
| 599 |
+
raise HTTPException(status_code=404, detail="Project or target item not found") from exc
|
| 600 |
+
job_id = job_manager.submit_task(
|
| 601 |
+
lambda task_id, log: StudioTaskProcessor(settings, log=log).ai_tool("auto_color_match", payload.model_dump(), task_id),
|
| 602 |
+
callback_url=payload.callback_url,
|
| 603 |
+
)
|
| 604 |
+
return _submission_response(job_id)
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
@api.post("/transition/add", dependencies=[Depends(_verify_api_key)])
|
| 608 |
+
def transition_add(payload: TransitionAddPayload) -> dict[str, Any]:
|
| 609 |
+
if payload.project_id and payload.from_item_id and payload.to_item_id:
|
| 610 |
+
try:
|
| 611 |
+
project = project_manager.get(payload.project_id)
|
| 612 |
+
record = add_transition(project, payload.from_item_id, payload.to_item_id, payload.transition, payload.duration)
|
| 613 |
+
project = project_manager.save(payload.project_id, project)
|
| 614 |
+
return {"transition": record, "project": project}
|
| 615 |
+
except KeyError as exc:
|
| 616 |
+
raise HTTPException(status_code=404, detail="Project not found") from exc
|
| 617 |
+
job_id = job_manager.submit_task(
|
| 618 |
+
lambda task_id, log: StudioTaskProcessor(settings, log=log).ai_tool("auto_edit", payload.model_dump(), task_id),
|
| 619 |
+
callback_url=payload.callback_url,
|
| 620 |
+
)
|
| 621 |
+
return _submission_response(job_id)
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
@api.post("/keyframe/add", dependencies=[Depends(_verify_api_key)])
|
| 625 |
+
def keyframe_add(payload: KeyframePayload) -> dict[str, Any]:
|
| 626 |
+
try:
|
| 627 |
+
project = project_manager.get(payload.project_id)
|
| 628 |
+
record = add_keyframe(project, payload.target_id, payload.property, payload.time, payload.value, payload.easing)
|
| 629 |
+
project = project_manager.save(payload.project_id, project)
|
| 630 |
+
return {"keyframe": record, "project": project}
|
| 631 |
+
except KeyError as exc:
|
| 632 |
+
raise HTTPException(status_code=404, detail="Project or target item not found") from exc
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
@api.post("/caption/generate", dependencies=[Depends(_verify_api_key)])
|
| 636 |
+
def caption_generate(payload: CaptionGeneratePayload) -> dict[str, str]:
|
| 637 |
+
data = payload.model_dump()
|
| 638 |
+
job_id = job_manager.submit_task(
|
| 639 |
+
lambda task_id, log: StudioTaskProcessor(settings, log=log).caption_generate(data, task_id),
|
| 640 |
+
callback_url=payload.callback_url,
|
| 641 |
+
)
|
| 642 |
+
return _submission_response(job_id)
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
@api.post("/music/generate", dependencies=[Depends(_verify_api_key)])
|
| 646 |
+
def music_generate(payload: GenerationPayload) -> dict[str, str]:
|
| 647 |
+
data = _generation_data(payload)
|
| 648 |
+
job_id = job_manager.submit_task(
|
| 649 |
+
lambda task_id, log: StudioTaskProcessor(settings, log=log).music_generate(data, task_id),
|
| 650 |
+
callback_url=payload.callback_url,
|
| 651 |
+
export_target=payload.export_target,
|
| 652 |
+
)
|
| 653 |
+
return _submission_response(job_id)
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
@api.post("/voice/generate", dependencies=[Depends(_verify_api_key)])
|
| 657 |
+
def voice_generate(payload: GenerationPayload) -> dict[str, str]:
|
| 658 |
+
data = _generation_data(payload)
|
| 659 |
+
job_id = job_manager.submit_task(
|
| 660 |
+
lambda task_id, log: StudioTaskProcessor(settings, log=log).voice_generate(data, task_id),
|
| 661 |
+
callback_url=payload.callback_url,
|
| 662 |
+
export_target=payload.export_target,
|
| 663 |
+
)
|
| 664 |
+
return _submission_response(job_id)
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
@api.post("/image/generate", dependencies=[Depends(_verify_api_key)])
|
| 668 |
+
def image_generate(payload: GenerationPayload) -> dict[str, str]:
|
| 669 |
+
data = _generation_data(payload)
|
| 670 |
+
job_id = job_manager.submit_task(
|
| 671 |
+
lambda task_id, log: StudioTaskProcessor(settings, log=log).image_generate(data, task_id),
|
| 672 |
+
callback_url=payload.callback_url,
|
| 673 |
+
export_target=payload.export_target,
|
| 674 |
+
)
|
| 675 |
+
return _submission_response(job_id)
|
| 676 |
+
|
| 677 |
+
|
| 678 |
+
@api.post("/video/generate", dependencies=[Depends(_verify_api_key)])
|
| 679 |
+
def video_generate(payload: GenerationPayload) -> dict[str, str]:
|
| 680 |
+
data = _generation_data(payload)
|
| 681 |
+
job_id = job_manager.submit_task(
|
| 682 |
+
lambda task_id, log: StudioTaskProcessor(settings, log=log).video_generate(data, task_id),
|
| 683 |
+
callback_url=payload.callback_url,
|
| 684 |
+
export_target=payload.export_target,
|
| 685 |
+
)
|
| 686 |
+
return _submission_response(job_id)
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
@api.post("/ai/{tool}", dependencies=[Depends(_verify_api_key)])
|
| 690 |
+
def ai_tool(tool: str, payload: AIToolPayload) -> dict[str, str]:
|
| 691 |
+
data = payload.model_dump()
|
| 692 |
+
if payload.model_extra:
|
| 693 |
+
data.update(payload.model_extra)
|
| 694 |
+
job_id = job_manager.submit_task(
|
| 695 |
+
lambda task_id, log: StudioTaskProcessor(settings, log=log).ai_tool(tool, data, task_id),
|
| 696 |
+
callback_url=payload.callback_url,
|
| 697 |
+
)
|
| 698 |
+
return _submission_response(job_id)
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
@api.post("/assistant/{tool}", dependencies=[Depends(_verify_api_key)])
|
| 702 |
+
def assistant_tool(tool: str, payload: AIToolPayload) -> dict[str, str]:
|
| 703 |
+
data = payload.model_dump()
|
| 704 |
+
if payload.model_extra:
|
| 705 |
+
data.update(payload.model_extra)
|
| 706 |
+
job_id = job_manager.submit_task(
|
| 707 |
+
lambda task_id, log: StudioTaskProcessor(settings, log=log).assistant_tool(tool, data, task_id),
|
| 708 |
+
callback_url=payload.callback_url,
|
| 709 |
+
)
|
| 710 |
+
return _submission_response(job_id)
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
@api.post("/render")
|
| 714 |
+
def render(payload: RenderPayload | AIReelsPayload) -> dict[str, str]:
|
| 715 |
+
if isinstance(payload, AIReelsPayload):
|
| 716 |
+
job_id = job_manager.submit_ai_reels(_ai_reels_request(payload))
|
| 717 |
+
else:
|
| 718 |
+
job_id = job_manager.submit_render(_render_request(payload))
|
| 719 |
+
return _submission_response(job_id)
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
|
| 723 |
+
@api.post("/render/ai-reels")
|
| 724 |
+
def render_ai_reels(payload: AIReelsPayload) -> dict[str, str]:
|
| 725 |
+
job_id = job_manager.submit_ai_reels(_ai_reels_request(payload))
|
| 726 |
+
return _submission_response(job_id)
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
@api.post("/render/batch")
|
| 730 |
+
def render_batch(payload: BatchPayload) -> dict[str, list[str]]:
|
| 731 |
+
job_ids = job_manager.submit_batch([_render_request(job) for job in payload.jobs])
|
| 732 |
+
return {"job_ids": job_ids}
|
| 733 |
+
|
| 734 |
+
|
| 735 |
+
@api.post("/automation/batch", dependencies=[Depends(_verify_api_key)])
|
| 736 |
+
def automation_batch(payload: BatchPayload) -> dict[str, list[str]]:
|
| 737 |
+
return render_batch(payload)
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
@api.post("/project/{project_id}/render", dependencies=[Depends(_verify_api_key)])
|
| 741 |
+
def project_render(project_id: str, payload: ProjectRenderPayload) -> dict[str, str]:
|
| 742 |
+
try:
|
| 743 |
+
project = project_manager.get(project_id)
|
| 744 |
+
request = _project_render_request(project, payload)
|
| 745 |
+
job_id = job_manager.submit_render(request)
|
| 746 |
+
return _submission_response(job_id)
|
| 747 |
+
except KeyError as exc:
|
| 748 |
+
raise HTTPException(status_code=404, detail="Project not found") from exc
|
| 749 |
+
except ValueError as exc:
|
| 750 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 751 |
+
|
| 752 |
+
|
| 753 |
+
@api.post("/render/upload")
|
| 754 |
+
async def render_upload(request_json: str = Form(...), files: list[UploadFile] = File(default=[])) -> dict[str, str]:
|
| 755 |
+
uploads = await _stage_uploads(files)
|
| 756 |
+
payload_data = _replace_upload_refs(json.loads(request_json), uploads)
|
| 757 |
+
payload = RenderPayload.model_validate(payload_data)
|
| 758 |
+
job_id = job_manager.submit_render(_render_request(payload))
|
| 759 |
+
return _submission_response(job_id)
|
| 760 |
+
|
| 761 |
+
|
| 762 |
+
@api.post("/render/ai-reels/upload")
|
| 763 |
+
async def render_ai_reels_upload(request_json: str = Form(...), files: list[UploadFile] = File(default=[])) -> dict[str, str]:
|
| 764 |
+
uploads = await _stage_uploads(files)
|
| 765 |
+
payload_data = _replace_upload_refs(json.loads(request_json), uploads)
|
| 766 |
+
payload = AIReelsPayload.model_validate(payload_data)
|
| 767 |
+
job_id = job_manager.submit_ai_reels(_ai_reels_request(payload))
|
| 768 |
+
return _submission_response(job_id)
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
@api.post("/assets/upload")
|
| 772 |
+
async def upload_assets(files: list[UploadFile] = File(...)) -> dict[str, list[UploadedAsset]]:
|
| 773 |
+
uploads = await _stage_uploads(files)
|
| 774 |
+
assets = [
|
| 775 |
+
UploadedAsset(filename=filename, path=path, reference=f"upload://{filename}", kind=_asset_kind(filename)) for filename, path in uploads.items()
|
| 776 |
+
]
|
| 777 |
+
return {"assets": [asset.model_dump() for asset in assets]}
|
| 778 |
+
|
| 779 |
+
|
| 780 |
+
@api.post("/upload")
|
| 781 |
+
async def upload(files: list[UploadFile] = File(...), expand_zip: bool = Form(default=True)) -> dict[str, list[UploadedAsset]]:
|
| 782 |
+
uploads = await _stage_uploads(files)
|
| 783 |
+
expanded: dict[str, str] = {}
|
| 784 |
+
for filename, path in uploads.items():
|
| 785 |
+
if expand_zip and filename.lower().endswith(".zip"):
|
| 786 |
+
expanded.update(_extract_zip(Path(path)))
|
| 787 |
+
else:
|
| 788 |
+
expanded[filename] = path
|
| 789 |
+
assets = [
|
| 790 |
+
UploadedAsset(filename=filename, path=path, reference=f"upload://{filename}", kind=_asset_kind(filename))
|
| 791 |
+
for filename, path in expanded.items()
|
| 792 |
+
]
|
| 793 |
+
return {"assets": [asset.model_dump() for asset in assets]}
|
| 794 |
+
|
| 795 |
+
|
| 796 |
+
@api.post("/ingest", dependencies=[Depends(_verify_api_key)])
|
| 797 |
+
def ingest(payload: IngestPayload) -> dict[str, str]:
|
| 798 |
+
job_id = job_manager.submit_task(
|
| 799 |
+
lambda task_id, log: PlatformProcessor(settings, log=log).ingest_sources([source.model_dump() for source in payload.sources], task_id),
|
| 800 |
+
callback_url=payload.callback_url,
|
| 801 |
+
)
|
| 802 |
+
return _submission_response(job_id)
|
| 803 |
+
|
| 804 |
+
|
| 805 |
+
@api.post("/analyze", dependencies=[Depends(_verify_api_key)])
|
| 806 |
+
def analyze(payload: AnalyzePayload) -> dict[str, str]:
|
| 807 |
+
job_id = job_manager.submit_task(
|
| 808 |
+
lambda task_id, log: PlatformProcessor(settings, log=log).analyze(
|
| 809 |
+
payload.media,
|
| 810 |
+
task_id,
|
| 811 |
+
transcript=payload.transcript,
|
| 812 |
+
platform=payload.platform,
|
| 813 |
+
),
|
| 814 |
+
callback_url=payload.callback_url,
|
| 815 |
+
)
|
| 816 |
+
return _submission_response(job_id)
|
| 817 |
+
|
| 818 |
+
|
| 819 |
+
@api.post("/clips", dependencies=[Depends(_verify_api_key)])
|
| 820 |
+
def clips(payload: ClipsPayload) -> dict[str, str]:
|
| 821 |
+
job_id = job_manager.submit_task(
|
| 822 |
+
lambda task_id, log: PlatformProcessor(settings, log=log).clips(payload.media, task_id, payload.clips),
|
| 823 |
+
callback_url=payload.callback_url,
|
| 824 |
+
)
|
| 825 |
+
return _submission_response(job_id)
|
| 826 |
+
|
| 827 |
+
|
| 828 |
+
@api.post("/thumbnail", dependencies=[Depends(_verify_api_key)])
|
| 829 |
+
def thumbnail(payload: ThumbnailPayload) -> dict[str, str]:
|
| 830 |
+
job_id = job_manager.submit_task(
|
| 831 |
+
lambda task_id, log: PlatformProcessor(settings, log=log).thumbnail(
|
| 832 |
+
payload.media,
|
| 833 |
+
task_id,
|
| 834 |
+
text=payload.text,
|
| 835 |
+
timestamp=payload.timestamp,
|
| 836 |
+
template=payload.template,
|
| 837 |
+
),
|
| 838 |
+
callback_url=payload.callback_url,
|
| 839 |
+
)
|
| 840 |
+
return _submission_response(job_id)
|
| 841 |
+
|
| 842 |
+
|
| 843 |
+
@api.post("/thumbnail/create", dependencies=[Depends(_verify_api_key)])
|
| 844 |
+
def thumbnail_create(payload: ThumbnailPayload) -> dict[str, str]:
|
| 845 |
+
return thumbnail(payload)
|
| 846 |
+
|
| 847 |
+
|
| 848 |
+
@api.post("/metadata", dependencies=[Depends(_verify_api_key)])
|
| 849 |
+
def metadata(payload: MetadataPayload) -> dict[str, str]:
|
| 850 |
+
job_id = job_manager.submit_task(
|
| 851 |
+
lambda task_id, log: PlatformProcessor(settings, log=log).metadata(
|
| 852 |
+
task_id,
|
| 853 |
+
topic=payload.topic,
|
| 854 |
+
transcript=payload.transcript,
|
| 855 |
+
platform=payload.platform,
|
| 856 |
+
),
|
| 857 |
+
callback_url=payload.callback_url,
|
| 858 |
+
)
|
| 859 |
+
return _submission_response(job_id)
|
| 860 |
+
|
| 861 |
+
|
| 862 |
+
@api.post("/publish", dependencies=[Depends(_verify_api_key)])
|
| 863 |
+
def publish(payload: PublishPayload) -> dict[str, str]:
|
| 864 |
+
job_id = job_manager.submit_task(
|
| 865 |
+
lambda task_id, log: PlatformProcessor(settings, log=log).publish(payload.model_dump(), task_id),
|
| 866 |
+
callback_url=payload.callback_url,
|
| 867 |
+
)
|
| 868 |
+
return _submission_response(job_id)
|
| 869 |
+
|
| 870 |
+
|
| 871 |
+
@api.post("/toolkit", dependencies=[Depends(_verify_api_key)])
|
| 872 |
+
def toolkit(payload: ToolkitPayload) -> dict[str, str]:
|
| 873 |
+
data = payload.model_dump()
|
| 874 |
+
if payload.model_extra:
|
| 875 |
+
data.update(payload.model_extra)
|
| 876 |
+
job_id = job_manager.submit_task(
|
| 877 |
+
lambda task_id, log: PlatformProcessor(settings, log=log).toolkit(data, task_id),
|
| 878 |
+
callback_url=payload.callback_url,
|
| 879 |
+
export_target=payload.export_target,
|
| 880 |
+
)
|
| 881 |
+
return _submission_response(job_id)
|
| 882 |
+
|
| 883 |
+
|
| 884 |
+
@api.post("/edit", dependencies=[Depends(_verify_api_key)])
|
| 885 |
+
def edit(payload: ToolkitPayload) -> dict[str, str]:
|
| 886 |
+
return toolkit(payload)
|
| 887 |
+
|
| 888 |
+
|
| 889 |
+
@api.post("/transcribe")
|
| 890 |
+
def transcribe(payload: TranscribePayload) -> dict:
|
| 891 |
+
from renderer.core.ingest import AssetIngestor
|
| 892 |
+
from renderer.core.utils import temp_workdir
|
| 893 |
+
|
| 894 |
+
try:
|
| 895 |
+
with temp_workdir(settings.temp_dir, "transcribe") as work:
|
| 896 |
+
audio = AssetIngestor(settings).resolve(payload.audio, Path(work) / "inputs", "audio")
|
| 897 |
+
return RenderEngine(settings).transcribe(
|
| 898 |
+
audio,
|
| 899 |
+
model_size=payload.model_size,
|
| 900 |
+
language=payload.language,
|
| 901 |
+
task=payload.task,
|
| 902 |
+
beam_size=payload.beam_size,
|
| 903 |
+
vad_filter=payload.vad_filter,
|
| 904 |
+
word_timestamps=payload.word_timestamps,
|
| 905 |
+
)
|
| 906 |
+
except Exception as exc:
|
| 907 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 908 |
+
|
| 909 |
+
|
| 910 |
+
@api.post("/subtitles")
|
| 911 |
+
def subtitles(payload: SubtitlePayload) -> FileResponse:
|
| 912 |
+
try:
|
| 913 |
+
events = [SubtitleEvent(float(event["start"]), float(event["end"]), str(event["text"])) for event in payload.events]
|
| 914 |
+
path = settings.temp_dir / f"subtitles_{uuid.uuid4().hex}.{payload.format}"
|
| 915 |
+
generator = SubtitleGenerator()
|
| 916 |
+
if payload.format == "ass":
|
| 917 |
+
generator.write_ass(events, path, payload.template)
|
| 918 |
+
media_type = "text/x-ssa"
|
| 919 |
+
else:
|
| 920 |
+
generator.write_srt(events, path)
|
| 921 |
+
media_type = "application/x-subrip"
|
| 922 |
+
return FileResponse(path, media_type=media_type, filename=path.name)
|
| 923 |
+
except Exception as exc:
|
| 924 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 925 |
+
|
| 926 |
+
|
| 927 |
+
@api.post("/scene-builder")
|
| 928 |
+
def scene_builder(payload: SceneBuildPayload) -> dict[str, Any]:
|
| 929 |
+
if not payload.assets:
|
| 930 |
+
raise HTTPException(status_code=400, detail="At least one asset is required")
|
| 931 |
+
style = get_creative_style(payload.creative_style)
|
| 932 |
+
words = payload.script.split()
|
| 933 |
+
total_duration = payload.duration or max(style.scene_duration * len(payload.assets), len(words) * 0.35, 3.0)
|
| 934 |
+
per_scene = total_duration / len(payload.assets)
|
| 935 |
+
captions = _split_words_for_assets(words, len(payload.assets))
|
| 936 |
+
scenes = [
|
| 937 |
+
{
|
| 938 |
+
"start": round(index * per_scene, 3),
|
| 939 |
+
"duration": round(per_scene, 3),
|
| 940 |
+
"media": asset,
|
| 941 |
+
"caption": captions[index] if index < len(captions) else "",
|
| 942 |
+
"transition": _style_transition(payload.transition, style.transition_sequence, index),
|
| 943 |
+
"effect": style.scene_effect_sequence[index % len(style.scene_effect_sequence)],
|
| 944 |
+
"background": "blur",
|
| 945 |
+
"layout": "fill",
|
| 946 |
+
}
|
| 947 |
+
for index, asset in enumerate(payload.assets)
|
| 948 |
+
]
|
| 949 |
+
return {"scenes": scenes, "creative_style": style.metadata_payload()}
|
| 950 |
+
|
| 951 |
+
|
| 952 |
+
@api.post("/transcribe/upload")
|
| 953 |
+
async def transcribe_upload(
|
| 954 |
+
file: UploadFile = File(...),
|
| 955 |
+
model_size: str | None = Form(default=None),
|
| 956 |
+
language: str | None = Form(default=None),
|
| 957 |
+
task: str = Form(default="transcribe"),
|
| 958 |
+
beam_size: int = Form(default=5),
|
| 959 |
+
vad_filter: bool = Form(default=True),
|
| 960 |
+
word_timestamps: bool = Form(default=True),
|
| 961 |
+
) -> dict:
|
| 962 |
+
try:
|
| 963 |
+
uploads = await _stage_uploads([file])
|
| 964 |
+
audio = next(iter(uploads.values()))
|
| 965 |
+
return RenderEngine(settings).transcribe(
|
| 966 |
+
audio,
|
| 967 |
+
model_size=model_size,
|
| 968 |
+
language=language,
|
| 969 |
+
task=task,
|
| 970 |
+
beam_size=beam_size,
|
| 971 |
+
vad_filter=vad_filter,
|
| 972 |
+
word_timestamps=word_timestamps,
|
| 973 |
+
)
|
| 974 |
+
except Exception as exc:
|
| 975 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 976 |
+
|
| 977 |
+
|
| 978 |
+
@api.get("/status")
|
| 979 |
+
def status_query(job_id: str = Query(...)) -> dict:
|
| 980 |
+
return status(job_id)
|
| 981 |
+
|
| 982 |
+
|
| 983 |
+
@api.get("/status/{job_id}")
|
| 984 |
+
def status(job_id: str) -> dict:
|
| 985 |
+
try:
|
| 986 |
+
return job_manager.get(job_id).__dict__
|
| 987 |
+
except KeyError as exc:
|
| 988 |
+
raise HTTPException(status_code=404, detail="Job not found") from exc
|
| 989 |
+
|
| 990 |
+
|
| 991 |
+
@api.post("/cancel/{job_id}")
|
| 992 |
+
def cancel(job_id: str) -> dict:
|
| 993 |
+
try:
|
| 994 |
+
return job_manager.cancel(job_id).__dict__
|
| 995 |
+
except KeyError as exc:
|
| 996 |
+
raise HTTPException(status_code=404, detail="Job not found") from exc
|
| 997 |
+
|
| 998 |
+
|
| 999 |
+
@api.post("/admin/cleanup")
|
| 1000 |
+
def cleanup(older_than_seconds: int | None = None) -> dict[str, int]:
|
| 1001 |
+
return job_manager.cleanup(older_than_seconds)
|
| 1002 |
+
|
| 1003 |
+
|
| 1004 |
+
@api.get("/download")
|
| 1005 |
+
def download_query(job_id: str = Query(...), token: str | None = Query(default=None)) -> FileResponse:
|
| 1006 |
+
return download(job_id, token)
|
| 1007 |
+
|
| 1008 |
+
|
| 1009 |
+
@api.get("/download/{job_id}")
|
| 1010 |
+
def download(job_id: str, token: str | None = Query(default=None)) -> FileResponse:
|
| 1011 |
+
try:
|
| 1012 |
+
record = job_manager.get(job_id)
|
| 1013 |
+
except KeyError as exc:
|
| 1014 |
+
raise HTTPException(status_code=404, detail="Job not found") from exc
|
| 1015 |
+
if record.state != "COMPLETED" or not record.output_path:
|
| 1016 |
+
raise HTTPException(status_code=409, detail=f"Job is {record.state}")
|
| 1017 |
+
if record.download_token and not verify_download_token(settings.signing_secret, job_id, token):
|
| 1018 |
+
raise HTTPException(status_code=403, detail="Invalid or missing download token")
|
| 1019 |
+
path = Path(record.output_path)
|
| 1020 |
+
if not path.exists():
|
| 1021 |
+
raise HTTPException(status_code=404, detail="Output file is missing")
|
| 1022 |
+
return FileResponse(path, media_type=_media_type(path), filename=path.name)
|
| 1023 |
+
|
| 1024 |
+
|
| 1025 |
+
@api.post("/inspect")
|
| 1026 |
+
def inspect_asset(path: str) -> dict:
|
| 1027 |
+
try:
|
| 1028 |
+
return RenderEngine(settings).inspect_asset(path)
|
| 1029 |
+
except Exception as exc:
|
| 1030 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 1031 |
+
|
| 1032 |
+
|
| 1033 |
+
def _render_request(payload: RenderPayload) -> RenderRequest:
|
| 1034 |
+
payload = RenderPayload.model_validate(apply_creative_style(apply_preset(payload.model_dump(exclude_unset=True))))
|
| 1035 |
+
request = RenderRequest(
|
| 1036 |
+
scenes=[Scene(**scene.model_dump()) for scene in payload.scenes],
|
| 1037 |
+
template=payload.template,
|
| 1038 |
+
preset=payload.preset,
|
| 1039 |
+
creative_style=payload.creative_style,
|
| 1040 |
+
platform=payload.platform,
|
| 1041 |
+
output_name=payload.output_name,
|
| 1042 |
+
voiceover=payload.voiceover,
|
| 1043 |
+
background_music=payload.background_music,
|
| 1044 |
+
music_volume=payload.music_volume,
|
| 1045 |
+
music_fade_in=payload.music_fade_in,
|
| 1046 |
+
music_fade_out=payload.music_fade_out,
|
| 1047 |
+
music_loop=payload.music_loop,
|
| 1048 |
+
music_start=payload.music_start,
|
| 1049 |
+
music_ducking=payload.music_ducking,
|
| 1050 |
+
voice_volume=payload.voice_volume,
|
| 1051 |
+
subtitle_format=payload.subtitle_format, # type: ignore[arg-type]
|
| 1052 |
+
auto_subtitles=payload.auto_subtitles,
|
| 1053 |
+
subtitle_language=payload.subtitle_language,
|
| 1054 |
+
whisper_model_size=payload.whisper_model_size,
|
| 1055 |
+
preview=payload.preview,
|
| 1056 |
+
audio_normalize=payload.audio_normalize,
|
| 1057 |
+
watermark=payload.watermark,
|
| 1058 |
+
watermark_position=payload.watermark_position,
|
| 1059 |
+
intro=payload.intro,
|
| 1060 |
+
outro=payload.outro,
|
| 1061 |
+
callback_url=payload.callback_url,
|
| 1062 |
+
export_target=payload.export_target,
|
| 1063 |
+
priority=payload.priority,
|
| 1064 |
+
scheduled_at=payload.scheduled_at,
|
| 1065 |
+
normalize=payload.normalize,
|
| 1066 |
+
metadata=payload.metadata,
|
| 1067 |
+
)
|
| 1068 |
+
Timeline(request.scenes)
|
| 1069 |
+
return request
|
| 1070 |
+
|
| 1071 |
+
|
| 1072 |
+
def _project_render_request(project: dict[str, Any], payload: ProjectRenderPayload) -> RenderRequest:
|
| 1073 |
+
scenes: list[dict[str, Any]] = []
|
| 1074 |
+
for tracks in project.get("timeline", {}).get("tracks", {}).values():
|
| 1075 |
+
for track in tracks:
|
| 1076 |
+
if track.get("hidden") or track.get("type") not in {"video", "overlay"}:
|
| 1077 |
+
continue
|
| 1078 |
+
for item in track.get("items", []):
|
| 1079 |
+
if item.get("hidden"):
|
| 1080 |
+
continue
|
| 1081 |
+
media = _item_media(project, item)
|
| 1082 |
+
if not media:
|
| 1083 |
+
continue
|
| 1084 |
+
scenes.append(
|
| 1085 |
+
{
|
| 1086 |
+
"start": float(item.get("start", 0.0)),
|
| 1087 |
+
"duration": float(item.get("duration", 1.0)),
|
| 1088 |
+
"media": media,
|
| 1089 |
+
"caption": item.get("caption") or item.get("text") or "",
|
| 1090 |
+
"transition": item.get("transition", "fade"),
|
| 1091 |
+
"background": item.get("background", "blur"),
|
| 1092 |
+
"layout": item.get("layout", "fill"),
|
| 1093 |
+
"effect": _first_named(item.get("effects")),
|
| 1094 |
+
}
|
| 1095 |
+
)
|
| 1096 |
+
if not scenes:
|
| 1097 |
+
raise ValueError("Project has no renderable video or overlay timeline items")
|
| 1098 |
+
export_settings = project.get("export_settings", {})
|
| 1099 |
+
data = {
|
| 1100 |
+
"scenes": sorted(scenes, key=lambda scene: scene["start"]),
|
| 1101 |
+
"template": payload.template or export_settings.get("template", "tiktok_classic"),
|
| 1102 |
+
"preset": payload.preset,
|
| 1103 |
+
"creative_style": payload.creative_style or project.get("metadata", {}).get("creative_style"),
|
| 1104 |
+
"platform": payload.platform or export_settings.get("platform"),
|
| 1105 |
+
"output_name": payload.output_name,
|
| 1106 |
+
"callback_url": payload.callback_url,
|
| 1107 |
+
"export_target": payload.export_target,
|
| 1108 |
+
"preview": payload.preview,
|
| 1109 |
+
"normalize": payload.normalize,
|
| 1110 |
+
"metadata": project.get("metadata", {}) | payload.metadata | {"project_id": project.get("id"), "project_name": project.get("name")},
|
| 1111 |
+
}
|
| 1112 |
+
return _render_request(RenderPayload.model_validate(data))
|
| 1113 |
+
|
| 1114 |
+
|
| 1115 |
+
def _ai_reels_request(payload: AIReelsPayload) -> AIReelsRequest:
|
| 1116 |
+
payload = AIReelsPayload.model_validate(apply_creative_style(apply_preset(payload.model_dump(exclude_unset=True))))
|
| 1117 |
+
return AIReelsRequest(**payload.model_dump())
|
| 1118 |
+
|
| 1119 |
+
|
| 1120 |
+
def _generation_data(payload: GenerationPayload) -> dict[str, Any]:
|
| 1121 |
+
data = payload.model_dump()
|
| 1122 |
+
if payload.model_extra:
|
| 1123 |
+
data.update(payload.model_extra)
|
| 1124 |
+
params = data.pop("params", {}) or {}
|
| 1125 |
+
if isinstance(params, dict):
|
| 1126 |
+
data.update(params)
|
| 1127 |
+
return data
|
| 1128 |
+
|
| 1129 |
+
|
| 1130 |
+
def _item_media(project: dict[str, Any], item: dict[str, Any]) -> str | None:
|
| 1131 |
+
direct = item.get("media") or item.get("path") or item.get("source")
|
| 1132 |
+
if direct:
|
| 1133 |
+
return str(direct)
|
| 1134 |
+
asset_id = item.get("asset_id")
|
| 1135 |
+
if not asset_id:
|
| 1136 |
+
return None
|
| 1137 |
+
for asset in project.get("assets", []):
|
| 1138 |
+
if asset.get("id") == asset_id:
|
| 1139 |
+
return str(asset.get("path") or asset.get("url") or asset.get("source") or "")
|
| 1140 |
+
return None
|
| 1141 |
+
|
| 1142 |
+
|
| 1143 |
+
def _first_named(records: Any) -> str | None:
|
| 1144 |
+
if not isinstance(records, list) or not records:
|
| 1145 |
+
return None
|
| 1146 |
+
first = records[0]
|
| 1147 |
+
if isinstance(first, dict):
|
| 1148 |
+
return first.get("effect") or first.get("name")
|
| 1149 |
+
return str(first)
|
| 1150 |
+
|
| 1151 |
+
|
| 1152 |
+
async def _stage_uploads(files: list[UploadFile]) -> dict[str, str]:
|
| 1153 |
+
upload_dir = settings.temp_dir / "uploads" / uuid.uuid4().hex
|
| 1154 |
+
staged: dict[str, str] = {}
|
| 1155 |
+
total_bytes = 0
|
| 1156 |
+
for upload in files:
|
| 1157 |
+
filename = safe_filename(upload.filename or f"asset_{len(staged)}")
|
| 1158 |
+
if not _allowed_upload(filename):
|
| 1159 |
+
raise HTTPException(status_code=415, detail=f"Unsupported asset type: {filename}")
|
| 1160 |
+
target = upload_dir / filename
|
| 1161 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 1162 |
+
with target.open("wb") as output:
|
| 1163 |
+
while True:
|
| 1164 |
+
chunk = await upload.read(1024 * 1024)
|
| 1165 |
+
if not chunk:
|
| 1166 |
+
break
|
| 1167 |
+
total_bytes += len(chunk)
|
| 1168 |
+
if total_bytes > settings.max_download_bytes:
|
| 1169 |
+
shutil.rmtree(upload_dir, ignore_errors=True)
|
| 1170 |
+
raise HTTPException(status_code=413, detail="Uploaded assets exceed MAX_DOWNLOAD_BYTES")
|
| 1171 |
+
output.write(chunk)
|
| 1172 |
+
staged[filename] = str(stage_upload(target, upload_dir, filename))
|
| 1173 |
+
return staged
|
| 1174 |
+
|
| 1175 |
+
|
| 1176 |
+
def _monitor_payload() -> dict[str, Any]:
|
| 1177 |
+
disk = shutil.disk_usage(settings.base_dir)
|
| 1178 |
+
return {
|
| 1179 |
+
"health": "ok",
|
| 1180 |
+
"queue": job_manager.summary(),
|
| 1181 |
+
"workers": {
|
| 1182 |
+
"max_workers": settings.max_workers,
|
| 1183 |
+
"ffmpeg_timeout_seconds": settings.ffmpeg_timeout_seconds,
|
| 1184 |
+
"whisper_model_size": settings.whisper_model_size,
|
| 1185 |
+
"whisper_device": settings.whisper_device,
|
| 1186 |
+
},
|
| 1187 |
+
"disk": {
|
| 1188 |
+
"total_bytes": disk.total,
|
| 1189 |
+
"used_bytes": disk.used,
|
| 1190 |
+
"free_bytes": disk.free,
|
| 1191 |
+
},
|
| 1192 |
+
"directories": {
|
| 1193 |
+
"temp": str(settings.temp_dir),
|
| 1194 |
+
"exports": str(settings.exports_dir),
|
| 1195 |
+
"jobs": str(settings.jobs_dir),
|
| 1196 |
+
"storage": str(settings.storage_dir),
|
| 1197 |
+
},
|
| 1198 |
+
}
|
| 1199 |
+
|
| 1200 |
+
|
| 1201 |
+
def _extract_zip(path: Path) -> dict[str, str]:
|
| 1202 |
+
output_dir = settings.temp_dir / "uploads" / f"zip_{uuid.uuid4().hex}"
|
| 1203 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 1204 |
+
extracted: dict[str, str] = {}
|
| 1205 |
+
with zipfile.ZipFile(path) as archive:
|
| 1206 |
+
for member in archive.infolist():
|
| 1207 |
+
if member.is_dir():
|
| 1208 |
+
continue
|
| 1209 |
+
name = safe_filename(Path(member.filename).name)
|
| 1210 |
+
if not _allowed_upload(name):
|
| 1211 |
+
continue
|
| 1212 |
+
target = output_dir / name
|
| 1213 |
+
resolved = target.resolve()
|
| 1214 |
+
if output_dir.resolve() not in resolved.parents and resolved != output_dir.resolve():
|
| 1215 |
+
raise HTTPException(status_code=400, detail="Unsafe ZIP member path")
|
| 1216 |
+
with archive.open(member) as source, target.open("wb") as destination:
|
| 1217 |
+
shutil.copyfileobj(source, destination)
|
| 1218 |
+
extracted[name] = str(target)
|
| 1219 |
+
return extracted
|
| 1220 |
+
|
| 1221 |
+
|
| 1222 |
+
def _asset_kind(filename: str) -> str:
|
| 1223 |
+
suffix = Path(filename).suffix.lower()
|
| 1224 |
+
if suffix in {".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".gif"}:
|
| 1225 |
+
return "video"
|
| 1226 |
+
if suffix in {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}:
|
| 1227 |
+
return "audio"
|
| 1228 |
+
if suffix in {".jpg", ".jpeg", ".png", ".webp", ".avif"}:
|
| 1229 |
+
return "image"
|
| 1230 |
+
if suffix in {".srt", ".ass", ".vtt"}:
|
| 1231 |
+
return "subtitle"
|
| 1232 |
+
return "other"
|
| 1233 |
+
|
| 1234 |
+
|
| 1235 |
+
def _allowed_upload(filename: str) -> bool:
|
| 1236 |
+
suffix = Path(filename).suffix.lower()
|
| 1237 |
+
return suffix in {
|
| 1238 |
+
".mp4",
|
| 1239 |
+
".mov",
|
| 1240 |
+
".m4v",
|
| 1241 |
+
".webm",
|
| 1242 |
+
".mkv",
|
| 1243 |
+
".avi",
|
| 1244 |
+
".gif",
|
| 1245 |
+
".mp3",
|
| 1246 |
+
".wav",
|
| 1247 |
+
".m4a",
|
| 1248 |
+
".aac",
|
| 1249 |
+
".flac",
|
| 1250 |
+
".ogg",
|
| 1251 |
+
".jpg",
|
| 1252 |
+
".jpeg",
|
| 1253 |
+
".png",
|
| 1254 |
+
".webp",
|
| 1255 |
+
".avif",
|
| 1256 |
+
".srt",
|
| 1257 |
+
".ass",
|
| 1258 |
+
".vtt",
|
| 1259 |
+
".zip",
|
| 1260 |
+
}
|
| 1261 |
+
|
| 1262 |
+
|
| 1263 |
+
def _media_type(path: Path) -> str:
|
| 1264 |
+
suffix = path.suffix.lower()
|
| 1265 |
+
if suffix == ".json":
|
| 1266 |
+
return "application/json"
|
| 1267 |
+
if suffix == ".zip":
|
| 1268 |
+
return "application/zip"
|
| 1269 |
+
if suffix == ".jpg" or suffix == ".jpeg":
|
| 1270 |
+
return "image/jpeg"
|
| 1271 |
+
if suffix == ".png":
|
| 1272 |
+
return "image/png"
|
| 1273 |
+
if suffix == ".gif":
|
| 1274 |
+
return "image/gif"
|
| 1275 |
+
if suffix == ".mp3":
|
| 1276 |
+
return "audio/mpeg"
|
| 1277 |
+
if suffix in {".srt", ".vtt", ".ass"}:
|
| 1278 |
+
return "text/plain"
|
| 1279 |
+
return "video/mp4"
|
| 1280 |
+
|
| 1281 |
+
|
| 1282 |
+
def _replace_upload_refs(value: Any, uploads: dict[str, str]) -> Any:
|
| 1283 |
+
if isinstance(value, dict):
|
| 1284 |
+
return {key: _replace_upload_refs(item, uploads) for key, item in value.items()}
|
| 1285 |
+
if isinstance(value, list):
|
| 1286 |
+
return [_replace_upload_refs(item, uploads) for item in value]
|
| 1287 |
+
if isinstance(value, str) and value.startswith("upload://"):
|
| 1288 |
+
name = safe_filename(value.removeprefix("upload://"))
|
| 1289 |
+
if name not in uploads:
|
| 1290 |
+
raise HTTPException(status_code=400, detail=f"Missing uploaded file for reference: upload://{name}")
|
| 1291 |
+
return uploads[name]
|
| 1292 |
+
return value
|
| 1293 |
+
|
| 1294 |
+
|
| 1295 |
+
def _split_words_for_assets(words: list[str], count: int) -> list[str]:
|
| 1296 |
+
if count <= 0:
|
| 1297 |
+
return []
|
| 1298 |
+
if not words:
|
| 1299 |
+
return [""] * count
|
| 1300 |
+
base, remainder = divmod(len(words), count)
|
| 1301 |
+
captions: list[str] = []
|
| 1302 |
+
cursor = 0
|
| 1303 |
+
for index in range(count):
|
| 1304 |
+
size = base + (1 if index < remainder else 0)
|
| 1305 |
+
size = max(1, size)
|
| 1306 |
+
captions.append(" ".join(words[cursor : cursor + size]))
|
| 1307 |
+
cursor += size
|
| 1308 |
+
return captions
|
| 1309 |
+
|
| 1310 |
+
|
| 1311 |
+
def _style_transition(requested: str, sequence: tuple[str, ...], index: int) -> str:
|
| 1312 |
+
if requested and requested != "fade":
|
| 1313 |
+
return requested
|
| 1314 |
+
return sequence[index % len(sequence)] if sequence else "fade"
|
services/render_engine/app.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import gradio as gr
|
| 8 |
+
import uvicorn
|
| 9 |
+
|
| 10 |
+
from api import api, job_manager, project_manager, settings
|
| 11 |
+
from renderer import RenderEngine
|
| 12 |
+
from renderer.core.models import AIReelsRequest
|
| 13 |
+
from renderer.scenes import Timeline
|
| 14 |
+
from renderer.studio import capability_catalog
|
| 15 |
+
from renderer.templates import apply_creative_style, apply_preset, list_creative_styles, list_platform_profiles, list_templates
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def create_dashboard() -> gr.Blocks:
|
| 19 |
+
with gr.Blocks(title="Ava2lon Studio AI") as dashboard:
|
| 20 |
+
gr.Markdown(
|
| 21 |
+
"# Ava2lon Studio AI\n"
|
| 22 |
+
"CPU-first CapCut-class video automation studio with REST API parity, async jobs, webhooks, and project JSON."
|
| 23 |
+
)
|
| 24 |
+
with gr.Tab("Projects"):
|
| 25 |
+
project_name = gr.Textbox(label="Project name", value="Untitled Ava2lon Project")
|
| 26 |
+
project_metadata = gr.Textbox(label="Metadata JSON", lines=5, value=json.dumps({"platform": "tiktok"}, indent=2))
|
| 27 |
+
project_create = gr.Button("Create Project", variant="primary")
|
| 28 |
+
project_list = gr.Button("Refresh Projects")
|
| 29 |
+
project_output = gr.JSON(label="Projects")
|
| 30 |
+
project_create.click(fn=_create_project, inputs=[project_name, project_metadata], outputs=project_output)
|
| 31 |
+
project_list.click(fn=_list_projects, outputs=project_output)
|
| 32 |
+
|
| 33 |
+
with gr.Tab("Assets"):
|
| 34 |
+
gr.Markdown("Use `/upload` or `/assets/upload` for multipart assets, then attach them to a project with `/project/assets/add`.")
|
| 35 |
+
asset_project_id = gr.Textbox(label="Project ID")
|
| 36 |
+
asset_json = gr.Textbox(label="Asset JSON", lines=6, value=json.dumps({"path": "clip.mp4", "kind": "video"}, indent=2))
|
| 37 |
+
asset_button = gr.Button("Attach Asset", variant="primary")
|
| 38 |
+
asset_output = gr.JSON(label="Project")
|
| 39 |
+
asset_button.click(fn=_add_project_asset, inputs=[asset_project_id, asset_json], outputs=asset_output)
|
| 40 |
+
|
| 41 |
+
with gr.Tab("Timeline"):
|
| 42 |
+
timeline_project_id = gr.Textbox(label="Project ID")
|
| 43 |
+
timeline_track_type = gr.Dropdown(choices=["video", "audio", "text", "overlay", "sticker", "subtitle"], value="video", label="Track type")
|
| 44 |
+
timeline_item = gr.Textbox(
|
| 45 |
+
label="Timeline item JSON",
|
| 46 |
+
lines=8,
|
| 47 |
+
value=json.dumps({"media": "clip.mp4", "start": 0, "duration": 5, "caption": "Hook"}, indent=2),
|
| 48 |
+
)
|
| 49 |
+
timeline_add_button = gr.Button("Add To Timeline", variant="primary")
|
| 50 |
+
timeline_output = gr.JSON(label="Project")
|
| 51 |
+
timeline_add_button.click(fn=_timeline_add, inputs=[timeline_project_id, timeline_track_type, timeline_item], outputs=timeline_output)
|
| 52 |
+
|
| 53 |
+
timeline_operation_json = gr.Textbox(
|
| 54 |
+
label="Operation JSON",
|
| 55 |
+
lines=8,
|
| 56 |
+
value=json.dumps({"operation": "split", "item_id": "clip_123", "params": {"offset": 2.5}}, indent=2),
|
| 57 |
+
)
|
| 58 |
+
timeline_operation_button = gr.Button("Apply Operation")
|
| 59 |
+
timeline_operation_button.click(fn=_timeline_operation, inputs=[timeline_project_id, timeline_operation_json], outputs=timeline_output)
|
| 60 |
+
|
| 61 |
+
with gr.Tab("Templates"):
|
| 62 |
+
template_button = gr.Button("Load Template Catalog")
|
| 63 |
+
template_output = gr.JSON(label="Templates")
|
| 64 |
+
template_button.click(fn=lambda: _catalog_section("templates"), outputs=template_output)
|
| 65 |
+
|
| 66 |
+
with gr.Tab("Effects"):
|
| 67 |
+
effect_button = gr.Button("Load Effect Catalog")
|
| 68 |
+
effect_output = gr.JSON(label="Effects")
|
| 69 |
+
effect_button.click(fn=lambda: _catalog_section("effects"), outputs=effect_output)
|
| 70 |
+
|
| 71 |
+
with gr.Tab("Filters"):
|
| 72 |
+
filter_button = gr.Button("Load Filter Catalog")
|
| 73 |
+
filter_output = gr.JSON(label="Filters")
|
| 74 |
+
filter_button.click(fn=lambda: _catalog_section("filters"), outputs=filter_output)
|
| 75 |
+
|
| 76 |
+
with gr.Tab("Captions"):
|
| 77 |
+
caption_text = gr.Textbox(label="Caption source text", lines=6)
|
| 78 |
+
caption_button = gr.Button("Submit Caption Job", variant="primary")
|
| 79 |
+
caption_output = gr.JSON(label="Caption Job")
|
| 80 |
+
caption_button.click(fn=_submit_caption_generation, inputs=caption_text, outputs=caption_output)
|
| 81 |
+
|
| 82 |
+
with gr.Tab("Audio"):
|
| 83 |
+
audio_button = gr.Button("Load Audio Catalog")
|
| 84 |
+
audio_output = gr.JSON(label="Audio")
|
| 85 |
+
audio_button.click(fn=lambda: {"audio": capability_catalog()["audio"], "music": capability_catalog()["music_generator"]}, outputs=audio_output)
|
| 86 |
+
|
| 87 |
+
with gr.Tab("AI Tools"):
|
| 88 |
+
ai_tool = gr.Dropdown(choices=capability_catalog()["ai_editing"], value="auto_viral_score", label="AI tool")
|
| 89 |
+
ai_payload = gr.Textbox(label="AI payload JSON", lines=8, value=json.dumps({"platform": "tiktok", "text": "A strong opening hook"}, indent=2))
|
| 90 |
+
ai_button = gr.Button("Submit AI Tool", variant="primary")
|
| 91 |
+
ai_output = gr.JSON(label="AI Job")
|
| 92 |
+
ai_button.click(fn=_submit_ai_tool, inputs=[ai_tool, ai_payload], outputs=ai_output)
|
| 93 |
+
|
| 94 |
+
with gr.Tab("Rendering"):
|
| 95 |
+
render_json = gr.Textbox(
|
| 96 |
+
label="Render JSON",
|
| 97 |
+
lines=14,
|
| 98 |
+
value="",
|
| 99 |
+
placeholder="Paste a production render request JSON object with absolute or uploaded asset paths.",
|
| 100 |
+
)
|
| 101 |
+
render_button = gr.Button("Submit Render", variant="primary")
|
| 102 |
+
render_output = gr.JSON(label="Submission")
|
| 103 |
+
render_button.click(fn=_submit_render_json, inputs=render_json, outputs=render_output)
|
| 104 |
+
|
| 105 |
+
with gr.Tab("AI Reels"):
|
| 106 |
+
script = gr.Textbox(label="Script", lines=6)
|
| 107 |
+
voiceover = gr.File(label="Voiceover", file_types=["audio"], type="filepath")
|
| 108 |
+
assets = gr.File(label="Assets", file_count="multiple", type="filepath")
|
| 109 |
+
template = gr.Dropdown(choices=list_templates(), value="tiktok_classic", label="Caption Template")
|
| 110 |
+
creative_style = gr.Dropdown(choices=list_creative_styles(), value="viral_shorts", label="Creative Style")
|
| 111 |
+
platform = gr.Dropdown(choices=list_platform_profiles(), value="tiktok", label="Platform")
|
| 112 |
+
music = gr.File(label="Background Music", file_types=["audio"], type="filepath")
|
| 113 |
+
ai_button = gr.Button("Submit AI Reel", variant="primary")
|
| 114 |
+
ai_output = gr.JSON(label="Submission")
|
| 115 |
+
ai_button.click(fn=_submit_ai_reel, inputs=[script, voiceover, assets, template, creative_style, platform, music], outputs=ai_output)
|
| 116 |
+
|
| 117 |
+
with gr.Tab("Batch Render"):
|
| 118 |
+
batch_json = gr.Textbox(label="Batch JSON", lines=14, value=json.dumps({"jobs": []}, indent=2))
|
| 119 |
+
batch_button = gr.Button("Submit Batch", variant="primary")
|
| 120 |
+
batch_output = gr.JSON(label="Batch Submission")
|
| 121 |
+
batch_button.click(fn=_submit_batch_json, inputs=batch_json, outputs=batch_output)
|
| 122 |
+
|
| 123 |
+
with gr.Tab("Job Status"):
|
| 124 |
+
status_job_id = gr.Textbox(label="Job ID")
|
| 125 |
+
status_button = gr.Button("Refresh")
|
| 126 |
+
status_output = gr.JSON(label="Status")
|
| 127 |
+
status_button.click(fn=_job_status, inputs=status_job_id, outputs=status_output)
|
| 128 |
+
|
| 129 |
+
with gr.Tab("Logs"):
|
| 130 |
+
logs_job_id = gr.Textbox(label="Job ID")
|
| 131 |
+
logs_button = gr.Button("Load Logs")
|
| 132 |
+
logs_output = gr.Textbox(label="Logs", lines=20)
|
| 133 |
+
logs_button.click(fn=_job_logs, inputs=logs_job_id, outputs=logs_output)
|
| 134 |
+
|
| 135 |
+
with gr.Tab("Downloads"):
|
| 136 |
+
download_job_id = gr.Textbox(label="Job ID")
|
| 137 |
+
download_button = gr.Button("Get Output")
|
| 138 |
+
download_output = gr.File(label="Rendered Video")
|
| 139 |
+
download_button.click(fn=_download_path, inputs=download_job_id, outputs=download_output)
|
| 140 |
+
|
| 141 |
+
with gr.Tab("Transcribe"):
|
| 142 |
+
transcribe_audio = gr.File(label="Audio or Video", file_types=["audio", "video"], type="filepath")
|
| 143 |
+
transcribe_model = gr.Dropdown(
|
| 144 |
+
choices=["tiny", "base", "small", "medium", "large-v3"],
|
| 145 |
+
value=settings.whisper_model_size,
|
| 146 |
+
label="Whisper Model",
|
| 147 |
+
)
|
| 148 |
+
transcribe_language = gr.Textbox(label="Language", placeholder="Optional ISO code, e.g. en")
|
| 149 |
+
transcribe_button = gr.Button("Transcribe", variant="primary")
|
| 150 |
+
transcribe_output = gr.JSON(label="Transcript")
|
| 151 |
+
transcribe_button.click(
|
| 152 |
+
fn=_transcribe_file,
|
| 153 |
+
inputs=[transcribe_audio, transcribe_model, transcribe_language],
|
| 154 |
+
outputs=transcribe_output,
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
with gr.Tab("Asset Inspector"):
|
| 158 |
+
asset_path = gr.Textbox(label="Asset path")
|
| 159 |
+
inspect_button = gr.Button("Inspect")
|
| 160 |
+
inspect_output = gr.JSON(label="Metadata")
|
| 161 |
+
inspect_button.click(fn=_inspect_asset, inputs=asset_path, outputs=inspect_output)
|
| 162 |
+
|
| 163 |
+
with gr.Tab("AI Analysis"):
|
| 164 |
+
analysis_media = gr.Textbox(label="Media URL or path")
|
| 165 |
+
analysis_transcript = gr.Textbox(label="Transcript", lines=5)
|
| 166 |
+
analysis_platform = gr.Dropdown(choices=list_platform_profiles(), value="tiktok", label="Target Platform")
|
| 167 |
+
analysis_button = gr.Button("Submit Analysis", variant="primary")
|
| 168 |
+
analysis_output = gr.JSON(label="Analysis Job")
|
| 169 |
+
analysis_button.click(
|
| 170 |
+
fn=_submit_analysis,
|
| 171 |
+
inputs=[analysis_media, analysis_transcript, analysis_platform],
|
| 172 |
+
outputs=analysis_output,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
with gr.Tab("Clip Generator"):
|
| 176 |
+
clip_media = gr.Textbox(label="Media URL or path")
|
| 177 |
+
clip_json = gr.Textbox(label="Clip JSON", lines=6, value=json.dumps([{"start": 0, "end": 8}], indent=2))
|
| 178 |
+
clip_button = gr.Button("Generate Clips", variant="primary")
|
| 179 |
+
clip_output = gr.JSON(label="Clip Job")
|
| 180 |
+
clip_button.click(fn=_submit_clips, inputs=[clip_media, clip_json], outputs=clip_output)
|
| 181 |
+
|
| 182 |
+
with gr.Tab("Metadata"):
|
| 183 |
+
metadata_topic = gr.Textbox(label="Topic or transcript", lines=5)
|
| 184 |
+
metadata_platform = gr.Dropdown(choices=list_platform_profiles(), value="tiktok", label="Platform")
|
| 185 |
+
metadata_button = gr.Button("Generate Metadata", variant="primary")
|
| 186 |
+
metadata_output = gr.JSON(label="Metadata Job")
|
| 187 |
+
metadata_button.click(fn=_submit_metadata, inputs=[metadata_topic, metadata_platform], outputs=metadata_output)
|
| 188 |
+
|
| 189 |
+
with gr.Tab("Publishing"):
|
| 190 |
+
publish_media = gr.Textbox(label="Media URL or rendered output path")
|
| 191 |
+
publish_title = gr.Textbox(label="Title")
|
| 192 |
+
publish_platforms = gr.Textbox(label="Platforms", value="youtube,tiktok,instagram")
|
| 193 |
+
publish_button = gr.Button("Create Publish Draft", variant="primary")
|
| 194 |
+
publish_output = gr.JSON(label="Publish Job")
|
| 195 |
+
publish_button.click(fn=_submit_publish, inputs=[publish_media, publish_title, publish_platforms], outputs=publish_output)
|
| 196 |
+
|
| 197 |
+
with gr.Tab("Settings"):
|
| 198 |
+
settings_button = gr.Button("Load Settings")
|
| 199 |
+
settings_output = gr.JSON(label="Settings")
|
| 200 |
+
settings_button.click(fn=_settings_payload, outputs=settings_output)
|
| 201 |
+
|
| 202 |
+
with gr.Tab("Queue Monitor"):
|
| 203 |
+
queue_button = gr.Button("Refresh Queue")
|
| 204 |
+
queue_output = gr.JSON(label="Queue")
|
| 205 |
+
queue_button.click(fn=_queue_status, outputs=queue_output)
|
| 206 |
+
|
| 207 |
+
return dashboard
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _submit_render_json(payload: str) -> dict[str, Any]:
|
| 211 |
+
data = apply_creative_style(apply_preset(json.loads(payload)))
|
| 212 |
+
request = Timeline.request_from_payload(data)
|
| 213 |
+
job_id = job_manager.submit_render(request)
|
| 214 |
+
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _create_project(name: str, metadata_json: str) -> dict[str, Any]:
|
| 218 |
+
metadata = json.loads(metadata_json or "{}")
|
| 219 |
+
return {"project": project_manager.create(name, metadata=metadata)}
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _list_projects() -> dict[str, Any]:
|
| 223 |
+
return {"projects": project_manager.list()}
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _add_project_asset(project_id: str, asset_json: str) -> dict[str, Any]:
|
| 227 |
+
return {"project": project_manager.add_asset(project_id, json.loads(asset_json or "{}"))}
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _timeline_add(project_id: str, track_type: str, item_json: str) -> dict[str, Any]:
|
| 231 |
+
return {"project": project_manager.add_to_timeline(project_id, json.loads(item_json or "{}"), track_type=track_type)}
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _timeline_operation(project_id: str, operation_json: str) -> dict[str, Any]:
|
| 235 |
+
data = json.loads(operation_json or "{}")
|
| 236 |
+
return {
|
| 237 |
+
"project": project_manager.timeline_operation(
|
| 238 |
+
project_id,
|
| 239 |
+
data.get("operation", "drag"),
|
| 240 |
+
item_id=data.get("item_id"),
|
| 241 |
+
params=data.get("params", {}),
|
| 242 |
+
)
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _catalog_section(section: str) -> dict[str, Any]:
|
| 247 |
+
catalog = capability_catalog()
|
| 248 |
+
return {section: catalog.get(section)}
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _submit_caption_generation(text: str) -> dict[str, Any]:
|
| 252 |
+
from renderer.studio import StudioTaskProcessor
|
| 253 |
+
|
| 254 |
+
job_id = job_manager.submit_task(lambda task_id, log: StudioTaskProcessor(settings, log=log).caption_generate({"text": text}, task_id))
|
| 255 |
+
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _submit_ai_tool(tool: str, payload: str) -> dict[str, Any]:
|
| 259 |
+
from renderer.studio import StudioTaskProcessor
|
| 260 |
+
|
| 261 |
+
data = json.loads(payload or "{}")
|
| 262 |
+
job_id = job_manager.submit_task(lambda task_id, log: StudioTaskProcessor(settings, log=log).ai_tool(tool, data, task_id))
|
| 263 |
+
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def _submit_batch_json(payload: str) -> dict[str, Any]:
|
| 267 |
+
data = json.loads(payload)
|
| 268 |
+
requests = [Timeline.request_from_payload(apply_creative_style(apply_preset(job))) for job in data.get("jobs", [])]
|
| 269 |
+
return {"job_ids": job_manager.submit_batch(requests)}
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _submit_ai_reel(
|
| 273 |
+
script: str,
|
| 274 |
+
voiceover: str,
|
| 275 |
+
assets: list[str],
|
| 276 |
+
template: str,
|
| 277 |
+
creative_style: str,
|
| 278 |
+
platform: str,
|
| 279 |
+
music: str | None,
|
| 280 |
+
) -> dict[str, Any]:
|
| 281 |
+
request = AIReelsRequest(
|
| 282 |
+
script=script,
|
| 283 |
+
voiceover=voiceover,
|
| 284 |
+
assets=assets or [],
|
| 285 |
+
template=template,
|
| 286 |
+
creative_style=creative_style,
|
| 287 |
+
platform=platform,
|
| 288 |
+
background_music=music,
|
| 289 |
+
)
|
| 290 |
+
job_id = job_manager.submit_ai_reels(request)
|
| 291 |
+
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _job_status(job_id: str) -> dict[str, Any]:
|
| 295 |
+
return job_manager.get(job_id).__dict__
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def _job_logs(job_id: str) -> str:
|
| 299 |
+
return "\n\n".join(job_manager.get(job_id).logs)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def _download_path(job_id: str) -> str | None:
|
| 303 |
+
record = job_manager.get(job_id)
|
| 304 |
+
if record.state != "COMPLETED":
|
| 305 |
+
return None
|
| 306 |
+
return record.output_path
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def _inspect_asset(path: str) -> dict[str, Any]:
|
| 310 |
+
return RenderEngine(settings).inspect_asset(path)
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def _transcribe_file(path: str, model_size: str, language: str) -> dict[str, Any]:
|
| 314 |
+
return RenderEngine(settings).transcribe(
|
| 315 |
+
path,
|
| 316 |
+
model_size=model_size,
|
| 317 |
+
language=language.strip() or None,
|
| 318 |
+
word_timestamps=True,
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def _submit_analysis(media: str, transcript: str, platform: str) -> dict[str, Any]:
|
| 323 |
+
from renderer.platform import PlatformProcessor
|
| 324 |
+
|
| 325 |
+
job_id = job_manager.submit_task(
|
| 326 |
+
lambda task_id, log: PlatformProcessor(settings, log=log).analyze(media, task_id, transcript=transcript, platform=platform)
|
| 327 |
+
)
|
| 328 |
+
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def _submit_clips(media: str, clips_json: str) -> dict[str, Any]:
|
| 332 |
+
from renderer.platform import PlatformProcessor
|
| 333 |
+
|
| 334 |
+
clips = json.loads(clips_json)
|
| 335 |
+
job_id = job_manager.submit_task(lambda task_id, log: PlatformProcessor(settings, log=log).clips(media, task_id, clips))
|
| 336 |
+
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def _submit_metadata(topic: str, platform: str) -> dict[str, Any]:
|
| 340 |
+
from renderer.platform import PlatformProcessor
|
| 341 |
+
|
| 342 |
+
job_id = job_manager.submit_task(lambda task_id, log: PlatformProcessor(settings, log=log).metadata(task_id, topic=topic, platform=platform))
|
| 343 |
+
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def _submit_publish(media: str, title: str, platforms: str) -> dict[str, Any]:
|
| 347 |
+
from renderer.platform import PlatformProcessor
|
| 348 |
+
|
| 349 |
+
payload = {"media": media, "title": title, "platforms": [item.strip() for item in platforms.split(",") if item.strip()], "draft": True}
|
| 350 |
+
job_id = job_manager.submit_task(lambda task_id, log: PlatformProcessor(settings, log=log).publish(payload, task_id))
|
| 351 |
+
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def _queue_status() -> dict[str, Any]:
|
| 355 |
+
return job_manager.summary()
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def _settings_payload() -> dict[str, Any]:
|
| 359 |
+
return {
|
| 360 |
+
"product": "Ava2lon Studio AI",
|
| 361 |
+
"base_dir": str(settings.base_dir),
|
| 362 |
+
"temp_dir": str(settings.temp_dir),
|
| 363 |
+
"exports_dir": str(settings.exports_dir),
|
| 364 |
+
"storage_dir": str(settings.storage_dir),
|
| 365 |
+
"max_workers": settings.max_workers,
|
| 366 |
+
"whisper_model_size": settings.whisper_model_size,
|
| 367 |
+
"whisper_device": settings.whisper_device,
|
| 368 |
+
"capabilities": capability_catalog()["principles"],
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
app = gr.mount_gradio_app(api, create_dashboard(), path="/dashboard")
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
if __name__ == "__main__":
|
| 376 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|