davinci-resolve-mcp 2.103.0 → 2.103.1
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 +44 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/install.py +1 -1
- package/package.json +1 -1
- 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,50 @@
|
|
|
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.1
|
|
6
|
+
|
|
7
|
+
**A loudness measurement could silently become a single frame's reading.**
|
|
8
|
+
`media_analysis`'s EBU R128 parser took the last match for `I:`, `LRA:` and `Peak:`
|
|
9
|
+
across the whole of ffmpeg's stderr. `ebur128` prints a progress line per frame carrying
|
|
10
|
+
those same fields, so that read was correct only because the `Summary:` block happens to
|
|
11
|
+
print last. Nothing enforces that ordering, and when it does not hold the numbers still
|
|
12
|
+
parse — a delivery-grade figure is quietly replaced by one frame's, with no error to
|
|
13
|
+
notice.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- `media_analysis._parse_loudness` now reads the summary block and nothing else.
|
|
18
|
+
- Both callers share one parser, `src/utils/loudness_parse.py`. The regexes were
|
|
19
|
+
duplicated so `mix_plan` stayed importable without the analysis engine; that argument
|
|
20
|
+
covers the engine, not the parsing rule, and a rule that has to be right in two places
|
|
21
|
+
is one that eventually is not.
|
|
22
|
+
- Absent a summary the result is `None`, not a best guess. "No measurement" and
|
|
23
|
+
"one frame's measurement" are different answers, and only one is safe to deliver on.
|
|
24
|
+
|
|
25
|
+
### How the block is bounded
|
|
26
|
+
|
|
27
|
+
Two independent guards, because each rests on a different assumption about ffmpeg's
|
|
28
|
+
output and either can outlive the other:
|
|
29
|
+
|
|
30
|
+
1. **Block bounding** — seek the last `Summary:`, then take lines until the next ffmpeg
|
|
31
|
+
log line. The summary body is indented plain text while every log line carries a
|
|
32
|
+
`[component @ address]` prefix, so the block ends at `[out#0/null …]`, at a trailing
|
|
33
|
+
progress line, and at anything else appended after it.
|
|
34
|
+
2. **`TARGET:` filtering** — the field on every progress line and on nothing in the
|
|
35
|
+
summary. This is what still holds if a progress line ever arrives without the
|
|
36
|
+
bracketed prefix, and it is what makes the no-summary path return `None`.
|
|
37
|
+
|
|
38
|
+
### Validation
|
|
39
|
+
|
|
40
|
+
- Offline suite: 2980 passed, 1 skipped, 725 subtests, 0 failures (was 2959/719).
|
|
41
|
+
- Verified against real ffmpeg output, not only fixtures: a live `ebur128` run is parsed
|
|
42
|
+
and the block asserted to end before ffmpeg's own trailer.
|
|
43
|
+
- Four deliberate mutations — no scoping, locating the summary without bounding the
|
|
44
|
+
block, bounding it without the `TARGET:` filter, and falling back to the raw stream
|
|
45
|
+
when no summary printed — were each caught. The third initially survived, and the test
|
|
46
|
+
isolating that guard was added until it failed.
|
|
47
|
+
- No Resolve behavior changed; live test not required.
|
|
48
|
+
|
|
5
49
|
## What's New in v2.103.0
|
|
6
50
|
|
|
7
51
|
**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.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
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.1"
|
|
41
41
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
42
42
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
43
43
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
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.1"
|
|
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]:
|