davinci-resolve-mcp 2.103.0 → 2.103.2
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 +101 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/bin/davinci-resolve-mcp.mjs +29 -9
- package/docs/install.md +1 -0
- package/install.py +59 -2
- package/package.json +1 -1
- package/scripts/doctor.py +71 -4
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
- package/src/utils/loudness_parse.py +90 -0
- package/src/utils/media_analysis.py +10 -10
- package/src/utils/mix_plan.py +6 -34
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,107 @@
|
|
|
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.103.2
|
|
6
|
+
|
|
7
|
+
**A Windows setup that failed with nothing to read.** Reported in issue #158 by
|
|
8
|
+
@KMiNT21 on a machine carrying both Python 3.12 and 3.13: `npx davinci-resolve-mcp`
|
|
9
|
+
exited without a traceback, a log line, or an error. Three defects compounded.
|
|
10
|
+
|
|
11
|
+
The first is the one that mattered. The npm launcher tested for the Windows `py`
|
|
12
|
+
launcher with `py --version`, and that result gated the entire `py -3.12 / -3.11 /
|
|
13
|
+
-3.10` candidate list. `py` does not accept `--version` on every build — it exits
|
|
14
|
+
101 on the ones it does not — so on those machines the probe reported no launcher,
|
|
15
|
+
every version-pinned candidate was discarded, and selection fell through to bare
|
|
16
|
+
`python`: the 3.13 that the candidate ordering exists specifically to avoid. The
|
|
17
|
+
3.13 protections added in v2.26.1 were not wrong; they were being skipped past.
|
|
18
|
+
|
|
19
|
+
The fix removes the probe rather than correcting its flag. `checkPython()` already
|
|
20
|
+
validates each candidate by running it, so a machine without `py` costs one failed
|
|
21
|
+
spawn. A probe that can produce a false negative earns its place only if something
|
|
22
|
+
downstream cannot do without it, and nothing here needed it.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- `bin/davinci-resolve-mcp.mjs` no longer gates the `py -3.x` candidates behind a
|
|
27
|
+
`py --version` probe.
|
|
28
|
+
- An access-violation exit is now explained instead of propagated bare. Windows
|
|
29
|
+
reports `STATUS_ACCESS_VIOLATION` as an exit code (`3221225477`, or `-1073741819`
|
|
30
|
+
read signed), not as a signal — the interpreter dies inside the native library
|
|
31
|
+
with no chance to print. Both the launcher and `install.py`'s connection probe now
|
|
32
|
+
name the code, say why there is no traceback, and give the remedy. Previously the
|
|
33
|
+
probe could only report `Process exited with code 3221225477`.
|
|
34
|
+
- `scripts/doctor.py` consults the runtime discovery helpers when every candidate
|
|
35
|
+
path misses, so Resolve installed off the conventional root (the reporter had it
|
|
36
|
+
on `D:`) is found rather than reported as four FAILs on a machine `install.py` had
|
|
37
|
+
just configured correctly. Same shape as issue #106.
|
|
38
|
+
- `scripts/doctor.py` no longer reports a client config as `missing` because of path
|
|
39
|
+
escaping. A Windows path written into JSON comes back with doubled separators, and
|
|
40
|
+
the literal substring test could never match it — a false negative in the tool
|
|
41
|
+
whose job is to say whether setup worked.
|
|
42
|
+
|
|
43
|
+
### Not changed
|
|
44
|
+
|
|
45
|
+
Python 3.13 is still permitted. The policy set in v2.26.1 is a 3.10 floor with no
|
|
46
|
+
cap — warn, do not block — and issue #158 proposed enforcing 3.10-3.12 on Windows.
|
|
47
|
+
The candidate ordering already prefers the lower-risk interpreters; the bug was that
|
|
48
|
+
ordering being bypassed, which is now fixed.
|
|
49
|
+
|
|
50
|
+
### Coverage and its limits
|
|
51
|
+
|
|
52
|
+
`tests/test_windows_python_crash.py` pins the launcher's candidate shape and the
|
|
53
|
+
crash-code translation; `tests/test_doctor_paths.py` gains the discovery and
|
|
54
|
+
path-escaping cases. All were confirmed to fail against the unfixed code.
|
|
55
|
+
|
|
56
|
+
What is **not** covered, and is not coverable from macOS: whether `py --version`
|
|
57
|
+
actually fails on any given Windows build. That claim comes from the reporter. The
|
|
58
|
+
fix does not rest on it — it removes the probe rather than correcting it, so the
|
|
59
|
+
code no longer has an opinion either way. The access-violation paths are likewise
|
|
60
|
+
tested by injecting the exit code, not by producing a real crash.
|
|
61
|
+
|
|
62
|
+
## What's New in v2.103.1
|
|
63
|
+
|
|
64
|
+
**A loudness measurement could silently become a single frame's reading.**
|
|
65
|
+
`media_analysis`'s EBU R128 parser took the last match for `I:`, `LRA:` and `Peak:`
|
|
66
|
+
across the whole of ffmpeg's stderr. `ebur128` prints a progress line per frame carrying
|
|
67
|
+
those same fields, so that read was correct only because the `Summary:` block happens to
|
|
68
|
+
print last. Nothing enforces that ordering, and when it does not hold the numbers still
|
|
69
|
+
parse — a delivery-grade figure is quietly replaced by one frame's, with no error to
|
|
70
|
+
notice.
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
|
|
74
|
+
- `media_analysis._parse_loudness` now reads the summary block and nothing else.
|
|
75
|
+
- Both callers share one parser, `src/utils/loudness_parse.py`. The regexes were
|
|
76
|
+
duplicated so `mix_plan` stayed importable without the analysis engine; that argument
|
|
77
|
+
covers the engine, not the parsing rule, and a rule that has to be right in two places
|
|
78
|
+
is one that eventually is not.
|
|
79
|
+
- Absent a summary the result is `None`, not a best guess. "No measurement" and
|
|
80
|
+
"one frame's measurement" are different answers, and only one is safe to deliver on.
|
|
81
|
+
|
|
82
|
+
### How the block is bounded
|
|
83
|
+
|
|
84
|
+
Two independent guards, because each rests on a different assumption about ffmpeg's
|
|
85
|
+
output and either can outlive the other:
|
|
86
|
+
|
|
87
|
+
1. **Block bounding** — seek the last `Summary:`, then take lines until the next ffmpeg
|
|
88
|
+
log line. The summary body is indented plain text while every log line carries a
|
|
89
|
+
`[component @ address]` prefix, so the block ends at `[out#0/null …]`, at a trailing
|
|
90
|
+
progress line, and at anything else appended after it.
|
|
91
|
+
2. **`TARGET:` filtering** — the field on every progress line and on nothing in the
|
|
92
|
+
summary. This is what still holds if a progress line ever arrives without the
|
|
93
|
+
bracketed prefix, and it is what makes the no-summary path return `None`.
|
|
94
|
+
|
|
95
|
+
### Validation
|
|
96
|
+
|
|
97
|
+
- Offline suite: 2980 passed, 1 skipped, 725 subtests, 0 failures (was 2959/719).
|
|
98
|
+
- Verified against real ffmpeg output, not only fixtures: a live `ebur128` run is parsed
|
|
99
|
+
and the block asserted to end before ffmpeg's own trailer.
|
|
100
|
+
- Four deliberate mutations — no scoping, locating the summary without bounding the
|
|
101
|
+
block, bounding it without the `TARGET:` filter, and falling back to the raw stream
|
|
102
|
+
when no summary printed — were each caught. The third initially survived, and the test
|
|
103
|
+
isolating that guard was added until it failed.
|
|
104
|
+
- No Resolve behavior changed; live test not required.
|
|
105
|
+
|
|
5
106
|
## What's New in v2.103.0
|
|
6
107
|
|
|
7
108
|
**An unreachable Resolve no longer ends the work.** The interchange authoring that can
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.103.
|
|
15
|
+
> 本翻译对应 v2.103.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
|
@@ -179,14 +179,6 @@ function syncManagedInstall(root) {
|
|
|
179
179
|
return root;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
function commandExists(command, args = []) {
|
|
183
|
-
const result = spawnSync(command, [...args, "--version"], {
|
|
184
|
-
encoding: "utf8",
|
|
185
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
186
|
-
});
|
|
187
|
-
return result.status === 0;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
182
|
function parseExecutable(value) {
|
|
191
183
|
if (!value) {
|
|
192
184
|
return null;
|
|
@@ -207,7 +199,14 @@ function pythonCandidates() {
|
|
|
207
199
|
// Prefer the lowest-ABI-risk interpreters first, then newer ones, then the
|
|
208
200
|
// generic launchers. All 3.10+ are accepted; ordering just picks the safest
|
|
209
201
|
// when several are installed.
|
|
210
|
-
|
|
202
|
+
// No existence probe in front of these. `py --version` is not a reliable
|
|
203
|
+
// one — the Windows launcher does not accept it on every build, and it
|
|
204
|
+
// exits 101 on the ones it does not (issue #158). A probe that gets that
|
|
205
|
+
// wrong discards every version-pinned candidate below and falls through to
|
|
206
|
+
// bare `python`, which is exactly the 3.13 the ordering exists to avoid.
|
|
207
|
+
// checkPython() runs each candidate anyway, so a missing `py` costs one
|
|
208
|
+
// failed spawn and is skipped.
|
|
209
|
+
if (process.platform === "win32") {
|
|
211
210
|
candidates.push(
|
|
212
211
|
{ command: "py", args: ["-3.12"] },
|
|
213
212
|
{ command: "py", args: ["-3.11"] },
|
|
@@ -317,6 +316,24 @@ function venvPython(root) {
|
|
|
317
316
|
return info;
|
|
318
317
|
}
|
|
319
318
|
|
|
319
|
+
// Windows reports a hard access violation as the process exit code, not as a
|
|
320
|
+
// signal and not as a traceback: the interpreter is gone before it can say
|
|
321
|
+
// anything. Loading Resolve's fusionscript under a Python its C ABI does not
|
|
322
|
+
// match is one way to get there, so name that possibility rather than letting
|
|
323
|
+
// the run end in a bare unexplained code (issue #158).
|
|
324
|
+
const WINDOWS_ACCESS_VIOLATION = [3221225477, -1073741819];
|
|
325
|
+
|
|
326
|
+
function accessViolationNote(code) {
|
|
327
|
+
return [
|
|
328
|
+
`The Python process was terminated by an access violation (0x${(code >>> 0).toString(16).toUpperCase()}).`,
|
|
329
|
+
"It crashed inside a native library before it could report anything, so there is no traceback above.",
|
|
330
|
+
"The usual cause is Resolve's scripting library being loaded by a Python whose C ABI it was not built",
|
|
331
|
+
"against. If you are on Python 3.13+, install Python 3.10-3.12 and pin it:",
|
|
332
|
+
" DAVINCI_RESOLVE_MCP_PYTHON=C:\\Path\\To\\python3.12.exe",
|
|
333
|
+
"then re-run setup so the managed venv is rebuilt on that interpreter.",
|
|
334
|
+
].join("\n");
|
|
335
|
+
}
|
|
336
|
+
|
|
320
337
|
function run(command, args, options = {}) {
|
|
321
338
|
const child = spawn(command, args, {
|
|
322
339
|
cwd: options.cwd,
|
|
@@ -329,6 +346,9 @@ function run(command, args, options = {}) {
|
|
|
329
346
|
process.kill(process.pid, signal);
|
|
330
347
|
return;
|
|
331
348
|
}
|
|
349
|
+
if (WINDOWS_ACCESS_VIOLATION.includes(code)) {
|
|
350
|
+
console.error(accessViolationNote(code));
|
|
351
|
+
}
|
|
332
352
|
process.exit(code ?? 1);
|
|
333
353
|
});
|
|
334
354
|
child.on("error", (error) => {
|
package/docs/install.md
CHANGED
|
@@ -86,6 +86,7 @@ The installer can automatically configure any of these clients:
|
|
|
86
86
|
|
|
87
87
|
| Client | Config Written To |
|
|
88
88
|
|--------|-------------------|
|
|
89
|
+
| Antigravity (Google) | `~/.gemini/antigravity/mcp_config.json` (all platforms) |
|
|
89
90
|
| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS); `%APPDATA%\Claude\claude_desktop_config.json` (Windows, see MSIX note below) |
|
|
90
91
|
| Claude Code | `.mcp.json` (project root) |
|
|
91
92
|
| Cursor | `~/.cursor/mcp.json` |
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "2.103.
|
|
40
|
+
VERSION = "2.103.2"
|
|
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
|
|
@@ -1253,6 +1253,54 @@ def install_dependencies(venv_path, project_dir):
|
|
|
1253
1253
|
|
|
1254
1254
|
# ─── Connection Verification ─────────────────────────────────────────────────
|
|
1255
1255
|
|
|
1256
|
+
#: Windows surfaces a hard access violation (STATUS_ACCESS_VIOLATION) as a
|
|
1257
|
+
#: process exit code. Both spellings appear in the wild: the unsigned value and
|
|
1258
|
+
#: the signed reading of the same 32 bits. The interpreter dies inside the
|
|
1259
|
+
#: native library, so there is no traceback and no stderr to report — without
|
|
1260
|
+
#: this translation the probe can only say "exited with code 3221225477",
|
|
1261
|
+
#: which is the shape issue #158 was reported as.
|
|
1262
|
+
WINDOWS_ACCESS_VIOLATION_CODES = (3221225477, -1073741819)
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def _version_parts(version):
|
|
1266
|
+
"""A `(major, minor, ...)` int tuple, or None if it cannot be read.
|
|
1267
|
+
|
|
1268
|
+
`is_abi_risk_python_version` unpacks `version[:2]`, so handing it the string
|
|
1269
|
+
"3.13.3" yields `("3", ".")` and a quiet False — the interpreter most likely
|
|
1270
|
+
to have caused the crash would be reported as not at risk. Normalizing here
|
|
1271
|
+
means a caller cannot make that mistake by passing the obvious thing.
|
|
1272
|
+
"""
|
|
1273
|
+
if version is None:
|
|
1274
|
+
return None
|
|
1275
|
+
if isinstance(version, str):
|
|
1276
|
+
try:
|
|
1277
|
+
return tuple(int(part) for part in version.split(".")[:3])
|
|
1278
|
+
except ValueError:
|
|
1279
|
+
return None
|
|
1280
|
+
try:
|
|
1281
|
+
return tuple(int(part) for part in tuple(version)[:3])
|
|
1282
|
+
except (TypeError, ValueError):
|
|
1283
|
+
return None
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def access_violation_message(returncode, version=None):
|
|
1287
|
+
"""Explain an access-violation exit, naming the ABI theory when it fits."""
|
|
1288
|
+
text = (
|
|
1289
|
+
"Python crashed with an access violation (0xC0000005) while loading "
|
|
1290
|
+
"Resolve's scripting library — no traceback is possible, the process "
|
|
1291
|
+
"was terminated by the OS."
|
|
1292
|
+
)
|
|
1293
|
+
parts = _version_parts(version)
|
|
1294
|
+
if parts is None or is_abi_risk_python_version(parts):
|
|
1295
|
+
text += (
|
|
1296
|
+
" This is the signature of a C ABI mismatch: recreate the venv on "
|
|
1297
|
+
"Python 3.10-3.12 (e.g. DAVINCI_RESOLVE_MCP_PYTHON=...\\python3.12.exe)."
|
|
1298
|
+
)
|
|
1299
|
+
else:
|
|
1300
|
+
text += " Check that RESOLVE_SCRIPT_LIB names the library from your Resolve install."
|
|
1301
|
+
return text
|
|
1302
|
+
|
|
1303
|
+
|
|
1256
1304
|
def verify_resolve_connection(python_path, api_path, lib_path):
|
|
1257
1305
|
"""Try to import DaVinciResolveScript and connect."""
|
|
1258
1306
|
if not api_path:
|
|
@@ -1311,6 +1359,12 @@ def verify_resolve_connection(python_path, api_path, lib_path):
|
|
|
1311
1359
|
elif output.startswith("IMPORTED_OK:"):
|
|
1312
1360
|
return True, "API module loaded (Resolve not running)"
|
|
1313
1361
|
else:
|
|
1362
|
+
if result.returncode in WINDOWS_ACCESS_VIOLATION_CODES:
|
|
1363
|
+
try:
|
|
1364
|
+
version = _version_for_python(python_path)
|
|
1365
|
+
except Exception:
|
|
1366
|
+
version = None
|
|
1367
|
+
return False, access_violation_message(result.returncode, version)
|
|
1314
1368
|
if output:
|
|
1315
1369
|
return False, output
|
|
1316
1370
|
return False, f"Process exited with code {result.returncode}"
|
|
@@ -2219,7 +2273,10 @@ def main():
|
|
|
2219
2273
|
" Point RESOLVE_SCRIPT_LIB at the fusionscript library inside your "
|
|
2220
2274
|
"Resolve install, or start Resolve and re-run setup."
|
|
2221
2275
|
)
|
|
2222
|
-
if py_abi_risk:
|
|
2276
|
+
if py_abi_risk and "0xC0000005" not in message:
|
|
2277
|
+
# The access-violation message already carries this remedy, and
|
|
2278
|
+
# states it as a diagnosis rather than a maybe. Do not follow it
|
|
2279
|
+
# with a weaker restatement of itself.
|
|
2223
2280
|
print(
|
|
2224
2281
|
f" On Python 3.13+ this may be an ABI mismatch with Resolve's "
|
|
2225
2282
|
f"scripting library — try Python 3.10-3.12 if it persists."
|
package/package.json
CHANGED
package/scripts/doctor.py
CHANGED
|
@@ -95,13 +95,64 @@ def _platform_key(platform: str | None = None) -> str:
|
|
|
95
95
|
return "linux"
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
def _discovered_default(kind: str) -> str | None:
|
|
99
|
+
"""Ask the runtime helpers where Resolve actually is.
|
|
100
|
+
|
|
101
|
+
The candidate table above is a table of *conventional* locations. Resolve is
|
|
102
|
+
routinely installed somewhere else — a second drive is the common case,
|
|
103
|
+
because the application and its caches are large (issue #158 reported
|
|
104
|
+
`D:\\Programs\\DaVinci Resolve`). install.py already looks past the table
|
|
105
|
+
via these helpers; doctor did not, so it reported FAIL on installs the
|
|
106
|
+
installer had just configured correctly. That disagreement between two tools
|
|
107
|
+
describing one machine is the same failure shape as issue #106.
|
|
108
|
+
|
|
109
|
+
Only consulted when no candidate exists, and only for the two kinds the
|
|
110
|
+
helpers can answer. `api` has no discovery helper, so a non-default install
|
|
111
|
+
can still miss there — worth knowing when reading a report.
|
|
112
|
+
"""
|
|
113
|
+
if str(REPO) not in sys.path:
|
|
114
|
+
# doctor is run standalone (`python scripts/doctor.py`) as often as it is
|
|
115
|
+
# run through the launcher, and only the latter puts the repo on the path.
|
|
116
|
+
sys.path.insert(0, str(REPO))
|
|
117
|
+
|
|
118
|
+
if kind == "lib":
|
|
119
|
+
try:
|
|
120
|
+
from src.utils.platform import discover_scripting_lib
|
|
121
|
+
except Exception:
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
discovered = discover_scripting_lib()
|
|
125
|
+
except Exception:
|
|
126
|
+
return None
|
|
127
|
+
return str(discovered) if discovered and Path(discovered).exists() else None
|
|
128
|
+
|
|
129
|
+
if kind == "app":
|
|
130
|
+
try:
|
|
131
|
+
from src.utils.resolve_runtime import _executable_from_line, resolve_processes
|
|
132
|
+
except Exception:
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
processes = resolve_processes() or []
|
|
136
|
+
except Exception:
|
|
137
|
+
return None
|
|
138
|
+
for line in processes:
|
|
139
|
+
executable = _executable_from_line(line)
|
|
140
|
+
if executable and Path(executable).exists():
|
|
141
|
+
return str(executable)
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
98
145
|
def _resolve_default(kind: str, platform: str | None = None) -> str:
|
|
99
|
-
"""First candidate of `kind` that exists, else the first
|
|
100
|
-
names the canonical location rather than an
|
|
146
|
+
"""First candidate of `kind` that exists, then discovery, else the first
|
|
147
|
+
candidate (so the FAIL line names the canonical location rather than an
|
|
148
|
+
arbitrary miss)."""
|
|
101
149
|
candidates = _RESOLVE_PATH_CANDIDATES[_platform_key(platform)][kind]
|
|
102
150
|
for candidate in candidates:
|
|
103
151
|
if Path(candidate).exists():
|
|
104
152
|
return candidate
|
|
153
|
+
discovered = _discovered_default(kind)
|
|
154
|
+
if discovered:
|
|
155
|
+
return discovered
|
|
105
156
|
return candidates[0]
|
|
106
157
|
|
|
107
158
|
|
|
@@ -170,11 +221,27 @@ def check(results: list[dict[str, str]], status: str, name: str, detail: str) ->
|
|
|
170
221
|
results.append({"status": status, "name": name, "detail": detail})
|
|
171
222
|
|
|
172
223
|
|
|
224
|
+
def _normalize_separators(text: str) -> str:
|
|
225
|
+
r"""Collapse every run of backslashes to a single forward slash.
|
|
226
|
+
|
|
227
|
+
A Windows path does not survive a literal substring test against the file it
|
|
228
|
+
was written into: JSON escapes `C:\Users\x` as `C:\\Users\\x`, and TOML
|
|
229
|
+
may keep it single-escaped or write a literal string. doctor compared the
|
|
230
|
+
unescaped path against the raw file text and so reported the entry missing
|
|
231
|
+
on configs it had itself just written (issue #158).
|
|
232
|
+
|
|
233
|
+
Collapsing runs — rather than only the doubled form — is what makes both
|
|
234
|
+
spellings compare equal, and normalizing the needle the same way keeps the
|
|
235
|
+
comparison symmetric.
|
|
236
|
+
"""
|
|
237
|
+
return re.sub(r"\\+", "/", text)
|
|
238
|
+
|
|
239
|
+
|
|
173
240
|
def file_contains(path: Path, needles: list[str]) -> tuple[bool, str]:
|
|
174
241
|
if not path.exists():
|
|
175
242
|
return False, "missing"
|
|
176
|
-
text = path.read_text(errors="replace")
|
|
177
|
-
missing = [needle for needle in needles if needle not in text]
|
|
243
|
+
text = _normalize_separators(path.read_text(errors="replace"))
|
|
244
|
+
missing = [needle for needle in needles if _normalize_separators(needle) not in text]
|
|
178
245
|
if missing:
|
|
179
246
|
return False, "missing: " + ", ".join(missing)
|
|
180
247
|
return True, "contains davinci-resolve MCP entry"
|
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 = "2.103.
|
|
90
|
+
VERSION = "2.103.2"
|
|
91
91
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
92
92
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
93
93
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Read EBU R128 figures out of ffmpeg's `ebur128` output, and nothing else out of it.
|
|
2
|
+
|
|
3
|
+
One parser, imported by both callers. There were two: `media_analysis` measures loudness
|
|
4
|
+
during analysis and `mix_plan` measures the premix it just rendered, and they carried
|
|
5
|
+
copies of the same three regexes on the reasoning that `mix_plan` should stay importable
|
|
6
|
+
without pulling in the analysis engine. That reasoning still holds for the *engine*; it
|
|
7
|
+
does not justify two copies of the parsing rule, which is the part that has to be right.
|
|
8
|
+
|
|
9
|
+
## The rule
|
|
10
|
+
|
|
11
|
+
`ebur128` prints a progress line per frame carrying its own `I:`, `LRA:` and peak fields,
|
|
12
|
+
then a `Summary:` block at the end. A last-match-wins read over the whole stream picks
|
|
13
|
+
the summary only because the summary happens to print last. Nothing enforces that, and
|
|
14
|
+
when it does not hold the numbers still parse — they are simply a single frame's reading
|
|
15
|
+
presented as a programme measurement. There is no error to notice.
|
|
16
|
+
|
|
17
|
+
So the summary block is *bounded*, not merely located:
|
|
18
|
+
|
|
19
|
+
1. Seek the last `Summary:`.
|
|
20
|
+
2. Take lines until the next ffmpeg log line — the block's body is indented plain text,
|
|
21
|
+
while every log line carries a `[component @ address]` prefix. That ends the block at
|
|
22
|
+
`[out#0/null …]`, at a trailing progress line, and at anything else ffmpeg appends.
|
|
23
|
+
3. Drop any remaining `TARGET:` line, which is the field on every progress line and on
|
|
24
|
+
nothing in the summary. This is what protects the fallback path when no summary was
|
|
25
|
+
printed at all: there, reading a progress line would be the worst possible answer, so
|
|
26
|
+
the result is `None`.
|
|
27
|
+
|
|
28
|
+
Absent a summary the answer is `None` rather than a best guess. "No measurement" and
|
|
29
|
+
"a measurement of one frame" are different, and only one of them is safe to deliver on.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import re
|
|
35
|
+
from typing import Any, Callable, Dict, Optional
|
|
36
|
+
|
|
37
|
+
INTEGRATED_RE = r"I:\s*(-?\d+(?:\.\d+)?)\s*LUFS"
|
|
38
|
+
LRA_RE = r"LRA:\s*(-?\d+(?:\.\d+)?)\s*LU"
|
|
39
|
+
PEAK_RE = r"Peak:\s*(-?\d+(?:\.\d+)?)\s*dBFS"
|
|
40
|
+
|
|
41
|
+
#: ffmpeg prefixes every log line with `[component @ 0xaddr]`. The summary body does not
|
|
42
|
+
#: carry one, so this is where the block ends.
|
|
43
|
+
_LOG_LINE_PREFIX = "["
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def summary_block(stderr: str) -> str:
|
|
47
|
+
"""The `ebur128` summary block alone, or "" when none was printed."""
|
|
48
|
+
marker = stderr.rfind("Summary:")
|
|
49
|
+
if marker < 0:
|
|
50
|
+
return ""
|
|
51
|
+
# Back up to the line start: `Summary:` sits at the end of an ffmpeg log line, and a
|
|
52
|
+
# block that begins mid-line would make the "first line is the header" rule below
|
|
53
|
+
# depend on where the word happened to fall.
|
|
54
|
+
line_start = stderr.rfind("\n", 0, marker) + 1
|
|
55
|
+
lines = stderr[line_start:].splitlines()
|
|
56
|
+
block = [lines[0]] if lines else []
|
|
57
|
+
for line in lines[1:]:
|
|
58
|
+
if line.startswith(_LOG_LINE_PREFIX):
|
|
59
|
+
break
|
|
60
|
+
block.append(line)
|
|
61
|
+
return "\n".join(line for line in block if "TARGET:" not in line)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_loudness(
|
|
65
|
+
stderr: str, *, to_float: Optional[Callable[[Any], Optional[float]]] = None
|
|
66
|
+
) -> Dict[str, Optional[float]]:
|
|
67
|
+
"""Integrated LUFS, loudness range, and true peak from the summary block.
|
|
68
|
+
|
|
69
|
+
`to_float` lets a caller supply its own lenient conversion; the default is `float`
|
|
70
|
+
with a `None` on failure.
|
|
71
|
+
"""
|
|
72
|
+
convert = to_float or _default_float
|
|
73
|
+
scope = summary_block(stderr)
|
|
74
|
+
|
|
75
|
+
def latest(pattern: str) -> Optional[float]:
|
|
76
|
+
matches = re.findall(pattern, scope)
|
|
77
|
+
return convert(matches[-1]) if matches else None
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"integrated_lufs": latest(INTEGRATED_RE),
|
|
81
|
+
"loudness_range_lu": latest(LRA_RE),
|
|
82
|
+
"true_peak_dbtp": latest(PEAK_RE),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _default_float(value: Any) -> Optional[float]:
|
|
87
|
+
try:
|
|
88
|
+
return float(value)
|
|
89
|
+
except (TypeError, ValueError):
|
|
90
|
+
return None
|
|
@@ -2659,17 +2659,17 @@ def _ffmpeg_stderr_filter(path: str, video_filter: Optional[str] = None, audio_f
|
|
|
2659
2659
|
|
|
2660
2660
|
|
|
2661
2661
|
def _parse_loudness(stderr: str) -> Dict[str, Any]:
|
|
2662
|
-
|
|
2663
|
-
matches = re.findall(pattern, stderr)
|
|
2664
|
-
if not matches:
|
|
2665
|
-
return None
|
|
2666
|
-
return _parse_float(matches[-1])
|
|
2662
|
+
"""EBU R128 figures from ffmpeg's `ebur128` output.
|
|
2667
2663
|
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2664
|
+
Delegates to `loudness_parse`, which bounds the summary block rather than taking the
|
|
2665
|
+
last match in the stream. `ebur128`'s per-frame progress lines carry their own `I:`,
|
|
2666
|
+
`LRA:` and peak fields, so a last-match read is right only while the summary happens
|
|
2667
|
+
to print last — and when it is not, the numbers still parse and a single frame is
|
|
2668
|
+
reported as a programme measurement, with nothing to notice.
|
|
2669
|
+
"""
|
|
2670
|
+
from src.utils import loudness_parse
|
|
2671
|
+
|
|
2672
|
+
return dict(loudness_parse.parse_loudness(stderr, to_float=_parse_float))
|
|
2673
2673
|
|
|
2674
2674
|
|
|
2675
2675
|
def _parse_scene_changes(stderr: str) -> List[Dict[str, Any]]:
|
package/src/utils/mix_plan.py
CHANGED
|
@@ -56,7 +56,7 @@ import subprocess
|
|
|
56
56
|
import tempfile
|
|
57
57
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
58
58
|
|
|
59
|
-
from . import delivery_targets
|
|
59
|
+
from . import delivery_targets, loudness_parse
|
|
60
60
|
|
|
61
61
|
try:
|
|
62
62
|
import numpy as _np
|
|
@@ -107,39 +107,11 @@ def _require() -> None:
|
|
|
107
107
|
|
|
108
108
|
# ── measurement ──────────────────────────────────────────────────────────────
|
|
109
109
|
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
_PEAK_RE = r"Peak:\s*(-?\d+(?:\.\d+)?)\s*dBFS"
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def parse_loudness(stderr: str) -> Dict[str, Optional[float]]:
|
|
119
|
-
"""Pull the ebur128 summary out of ffmpeg's stderr.
|
|
120
|
-
|
|
121
|
-
Scoped to the text after `Summary:`, with ebur128's per-frame progress lines removed.
|
|
122
|
-
Both steps are needed and neither is enough alone: the progress line carries its own
|
|
123
|
-
`I:` and `LRA:`, so a plain last-match-wins parse is right only because the summary
|
|
124
|
-
happens to print last, and scoping to the summary still swallows any progress line
|
|
125
|
-
that prints after it. Progress lines are identified by the `TARGET:` field, which
|
|
126
|
-
appears on every one of them and on nothing in the summary block.
|
|
127
|
-
"""
|
|
128
|
-
marker = stderr.rfind("Summary:")
|
|
129
|
-
scope = "\n".join(
|
|
130
|
-
line for line in (stderr[marker:] if marker >= 0 else stderr).splitlines()
|
|
131
|
-
if "TARGET:" not in line
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
def latest(pattern: str) -> Optional[float]:
|
|
135
|
-
matches = re.findall(pattern, scope)
|
|
136
|
-
return float(matches[-1]) if matches else None
|
|
137
|
-
|
|
138
|
-
return {
|
|
139
|
-
"integrated_lufs": latest(_INTEGRATED_RE),
|
|
140
|
-
"loudness_range_lu": latest(_LRA_RE),
|
|
141
|
-
"true_peak_dbtp": latest(_PEAK_RE),
|
|
142
|
-
}
|
|
110
|
+
# Parsing lives in `loudness_parse`, imported by both this module and the analysis
|
|
111
|
+
# engine. Two copies of the same three regexes were carried here so this module stayed
|
|
112
|
+
# importable without the engine; that argument covers the ENGINE, not the parsing rule,
|
|
113
|
+
# and a rule that has to be right in two places is one that eventually is not.
|
|
114
|
+
parse_loudness = loudness_parse.parse_loudness
|
|
143
115
|
|
|
144
116
|
|
|
145
117
|
def _run(args: Sequence[str], *, stdin_bytes: Optional[bytes] = None) -> Tuple[int, bytes, str]:
|