davinci-resolve-mcp 2.67.1 → 2.68.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 +99 -0
- package/README.md +27 -2
- package/docs/SKILL.md +20 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bridge_differential.py +431 -0
- package/scripts/install_resolve_bridge.py +260 -0
- package/scripts/resolve_bridge_launcher.py +202 -0
- package/scripts/resolve_bridge_probe.py +162 -0
- package/scripts/resolve_capability_probe.py +270 -0
- package/src/granular/common.py +6 -1
- package/src/server.py +193 -6
- package/src/utils/bridge_differential.py +375 -0
- package/src/utils/captions.py +323 -0
- package/src/utils/colorimetry.py +196 -0
- package/src/utils/delivery_targets.py +225 -0
- package/src/utils/edit_engine.py +181 -7
- package/src/utils/image_qc.py +499 -0
- package/src/utils/page_lock.py +5 -1
- package/src/utils/resolve_bridge.py +557 -0
- package/src/utils/resolve_bridge_client.py +423 -0
- package/src/utils/resolve_bridge_ops.py +714 -0
- package/src/utils/resolve_connection.py +29 -1
- package/src/utils/silence_ripple.py +256 -8
- package/src/utils/strata_analyzers.py +7 -0
- package/src/utils/strata_faces.py +6 -0
- package/src/utils/transcript_edit.py +372 -0
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
"""The named operation surface the in-app bridge exposes. Stdlib-only.
|
|
2
|
+
|
|
3
|
+
`resolve_bridge` is transport: authentication, framing, lifecycle. This is what
|
|
4
|
+
it is allowed to *do*. The split matters because the transport is generic and the
|
|
5
|
+
surface is the actual security boundary.
|
|
6
|
+
|
|
7
|
+
## Named operations, not a method-name proxy
|
|
8
|
+
|
|
9
|
+
The tempting design is one generic `call(object, method, args)` with a
|
|
10
|
+
method-name prefix allowlist. It costs nothing to write and covers the whole API.
|
|
11
|
+
It is also not a security boundary: `startswith("Delete")` admits
|
|
12
|
+
`DeleteProject`, and prefix-matched `Import`/`Export` admit arbitrary filesystem
|
|
13
|
+
paths. Anything that reaches the socket then has the run of the project.
|
|
14
|
+
|
|
15
|
+
So operations are named, arguments are validated, and paths are checked against
|
|
16
|
+
configured roots. Adding an operation is a deliberate act.
|
|
17
|
+
|
|
18
|
+
## The generic `call` operation, and its actual security model
|
|
19
|
+
|
|
20
|
+
`call` proxies one method invocation onto a live Resolve object, which is what
|
|
21
|
+
lets the MCP server's existing call sites work over the bridge unchanged. It is
|
|
22
|
+
deliberately NOT gated by a method-name allowlist — that was never a security
|
|
23
|
+
boundary anyway (`startswith("Delete")` admits `DeleteProject`).
|
|
24
|
+
|
|
25
|
+
The honest model: **`call` is gated by the token, and that is stronger than the
|
|
26
|
+
baseline it replaces.** Resolve's own external scripting API has no
|
|
27
|
+
authentication at all — any local process that can load `fusionscript` gets
|
|
28
|
+
unrestricted control of the running application. This bridge adds HMAC-signed
|
|
29
|
+
requests, one-use nonces, a freshness window, loopback-only binding and
|
|
30
|
+
pre-auth bounds on top of the same capability. Refusing `call` on security
|
|
31
|
+
grounds while native scripting sits wide open would be theatre.
|
|
32
|
+
|
|
33
|
+
What `call` genuinely constrains:
|
|
34
|
+
|
|
35
|
+
- **Only objects the API itself returned.** Targets are either one of the named
|
|
36
|
+
roots or a handle this bridge minted from a Resolve return value. A caller can
|
|
37
|
+
never name an arbitrary Python object, reach a module, or touch `__globals__`.
|
|
38
|
+
- **Dunder and private names are refused**, so `__class__`/`__globals__`
|
|
39
|
+
traversal is closed.
|
|
40
|
+
- **The handle table is bounded and session-scoped.** An unbounded table is a
|
|
41
|
+
slow memory leak inside Resolve — every enumerated timeline item pinning a
|
|
42
|
+
live proxy object forever.
|
|
43
|
+
|
|
44
|
+
The named operations below still exist and remain the stricter surface. Use them
|
|
45
|
+
when a caller only needs the common path; use `call` when the existing code has
|
|
46
|
+
to work unmodified.
|
|
47
|
+
|
|
48
|
+
## Reads may be broad; writes are narrow
|
|
49
|
+
|
|
50
|
+
Reading cannot damage a project, so the read surface covers what an agent needs
|
|
51
|
+
to orient. Writes are deliberately few here: this bridge exists to reach the free
|
|
52
|
+
edition, and the destructive surface belongs behind the main server's existing
|
|
53
|
+
confirm-token and versioning discipline, not duplicated inside Resolve where
|
|
54
|
+
neither is available.
|
|
55
|
+
|
|
56
|
+
**This is a subset, and says so.** `health` reports exactly which operations
|
|
57
|
+
exist, so a caller discovers the surface rather than assuming parity with the
|
|
58
|
+
direct transport.
|
|
59
|
+
|
|
60
|
+
## Lifecycle operations act on the bridge, not the project
|
|
61
|
+
|
|
62
|
+
`shutdown` and `reload` stop the listener. They exist because the launcher blocks
|
|
63
|
+
— the script that started the bridge cannot be re-run while it is alive, and the
|
|
64
|
+
runtime inside Resolve's Scripts folder is a copy taken at install time, so a
|
|
65
|
+
repository change strands a stale bridge with no way back short of quitting
|
|
66
|
+
Resolve. `reload` re-imports from disk in place and needs no UI interaction.
|
|
67
|
+
|
|
68
|
+
They carry no gate beyond the token, deliberately: a caller holding it can
|
|
69
|
+
already drive Resolve through `call`, so refusing them would guard nothing while
|
|
70
|
+
removing the only remote recovery path.
|
|
71
|
+
|
|
72
|
+
## False is an error
|
|
73
|
+
|
|
74
|
+
Resolve's API returns False or None for failure far more often than it raises.
|
|
75
|
+
`_call(..., false_is_error=True)` turns that into a real failure instead of a
|
|
76
|
+
silent success — the single most common way these connectors lie.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
from __future__ import annotations
|
|
80
|
+
|
|
81
|
+
import os
|
|
82
|
+
from collections import OrderedDict
|
|
83
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
84
|
+
|
|
85
|
+
PROTOCOL_SURFACE_VERSION = "1.0"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class OperationError(RuntimeError):
|
|
89
|
+
"""A refused or failed operation, carrying a stable code."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, code: str, message: str, **details: Any) -> None:
|
|
92
|
+
super().__init__(message)
|
|
93
|
+
self.code = code
|
|
94
|
+
self.message = message
|
|
95
|
+
self.details = details
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── path policy ──────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PathPolicy:
|
|
102
|
+
"""Confine filesystem arguments to configured roots, after symlink resolution.
|
|
103
|
+
|
|
104
|
+
Resolution happens before the check, so a symlink pointing out of an allowed
|
|
105
|
+
root is rejected rather than followed.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, media_roots: List[str], output_roots: List[str]) -> None:
|
|
109
|
+
self.media_roots = self._normalize(media_roots, "media")
|
|
110
|
+
self.output_roots = self._normalize(output_roots, "output")
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _normalize(roots: Any, kind: str) -> Tuple[str, ...]:
|
|
114
|
+
if not isinstance(roots, list) or not roots:
|
|
115
|
+
raise OperationError("policy_invalid", f"no {kind} roots configured")
|
|
116
|
+
resolved = []
|
|
117
|
+
for raw in roots:
|
|
118
|
+
if not isinstance(raw, str) or not raw:
|
|
119
|
+
raise OperationError("policy_invalid", f"invalid {kind} root")
|
|
120
|
+
resolved.append(os.path.realpath(os.path.expanduser(raw)))
|
|
121
|
+
return tuple(dict.fromkeys(resolved))
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _under(path: str, roots: Tuple[str, ...]) -> bool:
|
|
125
|
+
return any(path == root or path.startswith(root + os.sep) for root in roots)
|
|
126
|
+
|
|
127
|
+
def input_file(self, raw: Any) -> str:
|
|
128
|
+
if not isinstance(raw, str) or not raw:
|
|
129
|
+
raise OperationError("invalid_path", "input path must be a non-empty string")
|
|
130
|
+
expanded = os.path.expanduser(raw)
|
|
131
|
+
if not os.path.isabs(expanded):
|
|
132
|
+
raise OperationError("invalid_path", "input path must be absolute")
|
|
133
|
+
path = os.path.realpath(expanded)
|
|
134
|
+
if not os.path.isfile(path):
|
|
135
|
+
raise OperationError("input_not_found", "input file does not exist", path=raw)
|
|
136
|
+
if not self._under(path, self.media_roots):
|
|
137
|
+
raise OperationError("path_not_allowed", "input path is outside the configured media roots")
|
|
138
|
+
return path
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ── the surface ──────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ResolveOperations:
|
|
145
|
+
"""Explicit router over Resolve's native proxy objects."""
|
|
146
|
+
|
|
147
|
+
#: Read-only: cannot damage a project, so the surface is generous.
|
|
148
|
+
READ_OPERATIONS = (
|
|
149
|
+
"health",
|
|
150
|
+
"list_projects",
|
|
151
|
+
"get_project",
|
|
152
|
+
"list_timelines",
|
|
153
|
+
"get_timeline",
|
|
154
|
+
"list_media",
|
|
155
|
+
"get_render_formats",
|
|
156
|
+
)
|
|
157
|
+
#: Writes: deliberately narrow. Destructive work stays behind the main
|
|
158
|
+
#: server's confirm-token + versioning discipline, which does not exist here.
|
|
159
|
+
WRITE_OPERATIONS = (
|
|
160
|
+
"save_project",
|
|
161
|
+
"set_current_timeline",
|
|
162
|
+
)
|
|
163
|
+
#: The transparent proxy. See the module docstring for its security model.
|
|
164
|
+
PROXY_OPERATIONS = ("call", "release_handles", "list_methods", "get_attribute")
|
|
165
|
+
#: Lifecycle: they act on the bridge, never on the project. Separated from the
|
|
166
|
+
#: write surface so "writes stay narrower than reads" keeps measuring what it
|
|
167
|
+
#: was written to measure.
|
|
168
|
+
LIFECYCLE_OPERATIONS = ("shutdown", "reload")
|
|
169
|
+
OPERATIONS = READ_OPERATIONS + WRITE_OPERATIONS + PROXY_OPERATIONS + LIFECYCLE_OPERATIONS
|
|
170
|
+
|
|
171
|
+
#: Handle-table ceiling. Every entry pins a live Resolve proxy object, so an
|
|
172
|
+
#: unbounded table is a memory leak inside Resolve that grows with every
|
|
173
|
+
#: timeline item anyone enumerates. Oldest entries are evicted; a caller that
|
|
174
|
+
#: loses a handle gets a clear `stale_handle` error and can re-fetch.
|
|
175
|
+
MAX_HANDLES = 4096
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self,
|
|
179
|
+
resolve: Any,
|
|
180
|
+
*,
|
|
181
|
+
media_roots: List[str],
|
|
182
|
+
output_roots: List[str],
|
|
183
|
+
max_items: int = 500,
|
|
184
|
+
lifecycle: Optional[Callable[[str], Dict[str, Any]]] = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
if resolve is None:
|
|
187
|
+
raise OperationError("resolve_unavailable", "Resolve object is not available")
|
|
188
|
+
self.resolve = resolve
|
|
189
|
+
# Supplied by whatever owns the listener (the launcher). Absent means the
|
|
190
|
+
# lifecycle operations report `capability_unavailable` rather than
|
|
191
|
+
# pretending to stop something they have no handle on.
|
|
192
|
+
self._lifecycle = lifecycle
|
|
193
|
+
self.policy = PathPolicy(media_roots, output_roots)
|
|
194
|
+
self.max_items = max(1, min(int(max_items), 5000))
|
|
195
|
+
self._routes: Dict[str, Callable[[Dict[str, Any]], Any]] = {
|
|
196
|
+
name: getattr(self, f"op_{name}") for name in self.OPERATIONS
|
|
197
|
+
}
|
|
198
|
+
# Handles are session-scoped: a new ResolveOperations means new ids, so a
|
|
199
|
+
# stale handle from a previous bridge run can never silently resolve to a
|
|
200
|
+
# different object.
|
|
201
|
+
self._handles: "OrderedDict[str, Any]" = OrderedDict()
|
|
202
|
+
self._handle_counter = 0
|
|
203
|
+
self._session = os.urandom(4).hex()
|
|
204
|
+
|
|
205
|
+
# -- plumbing ---------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def dispatch(self, operation: str, arguments: Optional[Dict[str, Any]] = None) -> Any:
|
|
208
|
+
route = self._routes.get(operation)
|
|
209
|
+
if route is None:
|
|
210
|
+
raise OperationError(
|
|
211
|
+
"operation_not_allowed",
|
|
212
|
+
f"operation {operation!r} is not exposed by this bridge",
|
|
213
|
+
available=sorted(self.OPERATIONS),
|
|
214
|
+
)
|
|
215
|
+
if arguments is None:
|
|
216
|
+
arguments = {}
|
|
217
|
+
if not isinstance(arguments, dict):
|
|
218
|
+
raise OperationError("invalid_arguments", "arguments must be an object")
|
|
219
|
+
return route(arguments)
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _call(obj: Any, method: str, *args: Any, false_is_error: bool = False) -> Any:
|
|
223
|
+
fn = getattr(obj, method, None)
|
|
224
|
+
if not callable(fn):
|
|
225
|
+
raise OperationError("capability_unavailable", f"Resolve does not expose {method} in this build")
|
|
226
|
+
value = fn(*args)
|
|
227
|
+
if false_is_error and (value is False or value is None):
|
|
228
|
+
raise OperationError("operation_failed", f"Resolve returned failure from {method}")
|
|
229
|
+
return value
|
|
230
|
+
|
|
231
|
+
def _project_manager(self) -> Any:
|
|
232
|
+
manager = self._call(self.resolve, "GetProjectManager")
|
|
233
|
+
if manager is None:
|
|
234
|
+
raise OperationError("resolve_not_ready", "Resolve has no project manager yet")
|
|
235
|
+
return manager
|
|
236
|
+
|
|
237
|
+
def _project(self) -> Any:
|
|
238
|
+
project = self._call(self._project_manager(), "GetCurrentProject")
|
|
239
|
+
if project is None:
|
|
240
|
+
raise OperationError("no_project", "no Resolve project is currently open")
|
|
241
|
+
return project
|
|
242
|
+
|
|
243
|
+
def _timeline(self, arguments: Dict[str, Any]) -> Any:
|
|
244
|
+
project = self._project()
|
|
245
|
+
name = arguments.get("timeline_name")
|
|
246
|
+
if not name:
|
|
247
|
+
timeline = self._call(project, "GetCurrentTimeline")
|
|
248
|
+
if timeline is None:
|
|
249
|
+
raise OperationError("no_timeline", "no current timeline is selected")
|
|
250
|
+
return timeline
|
|
251
|
+
matches = []
|
|
252
|
+
for index in range(1, int(self._call(project, "GetTimelineCount") or 0) + 1):
|
|
253
|
+
candidate = self._call(project, "GetTimelineByIndex", index)
|
|
254
|
+
if candidate is not None and self._call(candidate, "GetName") == name:
|
|
255
|
+
matches.append(candidate)
|
|
256
|
+
if not matches:
|
|
257
|
+
raise OperationError("not_found", f"timeline {name!r} was not found")
|
|
258
|
+
if len(matches) > 1:
|
|
259
|
+
# Resolve permits duplicate timeline names; acting on an arbitrary
|
|
260
|
+
# one of them is exactly the kind of silent wrong-target this refuses.
|
|
261
|
+
raise OperationError("ambiguous_locator", f"more than one timeline is named {name!r}")
|
|
262
|
+
return matches[0]
|
|
263
|
+
|
|
264
|
+
# -- reads ------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def op_health(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
267
|
+
manager = self._project_manager()
|
|
268
|
+
project = self._call(manager, "GetCurrentProject")
|
|
269
|
+
product = str(self._call(self.resolve, "GetProductName") or "")
|
|
270
|
+
return {
|
|
271
|
+
"connected": True,
|
|
272
|
+
"product": product,
|
|
273
|
+
"version": self._call(self.resolve, "GetVersionString"),
|
|
274
|
+
"edition": "studio" if "studio" in product.lower() else "free",
|
|
275
|
+
"current_page": self._call(self.resolve, "GetCurrentPage"),
|
|
276
|
+
"current_project": self._call(project, "GetName") if project is not None else None,
|
|
277
|
+
"surface_version": PROTOCOL_SURFACE_VERSION,
|
|
278
|
+
# Handles are scoped to this id. A client that sees it change knows
|
|
279
|
+
# its handles are gone — which is how `reload` is waited on, since
|
|
280
|
+
# the bridge comes back on the same port with a new object graph.
|
|
281
|
+
"session": self._session,
|
|
282
|
+
# The caller discovers the surface rather than assuming parity with
|
|
283
|
+
# the direct transport — this bridge is deliberately a subset.
|
|
284
|
+
"operations": sorted(self.OPERATIONS),
|
|
285
|
+
"read_operations": sorted(self.READ_OPERATIONS),
|
|
286
|
+
"write_operations": sorted(self.WRITE_OPERATIONS),
|
|
287
|
+
# False when nothing owns the listener, so a client can tell
|
|
288
|
+
# "this bridge cannot be stopped remotely" from "I forgot to ask".
|
|
289
|
+
"lifecycle_available": self._lifecycle is not None,
|
|
290
|
+
"policy": {
|
|
291
|
+
"media_roots": list(self.policy.media_roots),
|
|
292
|
+
"output_roots": list(self.policy.output_roots),
|
|
293
|
+
"max_items": self.max_items,
|
|
294
|
+
},
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
def op_list_projects(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
298
|
+
manager = self._project_manager()
|
|
299
|
+
current = self._call(manager, "GetCurrentProject")
|
|
300
|
+
database = self._call(manager, "GetCurrentDatabase") or {}
|
|
301
|
+
if isinstance(database, dict):
|
|
302
|
+
database = {k: v for k, v in database.items() if k != "IpAddress"}
|
|
303
|
+
return {
|
|
304
|
+
"folder": self._call(manager, "GetCurrentFolder"),
|
|
305
|
+
"projects": list(self._call(manager, "GetProjectListInCurrentFolder") or [])[: self.max_items],
|
|
306
|
+
"current_project": self._call(current, "GetName") if current else None,
|
|
307
|
+
"database": database,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
def op_get_project(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
311
|
+
project = self._project()
|
|
312
|
+
return {
|
|
313
|
+
"name": self._call(project, "GetName"),
|
|
314
|
+
"timeline_count": int(self._call(project, "GetTimelineCount") or 0),
|
|
315
|
+
"current_timeline": (
|
|
316
|
+
self._call(self._call(project, "GetCurrentTimeline"), "GetName")
|
|
317
|
+
if self._call(project, "GetCurrentTimeline") is not None
|
|
318
|
+
else None
|
|
319
|
+
),
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
def op_list_timelines(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
323
|
+
project = self._project()
|
|
324
|
+
count = int(self._call(project, "GetTimelineCount") or 0)
|
|
325
|
+
timelines = []
|
|
326
|
+
for index in range(1, min(count, self.max_items) + 1):
|
|
327
|
+
timeline = self._call(project, "GetTimelineByIndex", index)
|
|
328
|
+
if timeline is None:
|
|
329
|
+
continue
|
|
330
|
+
timelines.append({
|
|
331
|
+
"index": index,
|
|
332
|
+
"name": self._call(timeline, "GetName"),
|
|
333
|
+
"start_frame": self._call(timeline, "GetStartFrame"),
|
|
334
|
+
"end_frame": self._call(timeline, "GetEndFrame"),
|
|
335
|
+
})
|
|
336
|
+
return {"timelines": timelines, "total": count, "truncated": count > self.max_items}
|
|
337
|
+
|
|
338
|
+
def op_get_timeline(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
339
|
+
timeline = self._timeline(arguments)
|
|
340
|
+
tracks = []
|
|
341
|
+
for track_type in ("video", "audio", "subtitle"):
|
|
342
|
+
count = int(self._call(timeline, "GetTrackCount", track_type) or 0)
|
|
343
|
+
for index in range(1, count + 1):
|
|
344
|
+
tracks.append({
|
|
345
|
+
"type": track_type,
|
|
346
|
+
"index": index,
|
|
347
|
+
"name": self._call(timeline, "GetTrackName", track_type, index),
|
|
348
|
+
"enabled": self._call(timeline, "GetIsTrackEnabled", track_type, index),
|
|
349
|
+
"locked": self._call(timeline, "GetIsTrackLocked", track_type, index),
|
|
350
|
+
})
|
|
351
|
+
return {
|
|
352
|
+
"name": self._call(timeline, "GetName"),
|
|
353
|
+
"start_frame": self._call(timeline, "GetStartFrame"),
|
|
354
|
+
"end_frame": self._call(timeline, "GetEndFrame"),
|
|
355
|
+
"start_timecode": self._call(timeline, "GetStartTimecode"),
|
|
356
|
+
"tracks": tracks,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
def op_list_media(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
360
|
+
pool = self._call(self._project(), "GetMediaPool")
|
|
361
|
+
root = self._call(pool, "GetRootFolder", false_is_error=True)
|
|
362
|
+
clips: List[Dict[str, Any]] = []
|
|
363
|
+
pending = [root]
|
|
364
|
+
seen = set()
|
|
365
|
+
while pending and len(clips) < self.max_items:
|
|
366
|
+
folder = pending.pop()
|
|
367
|
+
unique = str(self._call(folder, "GetUniqueId"))
|
|
368
|
+
if unique in seen:
|
|
369
|
+
continue
|
|
370
|
+
seen.add(unique)
|
|
371
|
+
for clip in list(self._call(folder, "GetClipList") or []):
|
|
372
|
+
if len(clips) >= self.max_items:
|
|
373
|
+
break
|
|
374
|
+
props = self._call(clip, "GetClipProperty") or {}
|
|
375
|
+
raw_path = str(props.get("File Path", "") or "")
|
|
376
|
+
clips.append({
|
|
377
|
+
"name": self._call(clip, "GetName"),
|
|
378
|
+
# A path outside the configured roots is reported as such
|
|
379
|
+
# rather than leaked: the bridge should not become a way to
|
|
380
|
+
# enumerate the filesystem.
|
|
381
|
+
"file_path": raw_path if self._path_visible(raw_path) else "<outside-allowed-roots>",
|
|
382
|
+
"duration": props.get("Duration"),
|
|
383
|
+
"fps": props.get("FPS"),
|
|
384
|
+
"resolution": props.get("Resolution"),
|
|
385
|
+
})
|
|
386
|
+
pending.extend(list(self._call(folder, "GetSubFolderList") or []))
|
|
387
|
+
return {"clips": clips, "truncated": len(clips) >= self.max_items}
|
|
388
|
+
|
|
389
|
+
def _path_visible(self, raw: str) -> bool:
|
|
390
|
+
if not raw:
|
|
391
|
+
return False
|
|
392
|
+
try:
|
|
393
|
+
resolved = os.path.realpath(os.path.expanduser(raw))
|
|
394
|
+
except OSError:
|
|
395
|
+
return False
|
|
396
|
+
return self.policy._under(resolved, self.policy.media_roots)
|
|
397
|
+
|
|
398
|
+
def op_get_render_formats(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
399
|
+
project = self._project()
|
|
400
|
+
formats = self._call(project, "GetRenderFormats") or {}
|
|
401
|
+
return {
|
|
402
|
+
"formats": formats,
|
|
403
|
+
"current": self._call(project, "GetCurrentRenderFormatAndCodec") or {},
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
# -- writes -----------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
def op_save_project(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
409
|
+
self._project()
|
|
410
|
+
return {"saved": bool(self._call(self._project_manager(), "SaveProject", false_is_error=True))}
|
|
411
|
+
|
|
412
|
+
def op_set_current_timeline(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
413
|
+
name = arguments.get("timeline_name")
|
|
414
|
+
if not isinstance(name, str) or not name.strip():
|
|
415
|
+
raise OperationError("invalid_arguments", "timeline_name is required")
|
|
416
|
+
timeline = self._timeline({"timeline_name": name})
|
|
417
|
+
self._call(self._project(), "SetCurrentTimeline", timeline, false_is_error=True)
|
|
418
|
+
return {"current_timeline": self._call(timeline, "GetName")}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# ── the transparent proxy ────────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
#: Roots a caller may start a chain from by name. Everything else must be a
|
|
424
|
+
#: handle this bridge minted, and therefore already a descendant of the live
|
|
425
|
+
#: `resolve` object.
|
|
426
|
+
ROOT_NAMES = ("resolve", "project_manager", "project", "media_pool", "current_timeline")
|
|
427
|
+
|
|
428
|
+
def _mint(self, obj: Any, shape: str = "?") -> str:
|
|
429
|
+
self._handle_counter += 1
|
|
430
|
+
handle = "h:%s:%d" % (self._session, self._handle_counter)
|
|
431
|
+
self._handles[handle] = (obj, shape)
|
|
432
|
+
# Bounded, **least-recently-used** — `_resolve_target` promotes on access,
|
|
433
|
+
# so a handle in continuous use is never the one dropped. Measured on a
|
|
434
|
+
# 400-item timeline: 6000 handles minted, a handle touched each round
|
|
435
|
+
# still valid, an untouched one evicted and refusing with `stale_handle`.
|
|
436
|
+
# That is the property that matters. An unbounded table would pin every
|
|
437
|
+
# enumerated item for the session; a recycled id resolving to a
|
|
438
|
+
# *different* object would be a wrong-target edit with no error anywhere.
|
|
439
|
+
while len(self._handles) > self.MAX_HANDLES:
|
|
440
|
+
self._handles.popitem(last=False)
|
|
441
|
+
return handle
|
|
442
|
+
|
|
443
|
+
def _shape_of(self, target: Any) -> str:
|
|
444
|
+
"""Provenance, used by the client as a method-set cache key.
|
|
445
|
+
|
|
446
|
+
`type(obj).__name__` cannot serve: every live Resolve object is a
|
|
447
|
+
`PyRemoteObject`, so caching on it makes the first object touched define
|
|
448
|
+
`hasattr` for every object afterwards. Provenance — "whatever
|
|
449
|
+
`resolve.GetProjectManager` returns" — actually discriminates, and costs
|
|
450
|
+
nothing to compute.
|
|
451
|
+
|
|
452
|
+
It is a *hint*, not a proof: it assumes one method always returns one
|
|
453
|
+
shape. The client is required to re-verify before reporting a method
|
|
454
|
+
**missing**, so a wrong hint can never manufacture an absence. A wrong
|
|
455
|
+
hint in the other direction just yields a method that fails when called,
|
|
456
|
+
which is what native Resolve does for every name anyway.
|
|
457
|
+
"""
|
|
458
|
+
if isinstance(target, str) and target in self.ROOT_NAMES:
|
|
459
|
+
return target
|
|
460
|
+
entry = self._handles.get(target) if isinstance(target, str) else None
|
|
461
|
+
return entry[1] if entry else "?"
|
|
462
|
+
|
|
463
|
+
def _named_root(self, name: str) -> Any:
|
|
464
|
+
if name == "resolve":
|
|
465
|
+
return self.resolve
|
|
466
|
+
manager = self._project_manager()
|
|
467
|
+
if name == "project_manager":
|
|
468
|
+
return manager
|
|
469
|
+
project = self._call(manager, "GetCurrentProject")
|
|
470
|
+
if name == "project":
|
|
471
|
+
if project is None:
|
|
472
|
+
raise OperationError("no_project", "no Resolve project is currently open")
|
|
473
|
+
return project
|
|
474
|
+
if project is None:
|
|
475
|
+
raise OperationError("no_project", "no Resolve project is currently open")
|
|
476
|
+
if name == "media_pool":
|
|
477
|
+
return self._call(project, "GetMediaPool")
|
|
478
|
+
if name == "current_timeline":
|
|
479
|
+
timeline = self._call(project, "GetCurrentTimeline")
|
|
480
|
+
if timeline is None:
|
|
481
|
+
raise OperationError("no_timeline", "no current timeline is selected")
|
|
482
|
+
return timeline
|
|
483
|
+
raise OperationError("invalid_arguments", f"unknown root object {name!r}")
|
|
484
|
+
|
|
485
|
+
def _resolve_target(self, target: Any) -> Any:
|
|
486
|
+
if not isinstance(target, str) or not target:
|
|
487
|
+
raise OperationError("invalid_arguments", "target must be a root name or a bridge handle")
|
|
488
|
+
if target in self.ROOT_NAMES:
|
|
489
|
+
return self._named_root(target)
|
|
490
|
+
if target.startswith("h:"):
|
|
491
|
+
if target in self._handles:
|
|
492
|
+
self._handles.move_to_end(target)
|
|
493
|
+
return self._handles[target][0]
|
|
494
|
+
raise OperationError(
|
|
495
|
+
"stale_handle",
|
|
496
|
+
"that handle is no longer held by the bridge — re-fetch the object and retry",
|
|
497
|
+
hint="handles are session-scoped and bounded; a restart or heavy enumeration evicts them",
|
|
498
|
+
)
|
|
499
|
+
raise OperationError(
|
|
500
|
+
"invalid_arguments",
|
|
501
|
+
f"target must be one of {list(self.ROOT_NAMES)} or a bridge-issued handle",
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
def _encode(self, value: Any, depth: int = 0, shape: str = "?") -> Any:
|
|
505
|
+
"""Resolve return value -> JSON-safe, minting handles for live objects.
|
|
506
|
+
|
|
507
|
+
Every object produced by one call carries the same shape, including the
|
|
508
|
+
elements of a returned list — a track's timeline items are homogeneous,
|
|
509
|
+
which is exactly the case where sharing a cached method set pays.
|
|
510
|
+
"""
|
|
511
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
512
|
+
return value
|
|
513
|
+
if depth > 6:
|
|
514
|
+
return str(value)
|
|
515
|
+
if isinstance(value, (list, tuple)):
|
|
516
|
+
return [self._encode(v, depth + 1, shape) for v in list(value)[: self.max_items]]
|
|
517
|
+
if isinstance(value, dict):
|
|
518
|
+
return {str(k): self._encode(v, depth + 1, shape) for k, v in list(value.items())[: self.max_items]}
|
|
519
|
+
return {
|
|
520
|
+
"__handle__": self._mint(value, shape),
|
|
521
|
+
"__type__": type(value).__name__,
|
|
522
|
+
"__shape__": shape,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
def _decode(self, value: Any) -> Any:
|
|
526
|
+
"""Argument -> live object, rehydrating handles the bridge itself issued."""
|
|
527
|
+
if isinstance(value, dict):
|
|
528
|
+
handle = value.get("__handle__")
|
|
529
|
+
if handle is not None:
|
|
530
|
+
return self._resolve_target(handle)
|
|
531
|
+
return {k: self._decode(v) for k, v in value.items()}
|
|
532
|
+
if isinstance(value, list):
|
|
533
|
+
return [self._decode(v) for v in value]
|
|
534
|
+
return value
|
|
535
|
+
|
|
536
|
+
def op_call(self, arguments: Dict[str, Any]) -> Any:
|
|
537
|
+
"""Invoke one method on a live Resolve object. See the module docstring.
|
|
538
|
+
|
|
539
|
+
Returns whatever Resolve returned, verbatim — including False. The proxy
|
|
540
|
+
is transparent on purpose: the MCP server's existing call sites already
|
|
541
|
+
interpret False correctly, and re-interpreting it here would change the
|
|
542
|
+
meaning of code that was written against the native API.
|
|
543
|
+
"""
|
|
544
|
+
method = arguments.get("method")
|
|
545
|
+
if not isinstance(method, str) or not method:
|
|
546
|
+
raise OperationError("invalid_arguments", "method must be a non-empty string")
|
|
547
|
+
if method.startswith("_"):
|
|
548
|
+
# Closes __class__/__globals__/__reduce__ traversal off the live object.
|
|
549
|
+
raise OperationError("method_not_allowed", "private and dunder attributes are not reachable")
|
|
550
|
+
target_key = arguments.get("target", "resolve")
|
|
551
|
+
target = self._resolve_target(target_key)
|
|
552
|
+
if target is None:
|
|
553
|
+
raise OperationError("not_found", "the requested object is not available right now")
|
|
554
|
+
fn = getattr(target, method, None)
|
|
555
|
+
if not callable(fn):
|
|
556
|
+
raise OperationError(
|
|
557
|
+
"capability_unavailable",
|
|
558
|
+
f"{type(target).__name__} has no callable {method!r} in this Resolve build",
|
|
559
|
+
)
|
|
560
|
+
raw_args = arguments.get("args") or []
|
|
561
|
+
if not isinstance(raw_args, list):
|
|
562
|
+
raise OperationError("invalid_arguments", "args must be a list")
|
|
563
|
+
if len(raw_args) > 64:
|
|
564
|
+
raise OperationError("invalid_arguments", "too many arguments")
|
|
565
|
+
try:
|
|
566
|
+
result = fn(*[self._decode(a) for a in raw_args])
|
|
567
|
+
except OperationError:
|
|
568
|
+
raise
|
|
569
|
+
except Exception as exc:
|
|
570
|
+
raise OperationError(
|
|
571
|
+
"resolve_raised",
|
|
572
|
+
f"Resolve raised while running {method}: {str(exc)[:200]}",
|
|
573
|
+
)
|
|
574
|
+
return {"value": self._encode(result, shape=f"{self._shape_of(target_key)}.{method}")}
|
|
575
|
+
|
|
576
|
+
def op_list_methods(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
577
|
+
"""The public attribute names on a target — what `hasattr` should answer.
|
|
578
|
+
|
|
579
|
+
The proxy needs this. Without it a `__getattr__`-based proxy claims every
|
|
580
|
+
name exists, so capability detection — `getattr(obj, "CreateMagicMask",
|
|
581
|
+
None)` — silently passes for methods this Resolve build does not have,
|
|
582
|
+
and the failure resurfaces later as an unrelated error.
|
|
583
|
+
|
|
584
|
+
**`dir()` is the whole answer; do not filter with `callable(getattr(...))`.**
|
|
585
|
+
Two measured reasons, both specific to Resolve's Python objects:
|
|
586
|
+
|
|
587
|
+
1. It cannot reject anything. Resolve's bridge fabricates a callable for
|
|
588
|
+
*any* attribute name, so the test is true by construction — that is the
|
|
589
|
+
documented limitation this operation exists to work around, and `dir()`
|
|
590
|
+
is the only thing that tells the truth.
|
|
591
|
+
2. It is ruinously slow. Each `getattr` is an IPC round trip into Resolve,
|
|
592
|
+
so filtering costs one round trip **per name**: measured at 5-10 ms for
|
|
593
|
+
a 30-88 method object against 0.85 ms for an ordinary call. Removing it
|
|
594
|
+
makes introspection as cheap as any other call.
|
|
595
|
+
|
|
596
|
+
`type()` is returned for diagnostics only. It is **not** an identity: every
|
|
597
|
+
live Resolve object is a `PyRemoteObject` regardless of what it is, so
|
|
598
|
+
anything that caches on it conflates a Timeline with the Resolve root.
|
|
599
|
+
"""
|
|
600
|
+
target_key = arguments.get("target", "resolve")
|
|
601
|
+
target = self._resolve_target(target_key)
|
|
602
|
+
names = sorted(name for name in dir(target) if not name.startswith("_"))
|
|
603
|
+
return {"type": type(target).__name__, "shape": self._shape_of(target_key), "methods": names}
|
|
604
|
+
|
|
605
|
+
# -- lifecycle ---------------------------------------------------------
|
|
606
|
+
#
|
|
607
|
+
# These stop or restart the bridge, never the project. No extra gate beyond
|
|
608
|
+
# the token: a caller holding it can already drive Resolve through `call`, so
|
|
609
|
+
# refusing them here would guard nothing while removing the only way to
|
|
610
|
+
# recover a stale bridge without quitting Resolve.
|
|
611
|
+
|
|
612
|
+
def _lifecycle_stop(self, mode: str) -> Dict[str, Any]:
|
|
613
|
+
if self._lifecycle is None:
|
|
614
|
+
raise OperationError(
|
|
615
|
+
"capability_unavailable",
|
|
616
|
+
"this bridge was started without a lifecycle owner, so it cannot stop itself; "
|
|
617
|
+
"quit the script inside Resolve instead",
|
|
618
|
+
)
|
|
619
|
+
outcome = self._lifecycle(mode) or {}
|
|
620
|
+
# The reply has to be written before the listener goes away, so the
|
|
621
|
+
# handler returns now and the teardown happens in the serve loop.
|
|
622
|
+
return {"stopping": True, "mode": mode, **outcome}
|
|
623
|
+
|
|
624
|
+
def op_shutdown(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
625
|
+
"""Stop the listener so Workspace ▸ Scripts can start it again."""
|
|
626
|
+
return self._lifecycle_stop("exit")
|
|
627
|
+
|
|
628
|
+
def op_reload(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
629
|
+
"""Re-import the runtime from disk and serve again, in the same process.
|
|
630
|
+
|
|
631
|
+
The in-Resolve runtime is a copy taken at install time, so a repository
|
|
632
|
+
change leaves the bridge stale with no way back except quitting Resolve.
|
|
633
|
+
This closes that loop: re-run the installer, call `reload`, and the new
|
|
634
|
+
code is live without touching the UI.
|
|
635
|
+
|
|
636
|
+
Handles do not survive: the new `ResolveOperations` has a new session, so
|
|
637
|
+
a client's existing proxies get `stale_handle` and re-fetch. That is the
|
|
638
|
+
designed behaviour — silently resolving an old handle against a new object
|
|
639
|
+
graph is the wrong-target failure the session scoping exists to prevent.
|
|
640
|
+
"""
|
|
641
|
+
return self._lifecycle_stop("reload")
|
|
642
|
+
|
|
643
|
+
#: Distinguishes "attribute is absent" from "attribute is present and None",
|
|
644
|
+
#: which `getattr(obj, name, None)` cannot.
|
|
645
|
+
_MISSING = object()
|
|
646
|
+
|
|
647
|
+
def op_get_attribute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
648
|
+
"""Read a non-callable attribute — Resolve's API constants live here.
|
|
649
|
+
|
|
650
|
+
`Timeline.Export(path, resolve.EXPORT_AAF, resolve.EXPORT_AAF_NEW)` is the
|
|
651
|
+
documented way to export, and `resolve.AUDIO_SYNC_*` is the documented way
|
|
652
|
+
to configure AutoSyncAudio. Both are plain attributes, and **`dir()` does
|
|
653
|
+
not list them** — measured on 21.0.3.7, where `dir(resolve)` returns 34
|
|
654
|
+
names and not one `EXPORT_*` among them.
|
|
655
|
+
|
|
656
|
+
Without this operation the proxy answers `hasattr(resolve, "EXPORT_AAF")`
|
|
657
|
+
with False, and the server's own resolver then falls back to passing the
|
|
658
|
+
bare string `"EXPORT_AAF"` to Resolve. That is a silent degradation of the
|
|
659
|
+
entire conform surface, which is why reading attributes is a first-class
|
|
660
|
+
operation rather than something a caller works around.
|
|
661
|
+
|
|
662
|
+
`kind` is reported rather than a bare value so a caller can tell a
|
|
663
|
+
constant from a method from an absence, instead of inferring it from a
|
|
664
|
+
None that could mean any of the three.
|
|
665
|
+
"""
|
|
666
|
+
name = arguments.get("name")
|
|
667
|
+
if not isinstance(name, str) or not name:
|
|
668
|
+
raise OperationError("invalid_arguments", "name must be a non-empty string")
|
|
669
|
+
if name.startswith("_"):
|
|
670
|
+
raise OperationError("method_not_allowed", "private and dunder attributes are not reachable")
|
|
671
|
+
target = self._resolve_target(arguments.get("target", "resolve"))
|
|
672
|
+
try:
|
|
673
|
+
value = getattr(target, name, self._MISSING)
|
|
674
|
+
except Exception as exc: # noqa: BLE001 - a raising getattr is an answer
|
|
675
|
+
raise OperationError("resolve_raised", f"reading {name} raised: {str(exc)[:200]}")
|
|
676
|
+
if value is self._MISSING:
|
|
677
|
+
return {"kind": "absent"}
|
|
678
|
+
if callable(value):
|
|
679
|
+
# Left to `call`. Reported rather than returned because Resolve
|
|
680
|
+
# fabricates a callable for names that do not exist, so "callable"
|
|
681
|
+
# is not evidence the attribute is real — `dir()` is.
|
|
682
|
+
return {"kind": "callable"}
|
|
683
|
+
if value is None:
|
|
684
|
+
# Measured on 21.0.3.7: `getattr(resolve, "Xyzzy_NotAThing")` returns
|
|
685
|
+
# None rather than raising, so the sentinel above never fires on a
|
|
686
|
+
# Resolve object and None cannot be told apart from absence. Reported
|
|
687
|
+
# as its own kind instead of as a value, because returning it would
|
|
688
|
+
# make `hasattr` true for every name anyone ever asks about — the
|
|
689
|
+
# always-true trap this whole path exists to avoid.
|
|
690
|
+
return {"kind": "none",
|
|
691
|
+
"note": "present-but-None; on Resolve objects this is indistinguishable "
|
|
692
|
+
"from absent, because getattr never raises"}
|
|
693
|
+
return {"kind": "value", "value": self._encode(value, shape=f"{self._shape_of(arguments.get('target', 'resolve'))}.{name}")}
|
|
694
|
+
|
|
695
|
+
def op_release_handles(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
696
|
+
"""Drop handles a client no longer needs, or all of them."""
|
|
697
|
+
handles = arguments.get("handles")
|
|
698
|
+
if handles is None:
|
|
699
|
+
count = len(self._handles)
|
|
700
|
+
self._handles.clear()
|
|
701
|
+
return {"released": count, "all": True}
|
|
702
|
+
if not isinstance(handles, list):
|
|
703
|
+
raise OperationError("invalid_arguments", "handles must be a list")
|
|
704
|
+
released = sum(1 for h in handles if self._handles.pop(h, None) is not None)
|
|
705
|
+
return {"released": released, "all": False, "held": len(self._handles)}
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def make_dispatch(operations: ResolveOperations) -> Callable[[str, Dict[str, Any]], Any]:
|
|
709
|
+
"""Adapt a `ResolveOperations` into the callable `Bridge` expects."""
|
|
710
|
+
|
|
711
|
+
def dispatch(operation: str, arguments: Dict[str, Any]) -> Any:
|
|
712
|
+
return operations.dispatch(operation, arguments)
|
|
713
|
+
|
|
714
|
+
return dispatch
|