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.
@@ -0,0 +1,431 @@
1
+ #!/usr/bin/env python3
2
+ """Round-trip a cut that has something to lose, and report *what* each format loses.
3
+
4
+ python scripts/roundtrip_rich.py run --mode gui --out rr_gui.json
5
+ python scripts/roundtrip_rich.py run --mode headless --out rr_headless.json
6
+ python scripts/roundtrip_rich.py compare rr_gui.json rr_headless.json
7
+
8
+ `roundtrip_matrix.py` found every format frame-exact in both modes. That result
9
+ is real but it was measured on a flat, single-track cut of untouched clips —
10
+ the formats agreed because there was nothing to disagree about. A cut like that
11
+ is not what anyone builds.
12
+
13
+ This one has: two video tracks, audio, a per-item transform (pan, zoom,
14
+ rotation, crop, flip), clip colours, flags, item markers and timeline markers.
15
+ Then it reports fidelity **per dimension** rather than as one boolean, because
16
+ "AAF preserved the cut but dropped the transforms" and "AAF preserved everything"
17
+ are completely different answers to "which format should my loop use", and a
18
+ single match/mismatch flag cannot tell them apart.
19
+
20
+ Every dimension is verified as *set* on the reference before it is compared —
21
+ a dimension the API silently refused to write would otherwise look like a
22
+ dimension every format faithfully preserved.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import subprocess
31
+ import sys
32
+ import time
33
+ from datetime import datetime, timezone
34
+ from pathlib import Path
35
+ from typing import Any, Dict, List, Optional
36
+
37
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
38
+
39
+ from src.utils import resolve_runtime as rr # noqa: E402
40
+ from src.utils.project_cleanup import delete_project_safely, save_project_if_safe # noqa: E402
41
+
42
+ MEDIA_DIR = Path.home() / "Movies" / "resolve_roundtrip_fixture"
43
+
44
+ FORMATS = [
45
+ ("drt", "EXPORT_DRT", "EXPORT_NONE", "drt"),
46
+ ("fcpxml_1_10", "EXPORT_FCPXML_1_10", "EXPORT_NONE", "fcpxml"),
47
+ ("fcp7xml", "EXPORT_FCP_7_XML", "EXPORT_NONE", "xml"),
48
+ ("aaf", "EXPORT_AAF", "EXPORT_AAF_NEW", "aaf"),
49
+ ("otio", "EXPORT_OTIO", "EXPORT_NONE", "otio"),
50
+ ("edl", "EXPORT_EDL", "EXPORT_NONE", "edl"),
51
+ ]
52
+
53
+ #: The transform written onto one item. Values are deliberately odd so a format
54
+ #: that writes defaults back cannot accidentally match.
55
+ TRANSFORM = {"ZoomX": 1.35, "Pan": 96.0, "RotationAngle": 12.0,
56
+ "CropLeft": 40.0, "FlipX": True}
57
+
58
+ DIMENSIONS = ("cut", "tracks", "transform", "colour", "flags", "markers",
59
+ "timeline_markers", "linkage")
60
+
61
+
62
+ def connect(timeout: float = 420.0) -> Any:
63
+ api = os.environ.get(
64
+ "RESOLVE_SCRIPT_API",
65
+ "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting",
66
+ )
67
+ modules = str(Path(api) / "Modules")
68
+ if modules not in sys.path:
69
+ sys.path.append(modules)
70
+ import DaVinciResolveScript as dvr
71
+
72
+ deadline = time.monotonic() + timeout
73
+ while time.monotonic() < deadline:
74
+ resolve = dvr.scriptapp("Resolve")
75
+ if resolve is not None:
76
+ try:
77
+ pm = resolve.GetProjectManager()
78
+ if pm is not None and pm.GetCurrentDatabase():
79
+ return resolve
80
+ except Exception:
81
+ pass
82
+ time.sleep(1)
83
+ return None
84
+
85
+
86
+ def media() -> List[Path]:
87
+ MEDIA_DIR.mkdir(parents=True, exist_ok=True)
88
+ out = []
89
+ for index, pattern in enumerate(("testsrc2", "smptebars"), start=1):
90
+ clip = MEDIA_DIR / f"rt_source_{index}.mov"
91
+ if not clip.exists():
92
+ subprocess.run(
93
+ ["ffmpeg", "-hide_banner", "-loglevel", "error",
94
+ "-f", "lavfi", "-i", f"{pattern}=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(clip)],
98
+ check=True, timeout=300)
99
+ out.append(clip)
100
+ return out
101
+
102
+
103
+ def describe(timeline: Any) -> Dict[str, Any]:
104
+ """Every dimension of a timeline that a format could drop."""
105
+ video_tracks = timeline.GetTrackCount("video") or 0
106
+ audio_tracks = timeline.GetTrackCount("audio") or 0
107
+ cut: List[Dict[str, Any]] = []
108
+ transform: List[Dict[str, Any]] = []
109
+ colour: List[Any] = []
110
+ flags: List[Any] = []
111
+ markers: List[int] = []
112
+ linked = offline = 0
113
+
114
+ for track in range(1, video_tracks + 1):
115
+ for item in timeline.GetItemListInTrack("video", track) or []:
116
+ path = None
117
+ try:
118
+ pool_item = item.GetMediaPoolItem()
119
+ path = pool_item.GetClipProperty("File Path") if pool_item else None
120
+ except Exception:
121
+ path = None
122
+ if path and Path(path).exists():
123
+ linked += 1
124
+ else:
125
+ offline += 1
126
+ cut.append({"track": track, "start": item.GetStart(),
127
+ "duration": item.GetDuration(),
128
+ "left_offset": item.GetLeftOffset(),
129
+ "source": Path(path).name if path else None})
130
+ values = {}
131
+ for key in TRANSFORM:
132
+ try:
133
+ values[key] = item.GetProperty(key)
134
+ except Exception:
135
+ values[key] = "error"
136
+ transform.append(values)
137
+ try:
138
+ colour.append(item.GetClipColor())
139
+ except Exception:
140
+ colour.append("error")
141
+ try:
142
+ flags.append(sorted(item.GetFlagList() or []))
143
+ except Exception:
144
+ flags.append("error")
145
+ try:
146
+ markers.append(len(item.GetMarkers() or {}))
147
+ except Exception:
148
+ markers.append(-1)
149
+
150
+ audio_items = sum(len(timeline.GetItemListInTrack("audio", t) or [])
151
+ for t in range(1, audio_tracks + 1))
152
+ return {
153
+ "cut": cut,
154
+ "tracks": {"video": video_tracks, "audio": audio_tracks, "audio_items": audio_items},
155
+ "transform": transform,
156
+ "colour": colour,
157
+ "flags": flags,
158
+ "markers": markers,
159
+ "timeline_markers": len(timeline.GetMarkers() or {}),
160
+ "linkage": {"linked": linked, "offline": offline},
161
+ }
162
+
163
+
164
+ class RichRoundTrip:
165
+ def __init__(self, resolve: Any) -> None:
166
+ self.resolve = resolve
167
+ self.pm = resolve.GetProjectManager()
168
+ self.work = MEDIA_DIR / "exports_rich"
169
+ self.work.mkdir(parents=True, exist_ok=True)
170
+ self.name = f"RICHRT_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
171
+ self.project = self.pool = self.reference = None
172
+ self.incumbent: Optional[str] = None
173
+
174
+ def setup(self) -> Dict[str, Any]:
175
+ incumbent = self.pm.GetCurrentProject()
176
+ self.incumbent = incumbent.GetName() if incumbent else None
177
+ save_project_if_safe(self.pm)
178
+ if not self.pm.CreateProject(self.name):
179
+ raise RuntimeError("could not create project")
180
+ self.pm.LoadProject(self.name)
181
+ self.project = self.pm.GetCurrentProject()
182
+ self.pool = self.project.GetMediaPool()
183
+
184
+ clips = self.resolve.GetMediaStorage().AddItemListToMediaPool(
185
+ [str(p) for p in media()]) or []
186
+ if len(clips) < 2:
187
+ raise RuntimeError("media import failed")
188
+ frames = int(clips[0].GetClipProperty("Frames") or 0)
189
+
190
+ self.reference = self.pool.CreateEmptyTimeline("RR_REFERENCE")
191
+ self.project.SetCurrentTimeline(self.reference)
192
+ # V1: the spine.
193
+ self.pool.AppendToTimeline([
194
+ {"mediaPoolItem": clips[0], "startFrame": 0, "endFrame": frames - 1},
195
+ {"mediaPoolItem": clips[1], "startFrame": 24, "endFrame": 71},
196
+ {"mediaPoolItem": clips[0], "startFrame": 48, "endFrame": 95},
197
+ ])
198
+ # V2: an overlay, which is what makes this a multi-track cut at all.
199
+ self.pool.AppendToTimeline([
200
+ {"mediaPoolItem": clips[1], "startFrame": 0, "endFrame": 47,
201
+ "trackIndex": 2, "recordFrame": self.reference.GetStartFrame() + 96,
202
+ "mediaType": 1},
203
+ ])
204
+
205
+ applied: Dict[str, Any] = {}
206
+ items = self.reference.GetItemListInTrack("video", 1) or []
207
+ if items:
208
+ applied["transform"] = {k: items[1].SetProperty(k, v) for k, v in TRANSFORM.items()} \
209
+ if len(items) > 1 else {}
210
+ applied["colour"] = items[0].SetClipColor("Orange")
211
+ applied["flag"] = items[-1].AddFlag("Blue")
212
+ applied["item_marker"] = items[0].AddMarker(1, "Blue", "RRM", "note", 1)
213
+ applied["timeline_marker"] = self.reference.AddMarker(5, "Green", "RRTL", "note", 1)
214
+
215
+ reference = describe(self.reference)
216
+ # A dimension the API refused to write would look like a dimension every
217
+ # format preserved. Record which ones actually landed, and compare only
218
+ # those.
219
+ landed = {
220
+ "transform": any(reference["transform"][1].get(k) not in (None, "error")
221
+ for k in TRANSFORM) if len(reference["transform"]) > 1 else False,
222
+ "colour": any(c not in (None, "", "error") for c in reference["colour"]),
223
+ "flags": any(f for f in reference["flags"] if f and f != "error"),
224
+ "markers": any(m > 0 for m in reference["markers"]),
225
+ "timeline_markers": reference["timeline_markers"] > 0,
226
+ "tracks": reference["tracks"]["video"] > 1,
227
+ }
228
+ return {"project": self.name, "applied": applied, "reference": reference,
229
+ "dimensions_landed": landed}
230
+
231
+ def export(self, label, type_attr, sub_attr, ext) -> Dict[str, Any]:
232
+ path = self.work / f"rr_{label}.{ext}"
233
+ if path.is_dir():
234
+ import shutil
235
+ shutil.rmtree(path, ignore_errors=True)
236
+ elif path.exists():
237
+ path.unlink()
238
+ try:
239
+ ok = self.reference.Export(str(path), getattr(self.resolve, type_attr),
240
+ getattr(self.resolve, sub_attr))
241
+ except Exception as exc:
242
+ return {"exported": False, "error": f"{type(exc).__name__}: {exc}"}
243
+ import_path = path
244
+ if path.is_dir(): # FCPXML 1.10 writes a bundle
245
+ members = sorted(path.glob("*.fcpxml"))
246
+ import_path = members[0] if members else path
247
+ return {"exported": bool(ok), "path": str(path), "import_path": str(import_path),
248
+ "bytes": import_path.stat().st_size if import_path.is_file() else 0}
249
+
250
+ def reimport(self, label: str, path: Path, tag: str) -> Dict[str, Any]:
251
+ options: Dict[str, Any] = {} if label == "drt" else {
252
+ "timelineName": f"RR_{label}_{tag}", "importSourceClips": False,
253
+ "sourceClipsFolders": [self.pool.GetRootFolder()]}
254
+ try:
255
+ imported = self.pool.ImportTimelineFromFile(str(path), options) if options \
256
+ else self.pool.ImportTimelineFromFile(str(path))
257
+ except Exception as exc:
258
+ return {"imported": False, "error": f"{type(exc).__name__}: {exc}"}
259
+ if not imported:
260
+ return {"imported": False}
261
+ out = {"imported": True, "name": imported.GetName(), "described": describe(imported)}
262
+ try:
263
+ self.pool.DeleteTimelines([imported])
264
+ except Exception:
265
+ pass
266
+ return out
267
+
268
+ def teardown(self) -> Dict[str, Any]:
269
+ notes = {}
270
+ try:
271
+ save_project_if_safe(self.pm)
272
+ notes["closed"] = bool(self.pm.CloseProject(self.pm.GetCurrentProject()))
273
+ if self.incumbent and self.incumbent != self.name:
274
+ notes["restored"] = bool(self.pm.LoadProject(self.incumbent))
275
+ notes["deleted"] = delete_project_safely(self.pm, self.name,
276
+ switch_to=self.incumbent)["success"]
277
+ except Exception as exc:
278
+ notes["error"] = repr(exc)
279
+ return notes
280
+
281
+
282
+ def _same_number(a: Any, b: Any) -> bool:
283
+ """Float-tolerant equality for transform values.
284
+
285
+ Exporters round-trip geometry through their own units, so a crop of 40.0
286
+ comes back as 39.999936. Calling that "lost" is not a useful thing to tell
287
+ anyone — it is the same crop. The tolerance is relative and tight enough that
288
+ a genuinely different value (a reset to 0.0, a default 1.0) still fails.
289
+ """
290
+ if isinstance(a, bool) or isinstance(b, bool):
291
+ return a == b
292
+ if isinstance(a, (int, float)) and isinstance(b, (int, float)):
293
+ return abs(a - b) <= max(1e-4, abs(a) * 1e-4)
294
+ return a == b
295
+
296
+
297
+ def _diff_transform(reference: List[Dict[str, Any]], actual: List[Dict[str, Any]]) -> str:
298
+ """Per-key, because "lost" hides the difference that matters.
299
+
300
+ Measured: AAF resets every transform key to its default, while FCP7 XML
301
+ preserves zoom, pan and rotation exactly and drops only FlipX. Both were
302
+ reported as `lost` by a whole-dict comparison, which is the same word for
303
+ "carries geometry" and "does not".
304
+ """
305
+ if len(reference) != len(actual):
306
+ return "lost:item-count"
307
+ dropped = set()
308
+ for expected, got in zip(reference, actual):
309
+ for key in TRANSFORM:
310
+ if not _same_number(expected.get(key), got.get(key)):
311
+ dropped.add(key)
312
+ if not dropped:
313
+ return "kept"
314
+ return "lost:all" if dropped == set(TRANSFORM) else "lost:" + ",".join(sorted(dropped))
315
+
316
+
317
+ def diff_dimensions(reference: Dict[str, Any], actual: Dict[str, Any],
318
+ landed: Dict[str, bool]) -> Dict[str, str]:
319
+ """Per-dimension verdict: kept / lost / n-a."""
320
+ out: Dict[str, str] = {}
321
+ out["cut"] = "kept" if reference["cut"] == actual["cut"] else "lost"
322
+ if out["cut"] == "lost" and len(reference["cut"]) == len(actual["cut"]):
323
+ drifted = [k for k in ("start", "duration", "left_offset")
324
+ if any(a[k] != b[k] for a, b in zip(reference["cut"], actual["cut"]))]
325
+ out["cut"] = "lost:" + ",".join(drifted) if drifted else "lost:source"
326
+ out["tracks"] = "kept" if reference["tracks"] == actual["tracks"] else (
327
+ f"lost:{actual['tracks']['video']}v{actual['tracks']['audio']}a"
328
+ f"!={reference['tracks']['video']}v{reference['tracks']['audio']}a")
329
+ out["linkage"] = "kept" if actual["linkage"]["offline"] == 0 else \
330
+ f"offline:{actual['linkage']['offline']}"
331
+ for dim in ("transform", "colour", "flags", "markers", "timeline_markers"):
332
+ if not landed.get(dim, True):
333
+ out[dim] = "n/a" # never set on the reference; nothing to lose
334
+ continue
335
+ if dim == "transform":
336
+ out[dim] = _diff_transform(reference[dim], actual[dim])
337
+ else:
338
+ out[dim] = "kept" if reference[dim] == actual[dim] else "lost"
339
+ return out
340
+
341
+
342
+ def run(mode: str, out: Path) -> int:
343
+ observed = rr.runtime_mode()
344
+ if observed["running"] and bool(observed["headless"]) != (mode == "headless"):
345
+ print(f"REFUSE: Resolve running in the other mode: {observed['command_lines']}",
346
+ file=sys.stderr)
347
+ return 1
348
+ resolve = connect()
349
+ if resolve is None:
350
+ print("REFUSE: no healthy Resolve.", file=sys.stderr)
351
+ return 2
352
+
353
+ rt = RichRoundTrip(resolve)
354
+ report: Dict[str, Any] = {"metadata": {
355
+ "mode": mode, "headless": observed["headless"],
356
+ "recorded_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
357
+ "version": resolve.GetVersionString()}, "formats": {}}
358
+ try:
359
+ setup = rt.setup()
360
+ report["setup"] = setup
361
+ reference, landed = setup["reference"], setup["dimensions_landed"]
362
+ print(f"reference: {len(reference['cut'])} video items, "
363
+ f"{reference['tracks']['video']}v{reference['tracks']['audio']}a, "
364
+ f"dimensions landed: {[k for k, v in landed.items() if v]}", flush=True)
365
+ for label, type_attr, sub_attr, ext in FORMATS:
366
+ entry = {"export": rt.export(label, type_attr, sub_attr, ext)}
367
+ if entry["export"].get("exported") and entry["export"].get("bytes"):
368
+ outcome = rt.reimport(label, Path(entry["export"]["import_path"]), "a")
369
+ entry["import"] = outcome
370
+ if outcome.get("imported"):
371
+ entry["dimensions"] = diff_dimensions(reference, outcome["described"], landed)
372
+ summary = " ".join(f"{k}={v}" for k, v in entry["dimensions"].items())
373
+ print(f" {label:12} {summary}", flush=True)
374
+ else:
375
+ print(f" {label:12} IMPORT FAILED {outcome}", flush=True)
376
+ else:
377
+ print(f" {label:12} EXPORT FAILED {entry['export']}", flush=True)
378
+ report["formats"][label] = entry
379
+ finally:
380
+ report["teardown"] = rt.teardown()
381
+ out.parent.mkdir(parents=True, exist_ok=True)
382
+ out.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
383
+ print(f"report: {out}")
384
+ return 0
385
+
386
+
387
+ def compare(gui_path: Path, headless_path: Path, out: Optional[Path]) -> int:
388
+ gui, headless = json.loads(gui_path.read_text()), json.loads(headless_path.read_text())
389
+ assert gui["metadata"]["headless"] is False and headless["metadata"]["headless"] is True
390
+ lines = ["# Complex-cut round trip: what each format keeps", "",
391
+ "Two video tracks, audio, a per-item transform, clip colours, flags, item and "
392
+ "timeline markers. `kept` = identical to the reference; `n/a` = the API would not "
393
+ "set that dimension, so no format can be blamed for losing it.", ""]
394
+ header = "| format | mode | " + " | ".join(DIMENSIONS) + " |"
395
+ lines += [header, "| " + " --- |" * (len(DIMENSIONS) + 2)]
396
+ for label, *_ in FORMATS:
397
+ for tag, report in (("gui", gui), ("headless", headless)):
398
+ entry = report["formats"].get(label, {})
399
+ dims = entry.get("dimensions")
400
+ if not dims:
401
+ state = "import-failed" if entry.get("export", {}).get("exported") else "export-failed"
402
+ lines.append(f"| {label} | {tag} | " + " | ".join([state] * len(DIMENSIONS)) + " |")
403
+ else:
404
+ lines.append(f"| {label} | {tag} | " +
405
+ " | ".join(dims.get(d, "?") for d in DIMENSIONS) + " |")
406
+ text = "\n".join(lines) + "\n"
407
+ if out:
408
+ out.parent.mkdir(parents=True, exist_ok=True)
409
+ out.write_text(text, encoding="utf-8")
410
+ print(text)
411
+ return 0
412
+
413
+
414
+ def main() -> int:
415
+ parser = argparse.ArgumentParser(description=__doc__,
416
+ formatter_class=argparse.RawDescriptionHelpFormatter)
417
+ sub = parser.add_subparsers(dest="command", required=True)
418
+ r = sub.add_parser("run")
419
+ r.add_argument("--mode", choices=("gui", "headless"), required=True)
420
+ r.add_argument("--out", type=Path, required=True)
421
+ c = sub.add_parser("compare")
422
+ c.add_argument("gui", type=Path)
423
+ c.add_argument("headless", type=Path)
424
+ c.add_argument("--out", type=Path)
425
+ args = parser.parse_args()
426
+ return run(args.mode, args.out) if args.command == "run" else \
427
+ compare(args.gui, args.headless, args.out)
428
+
429
+
430
+ if __name__ == "__main__":
431
+ raise SystemExit(main())
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.69.3"
88
+ VERSION = "2.70.0"
89
89
  logger = logging.getLogger("davinci-resolve-mcp")
90
90
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
91
91
  logger.info(f"Detected platform: {get_platform()}")