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
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""The probe catalogue for GUI-vs-headless differencing, and its verdicts.
|
|
2
|
+
|
|
3
|
+
This is the second attempt. The first (`headless_differential`) compared 238
|
|
4
|
+
observations and found zero mode-dependent failures — a result that was true as
|
|
5
|
+
far as it went and badly incomplete, because it could not observe the failure
|
|
6
|
+
that actually matters. `ProjectManager.SaveProject()` on the never-saved
|
|
7
|
+
`Untitled Project` does not *fail* headless; it **never returns**. A harness with
|
|
8
|
+
no timeouts cannot record that. It just stops, and its report is whatever it had
|
|
9
|
+
managed to write down before it died.
|
|
10
|
+
|
|
11
|
+
So this module adds the verdict the first one lacked:
|
|
12
|
+
|
|
13
|
+
hang — the call did not return within its timeout
|
|
14
|
+
|
|
15
|
+
and the catalogue is written so every probe carries its own timeout and its own
|
|
16
|
+
preconditions. `SaveProject` is not inherently dangerous; it is dangerous *on a
|
|
17
|
+
project that has never been saved*. State is part of the probe, not background.
|
|
18
|
+
|
|
19
|
+
## Why probes are data rather than a script
|
|
20
|
+
|
|
21
|
+
Each probe is a named unit with a category, a timeout and a body. That shape is
|
|
22
|
+
what lets the runner isolate them: results stream out one at a time, so when a
|
|
23
|
+
worker dies mid-probe the supervisor knows exactly which one was in flight, marks
|
|
24
|
+
it `hang`, and resumes after it. A single linear script cannot be resumed at the
|
|
25
|
+
call that killed it.
|
|
26
|
+
|
|
27
|
+
## Verdicts
|
|
28
|
+
|
|
29
|
+
Beyond the first module's set:
|
|
30
|
+
|
|
31
|
+
- ``hang`` — no return within the timeout, in at least one mode.
|
|
32
|
+
- ``hang_headless`` — returned in the GUI, hung headless. The SaveProject
|
|
33
|
+
class of bug, and the reason this file exists.
|
|
34
|
+
- ``hang_gui`` — the reverse.
|
|
35
|
+
|
|
36
|
+
A hang is deliberately NOT folded into "failed". A call that returns False is
|
|
37
|
+
survivable and usually recoverable; a call that never returns takes the session
|
|
38
|
+
with it and, as measured, degrades the instance afterwards. An agent must treat
|
|
39
|
+
them differently, so the report must distinguish them.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
45
|
+
|
|
46
|
+
#: Probe outcomes recorded by the worker, before any cross-mode comparison.
|
|
47
|
+
OUTCOMES = ("ok", "false", "none", "empty", "raised", "hang", "skipped")
|
|
48
|
+
|
|
49
|
+
#: How a single probe result maps to "did this work". `false`/`none` are the
|
|
50
|
+
#: Resolve API's two ways of saying no; `empty` is a container where the point of
|
|
51
|
+
#: the call was to return items.
|
|
52
|
+
FAILED_OUTCOMES = frozenset({"false", "none", "raised"})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Probe:
|
|
56
|
+
"""One named, timed, isolated observation.
|
|
57
|
+
|
|
58
|
+
`needs` names the fixture attributes the body requires (``project``,
|
|
59
|
+
``timeline``, ``item``, ``clip``). A probe whose needs are unmet is recorded
|
|
60
|
+
`skipped` with the reason, never silently dropped — an absent row in a
|
|
61
|
+
differential reads as agreement.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
name: str,
|
|
67
|
+
category: str,
|
|
68
|
+
body: Callable[[Any], Any],
|
|
69
|
+
*,
|
|
70
|
+
timeout: float = 30.0,
|
|
71
|
+
needs: tuple = (),
|
|
72
|
+
state: str = "scratch",
|
|
73
|
+
destructive: bool = False,
|
|
74
|
+
blocking: bool = False,
|
|
75
|
+
note: str = "",
|
|
76
|
+
) -> None:
|
|
77
|
+
self.name = name
|
|
78
|
+
self.category = category
|
|
79
|
+
self.body = body
|
|
80
|
+
self.timeout = timeout
|
|
81
|
+
self.needs = needs
|
|
82
|
+
#: Which project must be current: "scratch" (the disposable one) or
|
|
83
|
+
#: "untitled" (Resolve's never-saved default). The second exists because
|
|
84
|
+
#: the only mode-dependent hang found so far only happens there.
|
|
85
|
+
self.state = state
|
|
86
|
+
self.destructive = destructive
|
|
87
|
+
#: Blocks the ENTIRE application, not just the calling client — a modal
|
|
88
|
+
#: with no one to dismiss it. Excluded from sweeps unless asked for by
|
|
89
|
+
#: name, because one of these wedges the machine and costs a force-kill
|
|
90
|
+
#: and a slow restart. Measured: `pm.save_untitled_project` does this in
|
|
91
|
+
#: BOTH modes, so it is not something a mode sweep can route around.
|
|
92
|
+
self.blocking = blocking
|
|
93
|
+
self.note = note
|
|
94
|
+
|
|
95
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
96
|
+
return f"<Probe {self.name} ({self.category}) t={self.timeout}s state={self.state}>"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def classify_outcome(value: Any) -> str:
|
|
100
|
+
"""Map a returned value onto an outcome name.
|
|
101
|
+
|
|
102
|
+
`empty` is separated from `false`/`none` because an empty list is a real
|
|
103
|
+
answer for some calls (a timeline with no markers) and a failure for others
|
|
104
|
+
(an enumeration that should never be empty). Recording which it was lets the
|
|
105
|
+
comparison stay strict without inventing failures.
|
|
106
|
+
"""
|
|
107
|
+
if value is False:
|
|
108
|
+
return "false"
|
|
109
|
+
if value is None:
|
|
110
|
+
return "none"
|
|
111
|
+
if isinstance(value, (list, dict, tuple, str)) and len(value) == 0:
|
|
112
|
+
return "empty"
|
|
113
|
+
return "ok"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
VERDICTS = (
|
|
117
|
+
"parity",
|
|
118
|
+
"flaky",
|
|
119
|
+
"hang_headless",
|
|
120
|
+
"hang_gui",
|
|
121
|
+
"headless_degraded",
|
|
122
|
+
"gui_degraded",
|
|
123
|
+
"both_hang",
|
|
124
|
+
"both_failed",
|
|
125
|
+
"divergent",
|
|
126
|
+
"untested",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
#: Probes whose value legitimately differs between two sequential sweeps because
|
|
130
|
+
#: the sweep itself moved app-global state. Compared by outcome only.
|
|
131
|
+
#: `app.current_page` is the case: the page is one global setting and the `pages`
|
|
132
|
+
#: probes walk all seven of it, so whichever page the sweep happened to end on is
|
|
133
|
+
#: read back later. Reporting that every run trains the reader to skim the
|
|
134
|
+
#: findings table, which is the one table that must stay worth reading.
|
|
135
|
+
ORDER_SENSITIVE = frozenset({"app.current_page"})
|
|
136
|
+
|
|
137
|
+
#: Verdicts that describe a real mode difference an agent must act on.
|
|
138
|
+
ACTIONABLE = ("hang_headless", "hang_gui", "headless_degraded", "gui_degraded", "divergent")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def compare_repeated(name: str, gui_runs: List[Dict[str, Any]],
|
|
142
|
+
headless_runs: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
143
|
+
"""Compare N runs per mode, and refuse to call a flaky probe a difference.
|
|
144
|
+
|
|
145
|
+
This exists because a single pass per mode reports noise as findings, and it
|
|
146
|
+
was measured doing exactly that. Across four sweeps, `app.keyframe_mode`
|
|
147
|
+
returned `0` and `None` in BOTH modes depending on the run — a single pair
|
|
148
|
+
would have reported it as `headless_degraded` or `gui_degraded` with equal
|
|
149
|
+
confidence, and the direction it picked was decided by which sweeps happened
|
|
150
|
+
to be compared. `gallery.export_stills` succeeded in the GUI in two sweeps
|
|
151
|
+
and failed in a third.
|
|
152
|
+
|
|
153
|
+
So: a probe whose outcomes are not internally consistent *within* a mode
|
|
154
|
+
cannot support any claim about the difference *between* modes. It is reported
|
|
155
|
+
`flaky`, with the observed values, and kept out of the findings table. That
|
|
156
|
+
is a weaker result and the only honest one — and it is information in its own
|
|
157
|
+
right, since a call that is unreliable run to run is one an agent must verify
|
|
158
|
+
rather than trust.
|
|
159
|
+
"""
|
|
160
|
+
if not gui_runs or not headless_runs:
|
|
161
|
+
return {"probe": name, "verdict": "untested",
|
|
162
|
+
"reason": f"gui runs={len(gui_runs)}, headless runs={len(headless_runs)}",
|
|
163
|
+
"gui": gui_runs[0] if gui_runs else None,
|
|
164
|
+
"headless": headless_runs[0] if headless_runs else None}
|
|
165
|
+
|
|
166
|
+
def signature(runs):
|
|
167
|
+
return {f"{r.get('outcome')}:{r.get('value')}" for r in runs}
|
|
168
|
+
|
|
169
|
+
gui_sig, headless_sig = signature(gui_runs), signature(headless_runs)
|
|
170
|
+
unstable = [m for m, sig in (("gui", gui_sig), ("headless", headless_sig)) if len(sig) > 1]
|
|
171
|
+
if unstable:
|
|
172
|
+
return {
|
|
173
|
+
"probe": name, "verdict": "flaky",
|
|
174
|
+
"reason": f"not reproducible within {'/'.join(unstable)} across "
|
|
175
|
+
f"{len(gui_runs)} GUI and {len(headless_runs)} headless run(s)",
|
|
176
|
+
"gui": gui_runs[-1], "headless": headless_runs[-1],
|
|
177
|
+
"gui_observed": sorted(gui_sig), "headless_observed": sorted(headless_sig),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
verdict = compare_probe(name, gui_runs[-1], headless_runs[-1])
|
|
181
|
+
verdict["runs"] = {"gui": len(gui_runs), "headless": len(headless_runs)}
|
|
182
|
+
return verdict
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def build_repeated_matrix(gui_stores: List[Dict[str, Dict[str, Any]]],
|
|
186
|
+
headless_stores: List[Dict[str, Dict[str, Any]]]) -> Dict[str, Any]:
|
|
187
|
+
names = sorted({n for store in gui_stores + headless_stores for n in store})
|
|
188
|
+
findings = [
|
|
189
|
+
compare_repeated(n,
|
|
190
|
+
[s[n] for s in gui_stores if n in s],
|
|
191
|
+
[s[n] for s in headless_stores if n in s])
|
|
192
|
+
for n in names
|
|
193
|
+
]
|
|
194
|
+
counts = {v: 0 for v in VERDICTS}
|
|
195
|
+
for f in findings:
|
|
196
|
+
counts[f["verdict"]] += 1
|
|
197
|
+
return {"counts": counts, "findings": findings, "probe_count": len(names),
|
|
198
|
+
"runs": {"gui": len(gui_stores), "headless": len(headless_stores)}}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def compare_probe(name: str, gui: Optional[Dict[str, Any]], headless: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
202
|
+
"""Two recorded probe results, one verdict."""
|
|
203
|
+
if gui is None or headless is None:
|
|
204
|
+
return {
|
|
205
|
+
"probe": name,
|
|
206
|
+
"verdict": "untested",
|
|
207
|
+
"reason": "recorded in one mode only",
|
|
208
|
+
"gui": gui,
|
|
209
|
+
"headless": headless,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
g_out, h_out = gui.get("outcome"), headless.get("outcome")
|
|
213
|
+
if "skipped" in (g_out, h_out):
|
|
214
|
+
return {"probe": name, "verdict": "untested",
|
|
215
|
+
"reason": gui.get("detail") or headless.get("detail") or "skipped",
|
|
216
|
+
"gui": gui, "headless": headless}
|
|
217
|
+
|
|
218
|
+
# Hangs first and separately: a call that never returns is a different
|
|
219
|
+
# problem from one that returns False, and collapsing them would hide the
|
|
220
|
+
# only class of bug this harness was built to find.
|
|
221
|
+
if g_out == "hang" and h_out == "hang":
|
|
222
|
+
return {"probe": name, "verdict": "both_hang", "gui": gui, "headless": headless}
|
|
223
|
+
if h_out == "hang":
|
|
224
|
+
return {"probe": name, "verdict": "hang_headless", "gui": gui, "headless": headless,
|
|
225
|
+
"reason": "returned with a UI, never returned without one"}
|
|
226
|
+
if g_out == "hang":
|
|
227
|
+
return {"probe": name, "verdict": "hang_gui", "gui": gui, "headless": headless,
|
|
228
|
+
"reason": "returned headless, never returned with a UI"}
|
|
229
|
+
|
|
230
|
+
g_failed, h_failed = g_out in FAILED_OUTCOMES, h_out in FAILED_OUTCOMES
|
|
231
|
+
if g_failed and h_failed:
|
|
232
|
+
return {"probe": name, "verdict": "both_failed", "gui": gui, "headless": headless}
|
|
233
|
+
if h_failed:
|
|
234
|
+
return {"probe": name, "verdict": "headless_degraded", "gui": gui, "headless": headless,
|
|
235
|
+
"reason": "succeeded with a UI, failed without one"}
|
|
236
|
+
if g_failed:
|
|
237
|
+
return {"probe": name, "verdict": "gui_degraded", "gui": gui, "headless": headless,
|
|
238
|
+
"reason": "succeeded headless, failed with a UI"}
|
|
239
|
+
|
|
240
|
+
if name in ORDER_SENSITIVE:
|
|
241
|
+
return {"probe": name, "verdict": "parity", "gui": gui, "headless": headless,
|
|
242
|
+
"reason": "app-global state the sweep itself moves; compared by outcome only"}
|
|
243
|
+
if gui.get("value") == headless.get("value"):
|
|
244
|
+
return {"probe": name, "verdict": "parity", "gui": gui, "headless": headless}
|
|
245
|
+
return {"probe": name, "verdict": "divergent", "gui": gui, "headless": headless,
|
|
246
|
+
"reason": "both returned, values differ"}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def build_matrix(gui: Dict[str, Dict[str, Any]], headless: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
|
250
|
+
names = sorted(set(gui) | set(headless))
|
|
251
|
+
findings = [compare_probe(n, gui.get(n), headless.get(n)) for n in names]
|
|
252
|
+
counts = {v: 0 for v in VERDICTS}
|
|
253
|
+
for f in findings:
|
|
254
|
+
counts[f["verdict"]] += 1
|
|
255
|
+
return {"counts": counts, "findings": findings, "probe_count": len(names)}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def actionable(matrix: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
259
|
+
order = {v: i for i, v in enumerate(ACTIONABLE)}
|
|
260
|
+
return sorted(
|
|
261
|
+
(f for f in matrix["findings"] if f["verdict"] in order),
|
|
262
|
+
key=lambda f: (order[f["verdict"]], f["probe"]),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def render_markdown(matrix: Dict[str, Any], *, title: str = "GUI vs headless probe matrix") -> str:
|
|
267
|
+
counts = matrix["counts"]
|
|
268
|
+
runs = matrix.get("runs")
|
|
269
|
+
header = f"{matrix['probe_count']} probes"
|
|
270
|
+
if runs:
|
|
271
|
+
header += f", {runs['gui']} GUI run(s) x {runs['headless']} headless run(s)"
|
|
272
|
+
lines = [f"# {title}", "", header + ".", "",
|
|
273
|
+
"| verdict | count |", "| --- | --- |"]
|
|
274
|
+
for v in VERDICTS:
|
|
275
|
+
lines.append(f"| {v} | {counts.get(v, 0)} |")
|
|
276
|
+
|
|
277
|
+
by_cat: Dict[str, Dict[str, int]] = {}
|
|
278
|
+
for f in matrix["findings"]:
|
|
279
|
+
cat = (f.get("gui") or f.get("headless") or {}).get("category", "?")
|
|
280
|
+
by_cat.setdefault(cat, {v: 0 for v in VERDICTS})[f["verdict"]] += 1
|
|
281
|
+
lines += ["", "## Coverage by category", "",
|
|
282
|
+
"| category | probes | parity | hang_headless | headless_degraded | gui_degraded | divergent | untested |",
|
|
283
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |"]
|
|
284
|
+
for cat, c in sorted(by_cat.items()):
|
|
285
|
+
lines.append(
|
|
286
|
+
f"| {cat} | {sum(c.values())} | {c['parity']} | {c['hang_headless']} | "
|
|
287
|
+
f"{c['headless_degraded']} | {c['gui_degraded']} | {c['divergent']} | {c['untested']} |"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
flaky = [f for f in matrix["findings"] if f["verdict"] == "flaky"]
|
|
291
|
+
if flaky:
|
|
292
|
+
lines += ["", "## Not reproducible (excluded from findings)", "",
|
|
293
|
+
"These returned different things run-to-run *within* a single mode, so they "
|
|
294
|
+
"cannot support a claim about the difference between modes. Listed because "
|
|
295
|
+
"an unreliable call is itself something an agent must verify rather than trust.",
|
|
296
|
+
"", "| probe | observed in GUI | observed headless |", "| --- | --- | --- |"]
|
|
297
|
+
for f in flaky:
|
|
298
|
+
lines.append("| `{p}` | {g} | {h} |".format(
|
|
299
|
+
p=f["probe"],
|
|
300
|
+
g="; ".join(str(x)[:40] for x in f.get("gui_observed", [])),
|
|
301
|
+
h="; ".join(str(x)[:40] for x in f.get("headless_observed", []))))
|
|
302
|
+
|
|
303
|
+
acted = actionable(matrix)
|
|
304
|
+
lines += ["", "## Mode-dependent findings", ""]
|
|
305
|
+
if not acted:
|
|
306
|
+
lines.append("None: every probe that ran in both modes agreed.")
|
|
307
|
+
return "\n".join(lines) + "\n"
|
|
308
|
+
lines += [
|
|
309
|
+
"> **Re-verify every row below in isolation before believing it.** Probes run in "
|
|
310
|
+
"catalogue order against one shared fixture, so a probe near the end has ~90 "
|
|
311
|
+
"destructive probes' worth of accumulated state behind it, and the two modes can "
|
|
312
|
+
"drift apart for reasons that have nothing to do with the mode. Measured: five "
|
|
313
|
+
"findings here — nested timelines, clip linking, take selectors, multi-job queues "
|
|
314
|
+
"and render presets — all reproduced as mode differences in a full sweep and all "
|
|
315
|
+
"came out **identical** when re-run with `--only`. Isolation is the test:",
|
|
316
|
+
"",
|
|
317
|
+
"> ```",
|
|
318
|
+
"> python scripts/mode_matrix.py run --mode gui --out iso_gui.jsonl --only <probe>",
|
|
319
|
+
"> python scripts/mode_matrix.py run --mode headless --out iso_hl.jsonl --only <probe>",
|
|
320
|
+
"> ```",
|
|
321
|
+
"",
|
|
322
|
+
]
|
|
323
|
+
lines += ["| probe | verdict | GUI | headless | note |", "| --- | --- | --- | --- | --- |"]
|
|
324
|
+
for f in acted:
|
|
325
|
+
lines.append("| `{p}` | {v} | {g} | {h} | {r} |".format(
|
|
326
|
+
p=f["probe"], v=f["verdict"],
|
|
327
|
+
g=_cell(f.get("gui")), h=_cell(f.get("headless")), r=f.get("reason", "")))
|
|
328
|
+
return "\n".join(lines) + "\n"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _cell(result: Optional[Dict[str, Any]], limit: int = 46) -> str:
|
|
332
|
+
if not result:
|
|
333
|
+
return "—"
|
|
334
|
+
out = result.get("outcome")
|
|
335
|
+
value = result.get("value")
|
|
336
|
+
text = out if value in (None, "") else f"{out}: {value}"
|
|
337
|
+
text = str(text).replace("|", "\\|").replace("\n", " ")
|
|
338
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|