davinci-resolve-mcp 4.8.23 → 4.8.25

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,107 @@
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 v4.8.25 — open_settings and open_app_preferences say what Resolve cannot do
6
+
7
+ ### Fixed
8
+
9
+ - **The granular `open_settings` and `open_app_preferences` tools could never
10
+ work, and reported that as an ordinary failure.** Both went through
11
+ `Resolve.GetUIManager()`, which does not exist. Measured on Studio 19.1.3.7:
12
+ `dir(resolve)` lists 23 methods and `GetUIManager` is not one of them;
13
+ `Fusion().UIManager` is real but has neither `OpenProjectSettings` nor
14
+ `OpenPreferences`; and none of the three names appears in the 21.1 typed API.
15
+ The call raised `'NoneType' object is not callable`, a broad `except`
16
+ swallowed it, an ERROR was logged, and the tool answered
17
+ `Failed to open Project Settings dialog` with no reason.
18
+ Both tools now answer `Not supported:` and name the call that is missing;
19
+ `open_settings` also names the tools that read and write project settings.
20
+ Nothing is logged as an error, because nothing went wrong.
21
+ - The route is now probed with `has_method` rather than `hasattr`, which is
22
+ true for every name on a Resolve object. If a future build does provide these
23
+ calls they are used, and their result is reported: the old code discarded the
24
+ return and answered success regardless, so a refusal would have read as a
25
+ dialog that opened.
26
+
27
+ ### Documentation
28
+
29
+ - `api_truth` records the absence, with what `Fusion().UIManager` does expose.
30
+ Whether `UIManager.DoAction` or `QueueAction` can open these dialogs was not
31
+ tried: both dialogs are modal, and a modal dialog blocks the scripting API
32
+ until a person closes it.
33
+ - `scripts/audit_api_parity.py` no longer describes `GetUIManager` as a
34
+ documented API.
35
+
36
+ ### Tests
37
+
38
+ - `tests/test_app_control_dialogs.py`: a fake that fabricates attributes the way
39
+ a Resolve object does (every `hasattr` true, a missing `getattr` is `None`).
40
+ Covers the measured build, a manager without the method, a build that has
41
+ the call, a refusal, an exception, and both granular tools. Eight of its
42
+ twelve tests fail against v4.8.24.
43
+ - `tests/test_discarded_resolve_returns.py` now covers every `Open*` call, not
44
+ only `OpenPage`.
45
+
46
+ ## What's New in v4.8.24 — a frame capture leaves the render output folder and file name alone
47
+
48
+ ### Fixed
49
+
50
+ - **`timeline_frame` capture left the project's render output folder and file
51
+ name on its own temporary values.** The render route (`quality="frame"`,
52
+ `"preview"`, `"full"`) wrote `TargetDir` and `CustomName` and put neither
53
+ back, because there is no `GetRenderSettings` to read them from. The user's
54
+ next render job inherited a temporary folder the capture had already deleted
55
+ and a name like `capture-<timestamp>`. v4.8.23 documented this; this release
56
+ fixes it.
57
+ - **Output folder (`TargetDir`).** A queued render job carries the settings it
58
+ inherited, so the capture queues a throwaway job, reads `TargetDir` off its
59
+ `GetRenderJobList` entry, deletes the job, and writes the folder back
60
+ afterwards. A folder that is not put back, or a throwaway job that cannot
61
+ be removed, is reported in the capture's `warnings` block.
62
+ - **File name (`CustomName`).** It is no longer written at all. It can be
63
+ neither read back nor cleared (an empty one is refused), so the capture
64
+ renders under whatever name the project already produces, into a private
65
+ folder of its own, and takes the one file that appears there.
66
+ - Live on Studio 19.1.3.7, with a `.mov` format, an output folder and a custom
67
+ name set: a job queued after each of 15 captures inherited the same folder,
68
+ file name, range and format as one queued before, and the render queue was
69
+ left empty.
70
+ - **One gap remains, and it is stated rather than hidden.** A project that has
71
+ never had an output folder has none to read (`AddRenderJob` returns `''`),
72
+ and Resolve cannot clear one once set, so such a project is left with the
73
+ capture's temporary folder as its `TargetDir`. `timeline_frame capabilities`
74
+ reports this as `render_settings_caveat`.
75
+ - The captured frame can no longer be confused with, or delete, another file in
76
+ the shared capture folder. Each capture renders into its own subfolder; the
77
+ shared one (`~/Documents/resolve-stills` on macOS) is only removed when empty.
78
+
79
+ ### Changed
80
+
81
+ - `timeline_frame capabilities`: `render_settings_restorable.TargetDir` and
82
+ `.CustomName` are now `true`, and `render_settings_caveat` is new.
83
+ - A render capture takes about 0.2 s longer (measured: roughly 1.3 s against
84
+ 1.1 s), which is the throwaway job used to read the output folder.
85
+
86
+ ### Documentation
87
+
88
+ - `api_truth` gains `Project.AddRenderJob (the only readback for render
89
+ settings)`, measured on Studio 19.1.3.7: what a job entry exposes, that
90
+ `TargetDir` cannot be cleared, when `AddRenderJob` returns `''`, and that
91
+ duplicate jobs and existing output files raise no dialog.
92
+ `docs/reference/api-limitations.md` is regenerated.
93
+
94
+ ### Tests
95
+
96
+ - `tests/test_playhead_frame_capture.py`: the render fake now models the job
97
+ queue as the readback it is. `CaptureOutputSettingsTest` covers the folder
98
+ coming back, the name never being written, a project with no output folder, a
99
+ same-named file already in the shared folder, the read happening in
100
+ single-clip mode before anything changes, and both failure reports. Six of
101
+ its ten tests fail against v4.8.23.
102
+ - `tests/live_frame_capture_page_restore_validation.py` now gives the disposable
103
+ project a user's render settings and compares what a job inherits before and
104
+ after every capture, including one from Individual-clips mode.
105
+
5
106
  ## What's New in v4.8.23 — a frame capture no longer leaves Resolve on the Deliver page
