Nymbo commited on
Commit
04d4e85
Β·
verified Β·
1 Parent(s): c952dbd

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +204 -250
app.py CHANGED
@@ -1,159 +1,107 @@
1
- """Media Converter – Gradio Space
2
-
3
- This file defines a full-featured media‑conversion Gradio Space that can also
4
- expose its core Python utilities via the MCP protocol (``demo.launch(...,
5
- mcp_server=True)``). The code is a **verbatim rewrite** of the user‑supplied
6
- script but now includes:
7
-
8
- * Fully‑typed function signatures
9
- * Clear, Google‑style docstrings so the MCP schema shows descriptions instead of
10
- β€œNo description provided in function docstring”.
11
- * Single, explicit ``gr.api()`` registrations so each logical tool appears only
12
- once (stopping the auto‑generated ``func_``, ``func__`` duplicates Gradio
13
- otherwise adds when the same callable is wired to multiple UI events).
14
- * A couple of syntaxΒ / runtime fixes (missing parenthesis in ``CommandBuilder``
15
- call, forward reference for the ``ffmpeg_commands`` global).
16
-
17
- No logic, imports, or UI components have been removed, shortened, or skipped.
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- # ──────────────────────────────────────────────────────────────────────────────
23
- # StandardΒ / external imports
24
- # ──────────────────────────────────────────────────────────────────────────────
25
 
26
  import logging
27
  import subprocess
28
- from pathlib import Path
29
- from pprint import pprint # noqa: F401 (handy for local debugging, left intact)
30
  from tempfile import _TemporaryFileWrapper
31
- from typing import Any, Tuple
32
 
33
- import gradio as gr
34
  from ffmpy import FFmpeg, FFRuntimeError
 
35
 
36
- # Project‑local helpers – unchanged from the original submission.
37
  from functions import (
38
  Clear,
39
  CommandBuilder,
40
- VF,
41
  audio_channels,
42
  audio_codecs,
43
  audio_quality,
44
  audio_sample_rates,
45
  change_clipbox,
46
  containers,
 
47
  media_change,
48
  presets,
49
- set_custom_bitrate,
50
  supported_codecs,
51
  supported_presets,
52
  video_codecs,
 
53
  )
54
 
55
- # ──────────────────────────────────────────────────────────────────────────────
56
- # Global configuration
57
- # ──────────────────────────────────────────────────────────────────────────────
58
-
59
  logging.basicConfig(level=logging.INFO)
60
- logger = logging.getLogger("media‑converter")
61
 
62
- # This variable will be initialised later (see bottom of file) but must exist at
63
- # module import time so that ``convert()`` can type‑check & reference it.
64
- ffmpeg_commands: "CommandBuilder" | None = None # noqa: ANN401
65
-
66
- # ──────────────────────────────────────────────────────────────────────────────
67
- # Core MCP‑exposed function
68
- # ──────────────────────────────────────────────────────────────────────────────
69
 
70
  def convert(
71
- file: _TemporaryFileWrapper | None,
72
  container_format: str,
73
- state: str,
74
- ) -> Tuple[str | None, str | None, str | None, str, str]:
75
- """Transcode an uploaded media file into a new container using FFmpeg.
 
76
 
77
  Args:
78
- file: Temporary file handle supplied by Gradio's ``File`` component –
79
- can be *None* when the user presses *Convert* without selecting a
80
- file.
81
- container_format: Desired output extension / container (e.g. ``"mp4"``).
82
- state: A filename string preserved in a hidden ``gr.State`` so that the
83
- *Video*, *Audio*, and *File* buttons know which asset to reveal.
84
 
85
  Returns:
