davinci-resolve-mcp 2.213.2 → 2.214.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,78 @@
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.214.0 — color_trace reads exported .drp files, and the trace is live-validated
6
+
7
+ ### Added
8
+
9
+ - **`color_trace plan` takes `sourceDrp` / `targetDrp`.** The v2.213.0 matcher
10
+ read both timelines from `Project.db`, which a Postgres, network or cloud
11
+ library does not have. `ProjectManager.ExportProject` works on any project by
12
+ name without loading it, and the `.drp` it writes carries every field the
13
+ matcher keys on — name, record start, duration, in-point, media path, reel,
14
+ media ref, and the active grade version's body inline — so either side of a
15
+ plan can now be an exported `.drp`. The reader
16
+ (`resolve-advanced/server/drp-timeline-clips.mjs`) resolves a timeline name
17
+ through `MediaPool/**/MpFolder.xml` to its sequence id and reads the
18
+ `SeqContainer/<uuid>.xml` whose tracks reference it; it returns the same row
19
+ shape as the DB reader, active-version-first.
20
+ - **`apply_trace_plan` writes its full report to a file.** A real conform is
21
+ 800+ plan rows, and the first live run's per-clip tables (290k characters)
22
+ blew past what a tool response can carry. The full tables now go to
23
+ `report_path` (default `dry-run-report.json` / `apply-report.json` next to
24
+ the plan); the response keeps the summary, an `attention` list (ties,
25
+ partial overlaps, every skip that is not bulk `unmatched` /
26
+ `no-source-grade`) and the first `max_rows` rows (default 40, `verbose:
27
+ true` for everything). `failed` is never truncated.
28
+
29
+ ### Live-validated (Resolve Studio 19.1.3.7, Postgres library)
30
+
31
+ - Source: a 659-clip picture-lock turnover with 263 graded clips. Target: an
32
+ 878-clip conform of the next version, media on a different volume, reels
33
+ mostly empty, clip names carrying an extension the source names lack.
34
+ - Plan: 613 matched (583 by file name + source-range overlap, 3 by reel, 27 by
35
+ name only), 265 unmatched (reference masters, offline screeners, clips not
36
+ in the source), 266 with a grade to carry. The 12 name-only matches sat
37
+ below the default 0.8 gate and were skipped.
38
+ - Apply: 254 of 254 resolved clips graded, 0 failures, timeline archived as
39
+ `_archived_v01` first, a local version `traced V07 Sizing` added per clip.
40
+ Six sampled clips read back with exactly the node count their `.drx`
41
+ decodes to (9, 9, 8, 9, 5, 1) and the source's tools (Reduce Noise, a film
42
+ LUT, Halation, Glow, HDR wheels, hue curves).
43
+ - Reported for review, not hidden: 70 applied clips span more source range
44
+ than the graded section they matched (lowest overlap 14 percent), 2 applied
45
+ on a tie between identical candidates.
46
+
47
+ ### Notes
48
+
49
+ - Reading the Postgres library directly was not attempted: Resolve keeps the
50
+ connection password in plain text in `dblist.conf`, and the `.drp` route
51
+ needs no credential at all. `resolve-advanced/README.md` still says
52
+ `project_read` handles "SQLite or Postgres"; only SQLite is implemented.
53
+
54
+ ## What's New in v2.213.3 — the capture docstring says what is restored and what is only reset
55
+
56
+ ### Documentation
57
+
58
+ - **`_playhead_frame_render`'s docstring still said the mark range was not
59
+ readable.** Since v2.213.2 it is: `Timeline.GetMarkInOut` is read before the
60
+ capture and put back afterwards, offset into `SetRenderSettings`' absolute
61
+ frame space. The docstring now lists the three things that actually happen
62
+ on the way out — format and codec genuinely restored, the mark range
63
+ restored when one was set (whole timeline as the fallback), and TargetDir
64
+ and CustomName reset because nothing can read them back — and notes that
65
+ `GetRenderSettings` is still absent as of 21.1. It is the first thing a
66
+ caller reads to decide whether `quality="frame"` is safe against their
67
+ render setup. Contributed in #201 by @billcarroll.
68
+ - **#196 confirmed on Windows.** The reporter pulled v2.213.1 and confirms the
69
+ keyframe fix renders on Windows, which closes the one platform question the
70
+ v2.213.1 notes left open.
71
+
72
+ ### Validation
73
+
74
+ - Docstring only; no behavior changed and no Resolve run required. Full
75
+ offline suite, drift guards and the advanced Node suite green.
76
+
5
77
  ## What's New in v2.213.2 — the full transcript on 21.1, and a frame capture that puts the user's mark range back in the right frame space