6
107
 
7
108
  ### Fixed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.8.23-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.25-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(389%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.8.23-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.25-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(389%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v4.8.23 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.8.25 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/docs/SKILL.md CHANGED
@@ -1860,17 +1860,18 @@ metadata. (For the raw camera file instead, use
1860
1860
  `frame` is the absolute timeline frame. Omit both to capture the playhead.
1861
1861
 
1862
1862
  The playhead, page, current timeline and Gallery are restored. The render route
1863
- additionally touches project render settings: render mode, format, codec and the
1864
- mark range are restored and the render job is deleted. `TargetDir` and
1865
- `CustomName` cannot be read back (there is no `GetRenderSettings`), so they are
1866
- not restored: they stay on the capture's temporary folder and name, and
1867
- `capabilities` reports them under `render_settings_restorable`. Set your own
1868
- before the next render. Reach for `quality="thumbnail"` when zero side effects
1869
- matter more than accuracy.
1863
+ additionally touches project render settings and puts them back: render mode,
1864
+ format, codec, mark range and output folder (`TargetDir`, read off a throwaway
1865
+ render job because there is no `GetRenderSettings`). The file name
1866
+ (`CustomName`) is never written, and the render job is deleted. One gap: a
1867
+ project that has never had an output folder has none to put back, and Resolve
1868
+ cannot clear one, so it is left on the capture's temporary folder
1869
+ (`capabilities` repeats this as `render_settings_caveat`). Reach for
1870
+ `quality="thumbnail"` when zero side effects matter more than accuracy.
1870
1871
 
1871
1872
  The render calls pull Resolve onto the Deliver page. The capture switches back
1872
1873
  and reads the page to confirm it. If a restore does not take (page, playhead,
1873
- timeline, render mode, format or range), the image is followed by a
1874
+ timeline, render mode, format, range or output folder), the image is followed by a
1874
1875
  `{"warnings": [...]}` block naming what was left changed and the call that puts
1875
1876
  it back. No warnings block means every restore was confirmed.
1876
1877
 
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.2
14
14
 
15
- **Totals:** 41 missing capabilities, 56 bugs / unreliable behaviors.
15
+ **Totals:** 42 missing capabilities, 56 bugs / unreliable behaviors.
16
16
 
17
17
  The authoritative source is the runtime-queryable `api_truth` ledger
18
18
  (`resolve_control api_truth "<query>"`); this document is generated from
@@ -255,6 +255,15 @@ equivalent, blocking full automation.
255
255
  - **Workaround / current handling:** Check GetRenderCodecs(format) first; when it is empty, treat the format as unreachable through this API rather than guessing a codec value. Render audio-only via ExportVideo=False on a format that does expose codecs, or drive it from a saved render preset.
256
256
  - **Tags:** render, deliver, audio, unsupported
257
257
 
