davinci-resolve-mcp 2.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/AGENTS.md +85 -0
  2. package/CHANGELOG.md +802 -0
  3. package/CLAUDE.md +15 -0
  4. package/LICENSE +21 -0
  5. package/README.md +159 -0
  6. package/SECURITY.md +53 -0
  7. package/bin/davinci-resolve-mcp.mjs +376 -0
  8. package/docs/README.md +56 -0
  9. package/docs/SKILL.md +1145 -0
  10. package/docs/authoring/fuse-dctl-authoring.md +242 -0
  11. package/docs/authoring/script-plugin-authoring.md +195 -0
  12. package/docs/contributing.md +82 -0
  13. package/docs/guides/color-decision-guide.md +387 -0
  14. package/docs/guides/editorial-decision-guide.md +136 -0
  15. package/docs/guides/media-analysis-guide.md +615 -0
  16. package/docs/guides/multicam-setup-guide.md +138 -0
  17. package/docs/install.md +198 -0
  18. package/docs/integrations/workflow-integrations.md +120 -0
  19. package/docs/kernels/README.md +28 -0
  20. package/docs/kernels/audio-fairlight-kernel.md +86 -0
  21. package/docs/kernels/color-grade-kernel.md +103 -0
  22. package/docs/kernels/extension-authoring-kernel.md +101 -0
  23. package/docs/kernels/fusion-composition-kernel.md +91 -0
  24. package/docs/kernels/media-pool-ingest-kernel.md +147 -0
  25. package/docs/kernels/project-lifecycle-kernel.md +120 -0
  26. package/docs/kernels/render-deliver-kernel.md +92 -0
  27. package/docs/kernels/review-annotation-kernel.md +110 -0
  28. package/docs/kernels/timeline-conform-interchange-kernel.md +99 -0
  29. package/docs/kernels/timeline-edit-kernel.md +189 -0
  30. package/docs/notes/codec-plugin-notes.md +136 -0
  31. package/docs/notes/dctl-notes.md +234 -0
  32. package/docs/notes/fusion-template-notes.md +136 -0
  33. package/docs/notes/lut-notes.md +136 -0
  34. package/docs/notes/openfx-notes.md +120 -0
  35. package/docs/process/release-process.md +152 -0
  36. package/docs/reference/api-coverage.md +488 -0
  37. package/docs/reference/resolve_scripting_api.txt +1012 -0
  38. package/examples/README.md +53 -0
  39. package/examples/markers/README.md +81 -0
  40. package/examples/media/README.md +94 -0
  41. package/examples/timeline/README.md +98 -0
  42. package/install.py +1196 -0
  43. package/package.json +52 -0
  44. package/scripts/audit_api_parity.py +275 -0
  45. package/scripts/live_media_analysis_polish_probe.py +65 -0
  46. package/src/__init__.py +3 -0
  47. package/src/analysis_dashboard.py +4936 -0
  48. package/src/control_panel.py +13 -0
  49. package/src/granular/__init__.py +17 -0
  50. package/src/granular/common.py +727 -0
  51. package/src/granular/folder.py +287 -0
  52. package/src/granular/gallery.py +306 -0
  53. package/src/granular/graph.py +309 -0
  54. package/src/granular/media_pool.py +679 -0
  55. package/src/granular/media_pool_item.py +852 -0
  56. package/src/granular/media_storage.py +179 -0
  57. package/src/granular/project.py +1594 -0
  58. package/src/granular/resolve_control.py +521 -0
  59. package/src/granular/timeline.py +1074 -0
  60. package/src/granular/timeline_item.py +2251 -0
  61. package/src/resolve_mcp_server.py +43 -0
  62. package/src/server.py +15691 -0
  63. package/src/utils/__init__.py +3 -0
  64. package/src/utils/app_control.py +319 -0
  65. package/src/utils/audio_fairlight_live_probe.py +263 -0
  66. package/src/utils/cdl.py +20 -0
  67. package/src/utils/cloud_operations.py +192 -0
  68. package/src/utils/color_grade_live_probe.py +444 -0
  69. package/src/utils/dctl_templates.py +368 -0
  70. package/src/utils/extension_authoring_live_probe.py +292 -0
  71. package/src/utils/fuse_templates.py +1968 -0
  72. package/src/utils/fusion_composition_live_probe.py +284 -0
  73. package/src/utils/layout_presets.py +333 -0
  74. package/src/utils/mcp_stdio.py +32 -0
  75. package/src/utils/media_analysis.py +3618 -0
  76. package/src/utils/media_analysis_jobs.py +796 -0
  77. package/src/utils/media_pool_ingest_live_probe.py +592 -0
  78. package/src/utils/multicam.py +393 -0
  79. package/src/utils/object_inspection.py +287 -0
  80. package/src/utils/platform.py +157 -0
  81. package/src/utils/project_lifecycle_live_probe.py +376 -0
  82. package/src/utils/project_properties.py +601 -0
  83. package/src/utils/render_deliver_live_probe.py +384 -0
  84. package/src/utils/resolve_connection.py +77 -0
  85. package/src/utils/review_annotation_live_probe.py +352 -0
  86. package/src/utils/script_templates.py +1193 -0
  87. package/src/utils/sync_detection.py +887 -0
  88. package/src/utils/timeline_conform_live_probe.py +280 -0
  89. package/src/utils/timeline_kernel_live_probe.py +1091 -0
  90. package/src/utils/timeline_kernel_probe.py +185 -0
  91. package/src/utils/timeline_title_text.py +87 -0
  92. package/src/utils/update_check.py +610 -0
