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 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
- [![Version](https://img.shields.io/badge/version-2.103.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.103.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.103.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.103.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.103.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
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.0"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.103.0",
3
+ "version": "2.103.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.103.0"
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
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.103.0"
14
+ VERSION = "2.103.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -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
- def latest(pattern: str) -> Optional[float]:
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
- return {
2669
- "integrated_lufs": latest(r"I:\s*(-?\d+(?:\.\d+)?)\s*LUFS"),
2670
- "loudness_range_lu": latest(r"LRA:\s*(-?\d+(?:\.\d+)?)\s*LU"),
2671
- "true_peak_dbtp": latest(r"Peak:\s*(-?\d+(?:\.\d+)?)\s*dBFS"),
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]]:
@@ -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
- # These patterns mirror `media_analysis._parse_loudness`. The duplication is deliberate
111
- # this module stays importable without pulling in the analysis engine and a test
112
- # asserts both parsers agree on the same ffmpeg output, so the copy cannot drift.
113
- _INTEGRATED_RE = r"I:\s*(-?\d+(?:\.\d+)?)\s*LUFS"
114
- _LRA_RE = r"LRA:\s*(-?\d+(?:\.\d+)?)\s*LU"
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]: