Spaces:
Running
Running
Upload 11 files
Browse files- .env.example +15 -0
- .gitignore +5 -0
- MCP.md +25 -1
- README.md +7 -0
- app.py +238 -6
.env.example
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Shared stack security (set both to long random values in production)
|
| 2 |
+
FFMPEG_API_TOKEN=
|
| 3 |
+
FFMPEG_CALLBACK_TOKEN=
|
| 4 |
+
N8N_ENCRYPTION_KEY=replace-this-development-key
|
| 5 |
+
GRADIO_MCP_SERVER=True
|
| 6 |
+
|
| 7 |
+
# Public URLs used by webhook registration and API callback payloads
|
| 8 |
+
N8N_WEBHOOK_URL=http://localhost:5678/
|
| 9 |
+
FFMPEG_PUBLIC_BASE_URL=http://ffmpeg-api:7860
|
| 10 |
+
|
| 11 |
+
# Optional deployment settings
|
| 12 |
+
N8N_HOST=localhost
|
| 13 |
+
N8N_PROTOCOL=http
|
| 14 |
+
MAX_UPLOAD_MB=500
|
| 15 |
+
FILE_TTL_SECONDS=3600
|
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
temp/
|
| 5 |
+
.env
|
MCP.md
CHANGED
|
@@ -25,7 +25,7 @@ For a remote deployment, replace `localhost:7860` with the public HTTPS host.
|
|
| 25 |
|
| 26 |
Returns the 90 allowlisted task IDs, descriptions, output extensions, accepted
|
| 27 |
media categories, and file-count requirements. Call this first when the client
|
| 28 |
-
does not already know the task ID.
|
| 29 |
|
| 30 |
### `run_ffmpeg_task`
|
| 31 |
|
|
@@ -50,6 +50,30 @@ Example advanced options:
|
|
| 50 |
For long-running media, the n8n background-job API remains preferable because
|
| 51 |
MCP calls are synchronous and keep the client request open until FFmpeg finishes.
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
## Installation and startup
|
| 54 |
|
| 55 |
The MCP dependencies are installed by the `gradio[mcp]` requirement.
|
|
|
|
| 25 |
|
| 26 |
Returns the 90 allowlisted task IDs, descriptions, output extensions, accepted
|
| 27 |
media categories, and file-count requirements. Call this first when the client
|
| 28 |
+
does not already know the task ID. Every returned task ID is also an MCP tool.
|
| 29 |
|
| 30 |
### `run_ffmpeg_task`
|
| 31 |
|
|
|
|
| 50 |
For long-running media, the n8n background-job API remains preferable because
|
| 51 |
MCP calls are synchronous and keep the client request open until FFmpeg finishes.
|
| 52 |
|
| 53 |
+
### Fixed task tools
|
| 54 |
+
|
| 55 |
+
Each of the 90 task IDs is exposed directly as a tool. Examples include
|
| 56 |
+
`compress`, `trim`, `video_effect`, `audio_mix`, `episode_assembly`, and
|
| 57 |
+
`render_alpha_webm`. A fixed task tool accepts this compact payload shape:
|
| 58 |
+
|
| 59 |
+
```json
|
| 60 |
+
{
|
| 61 |
+
"files": ["https://example.com/input.mp4"],
|
| 62 |
+
"url": "",
|
| 63 |
+
"options": {
|
| 64 |
+
"resolution": "720p",
|
| 65 |
+
"crf": "28",
|
| 66 |
+
"preset": "fast"
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Use `files` for MCP uploads, `url` for one downloadable input placed before the
|
| 72 |
+
uploaded files, and `options` for allowlisted task settings. File order matters
|
| 73 |
+
for tasks such as `concat`, `watermark`, subtitles, narration, and transitions.
|
| 74 |
+
Each generated tool description includes its own fenced JSON payload example
|
| 75 |
+
with suitable file names and common options for that task.
|
| 76 |
+
|
| 77 |
## Installation and startup
|
| 78 |
|
| 79 |
The MCP dependencies are installed by the `gradio[mcp]` requirement.
|
README.md
CHANGED
|
@@ -70,6 +70,13 @@ Inspect its generated tool schemas at
|
|
| 70 |
- `list_ffmpeg_tasks`: returns all task IDs and file requirements.
|
| 71 |
- `run_ffmpeg_task`: runs a task with files, a media URL, standard controls, and
|
| 72 |
an allowlisted advanced-options JSON object.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
Set `GRADIO_MCP_SERVER=False` to disable MCP. When `API_AUTH_TOKEN` is set, MCP
|
| 75 |
clients must send the same bearer token or `X-API-Key` used by the REST API.
|
|
|
|
| 70 |
- `list_ffmpeg_tasks`: returns all task IDs and file requirements.
|
| 71 |
- `run_ffmpeg_task`: runs a task with files, a media URL, standard controls, and
|
| 72 |
an allowlisted advanced-options JSON object.
|
| 73 |
+
- one fixed tool for each of the 90 task IDs, such as `compress`, `trim`,
|
| 74 |
+
`video_effect`, and `audio_mix`. These tools accept `files`, `url`, and an
|
| 75 |
+
`options` JSON object without requiring a separate task selector.
|
| 76 |
+
|
| 77 |
+
Every MCP tool description includes a fenced JSON payload example. The fixed
|
| 78 |
+
task descriptions also document file-count requirements, accepted media types,
|
| 79 |
+
output extension, and upload order.
|
| 80 |
|
| 81 |
Set `GRADIO_MCP_SERVER=False` to disable MCP. When `API_AUTH_TOKEN` is set, MCP
|
| 82 |
clients must send the same bearer token or `X-API-Key` used by the REST API.
|
app.py
CHANGED
|
@@ -2966,7 +2966,7 @@ def n8n_manifest(request: Request):
|
|
| 2966 |
"enabled": MCP_SERVER_ENABLED,
|
| 2967 |
"endpoint": f"{base_url}/gradio_api/mcp/",
|
| 2968 |
"schema": f"{base_url}/gradio_api/mcp/schema",
|
| 2969 |
-
"tools": ["list_ffmpeg_tasks", "run_ffmpeg_task"],
|
| 2970 |
},
|
| 2971 |
}
|
| 2972 |
|
|
@@ -3240,12 +3240,212 @@ def task_choices() -> List[tuple[str, str]]:
|
|
| 3240 |
def mcp_task_catalog() -> Dict[str, Dict[str, object]]:
|
| 3241 |
"""List every available FFmpeg task and its required input file types.
|
| 3242 |
|
| 3243 |
-
|
| 3244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3245 |
"""
|
| 3246 |
return list_tasks()
|
| 3247 |
|
| 3248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3249 |
def selected_task_id(choice: str) -> str:
|
| 3250 |
if not choice:
|
| 3251 |
return "normalize"
|
|
@@ -3389,6 +3589,20 @@ def ui_handler(
|
|
| 3389 |
downloadable media URL, or both. Use advanced_options_json for task-specific
|
| 3390 |
settings such as effects, codecs, transitions, episode metadata, or audio
|
| 3391 |
mixing levels. Multiple files are processed in upload order.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3392 |
"""
|
| 3393 |
task_id = selected_task_id(task_choice)
|
| 3394 |
task = get_task(task_id)
|
|
@@ -3433,6 +3647,9 @@ def ui_handler(
|
|
| 3433 |
raise gr.Error(str(exc.detail))
|
| 3434 |
|
| 3435 |
|
|
|
|
|
|
|
|
|
|
| 3436 |
def history_table():
|
| 3437 |
with STATE_LOCK:
|
| 3438 |
rows = [
|
|
@@ -3518,9 +3735,8 @@ with gr.Blocks(title="Basyx FFmpeg Automation Hub") as ui:
|
|
| 3518 |
label="Recent outputs",
|
| 3519 |
value=history_table,
|
| 3520 |
)
|
| 3521 |
-
# Hidden components
|
| 3522 |
-
#
|
| 3523 |
-
# tools while UI-only state changes remain excluded.
|
| 3524 |
mcp_catalog_button = gr.Button("MCP: List tasks", visible=False)
|
| 3525 |
mcp_catalog_output = gr.JSON(visible=False)
|
| 3526 |
mcp_catalog_button.click(
|
|
@@ -3529,6 +3745,22 @@ with gr.Blocks(title="Basyx FFmpeg Automation Hub") as ui:
|
|
| 3529 |
outputs=mcp_catalog_output,
|
| 3530 |
api_name="list_ffmpeg_tasks",
|
| 3531 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3532 |
|
| 3533 |
task_input.change(
|
| 3534 |
option_visibility,
|
|
|
|
| 2966 |
"enabled": MCP_SERVER_ENABLED,
|
| 2967 |
"endpoint": f"{base_url}/gradio_api/mcp/",
|
| 2968 |
"schema": f"{base_url}/gradio_api/mcp/schema",
|
| 2969 |
+
"tools": ["list_ffmpeg_tasks", "run_ffmpeg_task", *TASKS.keys()],
|
| 2970 |
},
|
| 2971 |
}
|
| 2972 |
|
|
|
|
| 3240 |
def mcp_task_catalog() -> Dict[str, Dict[str, object]]:
|
| 3241 |
"""List every available FFmpeg task and its required input file types.
|
| 3242 |
|
| 3243 |
+
Each returned task ID is also a dedicated MCP tool. It can additionally be
|
| 3244 |
+
passed as task_choice to run_ffmpeg_task. Multiple files are consumed in
|
| 3245 |
+
upload order.
|
| 3246 |
+
|
| 3247 |
+
Payload example:
|
| 3248 |
+
|
| 3249 |
+
```json
|
| 3250 |
+
{}
|
| 3251 |
+
```
|
| 3252 |
"""
|
| 3253 |
return list_tasks()
|
| 3254 |
|
| 3255 |
|
| 3256 |
+
mcp_task_catalog.__name__ = "list_ffmpeg_tasks"
|
| 3257 |
+
|
| 3258 |
+
|
| 3259 |
+
MCP_TASK_OPTION_EXAMPLES: Dict[str, Dict[str, str]] = {
|
| 3260 |
+
"split_video": {"split_duration": "30"},
|
| 3261 |
+
"remove_section": {"remove_start": "12", "remove_end": "18"},
|
| 3262 |
+
"freeze_frame": {"freeze_at": "5", "freeze_duration": "3"},
|
| 3263 |
+
"loop_video": {"loop_count": "3"},
|
| 3264 |
+
"extract_audio": {"audio_bitrate": "192"},
|
| 3265 |
+
"audio_trim": {"start_time": "00:00:05", "end_time": "00:00:30"},
|
| 3266 |
+
"audio_mix": {"voice_volume": "1", "music_volume": "0.25"},
|
| 3267 |
+
"audio_fade": {"volume": "1", "fade_in": "2", "fade_out": "2"},
|
| 3268 |
+
"audio_delay": {"delay": "500"},
|
| 3269 |
+
"audio_speed": {"speed": "1.25"},
|
| 3270 |
+
"audio_channels": {"channels": "stereo"},
|
| 3271 |
+
"audio_denoise": {"noise_reduction": "12"},
|
| 3272 |
+
"add_subtitles": {"caption_style": "default", "subtitle_position": "bottom"},
|
| 3273 |
+
"burn_lyrics": {"caption_style": "karaoke", "subtitle_position": "center"},
|
| 3274 |
+
"text_overlay": {"text": "Sample caption", "font_size": "60"},
|
| 3275 |
+
"merge_music": {"music_volume": "0.3"},
|
| 3276 |
+
"thumbnail": {"timestamp": "00:00:02"},
|
| 3277 |
+
"watermark": {"position": "bottom-right", "opacity": "0.8"},
|
| 3278 |
+
"image_process": {"width": "1080", "height": "1080", "operation": "resize"},
|
| 3279 |
+
"image_watermark": {"position": "bottom-right", "opacity": "0.8"},
|
| 3280 |
+
"compress": {"resolution": "720p", "crf": "28", "preset": "fast"},
|
| 3281 |
+
"batch_compress": {"crf": "28", "preset": "fast"},
|
| 3282 |
+
"make_gif": {"duration": "5", "fps": "10", "width": "480"},
|
| 3283 |
+
"tiktok_lyrics": {"text": "Lyrics go here", "duration": "15", "font_size": "60"},
|
| 3284 |
+
"tiktok_pro_reframer": {"text": "Hook text", "font_size": "60"},
|
| 3285 |
+
"reels_blur_fit": {"crf": "23", "preset": "fast"},
|
| 3286 |
+
"reels_safe_caption": {"text": "Caption text", "font_size": "52"},
|
| 3287 |
+
"reels_hook_title": {"text": "Hook title", "font_size": "64"},
|
| 3288 |
+
"reels_progress_bar": {"duration": "15"},
|
| 3289 |
+
"reels_loop": {"duration": "15", "crf": "23"},
|
| 3290 |
+
"reels_subtitle_safe": {"caption_style": "default", "subtitle_position": "bottom"},
|
| 3291 |
+
"reels_reaction_stack": {"crf": "23", "preset": "fast"},
|
| 3292 |
+
"reels_audio_duck": {"music_volume": "0.2", "voice_volume": "1"},
|
| 3293 |
+
"faceless_quote_card": {"text": "Your quote", "duration": "8", "font_size": "64"},
|
| 3294 |
+
"faceless_story_pages": {"text": "Long story text", "image_duration": "4"},
|
| 3295 |
+
"faceless_image_narration": {"text": "Narration caption", "font_size": "52"},
|
| 3296 |
+
"faceless_video_narration": {"text": "Narration caption", "font_size": "52"},
|
| 3297 |
+
"faceless_broll_montage": {"duration": "20", "image_duration": "3"},
|
| 3298 |
+
"series_split_pack": {"split_duration": "60", "text": "Episode"},
|
| 3299 |
+
"series_episode_badge": {"text": "Series title", "episode_number": "1"},
|
| 3300 |
+
"series_batch_pack": {"text": "Series title", "episode_number": "1"},
|
| 3301 |
+
"series_recap_card": {"text": "Previously on...", "duration": "6"},
|
| 3302 |
+
"slideshow": {"image_duration": "3"},
|
| 3303 |
+
"crossfade_slideshow": {"image_duration": "3", "transition": "fade", "transition_duration": "1"},
|
| 3304 |
+
"ken_burns_slideshow": {"image_duration": "4"},
|
| 3305 |
+
"narrated_slideshow": {"image_duration": "4"},
|
| 3306 |
+
"trim": {"start_time": "00:00:05", "end_time": "00:00:30"},
|
| 3307 |
+
"crop_aspect": {"aspect_ratio": "9:16"},
|
| 3308 |
+
"waveform": {"wave_color": "00e5ff"},
|
| 3309 |
+
"extract_frames": {"frame_rate": "1"},
|
| 3310 |
+
"speed": {"speed": "1.25"},
|
| 3311 |
+
"episode_assembly": {
|
| 3312 |
+
"episode_title": "The Signal",
|
| 3313 |
+
"episode_number": "S01E03",
|
| 3314 |
+
"card_duration": "3",
|
| 3315 |
+
},
|
| 3316 |
+
"create_trailer": {"duration": "45"},
|
| 3317 |
+
"create_teaser": {"duration": "15"},
|
| 3318 |
+
"lower_third": {"text": "Speaker name", "episode_title": "Program title"},
|
| 3319 |
+
"countdown_overlay": {"countdown": "10", "font_size": "72"},
|
| 3320 |
+
"video_transition": {"transition": "fade", "transition_duration": "1"},
|
| 3321 |
+
"video_animation": {"operation": "zoom"},
|
| 3322 |
+
"video_effect": {"effect": "vhs"},
|
| 3323 |
+
"render_image_sequence": {"frame_rate": "30"},
|
| 3324 |
+
"render_alpha_webm": {"frame_rate": "30", "crf": "30"},
|
| 3325 |
+
"video_enhance": {"operation": "denoise"},
|
| 3326 |
+
"social_format": {"aspect_ratio": "9:16", "fit_mode": "crop"},
|
| 3327 |
+
"encode_video": {"codec": "h264", "crf": "23", "preset": "fast"},
|
| 3328 |
+
"encode_webm": {"codec": "vp9", "crf": "30"},
|
| 3329 |
+
"convert_mov": {"crf": "23"},
|
| 3330 |
+
"convert_mkv": {"crf": "23"},
|
| 3331 |
+
"convert_avi": {"crf": "23"},
|
| 3332 |
+
"audio_aac": {"sample_rate": "48000", "channels": "stereo", "audio_bitrate": "192"},
|
| 3333 |
+
"audio_flac": {"sample_rate": "48000", "channels": "stereo"},
|
| 3334 |
+
"audio_wav": {"sample_rate": "48000", "channels": "stereo"},
|
| 3335 |
+
"audio_ogg": {"sample_rate": "48000", "channels": "stereo", "audio_bitrate": "192"},
|
| 3336 |
+
"audio_opus": {"sample_rate": "48000", "channels": "stereo", "audio_bitrate": "192"},
|
| 3337 |
+
"audio_ac3": {"sample_rate": "48000", "channels": "stereo", "audio_bitrate": "192"},
|
| 3338 |
+
"streaming_compress": {"operation": "crf", "crf": "24", "streaming": "true"},
|
| 3339 |
+
}
|
| 3340 |
+
|
| 3341 |
+
|
| 3342 |
+
def mcp_example_filename(category: str, index: int) -> str:
|
| 3343 |
+
extensions = {"video": "mp4", "audio": "mp3", "image": "jpg", "text": "srt"}
|
| 3344 |
+
return f"https://example.com/input_{index}.{extensions.get(category, 'bin')}"
|
| 3345 |
+
|
| 3346 |
+
|
| 3347 |
+
def mcp_task_file_examples(task_id: str, task: TaskDefinition) -> List[str]:
|
| 3348 |
+
if task_id == "narrated_slideshow":
|
| 3349 |
+
return [
|
| 3350 |
+
"https://example.com/image_1.jpg",
|
| 3351 |
+
"https://example.com/image_2.jpg",
|
| 3352 |
+
"https://example.com/narration.mp3",
|
| 3353 |
+
]
|
| 3354 |
+
if task.min_files == 0:
|
| 3355 |
+
return []
|
| 3356 |
+
if task.file_types:
|
| 3357 |
+
return [
|
| 3358 |
+
mcp_example_filename(categories[0], index)
|
| 3359 |
+
for index, categories in enumerate(task.file_types, start=1)
|
| 3360 |
+
]
|
| 3361 |
+
category = task.accepted_types[0] if task.accepted_types else "file"
|
| 3362 |
+
return [
|
| 3363 |
+
mcp_example_filename(category, index)
|
| 3364 |
+
for index in range(1, task.min_files + 1)
|
| 3365 |
+
]
|
| 3366 |
+
|
| 3367 |
+
|
| 3368 |
+
def mcp_task_description(task_id: str, task: TaskDefinition) -> str:
|
| 3369 |
+
max_files = "unlimited" if task.max_files is None else str(task.max_files)
|
| 3370 |
+
payload = {
|
| 3371 |
+
"files": mcp_task_file_examples(task_id, task),
|
| 3372 |
+
"url": "",
|
| 3373 |
+
"options": MCP_TASK_OPTION_EXAMPLES.get(task_id, {}),
|
| 3374 |
+
}
|
| 3375 |
+
return (
|
| 3376 |
+
f"{task.description}\n\n"
|
| 3377 |
+
f"Input files: {task.min_files}-{max_files}; accepted types: "
|
| 3378 |
+
f"{', '.join(task.accepted_types) or 'none'}. Output: .{task.output_ext}. "
|
| 3379 |
+
"Files are processed in array order. Use either uploaded files, a URL, or both.\n\n"
|
| 3380 |
+
"Payload example:\n\n"
|
| 3381 |
+
"```json\n"
|
| 3382 |
+
f"{json.dumps(payload, indent=2)}\n"
|
| 3383 |
+
"```"
|
| 3384 |
+
)
|
| 3385 |
+
|
| 3386 |
+
|
| 3387 |
+
def mcp_file_paths(files) -> List[str]:
|
| 3388 |
+
if not files:
|
| 3389 |
+
return []
|
| 3390 |
+
values = files if isinstance(files, (list, tuple)) else [files]
|
| 3391 |
+
paths = []
|
| 3392 |
+
for value in values:
|
| 3393 |
+
if isinstance(value, str):
|
| 3394 |
+
path = value
|
| 3395 |
+
elif isinstance(value, dict):
|
| 3396 |
+
path = str(value.get("path") or value.get("name") or "")
|
| 3397 |
+
else:
|
| 3398 |
+
path = str(getattr(value, "name", ""))
|
| 3399 |
+
if not path:
|
| 3400 |
+
raise gr.Error("An uploaded file is missing its local path.")
|
| 3401 |
+
paths.append(path)
|
| 3402 |
+
return paths
|
| 3403 |
+
|
| 3404 |
+
|
| 3405 |
+
def mcp_options_from_json(options) -> Dict[str, str]:
|
| 3406 |
+
if options is None:
|
| 3407 |
+
values: Dict[str, object] = {}
|
| 3408 |
+
elif isinstance(options, str):
|
| 3409 |
+
try:
|
| 3410 |
+
parsed = json.loads(options)
|
| 3411 |
+
except json.JSONDecodeError as exc:
|
| 3412 |
+
raise gr.Error("Options must be a valid JSON object.") from exc
|
| 3413 |
+
if not isinstance(parsed, dict):
|
| 3414 |
+
raise gr.Error("Options must be a JSON object.")
|
| 3415 |
+
values = parsed
|
| 3416 |
+
elif isinstance(options, dict):
|
| 3417 |
+
values = options
|
| 3418 |
+
else:
|
| 3419 |
+
raise gr.Error("Options must be a JSON object.")
|
| 3420 |
+
return options_from_mapping(values)
|
| 3421 |
+
|
| 3422 |
+
|
| 3423 |
+
def make_mcp_task_handler(task_id: str):
|
| 3424 |
+
task = get_task(task_id)
|
| 3425 |
+
|
| 3426 |
+
def handler(files, url, options):
|
| 3427 |
+
paths = []
|
| 3428 |
+
try:
|
| 3429 |
+
if url:
|
| 3430 |
+
paths.append(download_url(str(url)))
|
| 3431 |
+
paths.extend(mcp_file_paths(files))
|
| 3432 |
+
parsed_options = mcp_options_from_json(options)
|
| 3433 |
+
resolved_id, resolved_task, parsed_options = resolve_task_request(
|
| 3434 |
+
task_id, parsed_options
|
| 3435 |
+
)
|
| 3436 |
+
if not paths and resolved_task.min_files > 0:
|
| 3437 |
+
raise gr.Error("Upload files or provide a URL.")
|
| 3438 |
+
output = run_ffmpeg(resolved_id, paths, parsed_options)
|
| 3439 |
+
record_history(uuid.uuid4().hex[:8], resolved_id, output)
|
| 3440 |
+
return output
|
| 3441 |
+
except HTTPException as exc:
|
| 3442 |
+
raise gr.Error(str(exc.detail)) from exc
|
| 3443 |
+
|
| 3444 |
+
handler.__name__ = task_id
|
| 3445 |
+
handler.__doc__ = mcp_task_description(task_id, task)
|
| 3446 |
+
return handler
|
| 3447 |
+
|
| 3448 |
+
|
| 3449 |
def selected_task_id(choice: str) -> str:
|
| 3450 |
if not choice:
|
| 3451 |
return "normalize"
|
|
|
|
| 3589 |
downloadable media URL, or both. Use advanced_options_json for task-specific
|
| 3590 |
settings such as effects, codecs, transitions, episode metadata, or audio
|
| 3591 |
mixing levels. Multiple files are processed in upload order.
|
| 3592 |
+
|
| 3593 |
+
Payload example:
|
| 3594 |
+
|
| 3595 |
+
```json
|
| 3596 |
+
{
|
| 3597 |
+
"files": ["https://example.com/input.mp4"],
|
| 3598 |
+
"url": "",
|
| 3599 |
+
"task_choice": "compress",
|
| 3600 |
+
"resolution": "720p",
|
| 3601 |
+
"crf": 28,
|
| 3602 |
+
"preset": "fast",
|
| 3603 |
+
"advanced_options_json": "{}"
|
| 3604 |
+
}
|
| 3605 |
+
```
|
| 3606 |
"""
|
| 3607 |
task_id = selected_task_id(task_choice)
|
| 3608 |
task = get_task(task_id)
|
|
|
|
| 3647 |
raise gr.Error(str(exc.detail))
|
| 3648 |
|
| 3649 |
|
| 3650 |
+
ui_handler.__name__ = "run_ffmpeg_task"
|
| 3651 |
+
|
| 3652 |
+
|
| 3653 |
def history_table():
|
| 3654 |
with STATE_LOCK:
|
| 3655 |
rows = [
|
|
|
|
| 3735 |
label="Recent outputs",
|
| 3736 |
value=history_table,
|
| 3737 |
)
|
| 3738 |
+
# Hidden components create documented Gradio API endpoints. With MCP enabled,
|
| 3739 |
+
# the catalog, generic runner, and every fixed task endpoint become tools.
|
|
|
|
| 3740 |
mcp_catalog_button = gr.Button("MCP: List tasks", visible=False)
|
| 3741 |
mcp_catalog_output = gr.JSON(visible=False)
|
| 3742 |
mcp_catalog_button.click(
|
|
|
|
| 3745 |
outputs=mcp_catalog_output,
|
| 3746 |
api_name="list_ffmpeg_tasks",
|
| 3747 |
)
|
| 3748 |
+
mcp_task_files_input = gr.File(
|
| 3749 |
+
label="files",
|
| 3750 |
+
file_count="multiple",
|
| 3751 |
+
visible=False,
|
| 3752 |
+
)
|
| 3753 |
+
mcp_task_url_input = gr.Textbox(label="url", visible=False)
|
| 3754 |
+
mcp_task_options_input = gr.JSON(label="options", value={}, visible=False)
|
| 3755 |
+
for mcp_task_id in TASKS:
|
| 3756 |
+
mcp_task_button = gr.Button(f"MCP: {mcp_task_id}", visible=False)
|
| 3757 |
+
mcp_task_output = gr.File(label=f"{mcp_task_id} output", visible=False)
|
| 3758 |
+
mcp_task_button.click(
|
| 3759 |
+
make_mcp_task_handler(mcp_task_id),
|
| 3760 |
+
inputs=[mcp_task_files_input, mcp_task_url_input, mcp_task_options_input],
|
| 3761 |
+
outputs=mcp_task_output,
|
| 3762 |
+
api_name=mcp_task_id,
|
| 3763 |
+
)
|
| 3764 |
|
| 3765 |
task_input.change(
|
| 3766 |
option_visibility,
|