basyx commited on
Commit
a666fdf
·
verified ·
1 Parent(s): 2d3623a

Upload 11 files

Browse files
Files changed (5) hide show
  1. .env.example +15 -0
  2. .gitignore +5 -0
  3. MCP.md +25 -1
  4. README.md +7 -0
  5. 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
- Use the returned task ID as the task_choice for run_ffmpeg_task. Tasks with
3244
- multiple files consume them in upload order.
 
 
 
 
 
 
 
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 still create documented Gradio API endpoints. With MCP
3522
- # enabled, these two endpoints become list_ffmpeg_tasks and run_ffmpeg_task
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,