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,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())
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Can a cut be re-imported after its media has moved? Per format, per mode.
|
|
3
|
+
|
|
4
|
+
python scripts/relink_moved_media.py run --mode headless --out relink_hl.json
|
|
5
|
+
|
|
6
|
+
Every round-trip measured so far imported media that was still exactly where it
|
|
7
|
+
was exported from. That is the easy case and not the one conform exists for. The
|
|
8
|
+
question here is the real one: **the cut is on one machine and the footage is
|
|
9
|
+
somewhere else — which formats can still find it, and is the result still
|
|
10
|
+
frame-exact?**
|
|
11
|
+
|
|
12
|
+
The test moves the media for real. Sources are staged into their own directory,
|
|
13
|
+
the project is built from that directory, the cut is exported, and then the
|
|
14
|
+
directory is *renamed* — so the paths baked into every exported file are now
|
|
15
|
+
dead. Import then runs in a fresh project with `sourceClipsPath` pointing at the
|
|
16
|
+
new location.
|
|
17
|
+
|
|
18
|
+
Two things are measured, and the second is the one that matters:
|
|
19
|
+
|
|
20
|
+
- **linked** — did Resolve find the media at all?
|
|
21
|
+
- **cut fidelity** — having found it, is the cut still frame-exact? A relink
|
|
22
|
+
that lands the right files at the wrong source in-points is worse than one
|
|
23
|
+
that fails, because it produces a plausible wrong answer.
|
|
24
|
+
|
|
25
|
+
The original fixture in `resolve_roundtrip_fixture/` is never touched; the
|
|
26
|
+
staging directories are separate copies, and are cleaned up at the end.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
from datetime import datetime, timezone
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any, Dict, List
|
|
41
|
+
|
|
42
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
43
|
+
|
|
44
|
+
from src.utils import resolve_runtime as rr # noqa: E402
|
|
45
|
+
from src.utils.project_cleanup import delete_project_safely, save_project_if_safe # noqa: E402
|
|
46
|
+
|
|
47
|
+
FIXTURE = Path.home() / "Movies" / "resolve_roundtrip_fixture"
|
|
48
|
+
STAGE_FROM = Path.home() / "Movies" / "relink_origin"
|
|
49
|
+
STAGE_TO = Path.home() / "Movies" / "relink_moved"
|
|
50
|
+
|
|
51
|
+
FORMATS = [
|
|
52
|
+
("drt", "EXPORT_DRT", "EXPORT_NONE", "drt"),
|
|
53
|
+
("fcpxml_1_10", "EXPORT_FCPXML_1_10", "EXPORT_NONE", "fcpxml"),
|
|
54
|
+
("fcp7xml", "EXPORT_FCP_7_XML", "EXPORT_NONE", "xml"),
|
|
55
|
+
("aaf", "EXPORT_AAF", "EXPORT_AAF_NEW", "aaf"),
|
|
56
|
+
("otio", "EXPORT_OTIO", "EXPORT_NONE", "otio"),
|
|
57
|
+
("edl", "EXPORT_EDL", "EXPORT_NONE", "edl"),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def connect(timeout: float = 420.0) -> Any:
|
|
62
|
+
api = os.environ.get(
|
|
63
|
+
"RESOLVE_SCRIPT_API",
|
|
64
|
+
"/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting")
|
|
65
|
+
modules = str(Path(api) / "Modules")
|
|
66
|
+
if modules not in sys.path:
|
|
67
|
+
sys.path.append(modules)
|
|
68
|
+
import DaVinciResolveScript as dvr
|
|
69
|
+
deadline = time.monotonic() + timeout
|
|
70
|
+
while time.monotonic() < deadline:
|
|
71
|
+
resolve = dvr.scriptapp("Resolve")
|
|
72
|
+
if resolve is not None:
|
|
73
|
+
try:
|
|
74
|
+
pm = resolve.GetProjectManager()
|
|
75
|
+
if pm is not None and pm.GetCurrentDatabase():
|
|
76
|
+
return resolve
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
time.sleep(1)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def stage() -> List[Path]:
|
|
84
|
+
"""Fresh copies in their own directory, so the fixture is never moved."""
|
|
85
|
+
for path in (STAGE_FROM, STAGE_TO):
|
|
86
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
87
|
+
STAGE_FROM.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
out = []
|
|
89
|
+
for index in (1, 2):
|
|
90
|
+
source = FIXTURE / f"rt_source_{index}.mov"
|
|
91
|
+
if not source.exists():
|
|
92
|
+
subprocess.run(
|
|
93
|
+
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
|
94
|
+
"-f", "lavfi", "-i", "testsrc2=size=1920x1080:rate=24:duration=8",
|
|
95
|
+
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=8",
|
|
96
|
+
"-shortest", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-c:a", "aac",
|
|
97
|
+
"-timecode", "01:00:00:00", "-y", str(source)],
|
|
98
|
+
check=True, timeout=300)
|
|
99
|
+
target = STAGE_FROM / source.name
|
|
100
|
+
shutil.copy2(source, target)
|
|
101
|
+
out.append(target)
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cut_of(timeline: Any) -> List[Dict[str, Any]]:
|
|
106
|
+
out = []
|
|
107
|
+
for track in range(1, (timeline.GetTrackCount("video") or 0) + 1):
|
|
108
|
+
for item in timeline.GetItemListInTrack("video", track) or []:
|
|
109
|
+
path = None
|
|
110
|
+
try:
|
|
111
|
+
pool_item = item.GetMediaPoolItem()
|
|
112
|
+
path = pool_item.GetClipProperty("File Path") if pool_item else None
|
|
113
|
+
except Exception:
|
|
114
|
+
path = None
|
|
115
|
+
out.append({"track": track, "start": item.GetStart(),
|
|
116
|
+
"duration": item.GetDuration(),
|
|
117
|
+
"left_offset": item.GetLeftOffset(),
|
|
118
|
+
"source": Path(path).name if path else None,
|
|
119
|
+
"resolved": bool(path and Path(path).exists()),
|
|
120
|
+
"dir": str(Path(path).parent) if path else None})
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run(mode: str, out_path: Path) -> int:
|
|
125
|
+
observed = rr.runtime_mode()
|
|
126
|
+
if observed["running"] and bool(observed["headless"]) != (mode == "headless"):
|
|
127
|
+
print("REFUSE: Resolve running in the other mode.", file=sys.stderr)
|
|
128
|
+
return 1
|
|
129
|
+
resolve = connect()
|
|
130
|
+
if resolve is None:
|
|
131
|
+
print("REFUSE: no healthy Resolve.", file=sys.stderr)
|
|
132
|
+
return 2
|
|
133
|
+
pm = resolve.GetProjectManager()
|
|
134
|
+
save_project_if_safe(pm)
|
|
135
|
+
incumbent = pm.GetCurrentProject()
|
|
136
|
+
incumbent_name = incumbent.GetName() if incumbent else None
|
|
137
|
+
|
|
138
|
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
139
|
+
build_name, import_name = f"RELINK_SRC_{stamp}", f"RELINK_DST_{stamp}"
|
|
140
|
+
exports = Path.home() / "Movies" / f"relink_exports_{stamp}"
|
|
141
|
+
exports.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
report: Dict[str, Any] = {"metadata": {
|
|
143
|
+
"mode": mode, "headless": observed["headless"],
|
|
144
|
+
"recorded_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
145
|
+
"version": resolve.GetVersionString()}, "formats": {}}
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
# ── build the cut from STAGE_FROM ────────────────────────────────────
|
|
149
|
+
media = stage()
|
|
150
|
+
pm.CreateProject(build_name)
|
|
151
|
+
pm.LoadProject(build_name)
|
|
152
|
+
project = pm.GetCurrentProject()
|
|
153
|
+
pool = project.GetMediaPool()
|
|
154
|
+
clips = resolve.GetMediaStorage().AddItemListToMediaPool([str(p) for p in media]) or []
|
|
155
|
+
if len(clips) < 2:
|
|
156
|
+
raise RuntimeError("staging import failed")
|
|
157
|
+
frames = int(clips[0].GetClipProperty("Frames") or 0)
|
|
158
|
+
timeline = pool.CreateEmptyTimeline("RELINK_REF")
|
|
159
|
+
project.SetCurrentTimeline(timeline)
|
|
160
|
+
pool.AppendToTimeline([
|
|
161
|
+
{"mediaPoolItem": clips[0], "startFrame": 0, "endFrame": frames - 1},
|
|
162
|
+
{"mediaPoolItem": clips[1], "startFrame": 24, "endFrame": 71},
|
|
163
|
+
{"mediaPoolItem": clips[0], "startFrame": 48, "endFrame": 95},
|
|
164
|
+
])
|
|
165
|
+
reference = cut_of(timeline)
|
|
166
|
+
report["reference"] = reference
|
|
167
|
+
print(f"built {len(reference)} items from {STAGE_FROM}", flush=True)
|
|
168
|
+
|
|
169
|
+
for label, type_attr, sub_attr, ext in FORMATS:
|
|
170
|
+
path = exports / f"relink.{ext}"
|
|
171
|
+
if path.is_dir():
|
|
172
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
173
|
+
ok = timeline.Export(str(path), getattr(resolve, type_attr),
|
|
174
|
+
getattr(resolve, sub_attr))
|
|
175
|
+
import_path = path
|
|
176
|
+
if path.is_dir():
|
|
177
|
+
members = sorted(path.glob("*.fcpxml"))
|
|
178
|
+
import_path = members[0] if members else path
|
|
179
|
+
report["formats"][label] = {"exported": bool(ok), "import_path": str(import_path)}
|
|
180
|
+
|
|
181
|
+
# ── move the media, and close the project that still references it ───
|
|
182
|
+
pm.SaveProject()
|
|
183
|
+
pm.CloseProject(pm.GetCurrentProject())
|
|
184
|
+
shutil.move(str(STAGE_FROM), str(STAGE_TO))
|
|
185
|
+
print(f"moved media: {STAGE_FROM} -> {STAGE_TO}", flush=True)
|
|
186
|
+
|
|
187
|
+
# ── import each cut into a fresh project, pointed at the NEW path ────
|
|
188
|
+
pm.CreateProject(import_name)
|
|
189
|
+
pm.LoadProject(import_name)
|
|
190
|
+
project = pm.GetCurrentProject()
|
|
191
|
+
pool = project.GetMediaPool()
|
|
192
|
+
for label, *_ in FORMATS:
|
|
193
|
+
entry = report["formats"][label]
|
|
194
|
+
if not entry.get("exported"):
|
|
195
|
+
continue
|
|
196
|
+
options: Dict[str, Any] = {} if label == "drt" else {
|
|
197
|
+
"timelineName": f"RL_{label}",
|
|
198
|
+
"importSourceClips": True,
|
|
199
|
+
"sourceClipsPath": str(STAGE_TO),
|
|
200
|
+
}
|
|
201
|
+
try:
|
|
202
|
+
imported = pool.ImportTimelineFromFile(entry["import_path"], options) if options \
|
|
203
|
+
else pool.ImportTimelineFromFile(entry["import_path"])
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
entry["imported"] = False
|
|
206
|
+
entry["error"] = f"{type(exc).__name__}: {exc}"
|
|
207
|
+
print(f" {label:12} import raised {exc}", flush=True)
|
|
208
|
+
continue
|
|
209
|
+
if not imported:
|
|
210
|
+
entry["imported"] = False
|
|
211
|
+
print(f" {label:12} import returned None", flush=True)
|
|
212
|
+
continue
|
|
213
|
+
actual = cut_of(imported)
|
|
214
|
+
entry["imported"] = True
|
|
215
|
+
entry["cut"] = actual
|
|
216
|
+
resolved = sum(1 for i in actual if i["resolved"])
|
|
217
|
+
# Frame fidelity compared on the fields that survive a relink: the
|
|
218
|
+
# media may legitimately live somewhere new, but the cut must not
|
|
219
|
+
# move.
|
|
220
|
+
same = (len(actual) == len(reference) and all(
|
|
221
|
+
a["start"] == b["start"] and a["duration"] == b["duration"]
|
|
222
|
+
and a["left_offset"] == b["left_offset"]
|
|
223
|
+
for a, b in zip(reference, actual)))
|
|
224
|
+
entry["relinked"] = resolved
|
|
225
|
+
entry["total"] = len(actual)
|
|
226
|
+
entry["frame_exact"] = same
|
|
227
|
+
where = {i["dir"] for i in actual if i["dir"]}
|
|
228
|
+
entry["resolved_dirs"] = sorted(where)
|
|
229
|
+
print(f" {label:12} relinked={resolved}/{len(actual)} frame_exact={same} "
|
|
230
|
+
f"dirs={[Path(w).name for w in sorted(where)]}", flush=True)
|
|
231
|
+
try:
|
|
232
|
+
pool.DeleteTimelines([imported])
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
finally:
|
|
236
|
+
try:
|
|
237
|
+
pm.SaveProject()
|
|
238
|
+
pm.CloseProject(pm.GetCurrentProject())
|
|
239
|
+
if incumbent_name:
|
|
240
|
+
pm.LoadProject(incumbent_name)
|
|
241
|
+
for name in (build_name, import_name):
|
|
242
|
+
delete_project_safely(pm, name, switch_to=incumbent_name)
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
report["teardown_error"] = repr(exc)
|
|
245
|
+
shutil.rmtree(STAGE_TO, ignore_errors=True)
|
|
246
|
+
shutil.rmtree(STAGE_FROM, ignore_errors=True)
|
|
247
|
+
shutil.rmtree(exports, ignore_errors=True)
|
|
248
|
+
|
|
249
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
out_path.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
|
|
251
|
+
print(f"report: {out_path}")
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def main() -> int:
|
|
256
|
+
parser = argparse.ArgumentParser(description=__doc__,
|
|
257
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
258
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
259
|
+
r = sub.add_parser("run")
|
|
260
|
+
r.add_argument("--mode", choices=("gui", "headless"), required=True)
|
|
261
|
+
r.add_argument("--out", type=Path, required=True)
|
|
262
|
+
args = parser.parse_args()
|
|
263
|
+
return run(args.mode, args.out)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
raise SystemExit(main())
|