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,225 @@
1
+ #!/usr/bin/env python3
2
+ """Start, stop and guard a headless DaVinci Resolve — the batch/CI entry point.
3
+
4
+ python scripts/resolve_headless.py status # what is running, and in which mode
5
+ python scripts/resolve_headless.py guard # exit non-zero unless it is safe to start
6
+ python scripts/resolve_headless.py start # boot -nogui and wait until scriptable
7
+ python scripts/resolve_headless.py stop # Quit() and wait for the process to go
8
+ python scripts/resolve_headless.py run -- python my_batch.py # guard, start, run, stop
9
+
10
+ Why a wrapper rather than a line in a Makefile:
11
+
12
+ - **`open -a` silently drops `-nogui`.** On macOS the flag has to go to the
13
+ binary inside the bundle. A CI job that used `open` would get a window on the
14
+ build machine, no error, and a modal nobody can see.
15
+ - **One Resolve per machine.** The application, a render node and any scripted
16
+ launch contend for the same singleton, and losing that contention has
17
+ produced crash loops rather than clean errors. `guard` refuses to start on
18
+ top of an existing instance, which is the check every job needs and none
19
+ remember to write.
20
+ - **`Quit()` is the only clean teardown.** Killing the process leaves project
21
+ locks behind; `CloseProject` on an unsaved project raises a dialog. `Quit()`
22
+ discards and exits — 2.1s, measured.
23
+
24
+ `run` is the shape a CI job actually wants: it only stops Resolve if it started
25
+ it, so running against a session someone else set up does not tear theirs down.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import os
32
+ import subprocess
33
+ import sys
34
+ import time
35
+ from pathlib import Path
36
+ from typing import Any, Dict, List, Optional
37
+
38
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
39
+
40
+ from src.utils import resolve_runtime as rr # noqa: E402
41
+
42
+ DEFAULT_API = "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting"
43
+ DEFAULT_LIB = (
44
+ "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so"
45
+ )
46
+
47
+
48
+ def scripting_env(env: Optional[Dict[str, str]] = None) -> Dict[str, str]:
49
+ """A copy of the environment with the scripting API discoverable."""
50
+ out = dict(env or os.environ)
51
+ api = out.get("RESOLVE_SCRIPT_API", DEFAULT_API)
52
+ out["RESOLVE_SCRIPT_API"] = api
53
+ out.setdefault("RESOLVE_SCRIPT_LIB", DEFAULT_LIB)
54
+ modules = str(Path(api) / "Modules")
55
+ existing = out.get("PYTHONPATH")
56
+ out["PYTHONPATH"] = f"{modules}:{existing}" if existing else modules
57
+ return out
58
+
59
+
60
+ def _connect() -> Any:
61
+ for path in (os.environ.get("RESOLVE_SCRIPT_API", DEFAULT_API),):
62
+ modules = str(Path(path) / "Modules")
63
+ if modules not in sys.path:
64
+ sys.path.append(modules)
65
+ os.environ.setdefault("RESOLVE_SCRIPT_LIB", DEFAULT_LIB)
66
+ try:
67
+ import DaVinciResolveScript as dvr
68
+ except ImportError:
69
+ return None
70
+ return dvr.scriptapp("Resolve")
71
+
72
+
73
+ def wait_until_scriptable(timeout: float = 120.0) -> Optional[Any]:
74
+ """Poll until the ProjectManager answers, not merely until a handle exists.
75
+
76
+ `scriptapp()` can hand back a live object whose ProjectManager is not yet
77
+ ready. A job that starts work on that handle sees an empty project list and
78
+ concludes the database is empty.
79
+ """
80
+ deadline = time.monotonic() + timeout
81
+ while time.monotonic() < deadline:
82
+ resolve = _connect()
83
+ if resolve is not None:
84
+ try:
85
+ pm = resolve.GetProjectManager()
86
+ if pm is not None and pm.GetProjectListInCurrentFolder() is not None:
87
+ return resolve
88
+ except Exception:
89
+ pass
90
+ time.sleep(1.0)
91
+ return None
92
+
93
+
94
+ def cmd_status() -> int:
95
+ mode = rr.describe()
96
+ print(f"running: {mode['running']}")
97
+ print(f"headless: {mode['headless']}")
98
+ print(f"instances: {mode['instances']}")
99
+ for line in mode["command_lines"]:
100
+ print(f" {line.strip()}")
101
+ print(f"guidance: {mode['guidance']}")
102
+ return 0
103
+
104
+
105
+ def cmd_guard() -> int:
106
+ """Exit 0 only when it is safe to start a headless instance."""
107
+ mode = rr.runtime_mode()
108
+ if not mode["determinable"]:
109
+ print("REFUSE: cannot read the process list, so cannot tell what is running.", file=sys.stderr)
110
+ return 2
111
+ if mode["running"]:
112
+ print(
113
+ f"REFUSE: {mode['instances']} Resolve instance(s) already running "
114
+ f"(headless={mode['headless']}). Stop them first — a second instance "
115
+ f"fights the singleton.",
116
+ file=sys.stderr,
117
+ )
118
+ for line in mode["command_lines"]:
119
+ print(f" {line.strip()}", file=sys.stderr)
120
+ return 1
121
+ print("OK: nothing running; safe to start a headless instance.")
122
+ return 0
123
+
124
+
125
+ def start(timeout: float) -> int:
126
+ guard = cmd_guard()
127
+ if guard != 0:
128
+ return guard
129
+ command = rr.launch_command(headless=True)
130
+ if command is None:
131
+ print("REFUSE: no DaVinci Resolve install found.", file=sys.stderr)
132
+ return 3
133
+ print(f"starting: {' '.join(command)}")
134
+ subprocess.Popen(command, stdin=subprocess.DEVNULL,
135
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
136
+ started = time.monotonic()
137
+ if wait_until_scriptable(timeout) is None:
138
+ print(f"FAILED: no scripting response within {timeout:.0f}s.", file=sys.stderr)
139
+ return 4
140
+ print(f"ready in {time.monotonic() - started:.1f}s")
141
+ return 0
142
+
143
+
144
+ def stop(timeout: float) -> int:
145
+ mode = rr.runtime_mode()
146
+ if not mode["running"]:
147
+ print("nothing to stop")
148
+ return 0
149
+ resolve = _connect()
150
+ if resolve is None:
151
+ print("REFUSE: Resolve is running but not answering; not killing it.", file=sys.stderr)
152
+ return 5
153
+ started = time.monotonic()
154
+ resolve.Quit()
155
+ while time.monotonic() - started < timeout:
156
+ time.sleep(0.5)
157
+ if not (rr.resolve_processes() or []):
158
+ print(f"stopped in {time.monotonic() - started:.1f}s")
159
+ return 0
160
+ print(f"FAILED: still running {timeout:.0f}s after Quit().", file=sys.stderr)
161
+ return 6
162
+
163
+
164
+ def cmd_run(command: List[str], timeout: float) -> int:
165
+ """Guard, start if needed, run the command, stop only what we started."""
166
+ mode = rr.runtime_mode()
167
+ we_started = False
168
+ if not mode["determinable"]:
169
+ print("REFUSE: cannot read the process list.", file=sys.stderr)
170
+ return 2
171
+ if mode["running"]:
172
+ if not mode["headless"]:
173
+ print(
174
+ "REFUSE: a Resolve with a UI is running. It can raise modal dialogs "
175
+ "that will block this job indefinitely. Quit it first, or run this "
176
+ "against it deliberately with `status` checked by hand.",
177
+ file=sys.stderr,
178
+ )
179
+ return 1
180
+ print("reusing the running headless instance")
181
+ else:
182
+ code = start(timeout)
183
+ if code != 0:
184
+ return code
185
+ we_started = True
186
+ try:
187
+ return subprocess.run(command, env=scripting_env(), check=False).returncode
188
+ finally:
189
+ if we_started:
190
+ stop(timeout)
191
+
192
+
193
+ def main() -> int:
194
+ parser = argparse.ArgumentParser(
195
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
196
+ )
197
+ parser.add_argument("--timeout", type=float, default=120.0,
198
+ help="seconds to wait for start/stop (default 120)")
199
+ sub = parser.add_subparsers(dest="command", required=True)
200
+ sub.add_parser("status")
201
+ sub.add_parser("guard")
202
+ sub.add_parser("start")
203
+ sub.add_parser("stop")
204
+ run = sub.add_parser("run")
205
+ run.add_argument("argv", nargs=argparse.REMAINDER,
206
+ help="command to run; put it after a bare --")
207
+
208
+ args = parser.parse_args()
209
+ if args.command == "status":
210
+ return cmd_status()
211
+ if args.command == "guard":
212
+ return cmd_guard()
213
+ if args.command == "start":
214
+ return start(args.timeout)
215
+ if args.command == "stop":
216
+ return stop(args.timeout)
217
+ argv = [a for a in args.argv if a != "--"]
218
+ if not argv:
219
+ print("run: nothing to run (put the command after `--`)", file=sys.stderr)
220
+ return 64
221
+ return cmd_run(argv, args.timeout)
222
+
223
+
224
+ if __name__ == "__main__":
225
+ raise SystemExit(main())
@@ -0,0 +1,434 @@
1
+ #!/usr/bin/env python3
2
+ """Which interchange format survives an export/import round trip, in which mode.
3
+
4
+ python scripts/roundtrip_matrix.py run --mode gui --out rt_gui.json
5
+ python scripts/roundtrip_matrix.py run --mode headless --out rt_headless.json
6
+ python scripts/roundtrip_matrix.py compare rt_gui.json rt_headless.json
7
+
8
+ The question this answers is not "does export work" — the probe matrix already
9
+ says every format writes a non-empty file in both modes. It is the one an
10
+ iterative edit loop actually depends on: **if I export a cut and import it back,
11
+ do I get the same cut, with its media still linked?**
12
+
13
+ That is a different and much stricter test. An export that writes 22 KB of valid
14
+ XML and re-imports as a timeline with the right number of clips at the wrong
15
+ source timecodes is worse than one that fails outright, because it looks like it
16
+ worked.
17
+
18
+ ## What "the same cut" means here
19
+
20
+ Each timeline is reduced to a fingerprint: for every item, its record start, its
21
+ duration, and its **left offset** — the source in-point. Left offset is the part
22
+ that matters and the part that silently drifts; a conform that lands every clip
23
+ one frame off is the classic failure, and clip counts alone will never show it.
24
+
25
+ Media linkage is measured separately, because a timeline can rebuild perfectly
26
+ and arrive fully offline. Both are recorded: `structure_matches` and `linked`.
27
+
28
+ ## Two ways to link, both tested
29
+
30
+ `ImportTimelineFromFile` can either re-import the source clips from disk
31
+ (`importSourceClips: True`, optionally with `sourceClipsPath`) or reuse what is
32
+ already in the media pool (`importSourceClips: False` with `sourceClipsFolders`).
33
+ An agent building edits iteratively wants the second — it should not duplicate
34
+ its media pool on every iteration — so both are measured rather than assumed.
35
+
36
+ DRT is special-cased throughout: Blackmagic's own documentation says
37
+ `timelineName`, `importSourceClips` and `sourceClipsFolders` are all invalid for
38
+ DRT import, so it is imported bare.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import json
45
+ import os
46
+ import subprocess
47
+ import sys
48
+ import time
49
+ from datetime import datetime, timezone
50
+ from pathlib import Path
51
+ from typing import Any, Dict, List, Optional
52
+
53
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
54
+
55
+ from src.utils import resolve_runtime as rr # noqa: E402
56
+ from src.utils.project_cleanup import delete_project_safely, save_project_if_safe # noqa: E402
57
+
58
+ #: Stable, outside any per-run temp dir, so both modes import byte-identical
59
+ #: media from the same absolute paths. A path that differs between modes would
60
+ #: make relinking differences unattributable.
61
+ MEDIA_DIR = Path.home() / "Movies" / "resolve_roundtrip_fixture"
62
+
63
+ #: (label, export_type_attr, export_subtype_attr, extension)
64
+ FORMATS = [
65
+ ("drt", "EXPORT_DRT", "EXPORT_NONE", "drt"),
66
+ ("fcpxml_1_10", "EXPORT_FCPXML_1_10", "EXPORT_NONE", "fcpxml"),
67
+ ("fcp7xml", "EXPORT_FCP_7_XML", "EXPORT_NONE", "xml"),
68
+ ("aaf", "EXPORT_AAF", "EXPORT_AAF_NEW", "aaf"),
69
+ ("otio", "EXPORT_OTIO", "EXPORT_NONE", "otio"),
70
+ ("edl", "EXPORT_EDL", "EXPORT_NONE", "edl"),
71
+ ]
72
+
73
+ #: How to resolve media on import. "reuse_pool" is what an iterative loop wants.
74
+ STRATEGIES = ("reuse_pool", "import_source")
75
+
76
+
77
+ def connect(timeout: float = 420.0) -> Any:
78
+ api = os.environ.get(
79
+ "RESOLVE_SCRIPT_API",
80
+ "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting",
81
+ )
82
+ modules = str(Path(api) / "Modules")
83
+ if modules not in sys.path:
84
+ sys.path.append(modules)
85
+ import DaVinciResolveScript as dvr
86
+
87
+ deadline = time.monotonic() + timeout
88
+ while time.monotonic() < deadline:
89
+ resolve = dvr.scriptapp("Resolve")
90
+ if resolve is not None:
91
+ try:
92
+ pm = resolve.GetProjectManager()
93
+ if pm is not None and pm.GetCurrentDatabase():
94
+ return resolve
95
+ except Exception:
96
+ pass
97
+ time.sleep(1)
98
+ return None
99
+
100
+
101
+ def build_media() -> List[Path]:
102
+ """Two distinguishable clips, generated once and reused across runs."""
103
+ MEDIA_DIR.mkdir(parents=True, exist_ok=True)
104
+ clips = []
105
+ for index, pattern in enumerate(("testsrc2", "smptebars"), start=1):
106
+ clip = MEDIA_DIR / f"rt_source_{index}.mov"
107
+ if not clip.exists() or clip.stat().st_size == 0:
108
+ subprocess.run(
109
+ ["ffmpeg", "-hide_banner", "-loglevel", "error",
110
+ "-f", "lavfi", "-i", f"{pattern}=size=1920x1080:rate=24:duration=8",
111
+ "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=8",
112
+ "-shortest", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-c:a", "aac",
113
+ "-timecode", "01:00:00:00", "-y", str(clip)],
114
+ check=True, timeout=300,
115
+ )
116
+ clips.append(clip)
117
+ return clips
118
+
119
+
120
+ def fingerprint(timeline: Any) -> List[Dict[str, Any]]:
121
+ """Structure of a timeline, in the terms a conform actually cares about.
122
+
123
+ `left_offset` is the source in-point. It is included because it is the value
124
+ that drifts silently: a round trip can return the right number of clips, at
125
+ the right places, cut from the wrong part of the source, and only this shows
126
+ it.
127
+ """
128
+ out: List[Dict[str, Any]] = []
129
+ for track in range(1, (timeline.GetTrackCount("video") or 0) + 1):
130
+ for index, item in enumerate(timeline.GetItemListInTrack("video", track) or []):
131
+ try:
132
+ pool_item = item.GetMediaPoolItem()
133
+ source = Path(pool_item.GetClipProperty("File Path")).name if pool_item else None
134
+ except Exception:
135
+ source = None
136
+ out.append({
137
+ "track": track,
138
+ "index": index,
139
+ "start": item.GetStart(),
140
+ "duration": item.GetDuration(),
141
+ "left_offset": item.GetLeftOffset(),
142
+ "source": source,
143
+ })
144
+ return out
145
+
146
+
147
+ def linkage(timeline: Any) -> Dict[str, int]:
148
+ """How many items still point at a real file on disk."""
149
+ linked = offline = 0
150
+ for track in range(1, (timeline.GetTrackCount("video") or 0) + 1):
151
+ for item in timeline.GetItemListInTrack("video", track) or []:
152
+ path = None
153
+ try:
154
+ pool_item = item.GetMediaPoolItem()
155
+ path = pool_item.GetClipProperty("File Path") if pool_item else None
156
+ except Exception:
157
+ path = None
158
+ if path and Path(path).exists():
159
+ linked += 1
160
+ else:
161
+ offline += 1
162
+ return {"linked": linked, "offline": offline}
163
+
164
+
165
+ class RoundTrip:
166
+ def __init__(self, resolve: Any, work: Path) -> None:
167
+ self.resolve = resolve
168
+ self.pm = resolve.GetProjectManager()
169
+ self.work = work
170
+ self.project_name = f"ROUNDTRIP_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
171
+ self.project: Any = None
172
+ self.pool: Any = None
173
+ self.reference: Any = None
174
+ self.incumbent: Optional[str] = None
175
+
176
+ def setup(self) -> Dict[str, Any]:
177
+ incumbent = self.pm.GetCurrentProject()
178
+ self.incumbent = incumbent.GetName() if incumbent else None
179
+ save_project_if_safe(self.pm)
180
+ if not self.pm.CreateProject(self.project_name):
181
+ raise RuntimeError("could not create the round-trip project")
182
+ self.pm.LoadProject(self.project_name)
183
+ self.project = self.pm.GetCurrentProject()
184
+ self.pool = self.project.GetMediaPool()
185
+
186
+ media = build_media()
187
+ clips = self.resolve.GetMediaStorage().AddItemListToMediaPool([str(p) for p in media]) or []
188
+ if len(clips) < 2:
189
+ raise RuntimeError(f"expected 2 clips imported, got {len(clips)}")
190
+
191
+ # A deliberately non-trivial cut: full clips AND trimmed subclips, so a
192
+ # round trip that only preserves clip order still fails the comparison.
193
+ frames = int(clips[0].GetClipProperty("Frames") or 0)
194
+ plan = [
195
+ {"mediaPoolItem": clips[0], "startFrame": 0, "endFrame": frames - 1},
196
+ {"mediaPoolItem": clips[1], "startFrame": 24, "endFrame": 71},
197
+ {"mediaPoolItem": clips[0], "startFrame": 48, "endFrame": 95},
198
+ {"mediaPoolItem": clips[1], "startFrame": 0, "endFrame": frames - 1},
199
+ ]
200
+ self.reference = self.pool.CreateEmptyTimeline("RT_REFERENCE")
201
+ if self.reference is None:
202
+ raise RuntimeError("could not create the reference timeline")
203
+ self.project.SetCurrentTimeline(self.reference)
204
+ if not self.pool.AppendToTimeline(plan):
205
+ raise RuntimeError("AppendToTimeline built nothing")
206
+ return {
207
+ "project": self.project_name,
208
+ "media": [str(p) for p in media],
209
+ "source_frames": frames,
210
+ "reference_fingerprint": fingerprint(self.reference),
211
+ "reference_linkage": linkage(self.reference),
212
+ }
213
+
214
+ def export(self, label: str, type_attr: str, sub_attr: str, ext: str) -> Dict[str, Any]:
215
+ path = self.work / f"rt_{label}.{ext}"
216
+ # Re-exporting onto a previous run's output silently mixes runs, and for
217
+ # the bundle format below it would leave stale members behind.
218
+ if path.is_dir():
219
+ import shutil as _shutil
220
+ _shutil.rmtree(path, ignore_errors=True)
221
+ elif path.exists():
222
+ path.unlink()
223
+ try:
224
+ ok = self.reference.Export(str(path),
225
+ getattr(self.resolve, type_attr),
226
+ getattr(self.resolve, sub_attr))
227
+ except Exception as exc:
228
+ return {"exported": False, "error": f"{type(exc).__name__}: {exc}"}
229
+
230
+ # FCPXML 1.10 does not write a file. It writes a *bundle directory* with
231
+ # `Info.fcpxml` inside — the `.fcpxmld` shape Final Cut uses. So the
232
+ # obvious `stat().st_size > 0` check reports 96 bytes of directory inode
233
+ # and a re-import of the given path fails, which looks exactly like a
234
+ # broken exporter and is not. Import has to be pointed at the member.
235
+ bundle = path.is_dir()
236
+ import_path = path
237
+ member = None
238
+ if bundle:
239
+ members = sorted(path.glob("*.fcpxml")) or sorted(p for p in path.iterdir() if p.is_file())
240
+ member = members[0] if members else None
241
+ import_path = member or path
242
+ return {
243
+ "exported": bool(ok),
244
+ "path": str(path),
245
+ "import_path": str(import_path),
246
+ "is_bundle": bundle,
247
+ "bundle_member": str(member) if member else None,
248
+ "bytes": (member.stat().st_size if member else 0) if bundle
249
+ else (path.stat().st_size if path.exists() else 0),
250
+ }
251
+
252
+ def reimport(self, label: str, path: Path, strategy: str) -> Dict[str, Any]:
253
+ name = f"RT_{label}_{strategy}"
254
+ if label == "drt":
255
+ # Documented: timelineName / importSourceClips / sourceClipsFolders
256
+ # are all invalid for DRT. Passing them anyway is a good way to get a
257
+ # failure that says nothing about the format.
258
+ options: Dict[str, Any] = {}
259
+ elif strategy == "reuse_pool":
260
+ options = {"timelineName": name, "importSourceClips": False,
261
+ "sourceClipsFolders": [self.pool.GetRootFolder()]}
262
+ else:
263
+ options = {"timelineName": name, "importSourceClips": True,
264
+ "sourceClipsPath": str(MEDIA_DIR)}
265
+
266
+ started = time.monotonic()
267
+ try:
268
+ imported = self.pool.ImportTimelineFromFile(str(path), options) if options \
269
+ else self.pool.ImportTimelineFromFile(str(path))
270
+ except Exception as exc:
271
+ return {"imported": False, "error": f"{type(exc).__name__}: {exc}",
272
+ "seconds": round(time.monotonic() - started, 1)}
273
+ result: Dict[str, Any] = {
274
+ "imported": bool(imported),
275
+ "seconds": round(time.monotonic() - started, 1),
276
+ }
277
+ if not imported:
278
+ return result
279
+ result["name"] = imported.GetName()
280
+ result["fingerprint"] = fingerprint(imported)
281
+ result["linkage"] = linkage(imported)
282
+ try:
283
+ self.pool.DeleteTimelines([imported])
284
+ result["cleaned"] = True
285
+ except Exception:
286
+ result["cleaned"] = False
287
+ return result
288
+
289
+ def teardown(self) -> Dict[str, Any]:
290
+ notes: Dict[str, Any] = {}
291
+ try:
292
+ save_project_if_safe(self.pm)
293
+ notes["closed"] = bool(self.pm.CloseProject(self.pm.GetCurrentProject()))
294
+ if self.incumbent and self.incumbent != self.project_name:
295
+ notes["restored"] = bool(self.pm.LoadProject(self.incumbent))
296
+ notes["deleted"] = delete_project_safely(
297
+ self.pm, self.project_name, switch_to=self.incumbent)["success"]
298
+ except Exception as exc:
299
+ notes["error"] = repr(exc)
300
+ return notes
301
+
302
+
303
+ def compare_fingerprints(reference: List[Dict[str, Any]],
304
+ actual: List[Dict[str, Any]]) -> Dict[str, Any]:
305
+ if len(reference) != len(actual):
306
+ return {"structure_matches": False,
307
+ "reason": f"item count {len(actual)} != reference {len(reference)}"}
308
+ for expected, got in zip(reference, actual):
309
+ for key in ("start", "duration", "left_offset"):
310
+ if expected[key] != got[key]:
311
+ return {
312
+ "structure_matches": False,
313
+ "reason": f"item {expected['index']} {key}: {got[key]} != {expected[key]}",
314
+ # The one that matters most, called out by name so it is not
315
+ # lost among count mismatches.
316
+ "frame_accurate": key != "left_offset",
317
+ }
318
+ return {"structure_matches": True}
319
+
320
+
321
+ def run(mode: str, out: Path) -> int:
322
+ observed = rr.runtime_mode()
323
+ if observed["running"] and bool(observed["headless"]) != (mode == "headless"):
324
+ print(f"REFUSE: Resolve is running in the other mode: {observed['command_lines']}",
325
+ file=sys.stderr)
326
+ return 1
327
+ resolve = connect()
328
+ if resolve is None:
329
+ print("REFUSE: no healthy Resolve.", file=sys.stderr)
330
+ return 2
331
+
332
+ work = MEDIA_DIR / "exports"
333
+ work.mkdir(parents=True, exist_ok=True)
334
+ rt = RoundTrip(resolve, work)
335
+ report: Dict[str, Any] = {
336
+ "metadata": {
337
+ "mode": mode,
338
+ "headless": observed["headless"],
339
+ "recorded_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
340
+ "version": resolve.GetVersionString(),
341
+ },
342
+ "formats": {},
343
+ }
344
+ try:
345
+ setup = rt.setup()
346
+ report["setup"] = setup
347
+ reference = setup["reference_fingerprint"]
348
+ print(f"reference: {len(reference)} items, "
349
+ f"linkage={setup['reference_linkage']}", flush=True)
350
+
351
+ for label, type_attr, sub_attr, ext in FORMATS:
352
+ entry: Dict[str, Any] = {"export": rt.export(label, type_attr, sub_attr, ext)}
353
+ if entry["export"].get("exported") and entry["export"].get("bytes"):
354
+ path = Path(entry["export"]["import_path"])
355
+ entry["imports"] = {}
356
+ for strategy in (("reuse_pool",) if label == "drt" else STRATEGIES):
357
+ outcome = rt.reimport(label, path, strategy)
358
+ if outcome.get("imported") and outcome.get("fingerprint") is not None:
359
+ outcome.update(compare_fingerprints(reference, outcome["fingerprint"]))
360
+ entry["imports"][strategy] = outcome
361
+ flag = ("MATCH" if outcome.get("structure_matches")
362
+ else "import-failed" if not outcome.get("imported") else "MISMATCH")
363
+ link = outcome.get("linkage", {})
364
+ print(f" {label:12} {strategy:14} {flag:14} "
365
+ f"linked={link.get('linked')} offline={link.get('offline')}", flush=True)
366
+ else:
367
+ print(f" {label:12} export failed: {entry['export']}", flush=True)
368
+ report["formats"][label] = entry
369
+ finally:
370
+ report["teardown"] = rt.teardown()
371
+
372
+ out.parent.mkdir(parents=True, exist_ok=True)
373
+ out.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
374
+ print(f"report: {out}")
375
+ return 0
376
+
377
+
378
+ def _verdict(entry: Dict[str, Any], strategy: str) -> str:
379
+ imports = entry.get("imports") or {}
380
+ outcome = imports.get(strategy)
381
+ if not entry.get("export", {}).get("exported"):
382
+ return "export-failed"
383
+ if outcome is None:
384
+ return "n/a"
385
+ if not outcome.get("imported"):
386
+ return "import-failed"
387
+ if outcome.get("structure_matches"):
388
+ link = outcome.get("linkage", {})
389
+ return "exact" if link.get("offline", 0) == 0 else f"exact-but-{link.get('offline')}-offline"
390
+ return "structure-drift"
391
+
392
+
393
+ def compare(gui_path: Path, headless_path: Path, out: Optional[Path]) -> int:
394
+ gui = json.loads(gui_path.read_text())
395
+ headless = json.loads(headless_path.read_text())
396
+ assert gui["metadata"]["headless"] is False, f"{gui_path} is not a GUI run"
397
+ assert headless["metadata"]["headless"] is True, f"{headless_path} is not a headless run"
398
+
399
+ lines = ["# Timeline round-trip fidelity: export, re-import, compare", "",
400
+ "`exact` = every item's record start, duration and **source in-point** survived, "
401
+ "with all media linked. Anything else is unusable for an iterative edit loop.", "",
402
+ "| format | link strategy | GUI | headless |", "| --- | --- | --- | --- |"]
403
+ for label, *_ in FORMATS:
404
+ for strategy in (("reuse_pool",) if label == "drt" else STRATEGIES):
405
+ lines.append(f"| {label} | {strategy} | "
406
+ f"{_verdict(gui['formats'].get(label, {}), strategy)} | "
407
+ f"{_verdict(headless['formats'].get(label, {}), strategy)} |")
408
+ text = "\n".join(lines) + "\n"
409
+ if out:
410
+ out.parent.mkdir(parents=True, exist_ok=True)
411
+ out.write_text(text, encoding="utf-8")
412
+ print(text)
413
+ return 0
414
+
415
+
416
+ def main() -> int:
417
+ parser = argparse.ArgumentParser(description=__doc__,
418
+ formatter_class=argparse.RawDescriptionHelpFormatter)
419
+ sub = parser.add_subparsers(dest="command", required=True)
420
+ r = sub.add_parser("run")
421
+ r.add_argument("--mode", choices=("gui", "headless"), required=True)
422
+ r.add_argument("--out", type=Path, required=True)
423
+ c = sub.add_parser("compare")
424
+ c.add_argument("gui", type=Path)
425
+ c.add_argument("headless", type=Path)
426
+ c.add_argument("--out", type=Path)
427
+ args = parser.parse_args()
428
+ if args.command == "run":
429
+ return run(args.mode, args.out)
430
+ return compare(args.gui, args.headless, args.out)
431
+
432
+
433
+ if __name__ == "__main__":
434
+ raise SystemExit(main())