davinci-resolve-mcp 2.67.1 → 2.68.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 +99 -0
- package/README.md +27 -2
- package/docs/SKILL.md +20 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bridge_differential.py +431 -0
- package/scripts/install_resolve_bridge.py +260 -0
- package/scripts/resolve_bridge_launcher.py +202 -0
- package/scripts/resolve_bridge_probe.py +162 -0
- package/scripts/resolve_capability_probe.py +270 -0
- package/src/granular/common.py +6 -1
- package/src/server.py +193 -6
- package/src/utils/bridge_differential.py +375 -0
- package/src/utils/captions.py +323 -0
- package/src/utils/colorimetry.py +196 -0
- package/src/utils/delivery_targets.py +225 -0
- package/src/utils/edit_engine.py +181 -7
- package/src/utils/image_qc.py +499 -0
- package/src/utils/page_lock.py +5 -1
- package/src/utils/resolve_bridge.py +557 -0
- package/src/utils/resolve_bridge_client.py +423 -0
- package/src/utils/resolve_bridge_ops.py +714 -0
- package/src/utils/resolve_connection.py +29 -1
- package/src/utils/silence_ripple.py +256 -8
- package/src/utils/strata_analyzers.py +7 -0
- package/src/utils/strata_faces.py +6 -0
- package/src/utils/transcript_edit.py +372 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,105 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.68.0
|
|
6
|
+
|
|
7
|
+
Six engines that let an agent judge its own output before shipping it — audio
|
|
8
|
+
loudness, silence calibration, image QC, transcript editing, captions — plus the
|
|
9
|
+
**in-app bridge**, which reaches the **free edition** of DaVinci Resolve, whose
|
|
10
|
+
external scripting API refuses foreign processes entirely.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Audio delivery QC.** Delivery targets carry a named loudness contract
|
|
15
|
+
(`web`, `podcast`, `ebu_r128`, `atsc_a85`, `ott_dialogue_gated`) and project it
|
|
16
|
+
into the existing `loudness_qc` vocabulary. Dialogue-gated standards emit no
|
|
17
|
+
gradeable `integrated` figure — `loudness_qc` measures full-program, so grading
|
|
18
|
+
a dialogue-gated number against it produces a verdict that means nothing; it
|
|
19
|
+
travels as advisory metadata with the reason stated. No shipped target names a
|
|
20
|
+
standard, because a ProRes master has no inherent programme loudness.
|
|
21
|
+
`render(action='list_loudness_standards')`.
|
|
22
|
+
- **Auto-calibrated silence gate.** `plan_silence_ripple` derives the threshold
|
|
23
|
+
from each clip's own dynamics instead of a fixed −30 dB. The metric is `astats`
|
|
24
|
+
**RMS trough** paired with RMS peak: `mean_volume` tracks programme level, and
|
|
25
|
+
`Noise floor dB` moved 19 dB between two fixtures sharing an identical noise
|
|
26
|
+
bed purely because one was quieter for longer. Against ground truth the fixed
|
|
27
|
+
threshold was wrong on all four fixtures. When calibration cannot be trusted
|
|
28
|
+
the item is **kept whole** rather than stripped — a fixed threshold applied to
|
|
29
|
+
material it does not suit is not a degraded answer, it is a wrong one.
|
|
30
|
+
- **Image QC.** `media_analysis(action='assess_grade')` gives an agent
|
|
31
|
+
deterministic grounds to reject its own grade: tonal frame, noise
|
|
32
|
+
amplification, banding, highlight posterization and clipping growth, with
|
|
33
|
+
validated **CIEDE2000** (all 33 Sharma/Wu/Dalal reference pairs to 4 dp). A
|
|
34
|
+
clean grade costs zero tokens; vision is spent only where the numbers cannot
|
|
35
|
+
settle it. Non-display-referred working spaces are **refused, never guessed** —
|
|
36
|
+
the right transform depends on camera and project colour management, which a
|
|
37
|
+
frame cannot tell you.
|
|
38
|
+
- **Word-level transcript editing.** `edit_engine(action='plan_transcript_tighten')`
|
|
39
|
+
removes fillers, false starts and over-long pauses at word boundaries with a
|
|
40
|
+
reason per cut, and `search_spoken_content` searches word timings across a
|
|
41
|
+
whole shoot to build selects. A lexical axis alongside `find_similar`'s
|
|
42
|
+
semantic-visual one.
|
|
43
|
+
- **Captions.** `media_analysis(action='generate_captions')` emits SRT and WebVTT
|
|
44
|
+
under broadcast line rules, plus chapters and YouTube description text.
|
|
45
|
+
- **The in-app bridge — live read and write control of the FREE edition.**
|
|
46
|
+
Opt-in with `DAVINCI_RESOLVE_BRIDGE=1`; unset changes nothing for existing
|
|
47
|
+
installs. A script launched from Workspace ▸ Scripts is handed the live
|
|
48
|
+
`resolve` object on any edition and re-exports it over an authenticated
|
|
49
|
+
loopback listener, which `connect_resolve` uses as a third transport beside
|
|
50
|
+
Local and Network. Existing call sites need no changes. This is the documented
|
|
51
|
+
in-app path, not a licence circumvention — but Blackmagic could close it, so
|
|
52
|
+
treat it as supported-until-it-is-not.
|
|
53
|
+
- **`shutdown` and `reload` for the bridge.** The launcher blocks (correctly — a
|
|
54
|
+
Scripts-menu script is a child process, so a daemon thread dies the instant it
|
|
55
|
+
returns), which used to mean a stale in-Resolve copy could only be replaced by
|
|
56
|
+
quitting Resolve. `reload` re-imports the runtime from disk in place, and
|
|
57
|
+
refuses before stopping if the new sources will not compile.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- **The bridge was unreachable on the machine it exists for.** `_try_connect`
|
|
62
|
+
returned on `dvr_script is None` before calling `connect_resolve`, the function
|
|
63
|
+
that accepts None in bridge mode. Blackmagic's scripting module ships with the
|
|
64
|
+
*installer*, not the App Store build, so a free-edition-only machine has no
|
|
65
|
+
`Developer/Scripting/Modules` tree and the import fails. Verified by blocking
|
|
66
|
+
the import against a healthy live bridge: `get_resolve()` answered None.
|
|
67
|
+
- **Auto-launch started the wrong Resolve, or one nobody asked for.** The macOS
|
|
68
|
+
path list contained only the installer location, so a free-only machine found
|
|
69
|
+
nothing and a machine with both always started Studio. In bridge mode it now
|
|
70
|
+
refuses outright: launching cannot create a listener that only a Scripts-menu
|
|
71
|
+
run creates.
|
|
72
|
+
- **Interchange export over the bridge was degrading silently.** Resolve's API
|
|
73
|
+
constants (`EXPORT_AAF`, `AUDIO_SYNC_*`) are plain attributes and `dir()` does
|
|
74
|
+
not list them — measured on 21.0.3.7, `dir(resolve)` returns 34 names with no
|
|
75
|
+
`EXPORT_*` among them while the constants read back as real values. The proxy
|
|
76
|
+
could only call methods, so `hasattr` said False and the server fell through to
|
|
77
|
+
handing Resolve the bare string `"EXPORT_AAF"`.
|
|
78
|
+
- **The proxy broke every chain longer than one call.** Every live Resolve object
|
|
79
|
+
reports `type(obj).__name__ == "PyRemoteObject"` — root 34 methods, Project 49,
|
|
80
|
+
TimelineItem 88, one class name — so caching method sets on it let the first
|
|
81
|
+
object touched define `hasattr` for everything after. Method sets are now keyed
|
|
82
|
+
on provenance, and absence is re-verified against the object itself.
|
|
83
|
+
- **Bridge error codes never crossed the transport.** Every surface code was
|
|
84
|
+
flattened to `operation_failed`, so `stale_handle` (re-fetch) and
|
|
85
|
+
`ambiguous_locator` (disambiguate) arrived as one undifferentiated failure.
|
|
86
|
+
|
|
87
|
+
### Validated live
|
|
88
|
+
|
|
89
|
+
On **DaVinci Resolve 21.0.3.7 (App Store, free, sandboxed)**, with Blackmagic's
|
|
90
|
+
scripting module blocked to reproduce a free-only machine: 34 tools, 583 declared
|
|
91
|
+
actions, **165 read-shaped actions attempted, 145 clean, zero bridge-attributable
|
|
92
|
+
failures**; 109 of the 112 API read methods the tools actually call exercised on
|
|
93
|
+
the live object graph; a render completed to a non-empty file; AAF/DRT/EDL/FCPXML
|
|
94
|
+
all written by `Timeline.Export`; 400 clips appended in 1.23 s and enumerated at
|
|
95
|
+
0.81 ms/item; an evicted handle refusing with `stale_handle` rather than
|
|
96
|
+
resolving to another object; and reads *and* writes continuing to work with a
|
|
97
|
+
native file dialog open, because the bridge runs in its own process.
|
|
98
|
+
|
|
99
|
+
`scripts/bridge_differential.py --mode differential` diffs the bridge against
|
|
100
|
+
native scripting on one Studio instance. That comparison has **not** been run
|
|
101
|
+
yet, so the evidenced claim is that the bridge carries the surface the tools use
|
|
102
|
+
on the free edition — not that it is byte-identical to native scripting.
|
|
103
|
+
|
|
5
104
|
## What's New in v2.67.1
|
|
6
105
|
|
|
7
106
|
Documentation correction. No code changes — v2.67.0 already ships the correct
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
|
@@ -22,7 +22,7 @@ A local browser control panel ships with the server for inspecting Resolve state
|
|
|
22
22
|
npx davinci-resolve-mcp setup
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Before connecting, open DaVinci Resolve Studio and set **Preferences > General > External scripting using** to **Local**. The npm launcher installs a managed copy under your user application-data directory, then runs the universal Python installer. The installer creates a virtual environment, detects Resolve paths, and can configure Claude Desktop, Claude Code, Cursor, VS Code, Windsurf, Zed, Continue, Cline, Roo Code, OpenCode, and JetBrains IDEs.
|
|
25
|
+
Before connecting, open DaVinci Resolve Studio and set **Preferences > General > External scripting using** to **Local**. (On the **free edition** that preference does not help — see [Free edition](#free-edition-in-app-bridge) below.) The npm launcher installs a managed copy under your user application-data directory, then runs the universal Python installer. The installer creates a virtual environment, detects Resolve paths, and can configure Claude Desktop, Claude Code, Cursor, VS Code, Windsurf, Zed, Continue, Cline, Roo Code, OpenCode, and JetBrains IDEs.
|
|
26
26
|
|
|
27
27
|
For source installs:
|
|
28
28
|
|
|
@@ -36,6 +36,31 @@ For platform paths, client-specific config, and manual setup, see [Installation
|
|
|
36
36
|
|
|
37
37
|
The installer and server check the latest GitHub release for MCP updates. Checks are best-effort and throttled; the server never blocks MCP startup for a prompt. The installer can prompt, snooze, ignore a release, disable checks, or apply an opt-in safe auto-update for clean git checkouts.
|
|
38
38
|
|
|
39
|
+
## Free edition (in-app bridge)
|
|
40
|
+
|
|
41
|
+
Blackmagic gates *external* scripting to Studio: on the free edition
|
|
42
|
+
`scriptapp("Resolve")` refuses a foreign process, whatever the preference says.
|
|
43
|
+
The **Workspace ▸ Scripts** menu is not gated — a script launched from it is
|
|
44
|
+
handed the live `resolve` object on any edition — so the server can reach the
|
|
45
|
+
free edition through a small script that runs *inside* Resolve and re-exports it
|
|
46
|
+
over an authenticated loopback listener.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
python scripts/install_resolve_bridge.py
|
|
50
|
+
# restart Resolve, open a project, then: Workspace > Scripts > resolve_bridge
|
|
51
|
+
export DAVINCI_RESOLVE_BRIDGE=1 # opt-in; unset changes nothing
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Requires a **framework Python** (python.org). Resolve enumerates `.py` scripts
|
|
55
|
+
only when it finds one — Homebrew, pyenv and conda interpreters are not
|
|
56
|
+
detected, and the script silently never appears in the menu. A Lua canary is
|
|
57
|
+
installed alongside so you can tell that apart from a wrong folder.
|
|
58
|
+
|
|
59
|
+
Validated on free 21.0.3.7 and Studio 19.1.3.7. This is the documented in-app
|
|
60
|
+
path, not a licence circumvention, but Blackmagic could close it — treat it as a
|
|
61
|
+
supported-until-it-is-not tier. Loopback only, HMAC-signed requests, one-use
|
|
62
|
+
nonces.
|
|
63
|
+
|
|
39
64
|
## Local Control Panel
|
|
40
65
|
|
|
41
66
|
Launch the single-user local control panel from the repository root:
|
package/docs/SKILL.md
CHANGED
|
@@ -19,6 +19,26 @@ configured to the Resolve host IP (use `127.0.0.1` on the same machine). The
|
|
|
19
19
|
server auto-launches Resolve if it is not running, but that first connection can
|
|
20
20
|
take up to 60 seconds.
|
|
21
21
|
|
|
22
|
+
**Free edition.** Both of those preferences are Studio features; on the free
|
|
23
|
+
edition `scriptapp("Resolve")` refuses a foreign process regardless. A third
|
|
24
|
+
transport reaches it — a script run from **Workspace ▸ Scripts** is handed the
|
|
25
|
+
live `resolve` object on any edition and re-exports it over an authenticated
|
|
26
|
+
loopback listener. Install with `python scripts/install_resolve_bridge.py`, start
|
|
27
|
+
it from that menu, and set `DAVINCI_RESOLVE_BRIDGE=1`. Existing tool call sites
|
|
28
|
+
work unchanged. Two things to know when diagnosing it:
|
|
29
|
+
|
|
30
|
+
- Resolve lists `.py` scripts only when it can find a **framework Python**
|
|
31
|
+
(python.org). Homebrew/pyenv/conda are not detected and the script simply never
|
|
32
|
+
appears, with no error. The installer preflights this and ships a Lua canary,
|
|
33
|
+
which always lists, so "Python not detected" is distinguishable from "wrong
|
|
34
|
+
folder".
|
|
35
|
+
- The in-Resolve runtime is a **copy taken at install time**. After changing the
|
|
36
|
+
repository, re-run the installer and then ask the running bridge to reload —
|
|
37
|
+
it re-imports from disk in place, so Resolve does not need restarting.
|
|
38
|
+
|
|
39
|
+
The bridge is a documented in-app path rather than a licence circumvention, but
|
|
40
|
+
Blackmagic could close it; treat it as a supported-until-it-is-not tier.
|
|
41
|
+
|
|
22
42
|
Network scripting permits remote control of Resolve. Use Local mode when remote
|
|
23
43
|
access is unnecessary; otherwise restrict access with host firewall and network
|
|
24
44
|
controls.
|
package/install.py
CHANGED
|
@@ -36,7 +36,7 @@ from src.utils.update_check import (
|
|
|
36
36
|
|
|
37
37
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
VERSION = "2.
|
|
39
|
+
VERSION = "2.68.0"
|
|
40
40
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
41
41
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
42
42
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Qualify the in-app bridge by differencing it against native scripting.
|
|
3
|
+
|
|
4
|
+
# Studio: both transports reach the same application — the real differential.
|
|
5
|
+
python scripts/bridge_differential.py --mode differential
|
|
6
|
+
|
|
7
|
+
# Free edition: no native side exists, so prove reachability and shape.
|
|
8
|
+
python scripts/bridge_differential.py --mode coverage
|
|
9
|
+
|
|
10
|
+
# Add the write scenarios (render, interchange export, SetLUT, scale, modal).
|
|
11
|
+
python scripts/bridge_differential.py --mode coverage --scenarios
|
|
12
|
+
|
|
13
|
+
`--mode differential` requires Studio, because external scripting is Studio-gated
|
|
14
|
+
— which is the entire reason the bridge exists. It also requires the in-app
|
|
15
|
+
bridge to be running *in that same Studio instance*: two transports into one
|
|
16
|
+
application is what makes a difference attributable to the transport rather than
|
|
17
|
+
to the two Resolves having different projects open.
|
|
18
|
+
|
|
19
|
+
Nothing here writes to a project unless `--scenarios` is passed, and every
|
|
20
|
+
scenario undoes what it did. Read `SCENARIOS` before running it against anything
|
|
21
|
+
you care about.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
33
|
+
|
|
34
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
35
|
+
|
|
36
|
+
from src.utils import bridge_differential as bd # noqa: E402
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def connect_bridge() -> Any:
|
|
40
|
+
from src.utils import resolve_bridge_client as client
|
|
41
|
+
|
|
42
|
+
os.environ.setdefault(client.ENV_ENABLE, "1")
|
|
43
|
+
return client.connect()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def connect_native() -> Any:
|
|
47
|
+
"""Blackmagic's own module, deliberately without the bridge in the way."""
|
|
48
|
+
from src.utils import resolve_bridge_client as client
|
|
49
|
+
|
|
50
|
+
previous = os.environ.pop(client.ENV_ENABLE, None)
|
|
51
|
+
try:
|
|
52
|
+
import DaVinciResolveScript as dvr
|
|
53
|
+
|
|
54
|
+
return dvr.scriptapp("Resolve")
|
|
55
|
+
finally:
|
|
56
|
+
if previous is not None:
|
|
57
|
+
os.environ[client.ENV_ENABLE] = previous
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── write scenarios ──────────────────────────────────────────────────────────
|
|
61
|
+
#
|
|
62
|
+
# The enumeration routes anything that mutates to a named scenario, because a
|
|
63
|
+
# blind call cannot set up its preconditions or clean up after itself. Each one
|
|
64
|
+
# returns a dict of observations; the differential compares those dicts.
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def scenario_render(resolve: Any, workspace: Path) -> Dict[str, Any]:
|
|
68
|
+
"""The delivery path end to end, to a real file on disk.
|
|
69
|
+
|
|
70
|
+
Untested until now, and the most consequential thing the bridge could get
|
|
71
|
+
wrong: a render that reports success and produces nothing is indistinguishable
|
|
72
|
+
from a render that worked, right up until someone looks for the file.
|
|
73
|
+
"""
|
|
74
|
+
project = resolve.GetProjectManager().GetCurrentProject()
|
|
75
|
+
observations: Dict[str, Any] = {}
|
|
76
|
+
formats = project.GetRenderFormats() or {}
|
|
77
|
+
observations["format_count"] = len(formats)
|
|
78
|
+
observations["codec_count_for_mov"] = len(project.GetRenderCodecs("mov") or {})
|
|
79
|
+
|
|
80
|
+
output = workspace / "bridge_render_probe"
|
|
81
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
observations["set_format"] = project.SetCurrentRenderFormatAndCodec("mov", "H.264")
|
|
83
|
+
observations["set_settings"] = project.SetRenderSettings({
|
|
84
|
+
"TargetDir": str(output),
|
|
85
|
+
"CustomName": "bridge_render_probe",
|
|
86
|
+
"MarkIn": int(project.GetCurrentTimeline().GetStartFrame()),
|
|
87
|
+
"MarkOut": int(project.GetCurrentTimeline().GetStartFrame()) + 4,
|
|
88
|
+
})
|
|
89
|
+
job = project.AddRenderJob()
|
|
90
|
+
observations["job_added"] = bool(job)
|
|
91
|
+
if not job:
|
|
92
|
+
return observations
|
|
93
|
+
try:
|
|
94
|
+
observations["started"] = project.StartRendering([job], False)
|
|
95
|
+
deadline = time.time() + 120
|
|
96
|
+
while time.time() < deadline and project.IsRenderingInProgress():
|
|
97
|
+
time.sleep(0.5)
|
|
98
|
+
status = project.GetRenderJobStatus(job) or {}
|
|
99
|
+
observations["job_status"] = status.get("JobStatus")
|
|
100
|
+
produced = sorted(p.name for p in output.iterdir() if p.is_file())
|
|
101
|
+
observations["files_written"] = len(produced)
|
|
102
|
+
observations["nonempty_output"] = any((output / n).stat().st_size > 0 for n in produced)
|
|
103
|
+
finally:
|
|
104
|
+
project.DeleteRenderJob(job)
|
|
105
|
+
return observations
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def scenario_interchange_export(resolve: Any, workspace: Path) -> Dict[str, Any]:
|
|
109
|
+
"""Timeline.Export to each conform format. The conform surface, untested."""
|
|
110
|
+
timeline = resolve.GetProjectManager().GetCurrentProject().GetCurrentTimeline()
|
|
111
|
+
observations: Dict[str, Any] = {}
|
|
112
|
+
targets = [
|
|
113
|
+
("EDL", "EXPORT_EDL", "EXPORT_CDL", ".edl"),
|
|
114
|
+
("FCPXML", "EXPORT_FCP_7_XML", "EXPORT_NONE", ".xml"),
|
|
115
|
+
("DRT", "EXPORT_DRT", "EXPORT_NONE", ".drt"),
|
|
116
|
+
("AAF", "EXPORT_AAF", "EXPORT_AAF_NEW", ".aaf"),
|
|
117
|
+
]
|
|
118
|
+
for label, export_type, subtype, suffix in targets:
|
|
119
|
+
path = workspace / f"bridge_export_probe{suffix}"
|
|
120
|
+
if path.exists():
|
|
121
|
+
path.unlink()
|
|
122
|
+
constant = getattr(resolve, export_type, None)
|
|
123
|
+
sub = getattr(resolve, subtype, None)
|
|
124
|
+
try:
|
|
125
|
+
if constant is None:
|
|
126
|
+
observations[label] = {"ok": False, "reason": "constant_absent"}
|
|
127
|
+
continue
|
|
128
|
+
ok = timeline.Export(str(path), constant, sub) if sub is not None else \
|
|
129
|
+
timeline.Export(str(path), constant)
|
|
130
|
+
observations[label] = {
|
|
131
|
+
"ok": bool(ok),
|
|
132
|
+
"written": path.exists(),
|
|
133
|
+
"bytes": path.stat().st_size if path.exists() else 0,
|
|
134
|
+
}
|
|
135
|
+
except Exception as exc: # noqa: BLE001 - the failure is the observation
|
|
136
|
+
observations[label] = {"ok": False, "reason": type(exc).__name__, "message": str(exc)[:120]}
|
|
137
|
+
return observations
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def scenario_set_lut(resolve: Any, _workspace: Path) -> Dict[str, Any]:
|
|
141
|
+
"""SetLUT specifically — last time GetLUT was called and the rest inferred.
|
|
142
|
+
|
|
143
|
+
Read back after writing, and restore. A write that returns True and does not
|
|
144
|
+
land is the failure mode this exists to catch, and only the read-back sees it.
|
|
145
|
+
"""
|
|
146
|
+
timeline = resolve.GetProjectManager().GetCurrentProject().GetCurrentTimeline()
|
|
147
|
+
items = timeline.GetItemListInTrack("video", 1) or []
|
|
148
|
+
if not items:
|
|
149
|
+
return {"skipped": "no clip on video track 1"}
|
|
150
|
+
item = items[0]
|
|
151
|
+
observations: Dict[str, Any] = {"node_count": item.GetNumNodes()}
|
|
152
|
+
original = item.GetLUT(1)
|
|
153
|
+
observations["lut_before"] = original
|
|
154
|
+
# An empty path is the documented way to clear a node's LUT, so it is a
|
|
155
|
+
# write we can make and unmake without shipping a .cube anywhere.
|
|
156
|
+
observations["set_empty"] = item.SetLUT(1, "")
|
|
157
|
+
observations["lut_after_clear"] = item.GetLUT(1)
|
|
158
|
+
if original:
|
|
159
|
+
observations["restored"] = item.SetLUT(1, original)
|
|
160
|
+
observations["set_out_of_range_node"] = item.SetLUT(999, "")
|
|
161
|
+
return observations
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def scenario_scale(resolve: Any, _workspace: Path) -> Dict[str, Any]:
|
|
165
|
+
"""Enumerate every item on every track and time it.
|
|
166
|
+
|
|
167
|
+
The round trip is 0.6 ms and the handle table holds 4096 entries; both only
|
|
168
|
+
matter at a size nobody has run yet. This measures where they start to.
|
|
169
|
+
"""
|
|
170
|
+
timeline = resolve.GetProjectManager().GetCurrentProject().GetCurrentTimeline()
|
|
171
|
+
observations: Dict[str, Any] = {"tracks": {}}
|
|
172
|
+
started = time.time()
|
|
173
|
+
total = 0
|
|
174
|
+
names: List[str] = []
|
|
175
|
+
for kind in ("video", "audio"):
|
|
176
|
+
count = int(timeline.GetTrackCount(kind) or 0)
|
|
177
|
+
for index in range(1, count + 1):
|
|
178
|
+
items = timeline.GetItemListInTrack(kind, index) or []
|
|
179
|
+
observations["tracks"][f"{kind}{index}"] = len(items)
|
|
180
|
+
for item in items:
|
|
181
|
+
names.append(item.GetName())
|
|
182
|
+
total += 1
|
|
183
|
+
elapsed = time.time() - started
|
|
184
|
+
observations["items"] = total
|
|
185
|
+
observations["distinct_names"] = len(set(names))
|
|
186
|
+
observations["elapsed_seconds"] = round(elapsed, 3)
|
|
187
|
+
observations["ms_per_item"] = round(elapsed / total * 1000, 3) if total else None
|
|
188
|
+
return observations
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def scenario_handle_pressure(resolve: Any, _workspace: Path) -> Dict[str, Any]:
|
|
192
|
+
"""Push past the 4096-handle ceiling and check what an evicted handle does.
|
|
193
|
+
|
|
194
|
+
The table is bounded so a long session cannot pin every enumerated object
|
|
195
|
+
inside Resolve forever. Eviction is therefore normal, and the property that
|
|
196
|
+
matters is that a lost handle says `stale_handle` — a client can re-fetch from
|
|
197
|
+
that. The failure this guards against is an evicted id being reused and
|
|
198
|
+
silently resolving to a *different* object, which is a wrong-target edit with
|
|
199
|
+
no error anywhere.
|
|
200
|
+
"""
|
|
201
|
+
from src.utils import resolve_bridge_client as client
|
|
202
|
+
|
|
203
|
+
transport = getattr(resolve, "_transport", None)
|
|
204
|
+
if transport is None:
|
|
205
|
+
return {"skipped": "native scripting has no handle table"}
|
|
206
|
+
|
|
207
|
+
timeline = resolve.GetProjectManager().GetCurrentProject().GetCurrentTimeline()
|
|
208
|
+
first_batch = timeline.GetItemListInTrack("video", 1) or []
|
|
209
|
+
if not first_batch:
|
|
210
|
+
return {"skipped": "no clip on video track 1"}
|
|
211
|
+
observations: Dict[str, Any] = {"batch_size": len(first_batch)}
|
|
212
|
+
|
|
213
|
+
# Two handles from the same first batch. One is touched every round, the
|
|
214
|
+
# other is left alone — because access promotes an entry, so "does a handle
|
|
215
|
+
# survive" has two different answers and only measuring one of them would
|
|
216
|
+
# report the wrong ceiling.
|
|
217
|
+
kept_warm, left_cold = first_batch[0], first_batch[-1]
|
|
218
|
+
|
|
219
|
+
minted = len(first_batch)
|
|
220
|
+
rounds = 0
|
|
221
|
+
started = time.time()
|
|
222
|
+
while minted < 6000 and rounds < 400 and time.time() - started < 180:
|
|
223
|
+
minted += len(timeline.GetItemListInTrack("video", 1) or [])
|
|
224
|
+
rounds += 1
|
|
225
|
+
try:
|
|
226
|
+
kept_warm.GetName()
|
|
227
|
+
observations["warm_valid_at"] = minted
|
|
228
|
+
except client.BridgeCallError as exc:
|
|
229
|
+
observations["warm_evicted_at"] = minted
|
|
230
|
+
observations["warm_code"] = exc.code
|
|
231
|
+
break
|
|
232
|
+
observations["handles_minted"] = minted
|
|
233
|
+
observations["rounds"] = rounds
|
|
234
|
+
observations["seconds"] = round(time.time() - started, 2)
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
left_cold.GetName()
|
|
238
|
+
observations["cold_survived"] = True
|
|
239
|
+
except client.BridgeCallError as exc:
|
|
240
|
+
observations["cold_evicted"] = True
|
|
241
|
+
observations["cold_code"] = exc.code
|
|
242
|
+
# The property that actually matters: an evicted handle refuses. If a
|
|
243
|
+
# recycled id resolved to a different object instead, the caller would
|
|
244
|
+
# edit the wrong clip with nothing to indicate it.
|
|
245
|
+
observations["cold_refused_cleanly"] = exc.code == "stale_handle"
|
|
246
|
+
return observations
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
SCENARIOS: Dict[str, Callable[[Any, Path], Dict[str, Any]]] = {
|
|
250
|
+
"render": scenario_render,
|
|
251
|
+
"interchange_export": scenario_interchange_export,
|
|
252
|
+
"set_lut": scenario_set_lut,
|
|
253
|
+
"scale": scenario_scale,
|
|
254
|
+
"handle_pressure": scenario_handle_pressure,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#: Name of the timeline `--build-timeline` creates. Distinctive so it is obvious
|
|
258
|
+
#: what it is and safe to delete.
|
|
259
|
+
SCALE_TIMELINE = "MCP Bridge Scale Test - delete me"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def build_scale_timeline(resolve: Any, items: int) -> Dict[str, Any]:
|
|
263
|
+
"""Create a timeline with `items` clips, so the scale scenario has a subject.
|
|
264
|
+
|
|
265
|
+
Writes. Only runs when `--build-timeline` is passed. Doubles as a test of
|
|
266
|
+
argument encoding depth: `AppendToTimeline` takes a list of dicts each
|
|
267
|
+
holding a live MediaPoolItem, so the proxy has to send objects back *inside*
|
|
268
|
+
a nested structure, not merely as a top-level argument.
|
|
269
|
+
"""
|
|
270
|
+
project = resolve.GetProjectManager().GetCurrentProject()
|
|
271
|
+
pool = project.GetMediaPool()
|
|
272
|
+
clips = [c for c in (pool.GetRootFolder().GetClipList() or [])
|
|
273
|
+
if (c.GetClipProperty("Type") or "") != "Timeline"]
|
|
274
|
+
if not clips:
|
|
275
|
+
return {"failed": "the media pool has no clip to append"}
|
|
276
|
+
source = clips[0]
|
|
277
|
+
timeline = pool.CreateEmptyTimeline(SCALE_TIMELINE)
|
|
278
|
+
if not timeline:
|
|
279
|
+
return {"failed": f"could not create {SCALE_TIMELINE!r} (does it already exist?)"}
|
|
280
|
+
project.SetCurrentTimeline(timeline)
|
|
281
|
+
started = time.time()
|
|
282
|
+
appended = pool.AppendToTimeline(
|
|
283
|
+
[{"mediaPoolItem": source, "startFrame": 0, "endFrame": 5} for _ in range(items)]
|
|
284
|
+
)
|
|
285
|
+
return {
|
|
286
|
+
"timeline": SCALE_TIMELINE,
|
|
287
|
+
"requested": items,
|
|
288
|
+
"appended": len(appended) if isinstance(appended, list) else bool(appended),
|
|
289
|
+
"seconds": round(time.time() - started, 2),
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def run_scenarios(resolve: Any, workspace: Path, only: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
294
|
+
results: Dict[str, Any] = {}
|
|
295
|
+
for name, function in SCENARIOS.items():
|
|
296
|
+
if only and name not in only:
|
|
297
|
+
continue
|
|
298
|
+
try:
|
|
299
|
+
results[name] = function(resolve, workspace)
|
|
300
|
+
except Exception as exc: # noqa: BLE001 - a scenario failing is a result
|
|
301
|
+
results[name] = {"failed": type(exc).__name__, "message": str(exc)[:300]}
|
|
302
|
+
return results
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ── modal behaviour ──────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def probe_modal_timeout(resolve: Any, timeout: float) -> Dict[str, Any]:
|
|
309
|
+
"""Does a blocked Resolve hang the client, or does it come back?
|
|
310
|
+
|
|
311
|
+
Measured on free 21.0.3.7 with dialogs genuinely open, and the answer was
|
|
312
|
+
better than the question assumed: **the bridge is not blocked by modals.**
|
|
313
|
+
With Project Settings open, and again with a native file-open dialog up,
|
|
314
|
+
reads and writes both went through — a 400-item enumeration finished in
|
|
315
|
+
0.17 s. The reason is the host model: a Scripts-menu script is a separate
|
|
316
|
+
process, so a modal that owns Resolve's UI thread does not own the scripting
|
|
317
|
+
IPC. The child-process lifecycle was forced on us by a different question and
|
|
318
|
+
turns out to buy modal immunity as a side effect.
|
|
319
|
+
|
|
320
|
+
One difference was observed under the file dialog and is recorded rather than
|
|
321
|
+
generalised from: `Timeline.SetName` returned None where the same call
|
|
322
|
+
returns False with no dialog open. A single observation, so treat it as a
|
|
323
|
+
reason to keep `false_is_error` catching None as well as False, not as a
|
|
324
|
+
characterisation of every write.
|
|
325
|
+
|
|
326
|
+
This probe cannot open a dialog itself, so it verifies the fallback that
|
|
327
|
+
matters if some future modal *does* block: the client gives up on a bounded
|
|
328
|
+
schedule with an actionable code rather than pinning a tool call.
|
|
329
|
+
"""
|
|
330
|
+
from src.utils import resolve_bridge_client as client
|
|
331
|
+
|
|
332
|
+
transport = getattr(resolve, "_transport", None)
|
|
333
|
+
if transport is None:
|
|
334
|
+
return {"skipped": "native transport has no timeout to probe"}
|
|
335
|
+
previous = transport.timeout
|
|
336
|
+
transport.timeout = timeout
|
|
337
|
+
started = time.time()
|
|
338
|
+
try:
|
|
339
|
+
resolve.GetProductName()
|
|
340
|
+
return {"answered": True, "seconds": round(time.time() - started, 3),
|
|
341
|
+
"note": "nothing was blocking; run again with a dialog open in Resolve"}
|
|
342
|
+
except client.BridgeCallError as exc:
|
|
343
|
+
return {"answered": False, "code": exc.code, "seconds": round(time.time() - started, 3),
|
|
344
|
+
"message": exc.message[:200]}
|
|
345
|
+
finally:
|
|
346
|
+
transport.timeout = previous
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def main() -> int:
|
|
350
|
+
parser = argparse.ArgumentParser(description=__doc__,
|
|
351
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
352
|
+
parser.add_argument("--mode", choices=("differential", "coverage", "surface"), default="coverage")
|
|
353
|
+
parser.add_argument("--scenarios", action="store_true", help="run the write scenarios (mutates the project)")
|
|
354
|
+
parser.add_argument("--only", action="append", help="restrict to one scenario; repeatable")
|
|
355
|
+
parser.add_argument("--build-timeline", type=int, metavar="N",
|
|
356
|
+
help=f"create {SCALE_TIMELINE!r} with N clips first (writes)")
|
|
357
|
+
parser.add_argument("--workspace", default=str(Path.home() / "Movies" / "mcp_bridge_test"))
|
|
358
|
+
parser.add_argument("--json", dest="json_path", help="write the full report here")
|
|
359
|
+
arguments = parser.parse_args()
|
|
360
|
+
|
|
361
|
+
surface = bd.probe_surface()
|
|
362
|
+
report: Dict[str, Any] = {"surface": {k: v for k, v in surface.items() if k != "sources"}}
|
|
363
|
+
|
|
364
|
+
if arguments.mode == "surface":
|
|
365
|
+
print(json.dumps(report, indent=2))
|
|
366
|
+
return 0
|
|
367
|
+
|
|
368
|
+
workspace = Path(arguments.workspace).expanduser()
|
|
369
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
370
|
+
|
|
371
|
+
bridge = connect_bridge()
|
|
372
|
+
report["bridge_health"] = bridge.bridge_health()
|
|
373
|
+
if arguments.build_timeline:
|
|
374
|
+
report["built"] = build_scale_timeline(bridge, arguments.build_timeline)
|
|
375
|
+
print("built : %s" % json.dumps(report["built"], sort_keys=True))
|
|
376
|
+
bridged = bd.run_probes(bridge, surface["safe_to_probe"])
|
|
377
|
+
report["coverage"] = bd.coverage_report(surface, bridged)
|
|
378
|
+
report["modal_timeout"] = probe_modal_timeout(bridge, timeout=5.0)
|
|
379
|
+
|
|
380
|
+
if arguments.mode == "differential":
|
|
381
|
+
native = connect_native()
|
|
382
|
+
if native is None:
|
|
383
|
+
print("native scripting is unavailable — Studio only. Use --mode coverage.",
|
|
384
|
+
file=sys.stderr)
|
|
385
|
+
return 2
|
|
386
|
+
product = native.GetProductName()
|
|
387
|
+
if product != report["bridge_health"]["product"]:
|
|
388
|
+
# Two different applications would make every difference meaningless.
|
|
389
|
+
print(f"refusing to diff: native sees {product!r}, the bridge sees "
|
|
390
|
+
f"{report['bridge_health']['product']!r}. Both transports must reach the "
|
|
391
|
+
"same running Resolve.", file=sys.stderr)
|
|
392
|
+
return 2
|
|
393
|
+
report["diff"] = bd.compare(bd.run_probes(native, surface["safe_to_probe"]), bridged)
|
|
394
|
+
if arguments.scenarios:
|
|
395
|
+
report["scenarios"] = {
|
|
396
|
+
"native": run_scenarios(native, workspace, arguments.only),
|
|
397
|
+
"bridge": run_scenarios(bridge, workspace, arguments.only),
|
|
398
|
+
}
|
|
399
|
+
elif arguments.scenarios:
|
|
400
|
+
report["scenarios"] = {"bridge": run_scenarios(bridge, workspace, arguments.only)}
|
|
401
|
+
|
|
402
|
+
if arguments.json_path:
|
|
403
|
+
Path(arguments.json_path).write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
|
|
404
|
+
|
|
405
|
+
coverage = report["coverage"]
|
|
406
|
+
print("bridge : %s %s (%s)" % (report["bridge_health"]["product"],
|
|
407
|
+
report["bridge_health"]["version"],
|
|
408
|
+
report["bridge_health"]["edition"]))
|
|
409
|
+
print("surface : %d methods called by the tools, %d safe to probe"
|
|
410
|
+
% (coverage["called_by_tools"], coverage["safe_to_probe"]))
|
|
411
|
+
print("exercised : %d/%d read methods on the live object graph"
|
|
412
|
+
% (coverage["exercised"], coverage["safe_to_probe"]))
|
|
413
|
+
if coverage["unreachable_objects"]:
|
|
414
|
+
print("unreachable : %s (no such object in this project)"
|
|
415
|
+
% ", ".join(coverage["unreachable_objects"]))
|
|
416
|
+
if coverage["not_exercised"]:
|
|
417
|
+
print("not covered : %s" % ", ".join(coverage["not_exercised"]))
|
|
418
|
+
if "diff" in report:
|
|
419
|
+
diff = report["diff"]
|
|
420
|
+
print("differential: %d probes compared, %d differences"
|
|
421
|
+
% (diff["compared"], len(diff["differences"])))
|
|
422
|
+
for difference in diff["differences"][:20]:
|
|
423
|
+
print(" ! %-46s %s" % (difference["key"], difference["kind"]))
|
|
424
|
+
for name, outcome in (report.get("scenarios", {}).get("bridge", {}) or {}).items():
|
|
425
|
+
print("scenario %-19s %s" % (name, json.dumps(outcome, sort_keys=True)[:180]))
|
|
426
|
+
failed = "diff" in report and not report["diff"]["identical"]
|
|
427
|
+
return 1 if failed else 0
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
if __name__ == "__main__":
|
|
431
|
+
sys.exit(main())
|