davinci-resolve-mcp 4.15.3 → 4.15.5
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 +78 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +222 -10
- package/src/utils/api_truth.py +126 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,84 @@
|
|
|
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.15.5 — DRX applies say which graph changed
|
|
6
|
+
|
|
7
|
+
`graph.apply_grade_from_drx` and `timeline_item_color.safe_apply_drx` no
|
|
8
|
+
longer report a bare `success` from `ApplyGradeFromDRX`. They read the graphs
|
|
9
|
+
back, and they refuse a timeline apply that would load no timeline grade.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **A timeline apply of a .drx with no timeline grade is refused** (#318).
|
|
14
|
+
A .drx keeps the clip grade in `<pClipFullVer>` and the timeline grade in
|
|
15
|
+
`<pTrackVer>`. `drx generate`'s single-node path writes no `<pTrackVer>`,
|
|
16
|
+
and native stills and the multi-node path write one with
|
|
17
|
+
`HasCorrection false`. On Studio 21.1.1.10 such an apply was reported to
|
|
18
|
+
return True, add nothing and empty the timeline graph. With
|
|
19
|
+
`source="timeline"`, both actions now return `DRX_NO_TIMELINE_GRADE` before
|
|
20
|
+
the confirm token is issued and before Resolve is called.
|
|
21
|
+
- **DRX applies read back every graph the grade could land in** (#319).
|
|
22
|
+
The node count, labels and LUTs of the clip graph, the clip's color-group
|
|
23
|
+
pre- and post-clip graphs and the timeline graph are read before and
|
|
24
|
+
after. The result carries `changed_graphs`, a per-graph before/after count
|
|
25
|
+
and a `verification` block, so the result no longer says
|
|
26
|
+
`readback_checked: false`. When only a graph you did not target changed,
|
|
27
|
+
the call fails with `DRX_APPLIED_TO_OTHER_GRAPH`. When no graph changed it
|
|
28
|
+
still reports success, with a `warning`: a grade with the same node
|
|
29
|
+
structure changes only parameters, which no readback reaches.
|
|
30
|
+
|
|
31
|
+
### Documentation
|
|
32
|
+
|
|
33
|
+
- Two `api_truth` entries for the reported behaviour, mapped to both apply
|
|
34
|
+
actions, with what Studio 19.1.3.7 did when we measured it.
|
|
35
|
+
- New live harness `tests/live_drx_apply_readback_validation.py`.
|
|
36
|
+
|
|
37
|
+
### Validation
|
|
38
|
+
|
|
39
|
+
- Full offline suite: 4,242 tests, OK. 12 new unit tests cover the file
|
|
40
|
+
check, both refusals, the group pre-clip landing case, a verified change,
|
|
41
|
+
the no-change warning, a color-group target and `safe_apply_drx`.
|
|
42
|
+
- Live on Studio 19.1.3.7, disposable project, one synthetic clip, a grade
|
|
43
|
+
from `drx generate`: 0 failures. The refusal fired and left the timeline
|
|
44
|
+
graph alone; the clip apply was detected through the node label
|
|
45
|
+
(`changed_graphs: ["item"]`); the same-structure re-apply warned; a
|
|
46
|
+
grouped clip's readback covered both group graphs.
|
|
47
|
+
- Neither reported behaviour reproduces on 19.1.3.7. Every timeline-graph
|
|
48
|
+
apply returned False and changed nothing, with or without a timeline
|
|
49
|
+
grade, so a successful timeline apply was not observed. A grouped clip took
|
|
50
|
+
the grade in its own graph. Both were reported on Studio 21.1.1.10, which
|
|
51
|
+
was not available for this release.
|
|
52
|
+
|
|
53
|
+
## What's New in v4.15.4 — add_keyframe no longer leaves a stray key
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- **`fusion_comp(action="add_keyframe")` removes the key Fusion adds when an
|
|
58
|
+
input is first animated** (#317, thanks @assassin007-hub). Attaching an
|
|
59
|
+
empty BezierSpline keys the comp's current time with the input's old value,
|
|
60
|
+
so keying frames 10 and 20 left keys `[0, 10, 20]` and the curve ramped from
|
|
61
|
+
the old value instead of holding until the first intended key. The first
|
|
62
|
+
call on an input now deletes that key after the real one is written, reads
|
|
63
|
+
the keys back, and returns `removed_stray_keys`, or `stray_keys_remaining`
|
|
64
|
+
with a `warning` pointing to `delete_keyframe` if a key survives. A key
|
|
65
|
+
written at the current time simply overwrites it. Text Follower range
|
|
66
|
+
bounds (±1e9) are not treated as keys.
|
|
67
|
+
|
|
68
|
+
### Documentation
|
|
69
|
+
|
|
70
|
+
- Two measured `api_truth` entries: BezierSpline LH/RH handles are offsets
|
|
71
|
+
from their key, not absolute points; and the stray key an empty spline
|
|
72
|
+
attach leaves, now attached to `fusion_comp add_keyframe`.
|
|
73
|
+
|
|
74
|
+
### Validation
|
|
75
|
+
|
|
76
|
+
- CI passed on Python 3.10 and 3.11 and on the advanced Node suite under
|
|
77
|
+
Node 20 and 22. Full offline suite run against main: 4,230 tests, with only
|
|
78
|
+
the 4 `source_media_guard` failures that always occur in a scratch worktree.
|
|
79
|
+
- Live behavior was measured by the contributor on Studio 21.0.2.4 (keys
|
|
80
|
+
`[0, 10, 20]` before, `[10, 20]` after `DeleteKeyFrames`). It was not
|
|
81
|
+
re-run on 19.1.3.7 for this release.
|
|
82
|
+
|
|
5
83
|
## What's New in v4.15.3 — CI covers the version floors
|
|
6
84
|
|
|
7
85
|
No server behavior changed. Pull requests are now tested on the oldest Python
|
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.15.
|
|
15
|
+
> 本翻译对应 v4.15.5 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "4.15.
|
|
40
|
+
VERSION = "4.15.5"
|
|
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
|
@@ -91,7 +91,7 @@ if not logging.getLogger().handlers:
|
|
|
91
91
|
handlers=[logging.StreamHandler()],
|
|
92
92
|
)
|
|
93
93
|
|
|
94
|
-
VERSION = "4.15.
|
|
94
|
+
VERSION = "4.15.5"
|
|
95
95
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
96
96
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
97
97
|
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.15.
|
|
14
|
+
VERSION = "4.15.5"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -29221,6 +29221,160 @@ def _color_graph_from_params(proj, item, p: Dict[str, Any]):
|
|
|
29221
29221
|
return None, source, _err(f"Unknown color graph source: {source}")
|
|
29222
29222
|
|
|
29223
29223
|
|
|
29224
|
+
def _drx_track_grade_state(path) -> str:
|
|
29225
|
+
"""What a .drx holds for a TIMELINE apply: "present", "empty", "missing" or "unreadable".
|
|
29226
|
+
|
|
29227
|
+
A .drx keeps the clip grade in <pClipFullVer> and the timeline grade in
|
|
29228
|
+
<pTrackVer>. A timeline ApplyGradeFromDRX loads <pTrackVer>, and when that
|
|
29229
|
+
block has HasCorrection false it replaces the timeline graph with an empty
|
|
29230
|
+
one and still returns True (issue #318).
|
|
29231
|
+
"""
|
|
29232
|
+
try:
|
|
29233
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
29234
|
+
text = fh.read()
|
|
29235
|
+
except OSError:
|
|
29236
|
+
return "unreadable"
|
|
29237
|
+
block = re.search(r"<pTrackVer>(.*?)</pTrackVer>", text, re.DOTALL)
|
|
29238
|
+
if not block:
|
|
29239
|
+
return "missing"
|
|
29240
|
+
has = re.search(r"<HasCorrection>\s*(true|false)\s*</HasCorrection>", block.group(1), re.IGNORECASE)
|
|
29241
|
+
return "present" if has and has.group(1).lower() == "true" else "empty"
|
|
29242
|
+
|
|
29243
|
+
|
|
29244
|
+
def _refuse_empty_track_grade(path, source):
|
|
29245
|
+
"""Error envelope when a timeline apply would load no timeline grade, else None."""
|
|
29246
|
+
if source != "timeline":
|
|
29247
|
+
return None
|
|
29248
|
+
state = _drx_track_grade_state(path)
|
|
29249
|
+
if state not in ("missing", "empty"):
|
|
29250
|
+
return None
|
|
29251
|
+
return _err(
|
|
29252
|
+
"This .drx carries no timeline grade (<pTrackVer> is "
|
|
29253
|
+
f"{'absent' if state == 'missing' else 'empty, HasCorrection false'}). Applying it to the "
|
|
29254
|
+
"timeline graph would change nothing and replace the existing timeline graph with an empty one.",
|
|
29255
|
+
code="DRX_NO_TIMELINE_GRADE", category="invalid_input",
|
|
29256
|
+
remediation="Apply it to a clip (source='item') instead, or use a .drx exported from a "
|
|
29257
|
+
"timeline grade. To clear the timeline graph on purpose, use reset_all_grades.",
|
|
29258
|
+
state={"path": path, "track_grade": state},
|
|
29259
|
+
)
|
|
29260
|
+
|
|
29261
|
+
|
|
29262
|
+
def _graph_fingerprint(g):
|
|
29263
|
+
"""Node count plus each node's label and LUT; None when the graph can't be read.
|
|
29264
|
+
|
|
29265
|
+
GetToolsInNode is left out on purpose: it crashes Resolve 19.1.3 on some graphs.
|
|
29266
|
+
"""
|
|
29267
|
+
if not g:
|
|
29268
|
+
return None
|
|
29269
|
+
try:
|
|
29270
|
+
count = int(g.GetNumNodes() or 0)
|
|
29271
|
+
except Exception:
|
|
29272
|
+
return None
|
|
29273
|
+
nodes = []
|
|
29274
|
+
for node_index in range(1, count + 1):
|
|
29275
|
+
row = {}
|
|
29276
|
+
for key, method_name in (("label", "GetNodeLabel"), ("lut", "GetLUT")):
|
|
29277
|
+
if _has_method(g, method_name):
|
|
29278
|
+
try:
|
|
29279
|
+
row[key] = _ser(getattr(g, method_name)(node_index))
|
|
29280
|
+
except Exception:
|
|
29281
|
+
row[key] = None
|
|
29282
|
+
nodes.append(row)
|
|
29283
|
+
return {"num_nodes": count, "nodes": nodes}
|
|
29284
|
+
|
|
29285
|
+
|
|
29286
|
+
def _drx_candidate_graphs(source, *, item=None, tl=None, group=None, layer_index=None):
|
|
29287
|
+
"""{name: getter} for every graph a .drx apply could land in, the target included.
|
|
29288
|
+
|
|
29289
|
+
The clip graph, its color group's pre/post-clip graphs and the timeline graph
|
|
29290
|
+
are all candidates: on a clip in a group the grade can land in the group's
|
|
29291
|
+
pre-clip graph instead of the clip's (issue #319).
|
|
29292
|
+
"""
|
|
29293
|
+
out = {}
|
|
29294
|
+
if item is not None:
|
|
29295
|
+
out["item"] = lambda: item.GetNodeGraph(layer_index) if layer_index is not None else item.GetNodeGraph()
|
|
29296
|
+
if group is None:
|
|
29297
|
+
try:
|
|
29298
|
+
group = item.GetColorGroup()
|
|
29299
|
+
except Exception:
|
|
29300
|
+
group = None
|
|
29301
|
+
if group is not None:
|
|
29302
|
+
out["color_group_pre"] = group.GetPreClipNodeGraph
|
|
29303
|
+
out["color_group_post"] = group.GetPostClipNodeGraph
|
|
29304
|
+
if tl is None:
|
|
29305
|
+
try:
|
|
29306
|
+
_, tl, _tl_err = _get_tl()
|
|
29307
|
+
except Exception:
|
|
29308
|
+
tl = None
|
|
29309
|
+
if tl is not None:
|
|
29310
|
+
out["timeline"] = tl.GetNodeGraph
|
|
29311
|
+
if source not in out:
|
|
29312
|
+
out[source] = None
|
|
29313
|
+
return out
|
|
29314
|
+
|
|
29315
|
+
|
|
29316
|
+
def _apply_drx_with_readback(graph, source, path, grade_mode, candidates):
|
|
29317
|
+
"""ApplyGradeFromDRX, with every candidate graph read before and after.
|
|
29318
|
+
|
|
29319
|
+
The native True does not say where the grade went or whether anything
|
|
29320
|
+
changed, so the result reports which graphs changed. A grade that lands only
|
|
29321
|
+
in another graph is a failure. Nothing changing is reported but not failed:
|
|
29322
|
+
a same-structure apply changes only parameters, which the API cannot read.
|
|
29323
|
+
"""
|
|
29324
|
+
def read(getter):
|
|
29325
|
+
try:
|
|
29326
|
+
return _graph_fingerprint(getter()) if getter else None
|
|
29327
|
+
except Exception:
|
|
29328
|
+
return None
|
|
29329
|
+
|
|
29330
|
+
if candidates.get(source) is None:
|
|
29331
|
+
candidates = {**candidates, source: lambda: graph}
|
|
29332
|
+
before = {name: read(getter) for name, getter in candidates.items()}
|
|
29333
|
+
native = bool(graph.ApplyGradeFromDRX(path, grade_mode))
|
|
29334
|
+
after = {name: read(getter) for name, getter in candidates.items()}
|
|
29335
|
+
|
|
29336
|
+
graphs = {}
|
|
29337
|
+
changed, unreadable = [], []
|
|
29338
|
+
for name in candidates:
|
|
29339
|
+
b, a = before[name], after[name]
|
|
29340
|
+
if b is None or a is None:
|
|
29341
|
+
unreadable.append(name)
|
|
29342
|
+
graphs[name] = {"readable": False}
|
|
29343
|
+
continue
|
|
29344
|
+
graphs[name] = {"num_nodes_before": b["num_nodes"], "num_nodes_after": a["num_nodes"],
|
|
29345
|
+
"changed": b != a}
|
|
29346
|
+
if b != a:
|
|
29347
|
+
changed.append(name)
|
|
29348
|
+
|
|
29349
|
+
landed_elsewhere = bool(changed) and source not in changed
|
|
29350
|
+
verification = {
|
|
29351
|
+
"verified": source in changed,
|
|
29352
|
+
"contradiction": landed_elsewhere,
|
|
29353
|
+
"method": "node count, labels and LUTs of each graph before and after",
|
|
29354
|
+
}
|
|
29355
|
+
result = {"success": native, "path": path, "source": source, "changed_graphs": changed,
|
|
29356
|
+
"graphs": graphs, "verification": verification}
|
|
29357
|
+
if unreadable:
|
|
29358
|
+
result["unreadable_graphs"] = unreadable
|
|
29359
|
+
if landed_elsewhere:
|
|
29360
|
+
err = _err(
|
|
29361
|
+
f"The grade was applied to the {', '.join(changed)} graph, not the {source} graph that "
|
|
29362
|
+
"was targeted. Resolve returned " + ("True" if native else "False") + ".",
|
|
29363
|
+
code="DRX_APPLIED_TO_OTHER_GRAPH", category="resolve_api_failed", retryable=False,
|
|
29364
|
+
remediation="Check the Node Editor's graph dropdown (Clip / Group Pre-Clip / Group "
|
|
29365
|
+
"Post-Clip / Timeline) on the Color page, then restore the changed graph "
|
|
29366
|
+
"from a still or grade version before retrying.",
|
|
29367
|
+
state={"targeted": source, "changed_graphs": changed},
|
|
29368
|
+
)
|
|
29369
|
+
return {**result, **err, "success": False}
|
|
29370
|
+
if native and not changed:
|
|
29371
|
+
result["warning"] = (
|
|
29372
|
+
"No graph changed its node count, labels or LUTs. Either the grade has the same node "
|
|
29373
|
+
"structure as before (only parameters changed, which the API cannot read back) or "
|
|
29374
|
+
"nothing was applied; confirm with a rendered frame or the scopes.")
|
|
29375
|
+
return result
|
|
29376
|
+
|
|
29377
|
+
|
|
29224
29378
|
def _grade_version_snapshot(item, p: Dict[str, Any]):
|
|
29225
29379
|
out = {"current": None, "local": [], "remote": [], "errors": []}
|
|
29226
29380
|
try:
|
|
@@ -29487,6 +29641,9 @@ def _safe_apply_drx(proj, item, p: Dict[str, Any]):
|
|
|
29487
29641
|
if not _has_method(graph, "ApplyGradeFromDRX"):
|
|
29488
29642
|
return _err(f"{source} graph does not expose ApplyGradeFromDRX",
|
|
29489
29643
|
code="APPLY_DRX_UNSUPPORTED", category="unsupported")
|
|
29644
|
+
refused = _refuse_empty_track_grade(path, source)
|
|
29645
|
+
if refused:
|
|
29646
|
+
return refused
|
|
29490
29647
|
if _coerce_bool(p.get("dry_run")):
|
|
29491
29648
|
return _ok(path=path, source=source, grade_mode=p.get("grade_mode", p.get("mode", 0)), would_apply=True)
|
|
29492
29649
|
# B2 — Whole-grade replacement is catastrophic; require confirmation.
|
|
@@ -29503,8 +29660,13 @@ def _safe_apply_drx(proj, item, p: Dict[str, Any]):
|
|
|
29503
29660
|
blocked = _consume_confirm_token(action="safe_apply_drx", params=p)
|
|
29504
29661
|
if blocked:
|
|
29505
29662
|
return blocked
|
|
29506
|
-
|
|
29507
|
-
|
|
29663
|
+
group = None
|
|
29664
|
+
if source in ("color_group_pre", "color_group_post"):
|
|
29665
|
+
group = next((g for g in proj.GetColorGroupsList() or [] if g.GetName() == p.get("group_name")), None)
|
|
29666
|
+
candidates = _drx_candidate_graphs(
|
|
29667
|
+
source, item=item, group=group,
|
|
29668
|
+
layer_index=p.get("layer_index") if source == "item" else None)
|
|
29669
|
+
return _apply_drx_with_readback(graph, source, path, p.get("grade_mode", p.get("mode", 0)), candidates)
|
|
29508
29670
|
|
|
29509
29671
|
|
|
29510
29672
|
# ── color_trace apply driver ─────────────────────────────────────────────────
|
|
@@ -29920,7 +30082,7 @@ _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = {
|
|
|
29920
30082
|
"safe_apply_drx": {
|
|
29921
30083
|
"summary": "Apply a .drx grade. REPLACES the target graph; gated by confirm_token.",
|
|
29922
30084
|
"params": "path: str, source?, grade_mode? (0=no keyframes, 1=source TC aligned, 2=start aligned), require_temp_path?, confirm_token?",
|
|
29923
|
-
"returns": "{success, path, source}",
|
|
30085
|
+
"returns": "{success, path, source, changed_graphs, graphs, verification, warning?} (same readback and refusals as graph.apply_grade_from_drx)",
|
|
29924
30086
|
"example": (
|
|
29925
30087
|
'timeline_item_color(action="safe_apply_drx", params={\n'
|
|
29926
30088
|
' "track_type": "video", "track_index": 1, "item_index": 0,\n'
|
|
@@ -30110,7 +30272,7 @@ _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = {
|
|
|
30110
30272
|
"apply_grade_from_drx": {
|
|
30111
30273
|
"summary": "REPLACES the entire node graph from a .drx. Confirm_token required.",
|
|
30112
30274
|
"params": "path, grade_mode? (0/1/2), source?",
|
|
30113
|
-
"returns": "{success}",
|
|
30275
|
+
"returns": "{success, path, source, changed_graphs, graphs, verification, warning?} — changed_graphs names every graph whose node count, labels or LUTs changed; DRX_APPLIED_TO_OTHER_GRAPH when only a graph you did not target changed; DRX_NO_TIMELINE_GRADE when source=timeline and the .drx has no timeline grade",
|
|
30114
30276
|
"example": (
|
|
30115
30277
|
'graph(action="apply_grade_from_drx", params={\n'
|
|
30116
30278
|
' "path": "/tmp/look.drx", "grade_mode": 0, "source": "item"\n'
|
|
@@ -30726,7 +30888,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
30726
30888
|
propose_grade -> {accepted: bool, validation, plan_id?, preview_path?, error?}
|
|
30727
30889
|
safe_set_cdl -> {success, validation, normalized, node_preflight, diagnosis?}
|
|
30728
30890
|
safe_copy_grade -> {success, targets, missing}
|
|
30729
|
-
safe_apply_drx -> {success, path, source} # first call may return confirm_token
|
|
30891
|
+
safe_apply_drx -> {success, path, source, changed_graphs, graphs, verification, warning?} # first call may return confirm_token
|
|
30730
30892
|
apply_trace_plan -> {success, timeline, applied, failed, skipped, summary} # dry_run → {resolution}; first live call returns confirm_token
|
|
30731
30893
|
grade_capabilities -> {item_methods, graph_sources, lut_export_types, guards}
|
|
30732
30894
|
grade_boundary_report -> {capabilities, item, color_groups, gallery}
|
|
@@ -30766,7 +30928,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
30766
30928
|
safe_copy_grade(target_ids, dry_run?, ...) -> {success, targets, missing}
|
|
30767
30929
|
Copies grade to N items; dry_run reports targets without mutating.
|
|
30768
30930
|
# example: action_help(name='<action_name>')
|
|
30769
|
-
safe_apply_drx(path, source?, grade_mode?, require_temp_path?) -> {success}
|
|
30931
|
+
safe_apply_drx(path, source?, grade_mode?, require_temp_path?) -> {success, path, source, changed_graphs, graphs, verification, warning?}
|
|
30770
30932
|
REPLACES the target graph. Captures a version snapshot first; require_temp_path defaults True.
|
|
30771
30933
|
# example: action_help(name='<action_name>')
|
|
30772
30934
|
apply_trace_plan(plan_path|plan, dry_run?, min_confidence?=0.8, start_tolerance?=0, grade_mode?=0, version_name?, require_temp_path?=True, allow_timeline_mismatch?, max_rows?=40, verbose?, report_path?, confirm_token?) -> {success, applied, failed, skipped, attention, summary, report_path}
|
|
@@ -31332,7 +31494,10 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
31332
31494
|
UNSAFE. Assigns a LUT to an existing node. No snapshot/dry_run.
|
|
31333
31495
|
set_node_cache(node_index, cache_value, source?, ...) -> {success}
|
|
31334
31496
|
set_node_enabled(node_index, enabled, source?, ...) -> {success}
|
|
31335
|
-
apply_grade_from_drx(path, grade_mode?, source?, ...) -> {success}
|
|
31497
|
+
apply_grade_from_drx(path, grade_mode?, source?, ...) -> {success, path, source, changed_graphs, graphs, verification, warning?}
|
|
31498
|
+
Reads the clip, color-group pre/post and timeline graphs before and after and
|
|
31499
|
+
reports changed_graphs; fails if the grade landed only in a graph not targeted.
|
|
31500
|
+
source="timeline" refuses a .drx whose <pTrackVer> has no correction.
|
|
31336
31501
|
CATASTROPHIC. REPLACES the entire node graph. Prefer timeline_item_color.safe_apply_drx
|
|
31337
31502
|
(confirm-token gated + validated; it does NOT snapshot — grab a still/.drx backup
|
|
31338
31503
|
first, and ALWAYS pass track_type/track_index/item_index explicitly: the default
|
|
@@ -31359,6 +31524,7 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
31359
31524
|
|
|
31360
31525
|
# Get the graph object based on source
|
|
31361
31526
|
g = None
|
|
31527
|
+
item = group = None
|
|
31362
31528
|
if source == "timeline":
|
|
31363
31529
|
_, tl, err = _get_tl()
|
|
31364
31530
|
if err:
|
|
@@ -31376,6 +31542,7 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
31376
31542
|
groups = proj.GetColorGroupsList() or []
|
|
31377
31543
|
for cg in groups:
|
|
31378
31544
|
if cg.GetName() == p.get("group_name"):
|
|
31545
|
+
group = cg
|
|
31379
31546
|
g = cg.GetPreClipNodeGraph() if source == "color_group_pre" else cg.GetPostClipNodeGraph()
|
|
31380
31547
|
break
|
|
31381
31548
|
if g is None:
|
|
@@ -31429,6 +31596,9 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
31429
31596
|
elif action == "set_node_enabled":
|
|
31430
31597
|
return {"success": bool(g.SetNodeEnabled(p["node_index"], p["enabled"]))}
|
|
31431
31598
|
elif action == "apply_grade_from_drx":
|
|
31599
|
+
refused = _refuse_empty_track_grade(p["path"], source)
|
|
31600
|
+
if refused:
|
|
31601
|
+
return refused
|
|
31432
31602
|
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
31433
31603
|
return _issue_confirm_token(
|
|
31434
31604
|
action="graph.apply_grade_from_drx",
|
|
@@ -31440,7 +31610,9 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
31440
31610
|
blocked = _consume_confirm_token(action="graph.apply_grade_from_drx", params=p)
|
|
31441
31611
|
if blocked:
|
|
31442
31612
|
return blocked
|
|
31443
|
-
|
|
31613
|
+
candidates = _drx_candidate_graphs(source, item=item, group=group,
|
|
31614
|
+
layer_index=p.get("layer_index"))
|
|
31615
|
+
return _apply_drx_with_readback(g, source, p["path"], p.get("grade_mode", p.get("mode", 0)), candidates)
|
|
31444
31616
|
elif action == "apply_arri_cdl_lut":
|
|
31445
31617
|
return {"success": bool(g.ApplyArriCdlLut())}
|
|
31446
31618
|
elif action == "reset_all_grades":
|
|
@@ -32535,6 +32707,32 @@ def _fusion_input_spline(inp):
|
|
|
32535
32707
|
return None
|
|
32536
32708
|
|
|
32537
32709
|
|
|
32710
|
+
def _fusion_remove_stray_keys(inp, stray: List[float]) -> Dict[str, Any]:
|
|
32711
|
+
"""Delete the keys Fusion added when add_keyframe attached a spline.
|
|
32712
|
+
|
|
32713
|
+
The result is read back rather than trusted: a key that is still there is
|
|
32714
|
+
reported, so a caller is never told the curve holds only what it wrote.
|
|
32715
|
+
"""
|
|
32716
|
+
spline = _fusion_input_spline(inp)
|
|
32717
|
+
if spline is not None and _has_method(spline, "DeleteKeyFrames"):
|
|
32718
|
+
for frame in stray:
|
|
32719
|
+
try:
|
|
32720
|
+
spline.DeleteKeyFrames(frame)
|
|
32721
|
+
except Exception:
|
|
32722
|
+
pass
|
|
32723
|
+
remaining = [f for f in _fusion_keyframe_frames(inp)
|
|
32724
|
+
if any(abs(f - s) < 1e-6 for s in stray)]
|
|
32725
|
+
removed = [s for s in stray if not any(abs(s - f) < 1e-6 for f in remaining)]
|
|
32726
|
+
if not remaining:
|
|
32727
|
+
return _ok(removed_stray_keys=removed)
|
|
32728
|
+
return _ok(
|
|
32729
|
+
removed_stray_keys=removed,
|
|
32730
|
+
stray_keys_remaining=remaining,
|
|
32731
|
+
warning=(f"Attaching the spline also keyed frame(s) {remaining} with the old value "
|
|
32732
|
+
"and they could not be removed; remove them with delete_keyframe."),
|
|
32733
|
+
)
|
|
32734
|
+
|
|
32735
|
+
|
|
32538
32736
|
def _fusion_delete_keyframe(tool, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
32539
32737
|
"""Remove one keyframe from an animated Fusion input. (issue #155)
|
|
32540
32738
|
|
|
@@ -32953,6 +33151,7 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
32953
33151
|
_already_animated = inp.GetConnectedOutput() is not None
|
|
32954
33152
|
except Exception:
|
|
32955
33153
|
_already_animated = False
|
|
33154
|
+
_stray = []
|
|
32956
33155
|
if not _already_animated:
|
|
32957
33156
|
_is_nest, _members = _fusion_nest_members(tool, p["input_name"])
|
|
32958
33157
|
if _is_nest:
|
|
@@ -32986,8 +33185,21 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
32986
33185
|
"attach it with add_modifier, then drive the "
|
|
32987
33186
|
"modifier tool it returns.",
|
|
32988
33187
|
)
|
|
33188
|
+
# Attaching an empty spline keys the comp's CURRENT time with the
|
|
33189
|
+
# input's old value (measured on Studio 21.0.2.4: keying 10 and 20
|
|
33190
|
+
# left [0, 10, 20]). Note it now; it is removed after the real key
|
|
33191
|
+
# lands, since DeleteKeyFrames will not remove a spline's only key.
|
|
33192
|
+
# Only a spline holds keys; a text Follower answers GetKeyFrames
|
|
33193
|
+
# with its +/-1e9 render-range bounds, which are not keys.
|
|
33194
|
+
_spline = _fusion_input_spline(tool[p["input_name"]])
|
|
33195
|
+
if _spline is not None and _has_method(_spline, "DeleteKeyFrames"):
|
|
33196
|
+
_stray = [f for f in _fusion_keyframe_frames(tool[p["input_name"]])
|
|
33197
|
+
if abs(f) < 1e8]
|
|
32989
33198
|
tool[p["input_name"]][p["time"]] = p["value"]
|
|
32990
|
-
|
|
33199
|
+
_stray = [f for f in _stray if abs(f - float(p["time"])) >= 1e-6]
|
|
33200
|
+
if not _stray:
|
|
33201
|
+
return _ok()
|
|
33202
|
+
return _fusion_remove_stray_keys(tool[p["input_name"]], _stray)
|
|
32991
33203
|
finally:
|
|
32992
33204
|
comp.EndUndo(True)
|
|
32993
33205
|
|
package/src/utils/api_truth.py
CHANGED
|
@@ -2016,6 +2016,62 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
2016
2016
|
"reports {page: {before, switched, restored}}.",
|
|
2017
2017
|
"tags": ["silent-failure", "color", "grade", "page", "drx", "version"],
|
|
2018
2018
|
},
|
|
2019
|
+
{
|
|
2020
|
+
"symbol": "Graph.ApplyGradeFromDRX (timeline graph, empty pTrackVer)",
|
|
2021
|
+
"object": "Graph",
|
|
2022
|
+
"signature": "Timeline.GetNodeGraph().ApplyGradeFromDRX(path, gradeMode) -> bool",
|
|
2023
|
+
"reality": "A .drx holds the clip grade in <pClipFullVer> (VerType 0) and the "
|
|
2024
|
+
"timeline grade in <pTrackVer> (VerType 1); a timeline-graph apply "
|
|
2025
|
+
"loads <pTrackVer>. When that block has HasCorrection false, which is "
|
|
2026
|
+
"what drx generate (direct) writes and what a clip still carries, "
|
|
2027
|
+
"the apply returns True, adds no grade and replaces the existing "
|
|
2028
|
+
"timeline graph with an empty one, deleting any node on it. Copying "
|
|
2029
|
+
"the clip <Body> into <pTrackVer> with HasCorrection true applied as "
|
|
2030
|
+
"expected (0 -> 1 node, no other graph changed).",
|
|
2031
|
+
"recommended": "Check the file's <pTrackVer> before a timeline apply. "
|
|
2032
|
+
"graph.apply_grade_from_drx and timeline_item_color.safe_apply_drx "
|
|
2033
|
+
"with source='timeline' refuse a .drx whose <pTrackVer> is absent or "
|
|
2034
|
+
"has HasCorrection false (DRX_NO_TIMELINE_GRADE). Clear the "
|
|
2035
|
+
"timeline graph on purpose with reset_all_grades.",
|
|
2036
|
+
"tags": ["silent-failure", "color", "grade", "drx", "node-graph", "timeline"],
|
|
2037
|
+
"issue": 318,
|
|
2038
|
+
"mitigation": ["_refuse_empty_track_grade"],
|
|
2039
|
+
"verified_on": "DaVinci Resolve Studio 21.1.1.10 (issue #318; not re-measured here)",
|
|
2040
|
+
"measured": "2026-10-27 on Studio 19.1.3.7 (tests/live_drx_apply_readback_validation.py): "
|
|
2041
|
+
"every Timeline.GetNodeGraph().ApplyGradeFromDRX returned False and left the "
|
|
2042
|
+
"timeline graph at 0 nodes, with the timeline grade present or empty, so "
|
|
2043
|
+
"the empty-graph replacement does not reproduce on that build. drx "
|
|
2044
|
+
"generate's single-node path writes no <pTrackVer> at all; the multi-node "
|
|
2045
|
+
"path writes one with HasCorrection false.",
|
|
2046
|
+
},
|
|
2047
|
+
{
|
|
2048
|
+
"symbol": "Graph.ApplyGradeFromDRX (grade lands in another graph)",
|
|
2049
|
+
"object": "Graph",
|
|
2050
|
+
"signature": "TimelineItem.GetNodeGraph().ApplyGradeFromDRX(path, gradeMode) -> bool",
|
|
2051
|
+
"reality": "The True return does not say which graph took the grade. On a clip "
|
|
2052
|
+
"in a color group, applying to the clip's graph replaced node 1 of "
|
|
2053
|
+
"the group's pre-clip graph instead (removing the LUT the group "
|
|
2054
|
+
"shared) and left the clip graph at one empty node. The manual says "
|
|
2055
|
+
"the Node Editor's graph dropdown (Clip / Group Pre-Clip / Group "
|
|
2056
|
+
"Post-Clip / Timeline) decides where a still's grade lands; whether "
|
|
2057
|
+
"the API follows the same rule has not been confirmed.",
|
|
2058
|
+
"recommended": "Read every graph the grade could land in before and after the "
|
|
2059
|
+
"apply. graph.apply_grade_from_drx and "
|
|
2060
|
+
"timeline_item_color.safe_apply_drx compare node count, labels and "
|
|
2061
|
+
"LUTs of the clip, group pre/post and timeline graphs, return "
|
|
2062
|
+
"changed_graphs, and fail with DRX_APPLIED_TO_OTHER_GRAPH when only "
|
|
2063
|
+
"an untargeted graph changed. No change at all is reported, not "
|
|
2064
|
+
"failed: a same-structure grade changes only parameters, which no "
|
|
2065
|
+
"readback reaches, so confirm with a rendered frame.",
|
|
2066
|
+
"tags": ["silent-failure", "color", "grade", "drx", "node-graph", "readback"],
|
|
2067
|
+
"issue": 319,
|
|
2068
|
+
"mitigation": ["_apply_drx_with_readback"],
|
|
2069
|
+
"verified_on": "DaVinci Resolve Studio 21.1.1.10 (issue #319; not re-measured here)",
|
|
2070
|
+
"measured": "2026-10-27 on Studio 19.1.3.7 (tests/live_drx_apply_readback_validation.py): "
|
|
2071
|
+
"a clip assigned to a color group took the grade in its own graph; the "
|
|
2072
|
+
"group pre/post graphs did not change. The readback detected the clip "
|
|
2073
|
+
"change through the node label.",
|
|
2074
|
+
},
|
|
2019
2075
|
{
|
|
2020
2076
|
"symbol": "MediaPool.AppendToTimeline clipInfo endFrame (exclusive bound)",
|
|
2021
2077
|
"object": "MediaPool",
|
|
@@ -3915,6 +3971,64 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
3915
3971
|
"verified_on": "DaVinci Resolve Studio 19.1.3.7",
|
|
3916
3972
|
"mitigation": ["_timeline_replace_source_range_impl"],
|
|
3917
3973
|
},
|
|
3974
|
+
{
|
|
3975
|
+
"symbol": "BezierSpline.SetKeyFrames (handle coordinates)",
|
|
3976
|
+
"object": "Fusion BezierSpline",
|
|
3977
|
+
"signature": "(keyframes, replace) -> None",
|
|
3978
|
+
"reality": "A key's LH/RH handles are OFFSETS from that key, {1: dTime, 2: dValue}, "
|
|
3979
|
+
"not absolute (time, value) points. GetKeyFrames() reads them back the "
|
|
3980
|
+
"same way: Fusion's own auto-created first key returns RH {1: 2.0, "
|
|
3981
|
+
"2: -0.333} for a key at frame 0 easing toward the next one. Absolute "
|
|
3982
|
+
"coordinates are accepted without complaint and read back verbatim, so "
|
|
3983
|
+
"nothing flags the mistake; the curve is simply wrong. Measured with keys "
|
|
3984
|
+
"0 -> 1 at frames 10 -> 20: flat handles written relative ({3, 0} / "
|
|
3985
|
+
"{-3, 0}) sample 0, 0.113, 0.5, 0.887, 1 at frames 10/12/15/18/20, a clean "
|
|
3986
|
+
"ease-in-out; the same intent written absolute ({13, 0} / {17, 1}) "
|
|
3987
|
+
"samples 0, 0.015, 0.089, 0.217, 1. A whip authored with absolute handles "
|
|
3988
|
+
"moved the wrong way (0.5 -> 0.56) before snapping to its end value.",
|
|
3989
|
+
"recommended": "Write handles relative to their key: RH = {segment * k, 0} and "
|
|
3990
|
+
"LH = {-segment * k, 0} gives a flat-tangent ease, where k is the handle "
|
|
3991
|
+
"length as a fraction of the segment (0.33 is a standard ease, 0.6 to "
|
|
3992
|
+
"0.75 an expo-like snap). Prove a curve by sampling "
|
|
3993
|
+
"tool.GetInput(name, frame) at a few frames, not by reading the keys "
|
|
3994
|
+
"back.",
|
|
3995
|
+
"tags": ["fusion", "keyframe", "silent-failure", "units"],
|
|
3996
|
+
"verified_on": "DaVinci Resolve Studio 21.0.2.4",
|
|
3997
|
+
"measured": "2026-10-26 on a Fusion composition clip: BezierSpline keyed 10 -> 20 "
|
|
3998
|
+
"with relative vs absolute flat handles, connected to Blur YBlurSize, "
|
|
3999
|
+
"sampled with GetInput at frames 10/12/15/18/20; whip Transform Center "
|
|
4000
|
+
"via XYPath confirmed in rendered frames.",
|
|
4001
|
+
},
|
|
4002
|
+
{
|
|
4003
|
+
"symbol": "BezierSpline attach (stray key at the current time)",
|
|
4004
|
+
"object": "Fusion Tool input / BezierSpline",
|
|
4005
|
+
"signature": "tool.AddModifier(input, 'BezierSpline') | tool[input] = comp.BezierSpline()",
|
|
4006
|
+
"reality": "Connecting an EMPTY spline to an input, by either route, adds a key at "
|
|
4007
|
+
"the comp's current time holding the input's old value. Keys set "
|
|
4008
|
+
"afterwards are added next to it, and SetKeyFrames(keys, True) does not "
|
|
4009
|
+
"remove it despite the replace flag. Measured with CurrentTime 0: "
|
|
4010
|
+
"AddModifier then input[10] = 5, input[20] = 9 left keys [0, 10, 20] with "
|
|
4011
|
+
"1.0 (the old value) at frame 0, so the value drifts from 1.0 before the "
|
|
4012
|
+
"first intended key instead of holding. The stray key can be deleted with "
|
|
4013
|
+
"spline.DeleteKeyFrames(time) once other keys exist; deleting it while it "
|
|
4014
|
+
"is the only key does nothing. A spline that already has keys when it is "
|
|
4015
|
+
"connected gets no extra key.",
|
|
4016
|
+
"recommended": "Build the spline first and connect it last: s = comp.BezierSpline(); "
|
|
4017
|
+
"s.SetKeyFrames(keys, True); tool[input] = s. When the spline is "
|
|
4018
|
+
"already attached, delete the stray key (DeleteKeyFrames(time)) after "
|
|
4019
|
+
"the intended keys are written, then confirm with GetKeyFrames(). "
|
|
4020
|
+
"fusion_comp add_keyframe does this itself: its first call on an "
|
|
4021
|
+
"input deletes the stray key, reports it as removed_stray_keys, and "
|
|
4022
|
+
"returns stray_keys_remaining plus a warning if it survives.",
|
|
4023
|
+
"tags": ["fusion", "keyframe", "silent-failure"],
|
|
4024
|
+
"mitigation": ["fusion_comp.add_keyframe", "_fusion_remove_stray_keys"],
|
|
4025
|
+
"verified_on": "DaVinci Resolve Studio 21.0.2.4",
|
|
4026
|
+
"measured": "2026-10-26 on a Fusion composition clip (CurrentTime 0), on a Blur: "
|
|
4027
|
+
"AddModifier + two indexed writes -> keys [0, 10, 20]; empty spline "
|
|
4028
|
+
"connected then SetKeyFrames(replace=True) -> [0, 10, 20]; "
|
|
4029
|
+
"DeleteKeyFrames(0.0) then -> [10, 20]; spline keyed before connect -> "
|
|
4030
|
+
"[10, 20].",
|
|
4031
|
+
},
|
|
3918
4032
|
|
|
3919
4033
|
]
|
|
3920
4034
|
|
|
@@ -3973,10 +4087,21 @@ ACTION_SYMBOLS: Dict[Tuple[str, str], List[str]] = {
|
|
|
3973
4087
|
("timeline", "apply_look_to_items"): ["TimelineItem.CopyGrades"],
|
|
3974
4088
|
("timeline_item_color", "export_lut"): ["TimelineItem.ExportLUT"],
|
|
3975
4089
|
("timeline_item_color", "safe_export_lut"): ["TimelineItem.ExportLUT"],
|
|
4090
|
+
("graph", "apply_grade_from_drx"): [
|
|
4091
|
+
"Graph.ApplyGradeFromDRX (timeline graph, empty pTrackVer)",
|
|
4092
|
+
"Graph.ApplyGradeFromDRX (grade lands in another graph)",
|
|
4093
|
+
],
|
|
4094
|
+
("timeline_item_color", "safe_apply_drx"): [
|
|
4095
|
+
"Graph.ApplyGradeFromDRX (timeline graph, empty pTrackVer)",
|
|
4096
|
+
"Graph.ApplyGradeFromDRX (grade lands in another graph)",
|
|
4097
|
+
],
|
|
3976
4098
|
("timeline", "duplicate"): ["Timeline.DuplicateTimeline"],
|
|
3977
4099
|
("project_manager", "archive"): ["ProjectManager.ArchiveProject"],
|
|
3978
4100
|
("fusion_comp", "add_modifier"): ["Tool.AddModifier", "Tool.AddModifier (NestControl inputs)"],
|
|
3979
|
-
("fusion_comp", "add_keyframe"): [
|
|
4101
|
+
("fusion_comp", "add_keyframe"): [
|
|
4102
|
+
"Tool.AddModifier (NestControl inputs)",
|
|
4103
|
+
"BezierSpline attach (stray key at the current time)",
|
|
4104
|
+
],
|
|
3980
4105
|
("project_manager", "safe_project_archive"): ["ProjectManager.ArchiveProject"],
|
|
3981
4106
|
("render", "start"): ["Project.StartRendering"],
|
|
3982
4107
|
("resolve_control", "set_high_priority"): [
|