davinci-resolve-mcp 4.8.22 → 4.8.23

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,71 @@
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.23 — a frame capture no longer leaves Resolve on the Deliver page
6
+
7
+ ### Fixed
8
+
9
+ - **`timeline_frame` capture left Resolve on the Deliver page.** ([#270](https://github.com/samuelgursky/davinci-resolve-mcp/issues/270), reported by @Dragonfist76 on Studio 21.1.0.17)
10
+ The render route (`quality="frame"`, `"preview"`, `"full"`) recorded the page
11
+ to return to *after* calling `Project.GetCurrentRenderMode()`. That getter
12
+ switches Resolve to the Deliver page by itself (measured on Studio 19.1.3.7
13
+ from Edit, Color and Fairlight), so the page recorded was always `deliver` and
14
+ the restore was skipped as having nothing to do. The page is now read before
15
+ any render call. Live on 19.1.3.7: 14 captures from seven starting pages all
16
+ ended on the page they started on.
17
+ - **A restore that does not take is no longer silent.** The switch back is read
18
+ back with `GetCurrentPage()`. If the page, playhead, current timeline, render
19
+ mode, render format or render range is not put back, the image is followed by
20
+ a `{"warnings": [...]}` block naming what was left changed and the call that
21
+ restores it; an error result carries the same `warnings` key. A clean capture
22
+ is unchanged: one image.
23
+ - **The render range was never restored after a capture.** The restore shared a
24
+ `SetRenderSettings` payload with `CustomName: ""`, and Resolve refuses an empty
25
+ `CustomName` by rejecting the whole payload (measured on 19.1.3.7: `False`, and
26
+ a job queued afterwards still carried `MarkIn == MarkOut ==` the captured
27
+ frame). The range now goes in its own payload and its result is checked. Live
28
+ on 19.1.3.7: a job queued after each capture carried the whole timeline.
29
+ - **`render get_mode`, `render probe_render_settings` and the granular
30
+ `get_current_render_mode` left Resolve on the Deliver page**, for the same
31
+ reason: they call the same getter. They now return to the page they were
32
+ called from.
33
+ - **`resolve_control restore_state` reported the page as restored without
34
+ checking.** `OpenPage`'s return was discarded. `restored.page` is now written
35
+ only when the page reads back, and `page_error` says why otherwise.
36
+ - The Color-page and Edit-page guards used by thumbnails and timeline edits
37
+ discarded `OpenPage` on their way back too. Both now read the page back and
38
+ log a failure.
39
+
40
+ ### Changed
41
+
42
+ - `timeline_frame capabilities` returns `render_settings_restorable`, which its
43
+ docstring already listed. `TargetDir` and `CustomName` are `false`: there is no
44
+ `GetRenderSettings`, so after a render capture they stay on the capture's
45
+ temporary folder and name. The docs previously said they were reset.
46
+
47
+ ### Documentation
48
+
49
+ - `api_truth` gains two measured entries, both on Studio 19.1.3.7:
50
+ `Project.GetCurrentRenderMode` switches to the Deliver page (with the list of
51
+ render calls that do and do not), and `Project.SetRenderSettings` rejects a
52
+ whole payload over an empty `CustomName`. `docs/reference/api-limitations.md`
53
+ is regenerated.
54
+
55
+ ### Tests
56
+
57
+ - `tests/test_playhead_frame_capture.py`: the render fake now behaves as
58
+ measured (the mode getter switches page; an empty `CustomName` refuses the
59
+ payload). New tests cover the page coming back, a refused or lying `OpenPage`
60
+ being reported with the image, the warning reaching an MCP client as a text
61
+ block after the image, and the range Resolve holds after a capture. The two
62
+ regression tests fail against v4.8.22.
63
+ - `tests/test_page_restore.py`: `restore_page`, `restoring_page`, both page
64
+ guards, the three render-mode readers and `restore_state`.
65
+ - `tests/test_discarded_resolve_returns.py` now treats `OpenPage` and the
66
+ `open_page_serialized` wrapper as mutators whose return must be used.
67
+ - `tests/live_frame_capture_page_restore_validation.py`: the live harness behind
68
+ the numbers above.
69
+
5
70
  ## What's New in v4.8.22 — the control panel port check cannot hang on a wedged lsof
6
71
 
7
72
  ### 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.22-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.23-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.22-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.23-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.22 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.8.23 版 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,11 +1860,19 @@ 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: format and codec are restored and
1864
- the render job is deleted, but `TargetDir`/`CustomName`/mark range cannot be read
1865
- back on builds without `GetRenderSettings`, so they are reset to the full
1866
- timeline rather than restored. Reach for `quality="thumbnail"` when zero side
1867
- effects matter more than accuracy.
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.
1870
+
1871
+ The render calls pull Resolve onto the Deliver page. The capture switches back
1872
+ 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
+ `{"warnings": [...]}` block naming what was left changed and the call that puts
1875
+ it back. No warnings block means every restore was confirmed.
1868
1876
 
1869
1877
  ```
1870
1878
  timeline_frame(action="capture", params={"timecode": "01:00:15:12", "max_width": 1280})
@@ -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, 54 bugs / unreliable behaviors.
15
+ **Totals:** 41 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
@@ -689,6 +689,24 @@ values, or automation-hostile modal prompts.
689
689
  - **Reference:** [issue #123](https://github.com/samuelgursky/davinci-resolve-mcp/issues/123)
690
690
  - **Tags:** render, deliver, silent-failure, preset, readback-lies
691
691
 
692
+ ### Project.GetCurrentRenderMode (switches to the Deliver page)
693
+
694
+ - **Object:** `Project`
695
+ - **Signature:** `() -> int`
696
+ - **Behavior:** A getter with a side effect: calling it switches Resolve to the Deliver page. Measured 2026-09-30 on Studio 19.1.3.7 from the Edit, Color and Fairlight pages: GetCurrentPage() read 'deliver' immediately afterwards, every time. The other render readers do not do this — GetCurrentRenderFormatAndCodec, GetRenderFormats, GetRenderCodecs, GetRenderResolutions, GetRenderJobList, GetRenderPresetList, IsRenderingInProgress and Timeline.GetMarkInOut all left the page alone in the same run. The render WRITERS all switch: SetCurrentRenderMode, SetCurrentRenderFormatAndCodec, SetRenderSettings, AddRenderJob and StartRendering each moved Edit to Deliver; DeleteRenderJob did not. Nothing switches back on its own. Issue #270 reported the consequence on Studio 21.1.0.17 — a frame capture that left the user on Deliver — but the per-call measurement was not repeated on that build.
697
+ - **Workaround / current handling:** Read GetCurrentPage() BEFORE the first render call, not after, and OpenPage back when done. A page read after GetCurrentRenderMode is always 'deliver', which makes a restore look unnecessary — that ordering is how the capture stranded users. src/utils/page_lock.py:restoring_page reads first, restores after, and reads the page back to confirm.
698
+ - **Reference:** [issue #270](https://github.com/samuelgursky/davinci-resolve-mcp/issues/270)
699
+ - **Tags:** render, deliver, page, side-effect, getter
700
+
701
+ ### Project.SetRenderSettings (an empty CustomName rejects the whole payload)
702
+
703
+ - **Object:** `Project`
704
+ - **Signature:** `({settings}) -> bool`
705
+ - **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.
707
+ - **Reference:** [issue #270](https://github.com/samuelgursky/davinci-resolve-mcp/issues/270)
708
+ - **Tags:** render, deliver, silent-failure, unreliable-return
709
+
692
710
  ### ProjectManager.SaveProject
693
711
 
694
712
  - **Object:** `ProjectManager`
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.22"
40
+ VERSION = "4.8.23"
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.22",
3
+ "version": "4.8.23",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -93,7 +93,7 @@ if not logging.getLogger().handlers:
93
93
  handlers=[logging.StreamHandler()],
94
94
  )
95
95
 
96
- VERSION = "4.8.22"
96
+ VERSION = "4.8.23"
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()}")
@@ -1,6 +1,7 @@
1
1
  """Project, render, cache, cloud, and project-property tools."""
2
2
 
3
3
  from src.granular.common import * # noqa: F401,F403
4
+ from src.utils.page_lock import restoring_page
4
5
 
5
6
  resolve = ResolveProxy()
6
7
 
@@ -1338,7 +1339,10 @@ def get_current_render_mode() -> Dict[str, Any]:
1338
1339
  project = resolve.GetProjectManager().GetCurrentProject()
1339
1340
  if not project:
1340
1341
  return {"error": "No project currently open"}
1341
- mode = project.GetCurrentRenderMode()
1342
+ # The getter itself switches Resolve to the Deliver page (api_truth
1343
+ # 'Project.GetCurrentRenderMode'); a read must not move the user.
1344
+ with restoring_page(resolve, what="a render-mode read"):
1345
+ mode = project.GetCurrentRenderMode()
1342
1346
  return {"render_mode": mode, "mode_name": "Individual Clips" if mode == 0 else "Single Clip"}
1343
1347
 
1344
1348
 
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.22"
14
+ VERSION = "4.8.23"
15
15
 
16
16
  import base64
17
17
  import os
@@ -67,6 +67,8 @@ from src.utils.page_lock import (
67
67
  color_page_for_thumbnails as _color_page_for_thumbnails,
68
68
  edit_page_for_timeline_edits as _edit_page_for_timeline_edits,
69
69
  open_page_serialized as _open_page_serialized,
70
+ restore_page as _restore_page,
71
+ restoring_page as _restoring_page,
70
72
  page_lock as _page_lock,
71
73
  )
72
74
  from src.utils.proc import safe_run
@@ -14975,7 +14977,8 @@ def _render_job_completed(status: Optional[Dict[str, Any]]) -> bool:
14975
14977
  return False
14976
14978
 
14977
14979
 
14978
- def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
14980
+ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
14981
+ teardown: Optional[List[str]] = None):
14979
14982
  """Render exactly one frame — the only frame-accurate capture route.
14980
14983
 
14981
14984
  The two cheaper routes cannot do this job (both measured on Studio 19.1.3.7,
@@ -14995,11 +14998,20 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
14995
14998
  - The mark range is readable via Timeline.GetMarkInOut, so a range the
14996
14999
  caller had set is put back (offset into SetRenderSettings' absolute
14997
15000
  frame space); with no range set it falls back to the whole timeline.
14998
- - TargetDir and CustomName are readable from nowhere, so they are reset to
14999
- sane values rather than restored.
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).
15000
15004
  Callers who need a strictly side-effect-free read should use
15001
15005
  quality="thumbnail" and accept per-clip granularity.
15006
+
15007
+ `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).
15002
15012
  """
15013
+ if teardown is None:
15014
+ teardown = []
15003
15015
  fmt = str(p.get("format", "jpg")).lower().lstrip(".")
15004
15016
  if fmt == "jpeg":
15005
15017
  fmt = "jpg"
@@ -15042,6 +15054,24 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
15042
15054
  os.makedirs(folder, exist_ok=True)
15043
15055
  name = f"capture-{int(time.time() * 1000)}"
15044
15056
 
15057
+ # Where the user is comes FIRST, before any render call. Issue #270: this
15058
+ # was read after GetCurrentRenderMode(), and that getter itself switches
15059
+ # Resolve to the Deliver page (measured on Studio 19.1.3.7 from Edit, Color
15060
+ # and Fairlight; api_truth 'Project.GetCurrentRenderMode'). So the page
15061
+ # recorded here was always 'deliver', the restore below was skipped as
15062
+ # "nothing to restore", and every capture left the user on Deliver.
15063
+ # Rendering can move the playhead as well. Both are ours to put back.
15064
+ resolve = get_resolve()
15065
+ original_page = None
15066
+ try:
15067
+ original_page = resolve.GetCurrentPage() if resolve else None
15068
+ except Exception:
15069
+ original_page = None
15070
+ original_tc = None
15071
+ try:
15072
+ original_tc = tl.GetCurrentTimecode()
15073
+ except Exception:
15074
+ pass
15045
15075
  original_fc = None
15046
15076
  try:
15047
15077
  original_fc = proj.GetCurrentRenderFormatAndCodec()
@@ -15059,20 +15089,6 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
15059
15089
  original_mode = proj.GetCurrentRenderMode()
15060
15090
  except Exception:
15061
15091
  original_mode = None
15062
- # Rendering pulls Resolve onto the Deliver page and moves the playhead;
15063
- # measured leaving the user on Deliver at a different frame. Both are ours
15064
- # to put back.
15065
- resolve = get_resolve()
15066
- original_page = None
15067
- try:
15068
- original_page = resolve.GetCurrentPage() if resolve else None
15069
- except Exception:
15070
- original_page = None
15071
- original_tc = None
15072
- try:
15073
- original_tc = tl.GetCurrentTimecode()
15074
- except Exception:
15075
- pass
15076
15092
  # The capture pins the render range to the captured frame, and there is no
15077
15093
  # GetRenderSettings to read the surrounding settings back from (still absent
15078
15094
  # in 21.1). The mark range is the exception: Timeline.GetMarkInOut reports it
@@ -15109,6 +15125,7 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
15109
15125
  original_marks = None
15110
15126
 
15111
15127
  job = None
15128
+ range_pinned = False
15112
15129
  try:
15113
15130
  if original_mode is not None and original_mode != 1:
15114
15131
  if not proj.SetCurrentRenderMode(1):
@@ -15143,6 +15160,7 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
15143
15160
  code="RENDER_SETTINGS_REFUSED", category="api_error",
15144
15161
  state={"frame": frame},
15145
15162
  )
15163
+ range_pinned = True
15146
15164
  job = proj.AddRenderJob()
15147
15165
  if not job:
15148
15166
  return _err("AddRenderJob returned nothing", code="RENDER_JOB_FAILED", category="api_error")
@@ -15213,6 +15231,10 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
15213
15231
  "frame capture could not restore render mode %r: %s",
15214
15232
  original_mode, mode_exc or "SetCurrentRenderMode returned False",
15215
15233
  )
15234
+ teardown.append(
15235
+ f"The render mode was left on single clip (1); restoring {original_mode!r} "
15236
+ "failed. Put it back with render(action='set_mode')."
15237
+ )
15216
15238
  if original_fc:
15217
15239
  # A failed restore leaves the Deliver page on the capture's format
15218
15240
  # and codec, which the user's next render would silently inherit.
@@ -15230,6 +15252,11 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
15230
15252
  original_fc.get("format"), original_fc.get("codec"),
15231
15253
  restore_exc or "SetCurrentRenderFormatAndCodec returned False",
15232
15254
  )
15255
+ teardown.append(
15256
+ "The render format/codec was left on the capture's "
15257
+ f"({fmt}); restoring {original_fc.get('format')}/{original_fc.get('codec')} "
15258
+ "failed, so the next render job would inherit it."
15259
+ )
15233
15260
  # Still not a full restore — GetRenderSettings does not exist, so the
15234
15261
  # other settings cannot be read back. The mark range can: put the user's
15235
15262
  # own range back when they had one, and fall back to the whole timeline
@@ -15237,6 +15264,12 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
15237
15264
  # frame for the next render job to inherit.
15238
15265
  # (original_marks is already in SetRenderSettings' absolute space — see
15239
15266
  # the offset above.)
15267
+ #
15268
+ # The range goes in a payload of its own. It used to travel with
15269
+ # CustomName "", and SetRenderSettings refuses an empty CustomName by
15270
+ # rejecting the WHOLE payload (measured on Studio 19.1.3.7: False, and a
15271
+ # job added afterwards still carried MarkIn == MarkOut == the captured
15272
+ # frame). So the range was never put back, and the False was discarded.
15240
15273
  try:
15241
15274
  if original_marks:
15242
15275
  restored_marks = {
@@ -15250,8 +15283,25 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
15250
15283
  "MarkIn": tl.GetStartFrame(),
15251
15284
  "MarkOut": tl.GetEndFrame(),
15252
15285
  }
15253
- restored_marks["CustomName"] = ""
15254
- proj.SetRenderSettings(restored_marks)
15286
+ marks_restored = bool(proj.SetRenderSettings(restored_marks))
15287
+ marks_exc = None
15288
+ except Exception as exc:
15289
+ marks_restored, marks_exc = False, exc
15290
+ if not marks_restored:
15291
+ logger.warning(
15292
+ "frame capture could not restore the render range: %s",
15293
+ marks_exc or "SetRenderSettings returned False",
15294
+ )
15295
+ if range_pinned:
15296
+ teardown.append(
15297
+ f"The render range was left pinned to the captured frame ({frame}); "
15298
+ "restoring it failed, so the next render job would render one "
15299
+ "frame. Set the range again before rendering."
15300
+ )
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": ""})
15255
15305
  except Exception:
15256
15306
  pass
15257
15307
  try:
@@ -15265,12 +15315,22 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
15265
15315
  os.rmdir(folder)
15266
15316
  except OSError:
15267
15317
  pass
15268
- _restore_playhead(tl, original_tc, what="the render capture")
15318
+ if not _restore_playhead(tl, original_tc, what="the render capture"):
15319
+ teardown.append(
15320
+ f"The playhead was not put back at {original_tc}. Restore it with "
15321
+ "timeline_markers(action='set_current_timecode')."
15322
+ )
15323
+ # Last, and checked: the render calls pull Resolve onto Deliver. The
15324
+ # switch back is read back, and reported when it does not take.
15269
15325
  if original_page and original_page != "deliver":
15270
- try:
15271
- _open_page_serialized(resolve, original_page)
15272
- except Exception:
15273
- pass
15326
+ page = _restore_page(resolve, original_page, what="the render capture")
15327
+ if not page["restored"]:
15328
+ teardown.append(
15329
+ f"Resolve is not back on the {original_page!r} page (it reads "
15330
+ f"{page['page']!r}): the switch failed after {page['attempts']} "
15331
+ f"attempt(s) ({page['error']}). Restore it with "
15332
+ f"resolve_control(action='open_page', params={{'page': {original_page!r}}})."
15333
+ )
15274
15334
 
15275
15335
 
15276
15336
  def _playhead_frame_full(proj, tl, p: Dict[str, Any]):
@@ -15442,19 +15502,22 @@ def _playhead_frame_capture(p: Dict[str, Any]):
15442
15502
  f"Failed to make {wanted!r} the current timeline",
15443
15503
  code="SET_TIMELINE_FAILED", category="api_error",
15444
15504
  )
15505
+ # One sentence per restore that did not take. A capture is promised to be a
15506
+ # read, so a restore that failed is part of the answer, not just of the log.
15507
+ teardown: List[str] = []
15445
15508
  try:
15446
15509
  if quality == "thumbnail":
15447
- return _playhead_frame_preview(tl, p)
15448
- if quality == "still":
15449
- return _playhead_frame_full(proj, tl, p)
15450
- return _playhead_frame_render(proj, tl, p)
15510
+ result = _playhead_frame_preview(tl, p)
15511
+ elif quality == "still":
15512
+ result = _playhead_frame_full(proj, tl, p)
15513
+ else:
15514
+ result = _playhead_frame_render(proj, tl, p, teardown)
15451
15515
  finally:
15452
15516
  if original_tl is not None:
15453
15517
  # Best-effort by necessity: this runs in a finally, so raising or
15454
15518
  # returning here would replace the caller's real result (or its real
15455
- # exception) with a restore failure. It is still not silent -- a
15456
- # failed restore leaves the editor on a different timeline, which is
15457
- # visible immediately, and it is logged.
15519
+ # exception) with a restore failure. It is logged, and reported
15520
+ # alongside the result below.
15458
15521
  restore_err = _set_current_timeline_checked(
15459
15522
  proj, original_tl, what="restoring the timeline after the capture")
15460
15523
  if restore_err:
@@ -15462,11 +15525,36 @@ def _playhead_frame_capture(p: Dict[str, Any]):
15462
15525
  "frame capture could not restore the current timeline: %s",
15463
15526
  restore_err["error"]["message"],
15464
15527
  )
15528
+ teardown.append(
15529
+ "The current timeline was not switched back after the capture: "
15530
+ f"{restore_err['error']['message']}"
15531
+ )
15532
+ return _capture_with_teardown(result, teardown)
15533
+
15465
15534
 
15535
+ def _capture_with_teardown(result, teardown: List[str]):
15536
+ """Attach failed restores to a capture result without replacing the capture.
15466
15537
 
15467
- def _restore_playhead(tl, timecode, *, what: str) -> None:
15538
+ An image comes back as [image, {"warnings": [...]}] -- MCP content is a list
15539
+ of blocks anyway, so the frame is still the first block and the warnings
15540
+ follow as text. An error envelope gains a "warnings" key. With nothing to
15541
+ report the result is returned untouched, so a clean capture is exactly what
15542
+ it was before: one image.
15543
+ """
15544
+ if not teardown:
15545
+ return result
15546
+ if isinstance(result, dict):
15547
+ result["warnings"] = list(result.get("warnings") or []) + list(teardown)
15548
+ return result
15549
+ return [result, {"warnings": list(teardown)}]
15550
+
15551
+
15552
+ def _restore_playhead(tl, timecode, *, what: str) -> bool:
15468
15553
  """Put the playhead back after a capture. Logged, never raised.
15469
15554
 
15555
+ Returns False when the restore did not take, True otherwise (including when
15556
+ there was no timecode to restore).
15557
+
15470
15558
  Deliberately fire-and-forget on the CALLER's behalf: every use of this runs
15471
15559
  in a `finally`, so raising or returning an error would replace the caller's
15472
15560
  real result -- or its real exception -- with a teardown failure. What it is
@@ -15475,16 +15563,30 @@ def _restore_playhead(tl, timecode, *, what: str) -> None:
15475
15563
  diagnosis and an afternoon.
15476
15564
  """
15477
15565
  if not timecode:
15478
- return
15566
+ return True
15479
15567
  try:
15480
15568
  moved = tl.SetCurrentTimecode(timecode)
15481
15569
  except Exception as exc:
15482
15570
  logger.warning("could not restore the playhead to %s after %s: %s",
15483
15571
  timecode, what, exc)
15484
- return
15572
+ return False
15485
15573
  if not moved:
15486
15574
  logger.warning("could not restore the playhead to %s after %s: "
15487
15575
  "SetCurrentTimecode returned %r", timecode, what, moved)
15576
+ return False
15577
+ # The return is not the evidence. A True that did not move the playhead has
15578
+ # been observed on Studio 19.1.3.7 (straight after leaving the Deliver
15579
+ # page), so read it back wherever it can be read.
15580
+ try:
15581
+ landed = tl.GetCurrentTimecode()
15582
+ except Exception:
15583
+ return True
15584
+ if isinstance(landed, str) and landed and landed != timecode:
15585
+ logger.warning("could not restore the playhead to %s after %s: "
15586
+ "SetCurrentTimecode returned True but the playhead reads %s",
15587
+ timecode, what, landed)
15588
+ return False
15589
+ return True
15488
15590
 
15489
15591
 
15490
15592
  def _set_current_timeline_checked(proj, tl, *, what: str):
@@ -18112,11 +18214,13 @@ def _resolve_restore_state(p: Dict[str, Any]) -> Dict[str, Any]:
18112
18214
 
18113
18215
  # Restore page first so subsequent ops land in the right context
18114
18216
  if state.get("page"):
18115
- try:
18116
- r.OpenPage(state["page"])
18217
+ # Reported from a readback, not from having asked: OpenPage's return
18218
+ # used to be discarded and the page listed as restored regardless.
18219
+ page = _restore_page(r, state["page"], what="restore_state")
18220
+ if page["restored"]:
18117
18221
  restored["page"] = state["page"]
18118
- except Exception as exc:
18119
- restored["page_error"] = str(exc)
18222
+ else:
18223
+ restored["page_error"] = page["error"]
18120
18224
 
18121
18225
  pm = r.GetProjectManager()
18122
18226
  proj = pm.GetCurrentProject() if pm else None
@@ -21043,7 +21147,10 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
21043
21147
  )
21044
21148
  return {"success": True, "format_id": _format_id, "codec_id": _codec_id}
21045
21149
  elif action == "get_mode":
21046
- return {"mode": proj.GetCurrentRenderMode()}
21150
+ # The getter itself switches Resolve to the Deliver page (api_truth
21151
+ # 'Project.GetCurrentRenderMode'); a read must not move the user.
21152
+ with _restoring_page(get_resolve(), what="a render-mode read"):
21153
+ return {"mode": proj.GetCurrentRenderMode()}
21047
21154
  elif action == "set_mode":
21048
21155
  return {"success": bool(proj.SetCurrentRenderMode(p["mode"]))}
21049
21156
  elif action == "get_resolutions":
@@ -21102,7 +21209,9 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
21102
21209
  elif action == "probe_render_matrix":
21103
21210
  return _probe_render_matrix(proj, p)
21104
21211
  elif action == "probe_render_settings":
21105
- return _render_settings_snapshot(proj)
21212
+ # Reads the render mode, which switches to Deliver; see get_mode.
21213
+ with _restoring_page(get_resolve(), what="a render-settings read"):
21214
+ return _render_settings_snapshot(proj)
21106
21215
  elif action == "validate_render_settings":
21107
21216
  return _validate_render_settings_action(p)
21108
21217
  elif action == "safe_set_render_settings":
@@ -26578,6 +26687,7 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
26578
26687
 
26579
26688
  Actions:
26580
26689
  capture(timecode?|frame?, quality?, max_width?, format?, timeline_name?) -> MCP image content
26690
+ (followed by a {"warnings": [...]} block only when a restore failed; see below)
26581
26691
  capabilities() -> {quality_modes, ffmpeg, render_settings_restorable, ...}
26582
26692
 
26583
26693
  capture parameters:
@@ -26599,11 +26709,13 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
26599
26709
  Choosing a quality — the trade-off is accuracy against side effects:
26600
26710
 
26601
26711
  'frame'/'preview' Frame-exact. Renders one frame, so it changes
26602
- project-level render settings. Format and codec are restored;
26603
- TargetDir, CustomName and the mark range cannot be read back
26604
- on builds without GetRenderSettings, so they are reset to the
26605
- full timeline rather than truly restored. Refuses while
26606
- another render is running.
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.
26607
26719
  'thumbnail' Changes nothing and returns instantly, but it is NOT frame
26608
26720
  accurate: GetCurrentClipThumbnailImage returns the clip's
26609
26721
  thumbnail, identical for every frame of that clip (measured
@@ -26614,8 +26726,13 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
26614
26726
  be open on the Color page — no scripting call can open it,
26615
26727
  so this fails with a bare refusal when it is closed.
26616
26728
 
26617
- The playhead, the Color page, the current timeline and the Gallery are all
26618
- restored; a capture is a read of the picture, not an edit of the cut.
26729
+ The playhead, the page you were on, the current timeline and the Gallery
26730
+ are all restored; a capture is a read of the picture, not an edit of the
26731
+ cut. The render calls pull Resolve onto the Deliver page, so the render
26732
+ route switches back and reads the page to confirm it. If any restore does
26733
+ not take, the image is followed by {"warnings": [...]} naming what was left
26734
+ changed and the call that puts it back (an error result carries the same
26735
+ "warnings" key). No warnings block means every restore was confirmed.
26619
26736
  """
26620
26737
  p = _params(params)
26621
26738
  if action == "capture":
@@ -26634,6 +26751,16 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
26634
26751
  "ffmpeg": bool(shutil.which("ffmpeg")),
26635
26752
  "max_width_supported": bool(shutil.which("ffmpeg")),
26636
26753
  "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.
26757
+ "render_settings_restorable": {
26758
+ "render_mode": True,
26759
+ "format_codec": True,
26760
+ "mark_range": True,
26761
+ "TargetDir": False,
26762
+ "CustomName": False,
26763
+ },
26637
26764
  }