@@ -0,0 +1,679 @@
1
+ """Media pool operations and MediaPool API tools."""
2
+
3
+ from src.granular.common import * # noqa: F401,F403
4
+ from src.utils.multicam import build_multicam_setup_plan
5
+
6
+ resolve = ResolveProxy()
7
+
8
+
9
+ def _ensure_timeline_tracks_for_multicam(timeline, track_type: str, needed: int, audio_type: str = "stereo"):
10
+ needed = max(0, int(needed or 0))
11
+ added = 0
12
+ try:
13
+ current = int(timeline.GetTrackCount(track_type) or 0)
14
+ except Exception as exc:
15
+ return {"success": False, "error": f"GetTrackCount({track_type}) failed: {exc}"}
16
+ while current < needed:
17
+ try:
18
+ if track_type == "audio":
19
+ ok = timeline.AddTrack(track_type, {"audioType": audio_type})
20
+ else:
21
+ ok = timeline.AddTrack(track_type)
22
+ except TypeError:
23
+ ok = timeline.AddTrack(track_type)
24
+ except Exception as exc:
25
+ return {"success": False, "error": f"AddTrack({track_type}) failed: {exc}"}
26
+ if not ok:
27
+ return {"success": False, "error": f"AddTrack({track_type}) returned false at track {current + 1}"}
28
+ current += 1
29
+ added += 1
30
+ return {"success": True, "existing": current - added, "added": added, "count": current}
31
+
32
+
33
+ def _set_multicam_track_names(timeline, plan):
34
+ results = []
35
+ for angle in plan.get("angles") or []:
36
+ angle_name = angle.get("angle_name") or f"Angle {angle.get('angle_index')}"
37
+ video_track = angle.get("video_track_index")
38
+ if video_track:
39
+ try:
40
+ results.append({
41
+ "track_type": "video",
42
+ "track_index": video_track,
43
+ "name": angle_name,
44
+ "success": bool(timeline.SetTrackName("video", int(video_track), str(angle_name))),
45
+ })
46
+ except Exception as exc:
47
+ results.append({"track_type": "video", "track_index": video_track, "success": False, "error": str(exc)})
48
+ audio_track = angle.get("audio_track_index")
49
+ if audio_track:
50
+ try:
51
+ results.append({
52
+ "track_type": "audio",
53
+ "track_index": audio_track,
54
+ "name": angle_name,
55
+ "success": bool(timeline.SetTrackName("audio", int(audio_track), str(angle_name))),
56
+ })
57
+ except Exception as exc:
58
+ results.append({"track_type": "audio", "track_index": audio_track, "success": False, "error": str(exc)})
59
+ return results
60
+
61
+
62
+ def _appended_item_summary(item):
63
+ try:
64
+ item_id = item.GetUniqueId()
65
+ except Exception:
66
+ item_id = None
67
+ try:
68
+ name = item.GetName()
69
+ except Exception:
70
+ name = None
71
+ return {"timeline_item_id": item_id, "name": name}
72
+
73
+ @mcp.resource("resolve://media-pool-clips")
74
+ def list_media_pool_clips() -> List[Dict[str, Any]]:
75
+ """List all clips in the root folder of the media pool."""
76
+ pm, current_project = get_current_project()
77
+ if not current_project:
78
+ return [{"error": "No project currently open"}]
79
+
80
+ media_pool = current_project.GetMediaPool()
81
+ if not media_pool:
82
+ return [{"error": "Failed to get Media Pool"}]
83
+
84
+ root_folder = media_pool.GetRootFolder()
85
+ if not root_folder:
86
+ return [{"error": "Failed to get root folder"}]
87
+
88
+ clips = root_folder.GetClipList()
89
+ if not clips:
90
+ return [{"info": "No clips found in the root folder"}]
91
+
92
+ # Return a simplified list with basic clip info
93
+ result = []
94
+ for clip in clips:
95
+ result.append({
96
+ "name": clip.GetName(),
97
+ "duration": clip.GetDuration(),
98
+ "fps": clip.GetClipProperty("FPS")
99
+ })
100
+
101
+ return result
102
+
103
+
104
+ @mcp.tool(annotations=EXTERNAL_WRITE_TOOL)
105
+ def import_media(
106
+ paths: Optional[List[str]] = None,
107
+ clip_infos: Optional[List[Dict[str, Any]]] = None,
108
+ file_path: Optional[str] = None,
109
+ ) -> Dict[str, Any]:
110
+ """Import media into the current project's media pool.
111
+
112
+ Args:
113
+ paths: Simple form — list of file or folder paths to import.
114
+ clip_infos: Image-sequence form — list of dicts with keys FilePath
115
+ (required), StartIndex, EndIndex. Mirrors
116
+ MediaPool.ImportMedia([{clipInfo}, ...]). Each entry imports as
117
+ one MediaPoolItem unless 'Show Individual Frames' is enabled.
118
+ Example: [{"FilePath": "frame_%03d.dpx", "StartIndex": 1, "EndIndex": 100}]
119
+ file_path: Single-path convenience for backward compatibility.
120
+ """
121
+ project, mp, err = _get_mp()
122
+ if err:
123
+ return err
124
+ if clip_infos is not None:
125
+ if not isinstance(clip_infos, list) or not clip_infos:
126
+ return {"error": "clip_infos must be a non-empty list"}
127
+ for i, ci in enumerate(clip_infos):
128
+ if not isinstance(ci, dict):
129
+ return {"error": f"clip_infos[{i}] must be an object"}
130
+ if not ci.get("FilePath"):
131
+ return {"error": f"clip_infos[{i}] requires FilePath"}
132
+ result = mp.ImportMedia(clip_infos)
133
+ else:
134
+ path_list = paths if paths is not None else ([file_path] if file_path else None)
135
+ if not path_list:
136
+ return {"error": "Provide paths (list), clip_infos (image sequences), or file_path"}
137
+ result = mp.ImportMedia(path_list)
138
+ if not result:
139
+ return {"success": False, "error": "Failed to import media"}
140
+ return {
141
+ "success": True,
142
+ "imported": len(result),
143
+ "clips": [{"name": c.GetName(), "id": c.GetUniqueId()} for c in result],
144
+ }
145
+
146
+
147
+ @mcp.tool()
148
+ def append_to_timeline(
149
+ clip_ids: Optional[List[str]] = None,
150
+ clip_infos: Optional[List[Dict[str, Any]]] = None,
151
+ ) -> Dict[str, Any]:
152
+ """Append clips to the current timeline.
153
+
154
+ Mirrors MediaPool.AppendToTimeline per docs lines 219-221. Two forms:
155
+
156
+ Args:
157
+ clip_ids: Simple form — list of MediaPoolItem unique IDs to append in order.
158
+ clip_infos: Positioned form — list of dicts with keys clip_id (or
159
+ media_pool_item_id), start_frame, end_frame, record_frame, track_index,
160
+ and optional media_type (1=video only, 2=audio only). record_frame is
161
+ relative to the current timeline start frame by default; pass
162
+ record_frame_mode="absolute" for raw Resolve recordFrame values.
163
+ Returns timeline_item_id per appended item.
164
+ """
165
+ project, mp, err = _get_mp()
166
+ if err:
167
+ return err
168
+ if clip_infos is not None:
169
+ if not isinstance(clip_infos, list) or not clip_infos:
170
+ return {"error": "clip_infos must be a non-empty list"}
171
+ root = mp.GetRootFolder()
172
+ current_timeline = project.GetCurrentTimeline() if project else None
173
+ timeline_start = _timeline_start_frame(current_timeline)
174
+ built = []
175
+ for i, ci in enumerate(clip_infos):
176
+ row, row_err = _build_append_clip_info_dict(root, ci, i, timeline_start)
177
+ if row_err:
178
+ return row_err
179
+ built.append(row)
180
+ result = mp.AppendToTimeline(built)
181
+ if not result:
182
+ return {"success": False, "error": "Failed to append clip_infos to timeline"}
183
+ items_out = []
184
+ for i, item in enumerate(result):
185
+ if not item:
186
+ return {"success": False, "error": f"Missing timeline item at index {i}"}
187
+ try:
188
+ item_id = item.GetUniqueId()
189
+ name = item.GetName()
190
+ except Exception as exc:
191
+ return {"success": False, "error": f"Invalid timeline item at index {i}: {exc}"}
192
+ if not item_id:
193
+ return {"success": False, "error": f"Missing timeline item id at index {i}"}
194
+ items_out.append({"timeline_item_id": item_id, "name": name})
195
+ return {"success": True, "count": len(items_out), "items": items_out}
196
+ if not clip_ids:
197
+ return {"error": "Provide clip_ids (simple) or clip_infos (positioned)"}
198
+ root = mp.GetRootFolder()
199
+ clips = [_find_clip_by_id(root, cid) for cid in clip_ids]
200
+ clips = [c for c in clips if c]
201
+ if not clips:
202
+ return {"error": "No valid clips found"}
203
+ result = mp.AppendToTimeline(clips)
204
+ return {"success": True, "count": len(result) if result else 0}
205
+
206
+
207
+ @mcp.tool()
208
+ def setup_multicam_timeline(
209
+ name: str = "Multicam Setup",
210
+ clip_ids: Optional[List[str]] = None,
211
+ angles: Optional[List[Dict[str, Any]]] = None,
212
+ sync_mode: str = "stack_start",
213
+ include_audio: bool = False,
214
+ audio_track_mode: str = "matching",
215
+ start_timecode: Optional[str] = None,
216
+ timeline_start_timecode: Optional[str] = None,
217
+ record_frame_start: int = 0,
218
+ frame_rate: Optional[Any] = None,
219
+ audio_type: str = "stereo",
220
+ dry_run: bool = False,
221
+ ) -> Dict[str, Any]:
222
+ """Create a stacked multicam prep timeline from Media Pool clips.
223
+
224
+ This does not create a native Resolve multicam clip because the public
225
+ scripting API does not expose native multicam creation. It creates a prep
226
+ timeline with one angle per video track and optional matching audio tracks.
227
+
228
+ Args:
229
+ name: Name for the setup timeline.
230
+ clip_ids: Simple list of MediaPoolItem unique IDs, one per angle.
231
+ angles: Detailed angle rows with clip_id, optional angle_name,
232
+ start_frame/end_frame, record_frame, source_timecode, track_index.
233
+ sync_mode: 'stack_start', 'record_frame', or 'source_timecode'.
234
+ include_audio: Also append audio-only rows.
235
+ audio_track_mode: 'matching' for per-angle audio tracks, or 'first'.
236
+ start_timecode: Optional timeline start timecode to set after creation.
237
+ timeline_start_timecode: Base timecode for source_timecode sync math.
238
+ record_frame_start: Timeline-relative start frame for stack_start mode.
239
+ frame_rate: Fallback frame rate for duration/timecode parsing.
240
+ audio_type: Resolve audio track type when tracks must be added.
241
+ dry_run: Return the plan without creating a timeline.
242
+ """
243
+ project, mp, err = _get_mp()
244
+ if err:
245
+ return err
246
+ root = mp.GetRootFolder()
247
+ params = {
248
+ "name": name,
249
+ "clip_ids": clip_ids,
250
+ "angles": angles,
251
+ "sync_mode": sync_mode,
252
+ "include_audio": include_audio,
253
+ "audio_track_mode": audio_track_mode,
254
+ "start_timecode": start_timecode,
255
+ "timeline_start_timecode": timeline_start_timecode,
256
+ "record_frame_start": record_frame_start,
257
+ "frame_rate": frame_rate,
258
+ "audio_type": audio_type,
259
+ "dry_run": dry_run,
260
+ }
261
+ plan, plan_err = build_multicam_setup_plan(root, params, _find_clip_by_id)
262
+ if plan_err:
263
+ return plan_err
264
+ if dry_run:
265
+ return {
266
+ **plan,
267
+ "dry_run": True,
268
+ "would_create_timeline": True,
269
+ "would_append": len(plan.get("append_rows") or []),
270
+ }
271
+
272
+ timeline = mp.CreateEmptyTimeline(plan["name"])
273
+ if not timeline:
274
+ return {"error": f"Failed to create multicam setup timeline: {plan['name']}"}
275
+ try:
276
+ project.SetCurrentTimeline(timeline)
277
+ except Exception:
278
+ pass
279
+ if plan.get("start_timecode"):
280
+ try:
281
+ timeline.SetStartTimecode(plan["start_timecode"])
282
+ except Exception:
283
+ pass
284
+
285
+ video_tracks = _ensure_timeline_tracks_for_multicam(timeline, "video", plan.get("max_video_track", 0))
286
+ if not video_tracks.get("success"):
287
+ return video_tracks
288
+ audio_tracks = _ensure_timeline_tracks_for_multicam(
289
+ timeline,
290
+ "audio",
291
+ plan.get("max_audio_track", 0),
292
+ audio_type=audio_type,
293
+ )
294
+ if not audio_tracks.get("success"):
295
+ return audio_tracks
296
+ track_names = _set_multicam_track_names(timeline, plan)
297
+
298
+ timeline_start = _timeline_start_frame(timeline)
299
+ append_rows = plan.get("append_rows") or []
300
+ append_infos = []
301
+ for index, row in enumerate(append_rows):
302
+ clip_info, clip_err = _build_append_clip_info_dict(root, row, index, timeline_start)
303
+ if clip_err:
304
+ return clip_err
305
+ append_infos.append(clip_info)
306
+ appended = mp.AppendToTimeline(append_infos)
307
+ if not appended:
308
+ return {"error": "AppendToTimeline returned no items for multicam setup"}
309
+
310
+ items = []
311
+ for index, item in enumerate(appended):
312
+ summary = _appended_item_summary(item)
313
+ summary["setup_row"] = append_rows[index]
314
+ items.append(summary)
315
+ return {
316
+ **plan,
317
+ "dry_run": False,
318
+ "timeline_name": timeline.GetName(),
319
+ "timeline_id": timeline.GetUniqueId(),
320
+ "items": items,
321
+ "track_setup": {"video": video_tracks, "audio": audio_tracks, "names": track_names},
322
+ }
323
+
324
+
325
+ @mcp.tool()
326
+ def auto_sync_audio(
327
+ clip_ids: List[str],
328
+ sync_mode: Optional[str] = None,
329
+ channel_number: Optional[Any] = None,
330
+ retain_embedded_audio: Optional[bool] = None,
331
+ retain_video_metadata: Optional[bool] = None,
332
+ ) -> Dict[str, Any]:
333
+ """Sync audio across multiple clips.
334
+
335
+ Mirrors MediaPool.AutoSyncAudio([items], {audioSyncSettings}) per docs lines 600-614.
336
+
337
+ Args:
338
+ clip_ids: List of MediaPoolItem unique IDs to sync.
339
+ sync_mode: 'waveform' or 'timecode' (default on Resolve side: 'timecode').
340
+ channel_number: int >= 1 for channel offset, or 'automatic' (-1) / 'mix' (-2).
341
+ Only used in waveform mode.
342
+ retain_embedded_audio: keep clip's embedded audio after sync.
343
+ retain_video_metadata: keep video clip's metadata after sync.
344
+ """
345
+ r = get_resolve()
346
+ if r is None:
347
+ return {"error": "Not connected to DaVinci Resolve"}
348
+ _, mp, err = _get_mp()
349
+ if err:
350
+ return err
351
+ if not clip_ids:
352
+ return {"error": "clip_ids must be a non-empty list"}
353
+ root = mp.GetRootFolder()
354
+ clips = [_find_clip_by_id(root, cid) for cid in clip_ids]
355
+ clips = [c for c in clips if c]
356
+ if not clips:
357
+ return {"error": "No valid clips found"}
358
+ settings, settings_err = _build_audio_sync_settings(
359
+ r, sync_mode=sync_mode, channel_number=channel_number,
360
+ retain_embedded_audio=retain_embedded_audio,
361
+ retain_video_metadata=retain_video_metadata,
362
+ )
363
+ if settings_err:
364
+ return settings_err
365
+ return {"success": bool(mp.AutoSyncAudio(clips, settings))}
366
+
367
+
368
+ @mcp.tool()
369
+ def add_subfolder(folder_name: str) -> Dict[str, Any]:
370
+ """Create a new subfolder in the current Media Pool folder.
371
+
372
+ Args:
373
+ folder_name: Name of the subfolder to create.
374
+ """
375
+ _, mp, err = _get_mp()
376
+ if err:
377
+ return err
378
+ folder = mp.AddSubFolder(mp.GetCurrentFolder(), folder_name)
379
+ if folder:
380
+ return {"success": True, "folder_name": folder.GetName(), "unique_id": folder.GetUniqueId()}
381
+ return {"success": False, "error": f"Failed to create subfolder '{folder_name}'"}
382
+
383
+
384
+ @mcp.tool()
385
+ def refresh_media_pool_folders() -> Dict[str, Any]:
386
+ """Refresh all folders in the Media Pool."""
387
+ _, mp, err = _get_mp()
388
+ if err:
389
+ return err
390
+ result = mp.RefreshFolders()
391
+ return {"success": bool(result)}
392
+
393
+
394
+ @mcp.tool()
395
+ def import_timeline_from_file(file_path: str, import_options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
396
+ """Import a timeline from a file (AAF, EDL, XML, FCPXML, DRT, ADL, OTIO).
397
+
398
+ Args:
399
+ file_path: Absolute path to the timeline file.
400
+ import_options: Optional dict of import options.
401
+ """
402
+ _, mp, err = _get_mp()
403
+ if err:
404
+ return err
405
+ if import_options:
406
+ tl = mp.ImportTimelineFromFile(file_path, import_options)
407
+ else:
408
+ tl = mp.ImportTimelineFromFile(file_path)
409
+ if tl:
410
+ return {"success": True, "timeline_name": tl.GetName(), "unique_id": tl.GetUniqueId()}
411
+ return {"success": False, "error": f"Failed to import timeline from '{file_path}'"}
412
+
413
+
414
+ @mcp.tool()
415
+ def delete_timelines_by_id(timeline_ids: List[str]) -> Dict[str, Any]:
416
+ """Delete timelines by their unique IDs.
417
+
418
+ Args:
419
+ timeline_ids: List of timeline unique IDs to delete.
420
+ """
421
+ project, mp, err = _get_mp()
422
+ if err:
423
+ return err
424
+ timelines = []
425
+ for i in range(1, project.GetTimelineCount() + 1):
426
+ tl = project.GetTimelineByIndex(i)
427
+ if tl and tl.GetUniqueId() in timeline_ids:
428
+ timelines.append(tl)
429
+ if not timelines:
430
+ return {"error": "No matching timelines found"}
431
+ result = mp.DeleteTimelines(timelines)
432
+ return {"success": bool(result), "deleted_count": len(timelines)}
433
+
434
+
435
+ @mcp.tool()
436
+ def set_current_media_pool_folder(folder_path: str) -> Dict[str, Any]:
437
+ """Navigate to a specific folder in the Media Pool.
438
+
439
+ Args:
440
+ folder_path: Folder path using '/' separators from root (e.g. 'Master/Footage').
441
+ """
442
+ _, mp, err = _get_mp()
443
+ if err:
444
+ return err
445
+ target = _navigate_to_folder(mp, folder_path)
446
+ if not target:
447
+ return {"error": f"Folder '{folder_path}' not found"}
448
+ result = mp.SetCurrentFolder(target)
449
+ return {"success": bool(result), "folder": target.GetName()}
450
+
451
+
452
+ @mcp.tool()
453
+ def delete_media_pool_clips(clip_ids: List[str]) -> Dict[str, Any]:
454
+ """Delete clips from the Media Pool by their unique IDs.
455
+
456
+ Args:
457
+ clip_ids: List of clip unique IDs to delete.
458
+ """
459
+ _, mp, err = _get_mp()
460
+ if err:
461
+ return err
462
+ clips = _find_clips_by_ids(mp.GetRootFolder(), set(clip_ids))
463
+ if not clips:
464
+ return {"error": "No matching clips found"}
465
+ result = mp.DeleteClips(clips)
466
+ return {"success": bool(result), "deleted_count": len(clips)}
467
+
468
+
469
+ @mcp.tool()
470
+ def import_folder_from_file(file_path: str) -> Dict[str, Any]:
471
+ """Import a Media Pool folder structure from a file.
472
+
473
+ Args:
474
+ file_path: Absolute path to the file.
475
+ """
476
+ _, mp, err = _get_mp()
477
+ if err:
478
+ return err
479
+ result = mp.ImportFolderFromFile(file_path)
480
+ return {"success": bool(result), "file_path": file_path}
481
+
482
+
483
+ @mcp.tool()
484
+ def delete_media_pool_folders(folder_names: List[str]) -> Dict[str, Any]:
485
+ """Delete folders from the current Media Pool location.
486
+
487
+ Args:
488
+ folder_names: List of folder names to delete.
489
+ """
490
+ _, mp, err = _get_mp()
491
+ if err:
492
+ return err
493
+ current = mp.GetCurrentFolder()
494
+ folders = [sub for sub in (current.GetSubFolderList() or []) if sub.GetName() in folder_names]
495
+ if not folders:
496
+ return {"error": "No matching folders found"}
497
+ result = mp.DeleteFolders(folders)
498
+ return {"success": bool(result), "deleted_count": len(folders)}
499
+
500
+
501
+ @mcp.tool()
502
+ def move_clips_to_folder(clip_ids: List[str], target_folder_path: str) -> Dict[str, Any]:
503
+ """Move clips to a different Media Pool folder.
504
+
505
+ Args:
506
+ clip_ids: List of clip unique IDs to move.
507
+ target_folder_path: Path to target folder (e.g. 'Master/Footage').
508
+ """
509
+ _, mp, err = _get_mp()
510
+ if err:
511
+ return err
512
+ clips = _find_clips_by_ids(mp.GetRootFolder(), set(clip_ids))
513
+ if not clips:
514
+ return {"error": "No matching clips found"}
515
+ target = _navigate_to_folder(mp, target_folder_path)
516
+ if not target:
517
+ return {"error": f"Target folder '{target_folder_path}' not found"}
518
+ result = mp.MoveClips(clips, target)
519
+ return {"success": bool(result), "moved_count": len(clips)}
520
+
521
+
522
+ @mcp.tool()
523
+ def move_media_pool_folders(folder_names: List[str], target_folder_path: str) -> Dict[str, Any]:
524
+ """Move folders to a different Media Pool location.
525
+
526
+ Args:
527
+ folder_names: List of folder names to move.
528
+ target_folder_path: Path to target folder.
529
+ """
530
+ _, mp, err = _get_mp()
531
+ if err:
532
+ return err
533
+ current = mp.GetCurrentFolder()
534
+ folders = [sub for sub in (current.GetSubFolderList() or []) if sub.GetName() in folder_names]
535
+ if not folders:
536
+ return {"error": "No matching folders found"}
537
+ target = _navigate_to_folder(mp, target_folder_path)
538
+ if not target:
539
+ return {"error": f"Target folder '{target_folder_path}' not found"}
540
+ result = mp.MoveFolders(folders, target)
541
+ return {"success": bool(result), "moved_count": len(folders)}
542
+
543
+
544
+ @mcp.tool()
545
+ def get_clip_matte_list(clip_id: str) -> Dict[str, Any]:
546
+ """Get list of clip mattes for a MediaPoolItem.
547
+
548
+ Args:
549
+ clip_id: Unique ID of the clip.
550
+ """
551
+ _, mp, err = _get_mp()
552
+ if err:
553
+ return err
554
+ clip = _find_clip_by_id(mp.GetRootFolder(), clip_id)
555
+ if not clip:
556
+ return {"error": f"Clip with ID {clip_id} not found"}
557
+ mattes = mp.GetClipMatteList(clip)
558
+ return {"clip_id": clip_id, "mattes": mattes if mattes else []}
559
+
560
+
561
+ @mcp.tool()
562
+ def get_timeline_matte_list(item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
563
+ """Get list of timeline mattes for a timeline item.
564
+
565
+ Args:
566
+ item_index: 0-based index of the item in the track. Default: 0.
567
+ track_type: 'video' or 'audio'. Default: 'video'.
568
+ track_index: 1-based track index. Default: 1.
569
+ """
570
+ project, mp, err = _get_mp()
571
+ if err:
572
+ return err
573
+ tl = project.GetCurrentTimeline()
574
+ if not tl:
575
+ return {"error": "No current timeline"}
576
+ items = tl.GetItemListInTrack(track_type, track_index)
577
+ if not items or item_index >= len(items):
578
+ return {"error": f"No item at index {item_index}"}
579
+ mattes = mp.GetTimelineMatteList(items[item_index])
580
+ return {"mattes": mattes if mattes else []}
581
+
582
+
583
+ @mcp.tool()
584
+ def delete_clip_mattes(clip_id: str, matte_paths: List[str]) -> Dict[str, Any]:
585
+ """Delete clip mattes from a MediaPoolItem.
586
+
587
+ Args:
588
+ clip_id: Unique ID of the clip.
589
+ matte_paths: List of matte file paths to delete.
590
+ """
591
+ _, mp, err = _get_mp()
592
+ if err:
593
+ return err
594
+ clip = _find_clip_by_id(mp.GetRootFolder(), clip_id)
595
+ if not clip:
596
+ return {"error": f"Clip with ID {clip_id} not found"}
597
+ result = mp.DeleteClipMattes(clip, matte_paths)
598
+ return {"success": bool(result)}
599
+
600
+
601
+ @mcp.tool()
602
+ def export_media_pool_metadata(file_path: str) -> Dict[str, Any]:
603
+ """Export metadata of clips in the current Media Pool folder to a CSV file.
604
+
605
+ Args:
606
+ file_path: Absolute path for the exported CSV file.
607
+ """
608
+ _, mp, err = _get_mp()
609
+ if err:
610
+ return err
611
+ result = mp.ExportMetadata(file_path)
612
+ return {"success": bool(result), "file_path": file_path}
613
+
614
+
615
+ @mcp.tool()
616
+ def get_media_pool_unique_id() -> Dict[str, Any]:
617
+ """Get the unique ID of the Media Pool."""
618
+ _, mp, err = _get_mp()
619
+ if err:
620
+ return err
621
+ return {"unique_id": mp.GetUniqueId()}
622
+
623
+
624
+ @mcp.tool()
625
+ def create_stereo_clip(left_clip_id: str, right_clip_id: str) -> Dict[str, Any]:
626
+ """Create a stereo clip from left and right eye clips.
627
+
628
+ Args:
629
+ left_clip_id: Unique ID of the left eye clip.
630
+ right_clip_id: Unique ID of the right eye clip.
631
+ """
632
+ _, mp, err = _get_mp()
633
+ if err:
634
+ return err
635
+ root = mp.GetRootFolder()
636
+ left = _find_clip_by_id(root, left_clip_id)
637
+ right = _find_clip_by_id(root, right_clip_id)
638
+ if not left:
639
+ return {"error": f"Left clip {left_clip_id} not found"}
640
+ if not right:
641
+ return {"error": f"Right clip {right_clip_id} not found"}
642
+ result = mp.CreateStereoClip(left, right)
643
+ return {"success": bool(result)}
644
+
645
+
646
+ @mcp.tool()
647
+ def get_selected_clips() -> Dict[str, Any]:
648
+ """Get currently selected clips in the Media Pool."""
649
+ _, mp, err = _get_mp()
650
+ if err:
651
+ return err
652
+ clips = mp.GetSelectedClips()
653
+ if clips:
654
+ result = []
655
+ for clip in clips:
656
+ try:
657
+ result.append({"name": clip.GetName(), "unique_id": clip.GetUniqueId()})
658
+ except Exception:
659
+ logger.debug("Could not read selected clip identity", exc_info=True)
660
+ result.append({"name": "Unknown"})
661
+ return {"selected_clips": result}
662
+ return {"selected_clips": []}
663
+
664
+
665
+ @mcp.tool()
666
+ def set_selected_clip(clip_id: str) -> Dict[str, Any]:
667
+ """Set a clip as selected in the Media Pool.
668
+
669
+ Args:
670
+ clip_id: Unique ID of the clip to select.
671
+ """
672
+ _, mp, err = _get_mp()
673
+ if err:
674
+ return err
675
+ clip = _find_clip_by_id(mp.GetRootFolder(), clip_id)
676
+ if not clip:
677
+ return {"error": f"Clip {clip_id} not found"}
678
+ result = mp.SetSelectedClip(clip)
679
+ return {"success": bool(result)}