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,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())
|