davinci-resolve-mcp 2.32.1 → 2.33.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/CHANGELOG.md +58 -0
- package/README.md +1 -1
- package/docs/SKILL.md +6 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +30 -16
- package/src/granular/media_pool_item.py +15 -15
- package/src/server.py +242 -12
- package/src/utils/object_inspection.py +85 -71
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.33.0
|
|
6
|
+
|
|
7
|
+
Fusion node-graph layout and duplication, plus performance and robustness
|
|
8
|
+
improvements across the compound server. Live-validated on DaVinci Resolve
|
|
9
|
+
Studio 21.0.0.
|
|
10
|
+
|
|
11
|
+
### Fusion node layout & duplication
|
|
12
|
+
|
|
13
|
+
- **Added** `fusion_comp(action="get_position")` and `set_position` — read and
|
|
14
|
+
write a node's position on the FlowView canvas. `set_position` confirms the
|
|
15
|
+
move by reading the position back.
|
|
16
|
+
- **Added** `fusion_comp(action="copy_tool")` — duplicate a node, optionally
|
|
17
|
+
renaming and repositioning it. Settings are carried through a temporary
|
|
18
|
+
`.setting` file, which round-trips reliably across the Python bridge where the
|
|
19
|
+
in-memory `SaveSettings()`/`Paste()` table form fails.
|
|
20
|
+
- **Added** `fusion_comp(action="auto_arrange")` — lay tools out in a row
|
|
21
|
+
(`direction="horizontal"`) or column (`"vertical"`) at a given spacing.
|
|
22
|
+
|
|
23
|
+
### Performance
|
|
24
|
+
|
|
25
|
+
- **Changed** Resolve object inspection walks `dir(obj)` once instead of once for
|
|
26
|
+
methods and again for properties, skips `inspect.signature()` for C-extension
|
|
27
|
+
methods (slow and almost always raising there), and reads `__doc__` directly.
|
|
28
|
+
Each attribute access on a Resolve object is a bridge round-trip, so this
|
|
29
|
+
roughly halves inspection cost on the `resolve_control` path.
|
|
30
|
+
- **Changed** Media-pool find-by-name lookups walk the folder tree lazily and
|
|
31
|
+
stop at the first match instead of materializing the entire project tree.
|
|
32
|
+
|
|
33
|
+
### Robustness & fixes
|
|
34
|
+
|
|
35
|
+
- **Fixed** `export_frame_as_still` rejects an empty path or a nonexistent target
|
|
36
|
+
directory instead of silently returning failure.
|
|
37
|
+
- **Fixed** `set_mark_in_out` (clip and timeline) rejects `mark_in > mark_out`.
|
|
38
|
+
- **Fixed** Auto-sync audio resolves `AUDIO_SYNC_*` enum constants via the live
|
|
39
|
+
Resolve handle, closing a path where a stale module handle silently degraded
|
|
40
|
+
`AutoSyncAudio` to rejected string keys.
|
|
41
|
+
- **Changed** Every `subprocess` call that can run while the MCP stdio server is
|
|
42
|
+
active now sets `stdin=subprocess.DEVNULL`, so a child process cannot consume
|
|
43
|
+
bytes from the JSON-RPC protocol stream; the spec-hook runner also captures its
|
|
44
|
+
child's output. Applies to both the compound and granular launchers.
|
|
45
|
+
|
|
46
|
+
## What's New in v2.32.2
|
|
47
|
+
|
|
48
|
+
Fixes `fusion_comp(action="get_keyframes")` serialization.
|
|
49
|
+
|
|
50
|
+
The handler iterated `Input.GetKeyFrames()` as if it returned `{time: value}`,
|
|
51
|
+
but Fusion returns `{1-based index: frame_position}`. The result put the
|
|
52
|
+
keyframe **index** in `time` and the **frame position** in `value` — the actual
|
|
53
|
+
keyframed values were never reported.
|
|
54
|
+
|
|
55
|
+
The handler now treats the dict values as frame positions and reads each
|
|
56
|
+
keyframed value back via `GetInput(input_name, frame)`.
|
|
57
|
+
|
|
58
|
+
- **Fixed** `get_keyframes` now returns `[{"time": <frame>, "value": <value>}, ...]`
|
|
59
|
+
in frame order (live-validated on DaVinci Resolve Studio 21.0.0:
|
|
60
|
+
`Size` keyed `1.0@f0` / `1.4@f75` → `[{0.0: 1.0}, {75.0: 1.4}]`).
|
|
61
|
+
- Follow-up to the `add_keyframe` fix in v2.32.1; flagged by @sandypoli-boop in #56.
|
|
62
|
+
|
|
5
63
|
## What's New in v2.32.1
|
|
6
64
|
|
|
7
65
|
Fixes `fusion_comp(action="add_keyframe")` so it actually **animates** the input.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/docs/SKILL.md
CHANGED
|
@@ -979,6 +979,12 @@ Key actions:
|
|
|
979
979
|
- `get_inputs(tool_name)` / `get_outputs(tool_name)`
|
|
980
980
|
- `set_attrs(tool_name, attrs)` / `get_attrs(tool_name)`
|
|
981
981
|
- `add_keyframe(tool_name, input_name, time, value)`
|
|
982
|
+
- `get_position(tool_name)` / `set_position(tool_name, x, y)` — read/write a node's
|
|
983
|
+
position on the FlowView canvas; `set_position` returns a position read-back
|
|
984
|
+
- `copy_tool(tool_name, name?, x?, y?)` — duplicate a node (settings copied via a
|
|
985
|
+
temp `.setting` file), optionally renaming and repositioning it
|
|
986
|
+
- `auto_arrange(tool_names?, direction?, spacing?, x?, y?)` — lay tools out in a row
|
|
987
|
+
(`direction="horizontal"`, default) or column (`"vertical"`)
|
|
982
988
|
- `get_comp_info`, `set_frame_range(start, end)`, `render`
|
|
983
989
|
- `start_undo(name?)` / `end_undo(keep?)`
|
|
984
990
|
- `bulk_set_inputs(ops)` — batch set inputs across multiple timeline item comps in
|
package/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "2.
|
|
38
|
+
VERSION = "2.33.0"
|
|
39
39
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
40
40
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
41
41
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.
|
|
83
|
+
VERSION = "2.33.0"
|
|
84
84
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
85
85
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
86
86
|
logger.info(f"Detected platform: {get_platform()}")
|
|
@@ -330,17 +330,17 @@ def _launch_resolve():
|
|
|
330
330
|
app_path = "/Applications/DaVinci Resolve/DaVinci Resolve.app"
|
|
331
331
|
if not os.path.exists(app_path):
|
|
332
332
|
return False
|
|
333
|
-
subprocess.Popen(["open", app_path])
|
|
333
|
+
subprocess.Popen(["open", app_path], stdin=subprocess.DEVNULL)
|
|
334
334
|
elif sys_name == "windows":
|
|
335
335
|
app_path = r"C:\Program Files\Blackmagic Design\DaVinci Resolve\Resolve.exe"
|
|
336
336
|
if not os.path.exists(app_path):
|
|
337
337
|
return False
|
|
338
|
-
subprocess.Popen([app_path])
|
|
338
|
+
subprocess.Popen([app_path], stdin=subprocess.DEVNULL)
|
|
339
339
|
elif sys_name == "linux":
|
|
340
340
|
app_path = "/opt/resolve/bin/resolve"
|
|
341
341
|
if not os.path.exists(app_path):
|
|
342
342
|
return False
|
|
343
|
-
subprocess.Popen([app_path])
|
|
343
|
+
subprocess.Popen([app_path], stdin=subprocess.DEVNULL)
|
|
344
344
|
else:
|
|
345
345
|
return False
|
|
346
346
|
logger.info("Launched DaVinci Resolve, waiting for it to respond...")
|
|
@@ -380,22 +380,36 @@ def get_current_project():
|
|
|
380
380
|
proj = pm.GetCurrentProject()
|
|
381
381
|
return pm, proj
|
|
382
382
|
|
|
383
|
-
def
|
|
384
|
-
"""
|
|
385
|
-
|
|
383
|
+
def iter_all_media_pool_clips(media_pool):
|
|
384
|
+
"""Yield every clip in the media pool, walking subfolders lazily.
|
|
385
|
+
|
|
386
|
+
Unlike get_all_media_pool_clips(), this does not materialize the whole tree.
|
|
387
|
+
A caller that breaks early (e.g. find-by-name) stops the walk as soon as it
|
|
388
|
+
has a match, avoiding a full traversal. Each folder access is a Resolve
|
|
389
|
+
bridge round-trip, so early exit is a real win on large projects.
|
|
390
|
+
|
|
391
|
+
Yields in the same pre-order (parent clips before descendants, siblings in
|
|
392
|
+
listed order) as the eager get_all_media_pool_clips().
|
|
393
|
+
"""
|
|
386
394
|
root_folder = media_pool.GetRootFolder()
|
|
387
|
-
|
|
388
|
-
|
|
395
|
+
if not root_folder:
|
|
396
|
+
return
|
|
397
|
+
stack = [root_folder]
|
|
398
|
+
while stack:
|
|
399
|
+
folder = stack.pop()
|
|
389
400
|
folder_clips = folder.GetClipList()
|
|
390
401
|
if folder_clips:
|
|
391
|
-
|
|
392
|
-
|
|
402
|
+
for clip in folder_clips:
|
|
403
|
+
yield clip
|
|
393
404
|
sub_folders = folder.GetSubFolderList()
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
405
|
+
if sub_folders:
|
|
406
|
+
# Reversed so the LIFO stack pops siblings in their listed order.
|
|
407
|
+
stack.extend(reversed(list(sub_folders)))
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def get_all_media_pool_clips(media_pool):
|
|
411
|
+
"""Get all clips from media pool recursively including subfolders."""
|
|
412
|
+
return list(iter_all_media_pool_clips(media_pool))
|
|
399
413
|
|
|
400
414
|
def get_all_media_pool_folders(media_pool):
|
|
401
415
|
"""Get all folders from media pool recursively."""
|
|
@@ -53,10 +53,10 @@ def link_proxy_media(clip_name: str, proxy_file_path: str) -> str:
|
|
|
53
53
|
if not media_pool:
|
|
54
54
|
return "Error: Failed to get Media Pool"
|
|
55
55
|
|
|
56
|
-
# Find the clip by name
|
|
57
|
-
clips =
|
|
56
|
+
# Find the clip by name (lazy walk; stops at the first match)
|
|
57
|
+
clips = iter_all_media_pool_clips(media_pool)
|
|
58
58
|
target_clip = None
|
|
59
|
-
|
|
59
|
+
|
|
60
60
|
for clip in clips:
|
|
61
61
|
if clip.GetName() == clip_name:
|
|
62
62
|
target_clip = clip
|
|
@@ -94,10 +94,10 @@ def unlink_proxy_media(clip_name: str) -> str:
|
|
|
94
94
|
if not media_pool:
|
|
95
95
|
return "Error: Failed to get Media Pool"
|
|
96
96
|
|
|
97
|
-
# Find the clip by name
|
|
98
|
-
clips =
|
|
97
|
+
# Find the clip by name (lazy walk; stops at the first match)
|
|
98
|
+
clips = iter_all_media_pool_clips(media_pool)
|
|
99
99
|
target_clip = None
|
|
100
|
-
|
|
100
|
+
|
|
101
101
|
for clip in clips:
|
|
102
102
|
if clip.GetName() == clip_name:
|
|
103
103
|
target_clip = clip
|
|
@@ -132,10 +132,10 @@ def replace_clip(clip_name: str, replacement_path: str) -> str:
|
|
|
132
132
|
if not media_pool:
|
|
133
133
|
return "Error: Failed to get Media Pool"
|
|
134
134
|
|
|
135
|
-
# Find the clip by name
|
|
136
|
-
clips =
|
|
135
|
+
# Find the clip by name (lazy walk; stops at the first match)
|
|
136
|
+
clips = iter_all_media_pool_clips(media_pool)
|
|
137
137
|
target_clip = None
|
|
138
|
-
|
|
138
|
+
|
|
139
139
|
for clip in clips:
|
|
140
140
|
if clip.GetName() == clip_name:
|
|
141
141
|
target_clip = clip
|
|
@@ -181,10 +181,10 @@ def transcribe_audio(clip_name: str, use_speaker_detection: Optional[bool] = Non
|
|
|
181
181
|
if not media_pool:
|
|
182
182
|
return "Error: Failed to get Media Pool"
|
|
183
183
|
|
|
184
|
-
# Find the clip by name
|
|
185
|
-
clips =
|
|
184
|
+
# Find the clip by name (lazy walk; stops at the first match)
|
|
185
|
+
clips = iter_all_media_pool_clips(media_pool)
|
|
186
186
|
target_clip = None
|
|
187
|
-
|
|
187
|
+
|
|
188
188
|
for clip in clips:
|
|
189
189
|
if clip.GetName() == clip_name:
|
|
190
190
|
target_clip = clip
|
|
@@ -223,10 +223,10 @@ def clear_transcription(clip_name: str) -> str:
|
|
|
223
223
|
if not media_pool:
|
|
224
224
|
return "Error: Failed to get Media Pool"
|
|
225
225
|
|
|
226
|
-
# Find the clip by name
|
|
227
|
-
clips =
|
|
226
|
+
# Find the clip by name (lazy walk; stops at the first match)
|
|
227
|
+
clips = iter_all_media_pool_clips(media_pool)
|
|
228
228
|
target_clip = None
|
|
229
|
-
|
|
229
|
+
|
|
230
230
|
for clip in clips:
|
|
231
231
|
if clip.GetName() == clip_name:
|
|
232
232
|
target_clip = clip
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.33.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -559,19 +559,19 @@ def _launch_resolve():
|
|
|
559
559
|
if not os.path.exists(app_path):
|
|
560
560
|
logger.error(f"DaVinci Resolve not found at {app_path}")
|
|
561
561
|
return False
|
|
562
|
-
subprocess.Popen(["open", app_path])
|
|
562
|
+
subprocess.Popen(["open", app_path], stdin=subprocess.DEVNULL)
|
|
563
563
|
elif sys_name == "windows":
|
|
564
564
|
app_path = r"C:\Program Files\Blackmagic Design\DaVinci Resolve\Resolve.exe"
|
|
565
565
|
if not os.path.exists(app_path):
|
|
566
566
|
logger.error(f"DaVinci Resolve not found at {app_path}")
|
|
567
567
|
return False
|
|
568
|
-
subprocess.Popen([app_path])
|
|
568
|
+
subprocess.Popen([app_path], stdin=subprocess.DEVNULL)
|
|
569
569
|
elif sys_name == "linux":
|
|
570
570
|
app_path = "/opt/resolve/bin/resolve"
|
|
571
571
|
if not os.path.exists(app_path):
|
|
572
572
|
logger.error(f"DaVinci Resolve not found at {app_path}")
|
|
573
573
|
return False
|
|
574
|
-
subprocess.Popen([app_path])
|
|
574
|
+
subprocess.Popen([app_path], stdin=subprocess.DEVNULL)
|
|
575
575
|
else:
|
|
576
576
|
return False
|
|
577
577
|
logger.info("Launched DaVinci Resolve, waiting for it to respond...")
|
|
@@ -1058,6 +1058,7 @@ def _activate_resolve_window() -> Dict[str, Any]:
|
|
|
1058
1058
|
proc = subprocess.run(
|
|
1059
1059
|
["osascript", "-e", 'tell application "DaVinci Resolve" to activate'],
|
|
1060
1060
|
capture_output=True, text=True, timeout=5,
|
|
1061
|
+
stdin=subprocess.DEVNULL,
|
|
1061
1062
|
)
|
|
1062
1063
|
return {
|
|
1063
1064
|
"activated": proc.returncode == 0,
|
|
@@ -1071,6 +1072,7 @@ def _activate_resolve_window() -> Dict[str, Any]:
|
|
|
1071
1072
|
"$s = New-Object -ComObject WScript.Shell; "
|
|
1072
1073
|
"$null = $s.AppActivate('DaVinci Resolve')"],
|
|
1073
1074
|
capture_output=True, text=True, timeout=5,
|
|
1075
|
+
stdin=subprocess.DEVNULL,
|
|
1074
1076
|
)
|
|
1075
1077
|
return {
|
|
1076
1078
|
"activated": proc.returncode == 0,
|
|
@@ -1083,6 +1085,7 @@ def _activate_resolve_window() -> Dict[str, Any]:
|
|
|
1083
1085
|
proc = subprocess.run(
|
|
1084
1086
|
["wmctrl", "-a", "DaVinci Resolve"],
|
|
1085
1087
|
capture_output=True, text=True, timeout=5,
|
|
1088
|
+
stdin=subprocess.DEVNULL,
|
|
1086
1089
|
)
|
|
1087
1090
|
return {
|
|
1088
1091
|
"activated": proc.returncode == 0,
|
|
@@ -1093,6 +1096,7 @@ def _activate_resolve_window() -> Dict[str, Any]:
|
|
|
1093
1096
|
proc = subprocess.run(
|
|
1094
1097
|
["xdotool", "search", "--name", "DaVinci Resolve", "windowactivate"],
|
|
1095
1098
|
capture_output=True, text=True, timeout=5,
|
|
1099
|
+
stdin=subprocess.DEVNULL,
|
|
1096
1100
|
)
|
|
1097
1101
|
return {
|
|
1098
1102
|
"activated": proc.returncode == 0,
|
|
@@ -1124,6 +1128,7 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
|
|
|
1124
1128
|
proc = subprocess.run(
|
|
1125
1129
|
["osascript", "-e", script],
|
|
1126
1130
|
capture_output=True, text=True, timeout=5,
|
|
1131
|
+
stdin=subprocess.DEVNULL,
|
|
1127
1132
|
)
|
|
1128
1133
|
return {
|
|
1129
1134
|
"sent": proc.returncode == 0,
|
|
@@ -1139,6 +1144,7 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
|
|
|
1139
1144
|
"Start-Sleep -Milliseconds 150; "
|
|
1140
1145
|
"[System.Windows.Forms.SendKeys]::SendWait('+i')"],
|
|
1141
1146
|
capture_output=True, text=True, timeout=5,
|
|
1147
|
+
stdin=subprocess.DEVNULL,
|
|
1142
1148
|
)
|
|
1143
1149
|
return {
|
|
1144
1150
|
"sent": proc.returncode == 0,
|
|
@@ -1152,6 +1158,7 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
|
|
|
1152
1158
|
proc = subprocess.run(
|
|
1153
1159
|
["xdotool", "search", "--name", "DaVinci Resolve", "key", "--window", "%@", "shift+i"],
|
|
1154
1160
|
capture_output=True, text=True, timeout=5,
|
|
1161
|
+
stdin=subprocess.DEVNULL,
|
|
1155
1162
|
)
|
|
1156
1163
|
return {"sent": proc.returncode == 0, "platform": "linux", "tool": "xdotool", "shortcut": "Shift+I"}
|
|
1157
1164
|
return {"sent": False, "platform": sys.platform, "note": "no key-send tool found"}
|
|
@@ -5407,7 +5414,11 @@ def _safe_auto_sync_audio(mp, p: Dict[str, Any]):
|
|
|
5407
5414
|
if err:
|
|
5408
5415
|
return err
|
|
5409
5416
|
clips, missing = resolved
|
|
5410
|
-
|
|
5417
|
+
# Use get_resolve() rather than the module global `resolve`, which is None
|
|
5418
|
+
# until populated and can be reset to None by a mid-call reconnect. A None
|
|
5419
|
+
# here makes _normalize_auto_sync_settings fall back to string enum keys,
|
|
5420
|
+
# which AutoSyncAudio silently rejects (returns False).
|
|
5421
|
+
settings = _normalize_auto_sync_settings(dict(p.get("settings") or {}), get_resolve())
|
|
5411
5422
|
if p.get("dry_run", True):
|
|
5412
5423
|
return _ok(would_auto_sync=True, clips=_clip_summaries(clips), missing=missing, settings=settings)
|
|
5413
5424
|
return {"success": bool(mp.AutoSyncAudio(clips, settings)), "count": len(clips), "missing": missing, "settings": settings}
|
|
@@ -10897,6 +10908,7 @@ def _pick_dashboard_python(repo_root: str) -> Tuple[str, Optional[str]]:
|
|
|
10897
10908
|
capture_output=True,
|
|
10898
10909
|
timeout=5,
|
|
10899
10910
|
check=False,
|
|
10911
|
+
stdin=subprocess.DEVNULL,
|
|
10900
10912
|
)
|
|
10901
10913
|
except (OSError, subprocess.TimeoutExpired):
|
|
10902
10914
|
continue
|
|
@@ -10956,6 +10968,7 @@ def _port_owner_pid(host: str, port: int) -> Optional[int]:
|
|
|
10956
10968
|
result = subprocess.run(
|
|
10957
10969
|
["lsof", "-nP", "-iTCP:" + str(port), "-sTCP:LISTEN", "-t"],
|
|
10958
10970
|
capture_output=True, timeout=3, text=True, check=False,
|
|
10971
|
+
stdin=subprocess.DEVNULL,
|
|
10959
10972
|
)
|
|
10960
10973
|
except (OSError, subprocess.TimeoutExpired):
|
|
10961
10974
|
return None
|
|
@@ -12071,7 +12084,14 @@ def _make_spec_hook_runner(timeout: float = 120.0):
|
|
|
12071
12084
|
|
|
12072
12085
|
def _run(hook) -> bool:
|
|
12073
12086
|
try:
|
|
12074
|
-
proc = subprocess.run(
|
|
12087
|
+
proc = subprocess.run(
|
|
12088
|
+
hook.command,
|
|
12089
|
+
shell=True,
|
|
12090
|
+
timeout=timeout,
|
|
12091
|
+
stdin=subprocess.DEVNULL,
|
|
12092
|
+
capture_output=True,
|
|
12093
|
+
text=True,
|
|
12094
|
+
)
|
|
12075
12095
|
return proc.returncode == 0
|
|
12076
12096
|
except Exception as exc:
|
|
12077
12097
|
logger.warning("spec hook '%s' failed: %s", hook.name or hook.command, exc)
|
|
@@ -12450,7 +12470,13 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
|
|
|
12450
12470
|
g = proj.GetGallery()
|
|
12451
12471
|
return {"available": g is not None}
|
|
12452
12472
|
elif action == "export_frame_as_still":
|
|
12453
|
-
|
|
12473
|
+
still_path = p.get("path")
|
|
12474
|
+
if not still_path:
|
|
12475
|
+
return _err("export_frame_as_still requires a non-empty 'path'")
|
|
12476
|
+
parent = os.path.dirname(os.path.expanduser(still_path))
|
|
12477
|
+
if parent and not os.path.isdir(parent):
|
|
12478
|
+
return _err(f"export_frame_as_still target directory does not exist: {parent}")
|
|
12479
|
+
return {"success": bool(proj.ExportCurrentFrameAsStill(still_path))}
|
|
12454
12480
|
elif action == "load_burnin_preset":
|
|
12455
12481
|
return {"success": bool(proj.LoadBurnInPreset(p["name"]))}
|
|
12456
12482
|
elif action == "insert_audio":
|
|
@@ -13874,6 +13900,8 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
13874
13900
|
elif action == "get_mark_in_out":
|
|
13875
13901
|
return _ser(clip.GetMarkInOut())
|
|
13876
13902
|
elif action == "set_mark_in_out":
|
|
13903
|
+
if p["mark_in"] > p["mark_out"]:
|
|
13904
|
+
return _err(f"mark_in ({p['mark_in']}) must be <= mark_out ({p['mark_out']})")
|
|
13877
13905
|
return {"success": bool(clip.SetMarkInOut(p["mark_in"], p["mark_out"], p.get("type", "all")))}
|
|
13878
13906
|
elif action == "clear_mark_in_out":
|
|
13879
13907
|
return {"success": bool(clip.ClearMarkInOut(p.get("type", "all")))}
|
|
@@ -15274,6 +15302,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
15274
15302
|
elif action == "get_mark_in_out":
|
|
15275
15303
|
return _ser(tl.GetMarkInOut())
|
|
15276
15304
|
elif action == "set_mark_in_out":
|
|
15305
|
+
if p["mark_in"] > p["mark_out"]:
|
|
15306
|
+
return _err(f"mark_in ({p['mark_in']}) must be <= mark_out ({p['mark_out']})")
|
|
15277
15307
|
return {"success": bool(tl.SetMarkInOut(p["mark_in"], p["mark_out"], p.get("type", "all")))}
|
|
15278
15308
|
elif action == "clear_mark_in_out":
|
|
15279
15309
|
return {"success": bool(tl.ClearMarkInOut(p.get("type", "all")))}
|
|
@@ -18405,6 +18435,71 @@ def _fusion_boundary_report(comp, p: Dict[str, Any]):
|
|
|
18405
18435
|
|
|
18406
18436
|
|
|
18407
18437
|
@mcp.tool()
|
|
18438
|
+
def _parse_pos(raw):
|
|
18439
|
+
"""Normalize a FlowView position return into (x, y) floats, or None.
|
|
18440
|
+
|
|
18441
|
+
Depending on the bridge, position reads come back as a 1-indexed Lua table,
|
|
18442
|
+
a dict ({1: x, 2: y} / {"x": .., "y": ..}), or an (x, y) tuple/list. Be
|
|
18443
|
+
liberal in what we accept.
|
|
18444
|
+
"""
|
|
18445
|
+
if raw is None:
|
|
18446
|
+
return None
|
|
18447
|
+
if isinstance(raw, dict):
|
|
18448
|
+
for ka, kb in ((1, 2), ("1", "2"), ("x", "y"), ("X", "Y")):
|
|
18449
|
+
if ka in raw and kb in raw:
|
|
18450
|
+
try:
|
|
18451
|
+
return float(raw[ka]), float(raw[kb])
|
|
18452
|
+
except (TypeError, ValueError):
|
|
18453
|
+
return None
|
|
18454
|
+
vals = list(raw.values())
|
|
18455
|
+
if len(vals) >= 2:
|
|
18456
|
+
try:
|
|
18457
|
+
return float(vals[0]), float(vals[1])
|
|
18458
|
+
except (TypeError, ValueError):
|
|
18459
|
+
return None
|
|
18460
|
+
return None
|
|
18461
|
+
if isinstance(raw, (list, tuple)):
|
|
18462
|
+
if len(raw) >= 2:
|
|
18463
|
+
try:
|
|
18464
|
+
return float(raw[0]), float(raw[1])
|
|
18465
|
+
except (TypeError, ValueError):
|
|
18466
|
+
return None
|
|
18467
|
+
return None
|
|
18468
|
+
# Lua table object with numeric indexing.
|
|
18469
|
+
try:
|
|
18470
|
+
return float(raw[1]), float(raw[2])
|
|
18471
|
+
except Exception:
|
|
18472
|
+
return None
|
|
18473
|
+
|
|
18474
|
+
|
|
18475
|
+
def _fusion_flow_view(comp):
|
|
18476
|
+
"""Return the comp's FlowView (node-graph canvas), or None if unavailable."""
|
|
18477
|
+
try:
|
|
18478
|
+
cf = comp.CurrentFrame
|
|
18479
|
+
if cf is None:
|
|
18480
|
+
return None
|
|
18481
|
+
return cf.FlowView
|
|
18482
|
+
except Exception:
|
|
18483
|
+
return None
|
|
18484
|
+
|
|
18485
|
+
|
|
18486
|
+
def _iter_fusion_tools(comp):
|
|
18487
|
+
"""Yield (tool_name, tool) for every tool in the comp."""
|
|
18488
|
+
tools = comp.GetToolList() or {}
|
|
18489
|
+
for idx in tools:
|
|
18490
|
+
t = tools[idx]
|
|
18491
|
+
try:
|
|
18492
|
+
name = t.GetAttrs().get("TOOLS_Name", "")
|
|
18493
|
+
except Exception:
|
|
18494
|
+
name = ""
|
|
18495
|
+
yield name, t
|
|
18496
|
+
|
|
18497
|
+
|
|
18498
|
+
def _fusion_tool_names(comp):
|
|
18499
|
+
"""Return the set-friendly list of tool names currently in the comp."""
|
|
18500
|
+
return [name for name, _ in _iter_fusion_tools(comp)]
|
|
18501
|
+
|
|
18502
|
+
|
|
18408
18503
|
def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
18409
18504
|
"""Fusion composition node graph operations.
|
|
18410
18505
|
|
|
@@ -18433,6 +18528,11 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18433
18528
|
get_keyframes(tool_name, input_name) -> {keyframes}
|
|
18434
18529
|
delete_keyframe(tool_name, input_name, time) -> {success}
|
|
18435
18530
|
get_comp_info() -> {name, tool_count, attrs}
|
|
18531
|
+
get_position(tool_name) -> {tool_name, x, y} — read a node's FlowView position
|
|
18532
|
+
set_position(tool_name, x, y) -> {success, x, y, readback} — move a node
|
|
18533
|
+
copy_tool(tool_name, name?, x?, y?) -> {success, new_tool, new_tools} — duplicate a node
|
|
18534
|
+
auto_arrange(tool_names?, direction?, spacing?, x?, y?) -> {success, arranged, count}
|
|
18535
|
+
Lay tools out in a row (direction="horizontal", default) or column ("vertical").
|
|
18436
18536
|
set_frame_range(start, end) -> {success}
|
|
18437
18537
|
render() -> {success}
|
|
18438
18538
|
start_undo(name?) -> {success}
|
|
@@ -18685,11 +18785,17 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18685
18785
|
inp = tool[p["input_name"]]
|
|
18686
18786
|
if not inp:
|
|
18687
18787
|
return _err(f"Input '{p['input_name']}' not found on tool '{p['tool_name']}'")
|
|
18788
|
+
# GetKeyFrames() returns {1-based index: frame_position}, NOT
|
|
18789
|
+
# {time: value}. Iterating it directly puts the index in `time` and the
|
|
18790
|
+
# frame position in `value`. Read the actual keyframed value back from
|
|
18791
|
+
# the input at each frame position via GetInput.
|
|
18688
18792
|
keyframes = []
|
|
18689
18793
|
kfs = inp.GetKeyFrames()
|
|
18690
18794
|
if kfs:
|
|
18691
|
-
for
|
|
18692
|
-
|
|
18795
|
+
for idx in sorted(kfs):
|
|
18796
|
+
frame = kfs[idx]
|
|
18797
|
+
value = tool.GetInput(p["input_name"], frame)
|
|
18798
|
+
keyframes.append({"time": frame, "value": _ser(value)})
|
|
18693
18799
|
return {"keyframes": keyframes}
|
|
18694
18800
|
|
|
18695
18801
|
elif action == "delete_keyframe":
|
|
@@ -18731,6 +18837,127 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18731
18837
|
comp.EndUndo(p.get("keep", True))
|
|
18732
18838
|
return _ok()
|
|
18733
18839
|
|
|
18840
|
+
# --- Node Layout (FlowView) ---
|
|
18841
|
+
elif action == "get_position":
|
|
18842
|
+
tool = comp.FindTool(p["tool_name"])
|
|
18843
|
+
if not tool:
|
|
18844
|
+
return _err(f"Tool '{p['tool_name']}' not found")
|
|
18845
|
+
flow = _fusion_flow_view(comp)
|
|
18846
|
+
if flow is None:
|
|
18847
|
+
return _err("FlowView unavailable on this comp")
|
|
18848
|
+
pos = _parse_pos(flow.GetPosTable(tool))
|
|
18849
|
+
if pos is None:
|
|
18850
|
+
return _err(f"Could not read position for tool '{p['tool_name']}'")
|
|
18851
|
+
return {"tool_name": p["tool_name"], "x": pos[0], "y": pos[1]}
|
|
18852
|
+
|
|
18853
|
+
elif action == "set_position":
|
|
18854
|
+
if "x" not in p or "y" not in p:
|
|
18855
|
+
return _err("set_position requires x and y")
|
|
18856
|
+
tool = comp.FindTool(p["tool_name"])
|
|
18857
|
+
if not tool:
|
|
18858
|
+
return _err(f"Tool '{p['tool_name']}' not found")
|
|
18859
|
+
flow = _fusion_flow_view(comp)
|
|
18860
|
+
if flow is None:
|
|
18861
|
+
return _err("FlowView unavailable on this comp")
|
|
18862
|
+
x, y = float(p["x"]), float(p["y"])
|
|
18863
|
+
comp.Lock()
|
|
18864
|
+
try:
|
|
18865
|
+
flow.SetPos(tool, x, y)
|
|
18866
|
+
finally:
|
|
18867
|
+
comp.Unlock()
|
|
18868
|
+
# SetPos has no reliable return; confirm by reading the position back.
|
|
18869
|
+
pos = _parse_pos(flow.GetPosTable(tool))
|
|
18870
|
+
return {
|
|
18871
|
+
"success": True,
|
|
18872
|
+
"tool_name": p["tool_name"],
|
|
18873
|
+
"x": x,
|
|
18874
|
+
"y": y,
|
|
18875
|
+
"readback": {"x": pos[0], "y": pos[1]} if pos else None,
|
|
18876
|
+
}
|
|
18877
|
+
|
|
18878
|
+
elif action == "copy_tool":
|
|
18879
|
+
src = comp.FindTool(p["tool_name"])
|
|
18880
|
+
if not src:
|
|
18881
|
+
return _err(f"Tool '{p['tool_name']}' not found")
|
|
18882
|
+
regid = src.GetAttrs().get("TOOLS_RegID")
|
|
18883
|
+
if not regid:
|
|
18884
|
+
return _err(f"Could not determine the tool type of '{p['tool_name']}'")
|
|
18885
|
+
# Duplicate by adding a same-type tool and loading the source's settings
|
|
18886
|
+
# through a temp .setting FILE. Passing SaveSettings()'s in-memory table
|
|
18887
|
+
# back into Paste/LoadSettings fails across the Python bridge; a file path
|
|
18888
|
+
# round-trips reliably. New node is found by diffing tool names.
|
|
18889
|
+
before = set(_fusion_tool_names(comp))
|
|
18890
|
+
fd, settings_path = tempfile.mkstemp(suffix=".setting")
|
|
18891
|
+
os.close(fd)
|
|
18892
|
+
new_tool = None
|
|
18893
|
+
comp.Lock()
|
|
18894
|
+
try:
|
|
18895
|
+
if not src.SaveSettings(settings_path):
|
|
18896
|
+
return _err(f"SaveSettings failed for '{p['tool_name']}'")
|
|
18897
|
+
new_tool = comp.AddTool(regid, -32768, -32768)
|
|
18898
|
+
if not new_tool:
|
|
18899
|
+
return _err(f"Failed to create a duplicate of '{p['tool_name']}'")
|
|
18900
|
+
new_tool.LoadSettings(settings_path)
|
|
18901
|
+
finally:
|
|
18902
|
+
comp.Unlock()
|
|
18903
|
+
try:
|
|
18904
|
+
os.remove(settings_path)
|
|
18905
|
+
except OSError:
|
|
18906
|
+
pass
|
|
18907
|
+
new_names = [n for n in _fusion_tool_names(comp) if n not in before]
|
|
18908
|
+
new_name = new_names[0] if new_names else new_tool.GetAttrs().get("TOOLS_Name", "")
|
|
18909
|
+
rename = p.get("name")
|
|
18910
|
+
if rename:
|
|
18911
|
+
comp.Lock()
|
|
18912
|
+
try:
|
|
18913
|
+
new_tool.SetAttrs({"TOOLS_Name": rename})
|
|
18914
|
+
finally:
|
|
18915
|
+
comp.Unlock()
|
|
18916
|
+
new_name = rename
|
|
18917
|
+
if "x" in p and "y" in p:
|
|
18918
|
+
flow = _fusion_flow_view(comp)
|
|
18919
|
+
if flow is not None:
|
|
18920
|
+
comp.Lock()
|
|
18921
|
+
try:
|
|
18922
|
+
flow.SetPos(new_tool, float(p["x"]), float(p["y"]))
|
|
18923
|
+
finally:
|
|
18924
|
+
comp.Unlock()
|
|
18925
|
+
return {
|
|
18926
|
+
"success": True,
|
|
18927
|
+
"source": p["tool_name"],
|
|
18928
|
+
"new_tool": new_name,
|
|
18929
|
+
"new_tools": new_names,
|
|
18930
|
+
}
|
|
18931
|
+
|
|
18932
|
+
elif action == "auto_arrange":
|
|
18933
|
+
flow = _fusion_flow_view(comp)
|
|
18934
|
+
if flow is None:
|
|
18935
|
+
return _err("FlowView unavailable on this comp")
|
|
18936
|
+
names = p.get("tool_names")
|
|
18937
|
+
if names:
|
|
18938
|
+
tools = [(n, comp.FindTool(n)) for n in names]
|
|
18939
|
+
tools = [(n, t) for n, t in tools if t]
|
|
18940
|
+
else:
|
|
18941
|
+
tools = list(_iter_fusion_tools(comp))
|
|
18942
|
+
if not tools:
|
|
18943
|
+
return _err("No tools to arrange")
|
|
18944
|
+
direction = (p.get("direction") or "horizontal").strip().lower()
|
|
18945
|
+
spacing = float(p.get("spacing", 2.0))
|
|
18946
|
+
x0, y0 = float(p.get("x", 0.0)), float(p.get("y", 0.0))
|
|
18947
|
+
arranged = []
|
|
18948
|
+
comp.Lock()
|
|
18949
|
+
try:
|
|
18950
|
+
for i, (name, tool) in enumerate(tools):
|
|
18951
|
+
if direction == "vertical":
|
|
18952
|
+
x, y = x0, y0 + i * spacing
|
|
18953
|
+
else:
|
|
18954
|
+
x, y = x0 + i * spacing, y0
|
|
18955
|
+
flow.SetPos(tool, x, y)
|
|
18956
|
+
arranged.append({"tool_name": name, "x": x, "y": y})
|
|
18957
|
+
finally:
|
|
18958
|
+
comp.Unlock()
|
|
18959
|
+
return {"success": True, "arranged": arranged, "count": len(arranged)}
|
|
18960
|
+
|
|
18734
18961
|
return _unknown(action, [
|
|
18735
18962
|
"add_tool","delete_tool","get_tool_list","find_tool",
|
|
18736
18963
|
"connect","disconnect","get_inputs","get_outputs",
|
|
@@ -18738,6 +18965,7 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18738
18965
|
"add_keyframe","get_keyframes","delete_keyframe",
|
|
18739
18966
|
"get_comp_info","set_frame_range","render",
|
|
18740
18967
|
"start_undo","end_undo",
|
|
18968
|
+
"get_position","set_position","copy_tool","auto_arrange",
|
|
18741
18969
|
"bulk_set_inputs",
|
|
18742
18970
|
"bulk_set_expressions",
|
|
18743
18971
|
*_FUSION_GROUP_KERNEL_ACTIONS,
|
|
@@ -18776,7 +19004,8 @@ def _validate_lua_syntax(source: str) -> Dict[str, Any]:
|
|
|
18776
19004
|
luac = None
|
|
18777
19005
|
for candidate in ("luac", "luac5.1", "luac5.3", "luac5.4"):
|
|
18778
19006
|
try:
|
|
18779
|
-
subprocess.run([candidate, "-v"], capture_output=True, check=True, timeout=5
|
|
19007
|
+
subprocess.run([candidate, "-v"], capture_output=True, check=True, timeout=5,
|
|
19008
|
+
stdin=subprocess.DEVNULL)
|
|
18780
19009
|
luac = candidate
|
|
18781
19010
|
break
|
|
18782
19011
|
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
@@ -18788,7 +19017,8 @@ def _validate_lua_syntax(source: str) -> Dict[str, Any]:
|
|
|
18788
19017
|
f.write(source)
|
|
18789
19018
|
tmp = f.name
|
|
18790
19019
|
try:
|
|
18791
|
-
result = subprocess.run([luac, "-p", tmp], capture_output=True, text=True, timeout=10
|
|
19020
|
+
result = subprocess.run([luac, "-p", tmp], capture_output=True, text=True, timeout=10,
|
|
19021
|
+
stdin=subprocess.DEVNULL)
|
|
18792
19022
|
if result.returncode == 0:
|
|
18793
19023
|
return {"valid": True, "errors": None, "checker": luac}
|
|
18794
19024
|
return {"valid": False, "errors": result.stderr.strip() or result.stdout.strip(),
|
|
@@ -19346,7 +19576,7 @@ def _execute_python_script(path: str, args: List[str],
|
|
|
19346
19576
|
try:
|
|
19347
19577
|
result = subprocess.run(cmd, env=_python_env_for_resolve(),
|
|
19348
19578
|
capture_output=True, text=True,
|
|
19349
|
-
timeout=timeout)
|
|
19579
|
+
timeout=timeout, stdin=subprocess.DEVNULL)
|
|
19350
19580
|
except subprocess.TimeoutExpired as e:
|
|
19351
19581
|
return _err(f"Script timed out after {timeout}s. "
|
|
19352
19582
|
f"Partial stdout: {(e.stdout or '')[:1000]}")
|
|
@@ -11,99 +11,111 @@ This module provides functions for inspecting DaVinci Resolve API objects:
|
|
|
11
11
|
|
|
12
12
|
import sys
|
|
13
13
|
import inspect
|
|
14
|
+
import logging
|
|
14
15
|
from typing import Any, Dict, List, Optional, Union, Callable
|
|
15
16
|
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
|
|
20
|
+
def get_object_members(
|
|
21
|
+
obj: Any,
|
|
22
|
+
include_methods: bool = True,
|
|
23
|
+
include_properties: bool = True,
|
|
24
|
+
) -> Dict[str, Any]:
|
|
18
25
|
"""
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
Inspect a DaVinci Resolve object's methods and/or properties in a single pass.
|
|
27
|
+
|
|
28
|
+
DaVinci Resolve API objects are C extensions: every attribute access is a
|
|
29
|
+
bridge round-trip, so we walk ``dir(obj)`` exactly once and classify each
|
|
30
|
+
attribute, rather than walking it separately for methods and properties.
|
|
31
|
+
|
|
21
32
|
Args:
|
|
22
33
|
obj: A DaVinci Resolve API object
|
|
23
|
-
|
|
34
|
+
include_methods: Collect callable attributes
|
|
35
|
+
include_properties: Collect non-callable attributes
|
|
36
|
+
|
|
24
37
|
Returns:
|
|
25
|
-
A
|
|
38
|
+
A dict with ``"methods"`` and/or ``"properties"`` keys, or ``{"error": ...}``
|
|
26
39
|
"""
|
|
27
40
|
if obj is None:
|
|
28
41
|
return {"error": "Cannot inspect None object"}
|
|
29
|
-
|
|
30
|
-
methods = {}
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
|
|
43
|
+
methods: Dict[str, Dict[str, Any]] = {}
|
|
44
|
+
properties: Dict[str, Dict[str, Any]] = {}
|
|
45
|
+
|
|
33
46
|
for attr_name in dir(obj):
|
|
34
47
|
# Skip private/internal attributes
|
|
35
48
|
if attr_name.startswith('_'):
|
|
36
49
|
continue
|
|
37
|
-
|
|
50
|
+
|
|
38
51
|
try:
|
|
39
52
|
attr = getattr(obj, attr_name)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
except Exception as e:
|
|
54
|
+
if include_methods:
|
|
55
|
+
methods[attr_name] = {"error": str(e), "type": "error"}
|
|
56
|
+
if include_properties:
|
|
57
|
+
properties[attr_name] = {"error": str(e), "type_category": "error"}
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
if callable(attr):
|
|
61
|
+
if not include_methods:
|
|
62
|
+
continue
|
|
63
|
+
# Only real Python functions/methods carry an introspectable
|
|
64
|
+
# signature. Resolve API methods are C extensions where
|
|
65
|
+
# inspect.signature() is slow and almost always raises, so we skip
|
|
66
|
+
# the attempt and default to "()" for them.
|
|
67
|
+
if inspect.isfunction(attr) or inspect.ismethod(attr):
|
|
44
68
|
try:
|
|
45
69
|
signature = str(inspect.signature(attr))
|
|
46
70
|
except (ValueError, TypeError):
|
|
47
71
|
signature = "()"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"type": "method"
|
|
56
|
-
}
|
|
57
|
-
except Exception as e:
|
|
72
|
+
else:
|
|
73
|
+
signature = "()"
|
|
74
|
+
|
|
75
|
+
# Read __doc__ directly instead of inspect.getdoc() to avoid the
|
|
76
|
+
# MRO walk for inherited docstrings (irrelevant for C-ext methods).
|
|
77
|
+
doc = (getattr(attr, "__doc__", "") or "").strip()
|
|
78
|
+
|
|
58
79
|
methods[attr_name] = {
|
|
59
|
-
"
|
|
60
|
-
"
|
|
80
|
+
"signature": signature,
|
|
81
|
+
"doc": doc,
|
|
82
|
+
"type": "method",
|
|
61
83
|
}
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
else:
|
|
85
|
+
if not include_properties:
|
|
86
|
+
continue
|
|
87
|
+
properties[attr_name] = {
|
|
88
|
+
"value": str(attr),
|
|
89
|
+
"type": type(attr).__name__,
|
|
90
|
+
"type_category": "property",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
result: Dict[str, Any] = {}
|
|
94
|
+
if include_methods:
|
|
95
|
+
result["methods"] = methods
|
|
96
|
+
if include_properties:
|
|
97
|
+
result["properties"] = properties
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_object_methods(obj: Any) -> Dict[str, Dict[str, Any]]:
|
|
102
|
+
"""
|
|
103
|
+
Get all methods of a DaVinci Resolve object with their documentation.
|
|
104
|
+
|
|
105
|
+
Thin wrapper over :func:`get_object_members` kept for backwards compatibility.
|
|
106
|
+
"""
|
|
107
|
+
res = get_object_members(obj, include_methods=True, include_properties=False)
|
|
108
|
+
return res.get("methods", res)
|
|
64
109
|
|
|
65
110
|
|
|
66
111
|
def get_object_properties(obj: Any) -> Dict[str, Dict[str, Any]]:
|
|
67
112
|
"""
|
|
68
113
|
Get all properties (non-callable attributes) of a DaVinci Resolve object.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
obj: A DaVinci Resolve API object
|
|
72
|
-
|
|
73
|
-
Returns:
|
|
74
|
-
A dictionary of property names and their details
|
|
114
|
+
|
|
115
|
+
Thin wrapper over :func:`get_object_members` kept for backwards compatibility.
|
|
75
116
|
"""
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
properties = {}
|
|
80
|
-
|
|
81
|
-
# Get all object attributes
|
|
82
|
-
for attr_name in dir(obj):
|
|
83
|
-
# Skip private/internal attributes
|
|
84
|
-
if attr_name.startswith('_'):
|
|
85
|
-
continue
|
|
86
|
-
|
|
87
|
-
try:
|
|
88
|
-
attr = getattr(obj, attr_name)
|
|
89
|
-
|
|
90
|
-
# Skip if it's a method
|
|
91
|
-
if callable(attr):
|
|
92
|
-
continue
|
|
93
|
-
|
|
94
|
-
# Get the property value and type
|
|
95
|
-
properties[attr_name] = {
|
|
96
|
-
"value": str(attr),
|
|
97
|
-
"type": type(attr).__name__,
|
|
98
|
-
"type_category": "property"
|
|
99
|
-
}
|
|
100
|
-
except Exception as e:
|
|
101
|
-
properties[attr_name] = {
|
|
102
|
-
"error": str(e),
|
|
103
|
-
"type_category": "error"
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return properties
|
|
117
|
+
res = get_object_members(obj, include_methods=False, include_properties=True)
|
|
118
|
+
return res.get("properties", res)
|
|
107
119
|
|
|
108
120
|
|
|
109
121
|
def inspect_object(obj: Any, max_depth: int = 1) -> Dict[str, Any]:
|
|
@@ -120,12 +132,13 @@ def inspect_object(obj: Any, max_depth: int = 1) -> Dict[str, Any]:
|
|
|
120
132
|
if obj is None:
|
|
121
133
|
return {"error": "Cannot inspect None object"}
|
|
122
134
|
|
|
135
|
+
members = get_object_members(obj)
|
|
123
136
|
result = {
|
|
124
137
|
"type": type(obj).__name__,
|
|
125
|
-
"methods":
|
|
126
|
-
"properties":
|
|
138
|
+
"methods": members.get("methods", {}),
|
|
139
|
+
"properties": members.get("properties", {}),
|
|
127
140
|
}
|
|
128
|
-
|
|
141
|
+
|
|
129
142
|
# Add string representation
|
|
130
143
|
try:
|
|
131
144
|
result["str"] = str(obj)
|
|
@@ -252,9 +265,10 @@ def print_object_help(obj: Any) -> str:
|
|
|
252
265
|
return "Cannot provide help for None object"
|
|
253
266
|
|
|
254
267
|
obj_type = type(obj).__name__
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
268
|
+
members = get_object_members(obj)
|
|
269
|
+
methods = members.get("methods", {})
|
|
270
|
+
properties = members.get("properties", {})
|
|
271
|
+
|
|
258
272
|
help_text = [f"Help for {obj_type} object:"]
|
|
259
273
|
help_text.append("\n" + "=" * 40 + "\n")
|
|
260
274
|
|