davinci-resolve-mcp 2.69.3 → 2.70.0
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 +85 -0
- package/README.md +1 -1
- package/docs/SKILL.md +78 -1
- package/docs/guides/headless-edit-loop.md +238 -0
- package/docs/reference/api-limitations.md +65 -4
- package/docs/reference/headless-capability-matrix.md +56 -0
- package/docs/reference/headless-cli.md +751 -0
- package/docs/reference/pixel-equality.md +11 -0
- package/docs/reference/roundtrip-fidelity.md +17 -0
- package/docs/reference/roundtrip-rich-fidelity.md +18 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bisect_headless_hang.py +125 -0
- package/scripts/contact_sheet.py +209 -0
- package/scripts/headless_differential.py +578 -0
- package/scripts/mode_matrix.py +345 -0
- package/scripts/mode_matrix_worker.py +232 -0
- package/scripts/pixel_equality.py +402 -0
- package/scripts/relink_moved_media.py +267 -0
- package/scripts/render_stress.py +542 -0
- package/scripts/resolve_headless.py +225 -0
- package/scripts/roundtrip_matrix.py +434 -0
- package/scripts/roundtrip_rich.py +431 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +92 -67
- package/src/utils/api_truth.py +302 -4
- package/src/utils/headless_differential.py +363 -0
- package/src/utils/mode_matrix.py +338 -0
- package/src/utils/probe_catalogue.py +796 -0
- package/src/utils/project_cleanup.py +61 -9
- package/src/utils/resolve_runtime.py +233 -0
|
@@ -17,6 +17,50 @@ import time
|
|
|
17
17
|
from typing import Any, Dict, Optional
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
#: The name Resolve gives the project it opens when it has nothing else to open.
|
|
21
|
+
#: It has never been saved and has no location, so it cannot be saved.
|
|
22
|
+
UNSAVED_DEFAULT_PROJECT = "Untitled Project"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save_project_if_safe(pm: Any) -> Dict[str, Any]:
|
|
26
|
+
"""`SaveProject()`, unless the current project is the one that hangs on it.
|
|
27
|
+
|
|
28
|
+
`ProjectManager.SaveProject()` cannot succeed on the never-saved default
|
|
29
|
+
`Untitled Project` — no location, and no `SaveProjectAs` to give it one. The
|
|
30
|
+
two modes fail differently, and one of them is fatal:
|
|
31
|
+
|
|
32
|
+
- GUI: returns False.
|
|
33
|
+
- Headless: **blocks forever.** Measured on a cold `-nogui` boot with the
|
|
34
|
+
database verified attached immediately beforehand — no return after 45
|
|
35
|
+
seconds, client parked in `Fusion::RemoteApp::WaitPkt`. Resolve wants a
|
|
36
|
+
Save-As dialog and waits for an answer that cannot arrive. Interrupting
|
|
37
|
+
it degrades the instance further.
|
|
38
|
+
|
|
39
|
+
So the guard is not politeness, it is the difference between a no-op and a
|
|
40
|
+
dead unattended session. Skipping the save costs nothing: there is nothing
|
|
41
|
+
to save on a project that has never had a location.
|
|
42
|
+
|
|
43
|
+
Returns ``{saved, skipped, project, reason}``.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
project = pm.GetCurrentProject()
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
return {"saved": False, "skipped": True, "project": None, "reason": repr(exc)}
|
|
49
|
+
if project is None:
|
|
50
|
+
return {"saved": False, "skipped": True, "project": None, "reason": "no current project"}
|
|
51
|
+
try:
|
|
52
|
+
name = project.GetName()
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
return {"saved": False, "skipped": True, "project": None, "reason": repr(exc)}
|
|
55
|
+
if name == UNSAVED_DEFAULT_PROJECT:
|
|
56
|
+
return {"saved": False, "skipped": True, "project": name,
|
|
57
|
+
"reason": "never-saved default project; SaveProject hangs on it headless"}
|
|
58
|
+
try:
|
|
59
|
+
return {"saved": bool(pm.SaveProject()), "skipped": False, "project": name, "reason": ""}
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
return {"saved": False, "skipped": False, "project": name, "reason": repr(exc)}
|
|
62
|
+
|
|
63
|
+
|
|
20
64
|
def delete_project_safely(
|
|
21
65
|
pm: Any,
|
|
22
66
|
name: str,
|
|
@@ -42,17 +86,25 @@ def delete_project_safely(
|
|
|
42
86
|
except Exception:
|
|
43
87
|
current = None
|
|
44
88
|
if current == name:
|
|
45
|
-
|
|
89
|
+
# CloseProject ALWAYS, and first. It is what releases the session's
|
|
90
|
+
# lock on the project; switching away with LoadProject does not, and
|
|
91
|
+
# DeleteProject then returns False permanently — retries never help.
|
|
92
|
+
# Measured on Studio 19.1.3.7: after a LoadProject-away, six retries
|
|
93
|
+
# a second apart all failed; after a CloseProject the same delete
|
|
94
|
+
# succeeded on the first attempt.
|
|
95
|
+
try:
|
|
96
|
+
project = pm.GetCurrentProject()
|
|
97
|
+
if project is not None:
|
|
98
|
+
pm.CloseProject(project)
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
# Then land on a named project if one was named. CloseProject drops
|
|
102
|
+
# the session onto a fresh, never-saved "Untitled Project" that
|
|
103
|
+
# cannot be saved, so the next close or switch raises a modal no
|
|
104
|
+
# script can dismiss. That is a wedged application, not a tidy-up.
|
|
46
105
|
if switch_to and switch_to != name:
|
|
47
106
|
try:
|
|
48
|
-
|
|
49
|
-
except Exception:
|
|
50
|
-
switched = False
|
|
51
|
-
if not switched:
|
|
52
|
-
try:
|
|
53
|
-
project = pm.GetCurrentProject()
|
|
54
|
-
if project is not None:
|
|
55
|
-
pm.CloseProject(project)
|
|
107
|
+
pm.LoadProject(switch_to)
|
|
56
108
|
except Exception:
|
|
57
109
|
pass
|
|
58
110
|
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Which Resolve is running, and how — GUI, headless, or nothing at all.
|
|
2
|
+
|
|
3
|
+
The scripting API cannot answer this. Measured on Studio 19.1.3.7: a `-nogui`
|
|
4
|
+
instance returns a real page from `GetCurrentPage()`, the same product and
|
|
5
|
+
version strings, and identical results for every UI-shaped call tried —
|
|
6
|
+
`OpenPage` for all seven pages, layout preset save/export/load/delete, Gallery
|
|
7
|
+
handles, Fusion comps. There is no handle to interrogate and no capability to
|
|
8
|
+
sniff. See `docs/reference/headless-cli.md`.
|
|
9
|
+
|
|
10
|
+
So the mode is read from the operating system: the argument vector of the
|
|
11
|
+
running Resolve process. That is the only place `-nogui` is visible.
|
|
12
|
+
|
|
13
|
+
Why an agent needs to know, given that headless turned out to be capability-
|
|
14
|
+
identical:
|
|
15
|
+
|
|
16
|
+
- **Modals.** A GUI Resolve can raise a dialog no script can dismiss, and
|
|
17
|
+
project switching does exactly that when the outgoing project has unsaved
|
|
18
|
+
changes. Headless cannot. An agent that knows it is headless can take the
|
|
19
|
+
fast path; one that knows it is not can take the careful one.
|
|
20
|
+
- **Honest errors.** "Resolve is running but not answering" reads very
|
|
21
|
+
differently when the instance is a headless render worker than when it is
|
|
22
|
+
the editor the user is looking at.
|
|
23
|
+
- **Launching.** Starting a GUI Resolve on a machine whose user is mid-session
|
|
24
|
+
is rude; starting a second instance of any kind is worse. Both need the
|
|
25
|
+
current mode, not just a yes/no on "is it running".
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import platform
|
|
32
|
+
import subprocess
|
|
33
|
+
from typing import Any, Dict, List, Optional
|
|
34
|
+
|
|
35
|
+
#: Process paths that mean a DaVinci Resolve application is running. Not the
|
|
36
|
+
#: full path: the App Store build lives at `/Applications/DaVinci Resolve.app`
|
|
37
|
+
#: and the installer build inside `/Applications/DaVinci Resolve/`, and either
|
|
38
|
+
#: one counts.
|
|
39
|
+
RESOLVE_PROCESS_PATTERNS = (
|
|
40
|
+
"DaVinci Resolve.app/Contents/MacOS/Resolve", # macOS, both editions
|
|
41
|
+
"Resolve.exe", # Windows
|
|
42
|
+
"/opt/resolve/bin/resolve", # Linux
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
#: The documented headless switch. Blackmagic spells it exactly this way on all
|
|
46
|
+
#: three platforms.
|
|
47
|
+
HEADLESS_FLAG = "-nogui"
|
|
48
|
+
|
|
49
|
+
#: Set to 1 to make this server's auto-launch start Resolve without a UI.
|
|
50
|
+
ENV_PREFER_HEADLESS = "DAVINCI_RESOLVE_HEADLESS"
|
|
51
|
+
|
|
52
|
+
#: macOS puts the two editions in different places. Installer/Studio first, to
|
|
53
|
+
#: preserve the behaviour that a machine with both starts Studio.
|
|
54
|
+
MACOS_RESOLVE_APPS = (
|
|
55
|
+
"/Applications/DaVinci Resolve/DaVinci Resolve.app",
|
|
56
|
+
"/Applications/DaVinci Resolve.app",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _process_lines() -> Optional[List[str]]:
|
|
61
|
+
"""Every running command line, or None when that cannot be determined.
|
|
62
|
+
|
|
63
|
+
None rather than an empty list on failure: an unanswerable question must not
|
|
64
|
+
become "nothing is running", which is the answer that leads to launching a
|
|
65
|
+
second instance on top of a live one.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
if platform.system().lower() == "windows":
|
|
69
|
+
# `tasklist` prints no command line, so the flag is invisible there.
|
|
70
|
+
# WMIC does print it and is what makes headless detection possible.
|
|
71
|
+
out = subprocess.run(
|
|
72
|
+
["wmic", "process", "where", "name='Resolve.exe'", "get", "CommandLine"],
|
|
73
|
+
capture_output=True, text=True, timeout=10, check=False,
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
out = subprocess.run(
|
|
77
|
+
["ps", "-Ao", "command="], capture_output=True, text=True, timeout=10, check=False
|
|
78
|
+
)
|
|
79
|
+
if out.returncode != 0 and not out.stdout:
|
|
80
|
+
return None
|
|
81
|
+
return (out.stdout or "").splitlines()
|
|
82
|
+
except Exception: # pragma: no cover - defensive; an unknown answer is None
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_resolve_command(line: str) -> bool:
|
|
87
|
+
"""Is this command line a Resolve *executable*, not merely a mention of one?
|
|
88
|
+
|
|
89
|
+
A plain substring test matches any process whose command line happens to
|
|
90
|
+
contain the path — including a shell running a script that references it.
|
|
91
|
+
Observed: a `zsh -c '... /Applications/DaVinci Resolve/.../Resolve -nogui ...'`
|
|
92
|
+
was counted as a second Resolve instance, which made `instances` wrong and
|
|
93
|
+
made a launch refuse with "a Resolve is running in the other mode".
|
|
94
|
+
|
|
95
|
+
A real Resolve command line is the executable path, optionally followed by
|
|
96
|
+
flags. So strip trailing flag tokens and require what remains to *end* with
|
|
97
|
+
the pattern. That survives the spaces in "DaVinci Resolve.app" (no splitting
|
|
98
|
+
on whitespace) while rejecting a path buried mid-command.
|
|
99
|
+
"""
|
|
100
|
+
text = line.strip()
|
|
101
|
+
while True:
|
|
102
|
+
stripped = text.rstrip()
|
|
103
|
+
cut = stripped.rfind(" -")
|
|
104
|
+
if cut == -1:
|
|
105
|
+
break
|
|
106
|
+
candidate = stripped[:cut].rstrip()
|
|
107
|
+
# Only treat the tail as a flag if dropping it still leaves a plausible
|
|
108
|
+
# path; otherwise a directory named " -something" would be eaten.
|
|
109
|
+
if not candidate:
|
|
110
|
+
break
|
|
111
|
+
text = candidate
|
|
112
|
+
return any(text.endswith(pattern) for pattern in RESOLVE_PROCESS_PATTERNS)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def resolve_processes() -> Optional[List[str]]:
|
|
116
|
+
"""Command lines of running Resolve applications, or None if undeterminable."""
|
|
117
|
+
lines = _process_lines()
|
|
118
|
+
if lines is None:
|
|
119
|
+
return None
|
|
120
|
+
return [line for line in lines if _is_resolve_command(line)]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def runtime_mode() -> Dict[str, Any]:
|
|
124
|
+
"""`{running, headless, instances, command_lines, determinable}`.
|
|
125
|
+
|
|
126
|
+
`headless` is None whenever it cannot be established — nothing running, or
|
|
127
|
+
the process list unavailable. Callers must not read None as False; a wrong
|
|
128
|
+
"it has a UI" is what makes an agent wait for a dialog that will never open.
|
|
129
|
+
"""
|
|
130
|
+
processes = resolve_processes()
|
|
131
|
+
if processes is None:
|
|
132
|
+
return {
|
|
133
|
+
"determinable": False,
|
|
134
|
+
"running": None,
|
|
135
|
+
"headless": None,
|
|
136
|
+
"instances": None,
|
|
137
|
+
"command_lines": [],
|
|
138
|
+
}
|
|
139
|
+
if not processes:
|
|
140
|
+
return {
|
|
141
|
+
"determinable": True,
|
|
142
|
+
"running": False,
|
|
143
|
+
"headless": None,
|
|
144
|
+
"instances": 0,
|
|
145
|
+
"command_lines": [],
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
"determinable": True,
|
|
149
|
+
"running": True,
|
|
150
|
+
# Any headless instance makes the reachable one headless: only one
|
|
151
|
+
# Resolve can hold the singleton, so a second is a conflict to report
|
|
152
|
+
# rather than a mode to average.
|
|
153
|
+
"headless": any(HEADLESS_FLAG in line for line in processes),
|
|
154
|
+
"instances": len(processes),
|
|
155
|
+
"command_lines": processes,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def is_headless() -> Optional[bool]:
|
|
160
|
+
"""True, False, or None when it cannot be determined."""
|
|
161
|
+
return runtime_mode()["headless"]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def prefers_headless(env: Optional[Dict[str, str]] = None) -> bool:
|
|
165
|
+
"""Should an auto-launch start Resolve without a UI?"""
|
|
166
|
+
source = env if env is not None else os.environ
|
|
167
|
+
return str(source.get(ENV_PREFER_HEADLESS, "")).strip().lower() in ("1", "true", "yes", "on")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def launch_command(headless: bool) -> Optional[List[str]]:
|
|
171
|
+
"""The argv that starts Resolve in the requested mode, or None if not found.
|
|
172
|
+
|
|
173
|
+
Headless *must* run the binary inside the bundle. `open -a` hands the
|
|
174
|
+
argument list to LaunchServices, which starts the application normally and
|
|
175
|
+
discards `-nogui` — you get a window and no error, which is the worst
|
|
176
|
+
possible outcome for a batch job that is about to raise a modal nobody will
|
|
177
|
+
see. Verified on macOS.
|
|
178
|
+
"""
|
|
179
|
+
system = platform.system().lower()
|
|
180
|
+
if system == "darwin":
|
|
181
|
+
app = next((p for p in MACOS_RESOLVE_APPS if os.path.exists(p)), None)
|
|
182
|
+
if app is None:
|
|
183
|
+
return None
|
|
184
|
+
if not headless:
|
|
185
|
+
return ["open", app]
|
|
186
|
+
return [os.path.join(app, "Contents", "MacOS", "Resolve"), HEADLESS_FLAG]
|
|
187
|
+
if system == "windows":
|
|
188
|
+
exe = r"C:\Program Files\Blackmagic Design\DaVinci Resolve\Resolve.exe"
|
|
189
|
+
if not os.path.exists(exe):
|
|
190
|
+
return None
|
|
191
|
+
return [exe, HEADLESS_FLAG] if headless else [exe]
|
|
192
|
+
if system == "linux":
|
|
193
|
+
exe = "/opt/resolve/bin/resolve"
|
|
194
|
+
if not os.path.exists(exe):
|
|
195
|
+
return None
|
|
196
|
+
return [exe, HEADLESS_FLAG] if headless else [exe]
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
#: What an agent should do differently in each mode. Returned alongside the mode
|
|
201
|
+
#: so the advice travels with the fact, instead of living in a doc the agent has
|
|
202
|
+
#: to already know to read.
|
|
203
|
+
MODE_GUIDANCE = {
|
|
204
|
+
True: (
|
|
205
|
+
"Headless: NOT modal-immune — it cannot display a dialog but still tries "
|
|
206
|
+
"to raise one, and the call then never returns. Measured: "
|
|
207
|
+
"ProjectManager.SaveProject() on the never-saved 'Untitled Project' "
|
|
208
|
+
"blocks forever headless where the GUI returns False. Guard every save "
|
|
209
|
+
"with project_cleanup.save_project_if_safe. Capability is otherwise "
|
|
210
|
+
"identical to the GUI across 238 probed observations, but that is a "
|
|
211
|
+
"capability result, not a stability one: long renders, JPEG 2000/DCP "
|
|
212
|
+
"decode and ProRes-in-MXF writes are untested, with a field report of "
|
|
213
|
+
"crashes on write completion."
|
|
214
|
+
),
|
|
215
|
+
False: (
|
|
216
|
+
"GUI: a modal dialog can block any call until a human clicks it. Before "
|
|
217
|
+
"LoadProject or CloseProject, save the outgoing project via "
|
|
218
|
+
"project_cleanup.save_project_if_safe — SaveProject() returns False for "
|
|
219
|
+
"the never-saved default 'Untitled Project', which is exactly the project "
|
|
220
|
+
"that raises the prompt. Switching to headless does NOT avoid this; there "
|
|
221
|
+
"the same call blocks forever instead."
|
|
222
|
+
),
|
|
223
|
+
None: (
|
|
224
|
+
"Mode unknown: assume a GUI is present and take the careful path around "
|
|
225
|
+
"project switches."
|
|
226
|
+
),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def describe() -> Dict[str, Any]:
|
|
231
|
+
"""Mode plus the guidance that goes with it."""
|
|
232
|
+
mode = runtime_mode()
|
|
233
|
+
return {**mode, "guidance": MODE_GUIDANCE[mode["headless"]]}
|