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.
- package/CHANGELOG.md +171 -0
- package/README.md +16 -10
- package/docs/SKILL.md +89 -6
- 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/doctor.py +87 -17
- package/scripts/headless_differential.py +578 -0
- package/scripts/install_resolve_bridge.py +174 -56
- 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,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())
|