davinci-resolve-mcp 2.212.4 → 2.213.0

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,59 @@
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 v2.213.0 — a ColorTrace that matches on media, then applies
6
+
7
+ ### Added
8
+
9
+ - **`color_trace` matches on media identity, names last.** Native ColorTrace
10
+ keys on timecode, clip name and order inside one project, so a renamed clip,
11
+ a reordered cut, or a stringout cut into graded sections defeats it. The
12
+ advanced server's `color_trace plan` now reads both timelines from their
13
+ `Project.db` (any two projects, read-only, no Resolve) and matches in tiers:
14
+ same media (pool item id or file path) with the same in-point and duration;
15
+ same media with overlapping source range (the best overlap wins, so each
16
+ section of a stringout finds its own grade); same reel plus overlap; same
17
+ file name plus overlap (relocated media); then, only as a fallback, exact and
18
+ normalised clip names. Every match reports its `method`, `confidence`,
19
+ `sourceOverlap` and an `ambiguous` flag when two candidates tie.
20
+ - **`plan.json` + a lossless `.drx` per graded match.** With `emitDir` set the
21
+ plan writes one `.drx` per match by copying the source clip's grade body
22
+ byte for byte (no decode/re-encode, so OFX/ResolveFX nodes survive) and a
23
+ `plan.json` that names each target clip by (name, record start, duration).
24
+ - **`timeline_item_color.apply_trace_plan` — the live half.** Resolves every
25
+ plan entry to a clip on the CURRENT timeline, returns a dry-run resolution
26
+ table (`apply` or `skip` with a reason: `live_item_not_found`,
27
+ `ambiguous_live_item`, `below_min_confidence`, `drx_missing`,
28
+ `drx_path_not_temp`, `no-source-grade`, `unmatched`), then behind one
29
+ `confirm_token` for the whole batch runs `ApplyGradeFromDRX` per clip.
30
+ Registered as a destructive action (timeline archived to the Archive bin
31
+ first, rated with the other whole-grade replacements, native dry-run).
32
+ `version_name` adds a local version per clip before applying so the previous
33
+ grade stays intact; `min_confidence` (default 0.8) gates the name-only tiers
34
+ out unless you lower it. Unresolved entries are reported, never guessed.
35
+
36
+ ### Changed
37
+
38
+ - **`project_read.timeline_clips` reads the ACTIVE grade version.** The clip
39
+ join now follows the version table's `pActive` and dedupes to one row per
40
+ item, so a clip carrying several corrected versions no longer reads back as
41
+ several clips. Rows also carry the pool item id (`poolId`) and `hasGrade`.
42
+ - **Name normalisation only strips `v`-prefixed version tokens.** Stripping any
43
+ trailing number folded `SHOT_010` and `SHOT_020` onto one key, so a
44
+ name-tier match could cross shots.
45
+
46
+ ### What was checked
47
+
48
+ - Offline: 11 new match-engine tests (stringout sections by overlap, straddling
49
+ ranges, ties → ambiguous, pool-id vs path, reel and basename tiers,
50
+ unreadable in-point degrading to `media-only`, name fallbacks, active-version
51
+ dedupe) and 9 driver tests (resolution reasons, stacked clips disambiguated
52
+ by duration, ambiguous live items skipped, token → apply with `version_name`,
53
+ partial failure reported, dispatch without an item). Both full suites green.
54
+ - **Not yet live-validated:** the end-to-end trace onto a real target timeline
55
+ with a render check afterwards. The plan step was live-verified against a
56
+ scratch DB in v2.136; the apply step is unit-tested against stubs only.
57
+
5
58
  ## What's New in v2.212.4 — both dependency manifests move together again
6
59
 
