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,578 @@
1
+ #!/usr/bin/env python3
2
+ """Record and difference a Resolve session's capabilities with and without a UI.
3
+
4
+ # 1. with the GUI up, establish the baseline
5
+ python scripts/headless_differential.py record --label gui --out /tmp/gui.json
6
+
7
+ # 2. quit Resolve, boot it headless, record again
8
+ "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/MacOS/Resolve" -nogui &
9
+ python scripts/headless_differential.py record --label headless --out /tmp/headless.json
10
+
11
+ # 3. difference them
12
+ python scripts/headless_differential.py compare /tmp/gui.json /tmp/headless.json \
13
+ --out docs/reference/headless-capability-matrix.md
14
+
15
+ `record` creates a disposable project, imports two seconds of ffmpeg-generated
16
+ colour bars, exercises the surfaces that plausibly depend on a UI, then deletes
17
+ the project and its media. It never touches an existing project — but it does
18
+ open and close one, so do not run it against a Resolve that has unsaved work.
19
+
20
+ The scenarios are deliberately hand-written rather than enumerated. A generated
21
+ call list cannot set up a Gallery album, wait on a render, or clean up a Fusion
22
+ comp, and those are precisely the surfaces where the UI turns out to matter.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import shutil
31
+ import subprocess
32
+ import sys
33
+ import tempfile
34
+ import time
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+ from typing import Any, Callable, Dict, List, Optional
38
+
39
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
40
+
41
+ from src.utils import bridge_differential as bd # noqa: E402
42
+ from src.utils import headless_differential as hd # noqa: E402
43
+ from src.utils.project_cleanup import save_project_if_safe # noqa: E402
44
+
45
+ PAGES = ("media", "cut", "edit", "fusion", "color", "fairlight", "deliver")
46
+
47
+
48
+ # ── connection ───────────────────────────────────────────────────────────────
49
+
50
+
51
+ def connect() -> Any:
52
+ """Blackmagic's own module, with the in-app bridge deliberately out of the way.
53
+
54
+ The bridge runs *inside* Resolve's own interpreter, so a bridged call could
55
+ never observe a headless difference the same way an external one does. This
56
+ study is about external scripting, which is the transport every agent uses.
57
+ """
58
+ os.environ.pop("DAVINCI_RESOLVE_BRIDGE", None)
59
+ api = os.environ.get(
60
+ "RESOLVE_SCRIPT_API",
61
+ "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting",
62
+ )
63
+ modules = str(Path(api) / "Modules")
64
+ if modules not in sys.path:
65
+ sys.path.append(modules)
66
+ os.environ.setdefault("RESOLVE_SCRIPT_API", api)
67
+ os.environ.setdefault(
68
+ "RESOLVE_SCRIPT_LIB",
69
+ "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so",
70
+ )
71
+ import DaVinciResolveScript as dvr # noqa: E402
72
+
73
+ return dvr.scriptapp("Resolve")
74
+
75
+
76
+ def wait_for_project_manager(resolve: Any, timeout: float = 60.0) -> Any:
77
+ """`scriptapp()` answers before the ProjectManager is usable.
78
+
79
+ Verified during the E051 sessions and again here: a fresh headless boot hands
80
+ back a live `resolve` handle whose `GetProjectManager()` returns an object
81
+ that is not yet answering. Calling straight through gets None or a stall, and
82
+ the resulting "headless cannot list projects" would be a harness artefact
83
+ rather than a finding.
84
+ """
85
+ deadline = time.monotonic() + timeout
86
+ last: Optional[Exception] = None
87
+ while time.monotonic() < deadline:
88
+ try:
89
+ pm = resolve.GetProjectManager()
90
+ if pm is not None and pm.GetProjectListInCurrentFolder() is not None:
91
+ return pm
92
+ except Exception as exc: # pragma: no cover - live-only path
93
+ last = exc
94
+ time.sleep(1.0)
95
+ raise RuntimeError(f"ProjectManager not ready within {timeout}s (last error: {last!r})")
96
+
97
+
98
+ # ── fixture ──────────────────────────────────────────────────────────────────
99
+
100
+
101
+ def make_media(work_dir: Path) -> Path:
102
+ clip = work_dir / "headless_probe_source.mov"
103
+ subprocess.run(
104
+ [
105
+ "ffmpeg", "-hide_banner", "-loglevel", "error",
106
+ "-f", "lavfi", "-i", "testsrc2=size=640x360:rate=24:duration=2",
107
+ "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2",
108
+ "-shortest", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-c:a", "aac",
109
+ "-y", str(clip),
110
+ ],
111
+ check=True,
112
+ timeout=120,
113
+ )
114
+ return clip
115
+
116
+
117
+ class Fixture:
118
+ """A disposable project with one clip on one timeline."""
119
+
120
+ def __init__(self, resolve: Any, work_dir: Path, stamp: str) -> None:
121
+ self.resolve = resolve
122
+ self.work_dir = work_dir
123
+ self.name = f"HEADLESS_PROBE_{stamp}"
124
+ self.pm = wait_for_project_manager(resolve)
125
+ self.project: Any = None
126
+ self.timeline: Any = None
127
+ self.item: Any = None
128
+ self.clip: Any = None
129
+ self.incumbent: Optional[str] = None
130
+
131
+ def build(self) -> Dict[str, Any]:
132
+ notes: Dict[str, Any] = {}
133
+ # Loading the scratch project closes whatever is open, and closing an
134
+ # unsaved project in the GUI raises a modal that no script can dismiss —
135
+ # which wedges the run *and* the application. Saving first is not
136
+ # politeness, it is the only way this harness can be safe to point at a
137
+ # live session.
138
+ incumbent = self.pm.GetCurrentProject()
139
+ if incumbent is not None:
140
+ # SaveProject lives on ProjectManager, not Project — one of the
141
+ # "methods live on objects you wouldn't expect" cases the api_truth
142
+ # ledger exists for.
143
+ self.incumbent = incumbent.GetName()
144
+ notes["incumbent_name"] = self.incumbent
145
+ notes["saved_incumbent"] = save_project_if_safe(self.pm)
146
+ self.project = self.pm.CreateProject(self.name)
147
+ notes["created_project"] = bool(self.project)
148
+ if not self.project:
149
+ raise RuntimeError(f"could not create scratch project {self.name}")
150
+ # CreateProject does not load what it created — verified previously, and
151
+ # every import after it would otherwise land in whatever project was
152
+ # already open. That is a destructive harness bug, not a probe result.
153
+ self.pm.LoadProject(self.name)
154
+ self.project = self.pm.GetCurrentProject()
155
+ notes["loaded_project"] = self.project.GetName() == self.name
156
+
157
+ media_path = make_media(self.work_dir)
158
+ pool = self.project.GetMediaPool()
159
+ storage = self.resolve.GetMediaStorage()
160
+ imported = storage.AddItemListToMediaPool([str(media_path)]) or []
161
+ notes["imported_clips"] = len(imported)
162
+ self.clip = imported[0] if imported else None
163
+ if self.clip is None:
164
+ raise RuntimeError("media import produced no clips")
165
+
166
+ self.timeline = pool.CreateTimelineFromClips("PROBE_TL", [self.clip])
167
+ notes["created_timeline"] = bool(self.timeline)
168
+ if self.timeline:
169
+ self.project.SetCurrentTimeline(self.timeline)
170
+ items = self.timeline.GetItemListInTrack("video", 1) or []
171
+ self.item = items[0] if items else None
172
+ notes["timeline_item"] = bool(self.item)
173
+ return notes
174
+
175
+ def teardown(self) -> Dict[str, Any]:
176
+ """Hand the session back on a *named* project, never on the fallback.
177
+
178
+ Closing the scratch project drops Resolve onto a fresh, never-saved
179
+ `Untitled Project`. That project cannot be saved — `SaveProject()`
180
+ returns False for it — so the next close or switch raises a modal the
181
+ user has to dismiss by hand. This harness did that to a live GUI session:
182
+ it finished cleanly and left the application prompting.
183
+
184
+ Loading the incumbent *first* closes the scratch project (just saved, so
185
+ silent) and never rests on the fallback at all. Delete afterwards, when
186
+ nothing is holding it.
187
+ """
188
+ notes: Dict[str, Any] = {}
189
+ try:
190
+ save_project_if_safe(self.pm)
191
+ # CloseProject first: it releases the session lock, and switching
192
+ # away with LoadProject does not — measured, DeleteProject then
193
+ # returns False no matter how often it is retried.
194
+ notes["closed"] = bool(self.pm.CloseProject(self.project))
195
+ # Then land on a named project, so the session is not left on the
196
+ # unsaveable `Untitled Project` fallback.
197
+ if self.incumbent and self.incumbent != self.name:
198
+ notes["restored_incumbent"] = bool(self.pm.LoadProject(self.incumbent))
199
+ except Exception as exc:
200
+ notes["close_error"] = repr(exc)
201
+ try:
202
+ notes["deleted"] = bool(self.pm.DeleteProject(self.name))
203
+ except Exception as exc:
204
+ notes["delete_error"] = repr(exc)
205
+ return notes
206
+
207
+
208
+ # ── scenarios ────────────────────────────────────────────────────────────────
209
+ #
210
+ # Each takes the fixture and an `out` dict it fills in. Keys become matrix rows,
211
+ # so they are named for what was observed rather than for the call that produced
212
+ # it.
213
+ #
214
+ # `out` is passed in rather than returned so that a scenario which dies halfway
215
+ # keeps what it already learned. Returning a dict looked tidier until the colour
216
+ # scenario raised on its third call and discarded all three observations,
217
+ # reporting as a single opaque error — a silent coverage hole in exactly the
218
+ # tool this module exists to close.
219
+
220
+ Scenario = Callable[[Fixture, Dict[str, Any]], None]
221
+ SCENARIOS: Dict[str, Scenario] = {}
222
+
223
+
224
+ def scenario(name: str) -> Callable[[Scenario], Scenario]:
225
+ def register(fn: Scenario) -> Scenario:
226
+ SCENARIOS[name] = fn
227
+ return fn
228
+
229
+ return register
230
+
231
+
232
+ @scenario("pages")
233
+ def scenario_pages(fx: Fixture, out: Dict[str, Any]) -> None:
234
+ """Page navigation — the most obviously UI-shaped call in the API.
235
+
236
+ Worth measuring rather than assuming: several tools call `OpenPage` as a
237
+ precondition for something else (Gallery grabs want Color, render wants
238
+ Deliver), so whether it *reports* success headless decides whether those
239
+ tools fail loudly or proceed on a false premise.
240
+ """
241
+ for page in PAGES:
242
+ out[f"open_{page}"] = fx.resolve.OpenPage(page)
243
+ out[f"reads_back_{page}"] = fx.resolve.GetCurrentPage()
244
+
245
+
246
+ @scenario("gallery")
247
+ def scenario_gallery(fx: Fixture, out: Dict[str, Any]) -> None:
248
+ """Still grab and export — the known headless failure, now measured.
249
+
250
+ A 2026-07-03 design note recorded `ExportStills` and `export_frame_as_still`
251
+ as headless-only failures, confirmed by re-testing in the GUI. That finding
252
+ lived in `local/design/` where no agent reads it.
253
+ """
254
+ fx.resolve.OpenPage("color")
255
+ gallery = fx.project.GetGallery()
256
+ out["gallery_handle"] = bool(gallery)
257
+ if not gallery:
258
+ return
259
+ album = gallery.GetCurrentStillAlbum()
260
+ out["current_album"] = bool(album)
261
+ out["album_count"] = len(gallery.GetGalleryStillAlbums() or [])
262
+
263
+ still = fx.timeline.GrabStill()
264
+ out["grab_still"] = bool(still)
265
+ if still and album:
266
+ export_dir = fx.work_dir / "stills"
267
+ export_dir.mkdir(exist_ok=True)
268
+ out["export_stills"] = album.ExportStills([still], str(export_dir), "probe", "jpg")
269
+ produced = sorted(p.name for p in export_dir.glob("probe*"))
270
+ # The boolean and the filesystem disagree in at least one mode, which is
271
+ # the entire point of checking both.
272
+ out["export_produced_files"] = len(produced)
273
+ album.DeleteStills([still])
274
+
275
+
276
+ @scenario("frame_export")
277
+ def scenario_frame_export(fx: Fixture, out: Dict[str, Any]) -> None:
278
+ """`ExportCurrentFrameAsStill` — the other half of the 2026-07-03 note."""
279
+ target = fx.work_dir / "current_frame.png"
280
+ out["export_current_frame"] = fx.project.ExportCurrentFrameAsStill(str(target))
281
+ out["frame_file_written"] = target.exists() and target.stat().st_size > 0
282
+
283
+
284
+ @scenario("fusion")
285
+ def scenario_fusion(fx: Fixture, out: Dict[str, Any]) -> None:
286
+ """Fusion access, both the app-level object and a clip-level comp."""
287
+ fusion = fx.resolve.Fusion()
288
+ out["fusion_handle"] = bool(fusion)
289
+ if fusion:
290
+ # GetCurrentComp is the entry point every Fusion tool needs; without it
291
+ # the whole fusion_comp tool family is unreachable.
292
+ try:
293
+ comp = fusion.GetCurrentComp()
294
+ out["current_comp"] = bool(comp)
295
+ except Exception as exc:
296
+ out["current_comp"] = f"error: {type(exc).__name__}"
297
+ if fx.item:
298
+ out["comp_count_before"] = fx.item.GetFusionCompCount()
299
+ added = fx.item.AddFusionComp()
300
+ out["add_fusion_comp"] = bool(added)
301
+ out["comp_count_after"] = fx.item.GetFusionCompCount()
302
+ names = fx.item.GetFusionCompNameList() or []
303
+ out["comp_names"] = len(names)
304
+ if added and names:
305
+ out["delete_fusion_comp"] = fx.item.DeleteFusionCompByName(names[-1])
306
+
307
+
308
+ @scenario("color")
309
+ def scenario_color(fx: Fixture, out: Dict[str, Any]) -> None:
310
+ """Grading surface — node graph reads plus a colour-group round trip."""
311
+ if not fx.item:
312
+ return
313
+ graph = fx.item.GetNodeGraph()
314
+ out["node_graph"] = bool(graph)
315
+ if graph:
316
+ out["num_nodes"] = graph.GetNumNodes()
317
+ # Graph exposes GetNodeLabel with no SetNodeLabel beside it — a genuine
318
+ # read/write asymmetry, not an omission here.
319
+ out["read_node_label"] = graph.GetNodeLabel(1)
320
+ out["read_node_lut"] = graph.GetLUT(1)
321
+ out["tools_in_node"] = len(graph.GetToolsInNode(1) or [])
322
+ out["set_node_enabled"] = graph.SetNodeEnabled(1, True)
323
+ group = fx.project.AddColorGroup("PROBE_GROUP")
324
+ out["add_color_group"] = bool(group)
325
+ if group:
326
+ out["assign_to_group"] = fx.item.AssignToColorGroup(group)
327
+ out["delete_color_group"] = fx.project.DeleteColorGroup(group)
328
+
329
+
330
+ @scenario("render")
331
+ def scenario_render(fx: Fixture, out: Dict[str, Any]) -> None:
332
+ """Delivery end to end, to a real file.
333
+
334
+ The whole reason anyone wants headless. A render that reports success and
335
+ writes nothing is indistinguishable from one that worked until someone looks
336
+ for the file, so both are recorded.
337
+ """
338
+ formats = fx.project.GetRenderFormats() or {}
339
+ out["format_count"] = len(formats)
340
+ codecs = fx.project.GetRenderCodecs("mp4") or {}
341
+ out["codec_count_mp4"] = len(codecs)
342
+ # GetRenderCodecs returns {description: id} and only the id is accepted —
343
+ # feeding it the description shown in Deliver returns False for both ProRes
344
+ # and H.264. Documented trap; using it here would report a known quirk as a
345
+ # headless finding.
346
+ codec_id = codecs.get("H.264") or next(iter(codecs.values()), None)
347
+ out["set_format_codec"] = fx.project.SetCurrentRenderFormatAndCodec("mp4", codec_id) if codec_id else None
348
+
349
+ target = fx.work_dir / "render"
350
+ target.mkdir(exist_ok=True)
351
+ out["set_render_settings"] = fx.project.SetRenderSettings(
352
+ {"TargetDir": str(target), "CustomName": "probe_render", "SelectAllFrames": True}
353
+ )
354
+ job_id = fx.project.AddRenderJob()
355
+ out["add_render_job"] = bool(job_id)
356
+ if not job_id:
357
+ return
358
+ out["render_job_count"] = len(fx.project.GetRenderJobList() or [])
359
+ out["start_rendering"] = fx.project.StartRendering(job_id, isInteractiveMode=False)
360
+ deadline = time.monotonic() + 180
361
+ while fx.project.IsRenderingInProgress() and time.monotonic() < deadline:
362
+ time.sleep(1.0)
363
+ status = fx.project.GetRenderJobStatus(job_id) or {}
364
+ out["render_status"] = status.get("JobStatus")
365
+ written = sorted(p for p in target.glob("probe_render*") if p.stat().st_size > 0)
366
+ out["render_produced_files"] = len(written)
367
+ out["delete_render_job"] = fx.project.DeleteRenderJob(job_id)
368
+
369
+
370
+ @scenario("timeline_export")
371
+ def scenario_timeline_export(fx: Fixture, out: Dict[str, Any]) -> None:
372
+ """Interchange export — the conform pipeline's entire output side."""
373
+ exports = {
374
+ "aaf": (fx.resolve.EXPORT_AAF, fx.resolve.EXPORT_AAF_NEW),
375
+ "edl": (fx.resolve.EXPORT_EDL, fx.resolve.EXPORT_NONE),
376
+ "fcpxml": (fx.resolve.EXPORT_FCP_7_XML, fx.resolve.EXPORT_NONE),
377
+ "drt": (fx.resolve.EXPORT_DRT, fx.resolve.EXPORT_NONE),
378
+ "otio": (fx.resolve.EXPORT_OTIO, fx.resolve.EXPORT_NONE),
379
+ }
380
+ for label, (export_type, subtype) in exports.items():
381
+ path = fx.work_dir / f"probe_export.{label}"
382
+ try:
383
+ out[f"export_{label}"] = fx.timeline.Export(str(path), export_type, subtype)
384
+ out[f"export_{label}_bytes"] = path.stat().st_size if path.exists() else 0
385
+ except Exception as exc:
386
+ out[f"export_{label}"] = f"error: {type(exc).__name__}"
387
+
388
+
389
+ @scenario("layout_presets")
390
+ def scenario_layout_presets(fx: Fixture, out: Dict[str, Any]) -> None:
391
+ """UI layout presets — a surface that has no meaning without a UI.
392
+
393
+ Included precisely because it *should* fail headless. A probe set that only
394
+ contains things expected to pass cannot tell a working differential from a
395
+ broken one.
396
+ """
397
+ out["save_layout_preset"] = fx.resolve.SaveLayoutPreset("PROBE_LAYOUT")
398
+ export_path = fx.work_dir / "probe_layout.preset"
399
+ out["export_layout_preset"] = fx.resolve.ExportLayoutPreset("PROBE_LAYOUT", str(export_path))
400
+ out["layout_preset_bytes"] = export_path.stat().st_size if export_path.exists() else 0
401
+ out["load_layout_preset"] = fx.resolve.LoadLayoutPreset("PROBE_LAYOUT")
402
+ out["delete_layout_preset"] = fx.resolve.DeleteLayoutPreset("PROBE_LAYOUT")
403
+
404
+
405
+ @scenario("timeline_edit")
406
+ def scenario_timeline_edit(fx: Fixture, out: Dict[str, Any]) -> None:
407
+ """Ordinary editorial writes — the bulk of what the MCP tools do."""
408
+ out["add_marker"] = fx.timeline.AddMarker(1, "Blue", "PROBE", "probe note", 1)
409
+ markers = fx.timeline.GetMarkers() or {}
410
+ out["marker_count"] = len(markers)
411
+ out["delete_marker"] = fx.timeline.DeleteMarkerAtFrame(1)
412
+ out["track_count_video"] = fx.timeline.GetTrackCount("video")
413
+ out["add_track"] = fx.timeline.AddTrack("video")
414
+ out["track_count_after_add"] = fx.timeline.GetTrackCount("video")
415
+ out["delete_track"] = fx.timeline.DeleteTrack("video", fx.timeline.GetTrackCount("video"))
416
+ out["set_track_name"] = fx.timeline.SetTrackName("video", 1, "PROBE_TRACK")
417
+ out["read_track_name"] = fx.timeline.GetTrackName("video", 1)
418
+ if fx.item:
419
+ out["set_zoom"] = fx.item.SetProperty("ZoomX", 1.25)
420
+ out["read_zoom"] = fx.item.GetProperty("ZoomX")
421
+
422
+
423
+ @scenario("playhead")
424
+ def scenario_playhead(fx: Fixture, out: Dict[str, Any]) -> None:
425
+ """Playhead control — GUI-coupled in a way that has bitten frame exports."""
426
+ start = fx.timeline.GetStartTimecode()
427
+ out["start_timecode_shape"] = type(start).__name__
428
+ out["set_current_timecode"] = fx.timeline.SetCurrentTimecode(start)
429
+ out["current_timecode"] = fx.timeline.GetCurrentTimecode()
430
+ out["current_video_item"] = bool(fx.timeline.GetCurrentVideoItem())
431
+
432
+
433
+ @scenario("project_settings")
434
+ def scenario_project_settings(fx: Fixture, out: Dict[str, Any]) -> None:
435
+ out["read_frame_rate"] = fx.project.GetSetting("timelineFrameRate")
436
+ out["set_output_res"] = fx.project.SetSetting("timelineResolutionWidth", "1920")
437
+ out["read_output_res"] = fx.project.GetSetting("timelineResolutionWidth")
438
+ out["setting_count"] = len(fx.project.GetSetting() or {})
439
+
440
+
441
+ @scenario("media_pool")
442
+ def scenario_media_pool(fx: Fixture, out: Dict[str, Any]) -> None:
443
+ pool = fx.project.GetMediaPool()
444
+ root = pool.GetRootFolder()
445
+ out["root_folder"] = bool(root)
446
+ out["clip_count"] = len(root.GetClipList() or []) if root else 0
447
+ folder = pool.AddSubFolder(root, "PROBE_BIN")
448
+ out["add_subfolder"] = bool(folder)
449
+ out["set_current_folder"] = pool.SetCurrentFolder(folder) if folder else None
450
+ if folder:
451
+ out["delete_subfolder"] = pool.DeleteFolders([folder])
452
+ storage = fx.resolve.GetMediaStorage()
453
+ out["mounted_volume_count"] = len(storage.GetMountedVolumeList() or [])
454
+ if fx.clip:
455
+ out["clip_property_resolution"] = fx.clip.GetClipProperty("Resolution")
456
+ out["set_clip_color"] = fx.clip.SetClipColor("Orange")
457
+
458
+
459
+ # ── recording ────────────────────────────────────────────────────────────────
460
+
461
+
462
+ def resolve_process_argv() -> List[str]:
463
+ """How the running Resolve was actually started.
464
+
465
+ Recorded rather than assumed: the difference between the two runs is only
466
+ attributable to `-nogui` if `-nogui` is genuinely the difference, and a
467
+ lingering render-node instance has silently supplied the answer before.
468
+ """
469
+ try:
470
+ out = subprocess.run(["ps", "-Ao", "pid=,command="], capture_output=True, text=True, timeout=10)
471
+ for line in out.stdout.splitlines():
472
+ if "DaVinci Resolve.app/Contents/MacOS/Resolve" in line:
473
+ return line.split()
474
+ except Exception:
475
+ pass
476
+ return []
477
+
478
+
479
+ def record(label: str, out_path: Path, only: Optional[List[str]]) -> Dict[str, Any]:
480
+ started = time.monotonic()
481
+ resolve = connect()
482
+ if resolve is None:
483
+ raise SystemExit(
484
+ "No Resolve answered the scripting API. Start it (or start it with -nogui) first."
485
+ )
486
+ argv = resolve_process_argv()
487
+ metadata = {
488
+ "label": label,
489
+ "recorded_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
490
+ "version": resolve.GetVersionString(),
491
+ "product": resolve.GetProductName(),
492
+ "process_argv": argv,
493
+ "nogui_in_argv": "-nogui" in argv,
494
+ "connect_seconds": round(time.monotonic() - started, 2),
495
+ }
496
+ # A label that contradicts the process it connected to makes every downstream
497
+ # verdict wrong, and is far easier to do by accident than to notice later.
498
+ if label == "headless" and not metadata["nogui_in_argv"]:
499
+ raise SystemExit("--label headless but the running Resolve has no -nogui in its argv")
500
+ if label == "gui" and metadata["nogui_in_argv"]:
501
+ raise SystemExit("--label gui but the running Resolve was started with -nogui")
502
+
503
+ work_dir = Path(tempfile.mkdtemp(prefix=f"headless_probe_{label}_"))
504
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
505
+ scenarios: Dict[str, Any] = {}
506
+ surface: Dict[str, Any] = {}
507
+ fixture: Optional[Fixture] = None
508
+ try:
509
+ fixture = Fixture(resolve, work_dir, stamp)
510
+ scenarios["fixture"] = fixture.build()
511
+ # The read-only surface walks outwards from the *current* project and
512
+ # timeline, so it has to run after the fixture exists. Probing whatever
513
+ # project happened to be open would compare a GUI run against the user's
514
+ # session and a headless run against nothing, and report the difference
515
+ # between two projects as the difference between two modes.
516
+ surface_plan = bd.probe_surface()
517
+ metadata["surface_methods"] = len(surface_plan["safe_to_probe"])
518
+ surface = bd.run_probes(resolve, surface_plan["safe_to_probe"])
519
+ for name, fn in SCENARIOS.items():
520
+ if only and name not in only:
521
+ continue
522
+ observations: Dict[str, Any] = {}
523
+ scenarios[name] = observations
524
+ try:
525
+ fn(fixture, observations)
526
+ except Exception as exc:
527
+ # A scenario that raises is a finding, not a crash: the exception
528
+ # type is exactly what differs between modes for some surfaces.
529
+ # Whatever it recorded before dying is already in `observations`.
530
+ observations["scenario_error"] = f"{type(exc).__name__}: {exc}"
531
+ finally:
532
+ if fixture is not None:
533
+ scenarios["teardown"] = fixture.teardown()
534
+ shutil.rmtree(work_dir, ignore_errors=True)
535
+
536
+ metadata["elapsed_seconds"] = round(time.monotonic() - started, 2)
537
+ report = {"metadata": metadata, "surface": surface, "scenarios": scenarios}
538
+ out_path.parent.mkdir(parents=True, exist_ok=True)
539
+ out_path.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
540
+ return report
541
+
542
+
543
+ def main() -> int:
544
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
545
+ sub = parser.add_subparsers(dest="command", required=True)
546
+
547
+ rec = sub.add_parser("record", help="record one mode")
548
+ rec.add_argument("--label", choices=("gui", "headless"), required=True)
549
+ rec.add_argument("--out", type=Path, required=True)
550
+ rec.add_argument("--only", nargs="*", help="restrict to named scenarios")
551
+
552
+ cmp_ = sub.add_parser("compare", help="difference two recordings")
553
+ cmp_.add_argument("gui", type=Path)
554
+ cmp_.add_argument("headless", type=Path)
555
+ cmp_.add_argument("--out", type=Path)
556
+
557
+ args = parser.parse_args()
558
+
559
+ if args.command == "record":
560
+ report = record(args.label, args.out, args.only)
561
+ counts = {k: len(v) if isinstance(v, dict) else 1 for k, v in report["scenarios"].items()}
562
+ print(json.dumps({"metadata": report["metadata"], "scenario_observations": counts}, indent=2))
563
+ return 0
564
+
565
+ gui = json.loads(args.gui.read_text(encoding="utf-8"))
566
+ headless = json.loads(args.headless.read_text(encoding="utf-8"))
567
+ matrix = hd.build_matrix(gui, headless)
568
+ markdown = hd.render_matrix_markdown(matrix)
569
+ if args.out:
570
+ args.out.parent.mkdir(parents=True, exist_ok=True)
571
+ args.out.write_text(markdown, encoding="utf-8")
572
+ args.out.with_suffix(".json").write_text(json.dumps(matrix, indent=2, default=str), encoding="utf-8")
573
+ print(markdown)
574
+ return 0
575
+
576
+
577
+ if __name__ == "__main__":
578
+ raise SystemExit(main())