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,345 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Exhaustively difference GUI against headless Resolve, surviving hangs.
|
|
3
|
+
|
|
4
|
+
python scripts/mode_matrix.py run --mode gui --out runs/gui.jsonl
|
|
5
|
+
python scripts/mode_matrix.py run --mode headless --out runs/headless.jsonl
|
|
6
|
+
python scripts/mode_matrix.py compare runs/gui.jsonl runs/headless.jsonl \
|
|
7
|
+
--out docs/reference/headless-capability-matrix.md
|
|
8
|
+
|
|
9
|
+
## The architecture, and why it is not one script
|
|
10
|
+
|
|
11
|
+
`ProjectManager.SaveProject()` on the never-saved `Untitled Project` never
|
|
12
|
+
returns headless. Any harness that calls it in-process stops there, and its
|
|
13
|
+
report is whatever it wrote before it died. The first differential could not have
|
|
14
|
+
found that bug, and did not.
|
|
15
|
+
|
|
16
|
+
So this splits in two:
|
|
17
|
+
|
|
18
|
+
- a **worker** that runs probes in order and streams each result to JSONL,
|
|
19
|
+
flushed per line;
|
|
20
|
+
- a **supervisor** that runs the worker with a deadline. If the worker exits
|
|
21
|
+
early or overruns, the supervisor reads the JSONL, sees which probe has no
|
|
22
|
+
result, records it as `hang`, restarts Resolve, waits for it to be healthy,
|
|
23
|
+
and relaunches the worker to resume *after* the offender.
|
|
24
|
+
|
|
25
|
+
The consequence worth stating: a hang costs one probe and one Resolve restart,
|
|
26
|
+
not the run. That is what makes "exhaustive" possible at all.
|
|
27
|
+
|
|
28
|
+
## Health, not liveness
|
|
29
|
+
|
|
30
|
+
Between batches the supervisor requires `GetCurrentDatabase()` to be non-None.
|
|
31
|
+
Resolve can accept scripting connections, answer product/version/page queries,
|
|
32
|
+
and still have no database attached — in that state every project call returns
|
|
33
|
+
False forever. A liveness check passes straight through it; only the database
|
|
34
|
+
reading catches it.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import subprocess
|
|
43
|
+
import sys
|
|
44
|
+
import time
|
|
45
|
+
from datetime import datetime, timezone
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Any, Dict, List, Optional
|
|
48
|
+
|
|
49
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
50
|
+
|
|
51
|
+
from src.utils import mode_matrix as mm # noqa: E402
|
|
52
|
+
from src.utils import resolve_runtime as rr # noqa: E402
|
|
53
|
+
from src.utils.probe_catalogue import CATALOGUE, fingerprint # noqa: E402
|
|
54
|
+
|
|
55
|
+
WORKER = Path(__file__).resolve().parent / "mode_matrix_worker.py"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def scripting_env() -> Dict[str, str]:
|
|
59
|
+
env = dict(os.environ)
|
|
60
|
+
api = env.get(
|
|
61
|
+
"RESOLVE_SCRIPT_API",
|
|
62
|
+
"/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting",
|
|
63
|
+
)
|
|
64
|
+
env["RESOLVE_SCRIPT_API"] = api
|
|
65
|
+
env.setdefault(
|
|
66
|
+
"RESOLVE_SCRIPT_LIB",
|
|
67
|
+
"/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so",
|
|
68
|
+
)
|
|
69
|
+
modules = str(Path(api) / "Modules")
|
|
70
|
+
env["PYTHONPATH"] = f"{modules}:{env['PYTHONPATH']}" if env.get("PYTHONPATH") else modules
|
|
71
|
+
env.pop("DAVINCI_RESOLVE_BRIDGE", None)
|
|
72
|
+
return env
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── Resolve supervision ──────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def health(timeout: float = 420.0) -> Optional[Dict[str, Any]]:
|
|
79
|
+
"""Wait for a Resolve whose ProjectManager has a database attached."""
|
|
80
|
+
code = (
|
|
81
|
+
"import DaVinciResolveScript as d, json, time, sys\n"
|
|
82
|
+
"for _ in range(int(sys.argv[1])):\n"
|
|
83
|
+
" r = d.scriptapp('Resolve')\n"
|
|
84
|
+
" if r is not None:\n"
|
|
85
|
+
" try:\n"
|
|
86
|
+
" pm = r.GetProjectManager()\n"
|
|
87
|
+
" db = pm.GetCurrentDatabase() if pm else None\n"
|
|
88
|
+
" if db:\n"
|
|
89
|
+
" print(json.dumps(db)); sys.exit(0)\n"
|
|
90
|
+
" except Exception: pass\n"
|
|
91
|
+
" time.sleep(1)\n"
|
|
92
|
+
"sys.exit(3)\n"
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
out = subprocess.run([sys.executable, "-c", code, str(int(timeout))],
|
|
96
|
+
env=scripting_env(), capture_output=True, text=True,
|
|
97
|
+
timeout=timeout + 60)
|
|
98
|
+
except subprocess.TimeoutExpired:
|
|
99
|
+
print(" health: timed out waiting for a database", file=sys.stderr)
|
|
100
|
+
return None
|
|
101
|
+
if out.returncode != 0:
|
|
102
|
+
# Say WHY. "no healthy Resolve" with no reason sent this investigation
|
|
103
|
+
# looking at Resolve twice when the probe subprocess had simply failed to
|
|
104
|
+
# start — an unhelpful error of exactly the kind this study keeps finding
|
|
105
|
+
# in the API it is measuring.
|
|
106
|
+
detail = (out.stderr or out.stdout or "").strip().splitlines()
|
|
107
|
+
print(f" health: probe exited {out.returncode}"
|
|
108
|
+
+ (f": {detail[-1][:200]}" if detail else " with no output"), file=sys.stderr)
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
return json.loads(out.stdout.strip().splitlines()[-1])
|
|
112
|
+
except Exception:
|
|
113
|
+
print(f" health: unparseable output {out.stdout.strip()[:200]!r}", file=sys.stderr)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def stop_resolve(force_after: float = 30.0) -> str:
|
|
118
|
+
"""Quit, escalating to a kill. Returns how it went."""
|
|
119
|
+
code = (
|
|
120
|
+
"import DaVinciResolveScript as d, signal\n"
|
|
121
|
+
"signal.signal(signal.SIGALRM, lambda *a: (_ for _ in ()).throw(SystemExit(7)))\n"
|
|
122
|
+
"signal.alarm(20)\n"
|
|
123
|
+
"r = d.scriptapp('Resolve')\n"
|
|
124
|
+
"r and r.Quit()\n"
|
|
125
|
+
)
|
|
126
|
+
subprocess.run([sys.executable, "-c", code], env=scripting_env(),
|
|
127
|
+
capture_output=True, text=True, timeout=60, check=False)
|
|
128
|
+
deadline = time.monotonic() + force_after
|
|
129
|
+
while time.monotonic() < deadline:
|
|
130
|
+
if not (rr.resolve_processes() or []):
|
|
131
|
+
return "clean"
|
|
132
|
+
time.sleep(1)
|
|
133
|
+
subprocess.run(["pkill", "-9", "-f", "MacOS/Resolve"], check=False)
|
|
134
|
+
time.sleep(8)
|
|
135
|
+
return "forced"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def start_resolve(headless: bool) -> bool:
|
|
139
|
+
command = rr.launch_command(headless=headless)
|
|
140
|
+
if command is None:
|
|
141
|
+
return False
|
|
142
|
+
subprocess.Popen(command, stdin=subprocess.DEVNULL,
|
|
143
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def restart(headless: bool) -> Optional[Dict[str, Any]]:
|
|
148
|
+
how = stop_resolve()
|
|
149
|
+
print(f" resolve stopped ({how}); restarting {'headless' if headless else 'gui'}", flush=True)
|
|
150
|
+
if not start_resolve(headless):
|
|
151
|
+
return None
|
|
152
|
+
# Generous: a boot after an unclean shutdown was measured at 2m05s to a
|
|
153
|
+
# usable script server, against under 3s clean. Giving up early here would
|
|
154
|
+
# abort the run for a Resolve that was merely slow.
|
|
155
|
+
return health(420.0)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── the run ──────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def completed_probes(path: Path) -> Dict[str, Dict[str, Any]]:
|
|
162
|
+
results: Dict[str, Dict[str, Any]] = {}
|
|
163
|
+
if not path.exists():
|
|
164
|
+
return results
|
|
165
|
+
for line in path.read_text(errors="replace").splitlines():
|
|
166
|
+
line = line.strip()
|
|
167
|
+
if not line:
|
|
168
|
+
continue
|
|
169
|
+
try:
|
|
170
|
+
record = json.loads(line)
|
|
171
|
+
except json.JSONDecodeError:
|
|
172
|
+
continue # a torn final line from a killed worker
|
|
173
|
+
# `_start` breadcrumbs are phase markers, not results; `last_phase`
|
|
174
|
+
# reads them. Keyed apart so a marker can never be mistaken for an
|
|
175
|
+
# outcome.
|
|
176
|
+
if "probe" in record:
|
|
177
|
+
results[record["probe"]] = record
|
|
178
|
+
return results
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def last_phase(path: Path) -> Optional[Dict[str, Any]]:
|
|
182
|
+
"""The most recent phase breadcrumb, naming what was in flight."""
|
|
183
|
+
if not path.exists():
|
|
184
|
+
return None
|
|
185
|
+
marker = None
|
|
186
|
+
for line in path.read_text(errors="replace").splitlines():
|
|
187
|
+
line = line.strip()
|
|
188
|
+
if not line:
|
|
189
|
+
continue
|
|
190
|
+
try:
|
|
191
|
+
record = json.loads(line)
|
|
192
|
+
except json.JSONDecodeError:
|
|
193
|
+
continue
|
|
194
|
+
if "_start" in record:
|
|
195
|
+
marker = record
|
|
196
|
+
return marker
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def append(path: Path, record: Dict[str, Any]) -> None:
|
|
200
|
+
with path.open("a") as handle:
|
|
201
|
+
handle.write(json.dumps(record, default=str) + "\n")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def run(mode: str, out: Path, only: Optional[List[str]], batch_timeout: float) -> int:
|
|
205
|
+
headless = mode == "headless"
|
|
206
|
+
observed = rr.runtime_mode()
|
|
207
|
+
if observed["running"] and bool(observed["headless"]) != headless:
|
|
208
|
+
print(f"REFUSE: a Resolve is running in the other mode: {observed['command_lines']}",
|
|
209
|
+
file=sys.stderr)
|
|
210
|
+
return 1
|
|
211
|
+
if not observed["running"]:
|
|
212
|
+
start_resolve(headless)
|
|
213
|
+
db = health()
|
|
214
|
+
if db is None:
|
|
215
|
+
print("REFUSE: no healthy Resolve (no database attached).", file=sys.stderr)
|
|
216
|
+
return 2
|
|
217
|
+
|
|
218
|
+
# Blocking probes are opt-in by name. One of them stops the whole
|
|
219
|
+
# application in both modes, costing a force-kill and a slow restart, so a
|
|
220
|
+
# sweep that included them by default could never run to completion
|
|
221
|
+
# unattended — which is the entire point of a sweep.
|
|
222
|
+
if only:
|
|
223
|
+
planned = [p.name for p in CATALOGUE if p.name in only]
|
|
224
|
+
else:
|
|
225
|
+
planned = [p.name for p in CATALOGUE if not p.blocking]
|
|
226
|
+
skipped_blocking = [p.name for p in CATALOGUE if p.blocking and p.name not in planned]
|
|
227
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
print(f"mode={mode} db={json.dumps(db)} probes={len(planned)} out={out}", flush=True)
|
|
229
|
+
if skipped_blocking:
|
|
230
|
+
# Never silently: an excluded probe that vanishes from the report reads
|
|
231
|
+
# as one that passed.
|
|
232
|
+
print(f" excluded (blocking, run explicitly with --only): {', '.join(skipped_blocking)}",
|
|
233
|
+
flush=True)
|
|
234
|
+
|
|
235
|
+
hangs = 0
|
|
236
|
+
while True:
|
|
237
|
+
done = completed_probes(out)
|
|
238
|
+
remaining = [n for n in planned if n not in done]
|
|
239
|
+
if not remaining:
|
|
240
|
+
break
|
|
241
|
+
print(f" worker: {len(remaining)} probe(s) remaining, starting at {remaining[0]}", flush=True)
|
|
242
|
+
proc = subprocess.Popen(
|
|
243
|
+
[sys.executable, "-u", str(WORKER), "--out", str(out), "--probes", *remaining],
|
|
244
|
+
env=scripting_env(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
|
245
|
+
)
|
|
246
|
+
try:
|
|
247
|
+
proc.communicate(timeout=batch_timeout)
|
|
248
|
+
except subprocess.TimeoutExpired:
|
|
249
|
+
proc.kill()
|
|
250
|
+
proc.communicate()
|
|
251
|
+
|
|
252
|
+
after = completed_probes(out)
|
|
253
|
+
newly = [n for n in remaining if n in after]
|
|
254
|
+
still = [n for n in remaining if n not in after]
|
|
255
|
+
if not still:
|
|
256
|
+
break
|
|
257
|
+
# The first unrecorded probe is the one that was in flight when the
|
|
258
|
+
# worker stopped. Nothing else can have taken it down: results are
|
|
259
|
+
# flushed per line, in order.
|
|
260
|
+
culprit = still[0]
|
|
261
|
+
probe = next(p for p in CATALOGUE if p.name == culprit)
|
|
262
|
+
marker = last_phase(out) or {}
|
|
263
|
+
phase = marker.get("phase") if marker.get("_start") == culprit else "unknown"
|
|
264
|
+
hangs += 1
|
|
265
|
+
print(f" !! {culprit} did not return in phase={phase} "
|
|
266
|
+
f"(worker completed {len(newly)} probe(s) first)", flush=True)
|
|
267
|
+
append(out, {
|
|
268
|
+
"probe": culprit, "category": probe.category, "outcome": "hang",
|
|
269
|
+
"value": None, "seconds": None, "mode": mode, "phase": phase,
|
|
270
|
+
"catalogue": fingerprint(),
|
|
271
|
+
# A hang during `state` setup is NOT evidence about the probe's own
|
|
272
|
+
# call; it is evidence about reaching the state. Recorded as such so
|
|
273
|
+
# the comparison does not report a bug in the wrong place.
|
|
274
|
+
"detail": ("hung while setting up its state, not in the probe call itself"
|
|
275
|
+
if phase == "state" else
|
|
276
|
+
"worker died or overran with this probe's call in flight"),
|
|
277
|
+
})
|
|
278
|
+
if restart(headless) is None:
|
|
279
|
+
print("REFUSE: Resolve did not come back healthy after a hang.", file=sys.stderr)
|
|
280
|
+
return 3
|
|
281
|
+
|
|
282
|
+
results = completed_probes(out)
|
|
283
|
+
counts: Dict[str, int] = {}
|
|
284
|
+
for record in results.values():
|
|
285
|
+
counts[record.get("outcome", "?")] = counts.get(record.get("outcome", "?"), 0) + 1
|
|
286
|
+
print(json.dumps({"mode": mode, "probes": len(results), "outcomes": counts,
|
|
287
|
+
"hangs_recovered": hangs}, indent=2))
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def compare(gui_paths: List[Path], headless_paths: List[Path], out: Optional[Path]) -> int:
|
|
292
|
+
gui_stores, headless_stores = [], []
|
|
293
|
+
for label, paths, sink in (("gui", gui_paths, gui_stores),
|
|
294
|
+
("headless", headless_paths, headless_stores)):
|
|
295
|
+
for path in paths:
|
|
296
|
+
store = completed_probes(path)
|
|
297
|
+
modes = {r.get("mode") for r in store.values()}
|
|
298
|
+
# A mislabeled file inverts every verdict silently; the recorded mode
|
|
299
|
+
# is the check, not the filename.
|
|
300
|
+
if modes and modes != {label}:
|
|
301
|
+
print(f"REFUSE: {path} was labelled {label} but contains {modes}", file=sys.stderr)
|
|
302
|
+
return 1
|
|
303
|
+
stamps = {r.get("catalogue") for r in store.values() if r.get("catalogue")}
|
|
304
|
+
if stamps and stamps != {fingerprint()}:
|
|
305
|
+
print(f"REFUSE: {path} was recorded with catalogue {stamps}, "
|
|
306
|
+
f"current is {fingerprint()}. Re-run it; comparing across "
|
|
307
|
+
f"catalogue versions reports harness changes as findings.",
|
|
308
|
+
file=sys.stderr)
|
|
309
|
+
return 1
|
|
310
|
+
sink.append(store)
|
|
311
|
+
matrix = mm.build_repeated_matrix(gui_stores, headless_stores)
|
|
312
|
+
matrix["generated_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
313
|
+
text = mm.render_markdown(matrix)
|
|
314
|
+
if out:
|
|
315
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
out.write_text(text, encoding="utf-8")
|
|
317
|
+
out.with_suffix(".json").write_text(json.dumps(matrix, indent=2, default=str), encoding="utf-8")
|
|
318
|
+
print(text)
|
|
319
|
+
return 0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def main() -> int:
|
|
323
|
+
parser = argparse.ArgumentParser(description=__doc__,
|
|
324
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
325
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
326
|
+
r = sub.add_parser("run")
|
|
327
|
+
r.add_argument("--mode", choices=("gui", "headless"), required=True)
|
|
328
|
+
r.add_argument("--out", type=Path, required=True)
|
|
329
|
+
r.add_argument("--only", nargs="*")
|
|
330
|
+
r.add_argument("--batch-timeout", type=float, default=900.0)
|
|
331
|
+
c = sub.add_parser("compare")
|
|
332
|
+
# Lists, not single files: one run per mode reports flakes as findings, which
|
|
333
|
+
# was measured happening. Repeats are how a difference is distinguished from
|
|
334
|
+
# noise.
|
|
335
|
+
c.add_argument("--gui", type=Path, nargs="+", required=True)
|
|
336
|
+
c.add_argument("--headless", type=Path, nargs="+", required=True)
|
|
337
|
+
c.add_argument("--out", type=Path)
|
|
338
|
+
args = parser.parse_args()
|
|
339
|
+
if args.command == "run":
|
|
340
|
+
return run(args.mode, args.out, args.only, args.batch_timeout)
|
|
341
|
+
return compare(args.gui, args.headless, args.out)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
if __name__ == "__main__":
|
|
345
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run probes in order, streaming one JSONL result per probe. Not run directly.
|
|
3
|
+
|
|
4
|
+
Launched by `scripts/mode_matrix.py`, which supervises it. The division of labour
|
|
5
|
+
is the whole design: this process is *expected* to die. When a probe never
|
|
6
|
+
returns there is nothing this side can do about it — the call is blocked inside
|
|
7
|
+
fusionscript, and a Python signal handler cannot preempt a thread parked in a C
|
|
8
|
+
`pthread_cond_wait`. So the worker's only obligation is to have already written
|
|
9
|
+
down everything it finished, in order, flushed, before it hangs. The supervisor
|
|
10
|
+
reads that and infers the culprit from what is missing.
|
|
11
|
+
|
|
12
|
+
Consequences of that contract, both load-bearing:
|
|
13
|
+
|
|
14
|
+
- every result is written and flushed immediately, never batched;
|
|
15
|
+
- probes run in the catalogue's order, so "first missing" is unambiguous.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import time
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Dict, Optional
|
|
31
|
+
|
|
32
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
33
|
+
|
|
34
|
+
from src.utils.mode_matrix import classify_outcome # noqa: E402
|
|
35
|
+
from src.utils.probe_catalogue import CATALOGUE, fingerprint # noqa: E402
|
|
36
|
+
from src.utils.project_cleanup import UNSAVED_DEFAULT_PROJECT, save_project_if_safe # noqa: E402
|
|
37
|
+
from src.utils import resolve_runtime as rr # noqa: E402
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Context:
|
|
41
|
+
"""What a probe body is handed."""
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self.resolve: Any = None
|
|
45
|
+
self.pm: Any = None
|
|
46
|
+
self.project: Any = None
|
|
47
|
+
self.timeline: Any = None
|
|
48
|
+
self.item: Any = None
|
|
49
|
+
self.clip: Any = None
|
|
50
|
+
self.work: Path = Path(tempfile.mkdtemp(prefix="mode_matrix_"))
|
|
51
|
+
self.project_name = ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def connect() -> Any:
|
|
55
|
+
api = os.environ.get(
|
|
56
|
+
"RESOLVE_SCRIPT_API",
|
|
57
|
+
"/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting",
|
|
58
|
+
)
|
|
59
|
+
modules = str(Path(api) / "Modules")
|
|
60
|
+
if modules not in sys.path:
|
|
61
|
+
sys.path.append(modules)
|
|
62
|
+
import DaVinciResolveScript as dvr
|
|
63
|
+
|
|
64
|
+
for _ in range(120):
|
|
65
|
+
resolve = dvr.scriptapp("Resolve")
|
|
66
|
+
if resolve is not None:
|
|
67
|
+
try:
|
|
68
|
+
pm = resolve.GetProjectManager()
|
|
69
|
+
if pm is not None and pm.GetCurrentDatabase():
|
|
70
|
+
return resolve
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
time.sleep(1)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def make_media(work: Path) -> Path:
|
|
78
|
+
clip = work / "probe_source.mov"
|
|
79
|
+
subprocess.run(
|
|
80
|
+
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
|
81
|
+
"-f", "lavfi", "-i", "testsrc2=size=640x360:rate=24:duration=2",
|
|
82
|
+
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2",
|
|
83
|
+
"-shortest", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-c:a", "aac",
|
|
84
|
+
"-y", str(clip)],
|
|
85
|
+
check=True, timeout=180,
|
|
86
|
+
)
|
|
87
|
+
return clip
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_fixture(ctx: Context) -> None:
|
|
91
|
+
"""A disposable project with one clip on one timeline."""
|
|
92
|
+
ctx.pm = ctx.resolve.GetProjectManager()
|
|
93
|
+
# Guarded: on the never-saved default project this call hangs forever
|
|
94
|
+
# headless. The worker reaching it here would take down setup for every
|
|
95
|
+
# probe rather than for the one probe that is meant to measure it.
|
|
96
|
+
save_project_if_safe(ctx.pm)
|
|
97
|
+
ctx.project_name = f"MODE_MATRIX_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
98
|
+
if not ctx.pm.CreateProject(ctx.project_name):
|
|
99
|
+
raise RuntimeError(f"could not create {ctx.project_name}")
|
|
100
|
+
ctx.pm.LoadProject(ctx.project_name)
|
|
101
|
+
ctx.project = ctx.pm.GetCurrentProject()
|
|
102
|
+
|
|
103
|
+
media = make_media(ctx.work)
|
|
104
|
+
clips = ctx.resolve.GetMediaStorage().AddItemListToMediaPool([str(media)]) or []
|
|
105
|
+
ctx.clip = clips[0] if clips else None
|
|
106
|
+
if ctx.clip is None:
|
|
107
|
+
raise RuntimeError("media import produced no clips")
|
|
108
|
+
pool = ctx.project.GetMediaPool()
|
|
109
|
+
ctx.timeline = pool.CreateTimelineFromClips("PROBE_TL", [ctx.clip])
|
|
110
|
+
if ctx.timeline:
|
|
111
|
+
ctx.project.SetCurrentTimeline(ctx.timeline)
|
|
112
|
+
items = ctx.timeline.GetItemListInTrack("video", 1) or []
|
|
113
|
+
ctx.item = items[0] if items else None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def ensure_state(ctx: Context, state: str) -> Optional[str]:
|
|
117
|
+
"""Put Resolve on the project this probe needs. Returns a skip reason or None.
|
|
118
|
+
|
|
119
|
+
`untitled` means Resolve's never-saved default project — reachable by closing
|
|
120
|
+
whatever is open. It exists as a state because the only mode-dependent hang
|
|
121
|
+
found so far happens exclusively there, and a catalogue that always ran
|
|
122
|
+
against a well-formed scratch project reported no differences at all.
|
|
123
|
+
"""
|
|
124
|
+
current = ctx.pm.GetCurrentProject()
|
|
125
|
+
name = current.GetName() if current else None
|
|
126
|
+
if state == "untitled":
|
|
127
|
+
if name == UNSAVED_DEFAULT_PROJECT:
|
|
128
|
+
return None
|
|
129
|
+
# SAVE BEFORE CLOSING. The scratch project has unsaved changes by this
|
|
130
|
+
# point (media imported, timeline built), and CloseProject on a modified
|
|
131
|
+
# project raises the GUI's "save changes?" dialog — which blocked the
|
|
132
|
+
# whole application, put a modal on the user's screen, and got
|
|
133
|
+
# misattributed to the probe that never got to run. It is a named
|
|
134
|
+
# project, so the save is safe in both modes.
|
|
135
|
+
save_project_if_safe(ctx.pm)
|
|
136
|
+
ctx.pm.CloseProject(current) if current else None
|
|
137
|
+
current = ctx.pm.GetCurrentProject()
|
|
138
|
+
name = current.GetName() if current else None
|
|
139
|
+
return None if name == UNSAVED_DEFAULT_PROJECT else f"could not reach the default project (on {name!r})"
|
|
140
|
+
if name != ctx.project_name:
|
|
141
|
+
if not ctx.pm.LoadProject(ctx.project_name):
|
|
142
|
+
return f"could not return to the scratch project (on {name!r})"
|
|
143
|
+
ctx.project = ctx.pm.GetCurrentProject()
|
|
144
|
+
ctx.timeline = ctx.project.GetCurrentTimeline() if ctx.project else None
|
|
145
|
+
if ctx.timeline:
|
|
146
|
+
items = ctx.timeline.GetItemListInTrack("video", 1) or []
|
|
147
|
+
ctx.item = items[0] if items else None
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def unmet(ctx: Context, needs) -> Optional[str]:
|
|
152
|
+
for need in needs:
|
|
153
|
+
if getattr(ctx, need, None) is None:
|
|
154
|
+
return f"needs {need}"
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def emit(handle, record: Dict[str, Any]) -> None:
|
|
159
|
+
handle.write(json.dumps(record, default=str) + "\n")
|
|
160
|
+
handle.flush()
|
|
161
|
+
os.fsync(handle.fileno())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main() -> int:
|
|
165
|
+
parser = argparse.ArgumentParser()
|
|
166
|
+
parser.add_argument("--out", type=Path, required=True)
|
|
167
|
+
parser.add_argument("--probes", nargs="+", required=True)
|
|
168
|
+
args = parser.parse_args()
|
|
169
|
+
|
|
170
|
+
mode = "headless" if rr.is_headless() else "gui"
|
|
171
|
+
ctx = Context()
|
|
172
|
+
ctx.resolve = connect()
|
|
173
|
+
if ctx.resolve is None:
|
|
174
|
+
print("worker: no healthy Resolve", file=sys.stderr)
|
|
175
|
+
return 2
|
|
176
|
+
|
|
177
|
+
wanted = set(args.probes)
|
|
178
|
+
probes = [p for p in CATALOGUE if p.name in wanted]
|
|
179
|
+
|
|
180
|
+
with args.out.open("a") as handle:
|
|
181
|
+
try:
|
|
182
|
+
build_fixture(ctx)
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
for probe in probes:
|
|
185
|
+
emit(handle, {"probe": probe.name, "category": probe.category,
|
|
186
|
+
"outcome": "skipped", "mode": mode,
|
|
187
|
+
"detail": f"fixture failed: {type(exc).__name__}: {exc}"})
|
|
188
|
+
return 3
|
|
189
|
+
|
|
190
|
+
for probe in probes:
|
|
191
|
+
# Breadcrumbs, so a hang can be attributed to the phase that caused
|
|
192
|
+
# it. Setting up a probe's state is itself a sequence of Resolve
|
|
193
|
+
# calls that can block, and blaming the probe for a hang in its
|
|
194
|
+
# setup sends the reader looking at the wrong call — which is exactly
|
|
195
|
+
# what happened when CloseProject-on-a-modified-project raised a
|
|
196
|
+
# dialog and `pm.save_untitled_project` was recorded as the culprit.
|
|
197
|
+
emit(handle, {"_start": probe.name, "phase": "state"})
|
|
198
|
+
skip = ensure_state(ctx, probe.state) or unmet(ctx, probe.needs)
|
|
199
|
+
if skip:
|
|
200
|
+
emit(handle, {"probe": probe.name, "category": probe.category,
|
|
201
|
+
"outcome": "skipped", "mode": mode, "detail": skip})
|
|
202
|
+
continue
|
|
203
|
+
emit(handle, {"_start": probe.name, "phase": "body"})
|
|
204
|
+
started = time.monotonic()
|
|
205
|
+
try:
|
|
206
|
+
value = probe.body(ctx)
|
|
207
|
+
outcome = classify_outcome(value)
|
|
208
|
+
detail = ""
|
|
209
|
+
except Exception as exc:
|
|
210
|
+
value, outcome, detail = None, "raised", f"{type(exc).__name__}: {exc}"
|
|
211
|
+
emit(handle, {
|
|
212
|
+
"probe": probe.name, "category": probe.category, "outcome": outcome,
|
|
213
|
+
"value": repr(value)[:200] if outcome != "raised" else None,
|
|
214
|
+
"seconds": round(time.monotonic() - started, 2),
|
|
215
|
+
"mode": mode, "detail": detail, "note": probe.note,
|
|
216
|
+
"catalogue": fingerprint(),
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
# Teardown: close (releases the session lock) before deleting, and never
|
|
220
|
+
# leave the session on the unsaveable default project.
|
|
221
|
+
try:
|
|
222
|
+
ctx.pm.LoadProject(ctx.project_name)
|
|
223
|
+
ctx.pm.CloseProject(ctx.pm.GetCurrentProject())
|
|
224
|
+
ctx.pm.DeleteProject(ctx.project_name)
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
shutil.rmtree(ctx.work, ignore_errors=True)
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
raise SystemExit(main())
|