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 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
- [![Version](https://img.shields.io/badge/version-4.15.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.15.5-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(390%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.15.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.15.5-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(390%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v4.15.3 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
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.3"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "4.15.3",
3
+ "version": "4.15.5",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -91,7 +91,7 @@ if not logging.getLogger().handlers:
91
91
  handlers=[logging.StreamHandler()],
92
92
  )
93
93
 
94
- VERSION = "4.15.3"
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.3"
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
- success = bool(graph.ApplyGradeFromDRX(path, p.get("grade_mode", p.get("mode", 0))))
29507
- return {"success": success, "path": path, "source": source}
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
- return {"success": bool(g.ApplyGradeFromDRX(p["path"], p.get("grade_mode", p.get("mode", 0))))}
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
- return _ok()
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
 
@@ -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"): ["Tool.AddModifier (NestControl inputs)"],
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"): [