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 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
- [![Version](https://img.shields.io/badge/version-2.32.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.33.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-32%20(341%20full)-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?)`, `perform_audio_classification`,
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.32.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.32.2",
3
+ "version": "2.33.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.32.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 get_all_media_pool_clips(media_pool):
384
- """Get all clips from media pool recursively including subfolders."""
385
- clips = []
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
- def process_folder(folder):
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
- clips.extend(folder_clips)
392
-
402
+ for clip in folder_clips:
403
+ yield clip
393
404
  sub_folders = folder.GetSubFolderList()
394
- for sub_folder in sub_folders:
395
- process_folder(sub_folder)
396
-
397
- process_folder(root_folder)
398
- return clips
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 = get_all_media_pool_clips(media_pool)
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 = get_all_media_pool_clips(media_pool)
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 = get_all_media_pool_clips(media_pool)
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 = get_all_media_pool_clips(media_pool)
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 = get_all_media_pool_clips(media_pool)
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.32.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
- settings = _normalize_auto_sync_settings(dict(p.get("settings") or {}), resolve)
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
- return {"success": bool(mp.AutoSyncAudio(clips, settings)), "count": len(clips), "missing": missing, "settings": settings}
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(hook.command, shell=True, timeout=timeout)
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
- return {"success": bool(proj.ExportCurrentFrameAsStill(p["path"]))}
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
- def get_object_methods(obj: Any) -> Dict[str, Dict[str, Any]]:
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
- Get all methods of a DaVinci Resolve object with their documentation.
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 dictionary of method names and their details
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
- # Get all object attributes
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
- # Check if it's a callable method
42
- if callable(attr):
43
- # Get the method signature if possible
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
- # Get the docstring if available
50
- doc = inspect.getdoc(attr) or ""
51
-
52
- methods[attr_name] = {
53
- "signature": signature,
54
- "doc": doc,
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
- "error": str(e),
60
- "type": "error"
80
+ "signature": signature,
81
+ "doc": doc,
82
+ "type": "method",
61
83
  }
62
-
63
- return methods
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
- Args:
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
- if obj is None:
77
- return {"error": "Cannot inspect None object"}
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": get_object_methods(obj),
126
- "properties": get_object_properties(obj),
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
- methods = get_object_methods(obj)
256
- properties = get_object_properties(obj)
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