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.
@@ -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
- switched = False
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
- switched = bool(pm.LoadProject(switch_to))
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"]]}