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,284 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Live Fusion Composition boundary probe."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import platform
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
|
|
16
|
+
from src.utils.timeline_kernel_probe import ProbeRecorder, render_markdown_report, utc_timestamp
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _require_success(label: str, result: Dict[str, Any]) -> Dict[str, Any]:
|
|
20
|
+
if not isinstance(result, dict):
|
|
21
|
+
raise AssertionError(f"{label}: expected dict, got {result!r}")
|
|
22
|
+
if result.get("error"):
|
|
23
|
+
raise AssertionError(f"{label}: {result['error']}")
|
|
24
|
+
if "success" in result and result["success"] is not True:
|
|
25
|
+
raise AssertionError(f"{label}: expected success=True, got {result!r}")
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _record_tool_result(
|
|
30
|
+
recorder: ProbeRecorder,
|
|
31
|
+
category: str,
|
|
32
|
+
name: str,
|
|
33
|
+
result: Dict[str, Any],
|
|
34
|
+
*,
|
|
35
|
+
expected_status: Optional[str] = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
if not isinstance(result, dict):
|
|
38
|
+
recorder.record(category, name, "error", details={"reason": "non-dict result", "result": repr(result)})
|
|
39
|
+
return
|
|
40
|
+
if result.get("error"):
|
|
41
|
+
recorder.record(
|
|
42
|
+
category,
|
|
43
|
+
name,
|
|
44
|
+
expected_status or "error",
|
|
45
|
+
details={"reason": result.get("error"), "expected_status": expected_status},
|
|
46
|
+
evidence=result,
|
|
47
|
+
)
|
|
48
|
+
return
|
|
49
|
+
if "success" in result and result["success"] is not True:
|
|
50
|
+
recorder.record(
|
|
51
|
+
category,
|
|
52
|
+
name,
|
|
53
|
+
expected_status or "partially_supported",
|
|
54
|
+
details={"reason": "success returned false", "expected_status": expected_status},
|
|
55
|
+
evidence=result,
|
|
56
|
+
)
|
|
57
|
+
return
|
|
58
|
+
recorder.record(category, name, expected_status or "supported", evidence=result)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _run_ffmpeg(args: list[str]) -> None:
|
|
62
|
+
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _make_synthetic_video(work_dir: Path) -> Path:
|
|
66
|
+
video = work_dir / "fusion_composition_probe.mov"
|
|
67
|
+
_run_ffmpeg(
|
|
68
|
+
[
|
|
69
|
+
"-f",
|
|
70
|
+
"lavfi",
|
|
71
|
+
"-i",
|
|
72
|
+
"testsrc=size=640x360:rate=24:duration=4",
|
|
73
|
+
"-pix_fmt",
|
|
74
|
+
"yuv420p",
|
|
75
|
+
"-c:v",
|
|
76
|
+
"libx264",
|
|
77
|
+
"-y",
|
|
78
|
+
str(video),
|
|
79
|
+
]
|
|
80
|
+
)
|
|
81
|
+
return video
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _first_imported_clip(imported_items):
|
|
85
|
+
for item in imported_items or []:
|
|
86
|
+
try:
|
|
87
|
+
if item.GetUniqueId():
|
|
88
|
+
return item
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, Any]:
|
|
95
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
work_dir = Path(tempfile.mkdtemp(prefix="mcp_fusion_composition_probe_"))
|
|
97
|
+
project_name = f"_mcp_fusion_composition_probe_{int(time.time())}"
|
|
98
|
+
timeline_name = "Fusion Composition Probe Timeline"
|
|
99
|
+
recorder = ProbeRecorder()
|
|
100
|
+
created_project = False
|
|
101
|
+
delete_result: Optional[Dict[str, Any]] = None
|
|
102
|
+
|
|
103
|
+
metadata: Dict[str, Any] = {
|
|
104
|
+
"title": "Fusion Composition Kernel Capability Probe",
|
|
105
|
+
"timestamp_utc": utc_timestamp(),
|
|
106
|
+
"python": sys.version,
|
|
107
|
+
"platform": platform.platform(),
|
|
108
|
+
"output_dir": str(output_dir),
|
|
109
|
+
"project_name": project_name,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
version = _require_success("resolve_control.get_version", server.resolve_control("get_version"))
|
|
114
|
+
metadata.update(
|
|
115
|
+
{
|
|
116
|
+
"product": version.get("product"),
|
|
117
|
+
"version": version.get("version"),
|
|
118
|
+
"version_string": version.get("version_string"),
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
print(f"Connected to {metadata['product']} {metadata['version_string']}")
|
|
122
|
+
|
|
123
|
+
_require_success("project_manager.create", server.project_manager("create", {"name": project_name}))
|
|
124
|
+
created_project = True
|
|
125
|
+
print(f"Created disposable project: {project_name}")
|
|
126
|
+
server.resolve_control("open_page", {"page": "edit"})
|
|
127
|
+
|
|
128
|
+
video = _make_synthetic_video(work_dir)
|
|
129
|
+
metadata["synthetic_media"] = {"video": str(video)}
|
|
130
|
+
print(f"Generated synthetic media under: {work_dir}")
|
|
131
|
+
|
|
132
|
+
resolve = server.get_resolve()
|
|
133
|
+
project = resolve.GetProjectManager().GetCurrentProject()
|
|
134
|
+
media_pool = project.GetMediaPool()
|
|
135
|
+
imported = media_pool.ImportMedia([str(video)]) or []
|
|
136
|
+
clip = _first_imported_clip(imported)
|
|
137
|
+
if not clip:
|
|
138
|
+
raise AssertionError("Failed to import synthetic Fusion media")
|
|
139
|
+
timeline = media_pool.CreateTimelineFromClips(timeline_name, [clip])
|
|
140
|
+
if not timeline:
|
|
141
|
+
raise AssertionError("Failed to create Fusion timeline")
|
|
142
|
+
project.SetCurrentTimeline(timeline)
|
|
143
|
+
timeline.SetCurrentTimecode("01:00:00:01")
|
|
144
|
+
print(f"Created timeline: {timeline_name}")
|
|
145
|
+
|
|
146
|
+
item_scope = {"track_type": "video", "track_index": 1, "item_index": 0}
|
|
147
|
+
comp_scope = {"timeline_item": item_scope}
|
|
148
|
+
|
|
149
|
+
_record_tool_result(recorder, "timeline_item_fusion", "add_comp", server.timeline_item_fusion("add_comp", item_scope))
|
|
150
|
+
_record_tool_result(recorder, "timeline_item_fusion", "get_comp_count", server.timeline_item_fusion("get_comp_count", item_scope))
|
|
151
|
+
_record_tool_result(recorder, "timeline_item_fusion", "get_comp_names", server.timeline_item_fusion("get_comp_names", item_scope))
|
|
152
|
+
|
|
153
|
+
_record_tool_result(recorder, "capabilities", "fusion_graph_capabilities", server.fusion_comp("fusion_graph_capabilities", comp_scope))
|
|
154
|
+
_record_tool_result(
|
|
155
|
+
recorder,
|
|
156
|
+
"inspection",
|
|
157
|
+
"probe_fusion_comp_initial",
|
|
158
|
+
server.fusion_comp("probe_fusion_comp", {**comp_scope, "include_io": False}),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
for tool_type, name in (
|
|
162
|
+
("Background", "MCP_Background"),
|
|
163
|
+
("TextPlus", "MCP_Text"),
|
|
164
|
+
("Merge", "MCP_Merge"),
|
|
165
|
+
("Transform", "MCP_Transform"),
|
|
166
|
+
("Blur", "MCP_Blur"),
|
|
167
|
+
):
|
|
168
|
+
_record_tool_result(
|
|
169
|
+
recorder,
|
|
170
|
+
"tools",
|
|
171
|
+
f"safe_add_{tool_type}",
|
|
172
|
+
server.fusion_comp("safe_add_tool", {**comp_scope, "tool_type": tool_type, "name": name, "include_io": True}),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
_record_tool_result(
|
|
176
|
+
recorder,
|
|
177
|
+
"inputs",
|
|
178
|
+
"safe_set_text",
|
|
179
|
+
server.fusion_comp(
|
|
180
|
+
"safe_set_inputs",
|
|
181
|
+
{**comp_scope, "tool_name": "MCP_Text", "inputs": {"StyledText": "MCP Fusion Probe"}, "readback": True},
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
_record_tool_result(
|
|
185
|
+
recorder,
|
|
186
|
+
"inputs",
|
|
187
|
+
"safe_set_background_color",
|
|
188
|
+
server.fusion_comp(
|
|
189
|
+
"safe_set_inputs",
|
|
190
|
+
{
|
|
191
|
+
**comp_scope,
|
|
192
|
+
"tool_name": "MCP_Background",
|
|
193
|
+
"inputs": {
|
|
194
|
+
"TopLeftRed": 0.1,
|
|
195
|
+
"TopLeftGreen": 0.2,
|
|
196
|
+
"TopLeftBlue": 0.7,
|
|
197
|
+
"TopLeftAlpha": 1.0,
|
|
198
|
+
},
|
|
199
|
+
"readback": True,
|
|
200
|
+
},
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
_record_tool_result(
|
|
204
|
+
recorder,
|
|
205
|
+
"inspection",
|
|
206
|
+
"probe_text_tool",
|
|
207
|
+
server.fusion_comp("probe_fusion_tool", {**comp_scope, "tool_name": "MCP_Text", "include_io": True}),
|
|
208
|
+
)
|
|
209
|
+
_record_tool_result(
|
|
210
|
+
recorder,
|
|
211
|
+
"wiring",
|
|
212
|
+
"safe_connect_text_to_mediaout",
|
|
213
|
+
server.fusion_comp(
|
|
214
|
+
"safe_connect_tools",
|
|
215
|
+
{**comp_scope, "target_tool": "MediaOut1", "input_name": "Input", "source_tool": "MCP_Text"},
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
_record_tool_result(
|
|
219
|
+
recorder,
|
|
220
|
+
"bulk",
|
|
221
|
+
"bulk_set_inputs",
|
|
222
|
+
server.fusion_comp(
|
|
223
|
+
"bulk_set_inputs",
|
|
224
|
+
{
|
|
225
|
+
"ops": [
|
|
226
|
+
{**comp_scope, "tool_name": "MCP_Text", "input_name": "Size", "value": 0.08},
|
|
227
|
+
{**comp_scope, "tool_name": "MCP_Background", "input_name": "GlobalOut", "value": 96},
|
|
228
|
+
]
|
|
229
|
+
},
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
_record_tool_result(
|
|
233
|
+
recorder,
|
|
234
|
+
"composition",
|
|
235
|
+
"set_frame_range",
|
|
236
|
+
server.fusion_comp("set_frame_range", {**comp_scope, "start": 0, "end": 96}),
|
|
237
|
+
)
|
|
238
|
+
export_path = str(work_dir / "fusion_probe.setting")
|
|
239
|
+
_record_tool_result(
|
|
240
|
+
recorder,
|
|
241
|
+
"timeline_item_fusion",
|
|
242
|
+
"export_comp",
|
|
243
|
+
server.timeline_item_fusion("export_comp", {**item_scope, "path": export_path, "index": 1}),
|
|
244
|
+
)
|
|
245
|
+
_record_tool_result(
|
|
246
|
+
recorder,
|
|
247
|
+
"report",
|
|
248
|
+
"fusion_boundary_report",
|
|
249
|
+
server.fusion_comp("fusion_boundary_report", {**comp_scope, "include_io": False}),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if keep_open:
|
|
253
|
+
server.project_manager("save")
|
|
254
|
+
print(f"LEFT PROJECT OPEN FOR INSPECTION: {project_name}")
|
|
255
|
+
created_project = False
|
|
256
|
+
|
|
257
|
+
finally:
|
|
258
|
+
if created_project:
|
|
259
|
+
server.project_manager("save")
|
|
260
|
+
server.project_manager("close")
|
|
261
|
+
delete_result = server.project_manager("delete", {"name": project_name})
|
|
262
|
+
print(f"Deleted disposable project: {delete_result}")
|
|
263
|
+
|
|
264
|
+
report = recorder.to_report(
|
|
265
|
+
metadata,
|
|
266
|
+
{
|
|
267
|
+
"json": str(output_dir / "fusion-composition-probe.json"),
|
|
268
|
+
"markdown": str(output_dir / "fusion-composition-probe.md"),
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
json_path = output_dir / "fusion-composition-probe.json"
|
|
272
|
+
markdown_path = output_dir / "fusion-composition-probe.md"
|
|
273
|
+
json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
|
|
274
|
+
markdown_path.write_text(render_markdown_report(report), encoding="utf-8")
|
|
275
|
+
print(f"Wrote JSON report: {json_path}")
|
|
276
|
+
print(f"Wrote Markdown report: {markdown_path}")
|
|
277
|
+
print(f"Counts: {json.dumps(report['counts'], sort_keys=True)}")
|
|
278
|
+
if not keep_open:
|
|
279
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
280
|
+
print(f"Removed synthetic media directory: {work_dir}")
|
|
281
|
+
|
|
282
|
+
if delete_result and delete_result.get("success") is not True:
|
|
283
|
+
raise AssertionError(f"Cleanup failed for {project_name}: {delete_result!r}")
|
|
284
|
+
return report
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
DaVinci Resolve MCP Server - Layout Presets Utilities
|
|
4
|
+
|
|
5
|
+
This module provides functions for working with DaVinci Resolve UI layout presets:
|
|
6
|
+
- Saving layout presets
|
|
7
|
+
- Loading layout presets
|
|
8
|
+
- Exporting/importing preset files
|
|
9
|
+
- Managing layout configurations
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Dict, List, Any, Optional, Union
|
|
16
|
+
|
|
17
|
+
# Configure logging
|
|
18
|
+
logger = logging.getLogger("davinci-resolve-mcp.layout_presets")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _validate_path_within_directory(path: str, allowed_dir: str) -> bool:
|
|
22
|
+
"""Validate that a resolved path is within the allowed directory.
|
|
23
|
+
|
|
24
|
+
Prevents path traversal attacks (e.g. preset_name='../../etc/passwd').
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
path: The path to validate (will be resolved to absolute).
|
|
28
|
+
allowed_dir: The directory the path must reside within.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True if the path is safely within allowed_dir.
|
|
32
|
+
"""
|
|
33
|
+
resolved = os.path.realpath(path)
|
|
34
|
+
allowed = os.path.realpath(allowed_dir)
|
|
35
|
+
# Ensure the resolved path starts with the allowed directory
|
|
36
|
+
# (trailing sep prevents '/presets2' matching '/presets')
|
|
37
|
+
return resolved.startswith(allowed + os.sep) or resolved == allowed
|
|
38
|
+
|
|
39
|
+
# Default preset locations by platform
|
|
40
|
+
DEFAULT_PRESET_PATHS = {
|
|
41
|
+
"darwin": "~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Presets/",
|
|
42
|
+
"win32": "C:\\ProgramData\\Blackmagic Design\\DaVinci Resolve\\Presets\\",
|
|
43
|
+
"linux": "~/.local/share/DaVinciResolve/Presets/"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def get_layout_preset_path(platform: str = None) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Get the path to layout presets for the current platform.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
platform: Override the detected platform (darwin, win32, linux)
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Path to the layout presets directory
|
|
55
|
+
"""
|
|
56
|
+
import platform as platform_module
|
|
57
|
+
import os
|
|
58
|
+
|
|
59
|
+
# Determine platform if not specified
|
|
60
|
+
if platform is None:
|
|
61
|
+
platform = platform_module.system().lower()
|
|
62
|
+
if platform == "darwin":
|
|
63
|
+
platform = "darwin"
|
|
64
|
+
elif platform == "windows":
|
|
65
|
+
platform = "win32"
|
|
66
|
+
elif platform == "linux":
|
|
67
|
+
platform = "linux"
|
|
68
|
+
else:
|
|
69
|
+
platform = "darwin" # Default to macOS if unknown
|
|
70
|
+
|
|
71
|
+
# Get default path for platform
|
|
72
|
+
preset_path = DEFAULT_PRESET_PATHS.get(platform, DEFAULT_PRESET_PATHS["darwin"])
|
|
73
|
+
|
|
74
|
+
# Expand user directory if needed
|
|
75
|
+
preset_path = os.path.expanduser(preset_path)
|
|
76
|
+
|
|
77
|
+
# Ensure directory exists
|
|
78
|
+
if not os.path.exists(preset_path):
|
|
79
|
+
os.makedirs(preset_path, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
return preset_path
|
|
82
|
+
|
|
83
|
+
def get_ui_layout_path(preset_path: str = None) -> str:
|
|
84
|
+
"""
|
|
85
|
+
Get the path to UI layout presets.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
preset_path: Base preset directory path (determined automatically if None)
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Path to the UI layout presets directory
|
|
92
|
+
"""
|
|
93
|
+
if preset_path is None:
|
|
94
|
+
preset_path = get_layout_preset_path()
|
|
95
|
+
|
|
96
|
+
# UI layouts are in a specific subdirectory
|
|
97
|
+
ui_layout_path = os.path.join(preset_path, "UILayouts")
|
|
98
|
+
|
|
99
|
+
# Ensure directory exists
|
|
100
|
+
if not os.path.exists(ui_layout_path):
|
|
101
|
+
os.makedirs(ui_layout_path, exist_ok=True)
|
|
102
|
+
|
|
103
|
+
return ui_layout_path
|
|
104
|
+
|
|
105
|
+
def list_layout_presets(layout_type: str = "ui") -> List[Dict[str, Any]]:
|
|
106
|
+
"""
|
|
107
|
+
List available layout presets.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
layout_type: Type of layout presets to list ('ui', 'window', 'workspace')
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
List of preset information dictionaries
|
|
114
|
+
"""
|
|
115
|
+
# Get appropriate preset directory
|
|
116
|
+
if layout_type.lower() == "ui":
|
|
117
|
+
preset_dir = get_ui_layout_path()
|
|
118
|
+
else:
|
|
119
|
+
# Other layout types would be handled here
|
|
120
|
+
preset_dir = get_ui_layout_path()
|
|
121
|
+
|
|
122
|
+
# List files in the directory
|
|
123
|
+
if not os.path.exists(preset_dir):
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
presets = []
|
|
127
|
+
for filename in os.listdir(preset_dir):
|
|
128
|
+
# Only include layout preset files
|
|
129
|
+
if filename.endswith(".layout"):
|
|
130
|
+
preset_path = os.path.join(preset_dir, filename)
|
|
131
|
+
presets.append({
|
|
132
|
+
"name": os.path.splitext(filename)[0],
|
|
133
|
+
"path": preset_path,
|
|
134
|
+
"type": layout_type,
|
|
135
|
+
"size": os.path.getsize(preset_path)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
return presets
|
|
139
|
+
|
|
140
|
+
def save_layout_preset(resolve_obj, preset_name: str, layout_type: str = "ui") -> bool:
|
|
141
|
+
"""
|
|
142
|
+
Save the current layout as a preset.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
resolve_obj: DaVinci Resolve API object
|
|
146
|
+
preset_name: Name for the saved preset
|
|
147
|
+
layout_type: Type of layout to save ('ui', 'window', 'workspace')
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
True if successful, False otherwise
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
# Ensure preset name has no spaces or special characters
|
|
154
|
+
safe_name = preset_name.replace(" ", "_").replace("/", "_").replace("\\", "_")
|
|
155
|
+
|
|
156
|
+
# Different layout types have different save methods
|
|
157
|
+
if layout_type.lower() == "ui":
|
|
158
|
+
# For UI layouts, use the UI Manager
|
|
159
|
+
ui_manager = resolve_obj.GetUIManager()
|
|
160
|
+
if not ui_manager:
|
|
161
|
+
logger.error("Failed to get UI Manager")
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
# Save the current UI layout
|
|
165
|
+
return ui_manager.SaveUILayout(safe_name)
|
|
166
|
+
else:
|
|
167
|
+
# Other layout types would be handled here
|
|
168
|
+
logger.error(f"Unsupported layout type: {layout_type}")
|
|
169
|
+
return False
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.error(f"Error saving layout preset: {str(e)}")
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
def load_layout_preset(resolve_obj, preset_name: str, layout_type: str = "ui") -> bool:
|
|
175
|
+
"""
|
|
176
|
+
Load a layout preset.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
resolve_obj: DaVinci Resolve API object
|
|
180
|
+
preset_name: Name of the preset to load
|
|
181
|
+
layout_type: Type of layout to load ('ui', 'window', 'workspace')
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
True if successful, False otherwise
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
# Different layout types have different load methods
|
|
188
|
+
if layout_type.lower() == "ui":
|
|
189
|
+
# For UI layouts, use the UI Manager
|
|
190
|
+
ui_manager = resolve_obj.GetUIManager()
|
|
191
|
+
if not ui_manager:
|
|
192
|
+
logger.error("Failed to get UI Manager")
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
# Load the specified UI layout
|
|
196
|
+
return ui_manager.LoadUILayout(preset_name)
|
|
197
|
+
else:
|
|
198
|
+
# Other layout types would be handled here
|
|
199
|
+
logger.error(f"Unsupported layout type: {layout_type}")
|
|
200
|
+
return False
|
|
201
|
+
except Exception as e:
|
|
202
|
+
logger.error(f"Error loading layout preset: {str(e)}")
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
def export_layout_preset(preset_name: str, export_path: str, layout_type: str = "ui") -> bool:
|
|
206
|
+
"""
|
|
207
|
+
Export a layout preset to a file.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
preset_name: Name of the preset to export
|
|
211
|
+
export_path: Path to export the preset file to
|
|
212
|
+
layout_type: Type of layout to export ('ui', 'window', 'workspace')
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
True if successful, False otherwise
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
# Get the source preset path
|
|
219
|
+
if layout_type.lower() == "ui":
|
|
220
|
+
preset_dir = get_ui_layout_path()
|
|
221
|
+
else:
|
|
222
|
+
# Other layout types would be handled here
|
|
223
|
+
preset_dir = get_ui_layout_path()
|
|
224
|
+
|
|
225
|
+
# Construct source path and validate it stays within preset_dir
|
|
226
|
+
source_path = os.path.join(preset_dir, f"{preset_name}.layout")
|
|
227
|
+
if not _validate_path_within_directory(source_path, preset_dir):
|
|
228
|
+
logger.error(f"Path traversal blocked in preset name: {preset_name}")
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
# Ensure source file exists
|
|
232
|
+
if not os.path.exists(source_path):
|
|
233
|
+
logger.error(f"Preset file not found: {source_path}")
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
# Ensure destination directory exists
|
|
237
|
+
export_dir = os.path.dirname(export_path)
|
|
238
|
+
if export_dir and not os.path.exists(export_dir):
|
|
239
|
+
os.makedirs(export_dir, exist_ok=True)
|
|
240
|
+
|
|
241
|
+
# Copy the preset file
|
|
242
|
+
import shutil
|
|
243
|
+
shutil.copy2(source_path, export_path)
|
|
244
|
+
|
|
245
|
+
return True
|
|
246
|
+
except Exception as e:
|
|
247
|
+
logger.error(f"Error exporting layout preset: {str(e)}")
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
def import_layout_preset(import_path: str, preset_name: str = None, layout_type: str = "ui") -> bool:
|
|
251
|
+
"""
|
|
252
|
+
Import a layout preset from a file.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
import_path: Path to the preset file to import
|
|
256
|
+
preset_name: Name to save the imported preset as (uses filename if None)
|
|
257
|
+
layout_type: Type of layout to import ('ui', 'window', 'workspace')
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
True if successful, False otherwise
|
|
261
|
+
"""
|
|
262
|
+
try:
|
|
263
|
+
# Ensure source file exists
|
|
264
|
+
if not os.path.exists(import_path):
|
|
265
|
+
logger.error(f"Import file not found: {import_path}")
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
# Get the destination preset path
|
|
269
|
+
if layout_type.lower() == "ui":
|
|
270
|
+
preset_dir = get_ui_layout_path()
|
|
271
|
+
else:
|
|
272
|
+
# Other layout types would be handled here
|
|
273
|
+
preset_dir = get_ui_layout_path()
|
|
274
|
+
|
|
275
|
+
# Use filename as preset name if not specified
|
|
276
|
+
if preset_name is None:
|
|
277
|
+
preset_name = os.path.splitext(os.path.basename(import_path))[0]
|
|
278
|
+
|
|
279
|
+
# Ensure preset name has no spaces or special characters
|
|
280
|
+
safe_name = preset_name.replace(" ", "_").replace("/", "_").replace("\\", "_")
|
|
281
|
+
|
|
282
|
+
# Construct destination path and validate it stays within preset_dir
|
|
283
|
+
dest_path = os.path.join(preset_dir, f"{safe_name}.layout")
|
|
284
|
+
if not _validate_path_within_directory(dest_path, preset_dir):
|
|
285
|
+
logger.error(f"Path traversal blocked in preset name: {preset_name}")
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
# Copy the preset file
|
|
289
|
+
import shutil
|
|
290
|
+
shutil.copy2(import_path, dest_path)
|
|
291
|
+
|
|
292
|
+
return True
|
|
293
|
+
except Exception as e:
|
|
294
|
+
logger.error(f"Error importing layout preset: {str(e)}")
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
def delete_layout_preset(preset_name: str, layout_type: str = "ui") -> bool:
|
|
298
|
+
"""
|
|
299
|
+
Delete a layout preset.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
preset_name: Name of the preset to delete
|
|
303
|
+
layout_type: Type of layout to delete ('ui', 'window', 'workspace')
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
True if successful, False otherwise
|
|
307
|
+
"""
|
|
308
|
+
try:
|
|
309
|
+
# Get the preset path
|
|
310
|
+
if layout_type.lower() == "ui":
|
|
311
|
+
preset_dir = get_ui_layout_path()
|
|
312
|
+
else:
|
|
313
|
+
# Other layout types would be handled here
|
|
314
|
+
preset_dir = get_ui_layout_path()
|
|
315
|
+
|
|
316
|
+
# Construct the preset file path and validate it stays within preset_dir
|
|
317
|
+
preset_path = os.path.join(preset_dir, f"{preset_name}.layout")
|
|
318
|
+
if not _validate_path_within_directory(preset_path, preset_dir):
|
|
319
|
+
logger.error(f"Path traversal blocked in preset name: {preset_name}")
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
# Ensure file exists
|
|
323
|
+
if not os.path.exists(preset_path):
|
|
324
|
+
logger.error(f"Preset file not found: {preset_path}")
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
# Delete the file
|
|
328
|
+
os.remove(preset_path)
|
|
329
|
+
|
|
330
|
+
return True
|
|
331
|
+
except Exception as e:
|
|
332
|
+
logger.error(f"Error deleting layout preset: {str(e)}")
|
|
333
|
+
return False
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""FastMCP stdio helpers with strict LF line endings across platforms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from io import TextIOWrapper
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
from mcp.server.stdio import stdio_server
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_text_stdio():
|
|
13
|
+
"""Wrap stdio in UTF-8 text mode without platform newline translation."""
|
|
14
|
+
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", newline=""))
|
|
15
|
+
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8", newline=""))
|
|
16
|
+
return stdin, stdout
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def run_fastmcp_stdio_async(fastmcp):
|
|
20
|
+
"""Run a FastMCP server over stdio using strict LF-delimited JSON."""
|
|
21
|
+
stdin, stdout = create_text_stdio()
|
|
22
|
+
async with stdio_server(stdin=stdin, stdout=stdout) as (read_stream, write_stream):
|
|
23
|
+
await fastmcp._mcp_server.run( # noqa: SLF001 - no public injection point exists yet
|
|
24
|
+
read_stream,
|
|
25
|
+
write_stream,
|
|
26
|
+
fastmcp._mcp_server.create_initialization_options(),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_fastmcp_stdio(fastmcp):
|
|
31
|
+
"""Synchronous entrypoint for strict-LF FastMCP stdio."""
|
|
32
|
+
anyio.run(lambda: run_fastmcp_stdio_async(fastmcp))
|