26638
26765
  _, tl, err = _get_tl()
26639
26766
  if err:
@@ -2653,6 +2653,65 @@ API_TRUTH: List[Dict[str, Any]] = [
2653
2653
  "tags": ["render", "deliver", "audio", "unsupported"],
2654
2654
  "submit": "missing",
2655
2655
  },
2656
+ {
2657
+ "symbol": "Project.GetCurrentRenderMode (switches to the Deliver page)",
2658
+ "object": "Project",
2659
+ "signature": "() -> int",
2660
+ "reality": "A getter with a side effect: calling it switches Resolve to "
2661
+ "the Deliver page. Measured 2026-09-30 on Studio 19.1.3.7 from "
2662
+ "the Edit, Color and Fairlight pages: GetCurrentPage() read "
2663
+ "'deliver' immediately afterwards, every time. The other "
2664
+ "render readers do not do this — GetCurrentRenderFormatAndCodec, "
2665
+ "GetRenderFormats, GetRenderCodecs, GetRenderResolutions, "
2666
+ "GetRenderJobList, GetRenderPresetList, IsRenderingInProgress "
2667
+ "and Timeline.GetMarkInOut all left the page alone in the same "
2668
+ "run. The render WRITERS all switch: SetCurrentRenderMode, "
2669
+ "SetCurrentRenderFormatAndCodec, SetRenderSettings, "
2670
+ "AddRenderJob and StartRendering each moved Edit to Deliver; "
2671
+ "DeleteRenderJob did not. Nothing switches back on its own. "
2672
+ "Issue #270 reported the consequence on Studio 21.1.0.17 — a "
2673
+ "frame capture that left the user on Deliver — but the "
2674
+ "per-call measurement was not repeated on that build.",
2675
+ "recommended": "Read GetCurrentPage() BEFORE the first render call, not "
2676
+ "after, and OpenPage back when done. A page read after "
2677
+ "GetCurrentRenderMode is always 'deliver', which makes a "
2678
+ "restore look unnecessary — that ordering is how the "
2679
+ "capture stranded users. "
2680
+ "src/utils/page_lock.py:restoring_page reads first, "
2681
+ "restores after, and reads the page back to confirm.",
2682
+ "tags": ["render", "deliver", "page", "side-effect", "getter"],
2683
+ "submit": "bug",
2684
+ "issue": 270,
2685
+ "verified_on": "DaVinci Resolve Studio 19.1.3.7",
2686
+ "mitigation": ["_restoring_page", "_playhead_frame_render"],
2687
+ },
2688
+ {
2689
+ "symbol": "Project.SetRenderSettings (an empty CustomName rejects the whole payload)",
2690
+ "object": "Project",
2691
+ "signature": "({settings}) -> bool",
2692
+ "reality": "SetRenderSettings returns False for {'CustomName': ''} and "
2693
+ "for {'CustomName': None}, and when that key rides in a larger "
2694
+ "payload the WHOLE payload is rejected, not just the name. "
2695
+ "Measured 2026-09-30 on Studio 19.1.3.7: with the render range "
2696
+ "pinned to one frame, {SelectAllFrames: True, MarkIn: start, "
2697
+ "MarkOut: end, CustomName: ''} returned False and a job added "
2698
+ "afterwards still carried MarkIn == MarkOut == the pinned "
2699
+ "frame; the same payload without CustomName returned True and "
2700
+ "the job carried the whole timeline. A single space IS "
2701
+ "accepted, and becomes the file name. So a custom name, once "
2702
+ "set, cannot be cleared through this API, and there is no "
2703
+ "GetRenderSettings to read the previous one back from.",
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.",
2709
+ "tags": ["render", "deliver", "silent-failure", "unreliable-return"],
2710
+ "submit": "bug",
2711
+ "issue": 270,
2712
+ "verified_on": "DaVinci Resolve Studio 19.1.3.7",
2713
+ "mitigation": ["_playhead_frame_render"],
2714
+ },
2656
2715
  {
2657
2716
  "symbol": "ProjectManager.SaveProject",
2658
2717
  "object": "ProjectManager",
@@ -19,11 +19,15 @@ Usage:
19
19
  resolve.OpenPage("color")
20
20
  ... do color-page work, read results ...
21
21
  """
22
+ import logging
22
23
  import os
23
24
  import tempfile
24
25
  import threading
26
+ import time
25
27
  from contextlib import contextmanager
26
28
 
29
+ logger = logging.getLogger("resolve-mcp.page-lock")
30
+
27
31
  try:
28
32
  import fcntl # type: ignore
29
33
  _HAS_FCNTL = True
@@ -79,6 +83,98 @@ def open_page_serialized(resolve, page):
79
83
  return resolve.OpenPage(page)
80
84
 
81
85
 
86
+ # A refused page switch has not been measured on any build: on Studio 19.1.3.7
87
+ # OpenPage back to the caller's page took on the first call, straight after a
88
+ # render. The short retry is a hedge against a transient refusal, not a fix for
89
+ # a known one. The readback is the point — it is what turns a switch that did
90
+ # not take into something the caller hears about.
91
+ PAGE_RESTORE_ATTEMPTS = 3
92
+ PAGE_RESTORE_DELAY = 0.25
93
+
94
+
95
+ def _read_page(resolve):
96
+ """The current page as a non-empty string, or None when it cannot be read."""
97
+ try:
98
+ page = resolve.GetCurrentPage()
99
+ except Exception:
100
+ return None
101
+ return page if isinstance(page, str) and page else None
102
+
103
+
104
+ def restore_page(resolve, page, *, what, attempts=PAGE_RESTORE_ATTEMPTS,
105
+ delay=PAGE_RESTORE_DELAY):
106
+ """Put Resolve back on `page` and PROVE it got there. Logged, never raised.
107
+
108
+ Every caller runs this in a `finally`, after the work the user asked for,
109
+ so a failure here must not replace that work's result — but it must not
110
+ vanish either. A discarded OpenPage strands the user on a page they did
111
+ not choose with nothing to say why. The outcome is returned for callers
112
+ that can pass it on, and a failure is logged for the ones that cannot.
113
+
114
+ Already on `page` is a success with zero attempts: nothing is switched.
115
+
116
+ Returns {target, restored, page, attempts[, error]}: `page` is the last
117
+ page read back (None when unreadable), `attempts` the OpenPage calls made.
118
+ OpenPage's own True is accepted only when GetCurrentPage cannot be read at
119
+ all; a readable page that is not the target is a failure whatever OpenPage
120
+ returned.
121
+ """
122
+ outcome = {"target": page, "restored": False, "page": None, "attempts": 0}
123
+ error = None
124
+ with page_lock():
125
+ for attempt in range(1, max(1, int(attempts)) + 1):
126
+ current = _read_page(resolve)
127
+ if current == page:
128
+ outcome.update(restored=True, page=current)
129
+ break
130
+ try:
131
+ opened = bool(resolve.OpenPage(page))
132
+ error = None if opened else "OpenPage returned False"
133
+ except Exception as exc:
134
+ opened, error = False, f"OpenPage raised {exc}"
135
+ outcome["attempts"] = attempt
136
+ current = _read_page(resolve)
137
+ outcome["page"] = current
138
+ if current == page or (opened and current is None):
139
+ outcome["restored"] = True
140
+ break
141
+ if opened:
142
+ error = f"OpenPage returned True but Resolve is on {current!r}"
143
+ if attempt < attempts:
144
+ time.sleep(delay)
145
+ if not outcome["restored"]:
146
+ outcome["error"] = error or "OpenPage did not take"
147
+ logger.warning(
148
+ "could not restore the %r page after %s: %s (Resolve is on %r, %d attempt(s))",
149
+ page, what, outcome["error"], outcome["page"], outcome["attempts"],
150
+ )
151
+ return outcome
152
+
153
+
154
+ @contextmanager
155
+ def restoring_page(resolve, *, what):
156
+ """Put Resolve back on the page it was on when the block began.
157
+
158
+ For calls that move the page as a side effect. Several of the render calls
159
+ do, and one of them is a getter: measured on Studio 19.1.3.7,
160
+ Project.GetCurrentRenderMode() leaves Resolve on the Deliver page from
161
+ Edit, Color and Fairlight alike (api_truth has the full list). Issue #270
162
+ was that getter running before the page was read, so the page recorded as
163
+ "where the user was" was already Deliver and nothing was put back.
164
+
165
+ The page is read BEFORE the block runs, which is the whole contract. If it
166
+ cannot be read, nothing is restored, so a skipped restore can never move
167
+ the user somewhere they were not. Yields the page that will be restored.
168
+ """
169
+ original = _read_page(resolve) if resolve is not None else None
170
+ with page_lock():
171
+ try:
172
+ yield original
173
+ finally:
174
+ if original:
175
+ restore_page(resolve, original, what=what)
176
+
177
+
82
178
  @contextmanager
83
179
  def color_page_for_thumbnails(resolve):
84
180
  """Hold the Color page for the block, restoring the user's page after.
@@ -109,10 +205,7 @@ def color_page_for_thumbnails(resolve):
109
205
  yield on_color
110
206
  finally:
111
207
  if original and original != "color":
112
- try:
113
- resolve.OpenPage(original)
114
- except Exception:
115
- pass
208
+ restore_page(resolve, original, what="a Color-page read")
116
209
 
117
210
 
118
211
  @contextmanager
@@ -159,7 +252,4 @@ def edit_page_for_timeline_edits(resolve):
159
252
  # OpenPage refused, and restoring then would flip a page the user is
160
253
  # still on.
161
254
  if original and original != "edit" and on_edit:
162
- try:
163
- resolve.OpenPage(original)
164
- except Exception:
165
- pass
255
+ restore_page(resolve, original, what="an Edit-page edit")