6
78
 
7
79
  Both contributed by @billcarroll (#199, #200).
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.213.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.214.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.213.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.214.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.213.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.214.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -33,7 +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
+ | `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; the full per-clip tables go to `report_path`, the response carries the summary, an `attention` list and the first `max_rows` rows. |
37
37
  | `safe_export_lut` | Resolve LUT export type aliases and require temp output paths by default. |
38
38
  | `grade_version_snapshot` | Read current, local, and remote grade version names. |
39
39
  | `grade_version_restore` | Safely load a named local/remote version after verifying it exists. |
@@ -139,7 +139,8 @@ files and the project DB.
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
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
142
+ file path / reel / file name + source-range overlap; names last), from a
143
+ `Project.db` or an exported `.drp` (the Postgres/network-library route) → a trace
143
144
  plan with a lossless `.drx` per graded match and a `plan.json`. The live half
144
145
  is `timeline_item_color.apply_trace_plan` (dry-run resolution table → one
145
146
  confirm_token → ApplyGradeFromDRX per resolved clip, timeline archived first).
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.213.2"
40
+ VERSION = "2.214.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.213.2",
3
+ "version": "2.214.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -86,9 +86,13 @@ Each dispatches on an `action`. Highlights:
86
86
  timeline (any two projects, from `Project.db`, read-only, no Resolve) on **media identity** —
87
87
  pool item id / file path / reel / file name plus source-range overlap, so a stringout cut into
88
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
89
+ Either side is a `Project.db` (`…ProjectDb` / `…ProjectName`) **or an exported `.drp`**
90
+ (`sourceDrp` / `targetDrp`) — the `.drp` route is how a Postgres / network / cloud library
91
+ is read, since `ProjectManager.ExportProject` works on any project by name without loading
92
+ it. Emits one lossless `.drx` per graded match plus a `plan.json`; the live server's
90
93
  `timeline_item_color.apply_trace_plan` applies it (dry-run resolution table, one
91
- confirm_token for the batch, timeline archived first).
94
+ confirm_token for the batch, timeline archived first, full per-clip report to a file).
95
+ Live-validated 2026-09-08: 878-clip conform, 254 grades carried, 0 failures.
92
96
  - **`project_read` / `project_db`** — read/patch the Resolve project DB (SQLite or Postgres).
93
97
  Includes `list_subtitle_styles` / `set_subtitle_style` — caption font family/size/weight/italic
94
98
  and normalised position, which the scripting API cannot touch at all. Whole-track (not
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Timeline clips from an exported .drp — the DB-agnostic twin of
3
+ * project_read.readTimelineClips. A Postgres / cloud / network library has no
4
+ * Project.db to open, but ProjectManager.ExportProject works on ANY project by
5
+ * name without loading it, and the .drp it writes carries every field the
6
+ * color_trace matcher keys on:
7
+ *
8
+ * MediaPool/**\/MpFolder.xml <Sm2Timeline><Name>…</Name><Sequence><Sm2Sequence DbId=SEQ>
9
+ * SeqContainer/<uuid>.xml <Sm2TiTrack><Type>0</Type><Sequence>SEQ</Sequence><Items>
10
+ * <Sm2TiVideoClip DbId=…> Name/Start/Duration/In/MediaRef/
11
+ * MediaFilePath/MediaReelNumber/pLmVerTable(pActive → LmVersion Body)
12
+ *
13
+ * Measured on Resolve 19.1.3.7 exports (DbPrjVer 14): one container per
14
+ * timeline, tracks reference their sequence by id, the grade body sits inline
15
+ * in the clip's version table. Row shape matches readTimelineClips so the
16
+ * matcher cannot tell the two sources apart.
17
+ */
18
+
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { spawnSync } from 'node:child_process';
23
+
24
+ const scalar = (xml, tag) => {
25
+ const m = xml.match(new RegExp(`<${tag}>([^<]*)</${tag}>`));
26
+ return m ? m[1].trim() : null;
27
+ };
28
+ const blank = (xml, tag) => new RegExp(`<${tag}/>`).test(xml);
29
+
30
+ /** Walk every `<Sm2Timeline>` in the media-pool folder XMLs → [{name, timelineId, sequenceId}]. */
31
+ export function listDrpTimelines(mpFolderXmls) {
32
+ const out = [];
33
+ for (const xml of mpFolderXmls) {
34
+ for (const m of xml.matchAll(/<Sm2Timeline DbId="([^"]+)">([\s\S]*?)<\/Sm2Timeline>/g)) {
35
+ const body = m[2];
36
+ const name = scalar(body, 'Name');
37
+ const seq = body.match(/<Sm2Sequence DbId="([^"]+)"/);
38
+ out.push({ name, timelineId: m[1], sequenceId: seq ? seq[1] : null });
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /** Decode `<In>` the way project_read does: plain decimal, or the FIRST field of a
45
+ * pipe-joined composite ('47|0000eaffffffef3f'). Anything else → null, never 0. */
46
+ function decodeIn(v) {
47
+ if (v === null || v === '') return null;
48
+ const head = String(v).split('|', 1)[0];
49
+ return /^-?\d+(\.\d+)?$/.test(head) ? Number(head) : null;
50
+ }
51
+
52
+ /** Active grade of one clip element: {gradeVersion, gradeBody} or nulls.
53
+ * pActive names the active version; fall back to the first corrected one. */
54
+ function activeGrade(clipXml) {
55
+ const vt = clipXml.match(/<pLmVerTable>([\s\S]*?)<\/pLmVerTable>/);
56
+ if (!vt) return { gradeVersion: null, gradeBody: null };
57
+ const active = scalar(vt[1], 'pActive');
58
+ const versions = [...vt[1].matchAll(/<ListMgt::LmVersion DbId="([^"]+)">([\s\S]*?)<\/ListMgt::LmVersion>/g)].map((m) => {
59
+ const body = m[2];
60
+ const bodyHex = (body.match(/<Body>([0-9a-fA-F\s]*)<\/Body>/) || [null, ''])[1];
61
+ return {
62
+ id: m[1],
63
+ name: scalar(body, 'Name'),
64
+ corrected: /<HasCorrection>true<\/HasCorrection>/.test(body),
65
+ bodyHex: bodyHex ? bodyHex.replace(/[^0-9a-fA-F]/g, '').toLowerCase() : '',
66
+ };
67
+ });
68
+ const pick = versions.find((v) => v.id === active && v.corrected && v.bodyHex) || versions.find((v) => v.corrected && v.bodyHex);
69
+ return pick ? { gradeVersion: pick.name, gradeBody: pick.bodyHex } : { gradeVersion: null, gradeBody: null };
70
+ }
71
+
72
+ /** Clips of the tracks that belong to `sequenceId` inside one SeqContainer XML.
73
+ * trackType 0 = video (Sm2TiVideoClip), 1 = audio (Sm2TiAudioClip). */
74
+ export function parseSeqContainerClips(containerXml, sequenceId, { includeGrade = false } = {}) {
75
+ const rows = [];
76
+ for (const t of containerXml.matchAll(/<Sm2TiTrack DbId="([^"]+)">([\s\S]*?)<\/Sm2TiTrack>/g)) {
77
+ const trackXml = t[2];
78
+ if (sequenceId && scalar(trackXml, 'Sequence') !== sequenceId) continue;
79
+ const trackType = Number(scalar(trackXml, 'Type') ?? 0);
80
+ const tag = trackType === 0 ? 'Sm2TiVideoClip' : 'Sm2TiAudioClip';
81
+ for (const c of trackXml.matchAll(new RegExp(`<${tag} DbId="([^"]+)">([\\s\\S]*?)</${tag}>`, 'g'))) {
82
+ const x = c[2];
83
+ const g = includeGrade ? activeGrade(x) : { gradeVersion: null, gradeBody: null };
84
+ rows.push({
85
+ itemId: c[1],
86
+ name: scalar(x, 'Name'),
87
+ trackType,
88
+ start: Number(scalar(x, 'Start')),
89
+ duration: Number(scalar(x, 'Duration')),
90
+ reel: blank(x, 'MediaReelNumber') ? '' : scalar(x, 'MediaReelNumber') || '',
91
+ mediaStart: scalar(x, 'MediaStartTime'),
92
+ sourceIn: decodeIn(scalar(x, 'In')),
93
+ mediaPath: blank(x, 'MediaFilePath') ? '' : scalar(x, 'MediaFilePath') || '',
94
+ poolId: scalar(x, 'MediaRef'), // per-project media ref; equal only inside one project
95
+ trackId: t[1],
96
+ gradeVersion: g.gradeVersion,
97
+ hasGrade: /<HasCorrection>true<\/HasCorrection>/.test(x),
98
+ ...(includeGrade ? { gradeBody: g.gradeBody } : {}),
99
+ });
100
+ }
101
+ }
102
+ rows.sort((a, b) => a.trackType - b.trackType || a.start - b.start);
103
+ return rows;
104
+ }
105
+
106
+ /** Unzip a .drp into a temp dir and return {dir, mpFolderXmls, containers:[{file, xml}]}. */
107
+ export function openDrp(drpPath) {
108
+ if (!fs.existsSync(drpPath)) throw new Error(`.drp not found: ${drpPath}`);
109
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'drp-clips-'));
110
+ const r = spawnSync('unzip', ['-q', '-o', drpPath, '-d', dir], { encoding: 'utf8' });
111
+ if (r.status !== 0) throw new Error(`unzip failed for ${drpPath}: ${(r.stderr || '').slice(-200)}`);
112
+ const walk = (d, out = []) => {
113
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
114
+ const p = path.join(d, e.name);
115
+ if (e.isDirectory()) walk(p, out);
116
+ else out.push(p);
117
+ }
118
+ return out;
119
+ };
120
+ const files = walk(dir);
121
+ const read = (p) => fs.readFileSync(p, 'utf8');
122
+ return {
123
+ dir,
124
+ mpFolderXmls: files.filter((p) => /MpFolder\.xml$/.test(p)).map(read),
125
+ containers: files.filter((p) => /(^|\/)SeqContainer\/[^/]+\.xml$/.test(p)).map((p) => ({ file: p, xml: read(p) })),
126
+ close: () => {
127
+ try {
128
+ fs.rmSync(dir, { recursive: true, force: true });
129
+ } catch {
130
+ /* ignore */
131
+ }
132
+ },
133
+ };
134
+ }
135
+
136
+ /** readTimelineClips twin for a .drp: clips of the named timeline, video by default. */
137
+ export function readTimelineClipsFromDrp(drpPath, timeline, trackType = 'video', includeGrade = false) {
138
+ const drp = openDrp(drpPath);
139
+ try {
140
+ const timelines = listDrpTimelines(drp.mpFolderXmls);
141
+ const tl = timelines.find((t) => t.name === timeline);
142
+ if (!tl) {
143
+ throw new Error(`timeline "${timeline}" not found in ${drpPath}. Timelines: ${timelines.map((t) => JSON.stringify(t.name)).join(', ') || '(none)'}`);
144
+ }
145
+ if (!tl.sequenceId) throw new Error(`timeline "${timeline}" has no <Sm2Sequence> in its media-pool entry`);
146
+ const holder = drp.containers.find((c) => c.xml.includes(`<Sequence>${tl.sequenceId}</Sequence>`));
147
+ if (!holder) throw new Error(`no SeqContainer references sequence ${tl.sequenceId} for timeline "${timeline}"`);
148
+ let rows = parseSeqContainerClips(holder.xml, tl.sequenceId, { includeGrade });
149
+ if (trackType === 'video') rows = rows.filter((r) => r.trackType === 0);
150
+ else if (trackType === 'audio') rows = rows.filter((r) => r.trackType !== 0);
151
+ return rows;
152
+ } finally {
153
+ drp.close();
154
+ }
155
+ }
@@ -32,12 +32,28 @@ import crypto from 'node:crypto';
32
32
  import { z } from 'zod';