86
- audio_path: Filesystem path to an audio‑only rendition (or *None*).
87
- file_path: Filesystem path for a direct download link (or *None*).
88
- video_path: Filesystem path to a video preview (or *None*).
89
- ffmpeg_cmd: The exact FFmpeg command executed – surfaced to the user for
90
- transparency / debugging.
91
- new_state: Updated state string (usually the new output filename).
92
-
93
- Notes:
94
- * Relies on the global ``ffmpeg_commands`` variable that is built after
95
- the UI components are instantiated.
96
- * Uses ``ffmpy`` so that users on HF Spaces don't needΒ raw shell access.
97
  """
98
-
99
  if file is None:
100
- logger.error("No file provided for conversion.")
101
- return None, None, None, "No file provided", state
102
-
103
- if ffmpeg_commands is None: # This *should* never happen.
104
- err_msg = "Internal error: FFmpeg command builder not initialised."
105
- logger.error(err_msg)
106
- return None, None, None, err_msg, state
107
-
108
- # Derive output path – keep original stem and add "1" to avoid overwrite.
109
- input_path = Path(file.name)
110
- output_path = input_path.with_suffix("").with_suffix("")
111
- output_path = output_path.with_name(output_path.name + "1").with_suffix(
112
- f".{container_format.lower()}"
113
- )
114
-
115
- # Build the command via ffmpy.
116
- ffmpeg = FFmpeg(
117
- inputs={str(input_path): None},
118
- outputs={str(output_path): ffmpeg_commands.commands.split()},
119
- global_options=["-y", "-hide_banner"],
120
- )
121
-
122
- # A second FFmpeg instance is created purely to show a *clean* command where
123
- # the filenames are placeholders – matches original behaviour.
124
- ffmpeg_display = FFmpeg(
125
- inputs={"input_file": None},
126
- outputs={f"output_file.{container_format.lower()}": ffmpeg_commands.commands.split()},
127
- global_options=["-y", "-hide_banner"],
128
- )
129
 
130
  try:
131
- ffmpeg.run(stderr=subprocess.PIPE)
132
- cmd_output = ffmpeg_display.cmd # What we show the user
133
- except FFRuntimeError as exc:
134
- logger.error("FFmpeg execution failed: %s", exc)
135
- return None, None, None, exc.stderr.decode(), state
136
-
137
- # Decide which UI component(s) should become visible based on the suffix.
138
- audio_out = str(output_path) if output_path.suffix in {".mp3", ".wav"} else None
139
- video_out = str(output_path) if output_path.suffix in {".mp4", ".mov"} else None
140
-
141
- new_state = str(output_path)
142
- return audio_out, str(output_path), video_out, cmd_output, new_state
143
-
144
-
145
- # ──────────────────────────────────────────────────────────────────────────────
146
- # UIΒ / components
147
- # ──────────────────────────────────────────────────────────────────────────────
148
-
149
- CSS = """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  body {
151
  background: var(--body-background-fill);
152
  }
