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 +77 -0
- package/README.md +4 -4
- package/README.zh-CN.md +5 -5
- package/docs/SKILL.md +1 -1
- package/docs/guides/control-panel.md +13 -0
- package/docs/install.md +2 -2
- package/docs/reference/api-coverage.md +6 -1
- package/docs/reference/typed-api-search.md +56 -0
- package/install.py +2 -2
- package/package.json +1 -1
- package/src/analysis_dashboard.py +175 -7
- package/src/granular/common.py +1 -1
- package/src/granular/resolve_control.py +47 -0
- package/src/resolve_mcp_server.py +1 -1
- package/src/server.py +30 -3
- package/src/utils/typed_api_search.py +260 -0
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
|
-
[-blue.svg)](#server-modes)
|
|
9
9
|
[-18%20tools-blueviolet.svg)](#server-modes)
|
|
10
10
|
[](docs/reference/api-coverage.md#test-results)
|
|
11
11
|
[](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` |
|
|
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 / **
|
|
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
|
-
[-blue.svg)](#服务器模式)
|
|
9
9
|
[-18%20tools-blueviolet.svg)](#服务器模式)
|
|
10
10
|
[](docs/reference/api-coverage.md#test-results)
|
|
11
11
|
[](https://www.blackmagicdesign.com/products/davinciresolve)
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v3.
|
|
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` |
|
|
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** 复合 / **
|
|
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` |
|
|
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
|

|
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` |
|
|
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
|
|
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 **
|
|
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.
|
|
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 ·
|
|
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
|
@@ -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:
|
|
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 =
|
|
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:
|
|
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)
|
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "3.
|
|
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} (
|
|
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.
|
|
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 == "
|
|
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
|
+
}
|