258
+ ### Project.AddRenderJob (the only readback for render settings)
259
+
260
+ - **Object:** `Project`
261
+ - **Signature:** `() -> str`
262
+ - **Behavior:** There is no GetRenderSettings, but a queued job carries the settings it inherited. Measured 2026-09-30 on Studio 19.1.3.7: after AddRenderJob, the matching GetRenderJobList entry reports TargetDir, OutputFilename (the custom name, or the timeline name when none was ever set, plus the format's extension), MarkIn/MarkOut, VideoFormat/VideoCodec, RenderMode and PresetName, and DeleteRenderJob removes it. The round trip took about 150 ms and switches Resolve to the Deliver page. Queuing two identical jobs, or a job whose output file already exists, raised no dialog and returned distinct ids. Limits: AddRenderJob returns '' when no TargetDir has ever been set, and also in Individual-clips mode on a generator-only timeline, so neither state can be read this way. Once set, TargetDir cannot be cleared — SetRenderSettings returns False for '' and for None — though a TargetDir that does not exist is accepted. CustomName has no direct readback: it is only visible folded into OutputFilename.
263
+ - **Workaround / current handling:** To preserve a user's output folder across work that has to change it: in single-clip mode, queue a job, read TargetDir off its entry, delete the job, and write TargetDir back afterwards. Leave CustomName alone wherever possible — it can be neither read nor cleared; render into a private folder and take the file that appears instead of naming it.
264
+ - **Reference:** [issue #270](https://github.com/samuelgursky/davinci-resolve-mcp/issues/270)
265
+ - **Tags:** render, deliver, readback, unsupported
266
+
258
267
  ### TimelineItem.SetCDL (write-only — no GetCDL anywhere)
259
268
 
260
269
  - **Object:** `TimelineItem`
@@ -703,7 +712,7 @@ values, or automation-hostile modal prompts.
703
712
  - **Object:** `Project`
704
713
  - **Signature:** `({settings}) -> bool`
705
714
  - **Behavior:** SetRenderSettings returns False for {'CustomName': ''} and for {'CustomName': None}, and when that key rides in a larger payload the WHOLE payload is rejected, not just the name. Measured 2026-09-30 on Studio 19.1.3.7: with the render range pinned to one frame, {SelectAllFrames: True, MarkIn: start, MarkOut: end, CustomName: ''} returned False and a job added afterwards still carried MarkIn == MarkOut == the pinned frame; the same payload without CustomName returned True and the job carried the whole timeline. A single space IS accepted, and becomes the file name. So a custom name, once set, cannot be cleared through this API, and there is no GetRenderSettings to read the previous one back from.
706
- - **Workaround / current handling:** Never send an empty CustomName, and never bundle a best-effort key with keys that matter: send the range in its own payload and check its return. To see what a job will inherit, AddRenderJob, read MarkIn/MarkOut/TargetDir/OutputFilename off GetRenderJobList, then DeleteRenderJob.
715
+ - **Workaround / current handling:** Never send an empty CustomName, and never bundle a best-effort key with keys that matter: send each setting in its own payload and check its return. Better, do not write CustomName at all when the name is not yours to keep. To see what a job will inherit, AddRenderJob, read MarkIn/MarkOut/TargetDir/OutputFilename off GetRenderJobList, then DeleteRenderJob.
707
716
  - **Reference:** [issue #270](https://github.com/samuelgursky/davinci-resolve-mcp/issues/270)
708
717
  - **Tags:** render, deliver, silent-failure, unreliable-return
709
718
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "4.8.23"
40
+ VERSION = "4.8.25"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (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": "4.8.23",
3
+ "version": "4.8.25",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -145,10 +145,13 @@ ALLOWLIST_UNDOCUMENTED: Set[str] = {
145
145
  "AddKeyframe", "DeleteKeyframe", "ModifyKeyframe", "RemoveKeyFrame",
146
146
  "GetKeyframeAtIndex", "GetKeyframeCount", "GetPropertyAtKeyframeIndex",
147
147
  "SetKeyframeInterpolation", "Render", "StartUndo",
148
- # UIManager / Resolve app-control API (documented under UIManager, not
149
- # the main Resolve scripting README)
150
- "GetUIManager", "OpenPreferences", "SetHighPriority",
151
- "OpenProjectSettings", "LoadUILayout", "SaveUILayout",
148
+ # Resolve app-control method, not in the scripting README this audit parses
149
+ "SetHighPriority",
150
+ # NOT a documented API: Resolve has no GetUIManager on any build measured
151
+ # (Studio 19.1.3.7; api_truth 'Resolve.GetUIManager ...'). It is called
152
+ # only behind has_method in src/utils/app_control.py, and from the unused
153
+ # helpers in src/utils/layout_presets.py along with the other two.
154
+ "GetUIManager", "LoadUILayout", "SaveUILayout",
152
155
  # Lua-table iteration helper used as a fallback in object_inspection.py
153
156
  "GetKeyList",
154
157
  # Project metadata accessor used defensively (hasattr-guarded)
@@ -93,7 +93,7 @@ if not logging.getLogger().handlers:
93
93
  handlers=[logging.StreamHandler()],
94
94
  )
95
95
 
96
- VERSION = "4.8.23"
96
+ VERSION = "4.8.25"
97
97
  logger = logging.getLogger("davinci-resolve-mcp")
98
98
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
99
99
  logger.info(f"Detected platform: {get_platform()}")
@@ -371,32 +371,45 @@ def restart_app(wait_seconds: int = 5) -> str:
371
371
 
372
372
  @mcp.tool()
373
373
  def open_settings() -> str:
374
- """Open the Project Settings dialog in DaVinci Resolve."""
374
+ """Open the Project Settings dialog in DaVinci Resolve.
375
+
376
+ Not available on any Resolve build measured so far: the scripting API has
377
+ no call that opens this dialog (Studio 19.1.3.7 measured; absent from the
378
+ 21.1 typed API). The reply says so by name instead of a bare failure. To
379
+ read or change project settings use get_project_settings,
380
+ get_project_setting and set_project_setting.
381
+ """
375
382
  resolve = get_resolve()
376
383
  if resolve is None:
377
384
  return "Error: Not connected to DaVinci Resolve"
378
-
385
+
379
386
  result = open_project_settings(resolve)
380
-
381
- if result:
387
+ if result["success"]:
382
388
  return "Project Settings dialog opened successfully"
383
- else:
384
- return "Failed to open Project Settings dialog"
389
+ if not result["supported"]:
390
+ return (
391
+ result["message"]
392
+ + " Use get_project_settings, get_project_setting and set_project_setting instead."
393
+ )
394
+ return result["message"]
385
395
 
386
396
 
387
397
  @mcp.tool()
388
398
  def open_app_preferences() -> str:
389
- """Open the Preferences dialog in DaVinci Resolve."""
399
+ """Open the Preferences dialog in DaVinci Resolve.
400
+
401
+ Not available on any Resolve build measured so far: the scripting API has
402
+ no call that opens this dialog (Studio 19.1.3.7 measured; absent from the
403
+ 21.1 typed API). The reply says so by name instead of a bare failure.
404
+ """
390
405
  resolve = get_resolve()
391
406
  if resolve is None:
392
407
  return "Error: Not connected to DaVinci Resolve"
393
-
408
+
394
409
  result = open_preferences(resolve)
395
-
396
- if result:
410
+ if result["success"]:
397
411
  return "Preferences dialog opened successfully"
398
- else:
399
- return "Failed to open Preferences dialog"
412
+ return result["message"]
400
413
 
401
414
 
402
415
  @mcp.tool()
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "4.8.23"
14
+ VERSION = "4.8.25"
15
15
 
16
16
  import base64
17
17
  import os
@@ -14977,6 +14977,47 @@ def _render_job_completed(status: Optional[Dict[str, Any]]) -> bool:
14977
14977
  return False
14978
14978
 
14979
14979
 
14980
+ def _render_target_dir(proj, teardown: List[str]) -> Optional[str]:
14981
+ """The project's current render TargetDir, read off a throwaway render job.
14982
+
14983
+ There is no GetRenderSettings, but a queued job carries the settings it
14984
+ inherited: queue one, read TargetDir off its GetRenderJobList entry, delete
14985
+ it. Measured on Studio 19.1.3.7 (api_truth 'Project.AddRenderJob (the only
14986
+ readback for render settings)'): about 150 ms, no dialog even when an
14987
+ identical job is already queued or the output file already exists.
14988
+
14989
+ Returns None when no job can be queued. That is what a project with no
14990
+ render target does (AddRenderJob returns ''), and then there is nothing to
14991
+ put back. A job that was queued and could not be removed again is reported
14992
+ through `teardown`: it is the one thing this read can leave behind.
14993
+ """
14994
+ try:
14995
+ job = proj.AddRenderJob()
14996
+ except Exception:
14997
+ return None
14998
+ if not job:
14999
+ return None
15000
+ target = None
15001
+ try:
15002
+ for entry in proj.GetRenderJobList() or []:
15003
+ if isinstance(entry, dict) and entry.get("JobId") == job:
15004
+ target = entry.get("TargetDir")
15005
+ break
15006
+ except Exception:
15007
+ target = None
15008
+ try:
15009
+ removed = bool(proj.DeleteRenderJob(job))
15010
+ except Exception:
15011
+ removed = False
15012
+ if not removed:
15013
+ logger.warning("frame capture could not remove its throwaway render job %s", job)
15014
+ teardown.append(
15015
+ f"A render job ({job}) queued only to read the output folder could not be "
15016
+ "removed from the render queue. Delete it with render(action='delete_job')."
15017
+ )
15018
+ return target if isinstance(target, str) and target else None
15019
+
15020
+
14980
15021
  def _playhead_frame_render(proj, tl, p: Dict[str, Any],
14981
15022
  teardown: Optional[List[str]] = None):
14982
15023
  """Render exactly one frame — the only frame-accurate capture route.
@@ -14991,24 +15032,30 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
14991
15032
  runs in well under a second, and needs no GUI panel or foreground window.
14992
15033
 
14993
15034
  The cost is that render settings are project-level state, and there is still
14994
- no GetRenderSettings to read them back from (absent as of 21.1). Three
14995
- different things happen on the way out:
15035
+ no GetRenderSettings to read them back from (absent as of 21.1). Each one
15036
+ the capture touches is put back by whatever route exists:
14996
15037
  - Format and codec are readable via GetCurrentRenderFormatAndCodec and are
14997
15038
  genuinely restored.
14998
15039
  - The mark range is readable via Timeline.GetMarkInOut, so a range the
14999
15040
  caller had set is put back (offset into SetRenderSettings' absolute
15000
15041
  frame space); with no range set it falls back to the whole timeline.
15001
- - TargetDir and CustomName are readable from nowhere, so they are NOT
15002
- restored: they stay on the capture's temporary folder and name. An
15003
- empty CustomName cannot be written back either (refused on 19.1.3.7).
15042
+ - TargetDir is read off a throwaway render job before the capture changes
15043
+ it (_render_target_dir) and written back afterwards. A project that
15044
+ has never had a render target has nothing to read, and Resolve cannot
15045
+ clear one once set, so there it is left on the capture's folder.
15046
+ - CustomName is never written. It can be neither read nor cleared (an
15047
+ empty one is refused on 19.1.3.7), so the capture renders under
15048
+ whatever name the project already produces, into a folder of its own,
15049
+ and takes the one file that appears there.
15004
15050
  Callers who need a strictly side-effect-free read should use
15005
15051
  quality="thumbnail" and accept per-clip granularity.
15006
15052
 
15007
15053
  `teardown` collects one sentence per restore that did not take (render
15008
- mode, format/codec, render range, playhead, page). The restores run in a
15009
- `finally`, which cannot change the value already being returned, so the
15010
- caller passes the list in and attaches it to the result afterwards (issue
15011
- #270: the user was left on Deliver with nothing in the result to say so).
15054
+ mode, format/codec, render range, output folder, playhead, page). The
15055
+ restores run in a `finally`, which cannot change the value already being
15056
+ returned, so the caller passes the list in and attaches it to the result
15057
+ afterwards (issue #270: the user was left on Deliver with nothing in the
15058
+ result to say so).
15012
15059
  """
15013
15060
  if teardown is None:
15014
15061
  teardown = []
@@ -15050,9 +15097,13 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15050
15097
  except (TypeError, ValueError):
15051
15098
  return _err("frame must be an integer", code="INVALID_FRAME", category="invalid_input")
15052
15099
 
15053
- folder = _resolve_safe_dir(os.path.join(tempfile.gettempdir(), "resolve-frame-captures"))
15100
+ # A directory of this capture's own, inside the shared capture folder. The
15101
+ # render is NOT given a CustomName (see the docstring), so the file comes
15102
+ # out under the project's own naming and cannot be picked out of a shared
15103
+ # folder by prefix; whatever appears in here is the frame.
15104
+ base = _resolve_safe_dir(os.path.join(tempfile.gettempdir(), "resolve-frame-captures"))
15105
+ folder = os.path.join(base, f"{STILL_STAGING_PREFIX}{_uuid.uuid4().hex}")
15054
15106
  os.makedirs(folder, exist_ok=True)
15055
- name = f"capture-{int(time.time() * 1000)}"
15056
15107
 
15057
15108
  # Where the user is comes FIRST, before any render call. Issue #270: this
15058
15109
  # was read after GetCurrentRenderMode(), and that getter itself switches
@@ -15078,9 +15129,9 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15078
15129
  except Exception:
15079
15130
  pass
15080
15131
  # Render MODE is project state too, and it decides whether the capture can
15081
- # work at all. In "Individual clips" mode (0) Resolve ignores CustomName,
15082
- # renders the WHOLE clip under the frame's own file naming, and the
15083
- # single-frame file this helper waits for never appears — measured
15132
+ # work at all. In "Individual clips" mode (0) Resolve renders the WHOLE
15133
+ # clip under its own per-clip file naming, and the single-frame file this
15134
+ # helper waits for never appears — measured
15084
15135
  # 2026-09-09 on a project whose delivery preset was per-clip: every capture
15085
15136
  # reported success, wrote no file, and took 30+ s rendering the clip.
15086
15137
  # Force single clip (1) for the capture and put the mode back afterwards.
@@ -15126,6 +15177,7 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15126
15177
 
15127
15178
  job = None
15128
15179
  range_pinned = False
15180
+ original_target = None
15129
15181
  try:
15130
15182
  if original_mode is not None and original_mode != 1:
15131
15183
  if not proj.SetCurrentRenderMode(1):
@@ -15137,6 +15189,11 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15137
15189
  remediation="render(action='set_mode', params={'mode': 1}) then retry.",
15138
15190
  state={"render_mode": original_mode},
15139
15191
  )
15192
+ # Read the output folder while the settings are still the user's, and
15193
+ # in single-clip mode: in individual-clips mode a job may not queue at
15194
+ # all (generator-only timeline, 19.1.3.7), and then there is no entry
15195
+ # to read it from.
15196
+ original_target = _render_target_dir(proj, teardown)
15140
15197
  codecs = proj.GetRenderCodecs("JPEG" if fmt == "jpg" else fmt.upper()) or {}
15141
15198
  codec = list(codecs.values())[0] if codecs else fmt
15142
15199
  if not proj.SetCurrentRenderFormatAndCodec(fmt, codec):
@@ -15147,7 +15204,6 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15147
15204
  )
15148
15205
  applied = proj.SetRenderSettings({
15149
15206
  "TargetDir": folder,
15150
- "CustomName": name,
15151
15207
  "MarkIn": frame,
15152
15208
  "MarkOut": frame,
15153
15209
  "SelectAllFrames": False,
@@ -15164,14 +15220,12 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15164
15220
  job = proj.AddRenderJob()
15165
15221
  if not job:
15166
15222
  return _err("AddRenderJob returned nothing", code="RENDER_JOB_FAILED", category="api_error")
15167
- # The folder is shared (every sandbox path redirects to one
15168
- # ~/Documents/resolve-stills) and the cleanup below removes it when it
15169
- # empties, so another capture — or anything else — can take it away
15170
- # between the makedirs above and here. Measured 2026-09-09: frame 81 of a
15171
- # 214-frame QC batch died in os.listdir on the missing folder. Recreate,
15172
- # don't assume.
15223
+ # The parent is shared (every sandbox path redirects to one
15224
+ # ~/Documents/resolve-stills) and each capture's cleanup removes it
15225
+ # when it empties. Measured 2026-09-09, when captures still shared the
15226
+ # folder itself: frame 81 of a 214-frame QC batch died in os.listdir on
15227
+ # a folder another capture had just removed. Recreate, don't assume.
15173
15228
  os.makedirs(folder, exist_ok=True)
15174
- before = set(os.listdir(folder))
15175
15229
  # Positional on purpose: the free-edition bridge proxies method calls
15176
15230
  # positionally, and a keyword argument dies inside _BoundMethod with
15177
15231
  # "unexpected keyword argument 'isInteractiveMode'" before reaching
@@ -15194,15 +15248,19 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15194
15248
  code="RENDER_FAILED", category="api_error",
15195
15249
  state={"status": status, "frame": frame},
15196
15250
  )
15197
- # Resolve appends the frame number to CustomName, so match on the prefix.
15198
- written = sorted(f for f in set(os.listdir(folder)) - before if f.startswith(name))
15251
+ # The folder is this capture's alone, so whatever is in it is the
15252
+ # frame — named by the project (custom name or timeline name, plus the
15253
+ # frame number), which is not ours to predict.
15254
+ written = sorted(
15255
+ os.path.join(root, f) for root, _dirs, files in os.walk(folder) for f in files
15256
+ )
15199
15257
  if not written:
15200
15258
  return _err(
15201
15259
  "Render reported success but wrote no file",
15202
15260
  code="RENDER_FAILED", category="api_error",
15203
15261
  state={"folder": folder, "frame": frame},
15204
15262
  )
15205
- src_path = os.path.join(folder, written[0])
15263
+ src_path = written[0]
15206
15264
  out_format = "jpg" if fmt == "jpg" else "png"
15207
15265
  if max_width or fmt == "tif":
15208
15266
  data, ff_err = _ffmpeg_scale_to_bytes(src_path, int(max_width) if max_width else None, out_format)
@@ -15257,11 +15315,10 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15257
15315
  f"({fmt}); restoring {original_fc.get('format')}/{original_fc.get('codec')} "
15258
15316
  "failed, so the next render job would inherit it."
15259
15317
  )
15260
- # Still not a full restore — GetRenderSettings does not exist, so the
15261
- # other settings cannot be read back. The mark range can: put the user's
15262
- # own range back when they had one, and fall back to the whole timeline
15263
- # when they did not, so the range is never left pinned to the captured
15264
- # frame for the next render job to inherit.
15318
+ # The mark range: put the user's own range back when they had one, and
15319
+ # fall back to the whole timeline when they did not, so the range is
15320
+ # never left pinned to the captured frame for the next render job to
15321
+ # inherit.
15265
15322
  # (original_marks is already in SetRenderSettings' absolute space — see
15266
15323
  # the offset above.)
15267
15324
  #
@@ -15270,6 +15327,7 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15270
15327
  # rejecting the WHOLE payload (measured on Studio 19.1.3.7: False, and a
15271
15328
  # job added afterwards still carried MarkIn == MarkOut == the captured
15272
15329
  # frame). So the range was never put back, and the False was discarded.
15330
+ # One setting per payload, each return checked.
15273
15331
  try:
15274
15332
  if original_marks:
15275
15333
  restored_marks = {
@@ -15298,21 +15356,28 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15298
15356
  "restoring it failed, so the next render job would render one "
15299
15357
  "frame. Set the range again before rendering."
15300
15358
  )
15301
- # Best effort, and expected to be refused on 19.1.3.7 (see above): where
15302
- # a build does accept it, the capture's name stops being inherited.
15303
- try:
15304
- proj.SetRenderSettings({"CustomName": ""})
15305
- except Exception:
15306
- pass
15359
+ # The output folder, when there was one to read. Without this the
15360
+ # user's next render job inherits a temporary folder that the cleanup
15361
+ # just below removes.
15362
+ if range_pinned and original_target:
15363
+ try:
15364
+ target_restored = bool(proj.SetRenderSettings({"TargetDir": original_target}))
15365
+ target_exc = None
15366
+ except Exception as exc:
15367
+ target_restored, target_exc = False, exc
15368
+ if not target_restored:
15369
+ logger.warning(
15370
+ "frame capture could not restore the render output folder to %s: %s",
15371
+ original_target, target_exc or "SetRenderSettings returned False",
15372
+ )
15373
+ teardown.append(
15374
+ "The render output folder was left on the capture's temporary "
15375
+ f"folder; restoring {original_target!r} failed. Set it again with "
15376
+ "render(action='set_settings', params={'settings': {'TargetDir': ...}})."
15377
+ )
15378
+ _discard_still_staging(folder)
15307
15379
  try:
15308
- for f in os.listdir(folder):
15309
- if f.startswith(name):
15310
- try:
15311
- os.remove(os.path.join(folder, f))
15312
- except OSError:
15313
- pass
15314
- if not os.listdir(folder):
15315
- os.rmdir(folder)
15380
+ os.rmdir(base) # only ever succeeds when nothing else is using it
15316
15381
  except OSError:
15317
15382
  pass
15318
15383
  if not _restore_playhead(tl, original_tc, what="the render capture"):
@@ -26709,13 +26774,13 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
26709
26774
  Choosing a quality — the trade-off is accuracy against side effects:
26710
26775
 
26711
26776
  'frame'/'preview' Frame-exact. Renders one frame, so it changes
26712
- project-level render settings. Render mode, format, codec
26713
- and the mark range are restored. TargetDir and CustomName
26714
- cannot be read back (there is no GetRenderSettings), so they
26715
- are NOT restored: they stay on the capture's temporary folder
26716
- and name, and capabilities() lists them under
26717
- render_settings_restorable. Set your own before the next
26718
- render. Refuses while another render is running.
26777
+ project-level render settings, and puts them back: render
26778
+ mode, format, codec, mark range and output folder
26779
+ (TargetDir). The file name (CustomName) is never touched.
26780
+ One exception: a project that has never had an output
26781
+ folder has none to put back, and Resolve cannot clear one,
26782
+ so it is left on the capture's temporary folder. Refuses
26783
+ while another render is running.
26719
26784
  'thumbnail' Changes nothing and returns instantly, but it is NOT frame
26720
26785
  accurate: GetCurrentClipThumbnailImage returns the clip's
26721
26786
  thumbnail, identical for every frame of that clip (measured
@@ -26751,16 +26816,21 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
26751
26816
  "ffmpeg": bool(shutil.which("ffmpeg")),
26752
26817
  "max_width_supported": bool(shutil.which("ffmpeg")),
26753
26818
  "current_page": current_page,
26754
- # What the render route ('frame'/'preview') puts back afterwards.
26755
- # False means left on the capture's own value, because Resolve
26756
- # offers no way to read the original: there is no GetRenderSettings.
26819
+ # What the render route ('frame'/'preview') leaves as it found it.
26820
+ # TargetDir is read off a throwaway render job and written back;
26821
+ # CustomName is never written. The one gap is a project with no
26822
+ # TargetDir yet: there is none to read and Resolve cannot clear it.
26757
26823
  "render_settings_restorable": {
26758
26824
  "render_mode": True,
26759
26825
  "format_codec": True,
26760
26826
  "mark_range": True,
26761
- "TargetDir": False,
26762
- "CustomName": False,
26827
+ "TargetDir": True,
26828
+ "CustomName": True,
26763
26829
  },
26830
+ "render_settings_caveat": (
26831
+ "A project that has never had a render TargetDir is left with the "
26832
+ "capture's temporary folder as its TargetDir."
26833
+ ),
26764
26834
  }
26765
26835
  _, tl, err = _get_tl()
26766
26836
  if err:
@@ -2702,16 +2702,81 @@ API_TRUTH: List[Dict[str, Any]] = [
2702
2702
  "set, cannot be cleared through this API, and there is no "
2703
2703
  "GetRenderSettings to read the previous one back from.",
2704
2704
  "recommended": "Never send an empty CustomName, and never bundle a "
2705
- "best-effort key with keys that matter: send the range in "
2706
- "its own payload and check its return. To see what a job "
2707
- "will inherit, AddRenderJob, read MarkIn/MarkOut/TargetDir/"
2708
- "OutputFilename off GetRenderJobList, then DeleteRenderJob.",
2705
+ "best-effort key with keys that matter: send each setting "
2706
+ "in its own payload and check its return. Better, do not "
2707
+ "write CustomName at all when the name is not yours to "
2708
+ "keep. To see what a job will inherit, AddRenderJob, read "
2709
+ "MarkIn/MarkOut/TargetDir/OutputFilename off "
2710
+ "GetRenderJobList, then DeleteRenderJob.",
2709
2711
  "tags": ["render", "deliver", "silent-failure", "unreliable-return"],
2710
2712
  "submit": "bug",
2711
2713
  "issue": 270,
2712
2714
  "verified_on": "DaVinci Resolve Studio 19.1.3.7",
2713
2715
  "mitigation": ["_playhead_frame_render"],
2714
2716
  },
2717
+ {
2718
+ "symbol": "Project.AddRenderJob (the only readback for render settings)",
2719
+ "object": "Project",
2720
+ "signature": "() -> str",
2721
+ "reality": "There is no GetRenderSettings, but a queued job carries the "
2722
+ "settings it inherited. Measured 2026-09-30 on Studio 19.1.3.7: "
2723
+ "after AddRenderJob, the matching GetRenderJobList entry "
2724
+ "reports TargetDir, OutputFilename (the custom name, or the "
2725
+ "timeline name when none was ever set, plus the format's "
2726
+ "extension), MarkIn/MarkOut, VideoFormat/VideoCodec, "
2727
+ "RenderMode and PresetName, and DeleteRenderJob removes it. "
2728
+ "The round trip took about 150 ms and switches Resolve to the "
2729
+ "Deliver page. Queuing two identical jobs, or a job whose "
2730
+ "output file already exists, raised no dialog and returned "
2731
+ "distinct ids. Limits: AddRenderJob returns '' when no "
2732
+ "TargetDir has ever been set, and also in Individual-clips "
2733
+ "mode on a generator-only timeline, so neither state can be "
2734
+ "read this way. Once set, TargetDir cannot be cleared — "
2735
+ "SetRenderSettings returns False for '' and for None — though "
2736
+ "a TargetDir that does not exist is accepted. CustomName has "
2737
+ "no direct readback: it is only visible folded into "
2738
+ "OutputFilename.",
2739
+ "recommended": "To preserve a user's output folder across work that has "
2740
+ "to change it: in single-clip mode, queue a job, read "
2741
+ "TargetDir off its entry, delete the job, and write "
2742
+ "TargetDir back afterwards. Leave CustomName alone "
2743
+ "wherever possible — it can be neither read nor cleared; "
2744
+ "render into a private folder and take the file that "
2745
+ "appears instead of naming it.",
2746
+ "tags": ["render", "deliver", "readback", "unsupported"],
2747
+ "submit": "missing",
2748
+ "issue": 270,
2749
+ "verified_on": "DaVinci Resolve Studio 19.1.3.7",
2750
+ "mitigation": ["_render_target_dir", "_playhead_frame_render"],
2751
+ },
2752
+ {
2753
+ "symbol": "Resolve.GetUIManager / UIManager.OpenProjectSettings / OpenPreferences (do not exist)",
2754
+ "object": "Resolve",
2755
+ "reality": "No scripting call opens the Project Settings or Preferences "
2756
+ "dialog. Measured 2026-09-30 on Studio 19.1.3.7, direct "
2757
+ "connection: dir(resolve) lists 23 methods and GetUIManager "
2758
+ "is not among them (getattr returns None; hasattr says True, "
2759
+ "as it does for every name). Fusion().UIManager is a real "
2760
+ "object with 15 names — AddNotify, Comp, Composition, "
2761
+ "DoAction, FindWindow, FindWindows, GetData, GetEvent, GetID, "
2762
+ "GetReg, QueueAction, QueueEvent, RemoveNotify, SetData, "
2763
+ "TriggerEvent — and none of OpenProjectSettings, "
2764
+ "OpenPreferences, SaveUILayout or LoadUILayout. None of those "
2765
+ "names, nor GetUIManager, appears in the 21.1 typed API "
2766
+ "either. Code written against them calls None and raises "
2767
+ "\"'NoneType' object is not callable\"; wrapped in a broad "
2768
+ "except, that reads as an ordinary failure. Whether "
2769
+ "UIManager.DoAction or QueueAction can open these dialogs was "
2770
+ "not tried: both dialogs are modal, and a modal dialog blocks "
2771
+ "the scripting API until a person closes it.",
2772
+ "recommended": "Do not offer to open these dialogs. Read and write "
2773
+ "project settings through Project.GetSetting/SetSetting. "
2774
+ "UI layouts go through Resolve.SaveLayoutPreset / "
2775
+ "LoadLayoutPreset, which do exist. Probe with "
2776
+ "resolve_probe.has_method, never hasattr.",
2777
+ "tags": ["ui", "unsupported", "dialog"],
2778
+ "verified_on": "DaVinci Resolve Studio 19.1.3.7",
2779
+ },
2715
2780
  {
2716
2781
  "symbol": "ProjectManager.SaveProject",
2717
2782
  "object": "ProjectManager",
@@ -16,6 +16,8 @@ import platform
16
16
  import subprocess
17
17
  from typing import Dict, Any, Optional, Union, List
18
18
 
19
+ from src.utils.resolve_probe import has_method
20
+
19
21
  # Configure logging
20
22
  logger = logging.getLogger("davinci-resolve-mcp.app_control")
21
23
  APP_CONTROL_TIMEOUT_SECONDS = 10
@@ -267,65 +269,68 @@ def restart_resolve_app(resolve_obj, wait_seconds: int = 5) -> bool:
267
269
  logger.error(f"Error restarting DaVinci Resolve: {str(e)}")
268
270
  return False
269
271
 
270
- def open_project_settings(resolve_obj) -> bool:
271
- """
272
- Open the Project Settings dialog in DaVinci Resolve.
273
-
274
- Args:
275
- resolve_obj: DaVinci Resolve API object
276
-
277
- Returns:
278
- True if successful, False otherwise
279
- """
280
- try:
281
- # Check if UI Manager is available
282
- ui_manager = resolve_obj.GetUIManager()
283
- if not ui_manager:
284
- logger.error("Failed to get UI Manager")
285
- return False
286
-
287
- # Open Project Settings dialog
288
- if hasattr(ui_manager, 'OpenProjectSettings') and callable(getattr(ui_manager, 'OpenProjectSettings')):
289
- ui_manager.OpenProjectSettings()
290
- return True
291
-
292
- # Alternative method - send keyboard shortcut based on platform
293
- current_page = resolve_obj.GetCurrentPage()
294
-
295
- # Ensure we're on a page that supports project settings
296
- if current_page not in ['media', 'cut', 'edit', 'fusion', 'color', 'fairlight', 'deliver']:
297
- logger.error(f"Can't open settings from page: {current_page}")
298
- return False
299
-
300
- return False # Keyboard shortcuts not implemented yet
301
- except Exception as e:
302
- logger.error(f"Error opening project settings: {str(e)}")
303
- return False
272
+ def _open_dialog(resolve_obj, method_name: str, label: str) -> Dict[str, Any]:
273
+ """Ask Resolve to open a dialog, and say what actually happened.
304
274
 
305
- def open_preferences(resolve_obj) -> bool:
306
- """
307
- Open the Preferences dialog in DaVinci Resolve.
308
-
309
- Args:
310
- resolve_obj: DaVinci Resolve API object
311
-
312
- Returns:
313
- True if successful, False otherwise
275
+ Returns {"success", "supported", "message"}.
276
+
277
+ The route this module has always used is Resolve.GetUIManager() and then a
278
+ method on the manager. Neither half exists on any build measured so far.
279
+ On Studio 19.1.3.7 (2026-09-30): dir(resolve) lists 23 methods and
280
+ GetUIManager is not one of them; Fusion().UIManager is real but offers
281
+ neither OpenProjectSettings nor OpenPreferences; and none of the three
282
+ names appears in the 21.1 typed API. So the old code raised "'NoneType'
283
+ object is not callable" on its first line, caught it, logged an error and
284
+ returned a bare False — a tool that could never work, reporting that as an
285
+ ordinary failure with no reason.
286
+
287
+ `hasattr` cannot make this distinction: it is True for every name on a
288
+ Resolve object (see src/utils/resolve_probe.py). `has_method` can, so the
289
+ route is probed with it, and a build that does grow these calls is used
290
+ and its answer reported instead of assumed.
314
291
  """
292
+ if not has_method(resolve_obj, "GetUIManager"):
293
+ return {
294
+ "success": False,
295
+ "supported": False,
296
+ "message": (
297
+ f"Not supported: DaVinci Resolve's scripting API has no call that opens the "
298
+ f"{label} dialog. Resolve.GetUIManager does not exist on this build."
299
+ ),
300
+ }
301
+ ui_manager = resolve_obj.GetUIManager()
302
+ if not has_method(ui_manager, method_name):
303
+ return {
304
+ "success": False,
305
+ "supported": False,
306
+ "message": (
307
+ f"Not supported: DaVinci Resolve's scripting API has no call that opens the "
308
+ f"{label} dialog. UIManager.{method_name} does not exist on this build."
309
+ ),
310
+ }
315
311
  try:
316
- # Check if UI Manager is available
317
- ui_manager = resolve_obj.GetUIManager()
318
- if not ui_manager:
319
- logger.error("Failed to get UI Manager")
320
- return False
321
-
322
- # Open Preferences dialog
323
- if hasattr(ui_manager, 'OpenPreferences') and callable(getattr(ui_manager, 'OpenPreferences')):
324
- ui_manager.OpenPreferences()
325
- return True
326
-
327
- # Alternative method - send keyboard shortcut based on platform
328
- return False # Keyboard shortcuts not implemented yet
329
- except Exception as e:
330
- logger.error(f"Error opening preferences: {str(e)}")
331
- return False
312
+ opened = getattr(ui_manager, method_name)()
313
+ except Exception as exc:
314
+ logger.error("UIManager.%s raised: %s", method_name, exc)
315
+ return {
316
+ "success": False,
317
+ "supported": True,
318
+ "message": f"Failed to open the {label} dialog: UIManager.{method_name} raised {exc}",
319
+ }
320
+ if opened is False:
321
+ return {
322
+ "success": False,
323
+ "supported": True,
324
+ "message": f"Failed to open the {label} dialog: UIManager.{method_name} returned False",
325
+ }
326
+ return {"success": True, "supported": True, "message": f"{label} dialog opened"}
327
+
328
+
329
+ def open_project_settings(resolve_obj) -> Dict[str, Any]:
330
+ """Open the Project Settings dialog. See `_open_dialog` for the result shape."""
331
+ return _open_dialog(resolve_obj, "OpenProjectSettings", "Project Settings")
332
+
333
+
334
+ def open_preferences(resolve_obj) -> Dict[str, Any]:
335
+ """Open the Preferences dialog. See `_open_dialog` for the result shape."""
336
+ return _open_dialog(resolve_obj, "OpenPreferences", "Preferences")