153
  """
154
 
 
155
  with gr.Blocks(
156
- css=CSS,
157
  theme=gr.themes.Soft(
158
  primary_hue=gr.themes.colors.green,
159
  secondary_hue=gr.themes.colors.amber,
@@ -161,71 +109,70 @@ with gr.Blocks(
161
  font=["sans-serif"],
162
  ),
163
  ) as demo:
164
- """Top‑level Gradio UI definition."""
165
-
166
- # ─── Tabs – Format / Video / Audio / Filters ────────────────────────────
167
 
168
  with gr.Tabs(selected="format", elem_classes="tabs"):
169
- # ──────────────────────────────
170
- # FORMAT TAB
171
- # ──────────────────────────────
172
  with gr.Tab("Format", id="format"):
173
- # INPUT area
174
  with gr.Row():
175
  with gr.Column() as inputs:
176
- file_input = gr.File()
177
  options = gr.Radio(
178
- label="options", choices=containers, value=containers[0]
 
 
179
  )
180
- # Optional clip box widgets
181
- with gr.Row():
182
- with gr.Row() as inputs_clip:
183
- clip = gr.Dropdown(
184
- choices=["None", "Enabled"],
185
- label="Clip:",
186
- value="None",
187
- )
188
- start_time = gr.Textbox(
189
- label="Start Time:",
190
- placeholder="00:00",
191
- visible=False,
192
- interactive=True,
193
- )
194
- stop_time = gr.Textbox(
195
- label="Stop Time:",
196
- placeholder="00:00",
197
- visible=False,
198
- interactive=True,
199
- )
200
  with gr.Row():
201
- clearBtn = gr.Button("Clear")
202
- convertBtn = gr.Button("Convert", variant="primary")
203
 
204
- # OUTPUT area
205
  with gr.Column():
 
206
  video_button = gr.Button("Video")
207
  audio_button = gr.Button("Audio")
208
- file_button = gr.Button("File")
209
 
210
- media_output_audio = gr.Audio(
211
- type="filepath", label="Audio", visible=False, interactive=False
212
- )
213
  media_output_video = gr.Video(label="Video", visible=True, height=300)
214
- media_output_file = gr.File(label="File", visible=False)
215
 
216
  output_textbox = gr.Code(
217
  value="$ echo 'Hello, World!'",
218
- label="command",
219
  language="shell",
220
  elem_id="outputtext",
221
  )
222
 
223
- # Wipe helpers
224
  resetFormat = Clear(inputs, inputs_clip)
 
 
 
 
 
 
 
 
 
225
  state = gr.State()
226
- clearBtn.click(resetFormat.clear, resetFormat(), resetFormat())
 
227
  convertBtn.click(
228
- convert,
229
  inputs=[file_input, options, state],
230
  outputs=[
231
  media_output_audio,
@@ -234,130 +181,137 @@ with gr.Blocks(
234
  output_textbox,
235
  state,
236
  ],
 
 
237
  )
238
 
239
- # ──────────────────────────────
240
- # VIDEO TAB
241
- # ──────────────────────────────
242
  with gr.Tab("Video", id="video"):
243
  with gr.Row() as video_inputs:
244
  video_options = gr.Dropdown(
245
- label="video", choices=video_codecs, value=video_codecs[-1]
246
  )
247
  preset_options = gr.Dropdown(
248
- choices=presets, label="presets", value=presets[-1]
249
  )
250
 
251
- with gr.Row(elem_id="button"):
252
- with gr.Column():
253
- clearBtn = gr.Button("Clear")
254
- videoReset = Clear(video_inputs)
255
- clearBtn.click(videoReset.clear, videoReset(), videoReset())
256
-
257
- # ──────────────────────────────
258
- # AUDIO TAB
259
- # ──────────────────────────────
 
 
260
  with gr.Tab("Audio", id="audio"):
261
  with gr.Row() as audio_inputs:
262
  audio_options = gr.Dropdown(
263
- label="audio", choices=audio_codecs, value=audio_codecs[-1]
264
  )
265
  audio_bitrate = gr.Dropdown(
266
- choices=audio_quality, label="Audio Qualities", value=audio_quality[0]
 
 
 
267
  )
268
- custom_bitrate = gr.Number(label="Audio Qualities", visible=False)
269
  gr.Dropdown(
270
- choices=audio_channels, label="Audio Channels", value=audio_channels[0]
271
  )
272
  gr.Dropdown(
273
- choices=audio_sample_rates, label="Sample Rates", value=audio_sample_rates[0]
274
  )
275
 
276
- with gr.Column(elem_id="button"):
277
- clearBtn = gr.Button("Clear")
278
  audioReset = Clear(audio_inputs)
279
- clearBtn.click(audioReset.clear, audioReset(), audioReset())
280
-
281
- # ──────────────────────────────
282
- # FILTERS TAB
283
- # ──────────────────────────────
 
 
 
 
284
  with gr.Tab("Filters", id="filters") as filter_inputs:
285
- gr.Markdown("## Video")
286
- with gr.Row(equal_height=True) as filter_inputs:
287
- for vf_category in VF:
288
- values = list(vf_category.values())[0]
289
- choices = [item.get("name") for item in values]
290
- gr.Dropdown(
291
- label=str(list(vf_category.keys())[0]),
292
- choices=choices,
293
- value=choices[0],
294
- )
295
- gr.Markdown("## Audio")
296
- with gr.Row(elem_id="acontrast") as filter_inputs_1:
297
- gr.Slider(label="Acontrast", elem_id="acontrast")
298
-
299
- with gr.Column(elem_id="button"):
300
- clearBtn = gr.Button("Clear")
301
- filterReset = Clear(filter_inputs, filter_inputs_1)
302
- clearBtn.click(filterReset.clear, filterReset(), filterReset())
303
-
304
- # ──────────────────────────────────────────────────────────────────────
305
- # Inter‑component wiring (dynamic behaviour)
306
- # ──────────────────────────────────────────────────────────────────────
307
-
308
- clip.change(fn=change_clipbox, inputs=clip, outputs=[start_time, stop_time])
309
-
310
- options.change(supported_codecs, [options], [video_options, audio_options])
311
-
312
- audio_button.click(
313
- media_change,
314
- [audio_button, state],
315
- [media_output_audio, media_output_video, media_output_file],
316
- )
317
- video_button.click(
318
- media_change,
319
- [video_button, state],
320
- [media_output_audio, media_output_video, media_output_file],
321
- )
322
- file_button.click(
323
- media_change,
324
- [file_button, state],
325
- [media_output_audio, media_output_video, media_output_file],
326
  )
327
 
328
- # Video‑tab dynamic presets
329
- video_options.change(supported_presets, [video_options], [preset_options])
 
 
 
 
 
330
 
331
- # Audio‑tab dynamic bitrate field
332
- audio_bitrate.change(set_custom_bitrate, [audio_bitrate], [custom_bitrate])
 
 
 
 
 
333
 
334
- # ──────────────────────────────────────────────────────────────────────
335
- # FFmpeg command builder (global) + listener hookup
336
- # ──────────────────────────────────────────────────────────────────────
 
 
 
 
337
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  ffmpeg_commands = CommandBuilder(
339
- inputs_clip,
340
- video_inputs,
341
- audio_inputs,
342
- filter_inputs,
343
- filter_inputs_1,
344
  )
345
- ffmpeg_commands.setup_listener()
346
- ffmpeg_commands.update(output_textbox)
347
-
348
- # ──────────────────────────────────────────────────────────────────────────────
349
- # MCP tool registration – one clean endpoint per logical function
350
- # ──────────────────────────────────────────────────────────────────────────────
351
-
352
- gr.api(convert, api_name="transcode_media")
353
- gr.api(supported_codecs)
354
- gr.api(supported_presets)
355
- gr.api(set_custom_bitrate)
356
- gr.api(media_change)
357
-
358
- # ──────────────────────────────────────────────────────────────────────────────
359
- # Launch
360
- # ──────────────────────────────────────────────────────────────────────────────
361
 
 
362
  if __name__ == "__main__":
363
  demo.launch(show_error=True, max_threads=300, mcp_server=True)
 
1
+ # File: app.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import logging
4
  import subprocess
5
+ from pprint import pprint
 
6
  from tempfile import _TemporaryFileWrapper
 
7
 
 
8
  from ffmpy import FFmpeg, FFRuntimeError
9
+ import gradio as gr
10
 
 
11
  from functions import (
12
  Clear,
13
  CommandBuilder,
 
14
  audio_channels,
15
  audio_codecs,
16
  audio_quality,
17
  audio_sample_rates,
18
  change_clipbox,
19
  containers,
20
+ set_custom_bitrate,
21
  media_change,
22
  presets,
 
23
  supported_codecs,
24
  supported_presets,
25
  video_codecs,
26
+ VF,
27
  )
28
 
29
+ # ─── Configure logging level ───────────────────────────────────────────────────
 
 
 
30
  logging.basicConfig(level=logging.INFO)
 
31
 
 
 
 
 
 
 
 
32
 
33
  def convert(
34
+ file: _TemporaryFileWrapper,
35
  container_format: str,
36
+ prev_state: str
37
+ ) -> tuple[str, str, str, str, str]:
38
+ """
39
+ Convert the given media file to a new container format using FFmpeg.
40
 
