davinci-resolve-mcp 3.2.2 → 3.4.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,83 @@
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 v3.4.0 — review a bin one frame at a time in the control panel
6
+
7
+ Contributed by @tpellet (#230), their first contribution here.
8
+
9
+ ### Added
10
+
11
+ - **Serial source review in the control panel.** Reviewing a bin meant opening
12
+ each clip separately. The review bin now has an **Enlarge / review** button
13
+ per card that opens a full-size frame with previous/next navigation, so a bin
14
+ is walked once rather than clicked through a card at a time.
15
+ - **Include / Exclude / Unreviewed per clip, and an independent star rating.**
16
+ Keyboard-driven — `I`, `X`, `U` and the arrow keys — with Include and Exclude
17
+ advancing automatically. Selection and rating are separate fields: rating a
18
+ clip does not decide it, and excluding one does not discard its rating.
19
+ - **A selection filter** — all / non-excluded / excluded — alongside the
20
+ existing bin filter, and a selection chip on every card, so the state of a
21
+ pass is visible without opening anything.
22
+ - **Decisions survive a reload.** Both fields go through the existing
23
+ correction store, so they persist the way clip notes already did and are
24
+ visible to everything else that reads corrections. Notes are untouched.
25
+
26
+ ### Changed
27
+
28
+ - **Review thumbnails are letterboxed rather than cropped** (`object-fit:
29
+ cover` → `contain`). A cropped thumbnail hides exactly what a source review
30
+ is for: framing, headroom, and what is at the edges of frame.
31
+
32
+ ### Gating
33
+
34
+ - **Every save is verified, not assumed.** The panel re-reads the clip after
35
+ each write and refuses to advance if the value it reads back is not the one
36
+ it sent, so a failed save cannot be walked past. `apply_clip_correction`
37
+ validates server-side as well: `user.selection` must be one of the three
38
+ literals, and `user.rating` must be an `int` from 0 to 5 — `type(value) is
39
+ not int` deliberately, so a JSON `true` is rejected rather than silently
40
+ stored as a rating of 1.
41
+ - **No source media is touched and no Resolve edit is made.** Previews are
42
+ analyzed frames that already exist on disk; there is no conversion step.
43
+
44
+ ## What's New in v3.3.0 — ask the server what the native Resolve API contains
45
+
46
+ Contributed by @legionsound (#229), the second of the two branches queued in #207.
47
+
48
+ ### Added
49
+
50
+ - **`resolve_control`: `search_api`, `describe_api`, `api_surface`**, plus
51
+ granular twins `search_resolve_api`, `describe_resolve_api` and
52
+ `get_resolve_api_surface`. `resolve_control api_truth` already answered *what
53
+ is broken*; nothing answered *what exists*. #205 shipped Blackmagic's typed
54
+ `DaVinciResolveScript.pyi` and `scripts/audit_typed_api.py` could inventory
55
+ it, but only from a shell — an agent talking to this server had no way to ask.
56
+ This is the equivalent of Blackmagic's own `search_scripting_api`. Tool count
57
+ 37/384 → 37/387.
58
+ - **All three are read-only and none needs a Resolve connection.** They parse
59
+ the stub that already ships in `docs/reference/`, so they answer with Resolve
60
+ closed, and they describe the stub checked into this repository — currently
61
+ 21.1.0.14 — not whatever build happens to be installed. A missing stub is
62
+ reported as missing rather than guessed around.
63
+ - **Every result carries `referenced_in_this_server` and the files that
64
+ reference the method**, so a lookup doubles as a parity check: does the native
65
+ API have it, and do we wrap it? The flag counts executable syntax only —
66
+ attribute access, calls, `getattr(obj, "Name")` — never docstrings or
67
+ comments, built the same way `audit_typed_api.py` builds its source
68
+ references. A method named in prose is not coverage.
69
+ - **Ambiguity is reported, not guessed.** A bare `GetName` lists its candidates
70
+ instead of picking one. An invalid regex is refused rather than raised.
71
+ Results are capped with an explicit `truncated` flag rather than silently cut.
72
+
73
+ ### Guards
74
+
75
+ - The parser independently reports **410 methods, 46 TypedDicts, 513 fields**,
76
+ and `tests/test_typed_api_search.py` asserts that equality against the
77
+ inventory recorded in `resolve-211-typed-api.md`. A future stub refresh that
78
+ changes the surface now fails the suite instead of drifting quietly. The same
79
+ file checks both interfaces agree on surface, search and describe, and that
80
+ neither needs a connection.
81
+
5
82
  ## What's New in v3.2.2 — an analysis root that is deleted is actually let go of
6
83
 
7
84
  Contributed by @Dev-next-gen (#228), generalised to the second site and to the
package/README.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-3.2.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.4.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
- [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(384%20full)-blue.svg)](#server-modes)
8
+ [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(387%20full)-blue.svg)](#server-modes)
9
9
  [![Advanced](https://img.shields.io/badge/Advanced%20(offline)-18%20tools-blueviolet.svg)](#server-modes)
10
10
  [![Tested](https://img.shields.io/badge/Live%20Tested-93.6%25-green.svg)](docs/reference/api-coverage.md#test-results)
11
11
  [![DaVinci Resolve](https://img.shields.io/badge/DaVinci%20Resolve-18.5+-darkred.svg)](https://www.blackmagicdesign.com/products/davinciresolve)
@@ -133,7 +133,7 @@ The command starts a loopback-only server and opens the control panel in your br
133
133
  | Mode | Entry point | Tools | Best for |
134
134
  |------|-------------|-------|----------|
135
135
  | Compound | `src/server.py` | 37 | Default mode for most assistants. Related Resolve operations are grouped behind action parameters to keep context usage low. |
136
- | Full / granular | `src/server.py --full` or `src/resolve_mcp_server.py` | 384 | Power users who want one MCP tool per Resolve API method. |
136
+ | Full / granular | `src/server.py --full` or `src/resolve_mcp_server.py` | 387 | Power users who want one MCP tool per Resolve API method. |
137
137
 
138
138
  The compound server is recommended unless you specifically need the granular one-tool-per-method surface.
139
139
 
@@ -365,7 +365,7 @@ The default server is a local stdio process launched by your MCP client; it does
365
365
 
366
366
  | Metric | Value |
367
367
  |--------|-------|
368
- | MCP Tools | **37** compound / **384** granular (live server) |
368
+ | MCP Tools | **37** compound / **387** granular (live server) |
369
369
  | Advanced (offline) tools | **18** — .drp/.drt/.drx + DB authoring, no Resolve running |
370
370
  | Kernel Actions | **136** guarded workflow actions across 9 compound tools |
371
371
  | API Methods Covered | **361/361** (100%) |
package/README.zh-CN.md CHANGED
@@ -2,17 +2,17 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-3.2.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.4.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
- [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(384%20full)-blue.svg)](#服务器模式)
8
+ [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(387%20full)-blue.svg)](#服务器模式)
9
9
  [![Advanced](https://img.shields.io/badge/Advanced%20(offline)-18%20tools-blueviolet.svg)](#服务器模式)
10
10
  [![Tested](https://img.shields.io/badge/Live%20Tested-93.6%25-green.svg)](docs/reference/api-coverage.md#test-results)
11
11
  [![DaVinci Resolve](https://img.shields.io/badge/DaVinci%20Resolve-18.5+-darkred.svg)](https://www.blackmagicdesign.com/products/davinciresolve)
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
- > 本翻译对应 v3.2.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v3.4.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -88,7 +88,7 @@ venv/bin/python -m src.control_panel
88
88
  | 模式 | 入口 | 工具数 | 适合谁 |
89
89
  |------|------|--------|--------|
90
90
  | Compound(复合) | `src/server.py` | 37 | 大多数助手的默认模式。相关的 Resolve 操作按 action 参数分组,压低上下文占用。 |
91
- | Full / granular(细粒度) | `src/server.py --full` 或 `src/resolve_mcp_server.py` | 384 | 想要"一个 Resolve API 方法 = 一个 MCP 工具"的重度用户。 |
91
+ | Full / granular(细粒度) | `src/server.py --full` 或 `src/resolve_mcp_server.py` | 387 | 想要"一个 Resolve API 方法 = 一个 MCP 工具"的重度用户。 |
92
92
 
93
93
  除非你明确需要一方法一工具的细粒度界面,否则推荐复合模式。
94
94
 
@@ -226,7 +226,7 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
226
226
 
227
227
  | 指标 | 数值 |
228
228
  |------|------|
229
- | MCP 工具 | **37** 复合 / **384** 细粒度(实时服务器) |
229
+ | MCP 工具 | **37** 复合 / **387** 细粒度(实时服务器) |
230
230
  | Advanced(离线)工具 | **18**——.drp/.drt/.drx + 数据库创作,无需 Resolve 运行 |
231
231
  | 内核 action | 9 个复合工具下 **136** 个带护栏的工作流 action |
232
232
  | API 方法覆盖 | **361/361**(100%) |
package/docs/SKILL.md CHANGED
@@ -353,7 +353,7 @@ to the user as verified.
353
353
  | Mode | Entry point | Tool count | Use when |
354
354
  |---|---|---|---|
355
355
  | Compound (default) | `src/server.py` | 37 tools | Most workflows — keeps context lean |
356
- | Granular (full) | `src/server.py --full` | 384 tools | Power users needing one tool per API method |
356
+ | Granular (full) | `src/server.py --full` | 387 tools | Power users needing one tool per API method |
357
357
 
358
358
  Resolve 21.1 adds [twelve read-only discovery controls](reference/resolve211-read-controls.md)
359
359
  for edition, presets, audio formats/codecs, normalization modes, speed, fades
@@ -101,6 +101,19 @@ detection has run (`detect_entities` + a one-frame-per-cluster confirmation
101
101
  in chat), a `Recurring across this bin` card lists the labeled people,
102
102
  places, and objects with their shot counts.
103
103
 
104
+ Use **Enlarge / review** on a card to inspect its complete analyzed frame without
105
+ cropping. Previous/Next (or arrow keys) follow the visible bin order. **Include**
106
+ (`I`) and **Exclude** (`X`) save the selection and advance after readback;
107
+ **Unreviewed** (`U`) clears the decision without advancing. Ratings save independently
108
+ and do not clear exclusions. The selection filter can show all, non-excluded,
109
+ or excluded clips; it applies to the bin browser, not search results.
110
+
111
+ Selections are stored as `user.selection` corrections alongside existing notes
112
+ and ratings. They express review preferences, not automatic timeline edits.
113
+ Previews use existing analyzed frames, so their resolution depends on the analysis;
114
+ this does not generate proxies or stream original media. Existing clip detail
115
+ controls remain available for notes and metadata edits.
116
+
104
117
  ### Clip detail
105
118
 
106
119
  ![Clip detail with shot strip](../images/control-panel/03-clip-detail.png)
package/docs/install.md CHANGED
@@ -144,7 +144,7 @@ The MCP server comes in two modes:
144
144
  | Mode | File | Tools | Best For |
145
145
  |------|------|-------|----------|
146
146
  | **Compound** (default) | `src/server.py` | 37 | Most users — fast, clean, low context usage |
147
- | **Full** | `src/resolve_mcp_server.py` | 384 | Power users who want one tool per API method |
147
+ | **Full** | `src/resolve_mcp_server.py` | 387 | Power users who want one tool per API method |
148
148
 
149
149
  The compound server's `timeline_item` tool includes dedicated actions for common workflows:
150
150
 
@@ -159,7 +159,7 @@ The compound server's `timeline_item` tool includes dedicated actions for common
159
159
 
160
160
  The installer uses the compound server by default. To use the full server:
161
161
  ```bash
162
- python src/server.py --full # Launch full 384-tool server
162
+ python src/server.py --full # Launch full 387-tool server
163
163
  # Or point your MCP config directly at src/resolve_mcp_server.py
164
164
  ```
165
165
 
@@ -25,7 +25,7 @@ Every non-deprecated method in the bundled legacy README is represented. This
25
25
  does not claim complete coverage of the newer Resolve 21.1 typed API. The
26
26
  default compound server exposes **37 tools** that group related operations by
27
27
  action parameter, keeping LLM context windows lean. The full granular server
28
- provides **384 individual tools** for power users. The legacy coverage spans
28
+ provides **387 individual tools** for power users. The legacy coverage spans
29
29
  13 API object classes. MCP-level kernel actions are tracked separately in
30
30
  [Kernel Action Coverage](../kernels/README.md).
31
31
 
@@ -39,6 +39,11 @@ Parity with Blackmagic's own MCP surfaced one more gap: it exposes
39
39
  node but never list, install or remove LUT files. The `lut` tool and its
40
40
  granular twins close that. See [LUT file controls](lut-file-controls.md).
41
41
 
42
+ The typed stub shipped in #205 is now queryable from the server itself, the
43
+ way Blackmagic's MCP exposes `search_scripting_api` — and each result also says
44
+ whether this server wraps the method, so a lookup doubles as a parity check.
45
+ See [Querying the typed API](typed-api-search.md).
46
+
42
47
  The 34th compound tool is `timeline_versioning` (C6) — an MCP-level workflow
43
48
  tool, not a wrapper around a Resolve API method. It surfaces the
44
49
  version-on-mutate hook that auto-archives the working timeline before any
@@ -0,0 +1,56 @@
1
+ # Querying the typed API from inside the server
2
+
3
+ `resolve_control api_truth` answers *what is broken*. Nothing answered *what
4
+ exists*. PR #205 landed Blackmagic's `DaVinciResolveScript.pyi` in
5
+ `docs/reference/`, and `scripts/audit_typed_api.py` can turn it into a full
6
+ inventory — but only from a shell. An agent talking to this server had no way
7
+ to ask what the native API contains.
8
+
9
+ Blackmagic's own MCP exposes `search_scripting_api` for this. These are the
10
+ equivalent, with one addition that matters more here.
11
+
12
+ ## The addition: coverage, not just existence
13
+
14
+ Every result carries `referenced_in_this_server` and the source files that
15
+ reference it. That turns a lookup into a parity check — *does the native API
16
+ have it, and do we wrap it?* — which is the question the whole 21.1 audit was
17
+ built to answer, and it was previously only answerable by grepping.
18
+
19
+ The flag counts **executable syntax only**: attribute access, calls, and
20
+ `getattr(obj, "Name")`. A method named in a docstring or a comment is not
21
+ coverage. Treating prose as coverage is the specific mistake this repo's audits
22
+ exist to avoid, so the flag is built the same way `scripts/audit_typed_api.py`
23
+ builds its source references.
24
+
25
+ ## Actions and tools
26
+
27
+ Compound `resolve_control`, none of which need a Resolve connection:
28
+
29
+ - `search_api(pattern, kind?, limit?)` — case-insensitive regex over
30
+ class-qualified method names, signatures, TypedDict names, field names and
31
+ descriptions. `kind` is `all`, `methods` or `options`. Results are capped and
32
+ the response declares `truncated` rather than silently cutting.
33
+ - `describe_api(symbol)` — one `Class.Method`, an unambiguous bare method name,
34
+ or a TypedDict name. An ambiguous bare name lists the candidates instead of
35
+ guessing.
36
+ - `api_surface()` — counts and the object list.
37
+
38
+ Granular twins: `search_resolve_api`, `describe_resolve_api`,
39
+ `get_resolve_api_surface`. Tool count 384 → 387. The granular tools carry the read-only annotation, since
40
+ for the granular server the MCP annotation is the signal a client reads.
41
+
42
+ ## Self-consistency
43
+
44
+ The parser reports **410 methods, 46 TypedDicts, 513 fields**, independently
45
+ matching the inventory published in `resolve-211-typed-api.md` and the
46
+ disposition ledgers built during the 21.1 audit. That agreement is asserted in
47
+ `tests/test_typed_api_search.py`, so a stub refresh that changes the surface
48
+ will fail the suite rather than drift quietly.
49
+
50
+ ## Scope
51
+
52
+ This reads the stub shipped in this repository; it does not query a running
53
+ Resolve, and it will report a missing stub rather than guessing. It therefore
54
+ describes the API of the Resolve version whose stub is checked in — 21.1.0.14 —
55
+ not whatever build happens to be installed. Refreshing the stub is the existing
56
+ documented process in `resolve-211-typed-api.md`.
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "3.2.2"
40
+ VERSION = "3.4.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
@@ -1543,7 +1543,7 @@ def verify_resolve_connection(python_path, api_path, lib_path):
1543
1543
 
1544
1544
  def print_banner():
1545
1545
  title = f"DaVinci Resolve MCP Server — Installer v{VERSION}"
1546
- subtitle = "37 compound · 384 full · 3 platforms"
1546
+ subtitle = "37 compound · 387 full · 3 platforms"
1547
1547
  print()
1548
1548
  print(bold(" ╔══════════════════════════════════════════════════════╗"))
1549
1549
  print(bold(f" ║{title:^54}║"))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "3.2.2",
3
+ "version": "3.4.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -1658,7 +1658,7 @@ HTML = r"""<!doctype html>
1658
1658
  aspect-ratio: 16 / 9;
1659
1659
  background: var(--lab-workspace-letterbox);
1660
1660
  border-radius: var(--radius-sm);
1661
- object-fit: cover;
1661
+ object-fit: contain;
1662
1662
  display: block;
1663
1663
  }
1664
1664
  .review-thumb.placeholder {
@@ -4265,6 +4265,11 @@ HTML = r"""<!doctype html>
4265
4265
  <select id="reviewBinFilter" aria-label="Filter by bin">
4266
4266
  <option value="">All bins</option>
4267
4267
  </select>
4268
+ <select id="sourceSelectionFilter" aria-label="Filter by source selection">
4269
+ <option value="all">All selections</option>
4270
+ <option value="non-excluded">Non-excluded</option>
4271
+ <option value="excluded">Excluded</option>
4272
+ </select>
4268
4273
  <div class="review-view-toggle" role="tablist" aria-label="View mode">
4269
4274
  <button id="reviewViewGridBtn" class="active" data-view-mode="grid" type="button">Grid</button>
4270
4275
  <button id="reviewViewListBtn" data-view-mode="list" type="button">List</button>
@@ -8822,11 +8827,159 @@ HTML = r"""<!doctype html>
8822
8827
  sorted.map(b => `<option value="${escapeHtml(b)}" ${b === current ? 'selected' : ''}>${escapeHtml(b)}</option>`).join('');
8823
8828
  }
8824
8829
 
8830
+ let sourceSelectionFilter = 'all';
8831
+ function matchesSourceSelection(clip) {
8832
+ const selection = clip.user_selection || 'Unreviewed';
8833
+ return sourceSelectionFilter === 'all'
8834
+ || (sourceSelectionFilter === 'excluded' ? selection === 'Exclude' : selection !== 'Exclude');
8835
+ }
8836
+
8837
+ // Source preview reviews the current bin/filter order using persisted corrections.
8838
+ const sourcePreview = document.createElement('dialog');
8839
+ sourcePreview.setAttribute('aria-label', 'Source preview');
8840
+ sourcePreview.style.cssText = 'max-width:96vw;max-height:96vh;background:var(--bg-elevated-1);color:var(--text-primary)';
8841
+ sourcePreview.innerHTML = `<button type="button" data-preview-close>Close</button>
8842
+ <h3 data-preview-title></h3><div data-preview-controls>
8843
+ <button type="button" data-preview-step="-1">← Previous</button>
8844
+ <button type="button" data-preview-selection="Include">Include & next (I)</button>
8845
+ <button type="button" data-preview-selection="Exclude">Exclude & next (X)</button>
8846
+ <button type="button" data-preview-selection="Unreviewed">Unreviewed (U)</button>
8847
+ <button type="button" data-preview-step="1">Next →</button>
8848
+ <span>Rating:</span>${[0,1,2,3,4,5].map(n => `<button type="button" data-preview-rating="${n}" aria-label="${n ? n + ' stars' : 'Clear stars'}">${n ? '★'.repeat(n) : 'Clear'}</button>`).join('')}
8849
+ </div><p data-preview-status role="status"></p>
8850
+ <img data-preview-image alt="" style="display:block;max-width:90vw;max-height:70vh;object-fit:contain">`;
8851
+ document.body.append(sourcePreview);
8852
+ const previewImage = sourcePreview.querySelector('[data-preview-image]');
8853
+ const previewStatus = sourcePreview.querySelector('[data-preview-status]');
8854
+ let previewQueue = [], previewIndex = -1, previewBusy = false, previewReady = false, previewGeneration = 0;
8855
+
8856
+ function lockSourcePreview(busy) {
8857
+ previewBusy = busy;
8858
+ sourcePreview.querySelectorAll('[data-preview-controls] button').forEach(button => {
8859
+ button.disabled = busy || (!button.dataset.previewStep && !previewReady);
8860
+ });
8861
+ }
8862
+
8863
+ function displaySourceReview(data, clip) {
8864
+ const selection = readCorrectionValue(data.corrections, 'clip', clip.clip_id, 'user.selection') || 'Unreviewed';
8865
+ const rating = readCorrectionValue(data.corrections, 'clip', clip.clip_id, 'user.rating') || 0;
8866
+ clip.user_selection = selection;
8867
+ sourcePreview.querySelectorAll('[data-preview-rating]').forEach(button => {
8868
+ button.setAttribute('aria-pressed', String(Number(button.dataset.previewRating) === rating));
8869
+ });
8870
+ sourcePreview.querySelectorAll('[data-preview-selection]').forEach(button => {
8871
+ button.setAttribute('aria-pressed', String(button.dataset.previewSelection === selection));
8872
+ });
8873
+ previewStatus.textContent = `${selection} · ${rating} stars — saved. ←/→ navigate; I include; X exclude; U clear selection.`;
8874
+ }
8875
+
8876
+ async function showSourcePreview(index) {
8877
+ if (previewBusy || index < 0 || index >= previewQueue.length) return;
8878
+ previewIndex = index;
8879
+ previewReady = false;
8880
+ const clip = previewQueue[index], generation = ++previewGeneration;
8881
+ lockSourcePreview(true);
8882
+ sourcePreview.querySelector('[data-preview-title]').textContent = `${index + 1} / ${previewQueue.length} — ${clip.clip_name || clip.clip_id}`;
8883
+ previewStatus.textContent = 'Loading preview and saved review…';
8884
+ previewImage.style.visibility = 'hidden';
8885
+ previewImage.alt = clip.clip_name || clip.clip_id;
8886
+ try {
8887
+ if (!clip.representative_frame_index) throw new Error('No analyzed frame available. Use Previous/Next to continue.');
8888
+ const loaded = new Promise((resolve, reject) => {
8889
+ previewImage.onload = resolve;
8890
+ previewImage.onerror = () => reject(new Error('Preview unavailable. Use Previous/Next to continue.'));
8891
+ });
8892
+ previewImage.src = `/api/clips/${encodeURIComponent(clip.clip_id)}/frames/${clip.representative_frame_index}`;
8893
+ const [data] = await Promise.all([api(`/api/clips/${encodeURIComponent(clip.clip_id)}`, {cache:'no-store'}), loaded]);
8894
+ if (generation !== previewGeneration) return;
8895
+ if (!data.success) throw new Error(data.error || 'Could not load saved review');
8896
+ displaySourceReview(data, clip);
8897
+ previewImage.style.visibility = 'visible';
8898
+ previewReady = true;
8899
+ } catch (error) {
8900
+ if (generation === previewGeneration) previewStatus.textContent = error.message;
8901
+ } finally {
8902
+ if (generation === previewGeneration) lockSourcePreview(false);
8903
+ }
8904
+ }
8905
+
8906
+ async function saveSourceReview(field, value) {
8907
+ if (previewBusy || !previewReady || previewIndex < 0) return;
8908
+ const clip = previewQueue[previewIndex], generation = previewGeneration;
8909
+ lockSourcePreview(true);
8910
+ previewStatus.textContent = 'Saving…';
8911
+ try {
8912
+ const result = await api(`/api/clips/${encodeURIComponent(clip.clip_id)}/corrections`, {
8913
+ method:'POST', body:JSON.stringify({entity_type:'clip', entity_uuid:clip.clip_id,
8914
+ field_path:field, new_value:value, author:'control_panel', reason:'source preview review'})});
8915
+ if (!result.success) throw new Error(result.error || 'Save failed');
8916
+ const data = await api(`/api/clips/${encodeURIComponent(clip.clip_id)}`, {cache:'no-store'});
8917
+ if (!data.success || readCorrectionValue(data.corrections, 'clip', clip.clip_id, field) !== value) {
8918
+ throw new Error('Saved review could not be verified; retry before continuing.');
8919
+ }
8920
+ clip.user_selection = readCorrectionValue(data.corrections, 'clip', clip.clip_id, 'user.selection') || 'Unreviewed';
8921
+ const listedClip = state.review.clipList?.clips?.find(item => item.clip_id === clip.clip_id);
8922
+ if (listedClip) listedClip.user_selection = clip.user_selection;
8923
+ renderReviewBin();
8924
+ if (generation !== previewGeneration) return;
8925
+ displaySourceReview(data, clip);
8926
+ lockSourcePreview(false);
8927
+ if (field === 'user.selection') {
8928
+ if (!matchesSourceSelection(clip)) {
8929
+ previewQueue.splice(previewIndex, 1);
8930
+ if (previewQueue.length) await showSourcePreview(Math.min(previewIndex, previewQueue.length - 1));
8931
+ else sourcePreview.close();
8932
+ } else if (value !== 'Unreviewed' && previewIndex + 1 < previewQueue.length) {
8933
+ await showSourcePreview(previewIndex + 1);
8934
+ }
8935
+ }
8936
+ } catch (error) {
8937
+ if (generation === previewGeneration) previewStatus.textContent = 'Save failed: ' + error.message;
8938
+ } finally {
8939
+ if (generation === previewGeneration) lockSourcePreview(false);
8940
+ }
8941
+ }
8942
+
8943
+ sourcePreview.querySelector('[data-preview-close]').onclick = () => sourcePreview.close();
8944
+ sourcePreview.addEventListener('close', () => {
8945
+ ++previewGeneration;
8946
+ previewReady = false;
8947
+ lockSourcePreview(false);
8948
+ renderReviewBin();
8949
+ });
8950
+ sourcePreview.addEventListener('click', event => {
8951
+ const button = event.target.closest('button');
8952
+ if (!button) return;
8953
+ if (button.dataset.previewStep) showSourcePreview(previewIndex + Number(button.dataset.previewStep));
8954
+ if (button.dataset.previewSelection) saveSourceReview('user.selection', button.dataset.previewSelection);
8955
+ if (button.dataset.previewRating != null) saveSourceReview('user.rating', Number(button.dataset.previewRating));
8956
+ });
8957
+ sourcePreview.addEventListener('keydown', event => {
8958
+ if (event.target.matches('input,textarea,select') || event.ctrlKey || event.metaKey || event.altKey) return;
8959
+ const actions = {
8960
+ ArrowLeft: () => showSourcePreview(previewIndex - 1),
8961
+ ArrowRight: () => showSourcePreview(previewIndex + 1),
8962
+ i: () => saveSourceReview('user.selection', 'Include'),
8963
+ x: () => saveSourceReview('user.selection', 'Exclude'),
8964
+ u: () => saveSourceReview('user.selection', 'Unreviewed'),
8965
+ };
8966
+ const action = actions[event.key] || actions[event.key.toLowerCase()];
8967
+ if (action) { event.preventDefault(); event.stopPropagation(); if (!event.repeat) action(); }
8968
+ });
8969
+ $('reviewBinGrid').addEventListener('click', event => {
8970
+ const button = event.target.closest('[data-source-preview]');
8971
+ if (!button) return;
8972
+ event.stopImmediatePropagation();
8973
+ previewQueue = filteredClips().slice();
8974
+ sourcePreview.showModal();
8975
+ showSourcePreview(previewQueue.findIndex(clip => clip.clip_id === button.dataset.sourcePreview));
8976
+ }, true);
8977
+
8825
8978
  function filteredClips() {
8826
8979
  const data = state.review.clipList;
8827
8980
  if (!data || !data.clips) return [];
8828
8981
  const binFilter = state.review.binFilter || '';
8829
- return data.clips.filter(c => !binFilter || c.bin_path === binFilter);
8982
+ return data.clips.filter(c => (!binFilter || c.bin_path === binFilter) && matchesSourceSelection(c));
8830
8983
  }
8831
8984
 
8832
8985
  function renderReviewBin() {
@@ -8850,8 +9003,8 @@ HTML = r"""<!doctype html>
8850
9003
  }
8851
9004
  const clips = filteredClips();
8852
9005
  if (!clips.length) {
8853
- if (state.review.binFilter) {
8854
- if (summary) summary.textContent = `No analyzed clips in bin "${state.review.binFilter}".`;
9006
+ if (state.review.binFilter || sourceSelectionFilter !== 'all') {
9007
+ if (summary) summary.textContent = 'No analyzed clips match these filters.';
8855
9008
  if (grid) grid.innerHTML = '';
8856
9009
  } else {
8857
9010
  if (summary) summary.textContent = 'Nothing analyzed yet.';
@@ -8868,8 +9021,8 @@ HTML = r"""<!doctype html>
8868
9021
  Array.from(selected).forEach(id => { if (!visibleIds.has(id)) selected.delete(id); });
8869
9022
  if (summary) {
8870
9023
  const total = data.clips.length;
8871
- const base = state.review.binFilter
8872
- ? `${clips.length} of ${total} analyzed clip${total === 1 ? '' : 's'} · bin: ${state.review.binFilter}`
9024
+ const base = state.review.binFilter || sourceSelectionFilter !== 'all'
9025
+ ? `${clips.length} of ${total} analyzed clip${total === 1 ? '' : 's'}${state.review.binFilter ? ' · bin: ' + state.review.binFilter : ''}`
8873
9026
  : `${total} analyzed clip${total === 1 ? '' : 's'} in this project\u2019s analysis root.`;
8874
9027
  if (selected.size > 0) {
8875
9028
  summary.innerHTML = `<span>${escapeHtml(base)}</span>
@@ -8901,8 +9054,9 @@ HTML = r"""<!doctype html>
8901
9054
  <span class="select-box">${isSelected ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>' : ''}</span>
8902
9055
  </button>
8903
9056
  ${thumb}
9057
+ <button type="button" class="secondary" data-source-preview="${escapeHtml(clip.clip_id || '')}">Enlarge / review</button>
8904
9058
  <div class="review-clip-card-name">${escapeHtml(clip.clip_name || '')}</div>
8905
- <div class="review-clip-card-meta"><span>${dur}</span><span>${shots}</span>${primaryUse}${chip}</div>
9059
+ <div class="review-clip-card-meta"><span>${dur}</span><span>${shots}</span>${primaryUse}${chip}<span class="review-chip">${escapeHtml(clip.user_selection || 'Unreviewed')}</span></div>
8906
9060
  ${clip.clip_summary_oneliner ? `<div class="review-clip-card-oneliner">${escapeHtml(clip.clip_summary_oneliner)}</div>` : ''}
8907
9061
  </div>`;
8908
9062
  }).join('');
@@ -11481,6 +11635,7 @@ HTML = r"""<!doctype html>
11481
11635
  openClipDetail(card.dataset.clipId).catch(alertError);
11482
11636
  });
11483
11637
  $('reviewBinGrid').addEventListener('keydown', event => {
11638
+ if (event.target.closest('[data-source-preview]')) return;
11484
11639
  if (event.key !== 'Enter' && event.key !== ' ') return;
11485
11640
  const card = event.target.closest('.review-clip-card');
11486
11641
  if (card && card.dataset.clipId) {
@@ -11886,6 +12041,10 @@ HTML = r"""<!doctype html>
11886
12041
  const q = $('reviewSearchInput').value.trim();
11887
12042
  if (q) runReviewSearch(q).catch(alertError);
11888
12043
  });
12044
+ $('sourceSelectionFilter').addEventListener('change', event => {
12045
+ sourceSelectionFilter = event.target.value;
12046
+ renderReviewBin();
12047
+ });
11889
12048
  // Bin dropdown.
11890
12049
  $('reviewBinFilter').addEventListener('change', event => {
11891
12050
  state.review.binFilter = event.target.value || '';
@@ -13433,7 +13592,10 @@ def _v2_clip_summary_card(clip_slug: str, clip_dir: str, report: Dict[str, Any])
13433
13592
  if not isinstance(oneliner, str) or not oneliner:
13434
13593
  oneliner = summary[:140] + ("…" if len(summary) > 140 else "")
13435
13594
  rep_index = _v2_pick_representative_frame_index(report)
13595
+ corrections = _v2_read_corrections_for_dir(clip_dir).get("current", {})
13596
+ selection = corrections.get(f"clip:{clip_block.get('clip_id')}:user.selection", {}).get("value", "Unreviewed")
13436
13597
  return {
13598
+ "user_selection": selection,
13437
13599
  "clip_id": clip_block.get("clip_id"),
13438
13600
  "clip_slug": clip_slug,
13439
13601
  "clip_dir": clip_dir,
@@ -13938,6 +14100,12 @@ def apply_clip_correction(project_root: str, clip_id: str, body: Dict[str, Any])
13938
14100
  clip_dir = _v2_find_clip_dir(project_root, clip_id)
13939
14101
  if not clip_dir:
13940
14102
  return {"success": False, "error": f"No analyzed clip found for id={clip_id}"}
14103
+ field_path = body.get("field_path") or body.get("fieldPath")
14104
+ value = body.get("new_value") if "new_value" in body else body.get("newValue", body.get("value"))
14105
+ if field_path == "user.selection" and value not in ("Include", "Exclude", "Unreviewed"):
14106
+ return {"success": False, "error": "Selection must be Include, Exclude, or Unreviewed"}
14107
+ if field_path == "user.rating" and (type(value) is not int or not 0 <= value <= 5):
14108
+ return {"success": False, "error": "Rating must be an integer from 0 to 5"}
13941
14109
  from src.server import _v2_update_field
13942
14110
  entity_type = body.get("entity_type") or body.get("entityType") or "shot"
13943
14111
  params: Dict[str, Any] = dict(body)
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "3.2.2"
90
+ VERSION = "3.4.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()}")
@@ -1,6 +1,8 @@
1
1
  """Resolve control resources, inspection helpers, and app-level tools."""
2
2
 
3
3
  from src.granular.common import * # noqa: F401,F403
4
+ import os
5
+ from src.utils import typed_api_search
4
6
 
5
7
  resolve = ResolveProxy()
6
8
 
@@ -719,3 +721,48 @@ def export_user_preferences_preset(preset_name: str, export_path: str) -> Dict[s
719
721
  return missing
720
722
  result = resolve.ExportUserPreferencesPreset(preset_name, export_path)
721
723
  return {"success": bool(result), "preset_name": preset_name, "export_path": export_path}
724
+
725
+
726
+ _PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
727
+
728
+
729
+ @mcp.tool(annotations=READ_ONLY_TOOL)
730
+ def search_resolve_api(pattern: str, kind: str = "all", limit: int = 50) -> Dict[str, Any]:
731
+ """Search the shipped Resolve 21.1 typed API stub. Needs no connection.
732
+
733
+ `api_truth` answers what is broken; this answers what exists. Each hit also
734
+ reports whether THIS server references the method and in which files, so a
735
+ search doubles as a parity check.
736
+
737
+ Args:
738
+ pattern: Case-insensitive regular expression, e.g. "marker" or "(Get|Set)Setting".
739
+ kind: 'all' (default), 'methods', or 'options' for TypedDicts only.
740
+ limit: Maximum results per section (capped at 200).
741
+ """
742
+ try:
743
+ return typed_api_search.search(_PROJECT_DIR, pattern, kind=kind, limit=limit)
744
+ except typed_api_search.TypedApiError as exc:
745
+ return {"error": str(exc)}
746
+
747
+
748
+ @mcp.tool(annotations=READ_ONLY_TOOL)
749
+ def describe_resolve_api(symbol: str) -> Dict[str, Any]:
750
+ """Full typed detail for one Resolve API symbol. Needs no connection.
751
+
752
+ Args:
753
+ symbol: 'Class.Method' (e.g. "Project.GetName"), a bare method name when
754
+ it is unambiguous, or a TypedDict name (e.g. "RenderSettings").
755
+ """
756
+ try:
757
+ return typed_api_search.describe(_PROJECT_DIR, symbol)
758
+ except typed_api_search.TypedApiError as exc:
759
+ return {"error": str(exc)}
760
+
761
+
762
+ @mcp.tool(annotations=READ_ONLY_TOOL)
763
+ def get_resolve_api_surface() -> Dict[str, Any]:
764
+ """Counts and object list for the shipped typed stub. Needs no connection."""
765
+ try:
766
+ return typed_api_search.summary(_PROJECT_DIR)
767
+ except typed_api_search.TypedApiError as exc:
768
+ return {"error": str(exc)}
@@ -34,7 +34,7 @@ from src.utils.update_check import start_background_update_check
34
34
  if __name__ == "__main__":
35
35
  try:
36
36
  start_background_update_check(VERSION, project_dir, logger)
37
- logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION} (384 granular tools)")
37
+ logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION} (387 granular tools)")
38
38
  run_fastmcp_stdio(mcp)
39
39
  except KeyboardInterrupt:
40
40
  logger.info("Server shutdown requested")
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 = "3.2.2"
14
+ VERSION = "3.4.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -52,6 +52,7 @@ from src.utils.resolve211_edits import validate_edit_options, validate_transitio
52
52
 
53
53
  # Platform-specific Resolve paths
54
54
  from src.utils.cdl import normalize_cdl_payload
55
+ from src.utils import typed_api_search
55
56
  from src.utils import lut_files
56
57
  from src.utils import resolve_writes as _resolve_writes
57
58
  from src.utils.mcp_stdio import run_fastmcp_stdio
@@ -16652,6 +16653,15 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16652
16653
  set_keyframe_mode(mode) -> {success}
16653
16654
  quit() -> {success}
16654
16655
  get_fairlight_presets() -> {presets}
16656
+ search_api(pattern, kind?, limit?) -> {methods, option_types, total_matches}
16657
+ — regex over the shipped Resolve 21.1 typed stub. Each hit says whether
16658
+ THIS server references the method, and where, so it doubles as a
16659
+ parity check. kind: 'all' (default) | 'methods' | 'options'.
16660
+ No connection needed.
16661
+ describe_api(symbol) -> {signatures, stub_line, description, source_files}
16662
+ — one 'Class.Method' or one TypedDict name. No connection needed.
16663
+ api_surface() -> {methods, option_types, option_fields, objects}
16664
+ — what the shipped stub contains. No connection needed.
16655
16665
  set_high_priority() -> {success}
16656
16666
  disable_background_tasks_for_current_session() -> {success} — Resolve 21+
16657
16667
  list_user_preferences_presets() -> {presets} — Resolve 21.0.4+
@@ -16706,7 +16716,24 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16706
16716
  # that property is worth keeping: it is the one call that still answers when
16707
16717
  # Resolve is down or unreachable. So the live build is used only if one is
16708
16718
  # ALREADY connected, or if the caller names it. Never connect for this.
16709
- if action == "api_truth":
16719
+ if action == "search_api":
16720
+ try:
16721
+ return typed_api_search.search(
16722
+ project_dir, p.get("pattern"),
16723
+ kind=p.get("kind", "all"), limit=p.get("limit", 50))
16724
+ except typed_api_search.TypedApiError as exc:
16725
+ return _err(str(exc), code="TYPED_API_QUERY", category="invalid_input")
16726
+ elif action == "describe_api":
16727
+ try:
16728
+ return typed_api_search.describe(project_dir, p.get("symbol"))
16729
+ except typed_api_search.TypedApiError as exc:
16730
+ return _err(str(exc), code="TYPED_API_QUERY", category="invalid_input")
16731
+ elif action == "api_surface":
16732
+ try:
16733
+ return typed_api_search.summary(project_dir)
16734
+ except typed_api_search.TypedApiError as exc:
16735
+ return _err(str(exc), code="TYPED_API_QUERY", category="invalid_input")
16736
+ elif action == "api_truth":
16710
16737
  facts = lookup_api_truth(p.get("query"))
16711
16738
  live_version = p.get("resolve_version")
16712
16739
  if not live_version and resolve is not None:
@@ -17139,7 +17166,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17139
17166
  if err:
17140
17167
  return _err(err)
17141
17168
  return {"success": bool(r.ExportUserPreferencesPreset(clean["name"], clean["path"]))}
17142
- return _unknown(action, ["is_studio","get_keyboard_presets","get_current_keyboard_preset","launch","runtime_mode","get_version","api_truth","check_version_support","verification_stats","report_issue","job_status","list_jobs","get_execution_trace","get_execution","list_recent_executions","begin_execution","end_execution","export_execution_report","clear_executions","inspect_operation","list_lifecycle_hooks","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","list_user_preferences_presets","save_user_preferences_preset","load_user_preferences_preset","delete_user_preferences_preset","import_user_preferences_preset","export_user_preferences_preset","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
17169
+ return _unknown(action, ["is_studio","get_keyboard_presets","get_current_keyboard_preset","launch","runtime_mode","get_version","search_api","describe_api","api_surface","api_truth","check_version_support","verification_stats","report_issue","job_status","list_jobs","get_execution_trace","get_execution","list_recent_executions","begin_execution","end_execution","export_execution_report","clear_executions","inspect_operation","list_lifecycle_hooks","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","list_user_preferences_presets","save_user_preferences_preset","load_user_preferences_preset","delete_user_preferences_preset","import_user_preferences_preset","export_user_preferences_preset","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
17143
17170
 
17144
17171
 
17145
17172
  # ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
@@ -0,0 +1,260 @@
1
+ """Query the shipped Resolve 21.1 typed stub from inside the server.
2
+
3
+ PR #205 landed Blackmagic's `DaVinciResolveScript.pyi` in `docs/reference/`, and
4
+ `scripts/audit_typed_api.py` can turn it into a full inventory — but only from a
5
+ shell. An agent talking to this server had no way to ask what the native API
6
+ contains. `resolve_control api_truth` answers "what is broken"; nothing answered
7
+ "what exists".
8
+
9
+ The official Blackmagic MCP exposes `search_scripting_api` for this. This is the
10
+ equivalent, with one addition that matters more here: each result says whether
11
+ **this server** references the method, and where. That turns a lookup into a
12
+ parity check — "does the native API have it, and do we wrap it?" — which is the
13
+ question that drove the whole 21.1 audit.
14
+
15
+ Parsing is `ast`-based and reuses the same shapes as the audit script. Source
16
+ references are executable syntax only: a method named in a docstring or comment
17
+ is not counted as coverage.
18
+ """
19
+
20
+ import ast
21
+ import os
22
+ import re
23
+ from functools import lru_cache
24
+ from typing import Any, Dict, List, Optional
25
+
26
+ STUB_RELATIVE = os.path.join("docs", "reference", "DaVinciResolveScript.pyi")
27
+ MAX_RESULTS = 200
28
+
29
+
30
+ class TypedApiError(ValueError):
31
+ """The stub is missing, unparseable, or the query was unusable."""
32
+
33
+
34
+ def stub_path(project_dir: str) -> str:
35
+ return os.path.join(project_dir, STUB_RELATIVE)
36
+
37
+
38
+ @lru_cache(maxsize=4)
39
+ def _inventory(path: str, mtime: float) -> Dict[str, Any]:
40
+ """Parse the stub into methods and TypedDicts. Cached on path plus mtime."""
41
+ with open(path, "r", encoding="utf-8") as handle:
42
+ text = handle.read()
43
+ methods: Dict[str, Any] = {}
44
+ option_types: Dict[str, Any] = {}
45
+ for cls in ast.parse(text).body:
46
+ if not isinstance(cls, ast.ClassDef):
47
+ continue
48
+ for node in cls.body:
49
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not node.name.startswith("_"):
50
+ signature = f"{node.name}({ast.unparse(node.args)})"
51
+ if node.returns is not None:
52
+ signature += f" -> {ast.unparse(node.returns)}"
53
+ row = methods.setdefault(f"{cls.name}.{node.name}", {
54
+ "object": cls.name,
55
+ "method": node.name,
56
+ "signatures": [],
57
+ "stub_line": node.lineno,
58
+ "description": ast.get_docstring(node),
59
+ })
60
+ row["signatures"].append(signature)
61
+ if any(ast.unparse(base).endswith("TypedDict") for base in cls.bases):
62
+ fields: Dict[str, Any] = {}
63
+ for index, node in enumerate(cls.body):
64
+ if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
65
+ doc = None
66
+ if index + 1 < len(cls.body):
67
+ following = cls.body[index + 1]
68
+ if (isinstance(following, ast.Expr)
69
+ and isinstance(following.value, ast.Constant)
70
+ and isinstance(following.value.value, str)):
71
+ doc = following.value.value
72
+ fields[node.target.id] = {"type": ast.unparse(node.annotation),
73
+ "stub_line": node.lineno,
74
+ "description": doc}
75
+ option_types[cls.name] = fields
76
+ if not methods:
77
+ raise TypedApiError("The typed stub contains no public class methods; refusing an empty inventory")
78
+ return {"methods": methods, "option_types": option_types}
79
+
80
+
81
+ def load(project_dir: str) -> Dict[str, Any]:
82
+ path = stub_path(project_dir)
83
+ if not os.path.isfile(path):
84
+ raise TypedApiError(
85
+ f"Typed API stub not found at {STUB_RELATIVE}. It ships with this "
86
+ "repository; a source checkout is required for API search."
87
+ )
88
+ return _inventory(path, os.path.getmtime(path))
89
+
90
+
91
+ @lru_cache(maxsize=4)
92
+ def _wrapped_names(project_dir: str, fingerprint: str) -> Dict[str, List[str]]:
93
+ """Map native method name -> source files that actually call or read it.
94
+
95
+ Executable syntax only. A name that appears solely in a docstring or comment
96
+ is not coverage, and counting it as such is exactly the mistake this whole
97
+ audit existed to avoid.
98
+ """
99
+ found: Dict[str, set] = {}
100
+ src_root = os.path.join(project_dir, "src")
101
+ for current, _dirs, files in os.walk(src_root):
102
+ for filename in files:
103
+ if not filename.endswith(".py"):
104
+ continue
105
+ absolute = os.path.join(current, filename)
106
+ try:
107
+ with open(absolute, "r", encoding="utf-8", errors="replace") as handle:
108
+ tree = ast.parse(handle.read())
109
+ except (OSError, SyntaxError):
110
+ continue
111
+ relative = os.path.relpath(absolute, project_dir).replace(os.sep, "/")
112
+ for node in ast.walk(tree):
113
+ name = None
114
+ if isinstance(node, ast.Attribute):
115
+ name = node.attr
116
+ elif (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
117
+ and node.func.id == "getattr" and len(node.args) > 1
118
+ and isinstance(node.args[1], ast.Constant)
119
+ and isinstance(node.args[1].value, str)):
120
+ name = node.args[1].value
121
+ if name:
122
+ found.setdefault(name, set()).add(relative)
123
+ return {name: sorted(paths) for name, paths in found.items()}
124
+
125
+
126
+ def _source_fingerprint(project_dir: str) -> str:
127
+ """Cheap change detector for the source tree: newest mtime plus file count."""
128
+ newest = 0.0
129
+ count = 0
130
+ for current, _dirs, files in os.walk(os.path.join(project_dir, "src")):
131
+ for filename in files:
132
+ if filename.endswith(".py"):
133
+ count += 1
134
+ try:
135
+ newest = max(newest, os.path.getmtime(os.path.join(current, filename)))
136
+ except OSError:
137
+ pass
138
+ return f"{count}:{newest}"
139
+
140
+
141
+ def search(project_dir: str, pattern: str, *, kind: str = "all",
142
+ limit: int = 50) -> Dict[str, Any]:
143
+ """Case-insensitive regex over method names, signatures, types and docs."""
144
+ if not isinstance(pattern, str) or not pattern.strip():
145
+ raise TypedApiError("pattern is required")
146
+ if kind not in ("all", "methods", "options"):
147
+ raise TypedApiError(f"kind must be all, methods or options, not {kind!r}")
148
+ try:
149
+ expression = re.compile(pattern, re.IGNORECASE)
150
+ except re.error as exc:
151
+ raise TypedApiError(f"pattern is not a valid regular expression: {exc}")
152
+ try:
153
+ limit = max(1, min(int(limit), MAX_RESULTS))
154
+ except (TypeError, ValueError):
155
+ raise TypedApiError("limit must be an integer")
156
+
157
+ inventory = load(project_dir)
158
+ wrapped = _wrapped_names(project_dir, _source_fingerprint(project_dir))
159
+
160
+ methods: List[Dict[str, Any]] = []
161
+ if kind in ("all", "methods"):
162
+ for key, row in sorted(inventory["methods"].items()):
163
+ haystack = " ".join([key] + row["signatures"] + [row["description"] or ""])
164
+ if not expression.search(haystack):
165
+ continue
166
+ references = wrapped.get(row["method"], [])
167
+ methods.append({
168
+ "symbol": key,
169
+ "object": row["object"],
170
+ "signatures": row["signatures"],
171
+ "stub_line": row["stub_line"],
172
+ "description": row["description"],
173
+ "referenced_in_this_server": bool(references),
174
+ "source_files": references[:5],
175
+ })
176
+
177
+ options: List[Dict[str, Any]] = []
178
+ if kind in ("all", "options"):
179
+ for type_name, fields in sorted(inventory["option_types"].items()):
180
+ matched = {
181
+ field: detail for field, detail in fields.items()
182
+ if expression.search(" ".join([type_name, field, detail["type"],
183
+ detail["description"] or ""]))
184
+ }
185
+ if expression.search(type_name):
186
+ matched = dict(fields)
187
+ if matched:
188
+ options.append({
189
+ "type": type_name,
190
+ "field_count": len(fields),
191
+ "matched_fields": matched,
192
+ })
193
+
194
+ total = len(methods) + len(options)
195
+ return {
196
+ "pattern": pattern,
197
+ "kind": kind,
198
+ "total_matches": total,
199
+ "truncated": total > limit,
200
+ "methods": methods[:limit],
201
+ "option_types": options[:limit],
202
+ "stub": STUB_RELATIVE,
203
+ "note": ("referenced_in_this_server is executable syntax only — a name that "
204
+ "appears solely in a docstring or comment is not counted."),
205
+ }
206
+
207
+
208
+ def describe(project_dir: str, symbol: str) -> Dict[str, Any]:
209
+ """Full detail for one `Class.Method` or one TypedDict name."""
210
+ if not isinstance(symbol, str) or not symbol.strip():
211
+ raise TypedApiError("symbol is required")
212
+ inventory = load(project_dir)
213
+ key = symbol.strip()
214
+
215
+ if key in inventory["option_types"]:
216
+ fields = inventory["option_types"][key]
217
+ return {"symbol": key, "kind": "option_type", "field_count": len(fields),
218
+ "fields": fields, "stub": STUB_RELATIVE}
219
+
220
+ row = inventory["methods"].get(key)
221
+ if row is None:
222
+ candidates = sorted(k for k in inventory["methods"] if k.split(".", 1)[1] == key)
223
+ if len(candidates) == 1:
224
+ key, row = candidates[0], inventory["methods"][candidates[0]]
225
+ elif candidates:
226
+ raise TypedApiError(
227
+ f"{symbol!r} exists on more than one object: {', '.join(candidates)}. "
228
+ "Qualify it, e.g. 'Project.GetName'."
229
+ )
230
+ else:
231
+ raise TypedApiError(
232
+ f"{symbol!r} is not in the Resolve 21.1 typed stub. Use search to "
233
+ "find the right name."
234
+ )
235
+
236
+ wrapped = _wrapped_names(project_dir, _source_fingerprint(project_dir))
237
+ references = wrapped.get(row["method"], [])
238
+ return {
239
+ "symbol": key,
240
+ "kind": "method",
241
+ "object": row["object"],
242
+ "signatures": row["signatures"],
243
+ "stub_line": row["stub_line"],
244
+ "description": row["description"],
245
+ "referenced_in_this_server": bool(references),
246
+ "source_files": references,
247
+ "stub": STUB_RELATIVE,
248
+ }
249
+
250
+
251
+ def summary(project_dir: str) -> Dict[str, Any]:
252
+ inventory = load(project_dir)
253
+ field_total = sum(len(fields) for fields in inventory["option_types"].values())
254
+ return {
255
+ "stub": STUB_RELATIVE,
256
+ "methods": len(inventory["methods"]),
257
+ "option_types": len(inventory["option_types"]),
258
+ "option_fields": field_total,
259
+ "objects": sorted({row["object"] for row in inventory["methods"].values()}),
260
+ }