Upload 44 files
Browse files- .dockerignore +9 -0
- .gitattributes +37 -0
- .gitattributes.editor +35 -0
- .gitattributes.ktts +35 -0
- .gitattributes.musicgen +35 -0
- .gitattributes.renderer +35 -0
- .gitignore +25 -0
- .gitignore.ktts +171 -0
- .gitignore.renderer +29 -0
- Dockerfile +49 -8
- Dockerfile.editor +30 -0
- Dockerfile.ktts +22 -0
- Dockerfile.musicgen +21 -0
- Dockerfile.renderer +42 -0
- Dockerfile.whisper +53 -0
- N8N_ENDPOINTS.md +880 -0
- README.editor.md +217 -0
- README.ktts-upstream.md +94 -0
- README.ktts.md +9 -0
- README.md +47 -10
- README.musicgen.md +10 -0
- README.renderer.md +365 -0
- README.whisper.md +11 -0
- api.py +1314 -0
- app.py +69 -0
- editor_app.py +2215 -0
- example.ipynb +0 -0
- generate.py +58 -0
- gradio_demo.png +3 -0
- inference.py +25 -0
- ktts_app.py +130 -0
- mcp_server.py +333 -0
- musicgen_app.py +74 -0
- musicgen_storage.py +24 -0
- renderer_app.py +376 -0
- requirements.editor.txt +6 -0
- requirements.ktts.txt +9 -0
- requirements.musicgen.txt +10 -0
- requirements.renderer.txt +11 -0
- requirements.txt +44 -9
- requirements.whisper.txt +71 -0
- start.sh +6 -0
- styles.py +21 -0
- whisper_app.py +361 -0
.dockerignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
*.pyd
|
| 5 |
+
*.db
|
| 6 |
+
.env
|
| 7 |
+
.git
|
| 8 |
+
.gitignore
|
| 9 |
+
.cache
|
.gitattributes
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 36 |
+
fonts/TikTok-Bold.ttf filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
gradio_demo.png filter=lfs diff=lfs merge=lfs -text
|
.gitattributes.editor
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
|
.gitattributes.ktts
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
|
.gitattributes.musicgen
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
|
.gitattributes.renderer
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
|
.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
.coverage
|
| 5 |
+
htmlcov/
|
| 6 |
+
|
| 7 |
+
.env
|
| 8 |
+
.env.*
|
| 9 |
+
!.env.example
|
| 10 |
+
.venv/
|
| 11 |
+
venv/
|
| 12 |
+
|
| 13 |
+
temp/*
|
| 14 |
+
!temp/.gitkeep
|
| 15 |
+
exports/*
|
| 16 |
+
!exports/.gitkeep
|
| 17 |
+
jobs/*
|
| 18 |
+
!jobs/.gitkeep
|
| 19 |
+
storage/*
|
| 20 |
+
!storage/.gitkeep
|
| 21 |
+
|
| 22 |
+
*.db
|
| 23 |
+
*.sqlite
|
| 24 |
+
*.sqlite3
|
| 25 |
+
|
.gitignore.ktts
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
|
.gitignore.renderer
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 |
+
|
Dockerfile
CHANGED
|
@@ -1,15 +1,56 @@
|
|
| 1 |
-
FROM python:3.
|
| 2 |
|
| 3 |
-
ENV
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
WORKDIR /app
|
|
|
|
| 7 |
COPY requirements.txt .
|
| 8 |
-
RUN pip install --
|
| 9 |
-
|
| 10 |
-
|
|
|
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
| 13 |
|
| 14 |
EXPOSE 7860
|
| 15 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
|
| 3 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 4 |
+
PIP_NO_CACHE_DIR=1 \
|
| 5 |
+
TEMP_DIR=/app/temp \
|
| 6 |
+
EXPORTS_DIR=/app/exports \
|
| 7 |
+
JOBS_DIR=/app/jobs \
|
| 8 |
+
STORAGE_DIR=/app/storage \
|
| 9 |
+
AVA2LON_BASE_DIR=/app \
|
| 10 |
+
BASYX_BASE_DIR=/app \
|
| 11 |
+
WHISPER_MODEL_DIR=/app/models \
|
| 12 |
+
WHISPER_MODEL_SIZE=tiny \
|
| 13 |
+
WHISPER_COMPUTE_TYPE=int8 \
|
| 14 |
+
WHISPER_DEVICE=cpu \
|
| 15 |
+
MAX_RENDER_WORKERS=1 \
|
| 16 |
+
FFMPEG_TIMEOUT_SECONDS=900 \
|
| 17 |
+
DOWNLOAD_TIMEOUT_SECONDS=60 \
|
| 18 |
+
MAX_DOWNLOAD_BYTES=524288000 \
|
| 19 |
+
ALLOW_PRIVATE_ASSET_URLS=false
|
| 20 |
+
|
| 21 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 22 |
+
build-essential \
|
| 23 |
+
ca-certificates \
|
| 24 |
+
curl \
|
| 25 |
+
espeak \
|
| 26 |
+
ffmpeg \
|
| 27 |
+
fonts-dejavu-core \
|
| 28 |
+
g++ \
|
| 29 |
+
gcc \
|
| 30 |
+
git \
|
| 31 |
+
imagemagick \
|
| 32 |
+
libglib2.0-0 \
|
| 33 |
+
libmagic1 \
|
| 34 |
+
libpq-dev \
|
| 35 |
+
libsm6 \
|
| 36 |
+
libsndfile1 \
|
| 37 |
+
libxext6 \
|
| 38 |
+
wget \
|
| 39 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 40 |
|
| 41 |
WORKDIR /app
|
| 42 |
+
|
| 43 |
COPY requirements.txt .
|
| 44 |
+
RUN pip install --upgrade pip setuptools wheel \
|
| 45 |
+
&& pip install --no-cache-dir -r requirements.txt
|
| 46 |
+
|
| 47 |
+
COPY . .
|
| 48 |
|
| 49 |
+
RUN mkdir -p /app/temp /app/exports /app/jobs /app/models /app/storage \
|
| 50 |
+
&& chmod -R 777 /app/temp /app/exports /app/jobs /app/models /app/storage \
|
| 51 |
+
&& chmod +x /app/start.sh
|
| 52 |
|
| 53 |
EXPOSE 7860
|
| 54 |
+
|
| 55 |
+
CMD ["bash", "start.sh"]
|
| 56 |
+
|
Dockerfile.editor
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"]
|
Dockerfile.ktts
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"]
|
Dockerfile.musicgen
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"]
|
Dockerfile.renderer
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
AVA2LON_SIGNING_SECRET=change-me-in-space-secrets \
|
| 11 |
+
BASYX_SIGNING_SECRET=change-me-in-space-secrets \
|
| 12 |
+
MAX_RENDER_WORKERS=1 \
|
| 13 |
+
FFMPEG_TIMEOUT_SECONDS=900 \
|
| 14 |
+
DOWNLOAD_TIMEOUT_SECONDS=60 \
|
| 15 |
+
MAX_DOWNLOAD_BYTES=524288000 \
|
| 16 |
+
ALLOW_PRIVATE_ASSET_URLS=false \
|
| 17 |
+
WHISPER_MODEL_SIZE=tiny \
|
| 18 |
+
WHISPER_COMPUTE_TYPE=int8 \
|
| 19 |
+
WHISPER_DEVICE=cpu \
|
| 20 |
+
WHISPER_MODEL_DIR=/app/models \
|
| 21 |
+
MAX_RETRIES=3 \
|
| 22 |
+
JOB_RETENTION_SECONDS=86400
|
| 23 |
+
|
| 24 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 25 |
+
ffmpeg \
|
| 26 |
+
fonts-dejavu-core \
|
| 27 |
+
ca-certificates \
|
| 28 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 29 |
+
|
| 30 |
+
WORKDIR /app
|
| 31 |
+
|
| 32 |
+
COPY requirements.txt .
|
| 33 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 34 |
+
|
| 35 |
+
COPY . .
|
| 36 |
+
|
| 37 |
+
RUN mkdir -p /app/temp /app/exports /app/jobs /app/models /app/storage \
|
| 38 |
+
&& chmod -R 777 /app/temp /app/exports /app/jobs /app/models /app/storage
|
| 39 |
+
|
| 40 |
+
EXPOSE 7860
|
| 41 |
+
|
| 42 |
+
CMD ["python", "app.py"]
|
Dockerfile.whisper
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# =========================
|
| 6 |
+
# SYSTEM DEPENDENCIES (CRITICAL)
|
| 7 |
+
# =========================
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
build-essential \
|
| 10 |
+
gcc \
|
| 11 |
+
g++ \
|
| 12 |
+
ffmpeg \
|
| 13 |
+
imagemagick \
|
| 14 |
+
fonts-dejavu-core \
|
| 15 |
+
libpq-dev \
|
| 16 |
+
libsm6 \
|
| 17 |
+
libxext6 \
|
| 18 |
+
libglib2.0-0 \
|
| 19 |
+
curl \
|
| 20 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 21 |
+
|
| 22 |
+
# =========================
|
| 23 |
+
# PYTHON BASE TOOLS
|
| 24 |
+
# =========================
|
| 25 |
+
RUN pip install --upgrade pip setuptools wheel
|
| 26 |
+
|
| 27 |
+
# =========================
|
| 28 |
+
# DEPENDENCIES (SINGLE LAYER)
|
| 29 |
+
# =========================
|
| 30 |
+
COPY requirements.txt .
|
| 31 |
+
|
| 32 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 33 |
+
|
| 34 |
+
# =========================
|
| 35 |
+
# APPLICATION CODE
|
| 36 |
+
# =========================
|
| 37 |
+
COPY . .
|
| 38 |
+
|
| 39 |
+
# =========================
|
| 40 |
+
# PERMISSIONS
|
| 41 |
+
# =========================
|
| 42 |
+
RUN chmod +x start.sh
|
| 43 |
+
|
| 44 |
+
# =========================
|
| 45 |
+
# ENV SAFETY (OPTIONAL BUT RECOMMENDED)
|
| 46 |
+
# =========================
|
| 47 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 48 |
+
PIP_NO_CACHE_DIR=1
|
| 49 |
+
|
| 50 |
+
# =========================
|
| 51 |
+
# START
|
| 52 |
+
# =========================
|
| 53 |
+
CMD ["bash", "start.sh"]
|
N8N_ENDPOINTS.md
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Unified Media Studio API for n8n
|
| 2 |
+
|
| 3 |
+
This reference describes every business API endpoint exposed by the unified
|
| 4 |
+
Space and how to call it with n8n's **HTTP Request** node.
|
| 5 |
+
|
| 6 |
+
## Base URL
|
| 7 |
+
|
| 8 |
+
Store the deployed Space URL in an n8n variable:
|
| 9 |
+
|
| 10 |
+
```text
|
| 11 |
+
STUDIO_BASE_URL=https://<space-subdomain>.hf.space
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
Use this expression in HTTP Request nodes:
|
| 15 |
+
|
| 16 |
+
```text
|
| 17 |
+
{{ $vars.STUDIO_BASE_URL }}/health
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
Do not add a trailing slash to `STUDIO_BASE_URL`.
|
| 21 |
+
|
| 22 |
+
## Standard HTTP Request Node Settings
|
| 23 |
+
|
| 24 |
+
### JSON request
|
| 25 |
+
|
| 26 |
+
- **Method:** Use the method in the endpoint tables.
|
| 27 |
+
- **URL:** `{{ $vars.STUDIO_BASE_URL }}<path>`
|
| 28 |
+
- **Send Body:** On
|
| 29 |
+
- **Body Content Type:** JSON
|
| 30 |
+
- **Specify Body:** Using JSON
|
| 31 |
+
- **Response Format:** JSON
|
| 32 |
+
|
| 33 |
+
For routes marked **API key**, add a Header Auth credential or enable
|
| 34 |
+
**Send Headers**:
|
| 35 |
+
|
| 36 |
+
```text
|
| 37 |
+
X-API-Key: <AVA2LON_API_KEY value>
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
The header is only enforced when `AVA2LON_API_KEY` or `BASYX_API_KEY` is set in
|
| 41 |
+
the Space environment.
|
| 42 |
+
|
| 43 |
+
### Query parameters
|
| 44 |
+
|
| 45 |
+
- Enable **Send Query Parameters**.
|
| 46 |
+
- Add each query parameter shown in the endpoint table.
|
| 47 |
+
- Do not place simple query parameters in the JSON body.
|
| 48 |
+
|
| 49 |
+
### Multipart file upload
|
| 50 |
+
|
| 51 |
+
- **Send Body:** On
|
| 52 |
+
- **Body Content Type:** Form-Data
|
| 53 |
+
- For text fields, select **Parameter Type: Form Data**.
|
| 54 |
+
- For files, select **Parameter Type: n8n Binary File**.
|
| 55 |
+
- Set **Name** to the API field name, usually `file` or `files`.
|
| 56 |
+
- Set **Input Data Field Name** to the incoming n8n binary field, commonly
|
| 57 |
+
`data`.
|
| 58 |
+
|
| 59 |
+
Add another `files` parameter for every additional binary file.
|
| 60 |
+
|
| 61 |
+
### File download response
|
| 62 |
+
|
| 63 |
+
For endpoints marked **File response**:
|
| 64 |
+
|
| 65 |
+
- Set **Response Format** to File.
|
| 66 |
+
- Set **Put Output in Field** to `data`.
|
| 67 |
+
- The following n8n node can read the output from `$binary.data`.
|
| 68 |
+
|
| 69 |
+
### Asynchronous job workflow
|
| 70 |
+
|
| 71 |
+
Most renderer operations return:
|
| 72 |
+
|
| 73 |
+
```json
|
| 74 |
+
{
|
| 75 |
+
"job_id": "job_123",
|
| 76 |
+
"status_url": "/status/job_123",
|
| 77 |
+
"download_url": "/download/job_123?token=..."
|
| 78 |
+
}
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
Recommended n8n flow:
|
| 82 |
+
|
| 83 |
+
1. HTTP Request submits the job.
|
| 84 |
+
2. Wait node pauses for several seconds.
|
| 85 |
+
3. HTTP Request calls
|
| 86 |
+
`{{ $vars.STUDIO_BASE_URL }}{{ $json.status_url }}`.
|
| 87 |
+
4. IF node checks whether `state` equals `COMPLETED`, `FAILED`, or `CANCELLED`.
|
| 88 |
+
5. Loop through Wait while the state is `PENDING` or `RUNNING`.
|
| 89 |
+
6. Download using the original submission node's `download_url`.
|
| 90 |
+
|
| 91 |
+
For payloads with `callback_url`, use an n8n Webhook node's production URL to
|
| 92 |
+
avoid polling.
|
| 93 |
+
|
| 94 |
+
## Root and Discovery
|
| 95 |
+
|
| 96 |
+
| Method | Path | n8n input | Response |
|
| 97 |
+
| --- | --- | --- | --- |
|
| 98 |
+
| GET | `/` | None | Redirects to `/dashboard`; use only for browser access. |
|
| 99 |
+
| GET | `/apps` | None | JSON map of mounted applications and MCP tool count. |
|
| 100 |
+
|
| 101 |
+
## Renderer Discovery and Monitoring
|
| 102 |
+
|
| 103 |
+
These routes use the root base URL.
|
| 104 |
+
|
| 105 |
+
| Method | Path | n8n input | Response |
|
| 106 |
+
| --- | --- | --- | --- |
|
| 107 |
+
| GET | `/health` | None | Renderer health. |
|
| 108 |
+
| GET | `/monitor` | None | Runtime, queue, storage, and capability summary. |
|
| 109 |
+
| GET | `/queue` | None | Job queue summary. |
|
| 110 |
+
| GET | `/workers` | None | Worker and runtime settings. |
|
| 111 |
+
| GET | `/presets` | None | Presets, templates, styles, transitions, and task IDs. |
|
| 112 |
+
| GET | `/platforms` | None | Platform profile metadata. |
|
| 113 |
+
| GET | `/toolkit/tasks` | None | Renderer toolkit task names. |
|
| 114 |
+
| GET | `/capabilities` | None | Complete capability catalog. |
|
| 115 |
+
| GET | `/effects` | None | Effects catalog. |
|
| 116 |
+
| GET | `/filters` | None | Filter catalog. |
|
| 117 |
+
| GET | `/transitions` | None | Transition catalog. |
|
| 118 |
+
| GET | `/templates/catalog` | None | Templates, presets, and styles. |
|
| 119 |
+
| GET | `/projects` | None | List saved projects. |
|
| 120 |
+
|
| 121 |
+
## Projects and Timeline
|
| 122 |
+
|
| 123 |
+
| Method | Path | n8n input | Auth | Response |
|
| 124 |
+
| --- | --- | --- | --- | --- |
|
| 125 |
+
| POST | `/projects` | JSON `ProjectPayload` | API key | Created project. |
|
| 126 |
+
| POST | `/project/create` | JSON `ProjectPayload` | API key | Alias of `/projects`. |
|
| 127 |
+
| GET | `/project/{project_id}` | Replace `{project_id}` | None | Project JSON. |
|
| 128 |
+
| POST | `/project/save` | JSON `ProjectSavePayload` | API key | Saved project. |
|
| 129 |
+
| POST | `/project/assets/add` | JSON `ProjectAssetPayload` | API key | Updated project. |
|
| 130 |
+
| POST | `/timeline/add` | JSON `TimelineAddPayload` | API key | Updated project. |
|
| 131 |
+
| POST | `/timeline/operation` | JSON `TimelineOperationPayload` | API key | Updated project. |
|
| 132 |
+
| POST | `/timeline/split` | JSON `TimelineOperationPayload` | API key | Runs `split`. |
|
| 133 |
+
| POST | `/timeline/trim` | JSON `TimelineOperationPayload` | API key | Runs `trim`. |
|
| 134 |
+
| POST | `/timeline/ripple-delete` | JSON `TimelineOperationPayload` | API key | Runs `ripple_delete`. |
|
| 135 |
+
| POST | `/timeline/insert` | JSON `TimelineOperationPayload` | API key | Runs `insert`. |
|
| 136 |
+
| POST | `/timeline/replace` | JSON `TimelineOperationPayload` | API key | Runs `replace`. |
|
| 137 |
+
| POST | `/timeline/group` | JSON `TimelineOperationPayload` | API key | Runs `group`. |
|
| 138 |
+
| POST | `/timeline/lock` | JSON `TimelineOperationPayload` | API key | Runs `lock`. |
|
| 139 |
+
| POST | `/timeline/hide` | JSON `TimelineOperationPayload` | API key | Runs `hide`. |
|
| 140 |
+
| POST | `/timeline/duplicate` | JSON `TimelineOperationPayload` | API key | Runs `duplicate`. |
|
| 141 |
+
| POST | `/effect/apply` | JSON `EffectApplyPayload` | API key | Updated project or asynchronous job. |
|
| 142 |
+
| POST | `/filter/apply` | JSON `FilterApplyPayload` | API key | Updated project or asynchronous job. |
|
| 143 |
+
| POST | `/transition/add` | JSON `TransitionAddPayload` | API key | Updated project or asynchronous job. |
|
| 144 |
+
| POST | `/keyframe/add` | JSON `KeyframePayload` | API key | Updated project. |
|
| 145 |
+
|
| 146 |
+
Example project creation:
|
| 147 |
+
|
| 148 |
+
```json
|
| 149 |
+
{
|
| 150 |
+
"name": "Campaign 01",
|
| 151 |
+
"metadata": {
|
| 152 |
+
"platform": "tiktok",
|
| 153 |
+
"creative_style": "high_energy"
|
| 154 |
+
},
|
| 155 |
+
"export_settings": {
|
| 156 |
+
"platform": "tiktok"
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
## AI and Generation
|
| 162 |
+
|
| 163 |
+
All routes in this table return asynchronous job information.
|
| 164 |
+
|
| 165 |
+
| Method | Path | n8n input | Auth |
|
| 166 |
+
| --- | --- | --- | --- |
|
| 167 |
+
| POST | `/caption/generate` | JSON `CaptionGeneratePayload` | API key |
|
| 168 |
+
| POST | `/music/generate` | JSON `GenerationPayload` | API key |
|
| 169 |
+
| POST | `/voice/generate` | JSON `GenerationPayload` | API key |
|
| 170 |
+
| POST | `/image/generate` | JSON `GenerationPayload` | API key |
|
| 171 |
+
| POST | `/video/generate` | JSON `GenerationPayload` | API key |
|
| 172 |
+
| POST | `/ai/{tool}` | Replace `{tool}`; JSON `AIToolPayload` | API key |
|
| 173 |
+
| POST | `/assistant/{tool}` | Replace `{tool}`; JSON `AIToolPayload` | API key |
|
| 174 |
+
|
| 175 |
+
Example:
|
| 176 |
+
|
| 177 |
+
```json
|
| 178 |
+
{
|
| 179 |
+
"prompt": "Upbeat cinematic background music",
|
| 180 |
+
"provider": "local",
|
| 181 |
+
"callback_url": "https://n8n.example/webhook/studio-complete",
|
| 182 |
+
"params": {
|
| 183 |
+
"duration": 20
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
## Rendering and Uploads
|
| 189 |
+
|
| 190 |
+
| Method | Path | n8n input | Auth | Response |
|
| 191 |
+
| --- | --- | --- | --- | --- |
|
| 192 |
+
| POST | `/render` | JSON `RenderPayload` or `AIReelsPayload` | None | Async job. |
|
| 193 |
+
| POST | `/render/ai-reels` | JSON `AIReelsPayload` | None | Async job. |
|
| 194 |
+
| POST | `/render/batch` | JSON `BatchPayload` | None | Array of job IDs. |
|
| 195 |
+
| POST | `/automation/batch` | JSON `BatchPayload` | API key | Array of job IDs. |
|
| 196 |
+
| POST | `/project/{project_id}/render` | Replace `{project_id}`; JSON `ProjectRenderPayload` | API key | Async job. |
|
| 197 |
+
| POST | `/render/upload` | Multipart `request_json` plus zero or more `files` | None | Async job. |
|
| 198 |
+
| POST | `/render/ai-reels/upload` | Multipart `request_json` plus zero or more `files` | None | Async job. |
|
| 199 |
+
| POST | `/assets/upload` | Multipart; one or more `files` | None | Uploaded asset references. |
|
| 200 |
+
| POST | `/upload` | Multipart `files`; optional `expand_zip` | None | Uploaded or expanded assets. |
|
| 201 |
+
|
| 202 |
+
Basic render body:
|
| 203 |
+
|
| 204 |
+
```json
|
| 205 |
+
{
|
| 206 |
+
"scenes": [
|
| 207 |
+
{
|
| 208 |
+
"start": 0,
|
| 209 |
+
"duration": 5,
|
| 210 |
+
"media": "https://example.com/video.mp4",
|
| 211 |
+
"caption": "Opening hook",
|
| 212 |
+
"transition": "fade",
|
| 213 |
+
"background": "blur",
|
| 214 |
+
"layout": "fill"
|
| 215 |
+
}
|
| 216 |
+
],
|
| 217 |
+
"output_name": "campaign.mp4",
|
| 218 |
+
"template": "tiktok_classic",
|
| 219 |
+
"auto_subtitles": true,
|
| 220 |
+
"subtitle_language": "en"
|
| 221 |
+
}
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
For `/render/upload`, configure n8n Form-Data as:
|
| 225 |
+
|
| 226 |
+
| Parameter Type | Name | Value |
|
| 227 |
+
| --- | --- | --- |
|
| 228 |
+
| Form Data | `request_json` | JSON string shown below |
|
| 229 |
+
| n8n Binary File | `files` | Input Data Field Name such as `data` |
|
| 230 |
+
|
| 231 |
+
```json
|
| 232 |
+
{
|
| 233 |
+
"scenes": [
|
| 234 |
+
{
|
| 235 |
+
"start": 0,
|
| 236 |
+
"duration": 5,
|
| 237 |
+
"media": "upload://clip.mp4",
|
| 238 |
+
"caption": "Uploaded clip"
|
| 239 |
+
}
|
| 240 |
+
],
|
| 241 |
+
"output_name": "render.mp4"
|
| 242 |
+
}
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
The filename in `upload://clip.mp4` must match the uploaded binary filename.
|
| 246 |
+
|
| 247 |
+
## Platform Processing and Toolkit
|
| 248 |
+
|
| 249 |
+
All routes in this table require `X-API-Key` and return asynchronous job
|
| 250 |
+
information.
|
| 251 |
+
|
| 252 |
+
| Method | Path | n8n input |
|
| 253 |
+
| --- | --- | --- |
|
| 254 |
+
| POST | `/ingest` | JSON `IngestPayload` |
|
| 255 |
+
| POST | `/analyze` | JSON `AnalyzePayload` |
|
| 256 |
+
| POST | `/clips` | JSON `ClipsPayload` |
|
| 257 |
+
| POST | `/thumbnail` | JSON `ThumbnailPayload` |
|
| 258 |
+
| POST | `/thumbnail/create` | JSON `ThumbnailPayload` |
|
| 259 |
+
| POST | `/metadata` | JSON `MetadataPayload` |
|
| 260 |
+
| POST | `/publish` | JSON `PublishPayload` |
|
| 261 |
+
| POST | `/toolkit` | JSON `ToolkitPayload` |
|
| 262 |
+
| POST | `/edit` | JSON `ToolkitPayload`; alias of `/toolkit` |
|
| 263 |
+
|
| 264 |
+
Toolkit example:
|
| 265 |
+
|
| 266 |
+
```json
|
| 267 |
+
{
|
| 268 |
+
"task": "compress",
|
| 269 |
+
"media": "https://example.com/input.mp4",
|
| 270 |
+
"output_name": "compressed.mp4",
|
| 271 |
+
"params": {
|
| 272 |
+
"crf": 28,
|
| 273 |
+
"resolution": "720p"
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
```
|
| 277 |
+
|
| 278 |
+
## Transcription, Subtitles, Jobs, and Downloads
|
| 279 |
+
|
| 280 |
+
| Method | Path | n8n input | Response |
|
| 281 |
+
| --- | --- | --- | --- |
|
| 282 |
+
| POST | `/transcribe` | JSON `TranscribePayload` | Transcription JSON. |
|
| 283 |
+
| POST | `/subtitles` | JSON `SubtitlePayload` | File response (`.srt` or `.ass`). |
|
| 284 |
+
| POST | `/scene-builder` | JSON `SceneBuildPayload` | Generated scene array. |
|
| 285 |
+
| POST | `/transcribe/upload` | Multipart `file` plus optional transcription fields | Transcription JSON. |
|
| 286 |
+
| GET | `/status` | Query `job_id` | Job record. |
|
| 287 |
+
| GET | `/status/{job_id}` | Replace `{job_id}` | Job record. |
|
| 288 |
+
| POST | `/cancel/{job_id}` | Replace `{job_id}` | Updated job record. |
|
| 289 |
+
| POST | `/admin/cleanup` | Optional query `older_than_seconds` | Cleanup counts. |
|
| 290 |
+
| GET | `/download` | Query `job_id`; optional `token` | File response. |
|
| 291 |
+
| GET | `/download/{job_id}` | Replace `{job_id}`; optional query `token` | File response. |
|
| 292 |
+
| POST | `/inspect` | Query `path` | Media metadata JSON. |
|
| 293 |
+
|
| 294 |
+
Transcription body:
|
| 295 |
+
|
| 296 |
+
```json
|
| 297 |
+
{
|
| 298 |
+
"audio": "https://example.com/audio.mp3",
|
| 299 |
+
"model_size": "tiny",
|
| 300 |
+
"language": "en",
|
| 301 |
+
"task": "transcribe",
|
| 302 |
+
"beam_size": 5,
|
| 303 |
+
"vad_filter": true,
|
| 304 |
+
"word_timestamps": true
|
| 305 |
+
}
|
| 306 |
+
```
|
| 307 |
+
|
| 308 |
+
Subtitle body:
|
| 309 |
+
|
| 310 |
+
```json
|
| 311 |
+
{
|
| 312 |
+
"events": [
|
| 313 |
+
{
|
| 314 |
+
"start": 0,
|
| 315 |
+
"end": 2.5,
|
| 316 |
+
"text": "Opening caption"
|
| 317 |
+
}
|
| 318 |
+
],
|
| 319 |
+
"format": "srt",
|
| 320 |
+
"template": "tiktok_classic"
|
| 321 |
+
}
|
| 322 |
+
```
|
| 323 |
+
|
| 324 |
+
## Kokoro TTS
|
| 325 |
+
|
| 326 |
+
| Method | Path | n8n input | Response |
|
| 327 |
+
| --- | --- | --- | --- |
|
| 328 |
+
| POST | `/tts/v1/audio/speech` | JSON shown below | WAV file response. |
|
| 329 |
+
|
| 330 |
+
Set the HTTP Request node's response format to **File**.
|
| 331 |
+
|
| 332 |
+
```json
|
| 333 |
+
{
|
| 334 |
+
"input": "This is generated speech.",
|
| 335 |
+
"voice": "af_bella.pt",
|
| 336 |
+
"model": "kokoro-v0_19.onnx",
|
| 337 |
+
"speed": 1.0
|
| 338 |
+
}
|
| 339 |
+
```
|
| 340 |
+
|
| 341 |
+
`voice` and `model` are optional; the server uses its first configured voice and
|
| 342 |
+
model when omitted.
|
| 343 |
+
|
| 344 |
+
## MusicGen
|
| 345 |
+
|
| 346 |
+
| Method | Path | n8n input | Response |
|
| 347 |
+
| --- | --- | --- | --- |
|
| 348 |
+
| GET | `/music/health` | None | Health JSON. |
|
| 349 |
+
| POST | `/music/generate` | Query `prompt`; optional query `duration` | WAV file response. |
|
| 350 |
+
|
| 351 |
+
For generation:
|
| 352 |
+
|
| 353 |
+
- **Method:** POST
|
| 354 |
+
- **URL:** `{{ $vars.STUDIO_BASE_URL }}/music/generate`
|
| 355 |
+
- **Send Query Parameters:** On
|
| 356 |
+
- `prompt`: `Soft cinematic instrumental`
|
| 357 |
+
- `duration`: `10`
|
| 358 |
+
- **Response Format:** File
|
| 359 |
+
|
| 360 |
+
## Whisper Operator
|
| 361 |
+
|
| 362 |
+
| Method | Path | n8n input | Response |
|
| 363 |
+
| --- | --- | --- | --- |
|
| 364 |
+
| POST | `/whisper/execute/{task_name}` | Multipart file or URL fields | JSON or file response. |
|
| 365 |
+
| GET | `/whisper/api/health` | None | Health JSON. |
|
| 366 |
+
| GET | `/whisper/api/status/{job_id}` | Replace `{job_id}` | Whisper queue status. |
|
| 367 |
+
|
| 368 |
+
Use one of these multipart inputs for `/whisper/execute/{task_name}`:
|
| 369 |
+
|
| 370 |
+
- `file`: n8n Binary File.
|
| 371 |
+
- `url_input`: downloadable media URL.
|
| 372 |
+
- `source`: alternative media URL.
|
| 373 |
+
- `webhook`: optional callback URL.
|
| 374 |
+
|
| 375 |
+
Supported task names:
|
| 376 |
+
|
| 377 |
+
```text
|
| 378 |
+
autonomous
|
| 379 |
+
auto-publish
|
| 380 |
+
publish
|
| 381 |
+
bulk-publish
|
| 382 |
+
generate-metadata
|
| 383 |
+
generate-thumbnail
|
| 384 |
+
schedule-post
|
| 385 |
+
transcribe
|
| 386 |
+
subtitles
|
| 387 |
+
render
|
| 388 |
+
highlights
|
| 389 |
+
clips
|
| 390 |
+
viral-score
|
| 391 |
+
strategy
|
| 392 |
+
batch
|
| 393 |
+
```
|
| 394 |
+
|
| 395 |
+
## Whisper Authentication
|
| 396 |
+
|
| 397 |
+
These routes exist only when the Whisper authentication subsystem starts
|
| 398 |
+
successfully.
|
| 399 |
+
|
| 400 |
+
| Method | Path | n8n input |
|
| 401 |
+
| --- | --- | --- |
|
| 402 |
+
| POST | `/whisper/api/auth/signup` | JSON `SignupSchema` |
|
| 403 |
+
| POST | `/whisper/api/auth/login` | JSON `LoginSchema` |
|
| 404 |
+
| POST | `/whisper/api/auth/verify` | JSON `TokenSchema` |
|
| 405 |
+
| POST | `/whisper/api/auth/refresh` | JSON `TokenSchema` |
|
| 406 |
+
| POST | `/whisper/api/auth/logout` | No body |
|
| 407 |
+
|
| 408 |
+
Examples:
|
| 409 |
+
|
| 410 |
+
```json
|
| 411 |
+
{
|
| 412 |
+
"email": "user@example.com",
|
| 413 |
+
"username": "studio_user",
|
| 414 |
+
"password": "strong-password"
|
| 415 |
+
}
|
| 416 |
+
```
|
| 417 |
+
|
| 418 |
+
```json
|
| 419 |
+
{
|
| 420 |
+
"email": "user@example.com",
|
| 421 |
+
"password": "strong-password"
|
| 422 |
+
}
|
| 423 |
+
```
|
| 424 |
+
|
| 425 |
+
```json
|
| 426 |
+
{
|
| 427 |
+
"token": "<JWT returned by signup or login>"
|
| 428 |
+
}
|
| 429 |
+
```
|
| 430 |
+
|
| 431 |
+
Logout returns success but does not currently maintain a server-side token
|
| 432 |
+
revocation list.
|
| 433 |
+
|
| 434 |
+
## FFmpeg Editor
|
| 435 |
+
|
| 436 |
+
| Method | Path | n8n input | Response |
|
| 437 |
+
| --- | --- | --- | --- |
|
| 438 |
+
| GET | `/editor/healthz` | None | Health and dependency checks. |
|
| 439 |
+
| GET | `/editor/readyz` | None | Readiness; returns 503 when dependencies fail. |
|
| 440 |
+
| GET | `/editor/tasks` | None | Editor task registry. |
|
| 441 |
+
| POST | `/editor/n8n/execute/{task_id}` | JSON, multipart, or raw binary | Synchronous file response. |
|
| 442 |
+
| POST | `/editor/n8n/jobs/{task_id}` | JSON, multipart, or raw binary | Background job JSON. |
|
| 443 |
+
| POST | `/editor/execute/{task_id}` | Multipart fields | Synchronous file response. |
|
| 444 |
+
| POST | `/editor/jobs/{task_id}` | Multipart fields | Background job JSON. |
|
| 445 |
+
| GET | `/editor/status/{job_id}` | Replace `{job_id}` | Background job status. |
|
| 446 |
+
| GET | `/editor/download/{job_id}` | Replace `{job_id}` | File response. |
|
| 447 |
+
| GET | `/editor/history` | None | Recent output metadata. |
|
| 448 |
+
| GET | `/editor/history/{history_id}/download` | Replace `{history_id}` | File response. |
|
| 449 |
+
|
| 450 |
+
### Recommended editor request for n8n
|
| 451 |
+
|
| 452 |
+
Use `/editor/n8n/jobs/{task_id}` for long-running jobs.
|
| 453 |
+
|
| 454 |
+
Multipart request:
|
| 455 |
+
|
| 456 |
+
- Add one or more **n8n Binary File** parameters. The field name may be
|
| 457 |
+
`files`.
|
| 458 |
+
- Add text options as **Form Data**.
|
| 459 |
+
- A URL may use `url`, `urls`, `file_url`, `source_url`, or `download_url`.
|
| 460 |
+
|
| 461 |
+
JSON request:
|
| 462 |
+
|
| 463 |
+
```json
|
| 464 |
+
{
|
| 465 |
+
"url": "https://example.com/input.mp4",
|
| 466 |
+
"options": {
|
| 467 |
+
"resolution": "720p",
|
| 468 |
+
"crf": "28",
|
| 469 |
+
"preset": "medium",
|
| 470 |
+
"text": "Caption text"
|
| 471 |
+
}
|
| 472 |
+
}
|
| 473 |
+
```
|
| 474 |
+
|
| 475 |
+
Raw binary request:
|
| 476 |
+
|
| 477 |
+
- **Body Content Type:** n8n Binary File
|
| 478 |
+
- **Input Data Field Name:** `data`
|
| 479 |
+
- Add header `X-Filename: input.mp4`
|
| 480 |
+
- Put editor options in query parameters.
|
| 481 |
+
|
| 482 |
+
Available editor option fields:
|
| 483 |
+
|
| 484 |
+
```text
|
| 485 |
+
text
|
| 486 |
+
start_time
|
| 487 |
+
end_time
|
| 488 |
+
duration
|
| 489 |
+
aspect_ratio
|
| 490 |
+
resolution
|
| 491 |
+
crf
|
| 492 |
+
preset
|
| 493 |
+
audio_bitrate
|
| 494 |
+
volume
|
| 495 |
+
position
|
| 496 |
+
opacity
|
| 497 |
+
fps
|
| 498 |
+
width
|
| 499 |
+
speed
|
| 500 |
+
timestamp
|
| 501 |
+
image_duration
|
| 502 |
+
frame_rate
|
| 503 |
+
font_size
|
| 504 |
+
wave_color
|
| 505 |
+
```
|
| 506 |
+
|
| 507 |
+
### Editor task IDs
|
| 508 |
+
|
| 509 |
+
Use one value below as `{task_id}`:
|
| 510 |
+
|
| 511 |
+
| Task ID | Purpose | Files |
|
| 512 |
+
| --- | --- | --- |
|
| 513 |
+
| `normalize` | Convert to H.264/AAC MP4 | 1 video |
|
| 514 |
+
| `extract_audio` | Export MP3 audio | 1 video/audio |
|
| 515 |
+
| `resize_916` | Crop to vertical 9:16 | 1 video |
|
| 516 |
+
| `add_subtitles` | Burn SRT/ASS subtitles | video + subtitle |
|
| 517 |
+
| `burn_lyrics` | Burn styled lyrics | video + subtitle |
|
| 518 |
+
| `text_overlay` | Add caption text | 1 video |
|
| 519 |
+
| `merge_music` | Add background music | video + audio |
|
| 520 |
+
| `thumbnail` | Export JPG frame | 1 video |
|
| 521 |
+
| `watermark` | Overlay logo/image | video + image |
|
| 522 |
+
| `compress` | Reduce video size | 1 video |
|
| 523 |
+
| `batch_compress` | Compress videos into ZIP | 1+ videos |
|
| 524 |
+
| `make_gif` | Create GIF | 1 video |
|
| 525 |
+
| `tiktok_lyrics` | Vertical lyric clip | 1 video |
|
| 526 |
+
| `tiktok_pro_reframer` | Vertical reframe and style | 1 video |
|
| 527 |
+
| `reels_blur_fit` | Blurred 9:16 fit | 1 video |
|
| 528 |
+
| `reels_safe_caption` | Mobile-safe caption | 1 video |
|
| 529 |
+
| `reels_hook_title` | Top hook/title | 1 video |
|
| 530 |
+
| `reels_progress_bar` | Progress bar and trim | 1 video |
|
| 531 |
+
| `reels_loop` | Loop to target duration | 1 video |
|
| 532 |
+
| `reels_subtitle_safe` | Mobile-safe subtitles | video + subtitle |
|
| 533 |
+
| `reels_reaction_stack` | Stack reaction videos | 2 videos |
|
| 534 |
+
| `reels_audio_duck` | Duck music below video | video + audio |
|
| 535 |
+
| `faceless_quote_card` | Text-only quote short | no file |
|
| 536 |
+
| `faceless_story_pages` | Timed text story slides | no file |
|
| 537 |
+
| `faceless_image_narration` | Image with narration | image + audio |
|
| 538 |
+
| `faceless_video_narration` | B-roll with narration | video + audio |
|
| 539 |
+
| `faceless_broll_montage` | Silent b-roll montage | 2+ videos |
|
| 540 |
+
| `series_split_pack` | Split long video into series ZIP | 1 video |
|
| 541 |
+
| `series_episode_badge` | Add episode badge | 1 video |
|
| 542 |
+
| `series_batch_pack` | Package clips as numbered series | 2+ videos |
|
| 543 |
+
| `series_recap_card` | Text-only recap card | no file |
|
| 544 |
+
| `concat` | Join clips | 2+ videos |
|
| 545 |
+
| `slideshow` | Build video from images | 2+ images |
|
| 546 |
+
| `trim` | Cut by start/end | 1 video/audio |
|
| 547 |
+
| `crop_aspect` | Crop to target aspect ratio | 1 video |
|
| 548 |
+
| `waveform` | Create waveform video | 1 audio/video |
|
| 549 |
+
| `extract_frames` | Export frames as ZIP | 1 video |
|
| 550 |
+
| `add_intro_outro` | Join intro/main/outro | 2-3 videos |
|
| 551 |
+
| `speed` | Change playback speed | 1 video |
|
| 552 |
+
| `remove_audio` | Remove video audio | 1 video |
|
| 553 |
+
| `replace_audio` | Replace video audio | video + audio |
|
| 554 |
+
|
| 555 |
+
## JSON Payload Reference
|
| 556 |
+
|
| 557 |
+
Fields marked `*` are required.
|
| 558 |
+
|
| 559 |
+
### Rendering
|
| 560 |
+
|
| 561 |
+
`ScenePayload`
|
| 562 |
+
|
| 563 |
+
```text
|
| 564 |
+
start*: number >= 0
|
| 565 |
+
duration*: number > 0
|
| 566 |
+
media*: string path or URL
|
| 567 |
+
caption: string
|
| 568 |
+
transition: string = "fade"
|
| 569 |
+
background: string = "blur"
|
| 570 |
+
layout: string = "fill"
|
| 571 |
+
effect: string | null
|
| 572 |
+
```
|
| 573 |
+
|
| 574 |
+
`RenderPayload`
|
| 575 |
+
|
| 576 |
+
```text
|
| 577 |
+
scenes*: ScenePayload[]
|
| 578 |
+
template: string = "tiktok_classic"
|
| 579 |
+
preset, creative_style, platform: string | null
|
| 580 |
+
output_name: string = "render.mp4"
|
| 581 |
+
voiceover, background_music: string | null
|
| 582 |
+
music_volume: number = 0.316
|
| 583 |
+
music_fade_in, music_fade_out, music_start: number = 0
|
| 584 |
+
music_loop, music_ducking: boolean = true
|
| 585 |
+
voice_volume: number = 1
|
| 586 |
+
subtitle_format: string = "ass"
|
| 587 |
+
auto_subtitles: boolean = false
|
| 588 |
+
subtitle_language, whisper_model_size: string | null
|
| 589 |
+
preview, audio_normalize: boolean = false
|
| 590 |
+
watermark, intro, outro: string | null
|
| 591 |
+
watermark_position: string = "bottom-right"
|
| 592 |
+
callback_url, export_target: string | null
|
| 593 |
+
priority: integer = 0
|
| 594 |
+
scheduled_at: number | null
|
| 595 |
+
normalize: boolean = true
|
| 596 |
+
metadata: object = {}
|
| 597 |
+
```
|
| 598 |
+
|
| 599 |
+
`AIReelsPayload`
|
| 600 |
+
|
| 601 |
+
```text
|
| 602 |
+
script*: string
|
| 603 |
+
voiceover*: string
|
| 604 |
+
assets*: string[]
|
| 605 |
+
template: string = "tiktok_classic"
|
| 606 |
+
creative_style, platform: string | null
|
| 607 |
+
output_name: string = "ai_reel.mp4"
|
| 608 |
+
background_music: string | null
|
| 609 |
+
music_volume: number = 0.316
|
| 610 |
+
music_fade_in, music_fade_out, music_start: number = 0
|
| 611 |
+
music_loop, music_ducking: boolean = true
|
| 612 |
+
voice_volume: number = 1
|
| 613 |
+
```
|
| 614 |
+
|
| 615 |
+
`BatchPayload`
|
| 616 |
+
|
| 617 |
+
```text
|
| 618 |
+
jobs*: RenderPayload[]
|
| 619 |
+
```
|
| 620 |
+
|
| 621 |
+
`ProjectRenderPayload`
|
| 622 |
+
|
| 623 |
+
```text
|
| 624 |
+
output_name: string = "project_render.mp4"
|
| 625 |
+
template, preset, creative_style, platform: string | null
|
| 626 |
+
callback_url, export_target: string | null
|
| 627 |
+
preview: boolean = false
|
| 628 |
+
normalize: boolean = true
|
| 629 |
+
metadata: object = {}
|
| 630 |
+
```
|
| 631 |
+
|
| 632 |
+
### Projects and Editing
|
| 633 |
+
|
| 634 |
+
`ProjectPayload`
|
| 635 |
+
|
| 636 |
+
```text
|
| 637 |
+
name*: string
|
| 638 |
+
metadata: object = {}
|
| 639 |
+
export_settings: object = {}
|
| 640 |
+
template: object | null
|
| 641 |
+
```
|
| 642 |
+
|
| 643 |
+
`ProjectSavePayload`
|
| 644 |
+
|
| 645 |
+
```text
|
| 646 |
+
project_id: string | null
|
| 647 |
+
project*: object
|
| 648 |
+
```
|
| 649 |
+
|
| 650 |
+
`ProjectAssetPayload`
|
| 651 |
+
|
| 652 |
+
```text
|
| 653 |
+
project_id*: string
|
| 654 |
+
asset*: object
|
| 655 |
+
```
|
| 656 |
+
|
| 657 |
+
`TimelineAddPayload`
|
| 658 |
+
|
| 659 |
+
```text
|
| 660 |
+
project_id*: string
|
| 661 |
+
track_type: string = "video"
|
| 662 |
+
track_id: string | null
|
| 663 |
+
item: object = {}
|
| 664 |
+
```
|
| 665 |
+
|
| 666 |
+
`TimelineOperationPayload`
|
| 667 |
+
|
| 668 |
+
```text
|
| 669 |
+
project_id*: string
|
| 670 |
+
operation: string = "drag"
|
| 671 |
+
item_id: string | null
|
| 672 |
+
params: object = {}
|
| 673 |
+
```
|
| 674 |
+
|
| 675 |
+
`EffectApplyPayload`
|
| 676 |
+
|
| 677 |
+
```text
|
| 678 |
+
project_id, target_id, item_id: string | null
|
| 679 |
+
effect*: string
|
| 680 |
+
params: object = {}
|
| 681 |
+
callback_url: string | null
|
| 682 |
+
```
|
| 683 |
+
|
| 684 |
+
`FilterApplyPayload`
|
| 685 |
+
|
| 686 |
+
```text
|
| 687 |
+
project_id, target_id, item_id: string | null
|
| 688 |
+
filter*: string
|
| 689 |
+
params: object = {}
|
| 690 |
+
lut: string | null
|
| 691 |
+
callback_url: string | null
|
| 692 |
+
```
|
| 693 |
+
|
| 694 |
+
`TransitionAddPayload`
|
| 695 |
+
|
| 696 |
+
```text
|
| 697 |
+
project_id, from_item_id, to_item_id, target_id: string | null
|
| 698 |
+
transition*: string
|
| 699 |
+
duration: number = 0.45
|
| 700 |
+
params: object = {}
|
| 701 |
+
callback_url: string | null
|
| 702 |
+
```
|
| 703 |
+
|
| 704 |
+
`KeyframePayload`
|
| 705 |
+
|
| 706 |
+
```text
|
| 707 |
+
project_id*: string
|
| 708 |
+
target_id*: string
|
| 709 |
+
property*: string
|
| 710 |
+
time*: number >= 0
|
| 711 |
+
value*: any JSON value
|
| 712 |
+
easing: string = "linear"
|
| 713 |
+
```
|
| 714 |
+
|
| 715 |
+
### Generation and Platform Tools
|
| 716 |
+
|
| 717 |
+
`GenerationPayload`
|
| 718 |
+
|
| 719 |
+
```text
|
| 720 |
+
prompt, text, media, provider: string | null
|
| 721 |
+
callback_url, export_target: string | null
|
| 722 |
+
params: object = {}
|
| 723 |
+
additional JSON fields are accepted
|
| 724 |
+
```
|
| 725 |
+
|
| 726 |
+
`CaptionGeneratePayload`
|
| 727 |
+
|
| 728 |
+
```text
|
| 729 |
+
media, audio, text, transcript: string | null
|
| 730 |
+
events: object[] | null
|
| 731 |
+
template: string = "capcut"
|
| 732 |
+
language: string | null
|
| 733 |
+
engine: string = "whisper"
|
| 734 |
+
word_timestamps: boolean = true
|
| 735 |
+
emoji_insertion, speaker_detection: boolean = false
|
| 736 |
+
karaoke, animated: boolean = true
|
| 737 |
+
callback_url: string | null
|
| 738 |
+
additional JSON fields are accepted
|
| 739 |
+
```
|
| 740 |
+
|
| 741 |
+
`AIToolPayload`
|
| 742 |
+
|
| 743 |
+
```text
|
| 744 |
+
project_id, media, transcript, text, platform: string | null
|
| 745 |
+
callback_url: string | null
|
| 746 |
+
params: object = {}
|
| 747 |
+
additional JSON fields are accepted
|
| 748 |
+
```
|
| 749 |
+
|
| 750 |
+
`IngestPayload`
|
| 751 |
+
|
| 752 |
+
```text
|
| 753 |
+
sources*: [{ "url": string, "type": string|null, "name": string|null }]
|
| 754 |
+
callback_url: string | null
|
| 755 |
+
```
|
| 756 |
+
|
| 757 |
+
`AnalyzePayload`
|
| 758 |
+
|
| 759 |
+
```text
|
| 760 |
+
media*: string
|
| 761 |
+
transcript: string = ""
|
| 762 |
+
platform, callback_url: string | null
|
| 763 |
+
```
|
| 764 |
+
|
| 765 |
+
`ClipsPayload`
|
| 766 |
+
|
| 767 |
+
```text
|
| 768 |
+
media*: string
|
| 769 |
+
clips: object[] | null
|
| 770 |
+
callback_url: string | null
|
| 771 |
+
```
|
| 772 |
+
|
| 773 |
+
`ThumbnailPayload`
|
| 774 |
+
|
| 775 |
+
```text
|
| 776 |
+
media*: string
|
| 777 |
+
text: string = ""
|
| 778 |
+
timestamp: number | null
|
| 779 |
+
template: string = "bold"
|
| 780 |
+
callback_url: string | null
|
| 781 |
+
```
|
| 782 |
+
|
| 783 |
+
`MetadataPayload`
|
| 784 |
+
|
| 785 |
+
```text
|
| 786 |
+
topic, transcript: string = ""
|
| 787 |
+
platform, callback_url: string | null
|
| 788 |
+
```
|
| 789 |
+
|
| 790 |
+
`PublishPayload`
|
| 791 |
+
|
| 792 |
+
```text
|
| 793 |
+
media, asset, title, description: string | null
|
| 794 |
+
platforms: string[] = []
|
| 795 |
+
platform, scheduled_at: string | null
|
| 796 |
+
draft: boolean = true
|
| 797 |
+
callback_url: string | null
|
| 798 |
+
```
|
| 799 |
+
|
| 800 |
+
`ToolkitPayload`
|
| 801 |
+
|
| 802 |
+
```text
|
| 803 |
+
task*: string
|
| 804 |
+
input, media, output_name: string | null
|
| 805 |
+
params: object = {}
|
| 806 |
+
callback_url, export_target: string | null
|
| 807 |
+
additional JSON fields are accepted
|
| 808 |
+
```
|
| 809 |
+
|
| 810 |
+
### Transcription and Subtitles
|
| 811 |
+
|
| 812 |
+
`TranscribePayload`
|
| 813 |
+
|
| 814 |
+
```text
|
| 815 |
+
audio*: string path or URL
|
| 816 |
+
model_size, language: string | null
|
| 817 |
+
task: string = "transcribe"
|
| 818 |
+
beam_size: integer = 5
|
| 819 |
+
vad_filter, word_timestamps: boolean = true
|
| 820 |
+
```
|
| 821 |
+
|
| 822 |
+
`SubtitlePayload`
|
| 823 |
+
|
| 824 |
+
```text
|
| 825 |
+
events*: [{ "start": number, "end": number, "text": string }]
|
| 826 |
+
format: string = "srt"
|
| 827 |
+
template: string = "tiktok_classic"
|
| 828 |
+
```
|
| 829 |
+
|
| 830 |
+
`SceneBuildPayload`
|
| 831 |
+
|
| 832 |
+
```text
|
| 833 |
+
script*: string
|
| 834 |
+
assets*: string[]
|
| 835 |
+
duration: number | null
|
| 836 |
+
transition: string = "fade"
|
| 837 |
+
creative_style: string | null
|
| 838 |
+
```
|
| 839 |
+
|
| 840 |
+
### Authentication
|
| 841 |
+
|
| 842 |
+
`SignupSchema`
|
| 843 |
+
|
| 844 |
+
```text
|
| 845 |
+
email*: valid email
|
| 846 |
+
username*: 3-32 letters, numbers, or underscores
|
| 847 |
+
password*: 8-72 characters
|
| 848 |
+
```
|
| 849 |
+
|
| 850 |
+
`LoginSchema`
|
| 851 |
+
|
| 852 |
+
```text
|
| 853 |
+
email*: valid email
|
| 854 |
+
password*: 1-72 characters
|
| 855 |
+
```
|
| 856 |
+
|
| 857 |
+
`TokenSchema`
|
| 858 |
+
|
| 859 |
+
```text
|
| 860 |
+
token*: 10-2048 characters
|
| 861 |
+
```
|
| 862 |
+
|
| 863 |
+
## Framework and UI Endpoints
|
| 864 |
+
|
| 865 |
+
These are not business API operations:
|
| 866 |
+
|
| 867 |
+
| Path | Purpose |
|
| 868 |
+
| --- | --- |
|
| 869 |
+
| `/dashboard` | Renderer Gradio interface. |
|
| 870 |
+
| `/tts/` | Kokoro TTS Gradio interface. |
|
| 871 |
+
| `/music/` | MusicGen Gradio interface. |
|
| 872 |
+
| `/whisper/` | Whisper Gradio interface. |
|
| 873 |
+
| `/editor/` | FFmpeg editor Gradio interface. |
|
| 874 |
+
| `/mcp/` | MCP Streamable HTTP transport; use an MCP client rather than a normal REST request. |
|
| 875 |
+
| `/docs` | Root FastAPI Swagger UI. |
|
| 876 |
+
| `/redoc` | Root FastAPI ReDoc UI. |
|
| 877 |
+
| `/openapi.json` | Root renderer OpenAPI schema. |
|
| 878 |
+
| `/<service>/docs` | Mounted service Swagger UI when not shadowed by its UI mount. |
|
| 879 |
+
| `/<service>/openapi.json` | Mounted service OpenAPI schema. |
|
| 880 |
+
|
README.editor.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`.
|
README.ktts-upstream.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
|
README.ktts.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 |
+
---
|
README.md
CHANGED
|
@@ -1,18 +1,55 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
#
|
| 12 |
|
| 13 |
-
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Unified Media Studio
|
| 3 |
+
emoji: "🎬"
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Unified Media Studio
|
| 13 |
|
| 14 |
+
One Hugging Face Docker Space containing the five original applications.
|
| 15 |
|
| 16 |
+
| Application | Path |
|
| 17 |
+
| --- | --- |
|
| 18 |
+
| Rendering studio | `/dashboard` |
|
| 19 |
+
| Kokoro TTS | `/tts/` |
|
| 20 |
+
| MusicGen | `/music/` |
|
| 21 |
+
| Whisper operator | `/whisper/` |
|
| 22 |
+
| FFmpeg editor | `/editor/` |
|
| 23 |
+
| MCP Streamable HTTP | `/mcp/` |
|
| 24 |
+
|
| 25 |
+
The rendering API remains available at the Space root. `GET /apps` returns the
|
| 26 |
+
mounted application paths.
|
| 27 |
+
|
| 28 |
+
## n8n
|
| 29 |
+
|
| 30 |
+
See [N8N_ENDPOINTS.md](N8N_ENDPOINTS.md) for all API endpoints, payload
|
| 31 |
+
schemas, editor task IDs, file upload settings, asynchronous job handling, and
|
| 32 |
+
HTTP Request node examples.
|
| 33 |
+
|
| 34 |
+
## MCP
|
| 35 |
+
|
| 36 |
+
Connect an MCP client to:
|
| 37 |
+
|
| 38 |
+
```text
|
| 39 |
+
https://<space-subdomain>.hf.space/mcp/
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
Every FastAPI application endpoint is exposed as a separate MCP tool. Tool
|
| 43 |
+
arguments use a consistent transport wrapper:
|
| 44 |
+
|
| 45 |
+
- `path_params`: values for placeholders such as `{job_id}`.
|
| 46 |
+
- `query`: URL query parameters.
|
| 47 |
+
- `body`: JSON, form, or text request body.
|
| 48 |
+
- `body_type`: `json`, `form`, or `text`.
|
| 49 |
+
- `headers`: forwarded headers such as `X-API-Key`.
|
| 50 |
+
- `files`: multipart files containing `field_name`, `filename`,
|
| 51 |
+
`content_type`, and `content_base64`.
|
| 52 |
+
|
| 53 |
+
Large response bodies are omitted from the MCP result. Increase
|
| 54 |
+
`MCP_INLINE_RESPONSE_MAX_BYTES` when small generated files should be returned
|
| 55 |
+
inline as base64.
|
README.musicgen.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
|
README.renderer.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.
|
README.whisper.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Whisper
|
| 3 |
+
emoji: 🏆
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
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"
|
app.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from contextlib import AsyncExitStack, asynccontextmanager
|
| 2 |
+
|
| 3 |
+
from fastapi.responses import RedirectResponse
|
| 4 |
+
|
| 5 |
+
from editor_app import app as editor_app
|
| 6 |
+
from ktts_app import app as tts_app
|
| 7 |
+
from musicgen_app import app as music_app
|
| 8 |
+
from mcp_server import RouteSource, create_mcp_server
|
| 9 |
+
from renderer_app import app
|
| 10 |
+
from whisper_app import app as whisper_app
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
CHILD_APPS = (
|
| 14 |
+
("/tts", tts_app, "tts"),
|
| 15 |
+
("/music", music_app, "music"),
|
| 16 |
+
("/whisper", whisper_app, "whisper"),
|
| 17 |
+
("/editor", editor_app, "editor"),
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
renderer_lifespan = app.router.lifespan_context
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@app.get("/", include_in_schema=False)
|
| 24 |
+
async def studio_home():
|
| 25 |
+
return RedirectResponse(url="/dashboard")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@app.get("/apps", tags=["studio"])
|
| 29 |
+
async def studio_apps():
|
| 30 |
+
return {
|
| 31 |
+
"renderer": "/dashboard",
|
| 32 |
+
"tts": "/tts/",
|
| 33 |
+
"music": "/music/",
|
| 34 |
+
"whisper": "/whisper/",
|
| 35 |
+
"editor": "/editor/",
|
| 36 |
+
"mcp": "/mcp/",
|
| 37 |
+
"mcp_tools": len(mcp_tool_catalog),
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
for path, child_app, name in CHILD_APPS:
|
| 42 |
+
app.mount(path, child_app, name=name)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
mcp, mcp_tool_catalog = create_mcp_server(
|
| 46 |
+
app,
|
| 47 |
+
(
|
| 48 |
+
RouteSource("renderer", app),
|
| 49 |
+
RouteSource("tts", tts_app, "/tts"),
|
| 50 |
+
RouteSource("music", music_app, "/music"),
|
| 51 |
+
RouteSource("whisper", whisper_app, "/whisper"),
|
| 52 |
+
RouteSource("editor", editor_app, "/editor"),
|
| 53 |
+
),
|
| 54 |
+
)
|
| 55 |
+
mcp_http_app = mcp.streamable_http_app()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@asynccontextmanager
|
| 59 |
+
async def studio_lifespan(root_app):
|
| 60 |
+
async with AsyncExitStack() as stack:
|
| 61 |
+
await stack.enter_async_context(renderer_lifespan(root_app))
|
| 62 |
+
for _, child_app, _ in CHILD_APPS:
|
| 63 |
+
await stack.enter_async_context(child_app.router.lifespan_context(child_app))
|
| 64 |
+
await stack.enter_async_context(mcp.session_manager.run())
|
| 65 |
+
yield
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
app.router.lifespan_context = studio_lifespan
|
| 69 |
+
app.mount("/mcp", mcp_http_app, name="mcp")
|
editor_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)
|
example.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
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
|
gradio_demo.png
ADDED
|
Git LFS Details
|
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()
|
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)
|
mcp_server.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import binascii
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Any, Literal
|
| 11 |
+
from urllib.parse import quote
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
from fastapi import FastAPI
|
| 15 |
+
from fastapi.routing import APIRoute
|
| 16 |
+
from mcp.server.fastmcp import FastMCP
|
| 17 |
+
from pydantic import BaseModel, Field
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
BodyType = Literal["json", "form", "text"]
|
| 21 |
+
PATH_PARAMETER = re.compile(r"{(?P<name>[^}:]+)(?::(?P<converter>[^}]+))?}")
|
| 22 |
+
INLINE_RESPONSE_LIMIT = int(os.getenv("MCP_INLINE_RESPONSE_MAX_BYTES", str(1024 * 1024)))
|
| 23 |
+
TEXT_PREVIEW_LIMIT = int(os.getenv("MCP_TEXT_PREVIEW_MAX_CHARS", "12000"))
|
| 24 |
+
RESTRICTED_HEADERS = {
|
| 25 |
+
"connection",
|
| 26 |
+
"content-length",
|
| 27 |
+
"host",
|
| 28 |
+
"transfer-encoding",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class EncodedFile(BaseModel):
|
| 33 |
+
field_name: str = Field(default="files", description="Multipart form field name.")
|
| 34 |
+
filename: str
|
| 35 |
+
content_base64: str = Field(description="Base64-encoded file content.")
|
| 36 |
+
content_type: str = "application/octet-stream"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass(frozen=True)
|
| 40 |
+
class RouteSource:
|
| 41 |
+
namespace: str
|
| 42 |
+
app: FastAPI
|
| 43 |
+
path_prefix: str = ""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def create_mcp_server(
|
| 47 |
+
target_app: FastAPI,
|
| 48 |
+
route_sources: tuple[RouteSource, ...],
|
| 49 |
+
) -> tuple[FastMCP, list[dict[str, Any]]]:
|
| 50 |
+
server = FastMCP(
|
| 51 |
+
"Unified Media Studio",
|
| 52 |
+
instructions=(
|
| 53 |
+
"Each tool maps to one Unified Media Studio HTTP endpoint. "
|
| 54 |
+
"Use path_params for URL placeholders, query for query parameters, "
|
| 55 |
+
"body for JSON or form data, headers for authentication, and files "
|
| 56 |
+
"for base64-encoded multipart uploads."
|
| 57 |
+
),
|
| 58 |
+
host="0.0.0.0",
|
| 59 |
+
stateless_http=True,
|
| 60 |
+
json_response=True,
|
| 61 |
+
streamable_http_path="/",
|
| 62 |
+
)
|
| 63 |
+
catalog: list[dict[str, Any]] = []
|
| 64 |
+
used_names: set[str] = set()
|
| 65 |
+
|
| 66 |
+
for source in route_sources:
|
| 67 |
+
for route in source.app.routes:
|
| 68 |
+
if not isinstance(route, APIRoute):
|
| 69 |
+
continue
|
| 70 |
+
|
| 71 |
+
full_path = _join_paths(source.path_prefix, route.path)
|
| 72 |
+
for method in sorted(route.methods or []):
|
| 73 |
+
if method in {"HEAD", "OPTIONS"}:
|
| 74 |
+
continue
|
| 75 |
+
|
| 76 |
+
tool_name = _tool_name(source.namespace, method, route, used_names)
|
| 77 |
+
description = _tool_description(method, full_path, route)
|
| 78 |
+
tool = _make_route_tool(target_app, method, full_path)
|
| 79 |
+
server.tool(name=tool_name, description=description)(tool)
|
| 80 |
+
catalog.append(
|
| 81 |
+
{
|
| 82 |
+
"name": tool_name,
|
| 83 |
+
"method": method,
|
| 84 |
+
"path": full_path,
|
| 85 |
+
"namespace": source.namespace,
|
| 86 |
+
"tags": list(route.tags or []),
|
| 87 |
+
}
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
return server, catalog
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _make_route_tool(target_app: FastAPI, method: str, path_template: str):
|
| 94 |
+
async def invoke_endpoint(
|
| 95 |
+
path_params: dict[str, Any] | None = None,
|
| 96 |
+
query: dict[str, Any] | None = None,
|
| 97 |
+
body: Any = None,
|
| 98 |
+
body_type: BodyType = "json",
|
| 99 |
+
headers: dict[str, str] | None = None,
|
| 100 |
+
files: list[EncodedFile] | None = None,
|
| 101 |
+
) -> dict[str, Any]:
|
| 102 |
+
"""
|
| 103 |
+
Invoke the mapped Studio endpoint.
|
| 104 |
+
|
| 105 |
+
Path placeholders belong in path_params. Query-string values belong in
|
| 106 |
+
query. Use body_type="form" for form fields and files for multipart
|
| 107 |
+
uploads. Existing endpoint authentication headers are forwarded.
|
| 108 |
+
"""
|
| 109 |
+
request_path = _render_path(path_template, path_params or {})
|
| 110 |
+
request_headers = _clean_headers(headers or {})
|
| 111 |
+
request_kwargs = _request_kwargs(body, body_type, files or [])
|
| 112 |
+
|
| 113 |
+
transport = httpx.ASGITransport(app=target_app, raise_app_exceptions=False)
|
| 114 |
+
async with httpx.AsyncClient(
|
| 115 |
+
transport=transport,
|
| 116 |
+
base_url="http://studio.internal",
|
| 117 |
+
follow_redirects=True,
|
| 118 |
+
timeout=None,
|
| 119 |
+
) as client:
|
| 120 |
+
async with client.stream(
|
| 121 |
+
method,
|
| 122 |
+
request_path,
|
| 123 |
+
params=query or None,
|
| 124 |
+
headers=request_headers or None,
|
| 125 |
+
**request_kwargs,
|
| 126 |
+
) as response:
|
| 127 |
+
return await _serialize_response(response, method, request_path)
|
| 128 |
+
|
| 129 |
+
invoke_endpoint.__name__ = f"invoke_{method.lower()}_{_slug(path_template)}"
|
| 130 |
+
return invoke_endpoint
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _request_kwargs(
|
| 134 |
+
body: Any,
|
| 135 |
+
body_type: BodyType,
|
| 136 |
+
files: list[EncodedFile],
|
| 137 |
+
) -> dict[str, Any]:
|
| 138 |
+
if files:
|
| 139 |
+
multipart_files = []
|
| 140 |
+
for item in files:
|
| 141 |
+
try:
|
| 142 |
+
content = base64.b64decode(item.content_base64, validate=True)
|
| 143 |
+
except (ValueError, binascii.Error) as exc:
|
| 144 |
+
raise ValueError(f"Invalid base64 content for {item.filename}") from exc
|
| 145 |
+
multipart_files.append(
|
| 146 |
+
(
|
| 147 |
+
item.field_name,
|
| 148 |
+
(item.filename, content, item.content_type),
|
| 149 |
+
)
|
| 150 |
+
)
|
| 151 |
+
return {
|
| 152 |
+
"data": _form_items(body),
|
| 153 |
+
"files": multipart_files,
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
if body is None:
|
| 157 |
+
return {}
|
| 158 |
+
if body_type == "json":
|
| 159 |
+
return {"json": body}
|
| 160 |
+
if body_type == "form":
|
| 161 |
+
return {"data": _form_items(body)}
|
| 162 |
+
if isinstance(body, str):
|
| 163 |
+
return {"content": body}
|
| 164 |
+
return {"content": json.dumps(body)}
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _form_items(body: Any) -> dict[str, str | list[str]]:
|
| 168 |
+
if body is None:
|
| 169 |
+
return {}
|
| 170 |
+
if not isinstance(body, dict):
|
| 171 |
+
raise ValueError("Form bodies must be JSON objects")
|
| 172 |
+
|
| 173 |
+
items: dict[str, str | list[str]] = {}
|
| 174 |
+
for key, value in body.items():
|
| 175 |
+
values = value if isinstance(value, list) else [value]
|
| 176 |
+
encoded_values: list[str] = []
|
| 177 |
+
for item in values:
|
| 178 |
+
if isinstance(item, (dict, list)):
|
| 179 |
+
encoded = json.dumps(item)
|
| 180 |
+
elif item is None:
|
| 181 |
+
encoded = ""
|
| 182 |
+
elif isinstance(item, bool):
|
| 183 |
+
encoded = str(item).lower()
|
| 184 |
+
else:
|
| 185 |
+
encoded = str(item)
|
| 186 |
+
encoded_values.append(encoded)
|
| 187 |
+
items[str(key)] = encoded_values if isinstance(value, list) else encoded_values[0]
|
| 188 |
+
return items
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _render_path(path_template: str, path_params: dict[str, Any]) -> str:
|
| 192 |
+
expected = {match.group("name") for match in PATH_PARAMETER.finditer(path_template)}
|
| 193 |
+
missing = sorted(expected - path_params.keys())
|
| 194 |
+
if missing:
|
| 195 |
+
raise ValueError(f"Missing path parameters: {', '.join(missing)}")
|
| 196 |
+
|
| 197 |
+
def replace(match: re.Match[str]) -> str:
|
| 198 |
+
name = match.group("name")
|
| 199 |
+
converter = match.group("converter")
|
| 200 |
+
safe = "/" if converter == "path" else ""
|
| 201 |
+
return quote(str(path_params[name]), safe=safe)
|
| 202 |
+
|
| 203 |
+
return PATH_PARAMETER.sub(replace, path_template)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
async def _serialize_response(
|
| 207 |
+
response: httpx.Response,
|
| 208 |
+
method: str,
|
| 209 |
+
request_path: str,
|
| 210 |
+
) -> dict[str, Any]:
|
| 211 |
+
content_type = response.headers.get("content-type", "").split(";", 1)[0].lower()
|
| 212 |
+
result: dict[str, Any] = {
|
| 213 |
+
"status_code": response.status_code,
|
| 214 |
+
"method": method,
|
| 215 |
+
"path": request_path,
|
| 216 |
+
"content_type": content_type or None,
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
for header in ("content-disposition", "location"):
|
| 220 |
+
if header in response.headers:
|
| 221 |
+
result[header.replace("-", "_")] = response.headers[header]
|
| 222 |
+
|
| 223 |
+
declared_size = _content_length(response.headers.get("content-length"))
|
| 224 |
+
if declared_size is not None and declared_size > INLINE_RESPONSE_LIMIT:
|
| 225 |
+
result["size_bytes"] = declared_size
|
| 226 |
+
result["body_omitted"] = True
|
| 227 |
+
return result
|
| 228 |
+
|
| 229 |
+
content = await _read_bounded(response, INLINE_RESPONSE_LIMIT + 1)
|
| 230 |
+
if not content:
|
| 231 |
+
result["body"] = None
|
| 232 |
+
return result
|
| 233 |
+
|
| 234 |
+
if len(content) > INLINE_RESPONSE_LIMIT:
|
| 235 |
+
result["size_bytes_at_least"] = len(content)
|
| 236 |
+
result["body_omitted"] = True
|
| 237 |
+
if _is_text_content(content_type):
|
| 238 |
+
result["preview"] = content[:TEXT_PREVIEW_LIMIT].decode("utf-8", errors="replace")
|
| 239 |
+
return result
|
| 240 |
+
|
| 241 |
+
if content_type == "application/json" or content_type.endswith("+json"):
|
| 242 |
+
try:
|
| 243 |
+
result["body"] = json.loads(content)
|
| 244 |
+
except (UnicodeDecodeError, ValueError):
|
| 245 |
+
result["body"] = content[:TEXT_PREVIEW_LIMIT].decode("utf-8", errors="replace")
|
| 246 |
+
return result
|
| 247 |
+
|
| 248 |
+
if _is_text_content(content_type):
|
| 249 |
+
result["body"] = content[:TEXT_PREVIEW_LIMIT].decode("utf-8", errors="replace")
|
| 250 |
+
return result
|
| 251 |
+
|
| 252 |
+
result["size_bytes"] = len(content)
|
| 253 |
+
result["body_base64"] = base64.b64encode(content).decode("ascii")
|
| 254 |
+
return result
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
async def _read_bounded(response: httpx.Response, limit: int) -> bytes:
|
| 258 |
+
content = bytearray()
|
| 259 |
+
async for chunk in response.aiter_bytes():
|
| 260 |
+
remaining = limit - len(content)
|
| 261 |
+
if remaining <= 0:
|
| 262 |
+
break
|
| 263 |
+
content.extend(chunk[:remaining])
|
| 264 |
+
if len(content) >= limit:
|
| 265 |
+
break
|
| 266 |
+
return bytes(content)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _content_length(value: str | None) -> int | None:
|
| 270 |
+
if value is None:
|
| 271 |
+
return None
|
| 272 |
+
try:
|
| 273 |
+
return max(0, int(value))
|
| 274 |
+
except ValueError:
|
| 275 |
+
return None
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _tool_name(
|
| 279 |
+
namespace: str,
|
| 280 |
+
method: str,
|
| 281 |
+
route: APIRoute,
|
| 282 |
+
used_names: set[str],
|
| 283 |
+
) -> str:
|
| 284 |
+
base = _slug(f"{namespace}_{method}_{route.name or route.path}")
|
| 285 |
+
if len(base) > 96:
|
| 286 |
+
digest = hashlib.sha1(base.encode("utf-8")).hexdigest()[:10]
|
| 287 |
+
base = f"{base[:85]}_{digest}"
|
| 288 |
+
|
| 289 |
+
candidate = base
|
| 290 |
+
suffix = 2
|
| 291 |
+
while candidate in used_names:
|
| 292 |
+
candidate = f"{base}_{suffix}"
|
| 293 |
+
suffix += 1
|
| 294 |
+
used_names.add(candidate)
|
| 295 |
+
return candidate
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def _tool_description(method: str, full_path: str, route: APIRoute) -> str:
|
| 299 |
+
summary = route.summary or route.description or route.name or "Studio API endpoint"
|
| 300 |
+
summary = " ".join(summary.split())
|
| 301 |
+
return (
|
| 302 |
+
f"{method} {full_path}. {summary} "
|
| 303 |
+
"Arguments: path_params, query, body, body_type, headers, and base64 files."
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _join_paths(prefix: str, path: str) -> str:
|
| 308 |
+
if not prefix:
|
| 309 |
+
return path or "/"
|
| 310 |
+
if path == "/":
|
| 311 |
+
return f"{prefix.rstrip('/')}/"
|
| 312 |
+
return f"{prefix.rstrip('/')}/{path.lstrip('/')}"
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def _clean_headers(headers: dict[str, str]) -> dict[str, str]:
|
| 316 |
+
return {
|
| 317 |
+
str(key): str(value)
|
| 318 |
+
for key, value in headers.items()
|
| 319 |
+
if str(key).lower() not in RESTRICTED_HEADERS
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def _is_text_content(content_type: str) -> bool:
|
| 324 |
+
return (
|
| 325 |
+
content_type.startswith("text/")
|
| 326 |
+
or content_type in {"application/javascript", "application/xml"}
|
| 327 |
+
or content_type.endswith("+xml")
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def _slug(value: str) -> str:
|
| 332 |
+
cleaned = re.sub(r"[^a-zA-Z0-9_-]+", "_", value).strip("_").lower()
|
| 333 |
+
return cleaned or "endpoint"
|
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="/")
|
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}"
|
renderer_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)
|
requirements.editor.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
gradio
|
| 4 |
+
python-multipart
|
| 5 |
+
python-magic
|
| 6 |
+
yt-dlp
|
requirements.ktts.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
|
requirements.musicgen.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
|
requirements.renderer.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.0
|
| 2 |
+
uvicorn[standard]>=0.30.0
|
| 3 |
+
gradio>=5.0.0
|
| 4 |
+
huggingface_hub<1.0
|
| 5 |
+
pydantic>=2.7.0
|
| 6 |
+
python-multipart>=0.0.9
|
| 7 |
+
psutil>=5.9.8
|
| 8 |
+
pytest>=8.2.0
|
| 9 |
+
pytest-cov>=5.0.0
|
| 10 |
+
httpx>=0.27.0
|
| 11 |
+
faster-whisper>=1.0.0
|
requirements.txt
CHANGED
|
@@ -1,9 +1,44 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 2 |
+
|
| 3 |
+
accelerate
|
| 4 |
+
aiofiles>=23.2.1
|
| 5 |
+
email-validator
|
| 6 |
+
fastapi>=0.115.0
|
| 7 |
+
faster-whisper==1.0.3
|
| 8 |
+
ffmpeg-python>=0.2.0
|
| 9 |
+
google-api-python-client>=2.118.0
|
| 10 |
+
google-auth>=2.29.0
|
| 11 |
+
google-auth-httplib2>=0.2.0
|
| 12 |
+
google-auth-oauthlib>=1.2.0
|
| 13 |
+
google-generativeai>=0.7.2
|
| 14 |
+
gradio>=5.0.0
|
| 15 |
+
httplib2>=0.22.0
|
| 16 |
+
huggingface_hub<1.0
|
| 17 |
+
httpx>=0.27.0
|
| 18 |
+
imageio>=2.34.0
|
| 19 |
+
imageio-ffmpeg>=0.4.9
|
| 20 |
+
moviepy==1.0.3
|
| 21 |
+
mcp>=1.28.1,<2
|
| 22 |
+
numpy>=1.26.0
|
| 23 |
+
onnxruntime
|
| 24 |
+
passlib[bcrypt]>=1.7.4
|
| 25 |
+
phonemizer
|
| 26 |
+
psutil>=5.9.8
|
| 27 |
+
psycopg2-binary>=2.9.9
|
| 28 |
+
pydantic>=2.7.0
|
| 29 |
+
pytest>=8.2.0
|
| 30 |
+
pytest-cov>=5.0.0
|
| 31 |
+
pysrt>=1.1.2
|
| 32 |
+
python-jose[cryptography]>=3.3.0
|
| 33 |
+
python-magic
|
| 34 |
+
python-multipart>=0.0.9
|
| 35 |
+
requests>=2.31.0
|
| 36 |
+
scipy
|
| 37 |
+
soundfile
|
| 38 |
+
sqlalchemy>=2.0.25
|
| 39 |
+
torch
|
| 40 |
+
torchaudio
|
| 41 |
+
torchvision
|
| 42 |
+
transformers
|
| 43 |
+
uvicorn[standard]>=0.30.0
|
| 44 |
+
yt-dlp>=2025.1.0
|
requirements.whisper.txt
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =========================
|
| 2 |
+
# CORE API
|
| 3 |
+
# =========================
|
| 4 |
+
fastapi>=0.110.0
|
| 5 |
+
uvicorn[standard]>=0.27.0
|
| 6 |
+
python-multipart>=0.0.9
|
| 7 |
+
|
| 8 |
+
# =========================
|
| 9 |
+
# UI
|
| 10 |
+
# =========================
|
| 11 |
+
gradio>=4.0.0
|
| 12 |
+
|
| 13 |
+
# =========================
|
| 14 |
+
# VIDEO PIPELINE
|
| 15 |
+
# =========================
|
| 16 |
+
faster-whisper==1.0.3
|
| 17 |
+
moviepy==1.0.3
|
| 18 |
+
ffmpeg-python>=0.2.0
|
| 19 |
+
imageio>=2.34.0
|
| 20 |
+
imageio-ffmpeg>=0.4.9
|
| 21 |
+
pysrt>=1.1.2
|
| 22 |
+
|
| 23 |
+
# =========================
|
| 24 |
+
# NUMERICS / DATA
|
| 25 |
+
# =========================
|
| 26 |
+
numpy>=1.26.0
|
| 27 |
+
pydantic>=2.6.0
|
| 28 |
+
|
| 29 |
+
# =========================
|
| 30 |
+
# YOUTUBE / SOCIAL INGESTION
|
| 31 |
+
# =========================
|
| 32 |
+
yt-dlp>=2025.1.0
|
| 33 |
+
|
| 34 |
+
# =========================
|
| 35 |
+
# HTTP / NETWORK
|
| 36 |
+
# =========================
|
| 37 |
+
requests>=2.31.0
|
| 38 |
+
httplib2>=0.22.0
|
| 39 |
+
|
| 40 |
+
# =========================
|
| 41 |
+
# GOOGLE YOUTUBE + PLATFORM APIs
|
| 42 |
+
# =========================
|
| 43 |
+
google-api-python-client>=2.118.0
|
| 44 |
+
google-auth>=2.29.0
|
| 45 |
+
google-auth-oauthlib>=1.2.0
|
| 46 |
+
google-auth-httplib2>=0.2.0
|
| 47 |
+
|
| 48 |
+
# =========================
|
| 49 |
+
# GEMINI AI
|
| 50 |
+
# =========================
|
| 51 |
+
google-generativeai>=0.7.2
|
| 52 |
+
|
| 53 |
+
# =========================
|
| 54 |
+
# AUTH SYSTEM (JWT + SECURITY)
|
| 55 |
+
# =========================
|
| 56 |
+
python-jose[cryptography]>=3.3.0
|
| 57 |
+
|
| 58 |
+
# FIXED BCRYPT STACK (CRITICAL STABILITY FIX)
|
| 59 |
+
passlib[bcrypt]>=1.7.4
|
| 60 |
+
email-validator
|
| 61 |
+
|
| 62 |
+
# =========================
|
| 63 |
+
# DATABASE (SQLALCHEMY + POSTGRES)
|
| 64 |
+
# =========================
|
| 65 |
+
sqlalchemy>=2.0.25
|
| 66 |
+
psycopg2-binary>=2.9.9
|
| 67 |
+
|
| 68 |
+
# =========================
|
| 69 |
+
# OPTIONAL STABILITY LAYER
|
| 70 |
+
# =========================
|
| 71 |
+
aiofiles>=23.2.1
|
start.sh
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -e
|
| 3 |
+
|
| 4 |
+
echo "Starting Unified Media Studio..."
|
| 5 |
+
|
| 6 |
+
exec uvicorn app:app --host 0.0.0.0 --port "${PORT:-7860}"
|
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 |
+
}
|
whisper_app.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, UploadFile, File, Form, Request
|
| 2 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 3 |
+
from contextlib import asynccontextmanager
|
| 4 |
+
import os
|
| 5 |
+
import uuid
|
| 6 |
+
import asyncio
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import gradio as gr
|
| 9 |
+
|
| 10 |
+
# ==============================
|
| 11 |
+
# LOGGER + QUEUE
|
| 12 |
+
# ==============================
|
| 13 |
+
from utils.logger import logger
|
| 14 |
+
from utils.job_queue import start_worker, create_job, get_job
|
| 15 |
+
from ingestion.resolver import resolve_input
|
| 16 |
+
|
| 17 |
+
# ==============================
|
| 18 |
+
# AUTH SYSTEM (HARDENED IMPORT)
|
| 19 |
+
# ==============================
|
| 20 |
+
try:
|
| 21 |
+
from auth.routes import router as auth_router
|
| 22 |
+
from auth.database import Base, engine
|
| 23 |
+
AUTH_ENABLED = True
|
| 24 |
+
except Exception as e:
|
| 25 |
+
logger.error(f"[AUTH BOOT FAILED] {e}")
|
| 26 |
+
AUTH_ENABLED = False
|
| 27 |
+
|
| 28 |
+
# ==============================
|
| 29 |
+
# CORE PIPELINE
|
| 30 |
+
# ==============================
|
| 31 |
+
from utils.transcription import transcribe_video
|
| 32 |
+
from utils.srt import generate_srt
|
| 33 |
+
from utils.render import render_subtitles
|
| 34 |
+
from utils.highlights import detect_highlights
|
| 35 |
+
from utils.viral_scorer import score_clip
|
| 36 |
+
from utils.director import rewrite_script, viral_score
|
| 37 |
+
from utils.engagement import simulate_retention
|
| 38 |
+
from utils.platform import adapt_platform
|
| 39 |
+
from utils.persona import predict_audience
|
| 40 |
+
from utils.clipper import create_clips
|
| 41 |
+
from utils.autonomous_engine import run_autonomous_engine
|
| 42 |
+
|
| 43 |
+
# ==============================
|
| 44 |
+
# PUBLISHER
|
| 45 |
+
# ==============================
|
| 46 |
+
from publisher.publisher_ai import autonomous_loop
|
| 47 |
+
from publisher.scheduler_engine import init_scheduler
|
| 48 |
+
from publisher.platform_dispatcher import dispatch_publish
|
| 49 |
+
from publisher.bulk import execute as bulk_execute
|
| 50 |
+
from publisher.metadata_engine import generate_metadata
|
| 51 |
+
from publisher.thumbnail_engine import generate_thumbnail
|
| 52 |
+
|
| 53 |
+
# ==============================
|
| 54 |
+
# SAFE bcrypt SHIELD (DO NOT FAIL BOOT)
|
| 55 |
+
# ==============================
|
| 56 |
+
try:
|
| 57 |
+
import bcrypt
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.warning(f"[bcrypt warning ignored] {e}")
|
| 60 |
+
|
| 61 |
+
# ==============================
|
| 62 |
+
# INIT
|
| 63 |
+
# ==============================
|
| 64 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 65 |
+
UPLOAD_DIR = str(BASE_DIR / "jobs")
|
| 66 |
+
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# =========================================================
|
| 70 |
+
# SAFE DB INITIALIZATION
|
| 71 |
+
# =========================================================
|
| 72 |
+
def init_database_safe():
|
| 73 |
+
if not AUTH_ENABLED:
|
| 74 |
+
logger.warning("Auth disabled - skipping DB init")
|
| 75 |
+
return
|
| 76 |
+
|
| 77 |
+
try:
|
| 78 |
+
Base.metadata.create_all(bind=engine)
|
| 79 |
+
logger.info("Database initialized successfully")
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.error(f"Database init failed (non-fatal): {e}")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# =========================================================
|
| 85 |
+
# LIFECYCLE
|
| 86 |
+
# =========================================================
|
| 87 |
+
@asynccontextmanager
|
| 88 |
+
async def lifespan(app: FastAPI):
|
| 89 |
+
|
| 90 |
+
logger.info("Starting Basyx Whisper V10.1")
|
| 91 |
+
|
| 92 |
+
# DB init (NON-FATAL)
|
| 93 |
+
init_database_safe()
|
| 94 |
+
|
| 95 |
+
# workers (must not block boot)
|
| 96 |
+
try:
|
| 97 |
+
start_worker()
|
| 98 |
+
except Exception as e:
|
| 99 |
+
logger.error(f"Worker failed: {e}")
|
| 100 |
+
|
| 101 |
+
# scheduler
|
| 102 |
+
try:
|
| 103 |
+
init_scheduler()
|
| 104 |
+
except Exception as e:
|
| 105 |
+
logger.error(f"Scheduler failed: {e}")
|
| 106 |
+
|
| 107 |
+
# autonomous engine (isolated task)
|
| 108 |
+
try:
|
| 109 |
+
asyncio.create_task(autonomous_loop())
|
| 110 |
+
except Exception as e:
|
| 111 |
+
logger.error(f"Autonomous engine failed: {e}")
|
| 112 |
+
|
| 113 |
+
yield
|
| 114 |
+
|
| 115 |
+
logger.info("Shutdown complete")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# =========================================================
|
| 119 |
+
# APP
|
| 120 |
+
# =========================================================
|
| 121 |
+
app = FastAPI(
|
| 122 |
+
title="Basyx Whisper V10.1 Autonomous Operator",
|
| 123 |
+
lifespan=lifespan,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# AUTH ROUTER (only if available)
|
| 127 |
+
if AUTH_ENABLED:
|
| 128 |
+
app.include_router(auth_router)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# =========================================================
|
| 132 |
+
# TASKS
|
| 133 |
+
# =========================================================
|
| 134 |
+
VALID_TASKS = {
|
| 135 |
+
"autonomous",
|
| 136 |
+
"auto-publish",
|
| 137 |
+
"publish",
|
| 138 |
+
"bulk-publish",
|
| 139 |
+
"generate-metadata",
|
| 140 |
+
"generate-thumbnail",
|
| 141 |
+
"schedule-post",
|
| 142 |
+
"transcribe",
|
| 143 |
+
"subtitles",
|
| 144 |
+
"render",
|
| 145 |
+
"highlights",
|
| 146 |
+
"viral-score",
|
| 147 |
+
"strategy",
|
| 148 |
+
"batch",
|
| 149 |
+
"clips",
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def normalize_task(task: str):
|
| 154 |
+
task = task.lower().replace("_", "-")
|
| 155 |
+
if task not in VALID_TASKS:
|
| 156 |
+
raise Exception(f"Unknown task: {task}")
|
| 157 |
+
return task
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# =========================================================
|
| 161 |
+
# SAFE INPUT RESOLVER
|
| 162 |
+
# =========================================================
|
| 163 |
+
async def safe_resolve(file, source):
|
| 164 |
+
try:
|
| 165 |
+
if not file and not source:
|
| 166 |
+
return None
|
| 167 |
+
|
| 168 |
+
upload_file = file if isinstance(file, UploadFile) else None
|
| 169 |
+
|
| 170 |
+
return await asyncio.to_thread(resolve_input, source, upload_file)
|
| 171 |
+
|
| 172 |
+
except Exception as e:
|
| 173 |
+
logger.error(f"resolve_input failed: {e}")
|
| 174 |
+
return None
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# =========================================================
|
| 178 |
+
# EXECUTION ENGINE
|
| 179 |
+
# =========================================================
|
| 180 |
+
async def execute_task(video_path, task, payload=None, webhook=None):
|
| 181 |
+
|
| 182 |
+
payload = payload or {}
|
| 183 |
+
|
| 184 |
+
if task == "bulk-publish":
|
| 185 |
+
return await bulk_execute(payload), None
|
| 186 |
+
|
| 187 |
+
if task not in ["bulk-publish", "schedule-post"] and not video_path:
|
| 188 |
+
return {"error": "No valid input resolved"}, None
|
| 189 |
+
|
| 190 |
+
if task == "autonomous":
|
| 191 |
+
return await asyncio.to_thread(run_autonomous_engine, video_path), None
|
| 192 |
+
|
| 193 |
+
if task == "auto-publish":
|
| 194 |
+
auto = await asyncio.to_thread(run_autonomous_engine, video_path)
|
| 195 |
+
return await dispatch_publish(variants=auto.get("all_variants", [])), None
|
| 196 |
+
|
| 197 |
+
if task == "publish":
|
| 198 |
+
return await dispatch_publish(video_path=video_path, payload=payload), None
|
| 199 |
+
|
| 200 |
+
if task == "generate-metadata":
|
| 201 |
+
return generate_metadata(video_path), None
|
| 202 |
+
|
| 203 |
+
if task == "generate-thumbnail":
|
| 204 |
+
output_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}.jpg")
|
| 205 |
+
thumb = generate_thumbnail(video_path, output=output_path)
|
| 206 |
+
return {"thumbnail": thumb}, output_path
|
| 207 |
+
|
| 208 |
+
if task == "batch":
|
| 209 |
+
job_id = create_job(video_path, webhook=webhook)
|
| 210 |
+
return {"status": "queued", "job_id": job_id}, None
|
| 211 |
+
|
| 212 |
+
if task == "transcribe":
|
| 213 |
+
words = await asyncio.to_thread(transcribe_video, video_path)
|
| 214 |
+
return {"words": words}, None
|
| 215 |
+
|
| 216 |
+
if task == "subtitles":
|
| 217 |
+
words = await asyncio.to_thread(transcribe_video, video_path)
|
| 218 |
+
return {"srt": generate_srt(words)}, None
|
| 219 |
+
|
| 220 |
+
if task == "render":
|
| 221 |
+
words = await asyncio.to_thread(transcribe_video, video_path)
|
| 222 |
+
srt = generate_srt(words)
|
| 223 |
+
|
| 224 |
+
output = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}_render.mp4")
|
| 225 |
+
|
| 226 |
+
await asyncio.to_thread(render_subtitles, video_path, srt, output)
|
| 227 |
+
|
| 228 |
+
return {"status": "render_complete"}, output
|
| 229 |
+
|
| 230 |
+
if task == "highlights":
|
| 231 |
+
words = await asyncio.to_thread(transcribe_video, video_path)
|
| 232 |
+
highlights = detect_highlights(words) or []
|
| 233 |
+
clips = create_clips(video_path, highlights)
|
| 234 |
+
|
| 235 |
+
return {"clips_created": len(clips)}, (clips[0] if clips else None)
|
| 236 |
+
|
| 237 |
+
if task == "clips":
|
| 238 |
+
words = await asyncio.to_thread(transcribe_video, video_path)
|
| 239 |
+
highlights = detect_highlights(words) or []
|
| 240 |
+
return {"clips": create_clips(video_path, highlights)}, None
|
| 241 |
+
|
| 242 |
+
if task == "viral-score":
|
| 243 |
+
words = await asyncio.to_thread(transcribe_video, video_path)
|
| 244 |
+
segments = detect_highlights(words) or []
|
| 245 |
+
return {"scores": [score_clip(s) for s in segments]}, None
|
| 246 |
+
|
| 247 |
+
if task == "strategy":
|
| 248 |
+
words = await asyncio.to_thread(transcribe_video, video_path)
|
| 249 |
+
|
| 250 |
+
script = rewrite_script(words)
|
| 251 |
+
persona = predict_audience(words)
|
| 252 |
+
curve = simulate_retention(words)
|
| 253 |
+
|
| 254 |
+
return {
|
| 255 |
+
"hook": script["hook"],
|
| 256 |
+
"persona": persona,
|
| 257 |
+
"viral_score": viral_score(curve),
|
| 258 |
+
"platforms": {
|
| 259 |
+
"tiktok": adapt_platform(script, "tiktok"),
|
| 260 |
+
"reels": adapt_platform(script, "reels"),
|
| 261 |
+
},
|
| 262 |
+
}, None
|
| 263 |
+
|
| 264 |
+
return {"error": "Task execution failed"}, None
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# =========================================================
|
| 268 |
+
# ROUTER
|
| 269 |
+
# =========================================================
|
| 270 |
+
@app.post("/execute/{task_name}")
|
| 271 |
+
async def execute_router(
|
| 272 |
+
request: Request,
|
| 273 |
+
task_name: str,
|
| 274 |
+
file: UploadFile = File(None),
|
| 275 |
+
url_input: str = Form(None),
|
| 276 |
+
source: str = Form(None),
|
| 277 |
+
webhook: str = Form(None),
|
| 278 |
+
):
|
| 279 |
+
|
| 280 |
+
try:
|
| 281 |
+
task = normalize_task(task_name)
|
| 282 |
+
payload = {}
|
| 283 |
+
|
| 284 |
+
if request.headers.get("content-type", "").startswith("application/json"):
|
| 285 |
+
payload = await request.json()
|
| 286 |
+
|
| 287 |
+
video_path = await safe_resolve(file, url_input or source)
|
| 288 |
+
|
| 289 |
+
result, output = await execute_task(video_path, task, payload, webhook)
|
| 290 |
+
|
| 291 |
+
if output and isinstance(output, str) and os.path.exists(output):
|
| 292 |
+
return FileResponse(output)
|
| 293 |
+
|
| 294 |
+
return {"task": task, "result": result}
|
| 295 |
+
|
| 296 |
+
except Exception as e:
|
| 297 |
+
logger.exception(e)
|
| 298 |
+
return JSONResponse({"error": str(e)}, status_code=500)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# =========================================================
|
| 302 |
+
# HEALTH
|
| 303 |
+
# =========================================================
|
| 304 |
+
@app.get("/api/health")
|
| 305 |
+
def health():
|
| 306 |
+
return {
|
| 307 |
+
"status": "online",
|
| 308 |
+
"version": "V10.1",
|
| 309 |
+
"auth_enabled": AUTH_ENABLED
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
@app.get("/api/status/{job_id}")
|
| 314 |
+
def status(job_id: str):
|
| 315 |
+
return get_job(job_id) or {"error": "Job not found"}
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# =========================================================
|
| 319 |
+
# GRADIO UI
|
| 320 |
+
# =========================================================
|
| 321 |
+
async def ui_handler(video, task, webhook, url_input):
|
| 322 |
+
|
| 323 |
+
source = url_input or video
|
| 324 |
+
video_path = await safe_resolve(video, source)
|
| 325 |
+
|
| 326 |
+
result, output = await execute_task(
|
| 327 |
+
video_path,
|
| 328 |
+
normalize_task(task),
|
| 329 |
+
{},
|
| 330 |
+
webhook,
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
return str(result), output
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
with gr.Blocks() as demo:
|
| 337 |
+
|
| 338 |
+
gr.Markdown("# 🚀 Basyx Whisper V10.1 Stable Operator")
|
| 339 |
+
|
| 340 |
+
video_input = gr.Video()
|
| 341 |
+
url_input = gr.Textbox(label="Video URL")
|
| 342 |
+
|
| 343 |
+
task_dropdown = gr.Dropdown(
|
| 344 |
+
choices=list(VALID_TASKS),
|
| 345 |
+
value="autonomous",
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
webhook_input = gr.Textbox(label="Webhook")
|
| 349 |
+
|
| 350 |
+
run_btn = gr.Button("Execute")
|
| 351 |
+
|
| 352 |
+
output_box = gr.Textbox()
|
| 353 |
+
video_output = gr.Video()
|
| 354 |
+
|
| 355 |
+
run_btn.click(
|
| 356 |
+
ui_handler,
|
| 357 |
+
[video_input, task_dropdown, webhook_input, url_input],
|
| 358 |
+
[output_box, video_output],
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
app = gr.mount_gradio_app(app, demo, path="/")
|