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,542 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stress a Resolve render session until it breaks, and record how it broke.
|
|
3
|
+
|
|
4
|
+
The capability differential (`scripts/headless_differential.py`) answers "can
|
|
5
|
+
headless do this at all". It cannot answer "does headless survive a real
|
|
6
|
+
delivery", because its render is two seconds of 640x360 H.264. This harness
|
|
7
|
+
answers the second question, on synthetic media, without needing production
|
|
8
|
+
assets:
|
|
9
|
+
|
|
10
|
+
# 20 ProRes renders in one headless session, 60s of DCP-like J2K each
|
|
11
|
+
python scripts/render_stress.py --mode headless --iterations 20 --duration 60
|
|
12
|
+
|
|
13
|
+
# the same run with a UI, for the comparison that makes it mean something
|
|
14
|
+
python scripts/render_stress.py --mode gui --iterations 20 --duration 60
|
|
15
|
+
|
|
16
|
+
# once production assets are available, same harness, real source
|
|
17
|
+
python scripts/render_stress.py --mode headless --source /path/to/dcp_video.mxf
|
|
18
|
+
|
|
19
|
+
The synthetic source is JPEG 2000 in MXF OP1A at 12 bits, 1998x1080 — which
|
|
20
|
+
Resolve identifies as `JPEG 2000 / MXF OP1A / 12 bit`, the same decode path a
|
|
21
|
+
DCP video track file takes. The render target is ProRes in QuickTime, because
|
|
22
|
+
**this Resolve offers ProRes in no MXF flavour** — `GetRenderCodecs` returns
|
|
23
|
+
ProRes only under `mov`, so "MXF to ProRes" means J2K-MXF in, ProRes-QuickTime
|
|
24
|
+
out.
|
|
25
|
+
|
|
26
|
+
What it is actually looking for:
|
|
27
|
+
|
|
28
|
+
- **Crashes at end of write.** Each iteration is bracketed by a snapshot of
|
|
29
|
+
`crash_archive.txt`, so a new crash block is captured with its stack rather
|
|
30
|
+
than being noticed later as "it died at some point".
|
|
31
|
+
- **Process death.** The driver runs outside Resolve and watches the PID, so a
|
|
32
|
+
hard exit is recorded as a result instead of hanging the run.
|
|
33
|
+
- **Leaks.** Resident memory is sampled through every render, so growth across
|
|
34
|
+
iterations is visible. A teardown bug and a slow leak both present as "it
|
|
35
|
+
crashes eventually"; the RSS curve separates them.
|
|
36
|
+
- **Degradation.** Per-iteration wall time is recorded for the same reason.
|
|
37
|
+
|
|
38
|
+
Outputs are deleted after each iteration is verified unless `--keep`, because
|
|
39
|
+
ProRes 422 HQ at this resolution is roughly 2 GB per minute.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
import shutil
|
|
48
|
+
import subprocess
|
|
49
|
+
import sys
|
|
50
|
+
import tempfile
|
|
51
|
+
import time
|
|
52
|
+
from datetime import datetime, timezone
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import Any, Dict, List, Optional
|
|
55
|
+
|
|
56
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
57
|
+
|
|
58
|
+
from src.utils import resolve_runtime as rr # noqa: E402
|
|
59
|
+
from src.utils.project_cleanup import save_project_if_safe # noqa: E402
|
|
60
|
+
|
|
61
|
+
CRASH_ARCHIVE = Path(
|
|
62
|
+
os.path.expanduser(
|
|
63
|
+
"~/Library/Application Support/Blackmagic Design/DaVinci Resolve/crash_archive.txt"
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
RESOLVE_LOG = Path(
|
|
67
|
+
os.path.expanduser(
|
|
68
|
+
"~/Library/Application Support/Blackmagic Design/DaVinci Resolve/logs/ResolveDebug.txt"
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
DEFAULT_API = "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── connection ───────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def connect(timeout: float = 420.0) -> Optional[Any]:
|
|
79
|
+
"""Wait for a scriptable Resolve.
|
|
80
|
+
|
|
81
|
+
The timeout is generous on purpose. A clean headless boot is scriptable in
|
|
82
|
+
under 3 seconds, but a boot *after an unclean shutdown* took 2 minutes 5
|
|
83
|
+
seconds to start its script server — measured, after force-killing a wedged
|
|
84
|
+
instance. A 90-second wait concluded "headless will not start" fifteen
|
|
85
|
+
seconds before it did, which is exactly the false negative that leads a
|
|
86
|
+
supervisor to launch a second instance on top of a starting one.
|
|
87
|
+
"""
|
|
88
|
+
os.environ.pop("DAVINCI_RESOLVE_BRIDGE", None)
|
|
89
|
+
api = os.environ.get("RESOLVE_SCRIPT_API", DEFAULT_API)
|
|
90
|
+
modules = str(Path(api) / "Modules")
|
|
91
|
+
if modules not in sys.path:
|
|
92
|
+
sys.path.append(modules)
|
|
93
|
+
os.environ.setdefault("RESOLVE_SCRIPT_API", api)
|
|
94
|
+
os.environ.setdefault(
|
|
95
|
+
"RESOLVE_SCRIPT_LIB",
|
|
96
|
+
"/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so",
|
|
97
|
+
)
|
|
98
|
+
import DaVinciResolveScript as dvr
|
|
99
|
+
|
|
100
|
+
deadline = time.monotonic() + timeout
|
|
101
|
+
while time.monotonic() < deadline:
|
|
102
|
+
resolve = dvr.scriptapp("Resolve")
|
|
103
|
+
if resolve is not None:
|
|
104
|
+
try:
|
|
105
|
+
pm = resolve.GetProjectManager()
|
|
106
|
+
if pm is not None and pm.GetProjectListInCurrentFolder() is not None:
|
|
107
|
+
return resolve
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
time.sleep(1.0)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def resolve_pid() -> Optional[int]:
|
|
115
|
+
try:
|
|
116
|
+
out = subprocess.run(["ps", "-Ao", "pid=,command="], capture_output=True, text=True, timeout=10)
|
|
117
|
+
for line in out.stdout.splitlines():
|
|
118
|
+
if any(p in line for p in rr.RESOLVE_PROCESS_PATTERNS):
|
|
119
|
+
return int(line.split()[0])
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def rss_mb(pid: Optional[int]) -> Optional[float]:
|
|
126
|
+
if pid is None:
|
|
127
|
+
return None
|
|
128
|
+
try:
|
|
129
|
+
out = subprocess.run(["ps", "-o", "rss=", "-p", str(pid)], capture_output=True, text=True, timeout=5)
|
|
130
|
+
return round(int(out.stdout.strip()) / 1024, 1)
|
|
131
|
+
except Exception:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── crash capture ────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class CrashWatch:
|
|
139
|
+
"""Bracket an operation and capture whatever Resolve appended to its archive.
|
|
140
|
+
|
|
141
|
+
Resolve appends every crash stack to one growing file with no timestamps, so
|
|
142
|
+
"did this iteration crash" is only answerable by remembering where the file
|
|
143
|
+
ended before it started. Reading the file afterwards and looking for
|
|
144
|
+
something that seems recent is guesswork.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(self) -> None:
|
|
148
|
+
self.offset = self._size()
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _size() -> int:
|
|
152
|
+
try:
|
|
153
|
+
return CRASH_ARCHIVE.stat().st_size
|
|
154
|
+
except OSError:
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
def take(self) -> Optional[str]:
|
|
158
|
+
"""New crash text since the last call, or None."""
|
|
159
|
+
size = self._size()
|
|
160
|
+
if size <= self.offset:
|
|
161
|
+
self.offset = size # rotation/truncation resets the mark
|
|
162
|
+
return None
|
|
163
|
+
try:
|
|
164
|
+
with CRASH_ARCHIVE.open("r", errors="replace") as handle:
|
|
165
|
+
handle.seek(self.offset)
|
|
166
|
+
text = handle.read()
|
|
167
|
+
except OSError:
|
|
168
|
+
return None
|
|
169
|
+
self.offset = size
|
|
170
|
+
return text or None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def log_tail(lines: int = 80) -> str:
|
|
174
|
+
try:
|
|
175
|
+
content = RESOLVE_LOG.read_text(errors="replace").splitlines()
|
|
176
|
+
except OSError:
|
|
177
|
+
return ""
|
|
178
|
+
return "\n".join(content[-lines:])
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ── source media ─────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def build_j2k_source(work: Path, seconds: int, width: int, height: int, fps: int) -> Path:
|
|
185
|
+
"""DCP-like JPEG 2000 in MXF OP1A, 12-bit.
|
|
186
|
+
|
|
187
|
+
Kept short and repeated on the timeline rather than generated at full length:
|
|
188
|
+
the decode work per frame is identical either way, and a 5-minute J2K source
|
|
189
|
+
is several gigabytes of scratch for no extra signal.
|
|
190
|
+
"""
|
|
191
|
+
out = work / "j2k_source.mxf"
|
|
192
|
+
subprocess.run(
|
|
193
|
+
[
|
|
194
|
+
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
|
195
|
+
"-f", "lavfi", "-i", f"testsrc2=size={width}x{height}:rate={fps}:duration={seconds}",
|
|
196
|
+
"-c:v", "jpeg2000", "-pix_fmt", "xyz12le", "-q:v", "1",
|
|
197
|
+
"-f", "mxf", str(out), "-y",
|
|
198
|
+
],
|
|
199
|
+
check=True, timeout=900,
|
|
200
|
+
)
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── the run ──────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class StressRun:
|
|
208
|
+
def __init__(self, args: argparse.Namespace) -> None:
|
|
209
|
+
self.args = args
|
|
210
|
+
self.work = Path(tempfile.mkdtemp(prefix="render_stress_"))
|
|
211
|
+
self.out_dir = Path(args.out) if args.out else (self.work / "renders")
|
|
212
|
+
self.out_dir.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
self.project_name = f"RENDER_STRESS_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
214
|
+
self.crash = CrashWatch()
|
|
215
|
+
self.iterations: List[Dict[str, Any]] = []
|
|
216
|
+
self.resolve: Any = None
|
|
217
|
+
self.pid: Optional[int] = None
|
|
218
|
+
self.source: Optional[Path] = None
|
|
219
|
+
self.incumbent: Optional[str] = None
|
|
220
|
+
|
|
221
|
+
# -- setup ---------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def attach(self) -> bool:
|
|
224
|
+
self.resolve = connect()
|
|
225
|
+
self.pid = resolve_pid()
|
|
226
|
+
return self.resolve is not None
|
|
227
|
+
|
|
228
|
+
def build_project(self) -> Dict[str, Any]:
|
|
229
|
+
pm = self.resolve.GetProjectManager()
|
|
230
|
+
incumbent = pm.GetCurrentProject()
|
|
231
|
+
self.incumbent = incumbent.GetName() if incumbent else None
|
|
232
|
+
# Guarded: SaveProject BLOCKS FOREVER headless on the never-saved
|
|
233
|
+
# 'Untitled Project', which is exactly the project a fresh headless
|
|
234
|
+
# boot is sitting on. This call hung two stress runs before it was
|
|
235
|
+
# traced.
|
|
236
|
+
save_project_if_safe(pm)
|
|
237
|
+
if not pm.CreateProject(self.project_name):
|
|
238
|
+
raise RuntimeError(f"could not create {self.project_name}")
|
|
239
|
+
pm.LoadProject(self.project_name)
|
|
240
|
+
project = pm.GetCurrentProject()
|
|
241
|
+
|
|
242
|
+
if self.args.source:
|
|
243
|
+
self.source = Path(self.args.source).expanduser()
|
|
244
|
+
if not self.source.exists():
|
|
245
|
+
raise RuntimeError(f"--source not found: {self.source}")
|
|
246
|
+
else:
|
|
247
|
+
self.source = build_j2k_source(
|
|
248
|
+
self.work, self.args.clip_seconds, self.args.width, self.args.height, self.args.fps
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
storage = self.resolve.GetMediaStorage()
|
|
252
|
+
clips = storage.AddItemListToMediaPool([str(self.source)]) or []
|
|
253
|
+
if not clips:
|
|
254
|
+
raise RuntimeError(f"Resolve would not import {self.source}")
|
|
255
|
+
clip = clips[0]
|
|
256
|
+
pool = project.GetMediaPool()
|
|
257
|
+
|
|
258
|
+
# Repeat the clip until the timeline reaches the requested duration. The
|
|
259
|
+
# decode path is per-frame, so N copies of a short clip stresses it the
|
|
260
|
+
# same as one long clip while keeping the scratch bounded.
|
|
261
|
+
#
|
|
262
|
+
# NOT via CreateTimelineFromClips([clip] * n): that call **deduplicates
|
|
263
|
+
# repeated references to the same MediaPoolItem**. Passing one clip 15
|
|
264
|
+
# times built a 96-frame timeline — one copy — and reported no error, so
|
|
265
|
+
# every "60 second" render in the first run was actually 4 seconds. The
|
|
266
|
+
# harness looked like it was working and was testing almost nothing.
|
|
267
|
+
# AppendToTimeline with explicit clipInfo dicts appends each instance.
|
|
268
|
+
frames = int(clip.GetClipProperty("Frames") or 0)
|
|
269
|
+
fps = float(clip.GetClipProperty("FPS") or self.args.fps)
|
|
270
|
+
want = max(1, int(round(self.args.duration * fps / max(frames, 1))))
|
|
271
|
+
timeline = pool.CreateEmptyTimeline("STRESS_TL")
|
|
272
|
+
if timeline is None:
|
|
273
|
+
raise RuntimeError("could not create the stress timeline")
|
|
274
|
+
project.SetCurrentTimeline(timeline)
|
|
275
|
+
# endFrame is EXCLUSIVE-adjacent in this API: the last frame index is
|
|
276
|
+
# frames - 1, and passing `frames` appends nothing.
|
|
277
|
+
appended = pool.AppendToTimeline(
|
|
278
|
+
[{"mediaPoolItem": clip, "startFrame": 0, "endFrame": max(frames - 1, 0)}] * want
|
|
279
|
+
)
|
|
280
|
+
if not appended:
|
|
281
|
+
raise RuntimeError("AppendToTimeline appended nothing")
|
|
282
|
+
built = timeline.GetEndFrame() - timeline.GetStartFrame()
|
|
283
|
+
# Assert the timeline is the length that was asked for. The whole failure
|
|
284
|
+
# above was a silent short-build that no check would have caught.
|
|
285
|
+
if built < want * max(frames - 1, 1) * 0.9:
|
|
286
|
+
raise RuntimeError(
|
|
287
|
+
f"timeline short-built: wanted ~{want * frames} frames, got {built}"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
codecs = project.GetRenderCodecs(self.args.format) or {}
|
|
291
|
+
# GetRenderCodecs returns {description: id} and rejects the description.
|
|
292
|
+
codec_id = codecs.get(self.args.codec) or self.args.codec
|
|
293
|
+
if codec_id not in codecs.values():
|
|
294
|
+
raise RuntimeError(
|
|
295
|
+
f"codec {self.args.codec!r} not offered for format {self.args.format!r}. "
|
|
296
|
+
f"Available: {sorted(codecs)}"
|
|
297
|
+
)
|
|
298
|
+
if not project.SetCurrentRenderFormatAndCodec(self.args.format, codec_id):
|
|
299
|
+
raise RuntimeError(f"SetCurrentRenderFormatAndCodec({self.args.format}, {codec_id}) refused")
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
"project": self.project_name,
|
|
303
|
+
"source": str(self.source),
|
|
304
|
+
"source_codec": clip.GetClipProperty("Video Codec"),
|
|
305
|
+
"source_format": clip.GetClipProperty("Format"),
|
|
306
|
+
"source_resolution": clip.GetClipProperty("Resolution"),
|
|
307
|
+
"clips_on_timeline": want,
|
|
308
|
+
"timeline_frames": timeline.GetEndFrame() - timeline.GetStartFrame(),
|
|
309
|
+
"render_format": self.args.format,
|
|
310
|
+
"render_codec": codec_id,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
# -- one iteration -------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
def render_once(self, index: int) -> Dict[str, Any]:
|
|
316
|
+
record: Dict[str, Any] = {"iteration": index}
|
|
317
|
+
project = self.resolve.GetProjectManager().GetCurrentProject()
|
|
318
|
+
name = f"stress_{index:03d}"
|
|
319
|
+
project.SetRenderSettings({
|
|
320
|
+
"TargetDir": str(self.out_dir),
|
|
321
|
+
"CustomName": name,
|
|
322
|
+
"SelectAllFrames": True,
|
|
323
|
+
})
|
|
324
|
+
job = project.AddRenderJob()
|
|
325
|
+
record["job"] = bool(job)
|
|
326
|
+
if not job:
|
|
327
|
+
record["outcome"] = "add_render_job_failed"
|
|
328
|
+
return record
|
|
329
|
+
|
|
330
|
+
started = time.monotonic()
|
|
331
|
+
record["start_rendering"] = project.StartRendering(job, isInteractiveMode=False)
|
|
332
|
+
peak = 0.0
|
|
333
|
+
samples = 0
|
|
334
|
+
deadline = started + self.args.timeout
|
|
335
|
+
died = False
|
|
336
|
+
while time.monotonic() < deadline:
|
|
337
|
+
current = rss_mb(self.pid)
|
|
338
|
+
if current is None:
|
|
339
|
+
died = True
|
|
340
|
+
break
|
|
341
|
+
peak = max(peak, current)
|
|
342
|
+
samples += 1
|
|
343
|
+
try:
|
|
344
|
+
if not project.IsRenderingInProgress():
|
|
345
|
+
break
|
|
346
|
+
except Exception:
|
|
347
|
+
# A dead Resolve makes the handle raise rather than answer. That
|
|
348
|
+
# is the crash, not an error in the harness.
|
|
349
|
+
died = True
|
|
350
|
+
break
|
|
351
|
+
time.sleep(1.0)
|
|
352
|
+
|
|
353
|
+
record["elapsed_seconds"] = round(time.monotonic() - started, 1)
|
|
354
|
+
record["peak_rss_mb"] = peak or None
|
|
355
|
+
record["rss_samples"] = samples
|
|
356
|
+
produced = sorted(p for p in self.out_dir.glob(f"{name}*") if p.is_file())
|
|
357
|
+
record["output_files"] = [p.name for p in produced]
|
|
358
|
+
record["output_bytes"] = sum(p.stat().st_size for p in produced)
|
|
359
|
+
|
|
360
|
+
if died or resolve_pid() != self.pid:
|
|
361
|
+
record["outcome"] = "resolve_died"
|
|
362
|
+
else:
|
|
363
|
+
try:
|
|
364
|
+
status = project.GetRenderJobStatus(job) or {}
|
|
365
|
+
record["job_status"] = status.get("JobStatus")
|
|
366
|
+
record["outcome"] = "complete" if status.get("JobStatus") == "Complete" else "incomplete"
|
|
367
|
+
project.DeleteRenderJob(job)
|
|
368
|
+
except Exception as exc:
|
|
369
|
+
record["outcome"] = "resolve_died"
|
|
370
|
+
record["exception"] = f"{type(exc).__name__}: {exc}"
|
|
371
|
+
|
|
372
|
+
crash = self.crash.take()
|
|
373
|
+
if crash:
|
|
374
|
+
# The whole point of the harness: a stack, attached to the iteration
|
|
375
|
+
# that produced it, rather than a file someone reads tomorrow.
|
|
376
|
+
record["crash_captured"] = True
|
|
377
|
+
record["crash_text"] = crash[-6000:]
|
|
378
|
+
record["log_tail"] = log_tail()
|
|
379
|
+
if not self.args.keep:
|
|
380
|
+
for path in produced:
|
|
381
|
+
path.unlink(missing_ok=True)
|
|
382
|
+
return record
|
|
383
|
+
|
|
384
|
+
# -- recovery ------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
def recover(self) -> bool:
|
|
387
|
+
"""Bring Resolve back after a death, so the run continues past the first one.
|
|
388
|
+
|
|
389
|
+
Stopping at the first crash would answer "does it crash" and nothing
|
|
390
|
+
else. Whether it crashes on iteration 2 or iteration 40, and whether the
|
|
391
|
+
RSS was climbing first, are the parts that point at a cause.
|
|
392
|
+
"""
|
|
393
|
+
if not self.args.restart_on_crash:
|
|
394
|
+
return False
|
|
395
|
+
headless = self.args.mode == "headless"
|
|
396
|
+
command = rr.launch_command(headless=headless)
|
|
397
|
+
if command is None:
|
|
398
|
+
return False
|
|
399
|
+
subprocess.Popen(command, stdin=subprocess.DEVNULL,
|
|
400
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
401
|
+
if not self.attach():
|
|
402
|
+
return False
|
|
403
|
+
pm = self.resolve.GetProjectManager()
|
|
404
|
+
pm.LoadProject(self.project_name)
|
|
405
|
+
return pm.GetCurrentProject() is not None
|
|
406
|
+
|
|
407
|
+
# -- teardown ------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
def teardown(self) -> Dict[str, Any]:
|
|
410
|
+
"""Hand the session back on a *named* project, never on the fallback.
|
|
411
|
+
|
|
412
|
+
Closing the stress project drops Resolve onto a fresh, never-saved
|
|
413
|
+
`Untitled Project`, which cannot be saved — so the next close or switch
|
|
414
|
+
raises a modal a human has to clear. This harness did exactly that to a
|
|
415
|
+
live GUI session: it finished cleanly and left the application prompting
|
|
416
|
+
with no way to cancel.
|
|
417
|
+
|
|
418
|
+
Loading the incumbent first closes the stress project silently (it was
|
|
419
|
+
just saved) and never rests on the fallback. Delete afterwards.
|
|
420
|
+
"""
|
|
421
|
+
notes: Dict[str, Any] = {}
|
|
422
|
+
try:
|
|
423
|
+
pm = self.resolve.GetProjectManager()
|
|
424
|
+
save_project_if_safe(pm)
|
|
425
|
+
# Order matters, and both steps are load-bearing:
|
|
426
|
+
# 1. CloseProject releases the session's lock. Switching away with
|
|
427
|
+
# LoadProject does NOT — measured: DeleteProject then returns
|
|
428
|
+
# False forever, and retrying never helps. After a CloseProject
|
|
429
|
+
# the same delete succeeds on the first attempt.
|
|
430
|
+
# 2. LoadProject(incumbent) immediately afterwards, so the session
|
|
431
|
+
# does not rest on the never-saved `Untitled Project` fallback
|
|
432
|
+
# that CloseProject drops it onto — that fallback cannot be
|
|
433
|
+
# saved, so the next close or switch raises a modal a human has
|
|
434
|
+
# to clear. This harness did that to a live GUI session.
|
|
435
|
+
notes["closed"] = bool(pm.CloseProject(pm.GetCurrentProject()))
|
|
436
|
+
if self.incumbent and self.incumbent != self.project_name:
|
|
437
|
+
notes["restored_incumbent"] = bool(pm.LoadProject(self.incumbent))
|
|
438
|
+
notes["deleted"] = bool(pm.DeleteProject(self.project_name))
|
|
439
|
+
except Exception as exc:
|
|
440
|
+
notes["teardown_error"] = repr(exc)
|
|
441
|
+
shutil.rmtree(self.work, ignore_errors=True)
|
|
442
|
+
return notes
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def main() -> int:
|
|
446
|
+
parser = argparse.ArgumentParser(
|
|
447
|
+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
448
|
+
)
|
|
449
|
+
parser.add_argument("--mode", choices=("headless", "gui", "current"), default="current",
|
|
450
|
+
help="which session to stress; 'current' uses whatever is running")
|
|
451
|
+
parser.add_argument("--iterations", type=int, default=10)
|
|
452
|
+
parser.add_argument("--duration", type=int, default=60, help="timeline length in seconds")
|
|
453
|
+
parser.add_argument("--clip-seconds", type=int, default=4, help="length of the generated source clip")
|
|
454
|
+
parser.add_argument("--width", type=int, default=1998)
|
|
455
|
+
parser.add_argument("--height", type=int, default=1080)
|
|
456
|
+
parser.add_argument("--fps", type=int, default=24)
|
|
457
|
+
parser.add_argument("--format", default="mov")
|
|
458
|
+
parser.add_argument("--codec", default="Apple ProRes 422 HQ")
|
|
459
|
+
parser.add_argument("--source", help="use real media instead of the synthetic J2K MXF")
|
|
460
|
+
parser.add_argument("--out", help="render output directory (default: temp, deleted after)")
|
|
461
|
+
parser.add_argument("--keep", action="store_true", help="keep rendered files")
|
|
462
|
+
parser.add_argument("--timeout", type=float, default=3600, help="per-render timeout in seconds")
|
|
463
|
+
parser.add_argument("--restart-on-crash", action="store_true",
|
|
464
|
+
help="relaunch Resolve and keep going after a death")
|
|
465
|
+
parser.add_argument("--report", type=Path, help="write the JSON report here")
|
|
466
|
+
args = parser.parse_args()
|
|
467
|
+
|
|
468
|
+
mode = rr.runtime_mode()
|
|
469
|
+
if args.mode != "current":
|
|
470
|
+
want_headless = args.mode == "headless"
|
|
471
|
+
if mode["running"] and bool(mode["headless"]) != want_headless:
|
|
472
|
+
print(f"REFUSE: a Resolve is already running in the other mode: {mode['command_lines']}",
|
|
473
|
+
file=sys.stderr)
|
|
474
|
+
return 1
|
|
475
|
+
if not mode["running"]:
|
|
476
|
+
command = rr.launch_command(headless=want_headless)
|
|
477
|
+
if command is None:
|
|
478
|
+
print("REFUSE: no Resolve install found.", file=sys.stderr)
|
|
479
|
+
return 3
|
|
480
|
+
print(f"starting: {' '.join(command)}")
|
|
481
|
+
subprocess.Popen(command, stdin=subprocess.DEVNULL,
|
|
482
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
483
|
+
|
|
484
|
+
run = StressRun(args)
|
|
485
|
+
if not run.attach():
|
|
486
|
+
print("REFUSE: no Resolve answered the scripting API.", file=sys.stderr)
|
|
487
|
+
return 4
|
|
488
|
+
|
|
489
|
+
observed = rr.runtime_mode()
|
|
490
|
+
print(f"stressing: headless={observed['headless']} pid={run.pid}", flush=True)
|
|
491
|
+
setup = run.build_project()
|
|
492
|
+
print(json.dumps(setup, indent=2), flush=True)
|
|
493
|
+
|
|
494
|
+
for index in range(1, args.iterations + 1):
|
|
495
|
+
record = run.render_once(index)
|
|
496
|
+
run.iterations.append(record)
|
|
497
|
+
flag = " CRASH" if record.get("crash_captured") else ""
|
|
498
|
+
print(f" [{index:3d}/{args.iterations}] {record.get('outcome'):16} "
|
|
499
|
+
f"{record.get('elapsed_seconds')}s peak_rss={record.get('peak_rss_mb')}MB "
|
|
500
|
+
f"bytes={record.get('output_bytes')}{flag}", flush=True)
|
|
501
|
+
if record["outcome"] == "resolve_died":
|
|
502
|
+
if not run.recover():
|
|
503
|
+
print(" Resolve died and was not recovered; stopping.")
|
|
504
|
+
break
|
|
505
|
+
print(" recovered; continuing", flush=True)
|
|
506
|
+
|
|
507
|
+
report = {
|
|
508
|
+
"metadata": {
|
|
509
|
+
"recorded_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
510
|
+
"headless": observed["headless"],
|
|
511
|
+
"iterations_requested": args.iterations,
|
|
512
|
+
"duration_seconds": args.duration,
|
|
513
|
+
"format": args.format,
|
|
514
|
+
"codec": args.codec,
|
|
515
|
+
},
|
|
516
|
+
"setup": setup,
|
|
517
|
+
"iterations": run.iterations,
|
|
518
|
+
"teardown": run.teardown(),
|
|
519
|
+
}
|
|
520
|
+
deaths = [i for i in run.iterations if i.get("outcome") == "resolve_died"]
|
|
521
|
+
crashes = [i for i in run.iterations if i.get("crash_captured")]
|
|
522
|
+
report["summary"] = {
|
|
523
|
+
"completed": sum(1 for i in run.iterations if i.get("outcome") == "complete"),
|
|
524
|
+
"deaths": len(deaths),
|
|
525
|
+
"crash_blocks_captured": len(crashes),
|
|
526
|
+
"first_death_iteration": deaths[0]["iteration"] if deaths else None,
|
|
527
|
+
"peak_rss_mb": max((i.get("peak_rss_mb") or 0) for i in run.iterations) if run.iterations else None,
|
|
528
|
+
"rss_first_to_last": [
|
|
529
|
+
run.iterations[0].get("peak_rss_mb") if run.iterations else None,
|
|
530
|
+
run.iterations[-1].get("peak_rss_mb") if run.iterations else None,
|
|
531
|
+
],
|
|
532
|
+
}
|
|
533
|
+
print(json.dumps(report["summary"], indent=2))
|
|
534
|
+
if args.report:
|
|
535
|
+
args.report.parent.mkdir(parents=True, exist_ok=True)
|
|
536
|
+
args.report.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
|
|
537
|
+
print(f"report: {args.report}")
|
|
538
|
+
return 0
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
if __name__ == "__main__":
|
|
542
|
+
raise SystemExit(main())
|