davinci-resolve-mcp 4.15.4 → 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,54 @@
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
+
5
53
  ## What's New in v4.15.4 — add_keyframe no longer leaves a stray key
6
54
 
7
55
  ### Fixed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.15.4-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.4-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.4 版 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.4"
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.4",
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.4"
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.4"
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":
@@ -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",
@@ -4031,6 +4087,14 @@ ACTION_SYMBOLS: Dict[Tuple[str, str], List[str]] = {
4031
4087
  ("timeline", "apply_look_to_items"): ["TimelineItem.CopyGrades"],
4032
4088
  ("timeline_item_color", "export_lut"): ["TimelineItem.ExportLUT"],
4033
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
+ ],
4034
4098
  ("timeline", "duplicate"): ["Timeline.DuplicateTimeline"],
4035
4099
  ("project_manager", "archive"): ["ProjectManager.ArchiveProject"],
4036
4100
  ("fusion_comp", "add_modifier"): ["Tool.AddModifier", "Tool.AddModifier (NestControl inputs)"],