davinci-resolve-mcp 2.67.0 → 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.
@@ -0,0 +1,431 @@
1
+ #!/usr/bin/env python3
2
+ """Qualify the in-app bridge by differencing it against native scripting.
3
+
4
+ # Studio: both transports reach the same application — the real differential.
5
+ python scripts/bridge_differential.py --mode differential
6
+
7
+ # Free edition: no native side exists, so prove reachability and shape.
8
+ python scripts/bridge_differential.py --mode coverage
9
+
10
+ # Add the write scenarios (render, interchange export, SetLUT, scale, modal).
11
+ python scripts/bridge_differential.py --mode coverage --scenarios
12
+
13
+ `--mode differential` requires Studio, because external scripting is Studio-gated
14
+ — which is the entire reason the bridge exists. It also requires the in-app
15
+ bridge to be running *in that same Studio instance*: two transports into one
16
+ application is what makes a difference attributable to the transport rather than
17
+ to the two Resolves having different projects open.
18
+
19
+ Nothing here writes to a project unless `--scenarios` is passed, and every
20
+ scenario undoes what it did. Read `SCENARIOS` before running it against anything
21
+ you care about.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import sys
30
+ import time
31
+ from pathlib import Path
32
+ from typing import Any, Callable, Dict, List, Optional
33
+
34
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
35
+
36
+ from src.utils import bridge_differential as bd # noqa: E402
37
+
38
+
39
+ def connect_bridge() -> Any:
40
+ from src.utils import resolve_bridge_client as client
41
+
42
+ os.environ.setdefault(client.ENV_ENABLE, "1")
43
+ return client.connect()
44
+
45
+
46
+ def connect_native() -> Any:
47
+ """Blackmagic's own module, deliberately without the bridge in the way."""
48
+ from src.utils import resolve_bridge_client as client
49
+
50
+ previous = os.environ.pop(client.ENV_ENABLE, None)
51
+ try:
52
+ import DaVinciResolveScript as dvr
53
+
54
+ return dvr.scriptapp("Resolve")
55
+ finally:
56
+ if previous is not None:
57
+ os.environ[client.ENV_ENABLE] = previous
58
+
59
+
60
+ # ── write scenarios ──────────────────────────────────────────────────────────
61
+ #
62
+ # The enumeration routes anything that mutates to a named scenario, because a
63
+ # blind call cannot set up its preconditions or clean up after itself. Each one
64
+ # returns a dict of observations; the differential compares those dicts.
65
+
66
+
67
+ def scenario_render(resolve: Any, workspace: Path) -> Dict[str, Any]:
68
+ """The delivery path end to end, to a real file on disk.
69
+
70
+ Untested until now, and the most consequential thing the bridge could get
71
+ wrong: a render that reports success and produces nothing is indistinguishable
72
+ from a render that worked, right up until someone looks for the file.
73
+ """
74
+ project = resolve.GetProjectManager().GetCurrentProject()
75
+ observations: Dict[str, Any] = {}
76
+ formats = project.GetRenderFormats() or {}
77
+ observations["format_count"] = len(formats)
78
+ observations["codec_count_for_mov"] = len(project.GetRenderCodecs("mov") or {})
79
+
80
+ output = workspace / "bridge_render_probe"
81
+ output.mkdir(parents=True, exist_ok=True)
82
+ observations["set_format"] = project.SetCurrentRenderFormatAndCodec("mov", "H.264")
83
+ observations["set_settings"] = project.SetRenderSettings({
84
+ "TargetDir": str(output),
85
+ "CustomName": "bridge_render_probe",
86
+ "MarkIn": int(project.GetCurrentTimeline().GetStartFrame()),
87
+ "MarkOut": int(project.GetCurrentTimeline().GetStartFrame()) + 4,
88
+ })
89
+ job = project.AddRenderJob()
90
+ observations["job_added"] = bool(job)
91
+ if not job:
92
+ return observations
93
+ try:
94
+ observations["started"] = project.StartRendering([job], False)
95
+ deadline = time.time() + 120
96
+ while time.time() < deadline and project.IsRenderingInProgress():
97
+ time.sleep(0.5)
98
+ status = project.GetRenderJobStatus(job) or {}
99
+ observations["job_status"] = status.get("JobStatus")
100
+ produced = sorted(p.name for p in output.iterdir() if p.is_file())
101
+ observations["files_written"] = len(produced)
102
+ observations["nonempty_output"] = any((output / n).stat().st_size > 0 for n in produced)
103
+ finally:
104
+ project.DeleteRenderJob(job)
105
+ return observations
106
+
107
+
108
+ def scenario_interchange_export(resolve: Any, workspace: Path) -> Dict[str, Any]:
109
+ """Timeline.Export to each conform format. The conform surface, untested."""
110
+ timeline = resolve.GetProjectManager().GetCurrentProject().GetCurrentTimeline()
111
+ observations: Dict[str, Any] = {}
112
+ targets = [
113
+ ("EDL", "EXPORT_EDL", "EXPORT_CDL", ".edl"),
114
+ ("FCPXML", "EXPORT_FCP_7_XML", "EXPORT_NONE", ".xml"),
115
+ ("DRT", "EXPORT_DRT", "EXPORT_NONE", ".drt"),
116
+ ("AAF", "EXPORT_AAF", "EXPORT_AAF_NEW", ".aaf"),
117
+ ]
118
+ for label, export_type, subtype, suffix in targets:
119
+ path = workspace / f"bridge_export_probe{suffix}"
120
+ if path.exists():
121
+ path.unlink()
122
+ constant = getattr(resolve, export_type, None)
123
+ sub = getattr(resolve, subtype, None)
124
+ try:
125
+ if constant is None:
126
+ observations[label] = {"ok": False, "reason": "constant_absent"}
127
+ continue
128
+ ok = timeline.Export(str(path), constant, sub) if sub is not None else \
129
+ timeline.Export(str(path), constant)
130
+ observations[label] = {
131
+ "ok": bool(ok),
132
+ "written": path.exists(),
133
+ "bytes": path.stat().st_size if path.exists() else 0,
134
+ }
135
+ except Exception as exc: # noqa: BLE001 - the failure is the observation
136
+ observations[label] = {"ok": False, "reason": type(exc).__name__, "message": str(exc)[:120]}
137
+ return observations
138
+
139
+
140
+ def scenario_set_lut(resolve: Any, _workspace: Path) -> Dict[str, Any]:
141
+ """SetLUT specifically — last time GetLUT was called and the rest inferred.
142
+
143
+ Read back after writing, and restore. A write that returns True and does not
144
+ land is the failure mode this exists to catch, and only the read-back sees it.
145
+ """
146
+ timeline = resolve.GetProjectManager().GetCurrentProject().GetCurrentTimeline()
147
+ items = timeline.GetItemListInTrack("video", 1) or []
148
+ if not items:
149
+ return {"skipped": "no clip on video track 1"}
150
+ item = items[0]
151
+ observations: Dict[str, Any] = {"node_count": item.GetNumNodes()}
152
+ original = item.GetLUT(1)
153
+ observations["lut_before"] = original
154
+ # An empty path is the documented way to clear a node's LUT, so it is a
155
+ # write we can make and unmake without shipping a .cube anywhere.
156
+ observations["set_empty"] = item.SetLUT(1, "")
157
+ observations["lut_after_clear"] = item.GetLUT(1)
158
+ if original:
159
+ observations["restored"] = item.SetLUT(1, original)
160
+ observations["set_out_of_range_node"] = item.SetLUT(999, "")
161
+ return observations
162
+
163
+
164
+ def scenario_scale(resolve: Any, _workspace: Path) -> Dict[str, Any]:
165
+ """Enumerate every item on every track and time it.
166
+
167
+ The round trip is 0.6 ms and the handle table holds 4096 entries; both only
168
+ matter at a size nobody has run yet. This measures where they start to.
169
+ """
170
+ timeline = resolve.GetProjectManager().GetCurrentProject().GetCurrentTimeline()
171
+ observations: Dict[str, Any] = {"tracks": {}}
172
+ started = time.time()
173
+ total = 0
174
+ names: List[str] = []
175
+ for kind in ("video", "audio"):
176
+ count = int(timeline.GetTrackCount(kind) or 0)
177
+ for index in range(1, count + 1):
178
+ items = timeline.GetItemListInTrack(kind, index) or []
179
+ observations["tracks"][f"{kind}{index}"] = len(items)
180
+ for item in items:
181
+ names.append(item.GetName())
182
+ total += 1
183
+ elapsed = time.time() - started
184
+ observations["items"] = total
185
+ observations["distinct_names"] = len(set(names))
186
+ observations["elapsed_seconds"] = round(elapsed, 3)
187
+ observations["ms_per_item"] = round(elapsed / total * 1000, 3) if total else None
188
+ return observations
189
+
190
+
191
+ def scenario_handle_pressure(resolve: Any, _workspace: Path) -> Dict[str, Any]:
192
+ """Push past the 4096-handle ceiling and check what an evicted handle does.
193
+
194
+ The table is bounded so a long session cannot pin every enumerated object
195
+ inside Resolve forever. Eviction is therefore normal, and the property that
196
+ matters is that a lost handle says `stale_handle` — a client can re-fetch from
197
+ that. The failure this guards against is an evicted id being reused and
198
+ silently resolving to a *different* object, which is a wrong-target edit with
199
+ no error anywhere.
200
+ """
201
+ from src.utils import resolve_bridge_client as client
202
+
203
+ transport = getattr(resolve, "_transport", None)
204
+ if transport is None:
205
+ return {"skipped": "native scripting has no handle table"}
206
+
207
+ timeline = resolve.GetProjectManager().GetCurrentProject().GetCurrentTimeline()
208
+ first_batch = timeline.GetItemListInTrack("video", 1) or []
209
+ if not first_batch:
210
+ return {"skipped": "no clip on video track 1"}
211
+ observations: Dict[str, Any] = {"batch_size": len(first_batch)}
212
+
213
+ # Two handles from the same first batch. One is touched every round, the
214
+ # other is left alone — because access promotes an entry, so "does a handle
215
+ # survive" has two different answers and only measuring one of them would
216
+ # report the wrong ceiling.
217
+ kept_warm, left_cold = first_batch[0], first_batch[-1]
218
+
219
+ minted = len(first_batch)
220
+ rounds = 0
221
+ started = time.time()
222
+ while minted < 6000 and rounds < 400 and time.time() - started < 180:
223
+ minted += len(timeline.GetItemListInTrack("video", 1) or [])
224
+ rounds += 1
225
+ try:
226
+ kept_warm.GetName()
227
+ observations["warm_valid_at"] = minted
228
+ except client.BridgeCallError as exc:
229
+ observations["warm_evicted_at"] = minted
230
+ observations["warm_code"] = exc.code
231
+ break
232
+ observations["handles_minted"] = minted
233
+ observations["rounds"] = rounds
234
+ observations["seconds"] = round(time.time() - started, 2)
235
+
236
+ try:
237
+ left_cold.GetName()
238
+ observations["cold_survived"] = True
239
+ except client.BridgeCallError as exc:
240
+ observations["cold_evicted"] = True
241
+ observations["cold_code"] = exc.code
242
+ # The property that actually matters: an evicted handle refuses. If a
243
+ # recycled id resolved to a different object instead, the caller would
244
+ # edit the wrong clip with nothing to indicate it.
245
+ observations["cold_refused_cleanly"] = exc.code == "stale_handle"
246
+ return observations
247
+
248
+
249
+ SCENARIOS: Dict[str, Callable[[Any, Path], Dict[str, Any]]] = {
250
+ "render": scenario_render,
251
+ "interchange_export": scenario_interchange_export,
252
+ "set_lut": scenario_set_lut,
253
+ "scale": scenario_scale,
254
+ "handle_pressure": scenario_handle_pressure,
255
+ }
256
+
257
+ #: Name of the timeline `--build-timeline` creates. Distinctive so it is obvious
258
+ #: what it is and safe to delete.
259
+ SCALE_TIMELINE = "MCP Bridge Scale Test - delete me"
260
+
261
+
262
+ def build_scale_timeline(resolve: Any, items: int) -> Dict[str, Any]:
263
+ """Create a timeline with `items` clips, so the scale scenario has a subject.
264
+
265
+ Writes. Only runs when `--build-timeline` is passed. Doubles as a test of
266
+ argument encoding depth: `AppendToTimeline` takes a list of dicts each
267
+ holding a live MediaPoolItem, so the proxy has to send objects back *inside*
268
+ a nested structure, not merely as a top-level argument.
269
+ """
270
+ project = resolve.GetProjectManager().GetCurrentProject()
271
+ pool = project.GetMediaPool()
272
+ clips = [c for c in (pool.GetRootFolder().GetClipList() or [])
273
+ if (c.GetClipProperty("Type") or "") != "Timeline"]
274
+ if not clips:
275
+ return {"failed": "the media pool has no clip to append"}
276
+ source = clips[0]
277
+ timeline = pool.CreateEmptyTimeline(SCALE_TIMELINE)
278
+ if not timeline:
279
+ return {"failed": f"could not create {SCALE_TIMELINE!r} (does it already exist?)"}
280
+ project.SetCurrentTimeline(timeline)
281
+ started = time.time()
282
+ appended = pool.AppendToTimeline(
283
+ [{"mediaPoolItem": source, "startFrame": 0, "endFrame": 5} for _ in range(items)]
284
+ )
285
+ return {
286
+ "timeline": SCALE_TIMELINE,
287
+ "requested": items,
288
+ "appended": len(appended) if isinstance(appended, list) else bool(appended),
289
+ "seconds": round(time.time() - started, 2),
290
+ }
291
+
292
+
293
+ def run_scenarios(resolve: Any, workspace: Path, only: Optional[List[str]] = None) -> Dict[str, Any]:
294
+ results: Dict[str, Any] = {}
295
+ for name, function in SCENARIOS.items():
296
+ if only and name not in only:
297
+ continue
298
+ try:
299
+ results[name] = function(resolve, workspace)
300
+ except Exception as exc: # noqa: BLE001 - a scenario failing is a result
301
+ results[name] = {"failed": type(exc).__name__, "message": str(exc)[:300]}
302
+ return results
303
+
304
+
305
+ # ── modal behaviour ──────────────────────────────────────────────────────────
306
+
307
+
308
+ def probe_modal_timeout(resolve: Any, timeout: float) -> Dict[str, Any]:
309
+ """Does a blocked Resolve hang the client, or does it come back?
310
+
311
+ Measured on free 21.0.3.7 with dialogs genuinely open, and the answer was
312
+ better than the question assumed: **the bridge is not blocked by modals.**
313
+ With Project Settings open, and again with a native file-open dialog up,
314
+ reads and writes both went through — a 400-item enumeration finished in
315
+ 0.17 s. The reason is the host model: a Scripts-menu script is a separate
316
+ process, so a modal that owns Resolve's UI thread does not own the scripting
317
+ IPC. The child-process lifecycle was forced on us by a different question and
318
+ turns out to buy modal immunity as a side effect.
319
+
320
+ One difference was observed under the file dialog and is recorded rather than
321
+ generalised from: `Timeline.SetName` returned None where the same call
322
+ returns False with no dialog open. A single observation, so treat it as a
323
+ reason to keep `false_is_error` catching None as well as False, not as a
324
+ characterisation of every write.
325
+
326
+ This probe cannot open a dialog itself, so it verifies the fallback that
327
+ matters if some future modal *does* block: the client gives up on a bounded
328
+ schedule with an actionable code rather than pinning a tool call.
329
+ """
330
+ from src.utils import resolve_bridge_client as client
331
+
332
+ transport = getattr(resolve, "_transport", None)
333
+ if transport is None:
334
+ return {"skipped": "native transport has no timeout to probe"}
335
+ previous = transport.timeout
336
+ transport.timeout = timeout
337
+ started = time.time()
338
+ try:
339
+ resolve.GetProductName()
340
+ return {"answered": True, "seconds": round(time.time() - started, 3),
341
+ "note": "nothing was blocking; run again with a dialog open in Resolve"}
342
+ except client.BridgeCallError as exc:
343
+ return {"answered": False, "code": exc.code, "seconds": round(time.time() - started, 3),
344
+ "message": exc.message[:200]}
345
+ finally:
346
+ transport.timeout = previous
347
+
348
+
349
+ def main() -> int:
350
+ parser = argparse.ArgumentParser(description=__doc__,
351
+ formatter_class=argparse.RawDescriptionHelpFormatter)
352
+ parser.add_argument("--mode", choices=("differential", "coverage", "surface"), default="coverage")
353
+ parser.add_argument("--scenarios", action="store_true", help="run the write scenarios (mutates the project)")
354
+ parser.add_argument("--only", action="append", help="restrict to one scenario; repeatable")
355
+ parser.add_argument("--build-timeline", type=int, metavar="N",
356
+ help=f"create {SCALE_TIMELINE!r} with N clips first (writes)")
357
+ parser.add_argument("--workspace", default=str(Path.home() / "Movies" / "mcp_bridge_test"))
358
+ parser.add_argument("--json", dest="json_path", help="write the full report here")
359
+ arguments = parser.parse_args()
360
+
361
+ surface = bd.probe_surface()
362
+ report: Dict[str, Any] = {"surface": {k: v for k, v in surface.items() if k != "sources"}}
363
+
364
+ if arguments.mode == "surface":
365
+ print(json.dumps(report, indent=2))
366
+ return 0
367
+
368
+ workspace = Path(arguments.workspace).expanduser()
369
+ workspace.mkdir(parents=True, exist_ok=True)
370
+
371
+ bridge = connect_bridge()
372
+ report["bridge_health"] = bridge.bridge_health()
373
+ if arguments.build_timeline:
374
+ report["built"] = build_scale_timeline(bridge, arguments.build_timeline)
375
+ print("built : %s" % json.dumps(report["built"], sort_keys=True))
376
+ bridged = bd.run_probes(bridge, surface["safe_to_probe"])
377
+ report["coverage"] = bd.coverage_report(surface, bridged)
378
+ report["modal_timeout"] = probe_modal_timeout(bridge, timeout=5.0)
379
+
380
+ if arguments.mode == "differential":
381
+ native = connect_native()
382
+ if native is None:
383
+ print("native scripting is unavailable — Studio only. Use --mode coverage.",
384
+ file=sys.stderr)
385
+ return 2
386
+ product = native.GetProductName()
387
+ if product != report["bridge_health"]["product"]:
388
+ # Two different applications would make every difference meaningless.
389
+ print(f"refusing to diff: native sees {product!r}, the bridge sees "
390
+ f"{report['bridge_health']['product']!r}. Both transports must reach the "
391
+ "same running Resolve.", file=sys.stderr)
392
+ return 2
393
+ report["diff"] = bd.compare(bd.run_probes(native, surface["safe_to_probe"]), bridged)
394
+ if arguments.scenarios:
395
+ report["scenarios"] = {
396
+ "native": run_scenarios(native, workspace, arguments.only),
397
+ "bridge": run_scenarios(bridge, workspace, arguments.only),
398
+ }
399
+ elif arguments.scenarios:
400
+ report["scenarios"] = {"bridge": run_scenarios(bridge, workspace, arguments.only)}
401
+
402
+ if arguments.json_path:
403
+ Path(arguments.json_path).write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
404
+
405
+ coverage = report["coverage"]
406
+ print("bridge : %s %s (%s)" % (report["bridge_health"]["product"],
407
+ report["bridge_health"]["version"],
408
+ report["bridge_health"]["edition"]))
409
+ print("surface : %d methods called by the tools, %d safe to probe"
410
+ % (coverage["called_by_tools"], coverage["safe_to_probe"]))
411
+ print("exercised : %d/%d read methods on the live object graph"
412
+ % (coverage["exercised"], coverage["safe_to_probe"]))
413
+ if coverage["unreachable_objects"]:
414
+ print("unreachable : %s (no such object in this project)"
415
+ % ", ".join(coverage["unreachable_objects"]))
416
+ if coverage["not_exercised"]:
417
+ print("not covered : %s" % ", ".join(coverage["not_exercised"]))
418
+ if "diff" in report:
419
+ diff = report["diff"]
420
+ print("differential: %d probes compared, %d differences"
421
+ % (diff["compared"], len(diff["differences"])))
422
+ for difference in diff["differences"][:20]:
423
+ print(" ! %-46s %s" % (difference["key"], difference["kind"]))
424
+ for name, outcome in (report.get("scenarios", {}).get("bridge", {}) or {}).items():
425
+ print("scenario %-19s %s" % (name, json.dumps(outcome, sort_keys=True)[:180]))
426
+ failed = "diff" in report and not report["diff"]["identical"]
427
+ return 1 if failed else 0
428
+
429
+
430
+ if __name__ == "__main__":
431
+ sys.exit(main())