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 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
- [![Version](https://img.shields.io/badge/version-2.32.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.33.0-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
@@ -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.32.1"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.32.1",
3
+ "version": "2.33.0",
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.1"
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 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.1"
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
- settings = _normalize_auto_sync_settings(dict(p.get("settings") or {}), resolve)
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(hook.command, shell=True, timeout=timeout)
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
- return {"success": bool(proj.ExportCurrentFrameAsStill(p["path"]))}
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 t in kfs:
18692
- keyframes.append({"time": t, "value": _ser(kfs[t])})
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
- 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