41
  Args:
42
+ file (_TemporaryFileWrapper): Uploaded input file wrapper (has .name).
43
+ container_format (str): Target container format (e.g., "mp4", "mp3").
44
+ prev_state (str): Previous state token (for UI chaining).
 
 
 
45
 
46
  Returns:
47
+ Tuple containing:
48
+ - audio_output_path (str)
49
+ - file_output_path (str)
50
+ - video_output_path (str)
51
+ - ffmpeg command run (str)
52
+ - new state token (str)
 
 
 
 
 
53
  """
54
+ # If no file was uploaded, bail out immediately
55
  if file is None:
56
+ logging.error("No file provided for conversion.")
57
+ return [None, None, None, "No file provided", prev_state]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  try:
60
+ # Extract base filename (without extension)
61
+ logging.info("File name: %s", file.name)
62
+ new_name, _ = file.name.rsplit(".", 1)
63
+ output_file = f"{new_name}_converted.{container_format.lower()}"
64
+
65
+ # Build FFmpeg command
66
+ ffmpeg_cmd_builder = FFmpeg(
67
+ inputs={file.name: None},
68
+ outputs={output_file: None},
69
+ global_options=["-y", "-hide_banner"],
70
+ )
71
+ print(ffmpeg_cmd_builder) # Debug: FFmpeg object
72
+ print(ffmpeg_cmd_builder.cmd) # Debug: actual shell command
73
+
74
+ # Run conversion
75
+ ffmpeg_cmd_builder.run(stderr=subprocess.PIPE)
76
+ ffmpeg_command_str = ffmpeg_cmd_builder.cmd
77
+
78
+ except FFRuntimeError as e:
79
+ # On error, decode stderr and return it as output_text
80
+ error_msg = e.stderr.decode()
81
+ print(error_msg, flush=True)
82
+ return [None, None, None, error_msg, prev_state]
83
+
84
+ # Update state and return all paths/outputs
85
+ new_state = output_file
86
+ return [
87
+ output_file, # audio_output_path
88
+ output_file, # file_output_path
89
+ output_file, # video_output_path
90
+ ffmpeg_command_str, # ffmpeg command run
91
+ new_state # new state token
92
+ ]
93
+
94
+
95
+ # ─── Custom CSS for the app ────────────────────────────────────────────────────
96
+ css = """
97
  body {
98
  background: var(--body-background-fill);
99
  }
