davinci-resolve-mcp 4.8.22 → 4.8.24
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 +125 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +14 -5
- package/docs/reference/api-limitations.md +28 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/granular/project.py +5 -1
- package/src/server.py +278 -81
- package/src/utils/api_truth.py +96 -0
- package/src/utils/page_lock.py +98 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,131 @@
|
|
|
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.24 — a frame capture leaves the render output folder and file name alone
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`timeline_frame` capture left the project's render output folder and file
|
|
10
|
+
name on its own temporary values.** The render route (`quality="frame"`,
|
|
11
|
+
`"preview"`, `"full"`) wrote `TargetDir` and `CustomName` and put neither
|
|
12
|
+
back, because there is no `GetRenderSettings` to read them from. The user's
|
|
13
|
+
next render job inherited a temporary folder the capture had already deleted
|
|
14
|
+
and a name like `capture-<timestamp>`. v4.8.23 documented this; this release
|
|
15
|
+
fixes it.
|
|
16
|
+
- **Output folder (`TargetDir`).** A queued render job carries the settings it
|
|
17
|
+
inherited, so the capture queues a throwaway job, reads `TargetDir` off its
|
|
18
|
+
`GetRenderJobList` entry, deletes the job, and writes the folder back
|
|
19
|
+
afterwards. A folder that is not put back, or a throwaway job that cannot
|
|
20
|
+
be removed, is reported in the capture's `warnings` block.
|
|
21
|
+
- **File name (`CustomName`).** It is no longer written at all. It can be
|
|
22
|
+
neither read back nor cleared (an empty one is refused), so the capture
|
|
23
|
+
renders under whatever name the project already produces, into a private
|
|
24
|
+
folder of its own, and takes the one file that appears there.
|
|
25
|
+
- Live on Studio 19.1.3.7, with a `.mov` format, an output folder and a custom
|
|
26
|
+
name set: a job queued after each of 15 captures inherited the same folder,
|
|
27
|
+
file name, range and format as one queued before, and the render queue was
|
|
28
|
+
left empty.
|
|
29
|
+
- **One gap remains, and it is stated rather than hidden.** A project that has
|
|
30
|
+
never had an output folder has none to read (`AddRenderJob` returns `''`),
|
|
31
|
+
and Resolve cannot clear one once set, so such a project is left with the
|
|
32
|
+
capture's temporary folder as its `TargetDir`. `timeline_frame capabilities`
|
|
33
|
+
reports this as `render_settings_caveat`.
|
|
34
|
+
- The captured frame can no longer be confused with, or delete, another file in
|
|
35
|
+
the shared capture folder. Each capture renders into its own subfolder; the
|
|
36
|
+
shared one (`~/Documents/resolve-stills` on macOS) is only removed when empty.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- `timeline_frame capabilities`: `render_settings_restorable.TargetDir` and
|
|
41
|
+
`.CustomName` are now `true`, and `render_settings_caveat` is new.
|
|
42
|
+
- A render capture takes about 0.2 s longer (measured: roughly 1.3 s against
|
|
43
|
+
1.1 s), which is the throwaway job used to read the output folder.
|
|
44
|
+
|
|
45
|
+
### Documentation
|
|
46
|
+
|
|
47
|
+
- `api_truth` gains `Project.AddRenderJob (the only readback for render
|
|
48
|
+
settings)`, measured on Studio 19.1.3.7: what a job entry exposes, that
|
|
49
|
+
`TargetDir` cannot be cleared, when `AddRenderJob` returns `''`, and that
|
|
50
|
+
duplicate jobs and existing output files raise no dialog.
|
|
51
|
+
`docs/reference/api-limitations.md` is regenerated.
|
|
52
|
+
|
|
53
|
+
### Tests
|
|
54
|
+
|
|
55
|
+
- `tests/test_playhead_frame_capture.py`: the render fake now models the job
|
|
56
|
+
queue as the readback it is. `CaptureOutputSettingsTest` covers the folder
|
|
57
|
+
coming back, the name never being written, a project with no output folder, a
|
|
58
|
+
same-named file already in the shared folder, the read happening in
|
|
59
|
+
single-clip mode before anything changes, and both failure reports. Six of
|
|
60
|
+
its ten tests fail against v4.8.23.
|
|
61
|
+
- `tests/live_frame_capture_page_restore_validation.py` now gives the disposable
|
|
62
|
+
project a user's render settings and compares what a job inherits before and
|
|
63
|
+
after every capture, including one from Individual-clips mode.
|
|
64
|
+
|
|
65
|
+
## What's New in v4.8.23 — a frame capture no longer leaves Resolve on the Deliver page
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- **`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)
|
|
70
|
+
The render route (`quality="frame"`, `"preview"`, `"full"`) recorded the page
|
|
71
|
+
to return to *after* calling `Project.GetCurrentRenderMode()`. That getter
|
|
72
|
+
switches Resolve to the Deliver page by itself (measured on Studio 19.1.3.7
|
|
73
|
+
from Edit, Color and Fairlight), so the page recorded was always `deliver` and
|
|
74
|
+
the restore was skipped as having nothing to do. The page is now read before
|
|
75
|
+
any render call. Live on 19.1.3.7: 14 captures from seven starting pages all
|
|
76
|
+
ended on the page they started on.
|
|
77
|
+
- **A restore that does not take is no longer silent.** The switch back is read
|
|
78
|
+
back with `GetCurrentPage()`. If the page, playhead, current timeline, render
|
|
79
|
+
mode, render format or render range is not put back, the image is followed by
|
|
80
|
+
a `{"warnings": [...]}` block naming what was left changed and the call that
|
|
81
|
+
restores it; an error result carries the same `warnings` key. A clean capture
|
|
82
|
+
is unchanged: one image.
|
|
83
|
+
- **The render range was never restored after a capture.** The restore shared a
|
|
84
|
+
`SetRenderSettings` payload with `CustomName: ""`, and Resolve refuses an empty
|
|
85
|
+
`CustomName` by rejecting the whole payload (measured on 19.1.3.7: `False`, and
|
|
86
|
+
a job queued afterwards still carried `MarkIn == MarkOut ==` the captured
|
|
87
|
+
frame). The range now goes in its own payload and its result is checked. Live
|
|
88
|
+
on 19.1.3.7: a job queued after each capture carried the whole timeline.
|
|
89
|
+
- **`render get_mode`, `render probe_render_settings` and the granular
|
|
90
|
+
`get_current_render_mode` left Resolve on the Deliver page**, for the same
|
|
91
|
+
reason: they call the same getter. They now return to the page they were
|
|
92
|
+
called from.
|
|
93
|
+
- **`resolve_control restore_state` reported the page as restored without
|
|
94
|
+
checking.** `OpenPage`'s return was discarded. `restored.page` is now written
|
|
95
|
+
only when the page reads back, and `page_error` says why otherwise.
|
|
96
|
+
- The Color-page and Edit-page guards used by thumbnails and timeline edits
|
|
97
|
+
discarded `OpenPage` on their way back too. Both now read the page back and
|
|
98
|
+
log a failure.
|
|
99
|
+
|
|
100
|
+
### Changed
|
|
101
|
+
|
|
102
|
+
- `timeline_frame capabilities` returns `render_settings_restorable`, which its
|
|
103
|
+
docstring already listed. `TargetDir` and `CustomName` are `false`: there is no
|
|
104
|
+
`GetRenderSettings`, so after a render capture they stay on the capture's
|
|
105
|
+
temporary folder and name. The docs previously said they were reset.
|
|
106
|
+
|
|
107
|
+
### Documentation
|
|
108
|
+
|
|
109
|
+
- `api_truth` gains two measured entries, both on Studio 19.1.3.7:
|
|
110
|
+
`Project.GetCurrentRenderMode` switches to the Deliver page (with the list of
|
|
111
|
+
render calls that do and do not), and `Project.SetRenderSettings` rejects a
|
|
112
|
+
whole payload over an empty `CustomName`. `docs/reference/api-limitations.md`
|
|
113
|
+
is regenerated.
|
|
114
|
+
|
|
115
|
+
### Tests
|
|
116
|
+
|
|
117
|
+
- `tests/test_playhead_frame_capture.py`: the render fake now behaves as
|
|
118
|
+
measured (the mode getter switches page; an empty `CustomName` refuses the
|
|
119
|
+
payload). New tests cover the page coming back, a refused or lying `OpenPage`
|
|
120
|
+
being reported with the image, the warning reaching an MCP client as a text
|
|
121
|
+
block after the image, and the range Resolve holds after a capture. The two
|
|
122
|
+
regression tests fail against v4.8.22.
|
|
123
|
+
- `tests/test_page_restore.py`: `restore_page`, `restoring_page`, both page
|
|
124
|
+
guards, the three render-mode readers and `restore_state`.
|
|
125
|
+
- `tests/test_discarded_resolve_returns.py` now treats `OpenPage` and the
|
|
126
|
+
`open_page_serialized` wrapper as mutators whose return must be used.
|
|
127
|
+
- `tests/live_frame_capture_page_restore_validation.py`: the live harness behind
|
|
128
|
+
the numbers above.
|
|
129
|
+
|
|
5
130
|
## What's New in v4.8.22 — the control panel port check cannot hang on a wedged lsof
|
|
6
131
|
|
|
7
132
|
### Fixed
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v4.8.
|
|
15
|
+
> 本翻译对应 v4.8.24 版 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,20 @@ 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
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
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.
|
|
1871
|
+
|
|
1872
|
+
The render calls pull Resolve onto the Deliver page. The capture switches back
|
|
1873
|
+
and reads the page to confirm it. If a restore does not take (page, playhead,
|
|
1874
|
+
timeline, render mode, format, range or output folder), the image is followed by a
|
|
1875
|
+
`{"warnings": [...]}` block naming what was left changed and the call that puts
|
|
1876
|
+
it back. No warnings block means every restore was confirmed.
|
|
1868
1877
|
|
|
1869
1878
|
```
|
|
1870
1879
|
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:**
|
|
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`
|
|
@@ -689,6 +698,24 @@ values, or automation-hostile modal prompts.
|
|
|
689
698
|
- **Reference:** [issue #123](https://github.com/samuelgursky/davinci-resolve-mcp/issues/123)
|
|
690
699
|
- **Tags:** render, deliver, silent-failure, preset, readback-lies
|
|
691
700
|
|
|
701
|
+
### Project.GetCurrentRenderMode (switches to the Deliver page)
|
|
702
|
+
|
|
703
|
+
- **Object:** `Project`
|
|
704
|
+
- **Signature:** `() -> int`
|
|
705
|
+
- **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.
|
|
706
|
+
- **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.
|
|
707
|
+
- **Reference:** [issue #270](https://github.com/samuelgursky/davinci-resolve-mcp/issues/270)
|
|
708
|
+
- **Tags:** render, deliver, page, side-effect, getter
|
|
709
|
+
|
|
710
|
+
### Project.SetRenderSettings (an empty CustomName rejects the whole payload)
|
|
711
|
+
|
|
712
|
+
- **Object:** `Project`
|
|
713
|
+
- **Signature:** `({settings}) -> bool`
|
|
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.
|
|
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.
|
|
716
|
+
- **Reference:** [issue #270](https://github.com/samuelgursky/davinci-resolve-mcp/issues/270)
|
|
717
|
+
- **Tags:** render, deliver, silent-failure, unreliable-return
|
|
718
|
+
|
|
692
719
|
### ProjectManager.SaveProject
|
|
693
720
|
|
|
694
721
|
- **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.
|
|
40
|
+
VERSION = "4.8.24"
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -93,7 +93,7 @@ if not logging.getLogger().handlers:
|
|
|
93
93
|
handlers=[logging.StreamHandler()],
|
|
94
94
|
)
|
|
95
95
|
|
|
96
|
-
VERSION = "4.8.
|
|
96
|
+
VERSION = "4.8.24"
|
|
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()}")
|
package/src/granular/project.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
14
|
+
VERSION = "4.8.24"
|
|
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,49 @@ def _render_job_completed(status: Optional[Dict[str, Any]]) -> bool:
|
|
|
14975
14977
|
return False
|
|
14976
14978
|
|
|
14977
14979
|
|
|
14978
|
-
def
|
|
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
|
+
|
|
15021
|
+
def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
15022
|
+
teardown: Optional[List[str]] = None):
|
|
14979
15023
|
"""Render exactly one frame — the only frame-accurate capture route.
|
|
14980
15024
|
|
|
14981
15025
|
The two cheaper routes cannot do this job (both measured on Studio 19.1.3.7,
|
|
@@ -14988,18 +15032,33 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
|
|
|
14988
15032
|
runs in well under a second, and needs no GUI panel or foreground window.
|
|
14989
15033
|
|
|
14990
15034
|
The cost is that render settings are project-level state, and there is still
|
|
14991
|
-
no GetRenderSettings to read them back from (absent as of 21.1).
|
|
14992
|
-
|
|
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:
|
|
14993
15037
|
- Format and codec are readable via GetCurrentRenderFormatAndCodec and are
|
|
14994
15038
|
genuinely restored.
|
|
14995
15039
|
- The mark range is readable via Timeline.GetMarkInOut, so a range the
|
|
14996
15040
|
caller had set is put back (offset into SetRenderSettings' absolute
|
|
14997
15041
|
frame space); with no range set it falls back to the whole timeline.
|
|
14998
|
-
- TargetDir
|
|
14999
|
-
|
|
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.
|
|
15000
15050
|
Callers who need a strictly side-effect-free read should use
|
|
15001
15051
|
quality="thumbnail" and accept per-clip granularity.
|
|
15052
|
+
|
|
15053
|
+
`teardown` collects one sentence per restore that did not take (render
|
|
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).
|
|
15002
15059
|
"""
|
|
15060
|
+
if teardown is None:
|
|
15061
|
+
teardown = []
|
|
15003
15062
|
fmt = str(p.get("format", "jpg")).lower().lstrip(".")
|
|
15004
15063
|
if fmt == "jpeg":
|
|
15005
15064
|
fmt = "jpg"
|
|
@@ -15038,19 +15097,41 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
|
|
|
15038
15097
|
except (TypeError, ValueError):
|
|
15039
15098
|
return _err("frame must be an integer", code="INVALID_FRAME", category="invalid_input")
|
|
15040
15099
|
|
|
15041
|
-
|
|
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}")
|
|
15042
15106
|
os.makedirs(folder, exist_ok=True)
|
|
15043
|
-
name = f"capture-{int(time.time() * 1000)}"
|
|
15044
15107
|
|
|
15108
|
+
# Where the user is comes FIRST, before any render call. Issue #270: this
|
|
15109
|
+
# was read after GetCurrentRenderMode(), and that getter itself switches
|
|
15110
|
+
# Resolve to the Deliver page (measured on Studio 19.1.3.7 from Edit, Color
|
|
15111
|
+
# and Fairlight; api_truth 'Project.GetCurrentRenderMode'). So the page
|
|
15112
|
+
# recorded here was always 'deliver', the restore below was skipped as
|
|
15113
|
+
# "nothing to restore", and every capture left the user on Deliver.
|
|
15114
|
+
# Rendering can move the playhead as well. Both are ours to put back.
|
|
15115
|
+
resolve = get_resolve()
|
|
15116
|
+
original_page = None
|
|
15117
|
+
try:
|
|
15118
|
+
original_page = resolve.GetCurrentPage() if resolve else None
|
|
15119
|
+
except Exception:
|
|
15120
|
+
original_page = None
|
|
15121
|
+
original_tc = None
|
|
15122
|
+
try:
|
|
15123
|
+
original_tc = tl.GetCurrentTimecode()
|
|
15124
|
+
except Exception:
|
|
15125
|
+
pass
|
|
15045
15126
|
original_fc = None
|
|
15046
15127
|
try:
|
|
15047
15128
|
original_fc = proj.GetCurrentRenderFormatAndCodec()
|
|
15048
15129
|
except Exception:
|
|
15049
15130
|
pass
|
|
15050
15131
|
# Render MODE is project state too, and it decides whether the capture can
|
|
15051
|
-
# work at all. In "Individual clips" mode (0) Resolve
|
|
15052
|
-
#
|
|
15053
|
-
#
|
|
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
|
|
15054
15135
|
# 2026-09-09 on a project whose delivery preset was per-clip: every capture
|
|
15055
15136
|
# reported success, wrote no file, and took 30+ s rendering the clip.
|
|
15056
15137
|
# Force single clip (1) for the capture and put the mode back afterwards.
|
|
@@ -15059,20 +15140,6 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
|
|
|
15059
15140
|
original_mode = proj.GetCurrentRenderMode()
|
|
15060
15141
|
except Exception:
|
|
15061
15142
|
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
15143
|
# The capture pins the render range to the captured frame, and there is no
|
|
15077
15144
|
# GetRenderSettings to read the surrounding settings back from (still absent
|
|
15078
15145
|
# in 21.1). The mark range is the exception: Timeline.GetMarkInOut reports it
|
|
@@ -15109,6 +15176,8 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
|
|
|
15109
15176
|
original_marks = None
|
|
15110
15177
|
|
|
15111
15178
|
job = None
|
|
15179
|
+
range_pinned = False
|
|
15180
|
+
original_target = None
|
|
15112
15181
|
try:
|
|
15113
15182
|
if original_mode is not None and original_mode != 1:
|
|
15114
15183
|
if not proj.SetCurrentRenderMode(1):
|
|
@@ -15120,6 +15189,11 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
|
|
|
15120
15189
|
remediation="render(action='set_mode', params={'mode': 1}) then retry.",
|
|
15121
15190
|
state={"render_mode": original_mode},
|
|
15122
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)
|
|
15123
15197
|
codecs = proj.GetRenderCodecs("JPEG" if fmt == "jpg" else fmt.upper()) or {}
|
|
15124
15198
|
codec = list(codecs.values())[0] if codecs else fmt
|
|
15125
15199
|
if not proj.SetCurrentRenderFormatAndCodec(fmt, codec):
|
|
@@ -15130,7 +15204,6 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
|
|
|
15130
15204
|
)
|
|
15131
15205
|
applied = proj.SetRenderSettings({
|
|
15132
15206
|
"TargetDir": folder,
|
|
15133
|
-
"CustomName": name,
|
|
15134
15207
|
"MarkIn": frame,
|
|
15135
15208
|
"MarkOut": frame,
|
|
15136
15209
|
"SelectAllFrames": False,
|
|
@@ -15143,17 +15216,16 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
|
|
|
15143
15216
|
code="RENDER_SETTINGS_REFUSED", category="api_error",
|
|
15144
15217
|
state={"frame": frame},
|
|
15145
15218
|
)
|
|
15219
|
+
range_pinned = True
|
|
15146
15220
|
job = proj.AddRenderJob()
|
|
15147
15221
|
if not job:
|
|
15148
15222
|
return _err("AddRenderJob returned nothing", code="RENDER_JOB_FAILED", category="api_error")
|
|
15149
|
-
# The
|
|
15150
|
-
# ~/Documents/resolve-stills) and
|
|
15151
|
-
#
|
|
15152
|
-
#
|
|
15153
|
-
#
|
|
15154
|
-
# 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.
|
|
15155
15228
|
os.makedirs(folder, exist_ok=True)
|
|
15156
|
-
before = set(os.listdir(folder))
|
|
15157
15229
|
# Positional on purpose: the free-edition bridge proxies method calls
|
|
15158
15230
|
# positionally, and a keyword argument dies inside _BoundMethod with
|
|
15159
15231
|
# "unexpected keyword argument 'isInteractiveMode'" before reaching
|
|
@@ -15176,15 +15248,19 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
|
|
|
15176
15248
|
code="RENDER_FAILED", category="api_error",
|
|
15177
15249
|
state={"status": status, "frame": frame},
|
|
15178
15250
|
)
|
|
15179
|
-
#
|
|
15180
|
-
|
|
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
|
+
)
|
|
15181
15257
|
if not written:
|
|
15182
15258
|
return _err(
|
|
15183
15259
|
"Render reported success but wrote no file",
|
|
15184
15260
|
code="RENDER_FAILED", category="api_error",
|
|
15185
15261
|
state={"folder": folder, "frame": frame},
|
|
15186
15262
|
)
|
|
15187
|
-
src_path =
|
|
15263
|
+
src_path = written[0]
|
|
15188
15264
|
out_format = "jpg" if fmt == "jpg" else "png"
|
|
15189
15265
|
if max_width or fmt == "tif":
|
|
15190
15266
|
data, ff_err = _ffmpeg_scale_to_bytes(src_path, int(max_width) if max_width else None, out_format)
|
|
@@ -15213,6 +15289,10 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
|
|
|
15213
15289
|
"frame capture could not restore render mode %r: %s",
|
|
15214
15290
|
original_mode, mode_exc or "SetCurrentRenderMode returned False",
|
|
15215
15291
|
)
|
|
15292
|
+
teardown.append(
|
|
15293
|
+
f"The render mode was left on single clip (1); restoring {original_mode!r} "
|
|
15294
|
+
"failed. Put it back with render(action='set_mode')."
|
|
15295
|
+
)
|
|
15216
15296
|
if original_fc:
|
|
15217
15297
|
# A failed restore leaves the Deliver page on the capture's format
|
|
15218
15298
|
# and codec, which the user's next render would silently inherit.
|
|
@@ -15230,13 +15310,24 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
|
|
|
15230
15310
|
original_fc.get("format"), original_fc.get("codec"),
|
|
15231
15311
|
restore_exc or "SetCurrentRenderFormatAndCodec returned False",
|
|
15232
15312
|
)
|
|
15233
|
-
|
|
15234
|
-
|
|
15235
|
-
|
|
15236
|
-
|
|
15237
|
-
|
|
15313
|
+
teardown.append(
|
|
15314
|
+
"The render format/codec was left on the capture's "
|
|
15315
|
+
f"({fmt}); restoring {original_fc.get('format')}/{original_fc.get('codec')} "
|
|
15316
|
+
"failed, so the next render job would inherit it."
|
|
15317
|
+
)
|
|
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.
|
|
15238
15322
|
# (original_marks is already in SetRenderSettings' absolute space — see
|
|
15239
15323
|
# the offset above.)
|
|
15324
|
+
#
|
|
15325
|
+
# The range goes in a payload of its own. It used to travel with
|
|
15326
|
+
# CustomName "", and SetRenderSettings refuses an empty CustomName by
|
|
15327
|
+
# rejecting the WHOLE payload (measured on Studio 19.1.3.7: False, and a
|
|
15328
|
+
# job added afterwards still carried MarkIn == MarkOut == the captured
|
|
15329
|
+
# frame). So the range was never put back, and the False was discarded.
|
|
15330
|
+
# One setting per payload, each return checked.
|
|
15240
15331
|
try:
|
|
15241
15332
|
if original_marks:
|
|
15242
15333
|
restored_marks = {
|
|
@@ -15250,27 +15341,61 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
|
|
|
15250
15341
|
"MarkIn": tl.GetStartFrame(),
|
|
15251
15342
|
"MarkOut": tl.GetEndFrame(),
|
|
15252
15343
|
}
|
|
15253
|
-
|
|
15254
|
-
|
|
15255
|
-
except Exception:
|
|
15256
|
-
|
|
15344
|
+
marks_restored = bool(proj.SetRenderSettings(restored_marks))
|
|
15345
|
+
marks_exc = None
|
|
15346
|
+
except Exception as exc:
|
|
15347
|
+
marks_restored, marks_exc = False, exc
|
|
15348
|
+
if not marks_restored:
|
|
15349
|
+
logger.warning(
|
|
15350
|
+
"frame capture could not restore the render range: %s",
|
|
15351
|
+
marks_exc or "SetRenderSettings returned False",
|
|
15352
|
+
)
|
|
15353
|
+
if range_pinned:
|
|
15354
|
+
teardown.append(
|
|
15355
|
+
f"The render range was left pinned to the captured frame ({frame}); "
|
|
15356
|
+
"restoring it failed, so the next render job would render one "
|
|
15357
|
+
"frame. Set the range again before rendering."
|
|
15358
|
+
)
|
|
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)
|
|
15257
15379
|
try:
|
|
15258
|
-
|
|
15259
|
-
if f.startswith(name):
|
|
15260
|
-
try:
|
|
15261
|
-
os.remove(os.path.join(folder, f))
|
|
15262
|
-
except OSError:
|
|
15263
|
-
pass
|
|
15264
|
-
if not os.listdir(folder):
|
|
15265
|
-
os.rmdir(folder)
|
|
15380
|
+
os.rmdir(base) # only ever succeeds when nothing else is using it
|
|
15266
15381
|
except OSError:
|
|
15267
15382
|
pass
|
|
15268
|
-
_restore_playhead(tl, original_tc, what="the render capture")
|
|
15383
|
+
if not _restore_playhead(tl, original_tc, what="the render capture"):
|
|
15384
|
+
teardown.append(
|
|
15385
|
+
f"The playhead was not put back at {original_tc}. Restore it with "
|
|
15386
|
+
"timeline_markers(action='set_current_timecode')."
|
|
15387
|
+
)
|
|
15388
|
+
# Last, and checked: the render calls pull Resolve onto Deliver. The
|
|
15389
|
+
# switch back is read back, and reported when it does not take.
|
|
15269
15390
|
if original_page and original_page != "deliver":
|
|
15270
|
-
|
|
15271
|
-
|
|
15272
|
-
|
|
15273
|
-
|
|
15391
|
+
page = _restore_page(resolve, original_page, what="the render capture")
|
|
15392
|
+
if not page["restored"]:
|
|
15393
|
+
teardown.append(
|
|
15394
|
+
f"Resolve is not back on the {original_page!r} page (it reads "
|
|
15395
|
+
f"{page['page']!r}): the switch failed after {page['attempts']} "
|
|
15396
|
+
f"attempt(s) ({page['error']}). Restore it with "
|
|
15397
|
+
f"resolve_control(action='open_page', params={{'page': {original_page!r}}})."
|
|
15398
|
+
)
|
|
15274
15399
|
|
|
15275
15400
|
|
|
15276
15401
|
def _playhead_frame_full(proj, tl, p: Dict[str, Any]):
|
|
@@ -15442,19 +15567,22 @@ def _playhead_frame_capture(p: Dict[str, Any]):
|
|
|
15442
15567
|
f"Failed to make {wanted!r} the current timeline",
|
|
15443
15568
|
code="SET_TIMELINE_FAILED", category="api_error",
|
|
15444
15569
|
)
|
|
15570
|
+
# One sentence per restore that did not take. A capture is promised to be a
|
|
15571
|
+
# read, so a restore that failed is part of the answer, not just of the log.
|
|
15572
|
+
teardown: List[str] = []
|
|
15445
15573
|
try:
|
|
15446
15574
|
if quality == "thumbnail":
|
|
15447
|
-
|
|
15448
|
-
|
|
15449
|
-
|
|
15450
|
-
|
|
15575
|
+
result = _playhead_frame_preview(tl, p)
|
|
15576
|
+
elif quality == "still":
|
|
15577
|
+
result = _playhead_frame_full(proj, tl, p)
|
|
15578
|
+
else:
|
|
15579
|
+
result = _playhead_frame_render(proj, tl, p, teardown)
|
|
15451
15580
|
finally:
|
|
15452
15581
|
if original_tl is not None:
|
|
15453
15582
|
# Best-effort by necessity: this runs in a finally, so raising or
|
|
15454
15583
|
# returning here would replace the caller's real result (or its real
|
|
15455
|
-
# exception) with a restore failure. It is
|
|
15456
|
-
#
|
|
15457
|
-
# visible immediately, and it is logged.
|
|
15584
|
+
# exception) with a restore failure. It is logged, and reported
|
|
15585
|
+
# alongside the result below.
|
|
15458
15586
|
restore_err = _set_current_timeline_checked(
|
|
15459
15587
|
proj, original_tl, what="restoring the timeline after the capture")
|
|
15460
15588
|
if restore_err:
|
|
@@ -15462,11 +15590,36 @@ def _playhead_frame_capture(p: Dict[str, Any]):
|
|
|
15462
15590
|
"frame capture could not restore the current timeline: %s",
|
|
15463
15591
|
restore_err["error"]["message"],
|
|
15464
15592
|
)
|
|
15593
|
+
teardown.append(
|
|
15594
|
+
"The current timeline was not switched back after the capture: "
|
|
15595
|
+
f"{restore_err['error']['message']}"
|
|
15596
|
+
)
|
|
15597
|
+
return _capture_with_teardown(result, teardown)
|
|
15465
15598
|
|
|
15466
15599
|
|
|
15467
|
-
def
|
|
15600
|
+
def _capture_with_teardown(result, teardown: List[str]):
|
|
15601
|
+
"""Attach failed restores to a capture result without replacing the capture.
|
|
15602
|
+
|
|
15603
|
+
An image comes back as [image, {"warnings": [...]}] -- MCP content is a list
|
|
15604
|
+
of blocks anyway, so the frame is still the first block and the warnings
|
|
15605
|
+
follow as text. An error envelope gains a "warnings" key. With nothing to
|
|
15606
|
+
report the result is returned untouched, so a clean capture is exactly what
|
|
15607
|
+
it was before: one image.
|
|
15608
|
+
"""
|
|
15609
|
+
if not teardown:
|
|
15610
|
+
return result
|
|
15611
|
+
if isinstance(result, dict):
|
|
15612
|
+
result["warnings"] = list(result.get("warnings") or []) + list(teardown)
|
|
15613
|
+
return result
|
|
15614
|
+
return [result, {"warnings": list(teardown)}]
|
|
15615
|
+
|
|
15616
|
+
|
|
15617
|
+
def _restore_playhead(tl, timecode, *, what: str) -> bool:
|
|
15468
15618
|
"""Put the playhead back after a capture. Logged, never raised.
|
|
15469
15619
|
|
|
15620
|
+
Returns False when the restore did not take, True otherwise (including when
|
|
15621
|
+
there was no timecode to restore).
|
|
15622
|
+
|
|
15470
15623
|
Deliberately fire-and-forget on the CALLER's behalf: every use of this runs
|
|
15471
15624
|
in a `finally`, so raising or returning an error would replace the caller's
|
|
15472
15625
|
real result -- or its real exception -- with a teardown failure. What it is
|
|
@@ -15475,16 +15628,30 @@ def _restore_playhead(tl, timecode, *, what: str) -> None:
|
|
|
15475
15628
|
diagnosis and an afternoon.
|
|
15476
15629
|
"""
|
|
15477
15630
|
if not timecode:
|
|
15478
|
-
return
|
|
15631
|
+
return True
|
|
15479
15632
|
try:
|
|
15480
15633
|
moved = tl.SetCurrentTimecode(timecode)
|
|
15481
15634
|
except Exception as exc:
|
|
15482
15635
|
logger.warning("could not restore the playhead to %s after %s: %s",
|
|
15483
15636
|
timecode, what, exc)
|
|
15484
|
-
return
|
|
15637
|
+
return False
|
|
15485
15638
|
if not moved:
|
|
15486
15639
|
logger.warning("could not restore the playhead to %s after %s: "
|
|
15487
15640
|
"SetCurrentTimecode returned %r", timecode, what, moved)
|
|
15641
|
+
return False
|
|
15642
|
+
# The return is not the evidence. A True that did not move the playhead has
|
|
15643
|
+
# been observed on Studio 19.1.3.7 (straight after leaving the Deliver
|
|
15644
|
+
# page), so read it back wherever it can be read.
|
|
15645
|
+
try:
|
|
15646
|
+
landed = tl.GetCurrentTimecode()
|
|
15647
|
+
except Exception:
|
|
15648
|
+
return True
|
|
15649
|
+
if isinstance(landed, str) and landed and landed != timecode:
|
|
15650
|
+
logger.warning("could not restore the playhead to %s after %s: "
|
|
15651
|
+
"SetCurrentTimecode returned True but the playhead reads %s",
|
|
15652
|
+
timecode, what, landed)
|
|
15653
|
+
return False
|
|
15654
|
+
return True
|
|
15488
15655
|
|
|
15489
15656
|
|
|
15490
15657
|
def _set_current_timeline_checked(proj, tl, *, what: str):
|
|
@@ -18112,11 +18279,13 @@ def _resolve_restore_state(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
18112
18279
|
|
|
18113
18280
|
# Restore page first so subsequent ops land in the right context
|
|
18114
18281
|
if state.get("page"):
|
|
18115
|
-
|
|
18116
|
-
|
|
18282
|
+
# Reported from a readback, not from having asked: OpenPage's return
|
|
18283
|
+
# used to be discarded and the page listed as restored regardless.
|
|
18284
|
+
page = _restore_page(r, state["page"], what="restore_state")
|
|
18285
|
+
if page["restored"]:
|
|
18117
18286
|
restored["page"] = state["page"]
|
|
18118
|
-
|
|
18119
|
-
restored["page_error"] =
|
|
18287
|
+
else:
|
|
18288
|
+
restored["page_error"] = page["error"]
|
|
18120
18289
|
|
|
18121
18290
|
pm = r.GetProjectManager()
|
|
18122
18291
|
proj = pm.GetCurrentProject() if pm else None
|
|
@@ -21043,7 +21212,10 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
21043
21212
|
)
|
|
21044
21213
|
return {"success": True, "format_id": _format_id, "codec_id": _codec_id}
|
|
21045
21214
|
elif action == "get_mode":
|
|
21046
|
-
|
|
21215
|
+
# The getter itself switches Resolve to the Deliver page (api_truth
|
|
21216
|
+
# 'Project.GetCurrentRenderMode'); a read must not move the user.
|
|
21217
|
+
with _restoring_page(get_resolve(), what="a render-mode read"):
|
|
21218
|
+
return {"mode": proj.GetCurrentRenderMode()}
|
|
21047
21219
|
elif action == "set_mode":
|
|
21048
21220
|
return {"success": bool(proj.SetCurrentRenderMode(p["mode"]))}
|
|
21049
21221
|
elif action == "get_resolutions":
|
|
@@ -21102,7 +21274,9 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
21102
21274
|
elif action == "probe_render_matrix":
|
|
21103
21275
|
return _probe_render_matrix(proj, p)
|
|
21104
21276
|
elif action == "probe_render_settings":
|
|
21105
|
-
|
|
21277
|
+
# Reads the render mode, which switches to Deliver; see get_mode.
|
|
21278
|
+
with _restoring_page(get_resolve(), what="a render-settings read"):
|
|
21279
|
+
return _render_settings_snapshot(proj)
|
|
21106
21280
|
elif action == "validate_render_settings":
|
|
21107
21281
|
return _validate_render_settings_action(p)
|
|
21108
21282
|
elif action == "safe_set_render_settings":
|
|
@@ -26578,6 +26752,7 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
|
26578
26752
|
|
|
26579
26753
|
Actions:
|
|
26580
26754
|
capture(timecode?|frame?, quality?, max_width?, format?, timeline_name?) -> MCP image content
|
|
26755
|
+
(followed by a {"warnings": [...]} block only when a restore failed; see below)
|
|
26581
26756
|
capabilities() -> {quality_modes, ffmpeg, render_settings_restorable, ...}
|
|
26582
26757
|
|
|
26583
26758
|
capture parameters:
|
|
@@ -26599,11 +26774,13 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
|
26599
26774
|
Choosing a quality — the trade-off is accuracy against side effects:
|
|
26600
26775
|
|
|
26601
26776
|
'frame'/'preview' Frame-exact. Renders one frame, so it changes
|
|
26602
|
-
project-level render settings
|
|
26603
|
-
|
|
26604
|
-
|
|
26605
|
-
|
|
26606
|
-
|
|
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.
|
|
26607
26784
|
'thumbnail' Changes nothing and returns instantly, but it is NOT frame
|
|
26608
26785
|
accurate: GetCurrentClipThumbnailImage returns the clip's
|
|
26609
26786
|
thumbnail, identical for every frame of that clip (measured
|
|
@@ -26614,8 +26791,13 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
|
26614
26791
|
be open on the Color page — no scripting call can open it,
|
|
26615
26792
|
so this fails with a bare refusal when it is closed.
|
|
26616
26793
|
|
|
26617
|
-
The playhead, the
|
|
26618
|
-
restored; a capture is a read of the picture, not an edit of the
|
|
26794
|
+
The playhead, the page you were on, the current timeline and the Gallery
|
|
26795
|
+
are all restored; a capture is a read of the picture, not an edit of the
|
|
26796
|
+
cut. The render calls pull Resolve onto the Deliver page, so the render
|
|
26797
|
+
route switches back and reads the page to confirm it. If any restore does
|
|
26798
|
+
not take, the image is followed by {"warnings": [...]} naming what was left
|
|
26799
|
+
changed and the call that puts it back (an error result carries the same
|
|
26800
|
+
"warnings" key). No warnings block means every restore was confirmed.
|
|
26619
26801
|
"""
|
|
26620
26802
|
p = _params(params)
|
|
26621
26803
|
if action == "capture":
|
|
@@ -26634,6 +26816,21 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
|
26634
26816
|
"ffmpeg": bool(shutil.which("ffmpeg")),
|
|
26635
26817
|
"max_width_supported": bool(shutil.which("ffmpeg")),
|
|
26636
26818
|
"current_page": current_page,
|
|
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.
|
|
26823
|
+
"render_settings_restorable": {
|
|
26824
|
+
"render_mode": True,
|
|
26825
|
+
"format_codec": True,
|
|
26826
|
+
"mark_range": True,
|
|
26827
|
+
"TargetDir": True,
|
|
26828
|
+
"CustomName": True,
|
|
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
|
+
),
|
|
26637
26834
|
}
|
|
26638
26835
|
_, tl, err = _get_tl()
|
|
26639
26836
|
if err:
|
package/src/utils/api_truth.py
CHANGED
|
@@ -2653,6 +2653,102 @@ 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 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.",
|
|
2711
|
+
"tags": ["render", "deliver", "silent-failure", "unreliable-return"],
|
|
2712
|
+
"submit": "bug",
|
|
2713
|
+
"issue": 270,
|
|
2714
|
+
"verified_on": "DaVinci Resolve Studio 19.1.3.7",
|
|
2715
|
+
"mitigation": ["_playhead_frame_render"],
|
|
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
|
+
},
|
|
2656
2752
|
{
|
|
2657
2753
|
"symbol": "ProjectManager.SaveProject",
|
|
2658
2754
|
"object": "ProjectManager",
|
package/src/utils/page_lock.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
163
|
-
resolve.OpenPage(original)
|
|
164
|
-
except Exception:
|
|
165
|
-
pass
|
|
255
|
+
restore_page(resolve, original, what="an Edit-page edit")
|