33
33
  import { resolveDbPath } from '../db-patch.mjs';
34
34
  import { readTimelineClips } from './project_read.mjs';
35
+ import { readTimelineClipsFromDrp } from '../drp-timeline-clips.mjs';
35
36
 
36
37
  const side = (name) => ({
37
38
  [`${name}ProjectDb`]: z.string().optional(),
38
39
  [`${name}ProjectName`]: z.string().optional(),
40
+ [`${name}Drp`]: z
41
+ .string()
42
+ .optional()
43
+ .describe(
44
+ `An exported .drp of the ${name} project (ProjectManager.ExportProject / project_manager safe_project_export). The DB-agnostic route: use it when the project lives in a Postgres / network / cloud library that has no Project.db to open.`,
45
+ ),
39
46
  [`${name}Timeline`]: z.string(),
40
47
  });
48
+
49
+ /** One side of the plan: a .drp when given, else a Project.db (path or name). */
50
+ function sideClips(p, name, includeGrade) {
51
+ const drp = p[`${name}Drp`];
52
+ const timeline = p[`${name}Timeline`];
53
+ if (drp) return { origin: { drp, timeline }, clips: readTimelineClipsFromDrp(drp, timeline, 'video', includeGrade) };
54
+ const db = resolveDbPath({ projectDb: p[`${name}ProjectDb`], projectName: p[`${name}ProjectName`] });
55
+ return { origin: { projectDb: db, timeline }, clips: readTimelineClips(db, timeline, 'video', includeGrade) };
56
+ }
41
57
  const planSchema = z.object({
42
58
  ...side('source'),
43
59
  ...side('target'),
@@ -274,14 +290,14 @@ const clipRef = (c) => ({
274
290
  export const colorTraceTool = {
275
291
  name: 'color_trace',
276
292
  description:
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.',
293
+ 'Better ColorTrace — match clips between a SOURCE and TARGET timeline (cross-project, from Project.db OR an exported .drp — the .drp route covers Postgres/network/cloud libraries — 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.',
278
294
  async handler({ action, args }) {
279
295
  if (action === 'plan') {
280
296
  const p = planSchema.parse(args);
281
- const srcDb = resolveDbPath({ projectDb: p.sourceProjectDb, projectName: p.sourceProjectName });
282
- const tgtDb = resolveDbPath({ projectDb: p.targetProjectDb, projectName: p.targetProjectName });
283
- const srcClips = readTimelineClips(srcDb, p.sourceTimeline, 'video', true); // includeGrade
284
- const tgtClips = readTimelineClips(tgtDb, p.targetTimeline, 'video');
297
+ const src = sideClips(p, 'source', true); // includeGrade
298
+ const tgt = sideClips(p, 'target', false);
299
+ const srcClips = src.clips;
300
+ const tgtClips = tgt.clips;
285
301
  if (!srcClips.length) throw new Error(`source timeline "${p.sourceTimeline}" has no video clips`);
286
302
  if (!tgtClips.length) throw new Error(`target timeline "${p.targetTimeline}" has no video clips`);
287
303
  if (p.emitDir) fs.mkdirSync(p.emitDir, { recursive: true });
@@ -330,8 +346,8 @@ export const colorTraceTool = {
330
346
  });
331
347
  const matched = matches.filter((m) => m.source).length;
332
348
  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 },
349
+ source: { ...src.origin, clips: srcClips.length, graded: srcClips.filter((c) => c.gradeBody).length },
350
+ target: { ...tgt.origin, clips: tgtClips.length },
335
351
  summary: { matched, unmatched: tgtClips.length - matched, gradesReady, ambiguous, belowThreshold, byMethod },
336
352
  emitDir: p.emitDir || null,
337
353
  planPath: null,
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.213.2"
90
+ VERSION = "2.214.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.213.2"
14
+ VERSION = "2.214.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -14866,12 +14866,18 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
14866
14866
  A single-frame render honours the grade, Fusion and titles, is frame-exact,
14867
14867
  runs in well under a second, and needs no GUI panel or foreground window.
14868
14868
 
14869
- The cost is that render settings are project-level state. Format and codec
14870
- are readable and are restored; the rest (TargetDir, CustomName, mark range)
14871
- is NOT readable on builds without GetRenderSettings, so this resets those to
14872
- sane values rather than truly restoring them. Callers who need a strictly
14873
- side-effect-free read should use quality="thumbnail" and accept per-clip
14874
- granularity.
14869
+ The cost is that render settings are project-level state, and there is still
14870
+ no GetRenderSettings to read them back from (absent as of 21.1). Three
14871
+ different things happen on the way out:
14872
+ - Format and codec are readable via GetCurrentRenderFormatAndCodec and are
14873
+ genuinely restored.
14874
+ - The mark range is readable via Timeline.GetMarkInOut, so a range the
14875
+ caller had set is put back (offset into SetRenderSettings' absolute
14876
+ frame space); with no range set it falls back to the whole timeline.
14877
+ - TargetDir and CustomName are readable from nowhere, so they are reset to
14878
+ sane values rather than restored.
14879
+ Callers who need a strictly side-effect-free read should use
14880
+ quality="thumbnail" and accept per-clip granularity.
14875
14881
  """
14876
14882
  fmt = str(p.get("format", "jpg")).lower().lstrip(".")
14877
14883
  if fmt == "jpeg":
@@ -27087,6 +27093,33 @@ def _resolve_trace_plan(tl, plan: Dict[str, Any], p: Dict[str, Any]) -> List[Dic
27087
27093
  return rows
27088
27094
 
27089
27095
 
27096
+ _TRACE_MAX_ROWS_DEFAULT = 40
27097
+
27098
+
27099
+ def _trace_report(p: Dict[str, Any], plan: Dict[str, Any], kind: str, payload: Dict[str, Any]) -> Optional[str]:
27100
+ """Write the full per-clip tables next to the plan (or to report_path) and
27101
+ return the path. A real cut is 800+ rows — far past what a tool response
27102
+ should carry — so the file is the durable artifact and the response stays
27103
+ compact (see max_rows)."""
27104
+ report_path = p.get("report_path")
27105
+ if not report_path:
27106
+ plan_path = p.get("plan_path")
27107
+ base = os.path.dirname(os.path.abspath(plan_path)) if plan_path else tempfile.gettempdir()
27108
+ report_path = os.path.join(base, f"{kind}-report.json")
27109
+ try:
27110
+ with open(report_path, "w", encoding="utf-8") as fh:
27111
+ json.dump({"kind": f"color_trace.{kind}", "written_at": _time.time(),
27112
+ "plan_source": plan.get("source"), **payload}, fh, indent=1, default=str)
27113
+ return report_path
27114
+ except Exception as exc:
27115
+ logger.warning("apply_trace_plan: could not write %s report: %s", kind, exc)
27116
+ return None
27117
+
27118
+
27119
+ def _trace_compact(rows: List[Dict[str, Any]], max_rows: int) -> Dict[str, Any]:
27120
+ return {"count": len(rows), "truncated": len(rows) > max_rows, "rows": rows[:max_rows]}
27121
+
27122
+
27090
27123
  def _apply_trace_plan(p: Dict[str, Any]) -> Dict[str, Any]:
27091
27124
  plan, err = _load_trace_plan(p)
27092
27125
  if err:
@@ -27120,12 +27153,25 @@ def _apply_trace_plan(p: Dict[str, Any]) -> Dict[str, Any]:
27120
27153
  public = [{k: v for k, v in r.items() if not k.startswith("_")} for r in rows]
27121
27154
  grade_mode = p.get("grade_mode", 0)
27122
27155
  version_name = p.get("version_name")
27156
+ max_rows = p.get("max_rows", _TRACE_MAX_ROWS_DEFAULT)
27157
+ max_rows = len(public) if p.get("verbose") else max(0, int(max_rows))
27158
+ # Entries that deserve a human look: skipped for a reason other than the
27159
+ # two bulk ones, or applied on a tie / a partial source-range overlap.
27160
+ bulk = {"unmatched", "no-source-grade"}
27161
+ attention = [r for r in public if (r["status"] != "apply" and r["reason"] not in bulk)
27162
+ or (r["status"] == "apply" and r["ambiguous"])]
27123
27163
 
27124
27164
  if p.get("dry_run"):
27165
+ report_path = _trace_report(p, plan, "dry-run", {"summary": summary, "resolution": public})
27125
27166
  return _ok(dry_run=True, timeline=live_name, plan_source=plan.get("source"),
27126
- summary=summary, resolution=public, grade_mode=grade_mode, version_name=version_name)
27167
+ summary=summary, report_path=report_path,
27168
+ attention=_trace_compact(attention, max_rows),
27169
+ resolution=_trace_compact(public, max_rows),
27170
+ grade_mode=grade_mode, version_name=version_name)
27127
27171
  if not to_apply:
27128
- return _ok(timeline=live_name, applied=[], failed=[], summary=summary, resolution=public,
27172
+ report_path = _trace_report(p, plan, "apply", {"summary": summary, "resolution": public, "applied": [], "failed": []})
27173
+ return _ok(timeline=live_name, applied=[], failed=[], summary=summary, report_path=report_path,
27174
+ attention=_trace_compact(attention, max_rows), resolution=_trace_compact(public, max_rows),
27129
27175
  note="nothing to apply — every plan entry was skipped (see skipped_by_reason)")
27130
27176
  if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
27131
27177
  preview = {
@@ -27171,13 +27217,17 @@ def _apply_trace_plan(p: Dict[str, Any]) -> Dict[str, Any]:
27171
27217
  rec["status"] = "applied" if ok else "failed"
27172
27218
  (applied if ok else failed).append(rec)
27173
27219
  summary.update({"applied": len(applied), "failed": len(failed)})
27220
+ skipped = [r for r in public if r["status"] != "apply"]
27221
+ report_path = _trace_report(p, plan, "apply", {"summary": summary, "applied": applied, "failed": failed, "skipped": skipped})
27174
27222
  return {
27175
27223
  "success": not failed,
27176
27224
  "timeline": live_name,
27177
27225
  "plan_source": plan.get("source"),
27178
- "applied": applied,
27179
- "failed": failed,
27180
- "skipped": [r for r in public if r["status"] != "apply"],
27226
+ "report_path": report_path,
27227
+ "applied": _trace_compact(applied, max_rows),
27228
+ "failed": failed, # never truncated: every failure is actionable
27229
+ "skipped": _trace_compact(skipped, max_rows),
27230
+ "attention": _trace_compact(attention, max_rows),
27181
27231
  "summary": summary,
27182
27232
  "grade_mode": grade_mode,
27183
27233
  "version_name": version_name,
@@ -27305,8 +27355,8 @@ _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = {
27305
27355
  },
27306
27356
  "apply_trace_plan": {
27307
27357
  "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.",
27308
- "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?",
27309
- "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}]}",
27358
+ "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?, max_rows? (default 40; verbose=True returns every row), report_path? (default <plan dir>/dry-run-report.json | apply-report.json), confirm_token?",
27359
+ "returns": "{success, timeline, report_path, summary: {plan_matches, would_apply, applied, failed, skipped_by_reason, ambiguous_in_plan}, attention: {count, truncated, rows}, applied: {count, truncated, rows}, failed: [...] (never truncated), skipped: {count, truncated, rows}}; dry_run → {resolution: {count, truncated, rows: [{target, source, method, confidence, status, reason, live}]}}",
27310
27360
  "example": (
27311
27361
  '# 1. advanced server (no Resolve needed):\n'
27312
27362
  '# color_trace(action="plan", {sourceProjectName: "SHOW_v07", sourceTimeline: "REEL_01 v07",\n'
@@ -28141,13 +28191,15 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
28141
28191
  safe_apply_drx(path, source?, grade_mode?, require_temp_path?) -> {success}
28142
28192
  REPLACES the target graph. Captures a version snapshot first; require_temp_path defaults True.
28143
28193
  # example: action_help(name='<action_name>')
28144
- 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}
28194
+ 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}
28145
28195
  The live half of the advanced server's color_trace (a ColorTrace that matches on media
28146
28196
  identity, cross-project, from the project DB). Resolves each plan entry to a clip on the
28147
28197
  CURRENT timeline by (name, record start, duration), then ApplyGradeFromDRX per clip —
28148
28198
  one confirm_token for the batch, timeline archived first. dry_run returns the resolution
28149
28199
  table without a token. version_name adds a new local version per clip before applying so
28150
28200
  the previous grade stays intact. Never guesses: unresolved entries are reported, not applied.
28201
+ The full per-clip tables go to report_path (default: next to the plan); the response carries
28202
+ the summary, `attention` (ties, partial overlaps, non-bulk skips) and the first max_rows rows.
28151
28203
  # example: action_help(name='<action_name>')
28152
28204
  safe_export_lut(type?, path, require_temp_path?) -> {success, path, size}
28153
28205
  Sandboxed LUT export.