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