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.
- package/AGENTS.md +85 -0
- package/CHANGELOG.md +802 -0
- package/CLAUDE.md +15 -0
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/SECURITY.md +53 -0
- package/bin/davinci-resolve-mcp.mjs +376 -0
- package/docs/README.md +56 -0
- package/docs/SKILL.md +1145 -0
- package/docs/authoring/fuse-dctl-authoring.md +242 -0
- package/docs/authoring/script-plugin-authoring.md +195 -0
- package/docs/contributing.md +82 -0
- package/docs/guides/color-decision-guide.md +387 -0
- package/docs/guides/editorial-decision-guide.md +136 -0
- package/docs/guides/media-analysis-guide.md +615 -0
- package/docs/guides/multicam-setup-guide.md +138 -0
- package/docs/install.md +198 -0
- package/docs/integrations/workflow-integrations.md +120 -0
- package/docs/kernels/README.md +28 -0
- package/docs/kernels/audio-fairlight-kernel.md +86 -0
- package/docs/kernels/color-grade-kernel.md +103 -0
- package/docs/kernels/extension-authoring-kernel.md +101 -0
- package/docs/kernels/fusion-composition-kernel.md +91 -0
- package/docs/kernels/media-pool-ingest-kernel.md +147 -0
- package/docs/kernels/project-lifecycle-kernel.md +120 -0
- package/docs/kernels/render-deliver-kernel.md +92 -0
- package/docs/kernels/review-annotation-kernel.md +110 -0
- package/docs/kernels/timeline-conform-interchange-kernel.md +99 -0
- package/docs/kernels/timeline-edit-kernel.md +189 -0
- package/docs/notes/codec-plugin-notes.md +136 -0
- package/docs/notes/dctl-notes.md +234 -0
- package/docs/notes/fusion-template-notes.md +136 -0
- package/docs/notes/lut-notes.md +136 -0
- package/docs/notes/openfx-notes.md +120 -0
- package/docs/process/release-process.md +152 -0
- package/docs/reference/api-coverage.md +488 -0
- package/docs/reference/resolve_scripting_api.txt +1012 -0
- package/examples/README.md +53 -0
- package/examples/markers/README.md +81 -0
- package/examples/media/README.md +94 -0
- package/examples/timeline/README.md +98 -0
- package/install.py +1196 -0
- package/package.json +52 -0
- package/scripts/audit_api_parity.py +275 -0
- package/scripts/live_media_analysis_polish_probe.py +65 -0
- package/src/__init__.py +3 -0
- package/src/analysis_dashboard.py +4936 -0
- package/src/control_panel.py +13 -0
- package/src/granular/__init__.py +17 -0
- package/src/granular/common.py +727 -0
- package/src/granular/folder.py +287 -0
- package/src/granular/gallery.py +306 -0
- package/src/granular/graph.py +309 -0
- package/src/granular/media_pool.py +679 -0
- package/src/granular/media_pool_item.py +852 -0
- package/src/granular/media_storage.py +179 -0
- package/src/granular/project.py +1594 -0
- package/src/granular/resolve_control.py +521 -0
- package/src/granular/timeline.py +1074 -0
- package/src/granular/timeline_item.py +2251 -0
- package/src/resolve_mcp_server.py +43 -0
- package/src/server.py +15691 -0
- package/src/utils/__init__.py +3 -0
- package/src/utils/app_control.py +319 -0
- package/src/utils/audio_fairlight_live_probe.py +263 -0
- package/src/utils/cdl.py +20 -0
- package/src/utils/cloud_operations.py +192 -0
- package/src/utils/color_grade_live_probe.py +444 -0
- package/src/utils/dctl_templates.py +368 -0
- package/src/utils/extension_authoring_live_probe.py +292 -0
- package/src/utils/fuse_templates.py +1968 -0
- package/src/utils/fusion_composition_live_probe.py +284 -0
- package/src/utils/layout_presets.py +333 -0
- package/src/utils/mcp_stdio.py +32 -0
- package/src/utils/media_analysis.py +3618 -0
- package/src/utils/media_analysis_jobs.py +796 -0
- package/src/utils/media_pool_ingest_live_probe.py +592 -0
- package/src/utils/multicam.py +393 -0
- package/src/utils/object_inspection.py +287 -0
- package/src/utils/platform.py +157 -0
- package/src/utils/project_lifecycle_live_probe.py +376 -0
- package/src/utils/project_properties.py +601 -0
- package/src/utils/render_deliver_live_probe.py +384 -0
- package/src/utils/resolve_connection.py +77 -0
- package/src/utils/review_annotation_live_probe.py +352 -0
- package/src/utils/script_templates.py +1193 -0
- package/src/utils/sync_detection.py +887 -0
- package/src/utils/timeline_conform_live_probe.py +280 -0
- package/src/utils/timeline_kernel_live_probe.py +1091 -0
- package/src/utils/timeline_kernel_probe.py +185 -0
- package/src/utils/timeline_title_text.py +87 -0
- 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)}
|