davinci-resolve-mcp 4.8.20 → 4.8.22

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,60 @@
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 v4.8.22 — the control panel port check cannot hang on a wedged lsof
6
+
7
+ ### Fixed
8
+
9
+ - **`open_control_panel` could block forever behind an unkillable `lsof`.** The
10
+ port-owner check ran `lsof -iTCP:<port> -sTCP:LISTEN -t` through
11
+ `subprocess.run(timeout=3)`. On macOS, lsof wedges in uninterruptible kernel wait
12
+ (state `U` in `ps`) when a network mount is stale, and a process in that state
13
+ ignores SIGKILL. `subprocess.run`'s timeout path kills the child and then waits
14
+ for it, so the 3-second timeout never returned: the caller hung with the child.
15
+ Measured on 2026-09-26 on the release machine, where 489 lsof processes had been
16
+ stuck for 12 hours and the offline suite sat in this function for 13 minutes.
17
+ `_port_owner_pid` now starts lsof in its own session, polls to the deadline, and
18
+ on expiry sends SIGKILL and abandons the child instead of joining it. stdout is
19
+ read only once `poll()` reports an exit, and the pipe is closed on every path.
20
+ A missing lsof is still `None`, not an exception.
21
+
22
+ ### Tests
23
+
24
+ - `tests/test_port_owner_pid.py`: a fake child whose `poll()` never returns and
25
+ whose `kill()` is a no-op yields `None` within the deadline with `wait()` and
26
+ `communicate()` never called and the pipe closed; an exited child still yields
27
+ its PID; a missing binary yields `None`; and a real subprocess that ignores
28
+ SIGTERM, with `kill` patched out, is left running rather than joined.
29
+
30
+ ## What's New in v4.8.21 — timeline duration no longer overcounts by one frame
31
+
32
+ ### Fixed
33
+
34
+ - **Two timeline-duration readers reported one frame too many.** ([#269](https://github.com/samuelgursky/davinci-resolve-mcp/pull/269), @Dev-next-gen)
35
+ The granular `get_current_timeline` and `get_project_info`'s per-timeline
36
+ `duration` computed `GetEndFrame() - GetStartFrame() + 1`, while the compound
37
+ server, `brain_edits` and `render_stress` all use `GetEndFrame() - GetStartFrame()`
38
+ for the same timeline. `GetEndFrame()` is one past the last frame, so the `+ 1`
39
+ counted a frame that does not exist: a 600-frame timeline read back as 601. Both
40
+ readers now agree with the rest of the server.
41
+
42
+ ### Documentation
43
+
44
+ - **`api_truth` records `Timeline.GetEndFrame` as an exclusive bound.** Measured
45
+ live on Studio 19.1.3.7 while reviewing #269: a timeline whose only full-length
46
+ item is a 32742-frame clip reads start 0 / end 32742, and the item's
47
+ `GetEnd()` is also 32742. The entry sits beside the existing
48
+ `AppendToTimeline clipInfo endFrame` entry, which documents the same half-open
49
+ convention on the write side, so the frame-count rule is now findable from
50
+ either direction.
51
+
52
+ ### Tests
53
+
54
+ - `tests/test_timeline_duration.py` (from the PR): both readers return
55
+ `end - start` for a stubbed 86400..87000 timeline; fails on v4.8.20 with 601.
56
+ - `tests/test_api_truth.py`: the new ledger entry is findable by `GetEndFrame`,
57
+ carries the `off-by-one` tag, and states the `end - start` rule.
58
+
5
59
  ## What's New in v4.8.20 — every boolean param honours `"false"`
6
60
 
7
61
  ### Fixed
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-4.8.20-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.22-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-37%20(389%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-4.8.20-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.22-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-37%20(389%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
- > 本翻译对应 v4.8.20 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.8.22 版 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 = "4.8.20"
40
+ VERSION = "4.8.22"
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": "4.8.20",
3
+ "version": "4.8.22",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -93,7 +93,7 @@ if not logging.getLogger().handlers:
93
93
  handlers=[logging.StreamHandler()],
94
94
  )
95
95
 
96
- VERSION = "4.8.20"
96
+ VERSION = "4.8.22"
97
97
  logger = logging.getLogger("davinci-resolve-mcp")
98
98
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
99
99
  logger.info(f"Detected platform: {get_platform()}")
@@ -65,7 +65,7 @@ def get_current_timeline() -> Dict[str, Any]:
65
65
  "width": current_timeline.GetSetting("timelineResolutionWidth"),
66
66
  "height": current_timeline.GetSetting("timelineResolutionHeight")
67
67
  },
68
- "duration": current_timeline.GetEndFrame() - current_timeline.GetStartFrame() + 1
68
+ "duration": current_timeline.GetEndFrame() - current_timeline.GetStartFrame()
69
69
  }
70
70
 
71
71
  return result
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "4.8.20"
14
+ VERSION = "4.8.22"
15
15
 
16
16
  import base64
17
17
  import os
@@ -17750,24 +17750,57 @@ def _control_panel_remote_version(host: str, port: int, timeout: float = 1.5) ->
17750
17750
  return _control_panel_probe(host, port, timeout).get("version")
17751
17751
 
17752
17752
 
17753
- def _port_owner_pid(host: str, port: int) -> Optional[int]:
17753
+ def _port_owner_pid(host: str, port: int, timeout: float = 3.0) -> Optional[int]:
17754
17754
  """Return PID of the process LISTENing on `port`, or None if free/unknown.
17755
17755
 
17756
17756
  Uses lsof with `-iTCP:<port> -sTCP:LISTEN -t`: one PID per line, no header.
17757
17757
  Host is informational only — lsof matches any local LISTEN socket on that
17758
17758
  port (which is what we care about for port-collision detection).
17759
+
17760
+ The deadline is enforced by polling, not by ``subprocess.run(timeout=)``.
17761
+ On macOS, lsof can wedge in uninterruptible kernel wait (state ``U`` in
17762
+ ``ps``) when a network mount is stale, and a child in that state ignores
17763
+ SIGKILL. ``subprocess.run``'s timeout path kills the child and then WAITS
17764
+ for it, so the caller hung with it — measured 2026-09-26 on the release
17765
+ machine: 489 lsof processes stuck in ``U`` for 12 h, and the offline suite
17766
+ blocked here for 13 min. Now the child is killed on expiry and abandoned
17767
+ rather than joined; stdout is read only once ``poll()`` says it exited.
17759
17768
  """
17760
17769
  import subprocess
17770
+ import time as _t
17761
17771
  try:
17762
- result = subprocess.run(
17772
+ proc = subprocess.Popen(
17763
17773
  ["lsof", "-nP", "-iTCP:" + str(port), "-sTCP:LISTEN", "-t"],
17764
- capture_output=True, timeout=3, text=True, encoding="utf-8",
17765
- errors="replace", check=False,
17766
- stdin=subprocess.DEVNULL,
17774
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
17775
+ stdin=subprocess.DEVNULL, start_new_session=True,
17767
17776
  )
17768
- except (OSError, subprocess.TimeoutExpired):
17777
+ except OSError:
17769
17778
  return None
17770
- for line in (result.stdout or "").splitlines():
17779
+ deadline = _t.monotonic() + max(0.0, float(timeout))
17780
+ out = b""
17781
+ try:
17782
+ while proc.poll() is None:
17783
+ if _t.monotonic() >= deadline:
17784
+ try:
17785
+ proc.kill()
17786
+ except OSError:
17787
+ pass
17788
+ # Deliberately no wait()/communicate(): a wedged lsof never
17789
+ # exits, and joining it is exactly the hang this guards against.
17790
+ return None
17791
+ _t.sleep(0.05)
17792
+ if proc.stdout is not None:
17793
+ try:
17794
+ out = proc.stdout.read() or b""
17795
+ except OSError:
17796
+ out = b""
17797
+ finally:
17798
+ if proc.stdout is not None:
17799
+ try:
17800
+ proc.stdout.close()
17801
+ except OSError:
17802
+ pass
17803
+ for line in out.decode("utf-8", "replace").splitlines():
17771
17804
  line = line.strip()
17772
17805
  if line.isdigit():
17773
17806
  return int(line)
@@ -2005,6 +2005,30 @@ API_TRUTH: List[Dict[str, Any]] = [
2005
2005
  "when mirroring keep-ranges into clipInfos.",
2006
2006
  "tags": ["timeline", "edit", "off-by-one", "readback"],
2007
2007
  },
2008
+ {
2009
+ "symbol": "Timeline.GetEndFrame (exclusive bound)",
2010
+ "object": "Timeline",
2011
+ "signature": "() -> int",
2012
+ "reality": "GetEndFrame() is an EXCLUSIVE bound, one past the last "
2013
+ "frame: a timeline's frame count is "
2014
+ "GetEndFrame() - GetStartFrame(), not that plus one. "
2015
+ "Measured live on Studio 19.1.3.7 (2026-09-26): a "
2016
+ "timeline whose only full-length item is a 32742-frame "
2017
+ "clip (media-pool 'Frames' 32742, 'End' 32741) reads "
2018
+ "GetStartFrame() 0 / GetEndFrame() 32742, and that item's "
2019
+ "TimelineItem.GetEnd() is also 32742 - the same half-open "
2020
+ "convention as clipInfo endFrame above. Until v4.8.21 two "
2021
+ "readers here (granular get_current_timeline and "
2022
+ "get_project_info's per-timeline duration) added 1 and "
2023
+ "reported 601 frames for a 600-frame timeline while every "
2024
+ "other duration path in the server did not (PR #269).",
2025
+ "recommended": "duration_frames = GetEndFrame() - GetStartFrame(). "
2026
+ "The last frame index is GetEndFrame() - 1. Apply the "
2027
+ "same rule to TimelineItem.GetEnd(): an item occupies "
2028
+ "[GetStart(), GetEnd()).",
2029
+ "tags": ["timeline", "off-by-one", "readback", "duration"],
2030
+ "verified_on": "DaVinci Resolve Studio 19.1.3.7",
2031
+ },
2008
2032
  {
2009
2033
  "symbol": "MediaPool.ImportTimelineFromFile (internal sequence name overrides timelineName)",
2010
2034
  "object": "MediaPool",
@@ -592,7 +592,7 @@ def get_project_info(project_obj) -> Dict[str, Any]:
592
592
  timeline_info = {
593
593
  "name": timeline.GetName(),
594
594
  "isCurrent": timeline.GetName() == current_timeline_name,
595
- "duration": timeline.GetEndFrame() - timeline.GetStartFrame() + 1
595
+ "duration": timeline.GetEndFrame() - timeline.GetStartFrame()
596
596
  }
597
597
  project_info["timelines"].append(timeline_info)
598
598