davinci-resolve-mcp 2.69.3 → 2.70.1

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,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())
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env python3
2
+ """Do GUI and headless render the same PIXELS? Per effect.
3
+
4
+ python scripts/pixel_equality.py run --mode gui --out px_gui.json
5
+ python scripts/pixel_equality.py run --mode headless --out px_headless.json
6
+ python scripts/pixel_equality.py compare px_gui.json px_headless.json
7
+
8
+ Everything measured until now compared structure and return values — clip
9
+ counts, source offsets, timings, byte counts. None of that can catch the failure
10
+ that actually reaches a client: a headless render that completes, produces a file
11
+ of the right length in the right codec, and contains **the wrong image** because
12
+ a grade, a transform or a Fusion comp was not applied.
13
+
14
+ The one render comparison that existed was byte-identical between modes, but it
15
+ was of a timeline with no grades and no effects. It proved the encoder is
16
+ deterministic. It said nothing about the image pipeline.
17
+
18
+ ## How pixels are compared
19
+
20
+ Not by file hash — a container carries timestamps and UUIDs that differ between
21
+ runs regardless of the picture. Each render is decoded and hashed **per frame**
22
+ with ffmpeg's `framemd5`, so the comparison is of decoded image data only.
23
+ Identical framemd5 lists means identical pixels, frame for frame.
24
+
25
+ ## The control that makes the result mean anything
26
+
27
+ Each effect is also compared against the **plain** baseline *within the same
28
+ mode*. If a grade does not change the picture relative to plain, then "GUI and
29
+ headless agree" is vacuous — both rendered the same ungraded image and the test
30
+ proved nothing. So every effect must first demonstrate `differs_from_plain:
31
+ true`, and any that does not is reported as `INEFFECTIVE` rather than as a pass.
32
+
33
+ This is the same trap that made the first capability differential report zero
34
+ differences: measuring something that was never actually set.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import json
41
+ import os
42
+ import shutil
43
+ import subprocess
44
+ import sys
45
+ import time
46
+ from datetime import datetime, timezone
47
+ from pathlib import Path
48
+ from typing import Any, Dict, List, Optional
49
+
50
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
51
+
52
+ from src.utils import resolve_runtime as rr # noqa: E402
53
+ from src.utils.project_cleanup import delete_project_safely, save_project_if_safe # noqa: E402
54
+ from src.utils.lut_paths import ensure_lut_in_master # noqa: E402
55
+
56
+ WORK = Path.home() / "Movies" / "pixel_equality"
57
+ MEDIA = WORK / "source.mov"
58
+ LUT = WORK / "px_shift.cube"
59
+
60
+ #: Rendered lossless-ish and compared on DECODED frames, so the codec cannot
61
+ #: mask or manufacture a difference. ProRes 422 HQ is deterministic here and was
62
+ #: already shown to produce byte-identical output across modes on a plain cut.
63
+ RENDER_FORMAT, RENDER_CODEC = "mov", "ProRes422HQ"
64
+
65
+
66
+ def connect(timeout: float = 420.0) -> Any:
67
+ api = os.environ.get(
68
+ "RESOLVE_SCRIPT_API",
69
+ "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting")
70
+ modules = str(Path(api) / "Modules")
71
+ if modules not in sys.path:
72
+ sys.path.append(modules)
73
+ import DaVinciResolveScript as dvr
74
+ deadline = time.monotonic() + timeout
75
+ while time.monotonic() < deadline:
76
+ resolve = dvr.scriptapp("Resolve")
77
+ if resolve is not None:
78
+ try:
79
+ pm = resolve.GetProjectManager()
80
+ if pm is not None and pm.GetCurrentDatabase():
81
+ return resolve
82
+ except Exception:
83
+ pass
84
+ time.sleep(1)
85
+ return None
86
+
87
+
88
+ def build_assets() -> None:
89
+ WORK.mkdir(parents=True, exist_ok=True)
90
+ if not MEDIA.exists():
91
+ subprocess.run(
92
+ ["ffmpeg", "-hide_banner", "-loglevel", "error",
93
+ "-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=24:duration=2",
94
+ "-pix_fmt", "yuv420p", "-c:v", "libx264", "-timecode", "01:00:00:00",
95
+ "-y", str(MEDIA)], check=True, timeout=180)
96
+ if not LUT.exists():
97
+ # A blunt, unmistakable LUT: swap red and blue and lift. Subtlety is the
98
+ # enemy here — the LUT exists to prove the grade path reached the
99
+ # renderer, so its effect must be impossible to confuse with rounding.
100
+ lines = ["TITLE \"px_shift\"", "LUT_3D_SIZE 2", "DOMAIN_MIN 0.0 0.0 0.0",
101
+ "DOMAIN_MAX 1.0 1.0 1.0"]
102
+ for b in (0.0, 1.0):
103
+ for g in (0.0, 1.0):
104
+ for r in (0.0, 1.0):
105
+ lines.append(f"{min(b + 0.15, 1.0):.6f} {g:.6f} {min(r + 0.15, 1.0):.6f}")
106
+ LUT.write_text("\n".join(lines) + "\n")
107
+
108
+
109
+ def frame_hashes(path: Path) -> List[str]:
110
+ """Per-frame MD5 of DECODED video. Container metadata cannot influence it."""
111
+ out = subprocess.run(
112
+ ["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(path),
113
+ "-map", "0:v:0", "-f", "framemd5", "-"],
114
+ capture_output=True, text=True, timeout=600)
115
+ hashes = []
116
+ for line in out.stdout.splitlines():
117
+ line = line.strip()
118
+ if not line or line.startswith("#"):
119
+ continue
120
+ parts = line.split(",")
121
+ if parts:
122
+ hashes.append(parts[-1].strip())
123
+ return hashes
124
+
125
+
126
+ # ── effects ──────────────────────────────────────────────────────────────────
127
+ #
128
+ # Each returns a description of what it did, so a treatment that silently failed
129
+ # to apply is visible in the report rather than being read as a format that
130
+ # preserved nothing.
131
+
132
+ def effect_plain(item: Any, project: Any) -> Dict[str, Any]:
133
+ return {"applied": True}
134
+
135
+
136
+ def effect_transform(item: Any, project: Any) -> Dict[str, Any]:
137
+ return {"applied": all([
138
+ item.SetProperty("ZoomX", 1.4), item.SetProperty("ZoomY", 1.4),
139
+ item.SetProperty("Pan", 120.0), item.SetProperty("RotationAngle", 15.0)])}
140
+
141
+
142
+ def effect_cdl(item: Any, project: Any) -> Dict[str, Any]:
143
+ ok = item.SetCDL({"NodeIndex": "1", "Slope": "1.0 0.6 0.4",
144
+ "Offset": "0.10 0.02 -0.05", "Power": "1.0 1.2 0.9",
145
+ "Saturation": "0.4"})
146
+ return {"applied": bool(ok)}
147
+
148
+
149
+ def effect_lut(item: Any, project: Any) -> Dict[str, Any]:
150
+ """`SetLUT` will not take an arbitrary path.
151
+
152
+ Blackmagic's own note says the call succeeds only "for valid lut paths that
153
+ Resolve has already discovered". Handed an absolute path to a freshly written
154
+ .cube it returns False and reads back an empty string — which is exactly what
155
+ the first run of this script did, quietly turning the grade-path test into a
156
+ no-op. The LUT has to be inside the master LUT directory and referenced
157
+ relative to it.
158
+ """
159
+ relative = ensure_lut_in_master(str(LUT))
160
+ project.RefreshLUTList()
161
+ graph = item.GetNodeGraph()
162
+ if not (graph and relative):
163
+ return {"applied": False, "reason": f"could not stage the LUT ({relative!r})"}
164
+ ok = graph.SetLUT(1, relative)
165
+ return {"applied": bool(ok), "staged_as": relative, "readback": graph.GetLUT(1)}
166
+
167
+
168
+ def effect_fusion(item: Any, project: Any) -> Dict[str, Any]:
169
+ """An EMPTY Fusion comp cannot change the picture, so one is not a test.
170
+
171
+ The first run added a bare comp and unsurprisingly rendered pixels identical
172
+ to plain — which says nothing about whether Fusion reaches the renderer. A
173
+ tool is added and wired between MediaIn and MediaOut so there is something to
174
+ detect. Whether that succeeds is itself recorded: this repository already has
175
+ a note that API-created Fusion comps on media clips never render, and this is
176
+ the measurement that either confirms it or does not.
177
+ """
178
+ comp = item.AddFusionComp()
179
+ if not comp:
180
+ return {"applied": False, "reason": "AddFusionComp returned nothing"}
181
+ detail: Dict[str, Any] = {"comp_count": item.GetFusionCompCount()}
182
+ try:
183
+ tools = {}
184
+ for tool in (comp.GetToolList(False) or {}).values():
185
+ try:
186
+ tools.setdefault(tool.ID, tool)
187
+ except Exception:
188
+ continue
189
+ detail["tools_present"] = sorted(tools)
190
+ media_in, media_out = tools.get("MediaIn"), tools.get("MediaOut")
191
+ blur = comp.AddTool("Blur")
192
+ detail["tool_added"] = bool(blur)
193
+ if blur and media_in is not None and media_out is not None:
194
+ blur.SetInput("XBlurSize", 20.0)
195
+ # BOTH connections. The first attempt wired only MediaOut -> Blur and
196
+ # left the Blur with no source, so the comp had nothing to output and
197
+ # the render job came back "Failed" with an 887-byte file. That was a
198
+ # bug in this harness, and it would have been written up as "a Fusion
199
+ # comp breaks the render" — a wrong finding about Resolve produced by
200
+ # a wrong graph.
201
+ blur.ConnectInput("Input", media_in)
202
+ media_out.ConnectInput("Input", blur)
203
+ detail["wired"] = True
204
+ else:
205
+ detail["wired"] = False
206
+ detail["reason"] = f"media_in={bool(media_in)} media_out={bool(media_out)}"
207
+ except Exception as exc:
208
+ detail["tool_error"] = f"{type(exc).__name__}: {exc}"
209
+ detail["applied"] = bool(detail.get("wired"))
210
+ return detail
211
+
212
+
213
+ EFFECTS = [
214
+ ("plain", effect_plain),
215
+ ("transform", effect_transform),
216
+ ("cdl", effect_cdl),
217
+ ("lut", effect_lut),
218
+ ("fusion_comp", effect_fusion),
219
+ ]
220
+
221
+
222
+ class PixelRun:
223
+ def __init__(self, resolve: Any) -> None:
224
+ self.resolve = resolve
225
+ self.pm = resolve.GetProjectManager()
226
+ self.name = f"PIXEQ_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
227
+ self.project = self.pool = None
228
+ self.incumbent: Optional[str] = None
229
+ self.renders = WORK / "renders"
230
+
231
+ def setup(self) -> Dict[str, Any]:
232
+ incumbent = self.pm.GetCurrentProject()
233
+ self.incumbent = incumbent.GetName() if incumbent else None
234
+ save_project_if_safe(self.pm)
235
+ if not self.pm.CreateProject(self.name):
236
+ raise RuntimeError("could not create project")
237
+ self.pm.LoadProject(self.name)
238
+ self.project = self.pm.GetCurrentProject()
239
+ self.pool = self.project.GetMediaPool()
240
+ build_assets()
241
+ clips = self.resolve.GetMediaStorage().AddItemListToMediaPool([str(MEDIA)]) or []
242
+ if not clips:
243
+ raise RuntimeError("media import failed")
244
+ self.clip = clips[0]
245
+ self.frames = int(self.clip.GetClipProperty("Frames") or 0)
246
+ codecs = self.project.GetRenderCodecs(RENDER_FORMAT) or {}
247
+ if RENDER_CODEC not in codecs.values():
248
+ raise RuntimeError(f"{RENDER_CODEC} not offered for {RENDER_FORMAT}")
249
+ self.project.SetCurrentRenderFormatAndCodec(RENDER_FORMAT, RENDER_CODEC)
250
+ shutil.rmtree(self.renders, ignore_errors=True)
251
+ self.renders.mkdir(parents=True, exist_ok=True)
252
+ return {"project": self.name, "frames": self.frames, "media": str(MEDIA)}
253
+
254
+ def render_effect(self, label: str, apply_effect) -> Dict[str, Any]:
255
+ timeline = self.pool.CreateEmptyTimeline(f"PX_{label}")
256
+ self.project.SetCurrentTimeline(timeline)
257
+ self.pool.AppendToTimeline([{"mediaPoolItem": self.clip, "startFrame": 0,
258
+ "endFrame": self.frames - 1}])
259
+ items = timeline.GetItemListInTrack("video", 1) or []
260
+ if not items:
261
+ return {"error": "no timeline item"}
262
+ applied = apply_effect(items[0], self.project)
263
+
264
+ target = self.renders / label
265
+ target.mkdir(parents=True, exist_ok=True)
266
+ self.project.SetRenderSettings({"TargetDir": str(target), "CustomName": label,
267
+ "SelectAllFrames": True})
268
+ job = self.project.AddRenderJob()
269
+ if not job:
270
+ return {"error": "AddRenderJob returned nothing", "applied": applied}
271
+ self.project.StartRendering(job, isInteractiveMode=False)
272
+ deadline = time.monotonic() + 600
273
+ while self.project.IsRenderingInProgress() and time.monotonic() < deadline:
274
+ time.sleep(1)
275
+ status = (self.project.GetRenderJobStatus(job) or {}).get("JobStatus")
276
+ self.project.DeleteRenderJob(job)
277
+ files = sorted(p for p in target.glob(f"{label}*") if p.is_file() and p.stat().st_size)
278
+ if not files:
279
+ return {"error": "no output file", "status": status, "applied": applied}
280
+ hashes = frame_hashes(files[0])
281
+ return {"applied": applied, "status": status, "file": files[0].name,
282
+ "bytes": files[0].stat().st_size, "frames_hashed": len(hashes),
283
+ "frame_hashes": hashes}
284
+
285
+ def teardown(self) -> Dict[str, Any]:
286
+ notes: Dict[str, Any] = {}
287
+ try:
288
+ save_project_if_safe(self.pm)
289
+ notes["closed"] = bool(self.pm.CloseProject(self.pm.GetCurrentProject()))
290
+ if self.incumbent and self.incumbent != self.name:
291
+ notes["restored"] = bool(self.pm.LoadProject(self.incumbent))
292
+ notes["deleted"] = delete_project_safely(self.pm, self.name,
293
+ switch_to=self.incumbent)["success"]
294
+ except Exception as exc:
295
+ notes["error"] = repr(exc)
296
+ shutil.rmtree(self.renders, ignore_errors=True)
297
+ return notes
298
+
299
+
300
+ def run(mode: str, out: Path) -> int:
301
+ observed = rr.runtime_mode()
302
+ if observed["running"] and bool(observed["headless"]) != (mode == "headless"):
303
+ print("REFUSE: Resolve is running in the other mode.", file=sys.stderr)
304
+ return 1
305
+ resolve = connect()
306
+ if resolve is None:
307
+ print("REFUSE: no healthy Resolve.", file=sys.stderr)
308
+ return 2
309
+
310
+ run_ = PixelRun(resolve)
311
+ report: Dict[str, Any] = {"metadata": {
312
+ "mode": mode, "headless": observed["headless"],
313
+ "recorded_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
314
+ "version": resolve.GetVersionString(),
315
+ "render": f"{RENDER_FORMAT}/{RENDER_CODEC}"}, "effects": {}}
316
+ try:
317
+ report["setup"] = run_.setup()
318
+ print(f"setup: {report['setup']}", flush=True)
319
+ for label, fn in EFFECTS:
320
+ result = run_.render_effect(label, fn)
321
+ report["effects"][label] = result
322
+ print(f" {label:12} applied={result.get('applied')} "
323
+ f"frames={result.get('frames_hashed')} bytes={result.get('bytes')} "
324
+ f"{result.get('error','')}", flush=True)
325
+ finally:
326
+ report["teardown"] = run_.teardown()
327
+
328
+ # Within-mode control: did each effect actually change the picture?
329
+ plain = report["effects"].get("plain", {}).get("frame_hashes")
330
+ for label, entry in report["effects"].items():
331
+ if label == "plain" or not plain or not entry.get("frame_hashes"):
332
+ continue
333
+ entry["differs_from_plain"] = entry["frame_hashes"] != plain
334
+ out.parent.mkdir(parents=True, exist_ok=True)
335
+ out.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
336
+ print(f"report: {out}")
337
+ return 0
338
+
339
+
340
+ def compare(gui_path: Path, headless_path: Path, out: Optional[Path]) -> int:
341
+ gui, headless = json.loads(gui_path.read_text()), json.loads(headless_path.read_text())
342
+ if gui["metadata"]["headless"] is not False or headless["metadata"]["headless"] is not True:
343
+ print("REFUSE: mode metadata does not match the arguments.", file=sys.stderr)
344
+ return 1
345
+
346
+ lines = ["# Pixel equality: GUI vs headless rendered frames", "",
347
+ "Frames are compared after decoding (ffmpeg `framemd5`), so container "
348
+ "metadata cannot influence the result. `effective` is the within-mode "
349
+ "control: whether the effect changed the picture at all relative to "
350
+ "`plain`. An effect that is not effective makes its mode comparison "
351
+ "meaningless, and is called out rather than counted as a pass.", "",
352
+ "| effect | effective (GUI) | effective (headless) | pixels identical across modes |",
353
+ "| --- | --- | --- | --- |"]
354
+ verdicts = {}
355
+ for label, _ in EFFECTS:
356
+ g, h = gui["effects"].get(label, {}), headless["effects"].get(label, {})
357
+ gh, hh = g.get("frame_hashes"), h.get("frame_hashes")
358
+ if not gh or not hh:
359
+ verdict = "render-failed"
360
+ elif gh == hh:
361
+ verdict = "**identical**"
362
+ else:
363
+ same = sum(1 for a, b in zip(gh, hh) if a == b)
364
+ verdict = f"**DIFFER** ({same}/{len(gh)} frames match)"
365
+ verdicts[label] = verdict
366
+ eff_g = "—" if label == "plain" else str(g.get("differs_from_plain"))
367
+ eff_h = "—" if label == "plain" else str(h.get("differs_from_plain"))
368
+ lines.append(f"| {label} | {eff_g} | {eff_h} | {verdict} |")
369
+
370
+ ineffective = [l for l, _ in EFFECTS if l != "plain"
371
+ and not gui["effects"].get(l, {}).get("differs_from_plain")]
372
+ if ineffective:
373
+ lines += ["", "## Ineffective treatments — these prove nothing", "",
374
+ "These did not change the rendered picture relative to `plain` in the GUI, so "
375
+ "agreement between modes says only that both rendered the same unaffected "
376
+ "image: " + ", ".join(f"`{l}`" for l in ineffective) + "."]
377
+ text = "\n".join(lines) + "\n"
378
+ if out:
379
+ out.parent.mkdir(parents=True, exist_ok=True)
380
+ out.write_text(text, encoding="utf-8")
381
+ print(text)
382
+ return 0
383
+
384
+
385
+ def main() -> int:
386
+ parser = argparse.ArgumentParser(description=__doc__,
387
+ formatter_class=argparse.RawDescriptionHelpFormatter)
388
+ sub = parser.add_subparsers(dest="command", required=True)
389
+ r = sub.add_parser("run")
390
+ r.add_argument("--mode", choices=("gui", "headless"), required=True)
391
+ r.add_argument("--out", type=Path, required=True)
392
+ c = sub.add_parser("compare")
393
+ c.add_argument("gui", type=Path)
394
+ c.add_argument("headless", type=Path)
395
+ c.add_argument("--out", type=Path)
396
+ args = parser.parse_args()
397
+ return run(args.mode, args.out) if args.command == "run" else \
398
+ compare(args.gui, args.headless, args.out)
399
+
400
+
401
+ if __name__ == "__main__":
402
+ raise SystemExit(main())