7
60
  ### Changed
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-2.212.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.213.0-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-36%20(353%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-2.212.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.213.0-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-36%20(353%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
- > 本翻译对应 v2.212.4 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.213.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/docs/SKILL.md CHANGED
@@ -1857,7 +1857,8 @@ Key actions:
1857
1857
  Color / Grade kernel actions (v2.11.0+) add safer grade inspection and
1858
1858
  boundary helpers: `grade_capabilities`, `probe_grade_item`,
1859
1859
  `probe_node_graph`, `safe_set_cdl`, `safe_copy_grade`, `safe_apply_drx`,
1860
- `safe_export_lut`, `grade_version_snapshot`, `grade_version_restore`,
1860
+ `apply_trace_plan` (the live half of the advanced server's identity-matched
1861
+ `color_trace`), `safe_export_lut`, `grade_version_snapshot`, `grade_version_restore`,
1861
1862
  `color_group_capabilities`, `gallery_capabilities`, and
1862
1863
  `grade_boundary_report`. See `docs/kernels/color-grade-kernel.md` for the live-tested
1863
1864
  support map, and `docs/guides/color-decision-guide.md` for the practical distinction
@@ -14,7 +14,7 @@ Current kernel coverage: **136 actions** across **9 compound MCP tools**.
14
14
  | Media Pool / ingest | `media_pool` | `ingest_capabilities`, `setup_multicam_timeline`, `probe_ingest_item`, `probe_media_pool`, `safe_import_media`, `safe_import_sequence`, `safe_import_folder`, `organize_clips`, `copy_metadata`, `normalize_metadata`, `probe_clip_properties`, `metadata_field_inventory`, `safe_relink`, `safe_unlink`, `link_proxy_checked`, `link_full_resolution_checked`, `set_clip_marks`, `clear_clip_marks`, `copy_clip_annotations`, `media_pool_boundary_report` |
15
15
  | Render / Deliver | `render` | `render_capabilities`, `probe_render_matrix`, `probe_render_settings`, `validate_render_settings`, `safe_set_render_settings`, `prepare_render_job`, `render_job_lifecycle_probe`, `quick_export_capabilities`, `safe_quick_export`, `export_render_boundary_report` |
16
16
  | Review annotations | `timeline_markers` | `annotation_capabilities`, `probe_annotations`, `normalize_marker_payload`, `copy_annotations`, `move_annotations`, `sync_marker_custom_data`, `clear_annotations_by_scope`, `export_review_report`, `annotation_boundary_report` |
17
- | Color / Grade | `timeline_item_color` | `grade_capabilities`, `probe_grade_item`, `probe_node_graph`, `safe_set_cdl`, `safe_copy_grade`, `safe_apply_drx`, `safe_export_lut`, `grade_version_snapshot`, `grade_version_restore`, `color_group_capabilities`, `gallery_capabilities`, `grade_boundary_report` |
17
+ | Color / Grade | `timeline_item_color` | `grade_capabilities`, `probe_grade_item`, `probe_node_graph`, `safe_set_cdl`, `safe_copy_grade`, `safe_apply_drx`, `apply_trace_plan`, `safe_export_lut`, `grade_version_snapshot`, `grade_version_restore`, `color_group_capabilities`, `gallery_capabilities`, `grade_boundary_report` |
18
18
  | Fusion composition | `fusion_comp` | `fusion_graph_capabilities`, `probe_fusion_comp`, `probe_fusion_tool`, `safe_add_tool`, `safe_set_inputs`, `safe_connect_tools`, `fusion_boundary_report` |
19
19
  | Conform / interchange | `timeline` | `conform_capabilities`, `probe_timeline_structure`, `detect_gaps_overlaps`, `source_range_report`, `export_timeline_checked`, `import_timeline_checked`, `compare_timelines`, `probe_interchange_roundtrip`, `detect_missing_media`, `build_relink_plan`, `conform_boundary_report` |
20
20
  | Audio / Fairlight | `timeline` | `audio_capabilities`, `probe_audio_item`, `probe_audio_track`, `safe_set_audio_properties`, `audio_mix_capability_report`, `voice_isolation_capabilities`, `audio_mapping_report`, `safe_auto_sync_audio`, `transcription_capabilities`, `subtitle_generation_probe`, `fairlight_boundary_report` |
@@ -33,6 +33,7 @@ All actions are exposed through `timeline_item_color`.
33
33
  | `safe_set_cdl` | Validate and normalize CDL payloads before calling `SetCDL`; supports dry run. |
34
34
  | `safe_copy_grade` | Resolve target timeline item IDs before calling `CopyGrades`; supports dry run. |
35
35
  | `safe_apply_drx` | Validate DRX file existence and temp-path guard before calling `ApplyGradeFromDRX`. |
36
+ | `apply_trace_plan` | Apply the advanced server's `color_trace` plan to the current timeline: resolve each entry to a live clip by (name, record start, duration), dry-run resolution table, one confirm_token for the batch, timeline archived first, then `ApplyGradeFromDRX` per clip; `version_name` adds a local version per clip so the previous grade survives. Unresolved entries are reported, never guessed. |
36
37
  | `safe_export_lut` | Resolve LUT export type aliases and require temp output paths by default. |
37
38
  | `grade_version_snapshot` | Read current, local, and remote grade version names. |
38
39
  | `grade_version_restore` | Safely load a named local/remote version after verifying it exists. |
@@ -114,7 +115,8 @@ The live actions above drive a *running* Resolve. The companion advanced server
114
115
  (`davinci-resolve-advanced`, see `resolve-advanced/README.md`) *computes* grades
115
116
  offline from extracted frames and reads/writes `.drx`/`.drp` grades with **no
116
117
  Resolve running**. It emits an apply-ready `.drx`; **applying it is this kernel's
117
- job** (`safe_apply_drx`). Node never drives Resolve.
118
+ job** (`safe_apply_drx` for one clip, `apply_trace_plan` for a whole `color_trace`
119
+ plan). Node never drives Resolve.
118
120
 
119
121
  `drx` grading/QC actions (frame-stats → arithmetic → `.drx`, all local,
120
122
  deterministic, guarded — they refuse to fabricate a match rather than emit a
@@ -138,8 +138,12 @@ files and the project DB.
138
138
  reference-render, scale-corrected, red/yellow/cyan verdicts; each cut is
139
139
  compared clear of its transition windows, and Resolve's own FCP7 export
140
140
  ingests with its `-1` edges resolved and its missing ticks tolerated).
141
- - **`color_trace`** — cross-project clip matching a trace plan for carrying
142
- grades across a re-conform (pairs with the color kernel's `drx grade_transfer`).
141
+ - **`color_trace`** — cross-project clip matching on media identity (pool id /
142
+ file path / reel / file name + source-range overlap; names last) → a trace
143
+ plan with a lossless `.drx` per graded match and a `plan.json`. The live half
144
+ is `timeline_item_color.apply_trace_plan` (dry-run resolution table → one
145
+ confirm_token → ApplyGradeFromDRX per resolved clip, timeline archived first).
146
+ Pairs with the color kernel's `drx grade_transfer` for single looks.
143
147
  - **`offline_ref`** — offline-reference clips have **no scripting API** but live
144
148
  inside `.drp`/`.drt` as `<OfflineClip>` entries; patch them here.
145
149
  - **`editorial`** — `parse_interchange` (EDL/OTIO/XMEML natively; **AAF via pyaaf2**,
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Read/Write Symmetry Audit
4
4
 
5
- - write-style action occurrences scanned: **116**
5
+ - write-style action occurrences scanned: **117**
6
6
  - write-style action occurrences with a matching read: **71**
7
7
  - distinct high-signal `set_` actions without a direct/known readback: **4**
8
8
 
@@ -13,6 +13,6 @@
13
13
  - `set_keyframe_interpolation`
14
14
  - `set_node_enabled`
15
15
 
16
- ## Low-signal (create/add/insert/apply/import — usually expected): 40 distinct names
16
+ ## Low-signal (create/add/insert/apply/import — usually expected): 41 distinct names
17
17
 
18
- `add_clip_mattes`, `add_comp`, `add_fusion_mask`, `add_subfolder`, `add_sync_event_markers`, `add_timeline_mattes`, `add_track`, `add_version`, `apply_arri_cdl_lut`, `apply_cuts`, `apply_fairlight_preset`, `apply_grade_from_drx`, `apply_look_to_items`, `apply_spec`, `create_compound_clip`, `create_fusion_clip`, `create_magic_mask`, `create_stereo_clip`, `create_subtitles`, `create_timeline`, `create_timeline_from_clips`, `create_variant_from_ranges`, `import_comp`, `import_folder`, `import_from_drp`, `import_into_timeline`, `import_media`, `import_preset`, `import_project`, `import_render`, `import_timeline`, `import_timeline_checked`, `import_to_pool`, `insert_audio`, `insert_fusion_composition`, `insert_fusion_generator`, `insert_fusion_title`, `insert_generator`, `insert_ofx_generator`, `insert_title`
18
+ `add_clip_mattes`, `add_comp`, `add_fusion_mask`, `add_subfolder`, `add_sync_event_markers`, `add_timeline_mattes`, `add_track`, `add_version`, `apply_arri_cdl_lut`, `apply_cuts`, `apply_fairlight_preset`, `apply_grade_from_drx`, `apply_look_to_items`, `apply_spec`, `apply_trace_plan`, `create_compound_clip`, `create_fusion_clip`, `create_magic_mask`, `create_stereo_clip`, `create_subtitles`, `create_timeline`, `create_timeline_from_clips`, `create_variant_from_ranges`, `import_comp`, `import_folder`, `import_from_drp`, `import_into_timeline`, `import_media`, `import_preset`, `import_project`, `import_render`, `import_timeline`, `import_timeline_checked`, `import_to_pool`, `insert_audio`, `insert_fusion_composition`, `insert_fusion_generator`, `insert_fusion_title`, `insert_generator`, `insert_ofx_generator`, `insert_title`
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.212.4"
40
+ VERSION = "2.213.0"
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": "2.212.4",
3
+ "version": "2.213.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -82,7 +82,13 @@ Each dispatches on an `action`. Highlights:
82
82
  - **`conform`** — offline conform/relink QC engine (frame-oracle math, not filename matching),
83
83
  reverse-clip DB repair, sequence lineage store + diff, per-cut frame QC (sampled clear of
84
84
  transition windows; ingests Resolve's own FCP7 export — `-1` edges → junctions, no ticks needed).
85
- - **`color_trace`** — cross-project clip matching a trace plan for carrying grades across a re-conform.
85
+ - **`color_trace`** — a ColorTrace that works: matches a graded SOURCE timeline against a TARGET
86
+ timeline (any two projects, from `Project.db`, read-only, no Resolve) on **media identity** —
87
+ pool item id / file path / reel / file name plus source-range overlap, so a stringout cut into
88
+ graded sections, a renamed clip, or relinked media still traces; clip names are the last resort.
89
+ Emits one lossless `.drx` per graded match plus a `plan.json`; the live server's
90
+ `timeline_item_color.apply_trace_plan` applies it (dry-run resolution table, one
91
+ confirm_token for the batch, timeline archived first).
86
92
  - **`project_read` / `project_db`** — read/patch the Resolve project DB (SQLite or Postgres).
87
93
  Includes `list_subtitle_styles` / `set_subtitle_style` — caption font family/size/weight/italic
88
94
  and normalised position, which the scripting API cannot touch at all. Whole-track (not
@@ -1,18 +1,29 @@
1
1
  /**
2
2
  * color_trace tool — a better ColorTrace. Match clips between a SOURCE and
3
- * TARGET timeline (cross-project, content-key-aware) → a trace plan mapping each
4
- * target clip to the source clip whose grade should carry over.
3
+ * TARGET timeline (cross-project, identity-aware) → a trace plan mapping each
4
+ * target clip to the source clip whose grade should carry over, plus a lossless
5
+ * .drx per graded match and a plan.json the live server's
6
+ * `timeline_item_color.apply_trace_plan` consumes.
5
7
  *
6
8
  * Resolve's native ColorTrace matches by TC/name/order within a project and breaks
7
9
  * on renames/reorders/retimes. This reads clip lists from ANY project's Project.db
8
- * (READ-only, no Resolve, even cross-project) and matches with a layered key.
10
+ * (READ-only, no Resolve, even cross-project) and matches on MEDIA IDENTITY first,
11
+ * names last:
9
12
  *
10
- * plan source timeline + target timeline { matches[], summary }
13
+ * media-exact same media (pool id or file path), same in-point + duration 1.00
14
+ * media-overlap same media, overlapping source range (best overlap wins) 0.95
15
+ * reel-overlap same reel name, overlapping source range 0.90
16
+ * basename-overlap same file name (relocated media), overlapping source range 0.85
17
+ * media-only same media, source range unreadable or disjoint 0.70
18
+ * exact-name clip name identical 0.75
19
+ * normalized-name clip name identical after version/separator normalisation 0.60
11
20
  *
12
- * NOTE (current scope): this is the MATCH engine. The grade read + apply
13
- * (drx-codec decode ApplyGradeFromDRX) is the next layer it needs graded source
14
- * data + the live API; each match carries a `gradeApply` stub describing what would
15
- * be traced.
21
+ * A stringout one long file cut into many graded sections is the case native
22
+ * ColorTrace loses: every section shares a name and a file. media-overlap picks
23
+ * the section whose source range covers the target's, and flags `ambiguous`
24
+ * when two candidates tie.
25
+ *
26
+ * plan — source timeline + target timeline → { matches[], summary, planPath? }
16
27
  */
17
28
 
18
29
  import fs from 'node:fs';
@@ -30,7 +41,20 @@ const side = (name) => ({
30
41
  const planSchema = z.object({
31
42
  ...side('source'),
32
43
  ...side('target'),
33
- emitDir: z.string().optional().describe('If set, write a .drx per matched+graded clip here (for ApplyGradeFromDRX via the live API)'),
44
+ emitDir: z
45
+ .string()
46
+ .optional()
47
+ .describe(
48
+ 'If set, write a .drx per matched+graded clip here plus plan.json (for timeline_item_color.apply_trace_plan on the live server). Use a temp-dir path: the live apply refuses .drx files outside the system temp dir by default.',
49
+ ),
50
+ minConfidence: z
51
+ .number()
52
+ .min(0)
53
+ .max(1)
54
+ .optional()
55
+ .describe(
56
+ 'Matches below this confidence get no .drx and status below-threshold (default 0 — everything is emitted, confidence is reported per match; the live apply has its own gate, default 0.8)',
57
+ ),
34
58
  });
35
59
 
36
60
  const xmlEscape = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -77,41 +101,180 @@ export const drxEnvelope = (label, bodyHex) => {
77
101
  };
78
102
 
79
103
  // Normalize a clip name to a match key: drop path + extension, lowercase,
80
- // collapse separators, strip trailing version tokens (_v2,.01, etc.).
81
- function normalize(name) {
104
+ // collapse separators, strip a trailing VERSION token (_v2, -V12). Only a
105
+ // v-prefixed token is a version: stripping any trailing number folded
106
+ // SHOT_010 and SHOT_020 onto one key and let a name-tier match cross shots.
107
+ export function normalize(name) {
82
108
  let n = String(name || '')
83
109
  .split('/')
84
110
  .pop()
85
111
  .replace(/\.[^.]+$/, '')
86
112
  .toLowerCase();
87
113
  n = n.replace(/[_\-.\s]+/g, ' ').trim();
88
- n = n.replace(/\s+v?\d{1,3}$/, '').trim();
114
+ n = n.replace(/\s+v\d{1,3}$/, '').trim();
89
115
  return n;
90
116
  }
91
117
 
118
+ const num = (v) => {
119
+ if (v === null || v === undefined || v === '') return null;
120
+ const n = Number(v);
121
+ return Number.isFinite(n) ? n : null;
122
+ };
123
+ const lower = (v) => (v === null || v === undefined ? '' : String(v).trim().toLowerCase());
124
+ const basename = (p) => lower(p).split(/[\\/]/).pop();
125
+
126
+ /** Source-frame range of a clip, or null when the in-point is unreadable. */
127
+ function sourceRange(c) {
128
+ const a = num(c.sourceIn);
129
+ const d = num(c.duration);
130
+ if (a === null || d === null || d <= 0) return null;
131
+ return [a, a + d];
132
+ }
133
+ function overlapFrames(r1, r2) {
134
+ if (!r1 || !r2) return 0;
135
+ return Math.max(0, Math.min(r1[1], r2[1]) - Math.max(r1[0], r2[0]));
136
+ }
137
+
138
+ const TIERS = [
139
+ // [method, confidence, key(clip) → string|null, needsOverlap]
140
+ ['media-exact', 1.0, null, 'exact'],
141
+ ['media-overlap', 0.95, null, 'overlap'],
142
+ ['reel-overlap', 0.9, (c) => lower(c.reel) || null, 'overlap'],
143
+ ['basename-overlap', 0.85, (c) => basename(c.mediaPath) || null, 'overlap'],
144
+ ['exact-name', 0.75, (c) => (c.name ? String(c.name) : null), 'none'],
145
+ ['media-only', 0.7, null, 'none'],
146
+ ['normalized-name', 0.6, (c) => normalize(c.name) || null, 'none'],
147
+ ];
148
+
149
+ /** Media identity keys a clip answers to: its pool item id and its file path.
150
+ * Either equal on both sides means "the same media". */
151
+ function mediaKeys(c) {
152
+ const keys = [];
153
+ if (c.poolId) keys.push(`pool:${lower(c.poolId)}`);
154
+ if (c.mediaPath) keys.push(`path:${lower(c.mediaPath)}`);
155
+ return keys;
156
+ }
157
+
92
158
  function buildIndex(clips) {
93
- const exact = new Map();
94
- const norm = new Map();
159
+ const idx = { media: new Map(), tiers: new Map() };
160
+ const add = (map, k, c) => {
161
+ if (!k) return;
162
+ if (!map.has(k)) map.set(k, []);
163
+ map.get(k).push(c);
164
+ };
95
165
  for (const c of clips) {
96
- if (!c.name) continue;
97
- if (!exact.has(c.name)) exact.set(c.name, c);
98
- const k = normalize(c.name);
99
- if (k && !norm.has(k)) norm.set(k, c);
166
+ for (const k of mediaKeys(c)) add(idx.media, k, c);
167
+ for (const [method, , keyFn] of TIERS) {
168
+ if (!keyFn) continue;
169
+ if (!idx.tiers.has(method)) idx.tiers.set(method, new Map());
170
+ add(idx.tiers.get(method), keyFn(c), c);
171
+ }
172
+ }
173
+ return idx;
174
+ }
175
+
176
+ /** Candidates that share media identity with the target (deduped). */
177
+ function mediaCandidates(target, idx) {
178
+ const seen = new Set();
179
+ const out = [];
180
+ for (const k of mediaKeys(target)) {
181
+ for (const c of idx.media.get(k) || []) {
182
+ const id = c.itemId || c;
183
+ if (seen.has(id)) continue;
184
+ seen.add(id);
185
+ out.push(c);
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ /** Pick the best candidate by source-range overlap. Ties (identical overlap)
192
+ * fall to the nearest record start and are flagged ambiguous. */
193
+ function bestByOverlap(target, candidates) {
194
+ const tr = sourceRange(target);
195
+ if (!tr) return null;
196
+ let best = null;
197
+ let bestOv = 0;
198
+ let ties = 0;
199
+ for (const c of candidates) {
200
+ const ov = overlapFrames(tr, sourceRange(c));
201
+ if (ov <= 0) continue;
202
+ if (ov > bestOv) {
203
+ best = c;
204
+ bestOv = ov;
205
+ ties = 0;
206
+ } else if (ov === bestOv) {
207
+ ties += 1;
208
+ const d = (x) => Math.abs(num(x.start) - num(target.start));
209
+ if (d(c) < d(best)) best = c;
210
+ }
100
211
  }
101
- return { exact, norm };
212
+ if (!best) return null;
213
+ return { src: best, overlap: bestOv, fraction: bestOv / (tr[1] - tr[0]), ambiguous: ties > 0, candidates: candidates.length };
102
214
  }
103
215
 
104
- function matchClip(target, idx) {
105
- if (target.name && idx.exact.has(target.name)) return { src: idx.exact.get(target.name), method: 'exact-name', confidence: 1.0 };
106
- const k = normalize(target.name);
107
- if (k && idx.norm.has(k)) return { src: idx.norm.get(k), method: 'normalized-name', confidence: 0.85 };
108
- return { src: null, method: null, confidence: 0 };
216
+ /** Match ONE target clip against the source index. Returns
217
+ * { src, method, confidence, ambiguous, candidates, sourceOverlap? } — src null when unmatched. */
218
+ export function matchClip(target, idx) {
219
+ const tr = sourceRange(target);
220
+ const tDur = num(target.duration);
221
+ const media = mediaCandidates(target, idx);
222
+
223
+ for (const [method, confidence, keyFn, mode] of TIERS) {
224
+ let candidates;
225
+ if (keyFn) candidates = idx.tiers.get(method)?.get(keyFn(target)) || [];
226
+ else candidates = media;
227
+ if (!candidates.length) continue;
228
+
229
+ if (mode === 'exact') {
230
+ const hits = candidates.filter((c) => tr && num(c.sourceIn) === tr[0] && num(c.duration) === tDur);
231
+ if (!hits.length) continue;
232
+ const src =
233
+ hits.length === 1 ? hits[0] : hits.reduce((a, b) => (Math.abs(num(b.start) - num(target.start)) < Math.abs(num(a.start) - num(target.start)) ? b : a));
234
+ return { src, method, confidence, ambiguous: hits.length > 1, candidates: hits.length, sourceOverlap: { frames: tDur, fraction: 1 } };
235
+ }
236
+ if (mode === 'overlap') {
237
+ const b = bestByOverlap(target, candidates);
238
+ if (!b) continue;
239
+ return {
240
+ src: b.src,
241
+ method,
242
+ confidence,
243
+ ambiguous: b.ambiguous,
244
+ candidates: b.candidates,
245
+ sourceOverlap: { frames: b.overlap, fraction: Number(b.fraction.toFixed(4)) },
246
+ };
247
+ }
248
+ // mode 'none' — first candidate, nearest record start on ties.
249
+ const src =
250
+ candidates.length === 1
251
+ ? candidates[0]
252
+ : candidates.reduce((a, b) => (Math.abs(num(b.start) - num(target.start)) < Math.abs(num(a.start) - num(target.start)) ? b : a));
253
+ return { src, method, confidence, ambiguous: candidates.length > 1, candidates: candidates.length };
254
+ }
255
+ return { src: null, method: null, confidence: 0, ambiguous: false, candidates: 0 };
256
+ }
257
+
258
+ /** Match every target clip against the source clips (pure; no DB). */
259
+ export function matchClips(srcClips, tgtClips) {
260
+ const idx = buildIndex(srcClips);
261
+ return tgtClips.map((t) => ({ target: t, ...matchClip(t, idx) }));
109
262
  }
110
263
 
264
+ const clipRef = (c) => ({
265
+ name: c.name,
266
+ start: num(c.start),
267
+ duration: num(c.duration),
268
+ sourceIn: num(c.sourceIn),
269
+ reel: c.reel || null,
270
+ mediaPath: c.mediaPath || null,
271
+ poolId: c.poolId || null,
272
+ });
273
+
111
274
  export const colorTraceTool = {
112
275
  name: 'color_trace',
113
276
  description:
114
- 'Better ColorTrace — match clips between a SOURCE and TARGET timeline (cross-project, from Project.db, read-only, no Resolve) → a trace plan for carrying grades across a re-conform. Action: plan.',
277
+ 'Better ColorTrace — match clips between a SOURCE and TARGET timeline (cross-project, from Project.db, read-only, no Resolve) on media identity (pool id / file path / reel / file name + source-range overlap), names last → a trace plan with a lossless .drx per graded match and a plan.json for timeline_item_color.apply_trace_plan on the live server. Action: plan.',
115
278
  async handler({ action, args }) {
116
279
  if (action === 'plan') {
117
280
  const p = planSchema.parse(args);
@@ -122,43 +285,63 @@ export const colorTraceTool = {
122
285
  if (!srcClips.length) throw new Error(`source timeline "${p.sourceTimeline}" has no video clips`);
123
286
  if (!tgtClips.length) throw new Error(`target timeline "${p.targetTimeline}" has no video clips`);
124
287
  if (p.emitDir) fs.mkdirSync(p.emitDir, { recursive: true });
288
+ const minConfidence = p.minConfidence ?? 0;
125
289
 
126
- const idx = buildIndex(srcClips);
127
- const byMethod = { 'exact-name': 0, 'normalized-name': 0, unmatched: 0 };
290
+ const byMethod = { unmatched: 0 };
291
+ for (const [m] of TIERS) byMethod[m] = 0;
128
292
  let gradesReady = 0;
129
- const matches = tgtClips.map((t, i) => {
130
- const m = matchClip(t, idx);
293
+ let ambiguous = 0;
294
+ let belowThreshold = 0;
295
+ const matches = matchClips(srcClips, tgtClips).map((m, i) => {
296
+ const t = m.target;
131
297
  byMethod[m.method || 'unmatched'] += 1;
298
+ if (m.ambiguous) ambiguous += 1;
132
299
  let gradeApply = null;
133
- if (m.src && m.src.gradeBody) {
300
+ if (m.src && m.confidence < minConfidence) {
301
+ belowThreshold += 1;
302
+ gradeApply = { status: 'below-threshold', sourceClip: m.src.name, confidence: m.confidence, minConfidence };
303
+ } else if (m.src && m.src.gradeBody) {
134
304
  gradesReady += 1;
135
- gradeApply = { status: 'ready', sourceClip: m.src.name };
305
+ gradeApply = { status: 'ready', sourceClip: m.src.name, sourceVersion: m.src.gradeVersion || null };
136
306
  if (p.emitDir) {
137
307
  // emit a .drx (lossless source-grade pass-through) for ApplyGradeFromDRX onto the target clip.
138
308
  const drxPath = path.join(p.emitDir, `trace-${String(i).padStart(4, '0')}.drx`);
139
309
  fs.writeFileSync(drxPath, drxEnvelope(m.src.name, m.src.gradeBody));
140
310
  gradeApply.drxPath = drxPath;
141
- gradeApply.applyVia = { api: 'timeline_item_color.safe_apply_drx', target: { timeline: p.targetTimeline, clipName: t.name, start: t.start } };
311
+ gradeApply.applyVia = {
312
+ api: 'timeline_item_color.apply_trace_plan',
313
+ target: { timeline: p.targetTimeline, clipName: t.name, start: num(t.start), duration: num(t.duration) },
314
+ };
142
315
  }
143
316
  } else if (m.src) {
144
317
  gradeApply = { status: 'no-source-grade', sourceClip: m.src.name };
145
318
  }
146
319
  return {
147
- target: { name: t.name, start: t.start },
148
- source: m.src ? { name: m.src.name, start: m.src.start } : null,
320
+ index: i,
321
+ target: clipRef(t),
322
+ source: m.src ? clipRef(m.src) : null,
149
323
  method: m.method,
150
324
  confidence: m.confidence,
325
+ ambiguous: m.ambiguous,
326
+ candidates: m.candidates,
327
+ sourceOverlap: m.sourceOverlap || null,
151
328
  gradeApply,
152
329
  };
153
330
  });
154
331
  const matched = matches.filter((m) => m.source).length;
155
- return {
156
- source: { timeline: p.sourceTimeline, clips: srcClips.length },
157
- target: { timeline: p.targetTimeline, clips: tgtClips.length },
158
- summary: { matched, unmatched: tgtClips.length - matched, gradesReady, byMethod },
332
+ const result = {
333
+ source: { projectDb: srcDb, timeline: p.sourceTimeline, clips: srcClips.length, graded: srcClips.filter((c) => c.gradeBody).length },
334
+ target: { projectDb: tgtDb, timeline: p.targetTimeline, clips: tgtClips.length },
335
+ summary: { matched, unmatched: tgtClips.length - matched, gradesReady, ambiguous, belowThreshold, byMethod },
159
336
  emitDir: p.emitDir || null,
337
+ planPath: null,
160
338
  matches,
161
339
  };
340
+ if (p.emitDir) {
341
+ result.planPath = path.join(p.emitDir, 'plan.json');
342
+ fs.writeFileSync(result.planPath, JSON.stringify({ kind: 'color_trace.plan', createdAt: new Date().toISOString(), ...result }, null, 2));
343
+ }
344
+ return result;
162
345
  }
163
346
  throw new Error(`Unknown color_trace action: ${action}`);
164
347
  },
@@ -35,16 +35,28 @@ const timelineClipsSchema = z.object({ ...dbTarget, timeline: z.string().describ
35
35
  // column: file-relative source frames, calibrated frame-EXACT against a real
36
36
  // turnover's AAF ground truth (A005C004 run: 36069/8863/8919/12804, through-
37
37
  // edit continuity intact). Emitted as sourceIn.
38
+ // Identity columns for color_trace (2026-09-08): MediaRef → Sm2MpMedia gives the
39
+ // pool item's UniqueMediaPoolItemId (poolId); the version table's pActive names
40
+ // the ACTIVE grade version, so an item carrying several corrected versions
41
+ // reads back ONE row — the active one (or the first corrected one when the
42
+ // active version has no correction). Measured on a 21k-item show DB: no item
43
+ // held two corrected versions, but the dedupe below is what keeps that from
44
+ // becoming a duplicate-clip bug the day one does.
38
45
  const TIMELINE_CLIPS_SQL = `
39
- SELECT i.Name AS name, t.Type AS trackType, i.Start AS start, i.Duration AS duration,
46
+ SELECT i.Sm2TiItem_id AS itemId, i.Name AS name, t.Type AS trackType, i.Start AS start, i.Duration AS duration,
40
47
  i.MediaReelNumber AS reel, i.MediaStartTime AS mediaStart, i."In" AS sourceIn,
41
48
  i.MediaFilePath AS mediaPath,
49
+ m.UniqueMediaPoolItemId AS poolId,
42
50
  i.Sm2TiTrack_id AS trackId,
51
+ v.Name AS gradeVersion,
52
+ CASE WHEN vt.pActive IS NOT NULL AND v."ListMgt::LmVersion_id" = vt.pActive THEN 1 ELSE 0 END AS gradeIsActive,
43
53
  lower(hex(v.Body)) AS gradeBody
44
54
  FROM Sm2TiItem i
45
55
  JOIN Sm2TiTrack t ON i.Sm2TiTrack_id = t.Sm2TiTrack_id
46
56
  JOIN Sm2Sequence s ON t.Sequence = s.Sm2Sequence_id
47
57
  JOIN Sm2Timeline tl ON s.Sm2Timeline_id = tl.Sm2Timeline_id
58
+ LEFT JOIN Sm2MpMedia m ON m.Sm2MpMedia_id = i.MediaRef
59
+ LEFT JOIN "ListMgt::LmVersionTable" vt ON vt."ListMgt::LmVersionTable_id" = i.pLmVerTable
48
60
  LEFT JOIN "ListMgt::LmVersion" v ON v."ListMgt::LmVersionTable_id" = i.pLmVerTable AND v.HasCorrection = '1'
49
61
  WHERE tl.Name = ?`;
50
62
  const tablesSchema = z.object({ ...dbTarget, withRowCounts: z.boolean().optional() });
@@ -107,12 +119,15 @@ export function readTimelineClips(dbPath, timeline, trackType = 'all', includeGr
107
119
  const db = openGuarded(dbPath, { writable: false });
108
120
  try {
109
121
  let rows = db.prepare(TIMELINE_CLIPS_SQL).all(timeline);
122
+ rows = dedupeVersionRows(rows);
110
123
  if (trackType === 'video') rows = rows.filter((r) => r.trackType === 0);
111
124
  else if (trackType === 'audio') rows = rows.filter((r) => r.trackType !== 0);
112
125
  rows.sort((a, b) => a.trackType - b.trackType || Number(a.start) - Number(b.start));
113
126
  // grade Body is large + only needed for ColorTrace — strip it by default to avoid bloat.
114
127
  for (const r of rows) {
128
+ r.hasGrade = !!r.gradeBody;
115
129
  if (!includeGrade) delete r.gradeBody;
130
+ delete r.gradeIsActive;
116
131
  r.sourceIn = decodeItemIn(r.sourceIn);
117
132
  }
118
133
  return rows;
@@ -121,6 +136,18 @@ export function readTimelineClips(dbPath, timeline, trackType = 'all', includeGr
121
136
  }
122
137
  }
123
138
 
139
+ /** One row per timeline item: an item with several corrected grade versions
140
+ * joins to several rows — keep the ACTIVE version's row, else the first. */
141
+ export function dedupeVersionRows(rows) {
142
+ const byItem = new Map();
143
+ for (const r of rows) {
144
+ const key = r.itemId || `${r.trackId}|${r.start}|${r.name}`;
145
+ const prev = byItem.get(key);
146
+ if (!prev || (!prev.gradeIsActive && r.gradeIsActive)) byItem.set(key, r);
147
+ }
148
+ return [...byItem.values()];
149
+ }
150
+
124
151
  export const projectReadTool = {
125
152
  name: 'project_read',
126
153
  description:
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.212.4"
90
+ VERSION = "2.213.0"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  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 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.212.4"
14
+ VERSION = "2.213.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -1281,6 +1281,8 @@ _TOKEN_GATED_DESTRUCTIVE_ACTIONS = frozenset({
1281
1281
  ("edit_engine", "execute_swap"),
1282
1282
  ("graph", "apply_grade_from_drx"),
1283
1283
  ("graph", "reset_all_grades"),
1284
+ # Batch grade carry-over from a color_trace plan: plan → confirm → apply.
1285
+ ("timeline_item_color", "apply_trace_plan"),
1284
1286
  # 21.0 AI ops that render/generate NEW media files (additive, but expensive
1285
1287
  # and irreversible without manual cleanup) — gated so they never run by
1286
1288
  # surprise. They never modify source media.
@@ -26386,6 +26388,7 @@ _COLOR_GRADE_KERNEL_ACTIONS = [
26386
26388
  "safe_set_cdl",
26387
26389
  "safe_copy_grade",
26388
26390
  "safe_apply_drx",
26391
+ "apply_trace_plan",
26389
26392
  "safe_export_lut",
26390
26393
  "grade_version_snapshot",
26391
26394
  "grade_version_restore",
@@ -26861,6 +26864,232 @@ def _safe_apply_drx(proj, item, p: Dict[str, Any]):
26861
26864
  return {"success": success, "path": path, "source": source}
26862
26865
 
26863
26866
 
26867
+ # ── color_trace apply driver ─────────────────────────────────────────────────
26868
+ #
26869
+ # The advanced (Node) server's `color_trace plan` matches a graded SOURCE
26870
+ # timeline against a TARGET timeline from the project DB (no Resolve needed),
26871
+ # writes one lossless .drx per graded match and a plan.json naming each target
26872
+ # clip by (name, record start, duration). This driver is the live half: it
26873
+ # resolves every plan entry to a clip on the CURRENT timeline, reports what it
26874
+ # would touch, and — behind a confirm_token, with the timeline archived first —
26875
+ # runs ApplyGradeFromDRX on each resolved clip. Every clip the plan could not
26876
+ # place on the live timeline is reported, never guessed.
26877
+
26878
+ _TRACE_SKIP_REASONS = (
26879
+ "unmatched", "no-source-grade", "below-threshold", "below_min_confidence",
26880
+ "drx_missing", "drx_path_not_temp", "live_item_not_found", "ambiguous_live_item",
26881
+ )
26882
+
26883
+
26884
+ def _load_trace_plan(p: Dict[str, Any]):
26885
+ plan = p.get("plan")
26886
+ plan_path = p.get("plan_path")
26887
+ if plan is None:
26888
+ if not plan_path:
26889
+ return None, _err(
26890
+ "plan_path (or an inline plan) is required",
26891
+ code="MISSING_PLAN", category="invalid_input",
26892
+ remediation="Run the advanced server's color_trace plan with emitDir set "
26893
+ "(a temp-dir path) and pass its planPath here.",
26894
+ )
26895
+ if not os.path.isfile(plan_path):
26896
+ return None, _err(f"plan not found: {plan_path}", code="PLAN_NOT_FOUND", category="invalid_input")
26897
+ try:
26898
+ with open(plan_path, encoding="utf-8") as fh:
26899
+ plan = json.load(fh)
26900
+ except Exception as exc:
26901
+ return None, _err(f"plan is not valid JSON: {exc}", code="PLAN_INVALID", category="invalid_input")
26902
+ if not isinstance(plan, dict) or not isinstance(plan.get("matches"), list):
26903
+ return None, _err("plan has no matches list (expected color_trace.plan output)",
26904
+ code="PLAN_INVALID", category="invalid_input")
26905
+ return plan, None
26906
+
26907
+
26908
+ def _live_video_items(tl) -> List[Dict[str, Any]]:
26909
+ """Every video-track item on the timeline with the fields the plan keys on."""
26910
+ out: List[Dict[str, Any]] = []
26911
+ try:
26912
+ track_count = int(tl.GetTrackCount("video") or 0)
26913
+ except Exception as exc:
26914
+ logger.warning("apply_trace_plan: GetTrackCount(video) failed: %s", exc)
26915
+ track_count = 0
26916
+ for track_index in range(1, track_count + 1):
26917
+ for item_index, it in enumerate(tl.GetItemListInTrack("video", track_index) or []):
26918
+ try:
26919
+ out.append({
26920
+ "item": it,
26921
+ "track_index": track_index,
26922
+ "item_index": item_index,
26923
+ "id": it.GetUniqueId(),
26924
+ "name": it.GetName(),
26925
+ "start": int(it.GetStart()),
26926
+ "duration": int(it.GetDuration()),
26927
+ })
26928
+ except Exception as exc:
26929
+ logger.warning("apply_trace_plan: skipping unreadable item on V%s: %s", track_index, exc)
26930
+ return out
26931
+
26932
+
26933
+ def _resolve_trace_plan(tl, plan: Dict[str, Any], p: Dict[str, Any]) -> List[Dict[str, Any]]:
26934
+ """Map each plan match onto a live clip. status: apply | skip (+reason)."""
26935
+ min_conf = float(p.get("min_confidence", 0.8))
26936
+ tol = int(p.get("start_tolerance", 0))
26937
+ require_temp = p.get("require_temp_path", True)
26938
+ live = _live_video_items(tl)
26939
+ rows: List[Dict[str, Any]] = []
26940
+ for m in plan["matches"]:
26941
+ tgt = m.get("target") or {}
26942
+ src = m.get("source") or {}
26943
+ ga = m.get("gradeApply") or {}
26944
+ drx = ga.get("drxPath")
26945
+ row: Dict[str, Any] = {
26946
+ "index": m.get("index"),
26947
+ "target": {"name": tgt.get("name"), "start": tgt.get("start"), "duration": tgt.get("duration")},
26948
+ "source": src.get("name"),
26949
+ "method": m.get("method"),
26950
+ "confidence": m.get("confidence"),
26951
+ "ambiguous": bool(m.get("ambiguous")),
26952
+ "drx_path": drx,
26953
+ "status": "skip",
26954
+ "reason": None,
26955
+ "live": None,
26956
+ }
26957
+ conf = row["confidence"] if isinstance(row["confidence"], (int, float)) else 0.0
26958
+ if not src:
26959
+ row["reason"] = "unmatched"
26960
+ elif ga.get("status") != "ready":
26961
+ row["reason"] = ga.get("status") or "no-source-grade"
26962
+ elif conf < min_conf:
26963
+ row["reason"] = "below_min_confidence"
26964
+ elif not drx or not os.path.isfile(drx):
26965
+ row["reason"] = "drx_missing"
26966
+ elif require_temp and not _grade_temp_path_ok(drx):
26967
+ row["reason"] = "drx_path_not_temp"
26968
+ else:
26969
+ name = tgt.get("name")
26970
+ start = tgt.get("start")
26971
+ cands = [
26972
+ x for x in live
26973
+ if x["name"] == name and isinstance(start, (int, float)) and abs(x["start"] - int(start)) <= tol
26974
+ ]
26975
+ if len(cands) > 1:
26976
+ same_dur = [x for x in cands if x["duration"] == tgt.get("duration")]
26977
+ if len(same_dur) == 1:
26978
+ cands = same_dur
26979
+ if not cands:
26980
+ row["reason"] = "live_item_not_found"
26981
+ elif len(cands) > 1:
26982
+ row["reason"] = "ambiguous_live_item"
26983
+ row["live_candidates"] = [
26984
+ {"track_index": x["track_index"], "item_index": x["item_index"], "id": x["id"], "duration": x["duration"]}
26985
+ for x in cands
26986
+ ]
26987
+ else:
26988
+ hit = cands[0]
26989
+ row["status"] = "apply"
26990
+ row["live"] = {k: hit[k] for k in ("track_index", "item_index", "id", "start", "duration")}
26991
+ row["_item"] = hit["item"]
26992
+ rows.append(row)
26993
+ return rows
26994
+
26995
+
26996
+ def _apply_trace_plan(p: Dict[str, Any]) -> Dict[str, Any]:
26997
+ plan, err = _load_trace_plan(p)
26998
+ if err:
26999
+ return err
27000
+ _, tl, err = _get_tl()
27001
+ if err:
27002
+ return err
27003
+ live_name = tl.GetName()
27004
+ plan_tl = (plan.get("target") or {}).get("timeline")
27005
+ if plan_tl and plan_tl != live_name and not p.get("allow_timeline_mismatch"):
27006
+ return _err(
27007
+ f"plan targets timeline {plan_tl!r} but the current timeline is {live_name!r}",
27008
+ code="TIMELINE_MISMATCH", category="invalid_input",
27009
+ remediation=f"timeline(action='set_current', params={{'name': {plan_tl!r}}}) first, "
27010
+ "or pass allow_timeline_mismatch=True if the plan was built for this cut under another name.",
27011
+ )
27012
+ rows = _resolve_trace_plan(tl, plan, p)
27013
+ to_apply = [r for r in rows if r["status"] == "apply"]
27014
+ skipped_by_reason: Dict[str, int] = {}
27015
+ for r in rows:
27016
+ if r["status"] != "apply":
27017
+ skipped_by_reason[r["reason"] or "unknown"] = skipped_by_reason.get(r["reason"] or "unknown", 0) + 1
27018
+ summary = {
27019
+ "plan_matches": len(rows),
27020
+ "would_apply": len(to_apply),
27021
+ "skipped": len(rows) - len(to_apply),
27022
+ "skipped_by_reason": skipped_by_reason,
27023
+ "ambiguous_in_plan": sum(1 for r in rows if r["ambiguous"]),
27024
+ "min_confidence": float(p.get("min_confidence", 0.8)),
27025
+ }
27026
+ public = [{k: v for k, v in r.items() if not k.startswith("_")} for r in rows]
27027
+ grade_mode = p.get("grade_mode", 0)
27028
+ version_name = p.get("version_name")
27029
+
27030
+ if p.get("dry_run"):
27031
+ return _ok(dry_run=True, timeline=live_name, plan_source=plan.get("source"),
27032
+ summary=summary, resolution=public, grade_mode=grade_mode, version_name=version_name)
27033
+ if not to_apply:
27034
+ return _ok(timeline=live_name, applied=[], failed=[], summary=summary, resolution=public,
27035
+ note="nothing to apply — every plan entry was skipped (see skipped_by_reason)")
27036
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
27037
+ preview = {
27038
+ "operation": "apply_trace_plan",
27039
+ "warning": "REPLACES the node graph of every listed target clip (ApplyGradeFromDRX has "
27040
+ "no append mode). The current timeline is archived to the Archive bin first.",
27041
+ "timeline": live_name,
27042
+ "will_apply": len(to_apply),
27043
+ "summary": summary,
27044
+ "targets": [
27045
+ {"name": r["target"]["name"], "start": r["target"]["start"], "source": r["source"],
27046
+ "method": r["method"], "confidence": r["confidence"], "ambiguous": r["ambiguous"]}
27047
+ for r in to_apply[:50]
27048
+ ],
27049
+ "grade_mode": grade_mode,
27050
+ "version_name": version_name,
27051
+ }
27052
+ return _issue_confirm_token(action="apply_trace_plan", params=p, preview=preview)
27053
+ blocked = _consume_confirm_token(action="apply_trace_plan", params=p)
27054
+ if blocked:
27055
+ return blocked
27056
+
27057
+ applied: List[Dict[str, Any]] = []
27058
+ failed: List[Dict[str, Any]] = []
27059
+ for r in to_apply:
27060
+ item = r["_item"]
27061
+ rec = {k: v for k, v in r.items() if not k.startswith("_")}
27062
+ ok = False
27063
+ try:
27064
+ if version_name:
27065
+ # AddVersion switches the item to the NEW version, so the apply
27066
+ # lands there and the previous version stays intact.
27067
+ rec["version_added"] = bool(item.AddVersion(version_name, 0))
27068
+ graph = item.GetNodeGraph()
27069
+ if not _has_method(graph, "ApplyGradeFromDRX"):
27070
+ rec["error"] = "item graph does not expose ApplyGradeFromDRX"
27071
+ else:
27072
+ ok = bool(graph.ApplyGradeFromDRX(r["drx_path"], grade_mode))
27073
+ if not ok:
27074
+ rec["error"] = "ApplyGradeFromDRX returned False"
27075
+ except Exception as exc:
27076
+ rec["error"] = str(exc)
27077
+ rec["status"] = "applied" if ok else "failed"
27078
+ (applied if ok else failed).append(rec)
27079
+ summary.update({"applied": len(applied), "failed": len(failed)})
27080
+ return {
27081
+ "success": not failed,
27082
+ "timeline": live_name,
27083
+ "plan_source": plan.get("source"),
27084
+ "applied": applied,
27085
+ "failed": failed,
27086
+ "skipped": [r for r in public if r["status"] != "apply"],
27087
+ "summary": summary,
27088
+ "grade_mode": grade_mode,
27089
+ "version_name": version_name,
27090
+ }
27091
+
27092
+
26864
27093
  def _grade_version_restore(item, p: Dict[str, Any]):
26865
27094
  name = p.get("name")
26866
27095
  if not name:
@@ -26980,6 +27209,25 @@ _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = {
26980
27209
  ' # re-call with confirm_token to apply'
26981
27210
  ),
26982
27211
  },
27212
+ "apply_trace_plan": {
27213
+ "summary": "Apply a color_trace plan (advanced server) to the CURRENT timeline: one ApplyGradeFromDRX per resolved clip. REPLACES those graphs; gated by confirm_token; timeline archived first.",
27214
+ "params": "plan_path: str (planPath from color_trace plan with emitDir) | plan: dict, dry_run?, min_confidence? (default 0.8), start_tolerance? (frames, default 0), grade_mode? (0=no keyframes, 1=source TC aligned, 2=start aligned), version_name? (AddVersion per clip before applying), require_temp_path? (default True), allow_timeline_mismatch?, confirm_token?",
27215
+ "returns": "{success, timeline, applied: [...], failed: [...], skipped: [...], summary: {plan_matches, would_apply, applied, failed, skipped_by_reason, ambiguous_in_plan}}; dry_run → {resolution: [{target, source, method, confidence, status, reason, live}]}",
27216
+ "example": (
27217
+ '# 1. advanced server (no Resolve needed):\n'
27218
+ '# color_trace(action="plan", {sourceProjectName: "SHOW_v07", sourceTimeline: "REEL_01 v07",\n'
27219
+ '# targetProjectName: "SHOW_v08", targetTimeline: "REEL_01 v08",\n'
27220
+ '# emitDir: "/tmp/trace-reel01"}) → planPath\n'
27221
+ '# 2. open the TARGET project + timeline in Resolve, then:\n'
27222
+ 'timeline_item_color(action="apply_trace_plan", params={\n'
27223
+ ' "plan_path": "/tmp/trace-reel01/plan.json", "dry_run": True\n'
27224
+ '}) # read resolution: every entry is apply | skip(reason)\n'
27225
+ 'timeline_item_color(action="apply_trace_plan", params={\n'
27226
+ ' "plan_path": "/tmp/trace-reel01/plan.json", "version_name": "traced v07"\n'
27227
+ '}) # first call returns {status: "confirmation_required", confirm_token}\n'
27228
+ ' # re-call with confirm_token to apply'
27229
+ ),
27230
+ },
26983
27231
  "grade_evidence_base": {
26984
27232
  "summary": "PREFERRED pre-flight read. Composes version_snapshot + node_graph + color_group + coverage_report.",
26985
27233
  "params": "include_coverage?, max_nodes?, min_source_trust? (medium|high), track_type?, track_index?, item_index?",
@@ -27757,6 +28005,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
27757
28005
  safe_set_cdl -> {success, validation, normalized, node_preflight, diagnosis?}
27758
28006
  safe_copy_grade -> {success, targets, missing}
27759
28007
  safe_apply_drx -> {success, path, source} # first call may return confirm_token
28008
+ apply_trace_plan -> {success, timeline, applied, failed, skipped, summary} # dry_run → {resolution}; first live call returns confirm_token
27760
28009
  grade_capabilities -> {item_methods, graph_sources, lut_export_types, guards}
27761
28010
  grade_boundary_report -> {capabilities, item, color_groups, gallery}
27762
28011
  All actions may return {"error": {code, category, retryable, message, remediation, reason?}}.
@@ -27798,6 +28047,14 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
27798
28047
  safe_apply_drx(path, source?, grade_mode?, require_temp_path?) -> {success}
27799
28048
  REPLACES the target graph. Captures a version snapshot first; require_temp_path defaults True.
27800
28049
  # example: action_help(name='<action_name>')
28050
+ 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?, confirm_token?) -> {success, applied, failed, skipped, summary}
28051
+ The live half of the advanced server's color_trace (a ColorTrace that matches on media
28052
+ identity, cross-project, from the project DB). Resolves each plan entry to a clip on the
28053
+ CURRENT timeline by (name, record start, duration), then ApplyGradeFromDRX per clip —
28054
+ one confirm_token for the batch, timeline archived first. dry_run returns the resolution
28055
+ table without a token. version_name adds a new local version per clip before applying so
28056
+ the previous grade stays intact. Never guesses: unresolved entries are reported, not applied.
28057
+ # example: action_help(name='<action_name>')
27801
28058
  safe_export_lut(type?, path, require_temp_path?) -> {success, path, size}
27802
28059
  Sandboxed LUT export.
27803
28060
  grade_version_restore(name, type?, dry_run?, ...) -> {success}
@@ -27837,6 +28094,9 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
27837
28094
  p = _params(params)
27838
28095
  if action == "action_help":
27839
28096
  return _action_help("timeline_item_color", p)
28097
+ if action == "apply_trace_plan":
28098
+ # Timeline-scoped (walks every video track); does not need an item.
28099
+ return _apply_trace_plan(p)
27840
28100
  _, item, err = _get_item(p)
27841
28101
  if err:
27842
28102
  return err
@@ -169,6 +169,7 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
169
169
  "timeline_item_color": frozenset({
170
170
  "set_cdl",
171
171
  "copy_grades",
172
+ "apply_trace_plan",
172
173
  "reset_all_node_colors",
173
174
  "assign_color_group",
174
175
  "remove_from_color_group",
@@ -277,6 +278,7 @@ DRY_RUN_DEFAULT_TRUE_ACTIONS: frozenset = frozenset({
277
278
 
278
279
  NATIVE_DRY_RUN_ACTIONS: frozenset = frozenset({
279
280
  ("media_pool", "clear_clip_marks"),
281
+ ("timeline_item_color", "apply_trace_plan"),
280
282
  ("media_pool", "set_clip_marks"),
281
283
  ("media_pool", "setup_multicam_timeline"),
282
284
  ("timeline", "apply_cuts"),
@@ -169,6 +169,9 @@ class RiskClassificationHook(LifecycleHook):
169
169
  # CopyGrades overwrites each target's grade.
170
170
  ("graph", "apply_grade_from_drx"),
171
171
  ("timeline_item_color", "copy_grades"),
172
+ # apply_trace_plan runs ApplyGradeFromDRX over every clip a color_trace
173
+ # plan resolved — one confirm_token, N replaced graphs.
174
+ ("timeline_item_color", "apply_trace_plan"),
172
175
  # Takes. delete removes one; finalize collapses the item to the selected
173
176
  # take and discards the rest.
174
177
  ("timeline_item_takes", "delete"),