davinci-resolve-mcp 2.32.2 → 2.33.1
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 +56 -0
- package/README.md +1 -1
- package/docs/SKILL.md +10 -1
- 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 +300 -12
- package/src/utils/object_inspection.py +85 -71
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,62 @@
|
|
|
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.1
|
|
6
|
+
|
|
7
|
+
Clip transcription read-back and more trustworthy auto-sync reporting.
|
|
8
|
+
|
|
9
|
+
- **Added** `media_pool_item(action="get_transcription")` returns
|
|
10
|
+
`{text, truncated, status, has_transcription}`. Transcription could previously
|
|
11
|
+
be triggered but never read back. Resolve's `Transcription` clip property is a
|
|
12
|
+
preview that ends in an ellipsis when the full transcript is longer, so
|
|
13
|
+
`truncated` tells callers the returned text is partial.
|
|
14
|
+
- **Changed** Auto-sync audio now verifies linkage by reading each clip's
|
|
15
|
+
`Synced Audio` property before and after the call and reporting
|
|
16
|
+
`linked` / `newly_linked` / `already_linked`, instead of trusting
|
|
17
|
+
`AutoSyncAudio`'s unreliable boolean. The boolean is still returned as
|
|
18
|
+
`success`, but callers should trust `linked`.
|
|
19
|
+
|
|
20
|
+
## What's New in v2.33.0
|
|
21
|
+
|
|
22
|
+
Fusion node-graph layout and duplication, plus performance and robustness
|
|
23
|
+
improvements across the compound server. Live-validated on DaVinci Resolve
|
|
24
|
+
Studio 21.0.0.
|
|
25
|
+
|
|
26
|
+
### Fusion node layout & duplication
|
|
27
|
+
|
|
28
|
+
- **Added** `fusion_comp(action="get_position")` and `set_position` — read and
|
|
29
|
+
write a node's position on the FlowView canvas. `set_position` confirms the
|
|
30
|
+
move by reading the position back.
|
|
31
|
+
- **Added** `fusion_comp(action="copy_tool")` — duplicate a node, optionally
|
|
32
|
+
renaming and repositioning it. Settings are carried through a temporary
|
|
33
|
+
`.setting` file, which round-trips reliably across the Python bridge where the
|
|
34
|
+
in-memory `SaveSettings()`/`Paste()` table form fails.
|
|
35
|
+
- **Added** `fusion_comp(action="auto_arrange")` — lay tools out in a row
|
|
36
|
+
(`direction="horizontal"`) or column (`"vertical"`) at a given spacing.
|
|
37
|
+
|
|
38
|
+
### Performance
|
|
39
|
+
|
|
40
|
+
- **Changed** Resolve object inspection walks `dir(obj)` once instead of once for
|
|
41
|
+
methods and again for properties, skips `inspect.signature()` for C-extension
|
|
42
|
+
methods (slow and almost always raising there), and reads `__doc__` directly.
|
|
43
|
+
Each attribute access on a Resolve object is a bridge round-trip, so this
|
|
44
|
+
roughly halves inspection cost on the `resolve_control` path.
|
|
45
|
+
- **Changed** Media-pool find-by-name lookups walk the folder tree lazily and
|
|
46
|
+
stop at the first match instead of materializing the entire project tree.
|
|
47
|
+
|
|
48
|
+
### Robustness & fixes
|
|
49
|
+
|
|
50
|
+
- **Fixed** `export_frame_as_still` rejects an empty path or a nonexistent target
|
|
51
|
+
directory instead of silently returning failure.
|
|
52
|
+
- **Fixed** `set_mark_in_out` (clip and timeline) rejects `mark_in > mark_out`.
|
|
53
|
+
- **Fixed** Auto-sync audio resolves `AUDIO_SYNC_*` enum constants via the live
|
|
54
|
+
Resolve handle, closing a path where a stale module handle silently degraded
|
|
55
|
+
`AutoSyncAudio` to rejected string keys.
|
|
56
|
+
- **Changed** Every `subprocess` call that can run while the MCP stdio server is
|
|
57
|
+
active now sets `stdin=subprocess.DEVNULL`, so a child process cannot consume
|
|
58
|
+
bytes from the JSON-RPC protocol stream; the spec-hook runner also captures its
|
|
59
|
+
child's output. Applies to both the compound and granular launchers.
|
|
60
|
+
|
|
5
61
|
## What's New in v2.32.2
|
|
6
62
|
|
|
7
63
|
Fixes `fusion_comp(action="get_keyframes")` serialization.
|
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
|
@@ -455,7 +455,10 @@ Key actions: `get_name`, `get_metadata(key?)`, `set_metadata(key, value)`,
|
|
|
455
455
|
`set_clip_color(color)`, `link_proxy(proxy_path)`, `replace_clip(path)`,
|
|
456
456
|
`set_name(name)`, `link_full_resolution_media(path)`,
|
|
457
457
|
`replace_clip_preserve_sub_clip(path)`, `monitor_growing_file`,
|
|
458
|
-
`transcribe_audio(use_speaker_detection?)`, `
|
|
458
|
+
`transcribe_audio(use_speaker_detection?)`, `clear_transcription`,
|
|
459
|
+
`get_transcription` (read back `{text, truncated, status, has_transcription}`;
|
|
460
|
+
`truncated` flags when Resolve's preview cut the text off),
|
|
461
|
+
`perform_audio_classification`,
|
|
459
462
|
`analyze_for_intellisearch(identify_faces?, is_better_mode?)`, `analyze_for_slate(marker_color?)`,
|
|
460
463
|
`remove_motion_blur(deblur_option?)` (Resolve 21+; AI Extras / confirm-token gated as noted above),
|
|
461
464
|
`get_audio_mapping`, `get_mark_in_out`, `set_mark_in_out`
|
|
@@ -979,6 +982,12 @@ Key actions:
|
|
|
979
982
|
- `get_inputs(tool_name)` / `get_outputs(tool_name)`
|
|
980
983
|
- `set_attrs(tool_name, attrs)` / `get_attrs(tool_name)`
|
|
981
984
|
- `add_keyframe(tool_name, input_name, time, value)`
|
|
985
|
+
- `get_position(tool_name)` / `set_position(tool_name, x, y)` — read/write a node's
|
|
986
|
+
position on the FlowView canvas; `set_position` returns a position read-back
|
|
987
|
+
- `copy_tool(tool_name, name?, x?, y?)` — duplicate a node (settings copied via a
|
|
988
|
+
temp `.setting` file), optionally renaming and repositioning it
|
|
989
|
+
- `auto_arrange(tool_names?, direction?, spacing?, x?, y?)` — lay tools out in a row
|
|
990
|
+
(`direction="horizontal"`, default) or column (`"vertical"`)
|
|
982
991
|
- `get_comp_info`, `set_frame_range(start, end)`, `render`
|
|
983
992
|
- `start_undo(name?)` / `end_undo(keep?)`
|
|
984
993
|
- `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.1"
|
|
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.1"
|
|
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.1"
|
|
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"}
|
|
@@ -1166,6 +1173,18 @@ def _requires_method(obj, method_name, min_version):
|
|
|
1166
1173
|
return None
|
|
1167
1174
|
return _err(f"{method_name} requires DaVinci Resolve {min_version}+")
|
|
1168
1175
|
|
|
1176
|
+
def _is_truncated(text):
|
|
1177
|
+
"""True if a transcription preview was cut off.
|
|
1178
|
+
|
|
1179
|
+
Resolve's `Transcription` clip property is a preview that ends with an
|
|
1180
|
+
ellipsis (… or ...) when the full transcript is longer than the property
|
|
1181
|
+
exposes, so the caller knows the returned text is partial.
|
|
1182
|
+
"""
|
|
1183
|
+
if not isinstance(text, str):
|
|
1184
|
+
return False
|
|
1185
|
+
t = text.rstrip()
|
|
1186
|
+
return t.endswith("…") or t.endswith("...")
|
|
1187
|
+
|
|
1169
1188
|
_MARKER_COLORS = [
|
|
1170
1189
|
"Blue", "Cyan", "Green", "Yellow", "Red", "Pink", "Purple", "Fuchsia",
|
|
1171
1190
|
"Rose", "Lavender", "Sky", "Mint", "Lemon", "Sand", "Cocoa", "Cream",
|
|
@@ -5401,16 +5420,55 @@ def _audio_mapping_report(mp, tl, p: Dict[str, Any]):
|
|
|
5401
5420
|
return {"timeline_items": timeline_items, "media_pool_items": clip_rows}
|
|
5402
5421
|
|
|
5403
5422
|
|
|
5423
|
+
def _clip_name(clip):
|
|
5424
|
+
try:
|
|
5425
|
+
return clip.GetName()
|
|
5426
|
+
except Exception:
|
|
5427
|
+
return None
|
|
5428
|
+
|
|
5429
|
+
|
|
5430
|
+
def _synced_audio(clip):
|
|
5431
|
+
"""Best-effort read of whether a clip currently has synced audio linked."""
|
|
5432
|
+
try:
|
|
5433
|
+
v = clip.GetClipProperty("Synced Audio")
|
|
5434
|
+
except Exception:
|
|
5435
|
+
return False
|
|
5436
|
+
return bool(v) and str(v).strip() not in ("", "None", "0", "Off", "No")
|
|
5437
|
+
|
|
5438
|
+
|
|
5404
5439
|
def _safe_auto_sync_audio(mp, p: Dict[str, Any]):
|
|
5405
5440
|
root = mp.GetRootFolder()
|
|
5406
5441
|
resolved, err = _clips_from_params(root, mp, p)
|
|
5407
5442
|
if err:
|
|
5408
5443
|
return err
|
|
5409
5444
|
clips, missing = resolved
|
|
5410
|
-
|
|
5445
|
+
# Use get_resolve() rather than the module global `resolve`, which is None
|
|
5446
|
+
# until populated and can be reset to None by a mid-call reconnect. A None
|
|
5447
|
+
# here makes _normalize_auto_sync_settings fall back to string enum keys,
|
|
5448
|
+
# which AutoSyncAudio silently rejects (returns False).
|
|
5449
|
+
settings = _normalize_auto_sync_settings(dict(p.get("settings") or {}), get_resolve())
|
|
5411
5450
|
if p.get("dry_run", True):
|
|
5412
5451
|
return _ok(would_auto_sync=True, clips=_clip_summaries(clips), missing=missing, settings=settings)
|
|
5413
|
-
|
|
5452
|
+
# Read-back verification: AutoSyncAudio's boolean return is unreliable, so
|
|
5453
|
+
# capture each clip's "Synced Audio" linkage before and after and report the
|
|
5454
|
+
# delta. Trust `linked`/`newly_linked`, not `success`.
|
|
5455
|
+
before = [_synced_audio(c) for c in clips]
|
|
5456
|
+
ok = bool(mp.AutoSyncAudio(clips, settings))
|
|
5457
|
+
linked, newly_linked, already_linked = [], [], []
|
|
5458
|
+
for c, was in zip(clips, before):
|
|
5459
|
+
name = _clip_name(c)
|
|
5460
|
+
if _synced_audio(c):
|
|
5461
|
+
linked.append(name)
|
|
5462
|
+
(already_linked if was else newly_linked).append(name)
|
|
5463
|
+
return {
|
|
5464
|
+
"success": ok,
|
|
5465
|
+
"linked": linked,
|
|
5466
|
+
"newly_linked": newly_linked,
|
|
5467
|
+
"already_linked": already_linked,
|
|
5468
|
+
"count": len(clips),
|
|
5469
|
+
"missing": missing,
|
|
5470
|
+
"settings": settings,
|
|
5471
|
+
}
|
|
5414
5472
|
|
|
5415
5473
|
|
|
5416
5474
|
def _resolve_audio_constant(resolve_obj, name: str, fallback):
|
|
@@ -10897,6 +10955,7 @@ def _pick_dashboard_python(repo_root: str) -> Tuple[str, Optional[str]]:
|
|
|
10897
10955
|
capture_output=True,
|
|
10898
10956
|
timeout=5,
|
|
10899
10957
|
check=False,
|
|
10958
|
+
stdin=subprocess.DEVNULL,
|
|
10900
10959
|
)
|
|
10901
10960
|
except (OSError, subprocess.TimeoutExpired):
|
|
10902
10961
|
continue
|
|
@@ -10956,6 +11015,7 @@ def _port_owner_pid(host: str, port: int) -> Optional[int]:
|
|
|
10956
11015
|
result = subprocess.run(
|
|
10957
11016
|
["lsof", "-nP", "-iTCP:" + str(port), "-sTCP:LISTEN", "-t"],
|
|
10958
11017
|
capture_output=True, timeout=3, text=True, check=False,
|
|
11018
|
+
stdin=subprocess.DEVNULL,
|
|
10959
11019
|
)
|
|
10960
11020
|
except (OSError, subprocess.TimeoutExpired):
|
|
10961
11021
|
return None
|
|
@@ -12071,7 +12131,14 @@ def _make_spec_hook_runner(timeout: float = 120.0):
|
|
|
12071
12131
|
|
|
12072
12132
|
def _run(hook) -> bool:
|
|
12073
12133
|
try:
|
|
12074
|
-
proc = subprocess.run(
|
|
12134
|
+
proc = subprocess.run(
|
|
12135
|
+
hook.command,
|
|
12136
|
+
shell=True,
|
|
12137
|
+
timeout=timeout,
|
|
12138
|
+
stdin=subprocess.DEVNULL,
|
|
12139
|
+
capture_output=True,
|
|
12140
|
+
text=True,
|
|
12141
|
+
)
|
|
12075
12142
|
return proc.returncode == 0
|
|
12076
12143
|
except Exception as exc:
|
|
12077
12144
|
logger.warning("spec hook '%s' failed: %s", hook.name or hook.command, exc)
|
|
@@ -12450,7 +12517,13 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
|
|
|
12450
12517
|
g = proj.GetGallery()
|
|
12451
12518
|
return {"available": g is not None}
|
|
12452
12519
|
elif action == "export_frame_as_still":
|
|
12453
|
-
|
|
12520
|
+
still_path = p.get("path")
|
|
12521
|
+
if not still_path:
|
|
12522
|
+
return _err("export_frame_as_still requires a non-empty 'path'")
|
|
12523
|
+
parent = os.path.dirname(os.path.expanduser(still_path))
|
|
12524
|
+
if parent and not os.path.isdir(parent):
|
|
12525
|
+
return _err(f"export_frame_as_still target directory does not exist: {parent}")
|
|
12526
|
+
return {"success": bool(proj.ExportCurrentFrameAsStill(still_path))}
|
|
12454
12527
|
elif action == "load_burnin_preset":
|
|
12455
12528
|
return {"success": bool(proj.LoadBurnInPreset(p["name"]))}
|
|
12456
12529
|
elif action == "insert_audio":
|
|
@@ -13600,6 +13673,9 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
13600
13673
|
get_unique_id(clip_id) -> {id}
|
|
13601
13674
|
transcribe_audio(clip_id, use_speaker_detection?) -> {success} — use_speaker_detection is Resolve 21+
|
|
13602
13675
|
clear_transcription(clip_id) -> {success}
|
|
13676
|
+
get_transcription(clip_id) -> {text, truncated, status, has_transcription}
|
|
13677
|
+
Read a clip's transcription. `truncated` flags when Resolve's preview
|
|
13678
|
+
property cut the text off (the full transcript is longer).
|
|
13603
13679
|
perform_audio_classification(clip_id) -> {success} — Resolve 21+
|
|
13604
13680
|
clear_audio_classification(clip_id) -> {success} — Resolve 21+
|
|
13605
13681
|
analyze_for_intellisearch(clip_id, identify_faces?, is_better_mode?) -> {success} — Resolve 21+, AI IntelliSearch Extra
|
|
@@ -13801,6 +13877,20 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
13801
13877
|
return {"success": bool(clip.TranscribeAudio(bool(usd)))}
|
|
13802
13878
|
elif action == "clear_transcription":
|
|
13803
13879
|
return {"success": bool(clip.ClearTranscription())}
|
|
13880
|
+
elif action == "get_transcription":
|
|
13881
|
+
raw = clip.GetClipProperty("Transcription")
|
|
13882
|
+
text = raw if isinstance(raw, str) else ("" if raw is None else str(raw))
|
|
13883
|
+
try:
|
|
13884
|
+
status = clip.GetClipProperty("Transcription Status")
|
|
13885
|
+
except Exception:
|
|
13886
|
+
status = None
|
|
13887
|
+
return {
|
|
13888
|
+
"clip_id": p.get("clip_id"),
|
|
13889
|
+
"text": text,
|
|
13890
|
+
"truncated": _is_truncated(text),
|
|
13891
|
+
"status": status or None,
|
|
13892
|
+
"has_transcription": bool(text.strip()),
|
|
13893
|
+
}
|
|
13804
13894
|
elif action == "perform_audio_classification":
|
|
13805
13895
|
missing = _requires_method(clip, "PerformAudioClassification", "21.0")
|
|
13806
13896
|
if missing:
|
|
@@ -13874,10 +13964,12 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
13874
13964
|
elif action == "get_mark_in_out":
|
|
13875
13965
|
return _ser(clip.GetMarkInOut())
|
|
13876
13966
|
elif action == "set_mark_in_out":
|
|
13967
|
+
if p["mark_in"] > p["mark_out"]:
|
|
13968
|
+
return _err(f"mark_in ({p['mark_in']}) must be <= mark_out ({p['mark_out']})")
|
|
13877
13969
|
return {"success": bool(clip.SetMarkInOut(p["mark_in"], p["mark_out"], p.get("type", "all")))}
|
|
13878
13970
|
elif action == "clear_mark_in_out":
|
|
13879
13971
|
return {"success": bool(clip.ClearMarkInOut(p.get("type", "all")))}
|
|
13880
|
-
return _unknown(action, ["get_name","get_metadata","set_metadata","get_third_party_metadata","set_third_party_metadata","get_media_id","get_clip_property","set_clip_property","get_clip_color","set_clip_color","clear_clip_color","link_proxy","unlink_proxy","replace_clip","set_name","link_full_resolution_media","monitor_growing_file","replace_clip_preserve_sub_clip","get_unique_id","transcribe_audio","clear_transcription","perform_audio_classification","clear_audio_classification","analyze_for_intellisearch","analyze_for_slate","remove_motion_blur","get_audio_mapping","get_mark_in_out","set_mark_in_out","clear_mark_in_out","open_in_viewer"])
|
|
13972
|
+
return _unknown(action, ["get_name","get_metadata","set_metadata","get_third_party_metadata","set_third_party_metadata","get_media_id","get_clip_property","set_clip_property","get_clip_color","set_clip_color","clear_clip_color","link_proxy","unlink_proxy","replace_clip","set_name","link_full_resolution_media","monitor_growing_file","replace_clip_preserve_sub_clip","get_unique_id","transcribe_audio","clear_transcription","get_transcription","perform_audio_classification","clear_audio_classification","analyze_for_intellisearch","analyze_for_slate","remove_motion_blur","get_audio_mapping","get_mark_in_out","set_mark_in_out","clear_mark_in_out","open_in_viewer"])
|
|
13881
13973
|
|
|
13882
13974
|
|
|
13883
13975
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -15274,6 +15366,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
15274
15366
|
elif action == "get_mark_in_out":
|
|
15275
15367
|
return _ser(tl.GetMarkInOut())
|
|
15276
15368
|
elif action == "set_mark_in_out":
|
|
15369
|
+
if p["mark_in"] > p["mark_out"]:
|
|
15370
|
+
return _err(f"mark_in ({p['mark_in']}) must be <= mark_out ({p['mark_out']})")
|
|
15277
15371
|
return {"success": bool(tl.SetMarkInOut(p["mark_in"], p["mark_out"], p.get("type", "all")))}
|
|
15278
15372
|
elif action == "clear_mark_in_out":
|
|
15279
15373
|
return {"success": bool(tl.ClearMarkInOut(p.get("type", "all")))}
|
|
@@ -18405,6 +18499,71 @@ def _fusion_boundary_report(comp, p: Dict[str, Any]):
|
|
|
18405
18499
|
|
|
18406
18500
|
|
|
18407
18501
|
@mcp.tool()
|
|
18502
|
+
def _parse_pos(raw):
|
|
18503
|
+
"""Normalize a FlowView position return into (x, y) floats, or None.
|
|
18504
|
+
|
|
18505
|
+
Depending on the bridge, position reads come back as a 1-indexed Lua table,
|
|
18506
|
+
a dict ({1: x, 2: y} / {"x": .., "y": ..}), or an (x, y) tuple/list. Be
|
|
18507
|
+
liberal in what we accept.
|
|
18508
|
+
"""
|
|
18509
|
+
if raw is None:
|
|
18510
|
+
return None
|
|
18511
|
+
if isinstance(raw, dict):
|
|
18512
|
+
for ka, kb in ((1, 2), ("1", "2"), ("x", "y"), ("X", "Y")):
|
|
18513
|
+
if ka in raw and kb in raw:
|
|
18514
|
+
try:
|
|
18515
|
+
return float(raw[ka]), float(raw[kb])
|
|
18516
|
+
except (TypeError, ValueError):
|
|
18517
|
+
return None
|
|
18518
|
+
vals = list(raw.values())
|
|
18519
|
+
if len(vals) >= 2:
|
|
18520
|
+
try:
|
|
18521
|
+
return float(vals[0]), float(vals[1])
|
|
18522
|
+
except (TypeError, ValueError):
|
|
18523
|
+
return None
|
|
18524
|
+
return None
|
|
18525
|
+
if isinstance(raw, (list, tuple)):
|
|
18526
|
+
if len(raw) >= 2:
|
|
18527
|
+
try:
|
|
18528
|
+
return float(raw[0]), float(raw[1])
|
|
18529
|
+
except (TypeError, ValueError):
|
|
18530
|
+
return None
|
|
18531
|
+
return None
|
|
18532
|
+
# Lua table object with numeric indexing.
|
|
18533
|
+
try:
|
|
18534
|
+
return float(raw[1]), float(raw[2])
|
|
18535
|
+
except Exception:
|
|
18536
|
+
return None
|
|
18537
|
+
|
|
18538
|
+
|
|
18539
|
+
def _fusion_flow_view(comp):
|
|
18540
|
+
"""Return the comp's FlowView (node-graph canvas), or None if unavailable."""
|
|
18541
|
+
try:
|
|
18542
|
+
cf = comp.CurrentFrame
|
|
18543
|
+
if cf is None:
|
|
18544
|
+
return None
|
|
18545
|
+
return cf.FlowView
|
|
18546
|
+
except Exception:
|
|
18547
|
+
return None
|
|
18548
|
+
|
|
18549
|
+
|
|
18550
|
+
def _iter_fusion_tools(comp):
|
|
18551
|
+
"""Yield (tool_name, tool) for every tool in the comp."""
|
|
18552
|
+
tools = comp.GetToolList() or {}
|
|
18553
|
+
for idx in tools:
|
|
18554
|
+
t = tools[idx]
|
|
18555
|
+
try:
|
|
18556
|
+
name = t.GetAttrs().get("TOOLS_Name", "")
|
|
18557
|
+
except Exception:
|
|
18558
|
+
name = ""
|
|
18559
|
+
yield name, t
|
|
18560
|
+
|
|
18561
|
+
|
|
18562
|
+
def _fusion_tool_names(comp):
|
|
18563
|
+
"""Return the set-friendly list of tool names currently in the comp."""
|
|
18564
|
+
return [name for name, _ in _iter_fusion_tools(comp)]
|
|
18565
|
+
|
|
18566
|
+
|
|
18408
18567
|
def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
18409
18568
|
"""Fusion composition node graph operations.
|
|
18410
18569
|
|
|
@@ -18433,6 +18592,11 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18433
18592
|
get_keyframes(tool_name, input_name) -> {keyframes}
|
|
18434
18593
|
delete_keyframe(tool_name, input_name, time) -> {success}
|
|
18435
18594
|
get_comp_info() -> {name, tool_count, attrs}
|
|
18595
|
+
get_position(tool_name) -> {tool_name, x, y} — read a node's FlowView position
|
|
18596
|
+
set_position(tool_name, x, y) -> {success, x, y, readback} — move a node
|
|
18597
|
+
copy_tool(tool_name, name?, x?, y?) -> {success, new_tool, new_tools} — duplicate a node
|
|
18598
|
+
auto_arrange(tool_names?, direction?, spacing?, x?, y?) -> {success, arranged, count}
|
|
18599
|
+
Lay tools out in a row (direction="horizontal", default) or column ("vertical").
|
|
18436
18600
|
set_frame_range(start, end) -> {success}
|
|
18437
18601
|
render() -> {success}
|
|
18438
18602
|
start_undo(name?) -> {success}
|
|
@@ -18737,6 +18901,127 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18737
18901
|
comp.EndUndo(p.get("keep", True))
|
|
18738
18902
|
return _ok()
|
|
18739
18903
|
|
|
18904
|
+
# --- Node Layout (FlowView) ---
|
|
18905
|
+
elif action == "get_position":
|
|
18906
|
+
tool = comp.FindTool(p["tool_name"])
|
|
18907
|
+
if not tool:
|
|
18908
|
+
return _err(f"Tool '{p['tool_name']}' not found")
|
|
18909
|
+
flow = _fusion_flow_view(comp)
|
|
18910
|
+
if flow is None:
|
|
18911
|
+
return _err("FlowView unavailable on this comp")
|
|
18912
|
+
pos = _parse_pos(flow.GetPosTable(tool))
|
|
18913
|
+
if pos is None:
|
|
18914
|
+
return _err(f"Could not read position for tool '{p['tool_name']}'")
|
|
18915
|
+
return {"tool_name": p["tool_name"], "x": pos[0], "y": pos[1]}
|
|
18916
|
+
|
|
18917
|
+
elif action == "set_position":
|
|
18918
|
+
if "x" not in p or "y" not in p:
|
|
18919
|
+
return _err("set_position requires x and y")
|
|
18920
|
+
tool = comp.FindTool(p["tool_name"])
|
|
18921
|
+
if not tool:
|
|
18922
|
+
return _err(f"Tool '{p['tool_name']}' not found")
|
|
18923
|
+
flow = _fusion_flow_view(comp)
|
|
18924
|
+
if flow is None:
|
|
18925
|
+
return _err("FlowView unavailable on this comp")
|
|
18926
|
+
x, y = float(p["x"]), float(p["y"])
|
|
18927
|
+
comp.Lock()
|
|
18928
|
+
try:
|
|
18929
|
+
flow.SetPos(tool, x, y)
|
|
18930
|
+
finally:
|
|
18931
|
+
comp.Unlock()
|
|
18932
|
+
# SetPos has no reliable return; confirm by reading the position back.
|
|
18933
|
+
pos = _parse_pos(flow.GetPosTable(tool))
|
|
18934
|
+
return {
|
|
18935
|
+
"success": True,
|
|
18936
|
+
"tool_name": p["tool_name"],
|
|
18937
|
+
"x": x,
|
|
18938
|
+
"y": y,
|
|
18939
|
+
"readback": {"x": pos[0], "y": pos[1]} if pos else None,
|
|
18940
|
+
}
|
|
18941
|
+
|
|
18942
|
+
elif action == "copy_tool":
|
|
18943
|
+
src = comp.FindTool(p["tool_name"])
|
|
18944
|
+
if not src:
|
|
18945
|
+
return _err(f"Tool '{p['tool_name']}' not found")
|
|
18946
|
+
regid = src.GetAttrs().get("TOOLS_RegID")
|
|
18947
|
+
if not regid:
|
|
18948
|
+
return _err(f"Could not determine the tool type of '{p['tool_name']}'")
|
|
18949
|
+
# Duplicate by adding a same-type tool and loading the source's settings
|
|
18950
|
+
# through a temp .setting FILE. Passing SaveSettings()'s in-memory table
|
|
18951
|
+
# back into Paste/LoadSettings fails across the Python bridge; a file path
|
|
18952
|
+
# round-trips reliably. New node is found by diffing tool names.
|
|
18953
|
+
before = set(_fusion_tool_names(comp))
|
|
18954
|
+
fd, settings_path = tempfile.mkstemp(suffix=".setting")
|
|
18955
|
+
os.close(fd)
|
|
18956
|
+
new_tool = None
|
|
18957
|
+
comp.Lock()
|
|
18958
|
+
try:
|
|
18959
|
+
if not src.SaveSettings(settings_path):
|
|
18960
|
+
return _err(f"SaveSettings failed for '{p['tool_name']}'")
|
|
18961
|
+
new_tool = comp.AddTool(regid, -32768, -32768)
|
|
18962
|
+
if not new_tool:
|
|
18963
|
+
return _err(f"Failed to create a duplicate of '{p['tool_name']}'")
|
|
18964
|
+
new_tool.LoadSettings(settings_path)
|
|
18965
|
+
finally:
|
|
18966
|
+
comp.Unlock()
|
|
18967
|
+
try:
|
|
18968
|
+
os.remove(settings_path)
|
|
18969
|
+
except OSError:
|
|
18970
|
+
pass
|
|
18971
|
+
new_names = [n for n in _fusion_tool_names(comp) if n not in before]
|
|
18972
|
+
new_name = new_names[0] if new_names else new_tool.GetAttrs().get("TOOLS_Name", "")
|
|
18973
|
+
rename = p.get("name")
|
|
18974
|
+
if rename:
|
|
18975
|
+
comp.Lock()
|
|
18976
|
+
try:
|
|
18977
|
+
new_tool.SetAttrs({"TOOLS_Name": rename})
|
|
18978
|
+
finally:
|
|
18979
|
+
comp.Unlock()
|
|
18980
|
+
new_name = rename
|
|
18981
|
+
if "x" in p and "y" in p:
|
|
18982
|
+
flow = _fusion_flow_view(comp)
|
|
18983
|
+
if flow is not None:
|
|
18984
|
+
comp.Lock()
|
|
18985
|
+
try:
|
|
18986
|
+
flow.SetPos(new_tool, float(p["x"]), float(p["y"]))
|
|
18987
|
+
finally:
|
|
18988
|
+
comp.Unlock()
|
|
18989
|
+
return {
|
|
18990
|
+
"success": True,
|
|
18991
|
+
"source": p["tool_name"],
|
|
18992
|
+
"new_tool": new_name,
|
|
18993
|
+
"new_tools": new_names,
|
|
18994
|
+
}
|
|
18995
|
+
|
|
18996
|
+
elif action == "auto_arrange":
|
|
18997
|
+
flow = _fusion_flow_view(comp)
|
|
18998
|
+
if flow is None:
|
|
18999
|
+
return _err("FlowView unavailable on this comp")
|
|
19000
|
+
names = p.get("tool_names")
|
|
19001
|
+
if names:
|
|
19002
|
+
tools = [(n, comp.FindTool(n)) for n in names]
|
|
19003
|
+
tools = [(n, t) for n, t in tools if t]
|
|
19004
|
+
else:
|
|
19005
|
+
tools = list(_iter_fusion_tools(comp))
|
|
19006
|
+
if not tools:
|
|
19007
|
+
return _err("No tools to arrange")
|
|
19008
|
+
direction = (p.get("direction") or "horizontal").strip().lower()
|
|
19009
|
+
spacing = float(p.get("spacing", 2.0))
|
|
19010
|
+
x0, y0 = float(p.get("x", 0.0)), float(p.get("y", 0.0))
|
|
19011
|
+
arranged = []
|
|
19012
|
+
comp.Lock()
|
|
19013
|
+
try:
|
|
19014
|
+
for i, (name, tool) in enumerate(tools):
|
|
19015
|
+
if direction == "vertical":
|
|
19016
|
+
x, y = x0, y0 + i * spacing
|
|
19017
|
+
else:
|
|
19018
|
+
x, y = x0 + i * spacing, y0
|
|
19019
|
+
flow.SetPos(tool, x, y)
|
|
19020
|
+
arranged.append({"tool_name": name, "x": x, "y": y})
|
|
19021
|
+
finally:
|
|
19022
|
+
comp.Unlock()
|
|
19023
|
+
return {"success": True, "arranged": arranged, "count": len(arranged)}
|
|
19024
|
+
|
|
18740
19025
|
return _unknown(action, [
|
|
18741
19026
|
"add_tool","delete_tool","get_tool_list","find_tool",
|
|
18742
19027
|
"connect","disconnect","get_inputs","get_outputs",
|
|
@@ -18744,6 +19029,7 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18744
19029
|
"add_keyframe","get_keyframes","delete_keyframe",
|
|
18745
19030
|
"get_comp_info","set_frame_range","render",
|
|
18746
19031
|
"start_undo","end_undo",
|
|
19032
|
+
"get_position","set_position","copy_tool","auto_arrange",
|
|
18747
19033
|
"bulk_set_inputs",
|
|
18748
19034
|
"bulk_set_expressions",
|
|
18749
19035
|
*_FUSION_GROUP_KERNEL_ACTIONS,
|
|
@@ -18782,7 +19068,8 @@ def _validate_lua_syntax(source: str) -> Dict[str, Any]:
|
|
|
18782
19068
|
luac = None
|
|
18783
19069
|
for candidate in ("luac", "luac5.1", "luac5.3", "luac5.4"):
|
|
18784
19070
|
try:
|
|
18785
|
-
subprocess.run([candidate, "-v"], capture_output=True, check=True, timeout=5
|
|
19071
|
+
subprocess.run([candidate, "-v"], capture_output=True, check=True, timeout=5,
|
|
19072
|
+
stdin=subprocess.DEVNULL)
|
|
18786
19073
|
luac = candidate
|
|
18787
19074
|
break
|
|
18788
19075
|
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
@@ -18794,7 +19081,8 @@ def _validate_lua_syntax(source: str) -> Dict[str, Any]:
|
|
|
18794
19081
|
f.write(source)
|
|
18795
19082
|
tmp = f.name
|
|
18796
19083
|
try:
|
|
18797
|
-
result = subprocess.run([luac, "-p", tmp], capture_output=True, text=True, timeout=10
|
|
19084
|
+
result = subprocess.run([luac, "-p", tmp], capture_output=True, text=True, timeout=10,
|
|
19085
|
+
stdin=subprocess.DEVNULL)
|
|
18798
19086
|
if result.returncode == 0:
|
|
18799
19087
|
return {"valid": True, "errors": None, "checker": luac}
|
|
18800
19088
|
return {"valid": False, "errors": result.stderr.strip() or result.stdout.strip(),
|
|
@@ -19352,7 +19640,7 @@ def _execute_python_script(path: str, args: List[str],
|
|
|
19352
19640
|
try:
|
|
19353
19641
|
result = subprocess.run(cmd, env=_python_env_for_resolve(),
|
|
19354
19642
|
capture_output=True, text=True,
|
|
19355
|
-
timeout=timeout)
|
|
19643
|
+
timeout=timeout, stdin=subprocess.DEVNULL)
|
|
19356
19644
|
except subprocess.TimeoutExpired as e:
|
|
19357
19645
|
return _err(f"Script timed out after {timeout}s. "
|
|
19358
19646
|
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
|
|