Upload 214 files
Browse files- renderer_app.py +97 -46
- tests/test_mcp_metadata.py +56 -0
renderer_app.py
CHANGED
|
@@ -15,6 +15,11 @@ 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(
|
|
@@ -27,8 +32,8 @@ def create_dashboard() -> gr.Blocks:
|
|
| 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=
|
| 31 |
-
project_list.click(fn=
|
| 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`.")
|
|
@@ -36,7 +41,7 @@ def create_dashboard() -> gr.Blocks:
|
|
| 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=
|
| 40 |
|
| 41 |
with gr.Tab("Timeline"):
|
| 42 |
timeline_project_id = gr.Textbox(label="Project ID")
|
|
@@ -48,7 +53,7 @@ def create_dashboard() -> gr.Blocks:
|
|
| 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=
|
| 52 |
|
| 53 |
timeline_operation_json = gr.Textbox(
|
| 54 |
label="Operation JSON",
|
|
@@ -56,40 +61,40 @@ def create_dashboard() -> gr.Blocks:
|
|
| 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=
|
| 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=
|
| 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=
|
| 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=
|
| 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=
|
| 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=
|
| 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=
|
| 93 |
|
| 94 |
with gr.Tab("Rendering"):
|
| 95 |
render_json = gr.Textbox(
|
|
@@ -100,7 +105,7 @@ def create_dashboard() -> gr.Blocks:
|
|
| 100 |
)
|
| 101 |
render_button = gr.Button("Submit Render", variant="primary")
|
| 102 |
render_output = gr.JSON(label="Submission")
|
| 103 |
-
render_button.click(fn=
|
| 104 |
|
| 105 |
with gr.Tab("AI Reels"):
|
| 106 |
script = gr.Textbox(label="Script", lines=6)
|
|
@@ -112,31 +117,31 @@ def create_dashboard() -> gr.Blocks:
|
|
| 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=
|
| 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=
|
| 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=
|
| 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=
|
| 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=
|
| 140 |
|
| 141 |
with gr.Tab("Transcribe"):
|
| 142 |
transcribe_audio = gr.File(label="Audio or Video", file_types=["audio", "video"], type="filepath")
|
|
@@ -149,16 +154,18 @@ def create_dashboard() -> gr.Blocks:
|
|
| 149 |
transcribe_button = gr.Button("Transcribe", variant="primary")
|
| 150 |
transcribe_output = gr.JSON(label="Transcript")
|
| 151 |
transcribe_button.click(
|
| 152 |
-
fn=
|
| 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=
|
| 162 |
|
| 163 |
with gr.Tab("AI Analysis"):
|
| 164 |
analysis_media = gr.Textbox(label="Media URL or path")
|
|
@@ -167,9 +174,11 @@ def create_dashboard() -> gr.Blocks:
|
|
| 167 |
analysis_button = gr.Button("Submit Analysis", variant="primary")
|
| 168 |
analysis_output = gr.JSON(label="Analysis Job")
|
| 169 |
analysis_button.click(
|
| 170 |
-
fn=
|
| 171 |
inputs=[analysis_media, analysis_transcript, analysis_platform],
|
| 172 |
outputs=analysis_output,
|
|
|
|
|
|
|
| 173 |
)
|
| 174 |
|
| 175 |
with gr.Tab("Clip Generator"):
|
|
@@ -177,14 +186,14 @@ def create_dashboard() -> gr.Blocks:
|
|
| 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=
|
| 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=
|
| 188 |
|
| 189 |
with gr.Tab("Publishing"):
|
| 190 |
publish_media = gr.Textbox(label="Media URL or rendered output path")
|
|
@@ -192,46 +201,52 @@ def create_dashboard() -> gr.Blocks:
|
|
| 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=
|
| 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=
|
| 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=
|
| 206 |
|
| 207 |
return dashboard
|
| 208 |
|
| 209 |
|
| 210 |
-
def
|
|
|
|
| 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
|
|
|
|
| 218 |
metadata = json.loads(metadata_json or "{}")
|
| 219 |
return {"project": project_manager.create(name, metadata=metadata)}
|
| 220 |
|
| 221 |
|
| 222 |
-
def
|
|
|
|
| 223 |
return {"projects": project_manager.list()}
|
| 224 |
|
| 225 |
|
| 226 |
-
def
|
|
|
|
| 227 |
return {"project": project_manager.add_asset(project_id, json.loads(asset_json or "{}"))}
|
| 228 |
|
| 229 |
|
| 230 |
-
def
|
|
|
|
| 231 |
return {"project": project_manager.add_to_timeline(project_id, json.loads(item_json or "{}"), track_type=track_type)}
|
| 232 |
|
| 233 |
|
| 234 |
-
def
|
|
|
|
| 235 |
data = json.loads(operation_json or "{}")
|
| 236 |
return {
|
| 237 |
"project": project_manager.timeline_operation(
|
|
@@ -248,14 +263,37 @@ def _catalog_section(section: str) -> dict[str, Any]:
|
|
| 248 |
return {section: catalog.get(section)}
|
| 249 |
|
| 250 |
|
| 251 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 259 |
from renderer.studio import StudioTaskProcessor
|
| 260 |
|
| 261 |
data = json.loads(payload or "{}")
|
|
@@ -263,13 +301,14 @@ def _submit_ai_tool(tool: str, payload: str) -> dict[str, Any]:
|
|
| 263 |
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 264 |
|
| 265 |
|
| 266 |
-
def
|
|
|
|
| 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
|
| 273 |
script: str,
|
| 274 |
voiceover: str,
|
| 275 |
assets: list[str],
|
|
@@ -278,6 +317,7 @@ def _submit_ai_reel(
|
|
| 278 |
platform: str,
|
| 279 |
music: str | None,
|
| 280 |
) -> dict[str, Any]:
|
|
|
|
| 281 |
request = AIReelsRequest(
|
| 282 |
script=script,
|
| 283 |
voiceover=voiceover,
|
|
@@ -291,26 +331,31 @@ def _submit_ai_reel(
|
|
| 291 |
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 292 |
|
| 293 |
|
| 294 |
-
def
|
|
|
|
| 295 |
return job_manager.get(job_id).__dict__
|
| 296 |
|
| 297 |
|
| 298 |
-
def
|
|
|
|
| 299 |
return "\n\n".join(job_manager.get(job_id).logs)
|
| 300 |
|
| 301 |
|
| 302 |
-
def
|
|
|
|
| 303 |
record = job_manager.get(job_id)
|
| 304 |
if record.state != "COMPLETED":
|
| 305 |
return None
|
| 306 |
return record.output_path
|
| 307 |
|
| 308 |
|
| 309 |
-
def
|
|
|
|
| 310 |
return RenderEngine(settings).inspect_asset(path)
|
| 311 |
|
| 312 |
|
| 313 |
-
def
|
|
|
|
| 314 |
return RenderEngine(settings).transcribe(
|
| 315 |
path,
|
| 316 |
model_size=model_size,
|
|
@@ -319,7 +364,8 @@ def _transcribe_file(path: str, model_size: str, language: str) -> dict[str, Any
|
|
| 319 |
)
|
| 320 |
|
| 321 |
|
| 322 |
-
def
|
|
|
|
| 323 |
from renderer.platform import PlatformProcessor
|
| 324 |
|
| 325 |
job_id = job_manager.submit_task(
|
|
@@ -328,7 +374,8 @@ def _submit_analysis(media: str, transcript: str, platform: str) -> dict[str, An
|
|
| 328 |
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 329 |
|
| 330 |
|
| 331 |
-
def
|
|
|
|
| 332 |
from renderer.platform import PlatformProcessor
|
| 333 |
|
| 334 |
clips = json.loads(clips_json)
|
|
@@ -336,14 +383,16 @@ def _submit_clips(media: str, clips_json: str) -> dict[str, Any]:
|
|
| 336 |
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 337 |
|
| 338 |
|
| 339 |
-
def
|
|
|
|
| 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
|
|
|
|
| 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}
|
|
@@ -351,11 +400,13 @@ def _submit_publish(media: str, title: str, platforms: str) -> dict[str, Any]:
|
|
| 351 |
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 352 |
|
| 353 |
|
| 354 |
-
def
|
|
|
|
| 355 |
return job_manager.summary()
|
| 356 |
|
| 357 |
|
| 358 |
-
def
|
|
|
|
| 359 |
return {
|
| 360 |
"product": "Ava2lon Studio AI",
|
| 361 |
"base_dir": str(settings.base_dir),
|
|
|
|
| 15 |
from renderer.templates import apply_creative_style, apply_preset, list_creative_styles, list_platform_profiles, list_templates
|
| 16 |
|
| 17 |
|
| 18 |
+
def _mcp_description(summary: str, example: dict[str, Any]) -> str:
|
| 19 |
+
"""Build a concise MCP description with a machine-readable JSON example."""
|
| 20 |
+
return f"{summary} Example JSON: {json.dumps(example, separators=(',', ':'), ensure_ascii=True)}"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
def create_dashboard() -> gr.Blocks:
|
| 24 |
with gr.Blocks(title="Ava2lon Studio AI") as dashboard:
|
| 25 |
gr.Markdown(
|
|
|
|
| 32 |
project_create = gr.Button("Create Project", variant="primary")
|
| 33 |
project_list = gr.Button("Refresh Projects")
|
| 34 |
project_output = gr.JSON(label="Projects")
|
| 35 |
+
project_create.click(fn=create_project, inputs=[project_name, project_metadata], outputs=project_output, api_name="create_project", api_description=_mcp_description("Create a project from a name and JSON metadata object.", {"name": "Campaign 01", "metadata_json": '{"platform":"tiktok"}'}))
|
| 36 |
+
project_list.click(fn=list_projects, outputs=project_output, api_name="list_projects", api_description=_mcp_description("List saved studio projects.", {}))
|
| 37 |
|
| 38 |
with gr.Tab("Assets"):
|
| 39 |
gr.Markdown("Use `/upload` or `/assets/upload` for multipart assets, then attach them to a project with `/project/assets/add`.")
|
|
|
|
| 41 |
asset_json = gr.Textbox(label="Asset JSON", lines=6, value=json.dumps({"path": "clip.mp4", "kind": "video"}, indent=2))
|
| 42 |
asset_button = gr.Button("Attach Asset", variant="primary")
|
| 43 |
asset_output = gr.JSON(label="Project")
|
| 44 |
+
asset_button.click(fn=add_project_asset, inputs=[asset_project_id, asset_json], outputs=asset_output, api_name="add_project_asset", api_description=_mcp_description("Attach a media asset JSON object to an existing project.", {"project_id": "project_123", "asset_json": '{"path":"clip.mp4","kind":"video"}'}))
|
| 45 |
|
| 46 |
with gr.Tab("Timeline"):
|
| 47 |
timeline_project_id = gr.Textbox(label="Project ID")
|
|
|
|
| 53 |
)
|
| 54 |
timeline_add_button = gr.Button("Add To Timeline", variant="primary")
|
| 55 |
timeline_output = gr.JSON(label="Project")
|
| 56 |
+
timeline_add_button.click(fn=add_timeline_item, inputs=[timeline_project_id, timeline_track_type, timeline_item], outputs=timeline_output, api_name="add_timeline_item", api_description=_mcp_description("Add a typed item to a project timeline.", {"project_id": "project_123", "track_type": "video", "item_json": '{"media":"clip.mp4","start":0,"duration":5,"caption":"Hook"}'}))
|
| 57 |
|
| 58 |
timeline_operation_json = gr.Textbox(
|
| 59 |
label="Operation JSON",
|
|
|
|
| 61 |
value=json.dumps({"operation": "split", "item_id": "clip_123", "params": {"offset": 2.5}}, indent=2),
|
| 62 |
)
|
| 63 |
timeline_operation_button = gr.Button("Apply Operation")
|
| 64 |
+
timeline_operation_button.click(fn=apply_timeline_operation, inputs=[timeline_project_id, timeline_operation_json], outputs=timeline_output, api_name="apply_timeline_operation", api_description=_mcp_description("Apply a timeline operation such as split, trim, insert, or ripple delete.", {"project_id": "project_123", "operation_json": '{"operation":"split","item_id":"clip_123","params":{"offset":2.5}}'}))
|
| 65 |
|
| 66 |
with gr.Tab("Templates"):
|
| 67 |
template_button = gr.Button("Load Template Catalog")
|
| 68 |
template_output = gr.JSON(label="Templates")
|
| 69 |
+
template_button.click(fn=load_template_catalog, outputs=template_output, api_name="load_template_catalog", api_description=_mcp_description("Load available render and caption templates.", {}))
|
| 70 |
|
| 71 |
with gr.Tab("Effects"):
|
| 72 |
effect_button = gr.Button("Load Effect Catalog")
|
| 73 |
effect_output = gr.JSON(label="Effects")
|
| 74 |
+
effect_button.click(fn=load_effect_catalog, outputs=effect_output, api_name="load_effect_catalog", api_description=_mcp_description("Load available video effects.", {}))
|
| 75 |
|
| 76 |
with gr.Tab("Filters"):
|
| 77 |
filter_button = gr.Button("Load Filter Catalog")
|
| 78 |
filter_output = gr.JSON(label="Filters")
|
| 79 |
+
filter_button.click(fn=load_filter_catalog, outputs=filter_output, api_name="load_filter_catalog", api_description=_mcp_description("Load available video filters.", {}))
|
| 80 |
|
| 81 |
with gr.Tab("Captions"):
|
| 82 |
caption_text = gr.Textbox(label="Caption source text", lines=6)
|
| 83 |
caption_button = gr.Button("Submit Caption Job", variant="primary")
|
| 84 |
caption_output = gr.JSON(label="Caption Job")
|
| 85 |
+
caption_button.click(fn=submit_caption_job, inputs=caption_text, outputs=caption_output, api_name="submit_caption_job", api_description=_mcp_description("Submit a caption-generation job from source text.", {"text": "Launch faster with automated rendering."}))
|
| 86 |
|
| 87 |
with gr.Tab("Audio"):
|
| 88 |
audio_button = gr.Button("Load Audio Catalog")
|
| 89 |
audio_output = gr.JSON(label="Audio")
|
| 90 |
+
audio_button.click(fn=load_audio_catalog, outputs=audio_output, api_name="load_audio_catalog", api_description=_mcp_description("Load audio-processing and music-generation capabilities.", {}))
|
| 91 |
|
| 92 |
with gr.Tab("AI Tools"):
|
| 93 |
ai_tool = gr.Dropdown(choices=capability_catalog()["ai_editing"], value="auto_viral_score", label="AI tool")
|
| 94 |
ai_payload = gr.Textbox(label="AI payload JSON", lines=8, value=json.dumps({"platform": "tiktok", "text": "A strong opening hook"}, indent=2))
|
| 95 |
ai_button = gr.Button("Submit AI Tool", variant="primary")
|
| 96 |
ai_output = gr.JSON(label="AI Job")
|
| 97 |
+
ai_button.click(fn=submit_ai_tool, inputs=[ai_tool, ai_payload], outputs=ai_output, api_name="submit_ai_tool", api_description=_mcp_description("Submit a configured AI editing tool job.", {"tool": "auto_viral_score", "payload": '{"platform":"tiktok","text":"A strong opening hook"}'}))
|
| 98 |
|
| 99 |
with gr.Tab("Rendering"):
|
| 100 |
render_json = gr.Textbox(
|
|
|
|
| 105 |
)
|
| 106 |
render_button = gr.Button("Submit Render", variant="primary")
|
| 107 |
render_output = gr.JSON(label="Submission")
|
| 108 |
+
render_button.click(fn=submit_render_job, inputs=render_json, outputs=render_output, api_name="submit_render_job", api_description=_mcp_description("Submit a validated JSON video render job.", {"payload": '{"scenes":[{"start":0,"duration":5,"media":"clip.mp4","caption":"Opening hook"}],"platform":"tiktok","output_name":"render.mp4"}'}))
|
| 109 |
|
| 110 |
with gr.Tab("AI Reels"):
|
| 111 |
script = gr.Textbox(label="Script", lines=6)
|
|
|
|
| 117 |
music = gr.File(label="Background Music", file_types=["audio"], type="filepath")
|
| 118 |
ai_button = gr.Button("Submit AI Reel", variant="primary")
|
| 119 |
ai_output = gr.JSON(label="Submission")
|
| 120 |
+
ai_button.click(fn=submit_ai_reel, inputs=[script, voiceover, assets, template, creative_style, platform, music], outputs=ai_output, api_name="submit_ai_reel", api_description=_mcp_description("Create an AI reel from a script, voiceover, and visual assets. Upload files first and use the returned paths.", {"script": "Three automation tips.", "voiceover": "/tmp/voice.wav", "assets": ["/tmp/a.jpg", "/tmp/b.mp4"], "template": "tiktok_classic", "creative_style": "viral_shorts", "platform": "tiktok", "music": None}))
|
| 121 |
|
| 122 |
with gr.Tab("Batch Render"):
|
| 123 |
batch_json = gr.Textbox(label="Batch JSON", lines=14, value=json.dumps({"jobs": []}, indent=2))
|
| 124 |
batch_button = gr.Button("Submit Batch", variant="primary")
|
| 125 |
batch_output = gr.JSON(label="Batch Submission")
|
| 126 |
+
batch_button.click(fn=submit_batch_render, inputs=batch_json, outputs=batch_output, api_name="submit_batch_render", api_description=_mcp_description("Submit multiple render jobs from a JSON batch.", {"payload": '{"jobs":[{"scenes":[{"start":0,"duration":3,"media":"clip.mp4"}],"output_name":"clip-a.mp4"}]}'}))
|
| 127 |
|
| 128 |
with gr.Tab("Job Status"):
|
| 129 |
status_job_id = gr.Textbox(label="Job ID")
|
| 130 |
status_button = gr.Button("Refresh")
|
| 131 |
status_output = gr.JSON(label="Status")
|
| 132 |
+
status_button.click(fn=get_job_status, inputs=status_job_id, outputs=status_output, api_name="get_job_status", api_description=_mcp_description("Retrieve the current state and metrics for a job.", {"job_id": "job_abc123"}))
|
| 133 |
|
| 134 |
with gr.Tab("Logs"):
|
| 135 |
logs_job_id = gr.Textbox(label="Job ID")
|
| 136 |
logs_button = gr.Button("Load Logs")
|
| 137 |
logs_output = gr.Textbox(label="Logs", lines=20)
|
| 138 |
+
logs_button.click(fn=get_job_logs, inputs=logs_job_id, outputs=logs_output, api_name="get_job_logs", api_description=_mcp_description("Retrieve execution logs for a job.", {"job_id": "job_abc123"}))
|
| 139 |
|
| 140 |
with gr.Tab("Downloads"):
|
| 141 |
download_job_id = gr.Textbox(label="Job ID")
|
| 142 |
download_button = gr.Button("Get Output")
|
| 143 |
download_output = gr.File(label="Rendered Video")
|
| 144 |
+
download_button.click(fn=get_download_path, inputs=download_job_id, outputs=download_output, api_name="get_download_path", api_description=_mcp_description("Return the completed artifact path for a job.", {"job_id": "job_abc123"}))
|
| 145 |
|
| 146 |
with gr.Tab("Transcribe"):
|
| 147 |
transcribe_audio = gr.File(label="Audio or Video", file_types=["audio", "video"], type="filepath")
|
|
|
|
| 154 |
transcribe_button = gr.Button("Transcribe", variant="primary")
|
| 155 |
transcribe_output = gr.JSON(label="Transcript")
|
| 156 |
transcribe_button.click(
|
| 157 |
+
fn=transcribe_audio_file,
|
| 158 |
inputs=[transcribe_audio, transcribe_model, transcribe_language],
|
| 159 |
outputs=transcribe_output,
|
| 160 |
+
api_name="transcribe_audio_file",
|
| 161 |
+
api_description=_mcp_description("Transcribe an uploaded audio or video file with word timestamps. Upload the file first and use its returned path.", {"path": "/tmp/interview.mp3", "model_size": "tiny", "language": "en"}),
|
| 162 |
)
|
| 163 |
|
| 164 |
with gr.Tab("Asset Inspector"):
|
| 165 |
asset_path = gr.Textbox(label="Asset path")
|
| 166 |
inspect_button = gr.Button("Inspect")
|
| 167 |
inspect_output = gr.JSON(label="Metadata")
|
| 168 |
+
inspect_button.click(fn=inspect_media_asset, inputs=asset_path, outputs=inspect_output, api_name="inspect_media_asset", api_description=_mcp_description("Inspect media codecs, streams, duration, and dimensions.", {"path": "/app/storage/video.mp4"}))
|
| 169 |
|
| 170 |
with gr.Tab("AI Analysis"):
|
| 171 |
analysis_media = gr.Textbox(label="Media URL or path")
|
|
|
|
| 174 |
analysis_button = gr.Button("Submit Analysis", variant="primary")
|
| 175 |
analysis_output = gr.JSON(label="Analysis Job")
|
| 176 |
analysis_button.click(
|
| 177 |
+
fn=submit_media_analysis,
|
| 178 |
inputs=[analysis_media, analysis_transcript, analysis_platform],
|
| 179 |
outputs=analysis_output,
|
| 180 |
+
api_name="submit_media_analysis",
|
| 181 |
+
api_description=_mcp_description("Analyze media for highlights, pacing, platform fit, and engagement signals.", {"media": "https://example.com/video.mp4", "transcript": "A strong opening hook and useful explanation.", "platform": "tiktok"}),
|
| 182 |
)
|
| 183 |
|
| 184 |
with gr.Tab("Clip Generator"):
|
|
|
|
| 186 |
clip_json = gr.Textbox(label="Clip JSON", lines=6, value=json.dumps([{"start": 0, "end": 8}], indent=2))
|
| 187 |
clip_button = gr.Button("Generate Clips", variant="primary")
|
| 188 |
clip_output = gr.JSON(label="Clip Job")
|
| 189 |
+
clip_button.click(fn=submit_clip_job, inputs=[clip_media, clip_json], outputs=clip_output, api_name="submit_clip_job", api_description=_mcp_description("Generate one or more clips from media and timed clip JSON.", {"media": "https://example.com/video.mp4", "clips_json": '[{"start":0,"end":8},{"start":20,"end":32}]'}))
|
| 190 |
|
| 191 |
with gr.Tab("Metadata"):
|
| 192 |
metadata_topic = gr.Textbox(label="Topic or transcript", lines=5)
|
| 193 |
metadata_platform = gr.Dropdown(choices=list_platform_profiles(), value="tiktok", label="Platform")
|
| 194 |
metadata_button = gr.Button("Generate Metadata", variant="primary")
|
| 195 |
metadata_output = gr.JSON(label="Metadata Job")
|
| 196 |
+
metadata_button.click(fn=submit_metadata_job, inputs=[metadata_topic, metadata_platform], outputs=metadata_output, api_name="submit_metadata_job", api_description=_mcp_description("Generate platform-aware title, description, hashtags, and chapters.", {"topic": "Five n8n video automation mistakes", "platform": "youtube_shorts"}))
|
| 197 |
|
| 198 |
with gr.Tab("Publishing"):
|
| 199 |
publish_media = gr.Textbox(label="Media URL or rendered output path")
|
|
|
|
| 201 |
publish_platforms = gr.Textbox(label="Platforms", value="youtube,tiktok,instagram")
|
| 202 |
publish_button = gr.Button("Create Publish Draft", variant="primary")
|
| 203 |
publish_output = gr.JSON(label="Publish Job")
|
| 204 |
+
publish_button.click(fn=create_publish_draft, inputs=[publish_media, publish_title, publish_platforms], outputs=publish_output, api_name="create_publish_draft", api_description=_mcp_description("Create a publishing draft for one or more platforms.", {"media": "/app/exports/render.mp4", "title": "Automation Tips", "platforms": "youtube,tiktok,instagram"}))
|
| 205 |
|
| 206 |
with gr.Tab("Settings"):
|
| 207 |
settings_button = gr.Button("Load Settings")
|
| 208 |
settings_output = gr.JSON(label="Settings")
|
| 209 |
+
settings_button.click(fn=get_runtime_settings, outputs=settings_output, api_name="get_runtime_settings", api_description=_mcp_description("Return renderer configuration and runtime capability settings.", {}))
|
| 210 |
|
| 211 |
with gr.Tab("Queue Monitor"):
|
| 212 |
queue_button = gr.Button("Refresh Queue")
|
| 213 |
queue_output = gr.JSON(label="Queue")
|
| 214 |
+
queue_button.click(fn=get_queue_status, outputs=queue_output, api_name="get_queue_status", api_description=_mcp_description("Return queue counts and active render jobs.", {}))
|
| 215 |
|
| 216 |
return dashboard
|
| 217 |
|
| 218 |
|
| 219 |
+
def submit_render_job(payload: str) -> dict[str, Any]:
|
| 220 |
+
"""Submit a validated JSON timeline for asynchronous video rendering."""
|
| 221 |
data = apply_creative_style(apply_preset(json.loads(payload)))
|
| 222 |
request = Timeline.request_from_payload(data)
|
| 223 |
job_id = job_manager.submit_render(request)
|
| 224 |
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 225 |
|
| 226 |
|
| 227 |
+
def create_project(name: str, metadata_json: str) -> dict[str, Any]:
|
| 228 |
+
"""Create a persistent studio project with optional JSON metadata."""
|
| 229 |
metadata = json.loads(metadata_json or "{}")
|
| 230 |
return {"project": project_manager.create(name, metadata=metadata)}
|
| 231 |
|
| 232 |
|
| 233 |
+
def list_projects() -> dict[str, Any]:
|
| 234 |
+
"""List all persistent studio projects."""
|
| 235 |
return {"projects": project_manager.list()}
|
| 236 |
|
| 237 |
|
| 238 |
+
def add_project_asset(project_id: str, asset_json: str) -> dict[str, Any]:
|
| 239 |
+
"""Attach a media asset JSON object to an existing project."""
|
| 240 |
return {"project": project_manager.add_asset(project_id, json.loads(asset_json or "{}"))}
|
| 241 |
|
| 242 |
|
| 243 |
+
def add_timeline_item(project_id: str, track_type: str, item_json: str) -> dict[str, Any]:
|
| 244 |
+
"""Add a JSON item to a video, audio, text, overlay, sticker, or subtitle track."""
|
| 245 |
return {"project": project_manager.add_to_timeline(project_id, json.loads(item_json or "{}"), track_type=track_type)}
|
| 246 |
|
| 247 |
|
| 248 |
+
def apply_timeline_operation(project_id: str, operation_json: str) -> dict[str, Any]:
|
| 249 |
+
"""Apply a JSON timeline operation such as split, trim, insert, or ripple delete."""
|
| 250 |
data = json.loads(operation_json or "{}")
|
| 251 |
return {
|
| 252 |
"project": project_manager.timeline_operation(
|
|
|
|
| 263 |
return {section: catalog.get(section)}
|
| 264 |
|
| 265 |
|
| 266 |
+
def load_template_catalog() -> dict[str, Any]:
|
| 267 |
+
"""Return available render and caption template capabilities."""
|
| 268 |
+
return _catalog_section("templates")
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def load_effect_catalog() -> dict[str, Any]:
|
| 272 |
+
"""Return available video effect capabilities."""
|
| 273 |
+
return _catalog_section("effects")
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def load_filter_catalog() -> dict[str, Any]:
|
| 277 |
+
"""Return available video filter capabilities."""
|
| 278 |
+
return _catalog_section("filters")
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def load_audio_catalog() -> dict[str, Any]:
|
| 282 |
+
"""Return audio-processing and music-generation capabilities."""
|
| 283 |
+
catalog = capability_catalog()
|
| 284 |
+
return {"audio": catalog["audio"], "music": catalog["music_generator"]}
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def submit_caption_job(text: str) -> dict[str, Any]:
|
| 288 |
+
"""Submit an asynchronous caption-generation job from source text."""
|
| 289 |
from renderer.studio import StudioTaskProcessor
|
| 290 |
|
| 291 |
job_id = job_manager.submit_task(lambda task_id, log: StudioTaskProcessor(settings, log=log).caption_generate({"text": text}, task_id))
|
| 292 |
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 293 |
|
| 294 |
|
| 295 |
+
def submit_ai_tool(tool: str, payload: str) -> dict[str, Any]:
|
| 296 |
+
"""Submit an AI editing tool using its name and a JSON payload."""
|
| 297 |
from renderer.studio import StudioTaskProcessor
|
| 298 |
|
| 299 |
data = json.loads(payload or "{}")
|
|
|
|
| 301 |
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 302 |
|
| 303 |
|
| 304 |
+
def submit_batch_render(payload: str) -> dict[str, Any]:
|
| 305 |
+
"""Submit multiple asynchronous render jobs from a JSON jobs array."""
|
| 306 |
data = json.loads(payload)
|
| 307 |
requests = [Timeline.request_from_payload(apply_creative_style(apply_preset(job))) for job in data.get("jobs", [])]
|
| 308 |
return {"job_ids": job_manager.submit_batch(requests)}
|
| 309 |
|
| 310 |
|
| 311 |
+
def submit_ai_reel(
|
| 312 |
script: str,
|
| 313 |
voiceover: str,
|
| 314 |
assets: list[str],
|
|
|
|
| 317 |
platform: str,
|
| 318 |
music: str | None,
|
| 319 |
) -> dict[str, Any]:
|
| 320 |
+
"""Create an AI reel from a script, voiceover, visual assets, style, and music."""
|
| 321 |
request = AIReelsRequest(
|
| 322 |
script=script,
|
| 323 |
voiceover=voiceover,
|
|
|
|
| 331 |
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 332 |
|
| 333 |
|
| 334 |
+
def get_job_status(job_id: str) -> dict[str, Any]:
|
| 335 |
+
"""Return state, metrics, artifact metadata, and errors for a job ID."""
|
| 336 |
return job_manager.get(job_id).__dict__
|
| 337 |
|
| 338 |
|
| 339 |
+
def get_job_logs(job_id: str) -> str:
|
| 340 |
+
"""Return execution log messages for a job ID."""
|
| 341 |
return "\n\n".join(job_manager.get(job_id).logs)
|
| 342 |
|
| 343 |
|
| 344 |
+
def get_download_path(job_id: str) -> str | None:
|
| 345 |
+
"""Return the local artifact path when a job has completed."""
|
| 346 |
record = job_manager.get(job_id)
|
| 347 |
if record.state != "COMPLETED":
|
| 348 |
return None
|
| 349 |
return record.output_path
|
| 350 |
|
| 351 |
|
| 352 |
+
def inspect_media_asset(path: str) -> dict[str, Any]:
|
| 353 |
+
"""Inspect a local media asset for streams, codecs, duration, and dimensions."""
|
| 354 |
return RenderEngine(settings).inspect_asset(path)
|
| 355 |
|
| 356 |
|
| 357 |
+
def transcribe_audio_file(path: str, model_size: str, language: str) -> dict[str, Any]:
|
| 358 |
+
"""Transcribe an uploaded audio or video file with optional language selection."""
|
| 359 |
return RenderEngine(settings).transcribe(
|
| 360 |
path,
|
| 361 |
model_size=model_size,
|
|
|
|
| 364 |
)
|
| 365 |
|
| 366 |
|
| 367 |
+
def submit_media_analysis(media: str, transcript: str, platform: str) -> dict[str, Any]:
|
| 368 |
+
"""Analyze media for highlights, pacing, retention, engagement, and platform fit."""
|
| 369 |
from renderer.platform import PlatformProcessor
|
| 370 |
|
| 371 |
job_id = job_manager.submit_task(
|
|
|
|
| 374 |
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 375 |
|
| 376 |
|
| 377 |
+
def submit_clip_job(media: str, clips_json: str) -> dict[str, Any]:
|
| 378 |
+
"""Generate timed clips from a media path or URL and a JSON clip specification."""
|
| 379 |
from renderer.platform import PlatformProcessor
|
| 380 |
|
| 381 |
clips = json.loads(clips_json)
|
|
|
|
| 383 |
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 384 |
|
| 385 |
|
| 386 |
+
def submit_metadata_job(topic: str, platform: str) -> dict[str, Any]:
|
| 387 |
+
"""Generate platform-aware title, description, hashtags, keywords, and chapters."""
|
| 388 |
from renderer.platform import PlatformProcessor
|
| 389 |
|
| 390 |
job_id = job_manager.submit_task(lambda task_id, log: PlatformProcessor(settings, log=log).metadata(task_id, topic=topic, platform=platform))
|
| 391 |
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 392 |
|
| 393 |
|
| 394 |
+
def create_publish_draft(media: str, title: str, platforms: str) -> dict[str, Any]:
|
| 395 |
+
"""Create an asynchronous publishing draft for comma-separated platforms."""
|
| 396 |
from renderer.platform import PlatformProcessor
|
| 397 |
|
| 398 |
payload = {"media": media, "title": title, "platforms": [item.strip() for item in platforms.split(",") if item.strip()], "draft": True}
|
|
|
|
| 400 |
return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
|
| 401 |
|
| 402 |
|
| 403 |
+
def get_queue_status() -> dict[str, Any]:
|
| 404 |
+
"""Return queue totals, state counts, worker limits, and active jobs."""
|
| 405 |
return job_manager.summary()
|
| 406 |
|
| 407 |
|
| 408 |
+
def get_runtime_settings() -> dict[str, Any]:
|
| 409 |
+
"""Return renderer paths, worker settings, Whisper settings, and capabilities."""
|
| 410 |
return {
|
| 411 |
"product": "Ava2lon Studio AI",
|
| 412 |
"base_dir": str(settings.base_dir),
|
tests/test_mcp_metadata.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import ast
|
| 4 |
+
import json
|
| 5 |
+
import unittest
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class MCPMetadataTests(unittest.TestCase):
|
| 10 |
+
def test_every_dashboard_tool_has_public_name_and_description(self) -> None:
|
| 11 |
+
tree = ast.parse(Path("renderer_app.py").read_text(encoding="utf-8-sig"))
|
| 12 |
+
functions = {
|
| 13 |
+
node.name: node
|
| 14 |
+
for node in tree.body
|
| 15 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
| 16 |
+
}
|
| 17 |
+
events = [
|
| 18 |
+
node
|
| 19 |
+
for node in ast.walk(tree)
|
| 20 |
+
if isinstance(node, ast.Call)
|
| 21 |
+
and isinstance(node.func, ast.Attribute)
|
| 22 |
+
and node.func.attr == "click"
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
self.assertEqual(len(events), 25)
|
| 26 |
+
for event in events:
|
| 27 |
+
keywords = {item.arg: item.value for item in event.keywords if item.arg}
|
| 28 |
+
callback = keywords.get("fn")
|
| 29 |
+
self.assertIsInstance(callback, ast.Name, "MCP callbacks must be named functions")
|
| 30 |
+
callback_name = callback.id
|
| 31 |
+
self.assertFalse(callback_name.startswith("_"), f"Private MCP callback: {callback_name}")
|
| 32 |
+
self.assertIn(callback_name, functions)
|
| 33 |
+
self.assertTrue(ast.get_docstring(functions[callback_name]), f"Missing docstring: {callback_name}")
|
| 34 |
+
|
| 35 |
+
api_name = keywords.get("api_name")
|
| 36 |
+
description = keywords.get("api_description")
|
| 37 |
+
self.assertIsInstance(api_name, ast.Constant, f"Missing api_name: {callback_name}")
|
| 38 |
+
self.assertTrue(api_name.value, f"Empty api_name: {callback_name}")
|
| 39 |
+
self.assertIsInstance(description, ast.Call, f"Missing structured api_description: {callback_name}")
|
| 40 |
+
self.assertIsInstance(description.func, ast.Name)
|
| 41 |
+
self.assertEqual(description.func.id, "_mcp_description")
|
| 42 |
+
self.assertEqual(len(description.args), 2)
|
| 43 |
+
|
| 44 |
+
summary = ast.literal_eval(description.args[0])
|
| 45 |
+
example = ast.literal_eval(description.args[1])
|
| 46 |
+
self.assertTrue(summary, f"Empty description: {callback_name}")
|
| 47 |
+
self.assertIsInstance(example, dict, f"Example must be an object: {callback_name}")
|
| 48 |
+
encoded = json.dumps(example, separators=(",", ":"), ensure_ascii=True)
|
| 49 |
+
self.assertEqual(json.loads(encoded), example)
|
| 50 |
+
|
| 51 |
+
parameters = [argument.arg for argument in functions[callback_name].args.args]
|
| 52 |
+
self.assertEqual(set(example), set(parameters), f"Example keys do not match: {callback_name}")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
if __name__ == "__main__":
|
| 56 |
+
unittest.main()
|