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