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,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())
|
package/src/granular/common.py
CHANGED
|
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
|
|
|
85
85
|
handlers=[logging.StreamHandler()],
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
VERSION = "2.
|
|
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()}")
|