Ava2lon commited on
Commit
1425afc
·
verified ·
1 Parent(s): 17f54f2

Upload 205 files

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

Git LFS Details

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