100
  """
101
 
102
+ # ─── Build the Gradio interface ────────────────────────────────────────────────
103
  with gr.Blocks(
104
+ css=css,
105
  theme=gr.themes.Soft(
106
  primary_hue=gr.themes.colors.green,
107
  secondary_hue=gr.themes.colors.amber,
 
109
  font=["sans-serif"],
110
  ),
111
  ) as demo:
 
 
 
112
 
113
  with gr.Tabs(selected="format", elem_classes="tabs"):
114
+
115
+ # ── Format Tab ────────────────────────────────────────────────────────
 
116
  with gr.Tab("Format", id="format"):
117
+
118
  with gr.Row():
119
  with gr.Column() as inputs:
120
+ file_input = gr.File(label="Upload File") # upload control
121
  options = gr.Radio(
122
+ label="Container Format",
123
+ choices=containers,
124
+ value=containers[0],
125
  )
126
+
127
+ with gr.Row() as inputs_clip:
128
+ clip = gr.Dropdown(
129
+ choices=["None", "Enabled"], label="Clip:", value="None"
130
+ )
131
+ start_time = gr.Textbox(
132
+ label="Start Time", placeholder="00:00", visible=False
133
+ )
134
+ stop_time = gr.Textbox(
135
+ label="Stop Time", placeholder="00:00", visible=False
136
+ )
137
+
 
 
 
 
 
 
 
 
138
  with gr.Row():
139
+ clearBtn = gr.Button("Clear") # reset inputs
140
+ convertBtn = gr.Button("Convert", variant="primary") # run conversion
141
 
 
142
  with gr.Column():
143
+ # Output modality buttons
144
  video_button = gr.Button("Video")
145
  audio_button = gr.Button("Audio")
146
+ file_button = gr.Button("File")
147
 
148
+ # Output components, initially hidden/shown appropriately
149
+ media_output_audio = gr.Audio(type="filepath", label="Audio", visible=False)
 
150
  media_output_video = gr.Video(label="Video", visible=True, height=300)
151
+ media_output_file = gr.File(label="File", visible=False)
152
 
153
  output_textbox = gr.Code(
154
  value="$ echo 'Hello, World!'",
155
+ label="FFmpeg Command",
156
  language="shell",
157
  elem_id="outputtext",
158
  )
159
 
160
+ # Instantiate and wire up clear/reset behavior
161
  resetFormat = Clear(inputs, inputs_clip)
162
+ clearBtn.click(
163
+ fn=resetFormat.clear,
164
+ inputs=resetFormat(),
165
+ outputs=resetFormat(),
166
+ api_name="reset_format_tab",
167
+ description="Clear all inputs in the Format tab"
168
+ )
169
+
170
+ # Persistent state token for chaining
171
  state = gr.State()
172
+
173
+ # Convert button β†’ convert_media tool
174
  convertBtn.click(
175
+ fn=convert,
176
  inputs=[file_input, options, state],
177
  outputs=[
178
  media_output_audio,
 
181
  output_textbox,
182
  state,
183
  ],
184
+ api_name="convert_media",
185
+ description="Convert an uploaded file to the selected format via FFmpeg"
186
  )
187
 
188
+ # ── Video Tab ─────────────────────────────────────────────────────────
 
 
189
  with gr.Tab("Video", id="video"):
190
  with gr.Row() as video_inputs:
191
  video_options = gr.Dropdown(
192
+ label="Video Codec", choices=video_codecs, value=video_codecs[-1]
193
  )
194
  preset_options = gr.Dropdown(
195
+ label="Preset", choices=presets, value=presets[-1]
196
  )
197
 
198
+ clearVidBtn = gr.Button("Clear") # reset video-specific inputs
199
+ videoReset = Clear(video_inputs)
200
+ clearVidBtn.click(
201
+ fn=videoReset.clear,
202
+ inputs=videoReset(),
203
+ outputs=videoReset(),
204
+ api_name="reset_video_tab",
205
+ description="Clear all inputs in the Video tab"
206
+ )
207
+
208
+ # ── Audio Tab ─────────────────────────────────────────────────────────
209
  with gr.Tab("Audio", id="audio"):
210
  with gr.Row() as audio_inputs:
211
  audio_options = gr.Dropdown(
212
+ label="Audio Codec", choices=audio_codecs, value=audio_codecs[-1]
213
  )
214
  audio_bitrate = gr.Dropdown(
215
+ label="Audio Quality", choices=audio_quality, value=audio_quality[0]
216
+ )
217
+ custom_bitrate = gr.Number(
218
+ label="Custom Bitrate", visible=False
219
  )
 
220
  gr.Dropdown(
221
+ label="Audio Channels", choices=audio_channels, value=audio_channels[0]
222
  )
223
  gr.Dropdown(
224
+ label="Sample Rate", choices=audio_sample_rates, value=audio_sample_rates[0]
225
  )
226
 
227
+ clearAudBtn = gr.Button("Clear")
 
228
  audioReset = Clear(audio_inputs)
229
+ clearAudBtn.click(
230
+ fn=audioReset.clear,
231
+ inputs=audioReset(),
232
+ outputs=audioReset(),
233
+ api_name="reset_audio_tab",
234
+ description="Clear all inputs in the Audio tab"
235
+ )
236
+
237
+ # ── Filters Tab ────────────────────────────────────────────────────────
238
  with gr.Tab("Filters", id="filters") as filter_inputs:
239
+ gr.Markdown("## Video Filters")
240
+ with gr.Row(equal_height=True):
241
+ for vf in VF:
242
+ # each filter group β†’ dropdown
243
+ name = list(vf.keys())[0]
244
+ choices = [opt["name"] for opt in vf[name]]
245
+ gr.Dropdown(label=name, choices=choices, value=choices[0])
246
+
247
+ gr.Markdown("## Audio Filters")
248
+ acontrastSlider = gr.Slider(label="Audio Contrast")
249
+
250
+ clearFltBtn = gr.Button("Clear")
251
+ filterReset = Clear(filter_inputs, acontrastSlider)
252
+ clearFltBtn.click(
253
+ fn=filterReset.clear,
254
+ inputs=filterReset(),
255
+ outputs=filterReset(),
256
+ api_name="reset_filters_tab",
257
+ description="Clear all inputs in the Filters tab"
258
+ )
259
+
260
+ # ─── Cross-tab event listeners ───────────────────────────────────────────────
261
+ clip.change(
262
+ fn=change_clipbox,
263
+ inputs=[clip],
264
+ outputs=[start_time, stop_time],
265
+ api_name="toggle_clip_inputs",
266
+ description="Show or hide clip time fields when Clip option changes"
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  )
268
 
269
+ options.change(
270
+ fn=supported_codecs,
271
+ inputs=[options],
272
+ outputs=[video_options, audio_options],
273
+ api_name="update_codecs",
274
+ description="Update available video/audio codecs when container format changes"
275
+ )
276
 
277
+ video_options.change(
278
+ fn=supported_presets,
279
+ inputs=[video_options],
280
+ outputs=[preset_options],
281
+ api_name="update_presets",
282
+ description="Refresh preset list when video codec changes"
283
+ )
284
 
285
+ audio_bitrate.change(
286
+ fn=set_custom_bitrate,
287
+ inputs=[audio_bitrate],
288
+ outputs=[custom_bitrate],
289
+ api_name="set_custom_bitrate",
290
+ description="Toggle custom bitrate field based on selected audio quality"
291
+ )
292
 
293
+ # Single media_change handler for all three buttons
294
+ for btn, mode in [
295
+ (audio_button, "audio"),
296
+ (video_button, "video"),
297
+ (file_button, "file"),
298
+ ]:
299
+ btn.click(
300
+ fn=media_change,
301
+ inputs=[btn, state],
302
+ outputs=[media_output_audio, media_output_video, media_output_file],
303
+ api_name=f"switch_to_{mode}",
304
+ description=f"Switch displayed output to {mode}"
305
+ )
306
+
307
+ # ─── FFmpeg command updater listener ────────────────────────────────────────
308
  ffmpeg_commands = CommandBuilder(
309
+ inputs_clip, video_inputs, audio_inputs, filter_inputs, acontrastSlider
 
 
 
 
310
  )
311
+ ffmpeg_commands.setup_listener() # start listening to UI changes
312
+ # pprint(ffmpeg_commands.commands) # debug: show commands
313
+ ffmpeg_commands.update(output_textbox) # populate initial command
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
+ # ─── Launch the app with MCP enabled ───────────────────────────────────────────
316
  if __name__ == "__main__":
317
  demo.launch(show_error=True, max_threads=300, mcp_server=True)