davinci-resolve-mcp 2.214.0 → 2.214.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 +62 -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 +56 -21
- package/src/utils/api_truth.py +20 -0
- package/src/utils/resolve_runtime.py +154 -36
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,68 @@
|
|
|
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.214.2 — a running Resolve is counted by its executable path, not only by its argument vector
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`resolve_control runtime_mode` could report `running: false, instances: 0`
|
|
10
|
+
while Resolve was up and answering scripting calls.** Seen on 2026-09-08
|
|
11
|
+
against Studio 19.1.3.7 at the stock macOS path, in the same minute
|
|
12
|
+
`get_version` connected. The scan read only `ps`'s argument-vector column
|
|
13
|
+
and required the line to *end* in the executable after flag stripping, so
|
|
14
|
+
it had a single point of failure that the exact trigger did not need to be
|
|
15
|
+
known to remove: the kernel withholds argv for some processes (`ps` prints
|
|
16
|
+
`(Resolve)`), a launch argument after the path — a project file — is not a
|
|
17
|
+
flag and defeated the suffix test, and `ps` was not asked for wide output.
|
|
18
|
+
The scan now reads two columns keyed by pid: the executable path (`comm`,
|
|
19
|
+
the full path on macOS, readable whenever the process is) decides whether an
|
|
20
|
+
instance exists; the argument vector decides its mode. An instance whose
|
|
21
|
+
argv cannot be read is counted with `headless: null` — never `false`, since
|
|
22
|
+
a wrong "it has a UI" is what makes an agent wait for a dialog that never
|
|
23
|
+
opens, and the tool's callers consult it before every project switch for
|
|
24
|
+
exactly that reason. `ps` is run with `-ww`. The "a shell line that merely
|
|
25
|
+
names the binary is not an instance" rule is kept and extended to the
|
|
26
|
+
unquoted `sh -c /opt/resolve/bin/resolve -nogui` shape.
|
|
27
|
+
- **The exact 2026-09-08 condition was not reproduced.** The same install,
|
|
28
|
+
restarted, matched the old scan. The fix is a removal of the scan's
|
|
29
|
+
dependence on argv parsing, verified against a fake process table built
|
|
30
|
+
from the real `ps` rows of that machine (pid 39560 at the stock path, its
|
|
31
|
+
IOXPC helper beside it), plus the unreadable-argv, positional-argument,
|
|
32
|
+
wide-output and one-column-failing cases.
|
|
33
|
+
|
|
34
|
+
### Validation
|
|
35
|
+
|
|
36
|
+
- Seven new tests in `tests/test_headless_runtime.py` pin those cases; the
|
|
37
|
+
existing 29 pass unchanged against the new scan (their bare-command-line
|
|
38
|
+
fake tables are read as both columns of one process). Live on this machine:
|
|
39
|
+
`running: true, instances: 1, headless: false` at the stock path. Full
|
|
40
|
+
offline suite, drift guards and the advanced Node suite green.
|
|
41
|
+
|
|
42
|
+
## What's New in v2.214.1 — grade calls fail silently off the Color page; apply_trace_plan switches for you
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **`apply_trace_plan` grades on the Color page and restores your page after.**
|
|
47
|
+
Measured on Studio 19.1.3.7 during the second live pass of the conform trace:
|
|
48
|
+
with the GUI on the Edit page, `TimelineItem.AddVersion` and
|
|
49
|
+
`Graph.ApplyGradeFromDRX` returned False for all 12 clips of a batch, with no
|
|
50
|
+
exception and no other signal; `OpenPage("color")` and nothing else, then 12
|
|
51
|
+
of 12 applied. The earlier 254-clip batch had succeeded only because the page
|
|
52
|
+
happened to be Color. The driver now reads `GetCurrentPage()` before the
|
|
53
|
+
batch, switches to Color, puts the page back afterwards, and reports
|
|
54
|
+
`page: {before, switched, restored}`; if the switch fails it refuses with
|
|
55
|
+
`PAGE_SWITCH_FAILED` before touching a clip.
|
|
56
|
+
- **api_truth records the page dependence** ("TimelineItem.AddVersion /
|
|
57
|
+
Graph.ApplyGradeFromDRX (page-dependent)"), so the False can be told apart
|
|
58
|
+
from a bad `.drx` or a locked clip.
|
|
59
|
+
|
|
60
|
+
### Live-validated
|
|
61
|
+
|
|
62
|
+
- The 12 name-only matches from the v2.214.0 run (same files, sections the
|
|
63
|
+
source never graded) applied with `min_confidence: 0.6`: 12 of 12, version
|
|
64
|
+
`traced V07 Sizing` added per clip, node graphs read back with the source's
|
|
65
|
+
tools.
|
|
66
|
+
|
|
5
67
|
## What's New in v2.214.0 — color_trace reads exported .drp files, and the trace is live-validated
|
|
6
68
|
|
|
7
69
|
### Added
|
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.214.
|
|
15
|
+
> 本翻译对应 v2.214.2 版 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.214.
|
|
40
|
+
VERSION = "2.214.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
|
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.214.
|
|
90
|
+
VERSION = "2.214.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
|
@@ -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.214.
|
|
14
|
+
VERSION = "2.214.2"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -27194,28 +27194,62 @@ def _apply_trace_plan(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
27194
27194
|
if blocked:
|
|
27195
27195
|
return blocked
|
|
27196
27196
|
|
|
27197
|
-
|
|
27198
|
-
|
|
27199
|
-
|
|
27200
|
-
|
|
27201
|
-
|
|
27202
|
-
|
|
27197
|
+
# Measured on Studio 19.1.3.7 (2026-09-08): with the GUI on the EDIT page,
|
|
27198
|
+
# TimelineItem.AddVersion and Graph.ApplyGradeFromDRX both return False for
|
|
27199
|
+
# every clip — 12/12 failed, then 12/12 applied after OpenPage("color") with
|
|
27200
|
+
# nothing else changed. Grade the batch on the color page and put the page
|
|
27201
|
+
# back afterwards; refuse up front rather than hand back 254 "returned
|
|
27202
|
+
# False" rows.
|
|
27203
|
+
page: Dict[str, Any] = {"before": None, "switched": False, "restored": None}
|
|
27204
|
+
resolve = get_resolve()
|
|
27205
|
+
try:
|
|
27206
|
+
page["before"] = resolve.GetCurrentPage() if resolve else None
|
|
27207
|
+
except Exception as exc:
|
|
27208
|
+
page["read_error"] = str(exc)
|
|
27209
|
+
if page["before"] and page["before"] != "color":
|
|
27203
27210
|
try:
|
|
27204
|
-
|
|
27205
|
-
# AddVersion switches the item to the NEW version, so the apply
|
|
27206
|
-
# lands there and the previous version stays intact.
|
|
27207
|
-
rec["version_added"] = bool(item.AddVersion(version_name, 0))
|
|
27208
|
-
graph = item.GetNodeGraph()
|
|
27209
|
-
if not _has_method(graph, "ApplyGradeFromDRX"):
|
|
27210
|
-
rec["error"] = "item graph does not expose ApplyGradeFromDRX"
|
|
27211
|
-
else:
|
|
27212
|
-
ok = bool(graph.ApplyGradeFromDRX(r["drx_path"], grade_mode))
|
|
27213
|
-
if not ok:
|
|
27214
|
-
rec["error"] = "ApplyGradeFromDRX returned False"
|
|
27211
|
+
page["switched"] = bool(resolve.OpenPage("color"))
|
|
27215
27212
|
except Exception as exc:
|
|
27216
|
-
|
|
27217
|
-
|
|
27218
|
-
|
|
27213
|
+
page["switch_error"] = str(exc)
|
|
27214
|
+
if not page["switched"]:
|
|
27215
|
+
err = _err(
|
|
27216
|
+
f"Resolve is on the {page['before']!r} page and could not switch to the color page; "
|
|
27217
|
+
"AddVersion and ApplyGradeFromDRX return False from any other page (19.1.3.7).",
|
|
27218
|
+
code="PAGE_SWITCH_FAILED", category="resolve_api_failed", retryable=True,
|
|
27219
|
+
remediation="resolve_control(action='open_page', params={'page': 'color'}) then re-call.",
|
|
27220
|
+
)
|
|
27221
|
+
err["page"] = page
|
|
27222
|
+
return err
|
|
27223
|
+
|
|
27224
|
+
applied: List[Dict[str, Any]] = []
|
|
27225
|
+
failed: List[Dict[str, Any]] = []
|
|
27226
|
+
try:
|
|
27227
|
+
for r in to_apply:
|
|
27228
|
+
item = r["_item"]
|
|
27229
|
+
rec = {k: v for k, v in r.items() if not k.startswith("_")}
|
|
27230
|
+
ok = False
|
|
27231
|
+
try:
|
|
27232
|
+
if version_name:
|
|
27233
|
+
# AddVersion switches the item to the NEW version, so the apply
|
|
27234
|
+
# lands there and the previous version stays intact.
|
|
27235
|
+
rec["version_added"] = bool(item.AddVersion(version_name, 0))
|
|
27236
|
+
graph = item.GetNodeGraph()
|
|
27237
|
+
if not _has_method(graph, "ApplyGradeFromDRX"):
|
|
27238
|
+
rec["error"] = "item graph does not expose ApplyGradeFromDRX"
|
|
27239
|
+
else:
|
|
27240
|
+
ok = bool(graph.ApplyGradeFromDRX(r["drx_path"], grade_mode))
|
|
27241
|
+
if not ok:
|
|
27242
|
+
rec["error"] = "ApplyGradeFromDRX returned False"
|
|
27243
|
+
except Exception as exc:
|
|
27244
|
+
rec["error"] = str(exc)
|
|
27245
|
+
rec["status"] = "applied" if ok else "failed"
|
|
27246
|
+
(applied if ok else failed).append(rec)
|
|
27247
|
+
finally:
|
|
27248
|
+
if page["switched"]:
|
|
27249
|
+
try:
|
|
27250
|
+
page["restored"] = bool(resolve.OpenPage(page["before"]))
|
|
27251
|
+
except Exception as exc:
|
|
27252
|
+
page["restore_error"] = str(exc)
|
|
27219
27253
|
summary.update({"applied": len(applied), "failed": len(failed)})
|
|
27220
27254
|
skipped = [r for r in public if r["status"] != "apply"]
|
|
27221
27255
|
report_path = _trace_report(p, plan, "apply", {"summary": summary, "applied": applied, "failed": failed, "skipped": skipped})
|
|
@@ -27224,6 +27258,7 @@ def _apply_trace_plan(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
27224
27258
|
"timeline": live_name,
|
|
27225
27259
|
"plan_source": plan.get("source"),
|
|
27226
27260
|
"report_path": report_path,
|
|
27261
|
+
"page": page,
|
|
27227
27262
|
"applied": _trace_compact(applied, max_rows),
|
|
27228
27263
|
"failed": failed, # never truncated: every failure is actionable
|
|
27229
27264
|
"skipped": _trace_compact(skipped, max_rows),
|
package/src/utils/api_truth.py
CHANGED
|
@@ -1916,6 +1916,26 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
1916
1916
|
"pass-through.",
|
|
1917
1917
|
"tags": ["silent-failure", "color", "node-graph", "layout", "drx"],
|
|
1918
1918
|
},
|
|
1919
|
+
{
|
|
1920
|
+
"symbol": "TimelineItem.AddVersion / Graph.ApplyGradeFromDRX (page-dependent)",
|
|
1921
|
+
"object": "TimelineItem / Graph",
|
|
1922
|
+
"signature": "AddVersion(name, type) -> bool; ApplyGradeFromDRX(path, gradeMode) -> bool",
|
|
1923
|
+
"reality": "Both return False for every clip while the GUI sits on the "
|
|
1924
|
+
"EDIT page, with no exception and no other signal. Measured on "
|
|
1925
|
+
"Studio 19.1.3.7 (2026-09-08) on a live conform: 12 of 12 "
|
|
1926
|
+
"clips failed from the edit page, then 12 of 12 applied after "
|
|
1927
|
+
"OpenPage('color') with nothing else changed; an earlier "
|
|
1928
|
+
"254-clip batch on the same timeline had succeeded while the "
|
|
1929
|
+
"page was color. Read-side calls (GetNodeGraph, GetNumNodes, "
|
|
1930
|
+
"GetVersionNameList) answer normally from the edit page, so "
|
|
1931
|
+
"the False cannot be told apart from a bad .drx or a locked "
|
|
1932
|
+
"clip without checking the page.",
|
|
1933
|
+
"recommended": "Read GetCurrentPage() before any grade mutation; OpenPage"
|
|
1934
|
+
"('color') for the batch and restore the page afterwards. "
|
|
1935
|
+
"timeline_item_color.apply_trace_plan does this itself and "
|
|
1936
|
+
"reports {page: {before, switched, restored}}.",
|
|
1937
|
+
"tags": ["silent-failure", "color", "grade", "page", "drx", "version"],
|
|
1938
|
+
},
|
|
1919
1939
|
{
|
|
1920
1940
|
"symbol": "MediaPool.AppendToTimeline clipInfo endFrame (exclusive bound)",
|
|
1921
1941
|
"object": "MediaPool",
|
|
@@ -29,6 +29,7 @@ from __future__ import annotations
|
|
|
29
29
|
|
|
30
30
|
import os
|
|
31
31
|
import platform
|
|
32
|
+
import re
|
|
32
33
|
import subprocess
|
|
33
34
|
from typing import Any, Dict, List, Optional
|
|
34
35
|
|
|
@@ -57,40 +58,103 @@ MACOS_RESOLVE_APPS = (
|
|
|
57
58
|
)
|
|
58
59
|
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
_PID_PREFIX = re.compile(r"^\s*(\d+)\s+(.*)$")
|
|
62
|
+
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
def _run_ps(columns: str) -> Optional[List[str]]:
|
|
65
|
+
"""`ps -Awwo <columns>` as lines, or None when it cannot be run.
|
|
66
|
+
|
|
67
|
+
`-ww` on purpose: without it BSD ps may cut long command lines to the
|
|
68
|
+
terminal width, and a cut line no longer ends in the executable. Not
|
|
69
|
+
reproduced here (the Resolve path is 70 characters), but the second-
|
|
70
|
+
instance guard should not depend on where a launch argument happens to
|
|
71
|
+
fall relative to a column limit.
|
|
66
72
|
"""
|
|
67
73
|
try:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
out = subprocess.run(
|
|
75
|
+
["ps", "-Awwo", columns], capture_output=True, text=True,
|
|
76
|
+
encoding="utf-8", errors="replace", timeout=10, check=False,
|
|
77
|
+
)
|
|
78
|
+
except Exception: # pragma: no cover - defensive; an unknown answer is None
|
|
79
|
+
return None
|
|
80
|
+
if out.returncode != 0 and not out.stdout:
|
|
81
|
+
return None
|
|
82
|
+
return (out.stdout or "").splitlines()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _split_pid(line: str, index: int):
|
|
86
|
+
"""(pid, field) for a `pid=,<col>=` row; a row with no pid gets a synthetic one.
|
|
87
|
+
|
|
88
|
+
The synthetic pid is the row index, so two column listings of the same
|
|
89
|
+
length join row-by-row. That is what keeps a fake process table written as
|
|
90
|
+
bare command lines (the shape every existing test uses) meaningful: it is
|
|
91
|
+
read as both the executable column and the argv column of one process.
|
|
92
|
+
"""
|
|
93
|
+
match = _PID_PREFIX.match(line)
|
|
94
|
+
if match:
|
|
95
|
+
return int(match.group(1)), match.group(2)
|
|
96
|
+
return -(index + 1), line
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _process_table() -> Optional[List[Dict[str, Optional[str]]]]:
|
|
100
|
+
"""One row per process: `{pid, comm, args}`, or None when undeterminable.
|
|
101
|
+
|
|
102
|
+
Two columns because they fail independently. `args` is the argument
|
|
103
|
+
vector, the only place `-nogui` is visible — but the kernel refuses to
|
|
104
|
+
expose it for some processes (ps prints `(Resolve)` in parentheses) and a
|
|
105
|
+
launch argument after the path breaks a suffix match on it. `comm` is the
|
|
106
|
+
executable path as the kernel knows it — on macOS the full path — and it
|
|
107
|
+
is readable whenever the process is. An instance is counted on EITHER;
|
|
108
|
+
the mode is read from argv when argv is readable.
|
|
109
|
+
|
|
110
|
+
None rather than an empty list on failure: an unanswerable question must
|
|
111
|
+
not become "nothing is running", which is the answer that leads to
|
|
112
|
+
launching a second instance on top of a live one.
|
|
113
|
+
"""
|
|
114
|
+
if platform.system().lower() == "windows":
|
|
115
|
+
# `tasklist` prints no command line, so the flag is invisible there.
|
|
116
|
+
# WMIC does print it and is what makes headless detection possible.
|
|
117
|
+
#
|
|
118
|
+
# Decoded explicitly: `text=True` alone decodes with the locale
|
|
119
|
+
# codepage, which raises UnicodeDecodeError on a byte cp1252 has no
|
|
120
|
+
# mapping for — and this read is the input to the second-instance
|
|
121
|
+
# guard, so it must fail to "cannot tell", never to an exception.
|
|
122
|
+
# ASCII is byte-identical under both codecs, so the matching this
|
|
123
|
+
# feeds is unchanged; what WMIC emits for a non-ASCII install path
|
|
124
|
+
# on a non-English Windows is not something we can verify here.
|
|
125
|
+
try:
|
|
79
126
|
out = subprocess.run(
|
|
80
127
|
["wmic", "process", "where", "name='Resolve.exe'", "get", "CommandLine"],
|
|
81
128
|
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
82
129
|
timeout=10, check=False,
|
|
83
130
|
)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
["ps", "-Ao", "command="], capture_output=True, text=True,
|
|
87
|
-
encoding="utf-8", errors="replace", timeout=10, check=False,
|
|
88
|
-
)
|
|
131
|
+
except Exception: # pragma: no cover - defensive; an unknown answer is None
|
|
132
|
+
return None
|
|
89
133
|
if out.returncode != 0 and not out.stdout:
|
|
90
134
|
return None
|
|
91
|
-
return (
|
|
92
|
-
|
|
135
|
+
return [{"pid": -(i + 1), "comm": None, "args": line}
|
|
136
|
+
for i, line in enumerate((out.stdout or "").splitlines())]
|
|
137
|
+
|
|
138
|
+
comm_lines = _run_ps("pid=,comm=")
|
|
139
|
+
args_lines = _run_ps("pid=,args=")
|
|
140
|
+
if comm_lines is None and args_lines is None:
|
|
93
141
|
return None
|
|
142
|
+
rows: Dict[int, Dict[str, Optional[str]]] = {}
|
|
143
|
+
for index, line in enumerate(comm_lines or []):
|
|
144
|
+
pid, comm = _split_pid(line, index)
|
|
145
|
+
rows.setdefault(pid, {"pid": pid, "comm": None, "args": None})["comm"] = comm
|
|
146
|
+
for index, line in enumerate(args_lines or []):
|
|
147
|
+
pid, args = _split_pid(line, index)
|
|
148
|
+
rows.setdefault(pid, {"pid": pid, "comm": None, "args": None})["args"] = args
|
|
149
|
+
return list(rows.values())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _process_lines() -> Optional[List[str]]:
|
|
153
|
+
"""Argument vectors of every process, kept for callers that read only argv."""
|
|
154
|
+
table = _process_table()
|
|
155
|
+
if table is None:
|
|
156
|
+
return None
|
|
157
|
+
return [row["args"] for row in table if row["args"] is not None]
|
|
94
158
|
|
|
95
159
|
|
|
96
160
|
def _matches_pattern(executable: str) -> bool:
|
|
@@ -137,6 +201,22 @@ def _executable_from_line(line: str) -> str:
|
|
|
137
201
|
close = text.find('"', 1)
|
|
138
202
|
if close > 1:
|
|
139
203
|
return text[1:close]
|
|
204
|
+
# A launch ARGUMENT after the path — a project file, most likely — is not a
|
|
205
|
+
# flag, so the flag-stripping loop below leaves it attached and the suffix
|
|
206
|
+
# test fails. If the line STARTS with a path that ends in a Resolve pattern
|
|
207
|
+
# at a token boundary, that path is the executable, whatever follows it.
|
|
208
|
+
# The prefix must contain no quote (a launcher quoting the path) and no
|
|
209
|
+
# flag token (`/bin/sh -c /opt/resolve/bin/resolve` names Resolve without
|
|
210
|
+
# being it), which keeps the "mere mention" cases out.
|
|
211
|
+
for pattern in RESOLVE_PROCESS_PATTERNS:
|
|
212
|
+
cut = text.find(pattern)
|
|
213
|
+
while cut != -1:
|
|
214
|
+
end = cut + len(pattern)
|
|
215
|
+
prefix = text[:end]
|
|
216
|
+
at_boundary = end == len(text) or text[end].isspace()
|
|
217
|
+
if at_boundary and '"' not in prefix and " -" not in prefix:
|
|
218
|
+
return prefix
|
|
219
|
+
cut = text.find(pattern, cut + 1)
|
|
140
220
|
while True:
|
|
141
221
|
stripped = text.rstrip()
|
|
142
222
|
cut = stripped.rfind(" -")
|
|
@@ -151,12 +231,42 @@ def _executable_from_line(line: str) -> str:
|
|
|
151
231
|
return text
|
|
152
232
|
|
|
153
233
|
|
|
234
|
+
def _argv_unreadable(args: Optional[str]) -> bool:
|
|
235
|
+
"""ps prints `(name)` when the kernel will not hand over the argument vector."""
|
|
236
|
+
if args is None:
|
|
237
|
+
return True
|
|
238
|
+
text = args.strip()
|
|
239
|
+
return text.startswith("(") and text.endswith(")")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _resolve_rows() -> Optional[List[Dict[str, Optional[str]]]]:
|
|
243
|
+
"""Process-table rows that are a running Resolve application."""
|
|
244
|
+
table = _process_table()
|
|
245
|
+
if table is None:
|
|
246
|
+
return None
|
|
247
|
+
matched = []
|
|
248
|
+
for row in table:
|
|
249
|
+
args = row.get("args")
|
|
250
|
+
comm = row.get("comm")
|
|
251
|
+
by_args = args is not None and not _argv_unreadable(args) and _is_resolve_command(args)
|
|
252
|
+
by_comm = comm is not None and _matches_pattern(comm.strip())
|
|
253
|
+
if by_args or by_comm:
|
|
254
|
+
matched.append(row)
|
|
255
|
+
return matched
|
|
256
|
+
|
|
257
|
+
|
|
154
258
|
def resolve_processes() -> Optional[List[str]]:
|
|
155
|
-
"""Command lines of running Resolve applications, or None if undeterminable.
|
|
156
|
-
|
|
157
|
-
|
|
259
|
+
"""Command lines of running Resolve applications, or None if undeterminable.
|
|
260
|
+
|
|
261
|
+
A row whose argv is unreadable reports its executable path instead, so a
|
|
262
|
+
caller still sees WHICH Resolve is up even when it cannot see how it was
|
|
263
|
+
started.
|
|
264
|
+
"""
|
|
265
|
+
rows = _resolve_rows()
|
|
266
|
+
if rows is None:
|
|
158
267
|
return None
|
|
159
|
-
return [
|
|
268
|
+
return [row["args"] if not _argv_unreadable(row.get("args")) else (row.get("comm") or "")
|
|
269
|
+
for row in rows]
|
|
160
270
|
|
|
161
271
|
|
|
162
272
|
#: Where the scripting library sits relative to the Resolve executable. The
|
|
@@ -212,8 +322,8 @@ def runtime_mode() -> Dict[str, Any]:
|
|
|
212
322
|
the process list unavailable. Callers must not read None as False; a wrong
|
|
213
323
|
"it has a UI" is what makes an agent wait for a dialog that will never open.
|
|
214
324
|
"""
|
|
215
|
-
|
|
216
|
-
if
|
|
325
|
+
rows = _resolve_rows()
|
|
326
|
+
if rows is None:
|
|
217
327
|
return {
|
|
218
328
|
"determinable": False,
|
|
219
329
|
"running": None,
|
|
@@ -221,7 +331,7 @@ def runtime_mode() -> Dict[str, Any]:
|
|
|
221
331
|
"instances": None,
|
|
222
332
|
"command_lines": [],
|
|
223
333
|
}
|
|
224
|
-
if not
|
|
334
|
+
if not rows:
|
|
225
335
|
return {
|
|
226
336
|
"determinable": True,
|
|
227
337
|
"running": False,
|
|
@@ -229,15 +339,23 @@ def runtime_mode() -> Dict[str, Any]:
|
|
|
229
339
|
"instances": 0,
|
|
230
340
|
"command_lines": [],
|
|
231
341
|
}
|
|
342
|
+
readable = [row["args"] for row in rows if not _argv_unreadable(row.get("args"))]
|
|
343
|
+
# Any headless instance makes the reachable one headless: only one Resolve
|
|
344
|
+
# can hold the singleton, so a second is a conflict to report rather than
|
|
345
|
+
# a mode to average. An instance counted on its executable path alone has
|
|
346
|
+
# an argv this process cannot read, so unless another instance shows the
|
|
347
|
+
# flag the mode is UNKNOWN — None, never False: a wrong "it has a UI" is
|
|
348
|
+
# what makes an agent wait for a dialog that will never open.
|
|
349
|
+
headless: Optional[bool] = any(HEADLESS_FLAG in line for line in readable)
|
|
350
|
+
if not headless and len(readable) < len(rows):
|
|
351
|
+
headless = None
|
|
232
352
|
return {
|
|
233
353
|
"determinable": True,
|
|
234
354
|
"running": True,
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
"instances": len(processes),
|
|
240
|
-
"command_lines": processes,
|
|
355
|
+
"headless": headless,
|
|
356
|
+
"instances": len(rows),
|
|
357
|
+
"command_lines": [row["args"] if not _argv_unreadable(row.get("args"))
|
|
358
|
+
else (row.get("comm") or "") for row in rows],
|
|
241
359
|
}
|
|
242
360
|
|
|
243
361
|
|