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.
@@ -0,0 +1,363 @@
1
+ """Differential qualification for headless Resolve: one application, two modes.
2
+
3
+ `-nogui` is documented in Blackmagic's own scripting README as "the user
4
+ interface is disabled. However, the various scripting APIs will continue to work
5
+ as expected." That sentence is wrong in ways that cost real sessions: Gallery
6
+ still export, for one, fails headless and works in the GUI — a fact this
7
+ repository learned the expensive way and recorded in a design note rather than
8
+ anywhere an agent would find it.
9
+
10
+ The problem with "some things don't work headless" as institutional knowledge is
11
+ that it is unfalsifiable and unactionable. An agent cannot reason with it. So
12
+ this module makes it a *measurement*: run the same probe set against the same
13
+ Resolve build twice — once with a UI, once without — and difference the two.
14
+ Anything that changes is caused by the mode, because nothing else did.
15
+
16
+ ## Why a differential and not a headless-only run
17
+
18
+ A headless-only run cannot distinguish "this fails without a UI" from "this
19
+ fails, full stop". Both produce False. Only the GUI baseline separates a
20
+ mode-dependent failure from a call that was never going to work — and the two
21
+ demand opposite things from an agent: the first says *retry with a UI*, the
22
+ second says *stop*.
23
+
24
+ ## Verdicts
25
+
26
+ Each probe or scenario observation lands in exactly one bucket:
27
+
28
+ - ``parity`` — identical in both modes. The overwhelming majority.
29
+ - ``headless_degraded`` — worked in the GUI, failed or returned less headless.
30
+ The findings that matter; these become api_truth
31
+ entries so agents stop retrying what cannot work.
32
+ - ``gui_degraded`` — worked headless, failed in the GUI. Rare and
33
+ interesting (modal dialogs block the GUI, not
34
+ headless), so it is a first-class verdict rather
35
+ than noise folded into ``divergent``.
36
+ - ``both_failed`` — failed identically in both. Not a headless finding;
37
+ belongs to the general limitations report.
38
+ - ``divergent`` — both succeeded but returned different values, and
39
+ the difference is not explained by volatility.
40
+ - ``untested`` — present in one run only, or skipped. Counted and
41
+ listed, never silently dropped: a coverage gap that
42
+ renders as a blank cell reads as parity.
43
+
44
+ ## What "the same" has to tolerate
45
+
46
+ Handles differ between sessions by construction and are normalised to their
47
+ shape by `bridge_differential.normalise`. Beyond that, two *sequential* runs of
48
+ one machine differ in ways two transports into one live session do not: project
49
+ names carry timestamps, temp paths differ, timecodes advance, and the GUI run
50
+ leaves the application on whatever page it was on. Those are named in
51
+ `SESSION_VARIANT_KEYS` and compared by type. Everything else is compared exactly.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ from typing import Any, Dict, List, Tuple
57
+
58
+ from src.utils.bridge_differential import VOLATILE_METHODS
59
+
60
+ #: Observations that differ between any two sequential runs regardless of mode,
61
+ #: because the harness or the clock moved them. Compared by type, not value.
62
+ #: Named individually rather than pattern-matched, so the exemption list stays
63
+ #: auditable — a regex over "path" or "name" would quietly excuse real findings.
64
+ SESSION_VARIANT_KEYS = frozenset({
65
+ "project_name",
66
+ "timeline_name",
67
+ "workspace",
68
+ "output_path",
69
+ "still_path",
70
+ "export_path",
71
+ "elapsed_seconds",
72
+ "startup_seconds",
73
+ "current_timecode",
74
+ "unique_id",
75
+ # Every handle Resolve mints carries a fresh UUID, and the scratch project's
76
+ # name and clip timestamps are stamped by this harness. Comparing them by
77
+ # value produced eleven guaranteed "divergent" rows per run — noise that
78
+ # buries the findings the report exists to surface.
79
+ "GetUniqueId",
80
+ "GetMediaId",
81
+ "GetName",
82
+ "GetClipProperty",
83
+ "GetProjectListInCurrentFolder",
84
+ }) | VOLATILE_METHODS
85
+
86
+ #: Suffix for observations recording a written file's size. Compared as
87
+ #: zero-versus-nonzero rather than exactly: whether a file was produced is the
88
+ #: capability question, while its exact byte count varies with the timestamped
89
+ #: project name baked into every interchange export.
90
+ BYTES_SUFFIX = "_bytes"
91
+
92
+ #: Read-only surface probes whose value legitimately differs between a session
93
+ #: with a UI and one without, *without that being a capability difference*.
94
+ #: `GetCurrentPage` is the canonical case: headless has no page to be on, so it
95
+ #: answers None. That is a true and expected consequence of having no UI, and
96
+ #: reporting it as a degradation every run would train the reader to skim.
97
+ EXPECTED_MODE_DIFFERENCES = {
98
+ "GetCurrentPage": "Headless has no page; None is correct, not a failure.",
99
+ }
100
+
101
+ VERDICTS = (
102
+ "parity",
103
+ "headless_degraded",
104
+ "gui_degraded",
105
+ "both_failed",
106
+ "divergent",
107
+ "untested",
108
+ )
109
+
110
+
111
+ def _is_failure(observation: Any) -> bool:
112
+ """Did this observation represent the call not working?
113
+
114
+ Deliberately narrow. `False` and `None` are failures because the Resolve API
115
+ signals failure with exactly those two values across most of its surface, and
116
+ an empty container is a failure for calls whose entire purpose is to return
117
+ items. A zero or an empty string is *not* treated as failure — those are
118
+ legitimate readings (a timeline with no markers, an unset property), and
119
+ counting them would manufacture degradations that do not exist.
120
+ """
121
+ if isinstance(observation, dict):
122
+ # Surface probes report an `outcome` rather than a boolean. A method that
123
+ # raised, that is missing from the object, or whose object could not be
124
+ # reached at all did not work — and which of those three it was matters
125
+ # enough to survive into the finding, so the raw dict is carried along.
126
+ outcome = observation.get("outcome")
127
+ if outcome in ("raised", "absent", "unreachable"):
128
+ return True
129
+ if "error" in observation and observation["error"]:
130
+ return True
131
+ if "ok" in observation:
132
+ return not observation["ok"]
133
+ if "success" in observation:
134
+ return not observation["success"]
135
+ return False
136
+ if observation is None or observation is False:
137
+ return True
138
+ return False
139
+
140
+
141
+ def _shape(value: Any) -> str:
142
+ """Type-level description, for comparing things whose value must differ."""
143
+ if isinstance(value, dict):
144
+ return f"dict[{len(value)}]"
145
+ if isinstance(value, list):
146
+ return f"list[{len(value)}]"
147
+ return type(value).__name__
148
+
149
+
150
+ def classify(name: str, gui: Any, headless: Any, *, present_in: Tuple[bool, bool]) -> Dict[str, Any]:
151
+ """One observation, two modes, one verdict."""
152
+ in_gui, in_headless = present_in
153
+ if not (in_gui and in_headless):
154
+ return {
155
+ "name": name,
156
+ "verdict": "untested",
157
+ "reason": "observed in GUI run only" if in_gui else "observed in headless run only",
158
+ }
159
+
160
+ gui_failed = _is_failure(gui)
161
+ headless_failed = _is_failure(headless)
162
+
163
+ if gui_failed and headless_failed:
164
+ return {"name": name, "verdict": "both_failed", "gui": gui, "headless": headless}
165
+ if headless_failed and not gui_failed:
166
+ return {
167
+ "name": name,
168
+ "verdict": "headless_degraded",
169
+ "gui": gui,
170
+ "headless": headless,
171
+ "reason": "succeeded with a UI, failed without one",
172
+ }
173
+ if gui_failed and not headless_failed:
174
+ return {
175
+ "name": name,
176
+ "verdict": "gui_degraded",
177
+ "gui": gui,
178
+ "headless": headless,
179
+ "reason": "succeeded headless, failed with a UI",
180
+ }
181
+
182
+ if name in EXPECTED_MODE_DIFFERENCES:
183
+ return {
184
+ "name": name,
185
+ "verdict": "parity",
186
+ "reason": EXPECTED_MODE_DIFFERENCES[name],
187
+ "gui": gui,
188
+ "headless": headless,
189
+ "expected_difference": True,
190
+ }
191
+ if name.endswith(BYTES_SUFFIX):
192
+ if bool(gui) == bool(headless):
193
+ return {"name": name, "verdict": "parity", "reason": "both wrote a non-empty file"}
194
+ return {
195
+ "name": name,
196
+ "verdict": "headless_degraded" if not headless else "gui_degraded",
197
+ "gui": gui,
198
+ "headless": headless,
199
+ "reason": "one mode wrote a file and the other wrote nothing",
200
+ }
201
+ if name in SESSION_VARIANT_KEYS:
202
+ if _shape(gui) == _shape(headless):
203
+ return {"name": name, "verdict": "parity", "reason": "session-variant; compared by type"}
204
+ return {
205
+ "name": name,
206
+ "verdict": "divergent",
207
+ "gui": _shape(gui),
208
+ "headless": _shape(headless),
209
+ "reason": "session-variant, and even the type differs",
210
+ }
211
+
212
+ if gui == headless:
213
+ return {"name": name, "verdict": "parity"}
214
+ return {
215
+ "name": name,
216
+ "verdict": "divergent",
217
+ "gui": gui,
218
+ "headless": headless,
219
+ "reason": "both succeeded, values differ",
220
+ }
221
+
222
+
223
+ def _flatten(report: Dict[str, Any]) -> Dict[str, Any]:
224
+ """`{qualified_name: observation}` from a recorded run.
225
+
226
+ Read-only surface probes and scenario observations are namespaced apart so a
227
+ scenario key can never collide with an API method of the same name.
228
+ """
229
+ flat: Dict[str, Any] = {}
230
+ for method, result in (report.get("surface") or {}).items():
231
+ if method.startswith("_"):
232
+ continue
233
+ flat[f"surface::{method}"] = result
234
+ for scenario, payload in (report.get("scenarios") or {}).items():
235
+ if not isinstance(payload, dict):
236
+ flat[f"scenario::{scenario}"] = payload
237
+ continue
238
+ for key, value in payload.items():
239
+ flat[f"{scenario}::{key}"] = value
240
+ return flat
241
+
242
+
243
+ def build_matrix(gui_report: Dict[str, Any], headless_report: Dict[str, Any]) -> Dict[str, Any]:
244
+ """Difference two recorded runs into a capability matrix."""
245
+ gui_flat = _flatten(gui_report)
246
+ headless_flat = _flatten(headless_report)
247
+ names = sorted(set(gui_flat) | set(headless_flat))
248
+
249
+ findings: List[Dict[str, Any]] = []
250
+ for name in names:
251
+ # The bare observation name drives the exemption lookups, so
252
+ # `render::output_path` is matched against `output_path`, and
253
+ # `surface::timeline.GetCurrentTimecode` against `GetCurrentTimecode`,
254
+ # rather than against a namespaced key nobody would think to add to the
255
+ # exemption lists.
256
+ bare = name.split("::")[-1].split(".")[-1]
257
+ findings.append(
258
+ classify(
259
+ bare,
260
+ gui_flat.get(name),
261
+ headless_flat.get(name),
262
+ present_in=(name in gui_flat, name in headless_flat),
263
+ )
264
+ | {"key": name}
265
+ )
266
+
267
+ counts = {verdict: 0 for verdict in VERDICTS}
268
+ for finding in findings:
269
+ counts[finding["verdict"]] += 1
270
+
271
+ return {
272
+ "gui_metadata": gui_report.get("metadata", {}),
273
+ "headless_metadata": headless_report.get("metadata", {}),
274
+ "counts": counts,
275
+ "findings": findings,
276
+ }
277
+
278
+
279
+ def by_group(matrix: Dict[str, Any]) -> Dict[str, Dict[str, int]]:
280
+ """Verdict counts per scenario (or `surface`), so parity is legible.
281
+
282
+ A report that prints "233 parity" and then lists only the failures is asking
283
+ to be read as "everything else was fine", when it could equally mean the
284
+ probe never ran. Breaking the count down by group makes the coverage itself
285
+ reviewable: a scenario that contributed two observations instead of ten is
286
+ visible at a glance.
287
+ """
288
+ groups: Dict[str, Dict[str, int]] = {}
289
+ for finding in matrix["findings"]:
290
+ group = finding["key"].split("::")[0]
291
+ counts = groups.setdefault(group, {verdict: 0 for verdict in VERDICTS})
292
+ counts[finding["verdict"]] += 1
293
+ return groups
294
+
295
+
296
+ def degradations(matrix: Dict[str, Any]) -> List[Dict[str, Any]]:
297
+ """The findings an agent has to act on, in the order they should be read."""
298
+ order = {"headless_degraded": 0, "gui_degraded": 1, "divergent": 2, "untested": 3}
299
+ return sorted(
300
+ (f for f in matrix["findings"] if f["verdict"] in order),
301
+ key=lambda f: (order[f["verdict"]], f["key"]),
302
+ )
303
+
304
+
305
+ def render_matrix_markdown(matrix: Dict[str, Any]) -> str:
306
+ gui_meta = matrix.get("gui_metadata", {})
307
+ headless_meta = matrix.get("headless_metadata", {})
308
+ counts = matrix["counts"]
309
+
310
+ lines = [
311
+ "# Headless (-nogui) capability differential",
312
+ "",
313
+ f"- GUI run: {gui_meta.get('recorded_at', 'unknown')} — {gui_meta.get('version', 'unknown')}",
314
+ f"- Headless run: {headless_meta.get('recorded_at', 'unknown')} — {headless_meta.get('version', 'unknown')}",
315
+ "",
316
+ "| verdict | count |",
317
+ "| --- | --- |",
318
+ ]
319
+ for verdict in VERDICTS:
320
+ lines.append(f"| {verdict} | {counts.get(verdict, 0)} |")
321
+
322
+ lines += [
323
+ "",
324
+ "## Coverage",
325
+ "",
326
+ "What was actually exercised, so the parity count above is reviewable "
327
+ "rather than merely large.",
328
+ "",
329
+ "| group | observations | parity | headless_degraded | gui_degraded | both_failed | divergent |",
330
+ "| --- | --- | --- | --- | --- | --- | --- |",
331
+ ]
332
+ for group, counts in sorted(by_group(matrix).items()):
333
+ total = sum(counts.values())
334
+ lines.append(
335
+ f"| {group} | {total} | {counts['parity']} | {counts['headless_degraded']} | "
336
+ f"{counts['gui_degraded']} | {counts['both_failed']} | {counts['divergent']} |"
337
+ )
338
+
339
+ acted_on = degradations(matrix)
340
+ lines += ["", "## Findings", ""]
341
+ if not acted_on:
342
+ lines.append("No differences: every observation reached parity.")
343
+ return "\n".join(lines) + "\n"
344
+
345
+ lines += ["| observation | verdict | GUI | headless | note |", "| --- | --- | --- | --- | --- |"]
346
+ for finding in acted_on:
347
+ lines.append(
348
+ "| `{key}` | {verdict} | {gui} | {headless} | {reason} |".format(
349
+ key=finding["key"],
350
+ verdict=finding["verdict"],
351
+ gui=_cell(finding.get("gui")),
352
+ headless=_cell(finding.get("headless")),
353
+ reason=finding.get("reason", ""),
354
+ )
355
+ )
356
+ return "\n".join(lines) + "\n"
357
+
358
+
359
+ def _cell(value: Any, limit: int = 60) -> str:
360
+ if value is None:
361
+ return "—"
362
+ text = str(value).replace("|", "\\|").replace("\n", " ")
363
+ return text if len(text) <= limit else text[: limit - 1] + "…"