davinci-resolve-mcp 4.8.23 → 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 +60 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +9 -8
- package/docs/reference/api-limitations.md +11 -2
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +127 -57
- package/src/utils/api_truth.py +41 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,66 @@
|
|
|
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
|
+
|
|
5
65
|
## What's New in v4.8.23 — a frame capture no longer leaves Resolve on the Deliver page
|
|
6
66
|
|
|
7
67
|
### 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,17 +1860,18 @@ metadata. (For the raw camera file instead, use
|
|
|
1860
1860
|
`frame` is the absolute timeline frame. Omit both to capture the playhead.
|
|
1861
1861
|
|
|
1862
1862
|
The playhead, page, current timeline and Gallery are restored. The render route
|
|
1863
|
-
additionally touches project render settings: render mode,
|
|
1864
|
-
mark range
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1863
|
+
additionally touches project render settings and puts them back: render mode,
|
|
1864
|
+
format, codec, mark range and output folder (`TargetDir`, read off a throwaway
|
|
1865
|
+
render job because there is no `GetRenderSettings`). The file name
|
|
1866
|
+
(`CustomName`) is never written, and the render job is deleted. One gap: a
|
|
1867
|
+
project that has never had an output folder has none to put back, and Resolve
|
|
1868
|
+
cannot clear one, so it is left on the capture's temporary folder
|
|
1869
|
+
(`capabilities` repeats this as `render_settings_caveat`). Reach for
|
|
1870
|
+
`quality="thumbnail"` when zero side effects matter more than accuracy.
|
|
1870
1871
|
|
|
1871
1872
|
The render calls pull Resolve onto the Deliver page. The capture switches back
|
|
1872
1873
|
and reads the page to confirm it. If a restore does not take (page, playhead,
|
|
1873
|
-
timeline, render mode, format or
|
|
1874
|
+
timeline, render mode, format, range or output folder), the image is followed by a
|
|
1874
1875
|
`{"warnings": [...]}` block naming what was left changed and the call that puts
|
|
1875
1876
|
it back. No warnings block means every restore was confirmed.
|
|
1876
1877
|
|
|
@@ -12,7 +12,7 @@ that none exists).
|
|
|
12
12
|
|
|
13
13
|
**Verified on:** DaVinci Resolve Studio 21.0.2
|
|
14
14
|
|
|
15
|
-
**Totals:**
|
|
15
|
+
**Totals:** 42 missing capabilities, 56 bugs / unreliable behaviors.
|
|
16
16
|
|
|
17
17
|
The authoritative source is the runtime-queryable `api_truth` ledger
|
|
18
18
|
(`resolve_control api_truth "<query>"`); this document is generated from
|
|
@@ -255,6 +255,15 @@ equivalent, blocking full automation.
|
|
|
255
255
|
- **Workaround / current handling:** Check GetRenderCodecs(format) first; when it is empty, treat the format as unreachable through this API rather than guessing a codec value. Render audio-only via ExportVideo=False on a format that does expose codecs, or drive it from a saved render preset.
|
|
256
256
|
- **Tags:** render, deliver, audio, unsupported
|
|
257
257
|
|
|
258
|
+
### Project.AddRenderJob (the only readback for render settings)
|
|
259
|
+
|
|
260
|
+
- **Object:** `Project`
|
|
261
|
+
- **Signature:** `() -> str`
|
|
262
|
+
- **Behavior:** There is no GetRenderSettings, but a queued job carries the settings it inherited. Measured 2026-09-30 on Studio 19.1.3.7: after AddRenderJob, the matching GetRenderJobList entry reports TargetDir, OutputFilename (the custom name, or the timeline name when none was ever set, plus the format's extension), MarkIn/MarkOut, VideoFormat/VideoCodec, RenderMode and PresetName, and DeleteRenderJob removes it. The round trip took about 150 ms and switches Resolve to the Deliver page. Queuing two identical jobs, or a job whose output file already exists, raised no dialog and returned distinct ids. Limits: AddRenderJob returns '' when no TargetDir has ever been set, and also in Individual-clips mode on a generator-only timeline, so neither state can be read this way. Once set, TargetDir cannot be cleared — SetRenderSettings returns False for '' and for None — though a TargetDir that does not exist is accepted. CustomName has no direct readback: it is only visible folded into OutputFilename.
|
|
263
|
+
- **Workaround / current handling:** To preserve a user's output folder across work that has to change it: in single-clip mode, queue a job, read TargetDir off its entry, delete the job, and write TargetDir back afterwards. Leave CustomName alone wherever possible — it can be neither read nor cleared; render into a private folder and take the file that appears instead of naming it.
|
|
264
|
+
- **Reference:** [issue #270](https://github.com/samuelgursky/davinci-resolve-mcp/issues/270)
|
|
265
|
+
- **Tags:** render, deliver, readback, unsupported
|
|
266
|
+
|
|
258
267
|
### TimelineItem.SetCDL (write-only — no GetCDL anywhere)
|
|
259
268
|
|
|
260
269
|
- **Object:** `TimelineItem`
|
|
@@ -703,7 +712,7 @@ values, or automation-hostile modal prompts.
|
|
|
703
712
|
- **Object:** `Project`
|
|
704
713
|
- **Signature:** `({settings}) -> bool`
|
|
705
714
|
- **Behavior:** SetRenderSettings returns False for {'CustomName': ''} and for {'CustomName': None}, and when that key rides in a larger payload the WHOLE payload is rejected, not just the name. Measured 2026-09-30 on Studio 19.1.3.7: with the render range pinned to one frame, {SelectAllFrames: True, MarkIn: start, MarkOut: end, CustomName: ''} returned False and a job added afterwards still carried MarkIn == MarkOut == the pinned frame; the same payload without CustomName returned True and the job carried the whole timeline. A single space IS accepted, and becomes the file name. So a custom name, once set, cannot be cleared through this API, and there is no GetRenderSettings to read the previous one back from.
|
|
706
|
-
- **Workaround / current handling:** Never send an empty CustomName, and never bundle a best-effort key with keys that matter: send
|
|
715
|
+
- **Workaround / current handling:** Never send an empty CustomName, and never bundle a best-effort key with keys that matter: send each setting in its own payload and check its return. Better, do not write CustomName at all when the name is not yours to keep. To see what a job will inherit, AddRenderJob, read MarkIn/MarkOut/TargetDir/OutputFilename off GetRenderJobList, then DeleteRenderJob.
|
|
707
716
|
- **Reference:** [issue #270](https://github.com/samuelgursky/davinci-resolve-mcp/issues/270)
|
|
708
717
|
- **Tags:** render, deliver, silent-failure, unreliable-return
|
|
709
718
|
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "4.8.
|
|
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/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
|
|
@@ -14977,6 +14977,47 @@ def _render_job_completed(status: Optional[Dict[str, Any]]) -> bool:
|
|
|
14977
14977
|
return False
|
|
14978
14978
|
|
|
14979
14979
|
|
|
14980
|
+
def _render_target_dir(proj, teardown: List[str]) -> Optional[str]:
|
|
14981
|
+
"""The project's current render TargetDir, read off a throwaway render job.
|
|
14982
|
+
|
|
14983
|
+
There is no GetRenderSettings, but a queued job carries the settings it
|
|
14984
|
+
inherited: queue one, read TargetDir off its GetRenderJobList entry, delete
|
|
14985
|
+
it. Measured on Studio 19.1.3.7 (api_truth 'Project.AddRenderJob (the only
|
|
14986
|
+
readback for render settings)'): about 150 ms, no dialog even when an
|
|
14987
|
+
identical job is already queued or the output file already exists.
|
|
14988
|
+
|
|
14989
|
+
Returns None when no job can be queued. That is what a project with no
|
|
14990
|
+
render target does (AddRenderJob returns ''), and then there is nothing to
|
|
14991
|
+
put back. A job that was queued and could not be removed again is reported
|
|
14992
|
+
through `teardown`: it is the one thing this read can leave behind.
|
|
14993
|
+
"""
|
|
14994
|
+
try:
|
|
14995
|
+
job = proj.AddRenderJob()
|
|
14996
|
+
except Exception:
|
|
14997
|
+
return None
|
|
14998
|
+
if not job:
|
|
14999
|
+
return None
|
|
15000
|
+
target = None
|
|
15001
|
+
try:
|
|
15002
|
+
for entry in proj.GetRenderJobList() or []:
|
|
15003
|
+
if isinstance(entry, dict) and entry.get("JobId") == job:
|
|
15004
|
+
target = entry.get("TargetDir")
|
|
15005
|
+
break
|
|
15006
|
+
except Exception:
|
|
15007
|
+
target = None
|
|
15008
|
+
try:
|
|
15009
|
+
removed = bool(proj.DeleteRenderJob(job))
|
|
15010
|
+
except Exception:
|
|
15011
|
+
removed = False
|
|
15012
|
+
if not removed:
|
|
15013
|
+
logger.warning("frame capture could not remove its throwaway render job %s", job)
|
|
15014
|
+
teardown.append(
|
|
15015
|
+
f"A render job ({job}) queued only to read the output folder could not be "
|
|
15016
|
+
"removed from the render queue. Delete it with render(action='delete_job')."
|
|
15017
|
+
)
|
|
15018
|
+
return target if isinstance(target, str) and target else None
|
|
15019
|
+
|
|
15020
|
+
|
|
14980
15021
|
def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
14981
15022
|
teardown: Optional[List[str]] = None):
|
|
14982
15023
|
"""Render exactly one frame — the only frame-accurate capture route.
|
|
@@ -14991,24 +15032,30 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
14991
15032
|
runs in well under a second, and needs no GUI panel or foreground window.
|
|
14992
15033
|
|
|
14993
15034
|
The cost is that render settings are project-level state, and there is still
|
|
14994
|
-
no GetRenderSettings to read them back from (absent as of 21.1).
|
|
14995
|
-
|
|
15035
|
+
no GetRenderSettings to read them back from (absent as of 21.1). Each one
|
|
15036
|
+
the capture touches is put back by whatever route exists:
|
|
14996
15037
|
- Format and codec are readable via GetCurrentRenderFormatAndCodec and are
|
|
14997
15038
|
genuinely restored.
|
|
14998
15039
|
- The mark range is readable via Timeline.GetMarkInOut, so a range the
|
|
14999
15040
|
caller had set is put back (offset into SetRenderSettings' absolute
|
|
15000
15041
|
frame space); with no range set it falls back to the whole timeline.
|
|
15001
|
-
- TargetDir
|
|
15002
|
-
|
|
15003
|
-
|
|
15042
|
+
- TargetDir is read off a throwaway render job before the capture changes
|
|
15043
|
+
it (_render_target_dir) and written back afterwards. A project that
|
|
15044
|
+
has never had a render target has nothing to read, and Resolve cannot
|
|
15045
|
+
clear one once set, so there it is left on the capture's folder.
|
|
15046
|
+
- CustomName is never written. It can be neither read nor cleared (an
|
|
15047
|
+
empty one is refused on 19.1.3.7), so the capture renders under
|
|
15048
|
+
whatever name the project already produces, into a folder of its own,
|
|
15049
|
+
and takes the one file that appears there.
|
|
15004
15050
|
Callers who need a strictly side-effect-free read should use
|
|
15005
15051
|
quality="thumbnail" and accept per-clip granularity.
|
|
15006
15052
|
|
|
15007
15053
|
`teardown` collects one sentence per restore that did not take (render
|
|
15008
|
-
mode, format/codec, render range, playhead, page). The
|
|
15009
|
-
`finally`, which cannot change the value already being
|
|
15010
|
-
caller passes the list in and attaches it to the result
|
|
15011
|
-
#270: the user was left on Deliver with nothing in the
|
|
15054
|
+
mode, format/codec, render range, output folder, playhead, page). The
|
|
15055
|
+
restores run in a `finally`, which cannot change the value already being
|
|
15056
|
+
returned, so the caller passes the list in and attaches it to the result
|
|
15057
|
+
afterwards (issue #270: the user was left on Deliver with nothing in the
|
|
15058
|
+
result to say so).
|
|
15012
15059
|
"""
|
|
15013
15060
|
if teardown is None:
|
|
15014
15061
|
teardown = []
|
|
@@ -15050,9 +15097,13 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15050
15097
|
except (TypeError, ValueError):
|
|
15051
15098
|
return _err("frame must be an integer", code="INVALID_FRAME", category="invalid_input")
|
|
15052
15099
|
|
|
15053
|
-
|
|
15100
|
+
# A directory of this capture's own, inside the shared capture folder. The
|
|
15101
|
+
# render is NOT given a CustomName (see the docstring), so the file comes
|
|
15102
|
+
# out under the project's own naming and cannot be picked out of a shared
|
|
15103
|
+
# folder by prefix; whatever appears in here is the frame.
|
|
15104
|
+
base = _resolve_safe_dir(os.path.join(tempfile.gettempdir(), "resolve-frame-captures"))
|
|
15105
|
+
folder = os.path.join(base, f"{STILL_STAGING_PREFIX}{_uuid.uuid4().hex}")
|
|
15054
15106
|
os.makedirs(folder, exist_ok=True)
|
|
15055
|
-
name = f"capture-{int(time.time() * 1000)}"
|
|
15056
15107
|
|
|
15057
15108
|
# Where the user is comes FIRST, before any render call. Issue #270: this
|
|
15058
15109
|
# was read after GetCurrentRenderMode(), and that getter itself switches
|
|
@@ -15078,9 +15129,9 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15078
15129
|
except Exception:
|
|
15079
15130
|
pass
|
|
15080
15131
|
# Render MODE is project state too, and it decides whether the capture can
|
|
15081
|
-
# work at all. In "Individual clips" mode (0) Resolve
|
|
15082
|
-
#
|
|
15083
|
-
#
|
|
15132
|
+
# work at all. In "Individual clips" mode (0) Resolve renders the WHOLE
|
|
15133
|
+
# clip under its own per-clip file naming, and the single-frame file this
|
|
15134
|
+
# helper waits for never appears — measured
|
|
15084
15135
|
# 2026-09-09 on a project whose delivery preset was per-clip: every capture
|
|
15085
15136
|
# reported success, wrote no file, and took 30+ s rendering the clip.
|
|
15086
15137
|
# Force single clip (1) for the capture and put the mode back afterwards.
|
|
@@ -15126,6 +15177,7 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15126
15177
|
|
|
15127
15178
|
job = None
|
|
15128
15179
|
range_pinned = False
|
|
15180
|
+
original_target = None
|
|
15129
15181
|
try:
|
|
15130
15182
|
if original_mode is not None and original_mode != 1:
|
|
15131
15183
|
if not proj.SetCurrentRenderMode(1):
|
|
@@ -15137,6 +15189,11 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15137
15189
|
remediation="render(action='set_mode', params={'mode': 1}) then retry.",
|
|
15138
15190
|
state={"render_mode": original_mode},
|
|
15139
15191
|
)
|
|
15192
|
+
# Read the output folder while the settings are still the user's, and
|
|
15193
|
+
# in single-clip mode: in individual-clips mode a job may not queue at
|
|
15194
|
+
# all (generator-only timeline, 19.1.3.7), and then there is no entry
|
|
15195
|
+
# to read it from.
|
|
15196
|
+
original_target = _render_target_dir(proj, teardown)
|
|
15140
15197
|
codecs = proj.GetRenderCodecs("JPEG" if fmt == "jpg" else fmt.upper()) or {}
|
|
15141
15198
|
codec = list(codecs.values())[0] if codecs else fmt
|
|
15142
15199
|
if not proj.SetCurrentRenderFormatAndCodec(fmt, codec):
|
|
@@ -15147,7 +15204,6 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15147
15204
|
)
|
|
15148
15205
|
applied = proj.SetRenderSettings({
|
|
15149
15206
|
"TargetDir": folder,
|
|
15150
|
-
"CustomName": name,
|
|
15151
15207
|
"MarkIn": frame,
|
|
15152
15208
|
"MarkOut": frame,
|
|
15153
15209
|
"SelectAllFrames": False,
|
|
@@ -15164,14 +15220,12 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15164
15220
|
job = proj.AddRenderJob()
|
|
15165
15221
|
if not job:
|
|
15166
15222
|
return _err("AddRenderJob returned nothing", code="RENDER_JOB_FAILED", category="api_error")
|
|
15167
|
-
# The
|
|
15168
|
-
# ~/Documents/resolve-stills) and
|
|
15169
|
-
#
|
|
15170
|
-
#
|
|
15171
|
-
#
|
|
15172
|
-
# don't assume.
|
|
15223
|
+
# The parent is shared (every sandbox path redirects to one
|
|
15224
|
+
# ~/Documents/resolve-stills) and each capture's cleanup removes it
|
|
15225
|
+
# when it empties. Measured 2026-09-09, when captures still shared the
|
|
15226
|
+
# folder itself: frame 81 of a 214-frame QC batch died in os.listdir on
|
|
15227
|
+
# a folder another capture had just removed. Recreate, don't assume.
|
|
15173
15228
|
os.makedirs(folder, exist_ok=True)
|
|
15174
|
-
before = set(os.listdir(folder))
|
|
15175
15229
|
# Positional on purpose: the free-edition bridge proxies method calls
|
|
15176
15230
|
# positionally, and a keyword argument dies inside _BoundMethod with
|
|
15177
15231
|
# "unexpected keyword argument 'isInteractiveMode'" before reaching
|
|
@@ -15194,15 +15248,19 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15194
15248
|
code="RENDER_FAILED", category="api_error",
|
|
15195
15249
|
state={"status": status, "frame": frame},
|
|
15196
15250
|
)
|
|
15197
|
-
#
|
|
15198
|
-
|
|
15251
|
+
# The folder is this capture's alone, so whatever is in it is the
|
|
15252
|
+
# frame — named by the project (custom name or timeline name, plus the
|
|
15253
|
+
# frame number), which is not ours to predict.
|
|
15254
|
+
written = sorted(
|
|
15255
|
+
os.path.join(root, f) for root, _dirs, files in os.walk(folder) for f in files
|
|
15256
|
+
)
|
|
15199
15257
|
if not written:
|
|
15200
15258
|
return _err(
|
|
15201
15259
|
"Render reported success but wrote no file",
|
|
15202
15260
|
code="RENDER_FAILED", category="api_error",
|
|
15203
15261
|
state={"folder": folder, "frame": frame},
|
|
15204
15262
|
)
|
|
15205
|
-
src_path =
|
|
15263
|
+
src_path = written[0]
|
|
15206
15264
|
out_format = "jpg" if fmt == "jpg" else "png"
|
|
15207
15265
|
if max_width or fmt == "tif":
|
|
15208
15266
|
data, ff_err = _ffmpeg_scale_to_bytes(src_path, int(max_width) if max_width else None, out_format)
|
|
@@ -15257,11 +15315,10 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15257
15315
|
f"({fmt}); restoring {original_fc.get('format')}/{original_fc.get('codec')} "
|
|
15258
15316
|
"failed, so the next render job would inherit it."
|
|
15259
15317
|
)
|
|
15260
|
-
#
|
|
15261
|
-
#
|
|
15262
|
-
#
|
|
15263
|
-
#
|
|
15264
|
-
# frame for the next render job to inherit.
|
|
15318
|
+
# The mark range: put the user's own range back when they had one, and
|
|
15319
|
+
# fall back to the whole timeline when they did not, so the range is
|
|
15320
|
+
# never left pinned to the captured frame for the next render job to
|
|
15321
|
+
# inherit.
|
|
15265
15322
|
# (original_marks is already in SetRenderSettings' absolute space — see
|
|
15266
15323
|
# the offset above.)
|
|
15267
15324
|
#
|
|
@@ -15270,6 +15327,7 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15270
15327
|
# rejecting the WHOLE payload (measured on Studio 19.1.3.7: False, and a
|
|
15271
15328
|
# job added afterwards still carried MarkIn == MarkOut == the captured
|
|
15272
15329
|
# frame). So the range was never put back, and the False was discarded.
|
|
15330
|
+
# One setting per payload, each return checked.
|
|
15273
15331
|
try:
|
|
15274
15332
|
if original_marks:
|
|
15275
15333
|
restored_marks = {
|
|
@@ -15298,21 +15356,28 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15298
15356
|
"restoring it failed, so the next render job would render one "
|
|
15299
15357
|
"frame. Set the range again before rendering."
|
|
15300
15358
|
)
|
|
15301
|
-
#
|
|
15302
|
-
#
|
|
15303
|
-
|
|
15304
|
-
|
|
15305
|
-
|
|
15306
|
-
|
|
15359
|
+
# The output folder, when there was one to read. Without this the
|
|
15360
|
+
# user's next render job inherits a temporary folder that the cleanup
|
|
15361
|
+
# just below removes.
|
|
15362
|
+
if range_pinned and original_target:
|
|
15363
|
+
try:
|
|
15364
|
+
target_restored = bool(proj.SetRenderSettings({"TargetDir": original_target}))
|
|
15365
|
+
target_exc = None
|
|
15366
|
+
except Exception as exc:
|
|
15367
|
+
target_restored, target_exc = False, exc
|
|
15368
|
+
if not target_restored:
|
|
15369
|
+
logger.warning(
|
|
15370
|
+
"frame capture could not restore the render output folder to %s: %s",
|
|
15371
|
+
original_target, target_exc or "SetRenderSettings returned False",
|
|
15372
|
+
)
|
|
15373
|
+
teardown.append(
|
|
15374
|
+
"The render output folder was left on the capture's temporary "
|
|
15375
|
+
f"folder; restoring {original_target!r} failed. Set it again with "
|
|
15376
|
+
"render(action='set_settings', params={'settings': {'TargetDir': ...}})."
|
|
15377
|
+
)
|
|
15378
|
+
_discard_still_staging(folder)
|
|
15307
15379
|
try:
|
|
15308
|
-
|
|
15309
|
-
if f.startswith(name):
|
|
15310
|
-
try:
|
|
15311
|
-
os.remove(os.path.join(folder, f))
|
|
15312
|
-
except OSError:
|
|
15313
|
-
pass
|
|
15314
|
-
if not os.listdir(folder):
|
|
15315
|
-
os.rmdir(folder)
|
|
15380
|
+
os.rmdir(base) # only ever succeeds when nothing else is using it
|
|
15316
15381
|
except OSError:
|
|
15317
15382
|
pass
|
|
15318
15383
|
if not _restore_playhead(tl, original_tc, what="the render capture"):
|
|
@@ -26709,13 +26774,13 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
|
26709
26774
|
Choosing a quality — the trade-off is accuracy against side effects:
|
|
26710
26775
|
|
|
26711
26776
|
'frame'/'preview' Frame-exact. Renders one frame, so it changes
|
|
26712
|
-
project-level render settings
|
|
26713
|
-
|
|
26714
|
-
|
|
26715
|
-
|
|
26716
|
-
|
|
26717
|
-
|
|
26718
|
-
|
|
26777
|
+
project-level render settings, and puts them back: render
|
|
26778
|
+
mode, format, codec, mark range and output folder
|
|
26779
|
+
(TargetDir). The file name (CustomName) is never touched.
|
|
26780
|
+
One exception: a project that has never had an output
|
|
26781
|
+
folder has none to put back, and Resolve cannot clear one,
|
|
26782
|
+
so it is left on the capture's temporary folder. Refuses
|
|
26783
|
+
while another render is running.
|
|
26719
26784
|
'thumbnail' Changes nothing and returns instantly, but it is NOT frame
|
|
26720
26785
|
accurate: GetCurrentClipThumbnailImage returns the clip's
|
|
26721
26786
|
thumbnail, identical for every frame of that clip (measured
|
|
@@ -26751,16 +26816,21 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
|
26751
26816
|
"ffmpeg": bool(shutil.which("ffmpeg")),
|
|
26752
26817
|
"max_width_supported": bool(shutil.which("ffmpeg")),
|
|
26753
26818
|
"current_page": current_page,
|
|
26754
|
-
# What the render route ('frame'/'preview')
|
|
26755
|
-
#
|
|
26756
|
-
#
|
|
26819
|
+
# What the render route ('frame'/'preview') leaves as it found it.
|
|
26820
|
+
# TargetDir is read off a throwaway render job and written back;
|
|
26821
|
+
# CustomName is never written. The one gap is a project with no
|
|
26822
|
+
# TargetDir yet: there is none to read and Resolve cannot clear it.
|
|
26757
26823
|
"render_settings_restorable": {
|
|
26758
26824
|
"render_mode": True,
|
|
26759
26825
|
"format_codec": True,
|
|
26760
26826
|
"mark_range": True,
|
|
26761
|
-
"TargetDir":
|
|
26762
|
-
"CustomName":
|
|
26827
|
+
"TargetDir": True,
|
|
26828
|
+
"CustomName": True,
|
|
26763
26829
|
},
|
|
26830
|
+
"render_settings_caveat": (
|
|
26831
|
+
"A project that has never had a render TargetDir is left with the "
|
|
26832
|
+
"capture's temporary folder as its TargetDir."
|
|
26833
|
+
),
|
|
26764
26834
|
}
|
|
26765
26835
|
_, tl, err = _get_tl()
|
|
26766
26836
|
if err:
|
package/src/utils/api_truth.py
CHANGED
|
@@ -2702,16 +2702,53 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
2702
2702
|
"set, cannot be cleared through this API, and there is no "
|
|
2703
2703
|
"GetRenderSettings to read the previous one back from.",
|
|
2704
2704
|
"recommended": "Never send an empty CustomName, and never bundle a "
|
|
2705
|
-
"best-effort key with keys that matter: send
|
|
2706
|
-
"its own payload and check its return.
|
|
2707
|
-
"
|
|
2708
|
-
"
|
|
2705
|
+
"best-effort key with keys that matter: send each setting "
|
|
2706
|
+
"in its own payload and check its return. Better, do not "
|
|
2707
|
+
"write CustomName at all when the name is not yours to "
|
|
2708
|
+
"keep. To see what a job will inherit, AddRenderJob, read "
|
|
2709
|
+
"MarkIn/MarkOut/TargetDir/OutputFilename off "
|
|
2710
|
+
"GetRenderJobList, then DeleteRenderJob.",
|
|
2709
2711
|
"tags": ["render", "deliver", "silent-failure", "unreliable-return"],
|
|
2710
2712
|
"submit": "bug",
|
|
2711
2713
|
"issue": 270,
|
|
2712
2714
|
"verified_on": "DaVinci Resolve Studio 19.1.3.7",
|
|
2713
2715
|
"mitigation": ["_playhead_frame_render"],
|
|
2714
2716
|
},
|
|
2717
|
+
{
|
|
2718
|
+
"symbol": "Project.AddRenderJob (the only readback for render settings)",
|
|
2719
|
+
"object": "Project",
|
|
2720
|
+
"signature": "() -> str",
|
|
2721
|
+
"reality": "There is no GetRenderSettings, but a queued job carries the "
|
|
2722
|
+
"settings it inherited. Measured 2026-09-30 on Studio 19.1.3.7: "
|
|
2723
|
+
"after AddRenderJob, the matching GetRenderJobList entry "
|
|
2724
|
+
"reports TargetDir, OutputFilename (the custom name, or the "
|
|
2725
|
+
"timeline name when none was ever set, plus the format's "
|
|
2726
|
+
"extension), MarkIn/MarkOut, VideoFormat/VideoCodec, "
|
|
2727
|
+
"RenderMode and PresetName, and DeleteRenderJob removes it. "
|
|
2728
|
+
"The round trip took about 150 ms and switches Resolve to the "
|
|
2729
|
+
"Deliver page. Queuing two identical jobs, or a job whose "
|
|
2730
|
+
"output file already exists, raised no dialog and returned "
|
|
2731
|
+
"distinct ids. Limits: AddRenderJob returns '' when no "
|
|
2732
|
+
"TargetDir has ever been set, and also in Individual-clips "
|
|
2733
|
+
"mode on a generator-only timeline, so neither state can be "
|
|
2734
|
+
"read this way. Once set, TargetDir cannot be cleared — "
|
|
2735
|
+
"SetRenderSettings returns False for '' and for None — though "
|
|
2736
|
+
"a TargetDir that does not exist is accepted. CustomName has "
|
|
2737
|
+
"no direct readback: it is only visible folded into "
|
|
2738
|
+
"OutputFilename.",
|
|
2739
|
+
"recommended": "To preserve a user's output folder across work that has "
|
|
2740
|
+
"to change it: in single-clip mode, queue a job, read "
|
|
2741
|
+
"TargetDir off its entry, delete the job, and write "
|
|
2742
|
+
"TargetDir back afterwards. Leave CustomName alone "
|
|
2743
|
+
"wherever possible — it can be neither read nor cleared; "
|
|
2744
|
+
"render into a private folder and take the file that "
|
|
2745
|
+
"appears instead of naming it.",
|
|
2746
|
+
"tags": ["render", "deliver", "readback", "unsupported"],
|
|
2747
|
+
"submit": "missing",
|
|
2748
|
+
"issue": 270,
|
|
2749
|
+
"verified_on": "DaVinci Resolve Studio 19.1.3.7",
|
|
2750
|
+
"mitigation": ["_render_target_dir", "_playhead_frame_render"],
|
|
2751
|
+
},
|
|
2715
2752
|
{
|
|
2716
2753
|
"symbol": "ProjectManager.SaveProject",
|
|
2717
2754
|
"object": "ProjectManager",
|