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,796 @@
|
|
|
1
|
+
"""What to probe in each mode, as data.
|
|
2
|
+
|
|
3
|
+
Ordered so that cheap read-only probes run first and the destructive ones last.
|
|
4
|
+
Within a worker run the order is stable, which is what lets the supervisor
|
|
5
|
+
identify a hung probe by position: results stream in order, so the first missing
|
|
6
|
+
one was in flight.
|
|
7
|
+
|
|
8
|
+
Two conventions worth knowing before adding probes:
|
|
9
|
+
|
|
10
|
+
**State is part of the probe.** `SaveProject` is harmless on a named project and
|
|
11
|
+
hangs forever headless on the never-saved `Untitled Project`. A catalogue that
|
|
12
|
+
only ever ran calls against a well-formed scratch project would have missed it —
|
|
13
|
+
the first differential did exactly that and reported no differences at all. So
|
|
14
|
+
probes declare `state="untitled"` when they need Resolve sitting on its default
|
|
15
|
+
project, and the worker arranges it.
|
|
16
|
+
|
|
17
|
+
**Timeouts are per probe.** A render needs minutes; a getter needs seconds.
|
|
18
|
+
Giving everything the same generous timeout means a hung getter costs minutes of
|
|
19
|
+
wall clock, and giving everything a short one turns a slow render into a false
|
|
20
|
+
hang.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any, List
|
|
26
|
+
|
|
27
|
+
from src.utils.mode_matrix import Probe
|
|
28
|
+
|
|
29
|
+
CATALOGUE: List[Probe] = []
|
|
30
|
+
|
|
31
|
+
#: Bumped whenever a probe's *body* changes meaning. Results from different
|
|
32
|
+
#: catalogue versions must not be compared: the `combine()` fix changed a failed
|
|
33
|
+
#: export from `ok:'False/0'` to `false:False`, and comparing runs across that
|
|
34
|
+
#: change made one probe look flaky within a mode when it had simply been
|
|
35
|
+
#: recorded two ways. The fingerprint below makes that mistake refuse rather
|
|
36
|
+
#: than mislead.
|
|
37
|
+
CATALOGUE_VERSION = 4
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def combine(ok: Any, detail: Any) -> Any:
|
|
41
|
+
"""`False` when the call failed, otherwise a composite of call and evidence.
|
|
42
|
+
|
|
43
|
+
Probes that returned `f"{ok}/{written}"` unconditionally reported outcome
|
|
44
|
+
`ok`, because a non-empty string is a successful reading. `gallery.
|
|
45
|
+
export_stills` therefore recorded `'False/0'` as a success and was compared
|
|
46
|
+
as merely *divergent* from the GUI's `'True/2'` — burying a clean
|
|
47
|
+
headless-only failure among value differences. The boolean has to survive
|
|
48
|
+
into the outcome, not just into the text.
|
|
49
|
+
"""
|
|
50
|
+
if ok is False or ok is None:
|
|
51
|
+
return ok
|
|
52
|
+
return f"{ok}/{detail}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def probe(name: str, category: str, **kwargs: Any):
|
|
56
|
+
def register(fn):
|
|
57
|
+
CATALOGUE.append(Probe(name, category, fn, **kwargs))
|
|
58
|
+
return fn
|
|
59
|
+
return register
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── app-level reads ──────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@probe("app.product_name", "app", timeout=15)
|
|
66
|
+
def _probe_app_product_name(ctx): return ctx.resolve.GetProductName()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@probe("app.version_string", "app", timeout=15)
|
|
70
|
+
def _probe_app_version_string(ctx): return ctx.resolve.GetVersionString()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@probe("app.current_page", "app", timeout=15)
|
|
74
|
+
def _probe_app_current_page(ctx): return ctx.resolve.GetCurrentPage()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@probe("app.keyframe_mode", "app", timeout=15)
|
|
78
|
+
def _probe_app_keyframe_mode(ctx): return ctx.resolve.GetKeyframeMode()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@probe("app.fusion_handle", "app", timeout=20)
|
|
82
|
+
def _probe_app_fusion_handle(ctx): return bool(ctx.resolve.Fusion())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@probe("app.fusion_current_comp", "app", timeout=20)
|
|
86
|
+
def _probe_app_fusion_current_comp(ctx): return bool(ctx.resolve.Fusion().GetCurrentComp())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@probe("app.media_storage", "app", timeout=20)
|
|
90
|
+
def _probe_app_media_storage(ctx): return bool(ctx.resolve.GetMediaStorage())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@probe("app.mounted_volumes", "app", timeout=30)
|
|
94
|
+
def _probe_app_mounted_volumes(ctx): return len(ctx.resolve.GetMediaStorage().GetMountedVolumeList() or [])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── pages: every page, opened and read back ──────────────────────────────────
|
|
98
|
+
|
|
99
|
+
for _page in ("media", "cut", "edit", "fusion", "color", "fairlight", "deliver"):
|
|
100
|
+
def _make(page):
|
|
101
|
+
def body(ctx):
|
|
102
|
+
ok = ctx.resolve.OpenPage(page)
|
|
103
|
+
return f"{ok}/{ctx.resolve.GetCurrentPage()}"
|
|
104
|
+
return body
|
|
105
|
+
CATALOGUE.append(Probe(f"page.open_{_page}", "pages", _make(_page), timeout=25))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── project manager ──────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@probe("pm.current_database", "project_manager", timeout=20)
|
|
112
|
+
def _probe_pm_current_database(ctx): return ctx.pm.GetCurrentDatabase()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@probe("pm.database_list", "project_manager", timeout=30)
|
|
116
|
+
def _probe_pm_database_list(ctx): return len(ctx.pm.GetDatabaseList() or [])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@probe("pm.project_list_count", "project_manager", timeout=60)
|
|
120
|
+
def _probe_pm_project_list_count(ctx): return len(ctx.pm.GetProjectListInCurrentFolder() or []) > 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@probe("pm.folder_list", "project_manager", timeout=30)
|
|
124
|
+
def _probe_pm_folder_list(ctx): return isinstance(ctx.pm.GetFolderListInCurrentFolder(), list)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@probe("pm.current_folder", "project_manager", timeout=20)
|
|
128
|
+
def _probe_pm_current_folder(ctx): return ctx.pm.GetCurrentFolder()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
#: The probe this whole harness exists for — and it is `blocking`, so it never
|
|
132
|
+
#: runs in a sweep unless named explicitly.
|
|
133
|
+
#:
|
|
134
|
+
#: Measured twice on 2026-08-01, and the second measurement corrected the first.
|
|
135
|
+
#: It does not merely hang the calling client: it blocks the ENTIRE application,
|
|
136
|
+
#: in the GUI as well as headless. A fresh client opened while it was pending got
|
|
137
|
+
#: no answer either. So this is not "GUI returns False, headless hangs" — it is a
|
|
138
|
+
#: modal that stops everything, visible and clickable with a UI, invisible and
|
|
139
|
+
#: unclearable without one.
|
|
140
|
+
#:
|
|
141
|
+
#: (An earlier run did see it return False immediately on an unmodified default
|
|
142
|
+
#: project, so some condition distinguishes the two behaviours. That condition is
|
|
143
|
+
#: not yet established, which is itself a reason to keep this out of sweeps.)
|
|
144
|
+
@probe("pm.save_untitled_project", "project_manager", timeout=45, state="untitled",
|
|
145
|
+
blocking=True,
|
|
146
|
+
note="BLOCKS THE WHOLE APP in both modes; needs a force-kill to clear")
|
|
147
|
+
def _probe_pm_save_untitled_project(ctx): return ctx.pm.SaveProject()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@probe("pm.save_named_project", "project_manager", timeout=45, state="scratch",
|
|
151
|
+
note="the control for save_untitled_project: must work in both modes")
|
|
152
|
+
def _probe_pm_save_named_project(ctx): return ctx.pm.SaveProject()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@probe("pm.current_project_name_on_untitled", "project_manager", timeout=20, state="untitled")
|
|
156
|
+
def _probe_pm_current_project_name_on_untitled(ctx): return ctx.pm.GetCurrentProject().GetName()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@probe("pm.export_project", "project_manager", timeout=120, destructive=True)
|
|
160
|
+
def _probe_pm_export_project(ctx):
|
|
161
|
+
target = ctx.work / "exported.drp"
|
|
162
|
+
ok = ctx.pm.ExportProject(ctx.project_name, str(target))
|
|
163
|
+
return combine(ok, target.exists() and target.stat().st_size > 0)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── project settings ─────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@probe("project.setting_count", "project_settings", timeout=30, needs=("project",))
|
|
170
|
+
def _probe_project_setting_count(ctx): return len(ctx.project.GetSetting() or {}) > 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@probe("project.read_frame_rate", "project_settings", timeout=20, needs=("project",))
|
|
174
|
+
def _probe_project_read_frame_rate(ctx): return ctx.project.GetSetting("timelineFrameRate")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@probe("project.set_resolution", "project_settings", timeout=25, needs=("project",))
|
|
178
|
+
def _probe_project_set_resolution(ctx):
|
|
179
|
+
ok = ctx.project.SetSetting("timelineResolutionWidth", "1920")
|
|
180
|
+
return f"{ok}/{ctx.project.GetSetting('timelineResolutionWidth')}"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@probe("project.set_colour_science", "project_settings", timeout=30, needs=("project",))
|
|
184
|
+
def _probe_project_set_colour_science(ctx):
|
|
185
|
+
ok = ctx.project.SetSetting("colorScienceMode", "davinciYRGB")
|
|
186
|
+
return f"{ok}/{ctx.project.GetSetting('colorScienceMode')}"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@probe("project.unique_id_shape", "project_settings", timeout=20, needs=("project",))
|
|
190
|
+
def _probe_project_unique_id_shape(ctx): return type(ctx.project.GetUniqueId()).__name__
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ── media pool ───────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@probe("pool.root_folder", "media_pool", timeout=25, needs=("project",))
|
|
197
|
+
def _probe_pool_root_folder(ctx): return bool(ctx.project.GetMediaPool().GetRootFolder())
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@probe("pool.clip_count", "media_pool", timeout=30, needs=("clip",))
|
|
201
|
+
def _probe_pool_clip_count(ctx): return len(ctx.project.GetMediaPool().GetRootFolder().GetClipList() or [])
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@probe("pool.add_subfolder", "media_pool", timeout=30, needs=("project",), destructive=True)
|
|
205
|
+
def _probe_pool_add_subfolder(ctx):
|
|
206
|
+
pool = ctx.project.GetMediaPool()
|
|
207
|
+
folder = pool.AddSubFolder(pool.GetRootFolder(), "PROBE_BIN")
|
|
208
|
+
if not folder:
|
|
209
|
+
return False
|
|
210
|
+
moved = pool.SetCurrentFolder(folder)
|
|
211
|
+
pool.SetCurrentFolder(pool.GetRootFolder())
|
|
212
|
+
pool.DeleteFolders([folder])
|
|
213
|
+
return moved
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@probe("pool.clip_resolution", "media_pool", timeout=25, needs=("clip",))
|
|
217
|
+
def _probe_pool_clip_resolution(ctx): return ctx.clip.GetClipProperty("Resolution")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@probe("pool.set_clip_color", "media_pool", timeout=25, needs=("clip",), destructive=True)
|
|
221
|
+
def _probe_pool_set_clip_color(ctx): return ctx.clip.SetClipColor("Orange")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@probe("pool.set_clip_metadata", "media_pool", timeout=25, needs=("clip",), destructive=True)
|
|
225
|
+
def _probe_pool_set_clip_metadata(ctx):
|
|
226
|
+
ok = ctx.clip.SetMetadata("Comments", "probe")
|
|
227
|
+
return f"{ok}/{ctx.clip.GetMetadata('Comments')}"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@probe("pool.clip_marker_roundtrip", "media_pool", timeout=30, needs=("clip",), destructive=True)
|
|
231
|
+
def _probe_pool_clip_marker_roundtrip(ctx):
|
|
232
|
+
added = ctx.clip.AddMarker(1, "Blue", "PROBE", "note", 1)
|
|
233
|
+
count = len(ctx.clip.GetMarkers() or {})
|
|
234
|
+
ctx.clip.DeleteMarkerAtFrame(1)
|
|
235
|
+
return f"{added}/{count}"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@probe("pool.append_to_timeline", "media_pool", timeout=60, needs=("clip", "timeline"), destructive=True)
|
|
239
|
+
def _probe_pool_append_to_timeline(ctx):
|
|
240
|
+
pool = ctx.project.GetMediaPool()
|
|
241
|
+
before = ctx.timeline.GetEndFrame()
|
|
242
|
+
frames = int(ctx.clip.GetClipProperty("Frames") or 0)
|
|
243
|
+
pool.AppendToTimeline([{"mediaPoolItem": ctx.clip, "startFrame": 0,
|
|
244
|
+
"endFrame": max(frames - 1, 0)}])
|
|
245
|
+
return ctx.timeline.GetEndFrame() > before
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ── timeline ─────────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@probe("timeline.name", "timeline", timeout=20, needs=("timeline",))
|
|
252
|
+
def _probe_timeline_name(ctx): return ctx.timeline.GetName()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@probe("timeline.track_count_video", "timeline", timeout=20, needs=("timeline",))
|
|
256
|
+
def _probe_timeline_track_count_video(ctx): return ctx.timeline.GetTrackCount("video")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@probe("timeline.start_timecode", "timeline", timeout=20, needs=("timeline",))
|
|
260
|
+
def _probe_timeline_start_timecode(ctx): return ctx.timeline.GetStartTimecode()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@probe("timeline.set_current_timecode", "timeline", timeout=25, needs=("timeline",))
|
|
264
|
+
def _probe_timeline_set_current_timecode(ctx): return ctx.timeline.SetCurrentTimecode(ctx.timeline.GetStartTimecode())
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@probe("timeline.current_video_item", "timeline", timeout=25, needs=("timeline",))
|
|
268
|
+
def _probe_timeline_current_video_item(ctx): return bool(ctx.timeline.GetCurrentVideoItem())
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@probe("timeline.marker_roundtrip", "timeline", timeout=30, needs=("timeline",), destructive=True)
|
|
272
|
+
def _probe_timeline_marker_roundtrip(ctx):
|
|
273
|
+
added = ctx.timeline.AddMarker(1, "Blue", "PROBE", "note", 1)
|
|
274
|
+
count = len(ctx.timeline.GetMarkers() or {})
|
|
275
|
+
ctx.timeline.DeleteMarkerAtFrame(1)
|
|
276
|
+
return f"{added}/{count}"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@probe("timeline.add_delete_track", "timeline", timeout=40, needs=("timeline",), destructive=True)
|
|
280
|
+
def _probe_timeline_add_delete_track(ctx):
|
|
281
|
+
added = ctx.timeline.AddTrack("video")
|
|
282
|
+
n = ctx.timeline.GetTrackCount("video")
|
|
283
|
+
deleted = ctx.timeline.DeleteTrack("video", n)
|
|
284
|
+
return f"{added}/{deleted}"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@probe("timeline.set_track_name", "timeline", timeout=25, needs=("timeline",), destructive=True)
|
|
288
|
+
def _probe_timeline_set_track_name(ctx):
|
|
289
|
+
ok = ctx.timeline.SetTrackName("video", 1, "PROBE_TRACK")
|
|
290
|
+
return f"{ok}/{ctx.timeline.GetTrackName('video', 1)}"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@probe("timeline.track_enable_roundtrip", "timeline", timeout=30, needs=("timeline",), destructive=True)
|
|
294
|
+
def _probe_timeline_track_enable_roundtrip(ctx):
|
|
295
|
+
off = ctx.timeline.SetTrackEnable("video", 1, False)
|
|
296
|
+
ctx.timeline.SetTrackEnable("video", 1, True)
|
|
297
|
+
return off
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@probe("timeline.set_name", "timeline", timeout=30, needs=("timeline",), destructive=True,
|
|
301
|
+
note="documented to fail on the ACTIVE timeline")
|
|
302
|
+
def _probe_timeline_set_name(ctx): return ctx.timeline.SetName("PROBE_RENAMED")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@probe("timeline.duplicate", "timeline", timeout=60, needs=("timeline",), destructive=True)
|
|
306
|
+
def _probe_timeline_duplicate(ctx):
|
|
307
|
+
pool = ctx.project.GetMediaPool()
|
|
308
|
+
dup = pool.DuplicateTimeline("PROBE_DUP")
|
|
309
|
+
return bool(dup)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@probe("timeline.grab_still", "timeline", timeout=60, needs=("timeline",), destructive=True)
|
|
313
|
+
def _probe_timeline_grab_still(ctx):
|
|
314
|
+
still = ctx.timeline.GrabStill()
|
|
315
|
+
if not still:
|
|
316
|
+
return False
|
|
317
|
+
album = ctx.project.GetGallery().GetCurrentStillAlbum()
|
|
318
|
+
album and album.DeleteStills([still])
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ── timeline items ───────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@probe("item.name", "timeline_item", timeout=20, needs=("item",))
|
|
326
|
+
def _probe_item_name(ctx): return ctx.item.GetName()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@probe("item.duration", "timeline_item", timeout=20, needs=("item",))
|
|
330
|
+
def _probe_item_duration(ctx): return ctx.item.GetDuration()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@probe("item.left_offset", "timeline_item", timeout=20, needs=("item",))
|
|
334
|
+
def _probe_item_left_offset(ctx): return ctx.item.GetLeftOffset()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@probe("item.set_zoom_property", "timeline_item", timeout=25, needs=("item",), destructive=True)
|
|
338
|
+
def _probe_item_set_zoom_property(ctx):
|
|
339
|
+
ok = ctx.item.SetProperty("ZoomX", 1.25)
|
|
340
|
+
return f"{ok}/{ctx.item.GetProperty('ZoomX')}"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@probe("item.marker_roundtrip", "timeline_item", timeout=30, needs=("item",), destructive=True)
|
|
344
|
+
def _probe_item_marker_roundtrip(ctx):
|
|
345
|
+
added = ctx.item.AddMarker(1, "Blue", "PROBE", "note", 1)
|
|
346
|
+
ctx.item.DeleteMarkerAtFrame(1)
|
|
347
|
+
return added
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@probe("item.flag_roundtrip", "timeline_item", timeout=30, needs=("item",), destructive=True)
|
|
351
|
+
def _probe_item_flag_roundtrip(ctx):
|
|
352
|
+
added = ctx.item.AddFlag("Blue")
|
|
353
|
+
ctx.item.ClearFlags("All")
|
|
354
|
+
return added
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@probe("item.set_clip_color", "timeline_item", timeout=25, needs=("item",), destructive=True)
|
|
358
|
+
def _probe_item_set_clip_color(ctx):
|
|
359
|
+
ok = ctx.item.SetClipColor("Orange")
|
|
360
|
+
ctx.item.ClearClipColor()
|
|
361
|
+
return ok
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@probe("item.media_pool_item", "timeline_item", timeout=25, needs=("item",))
|
|
365
|
+
def _probe_item_media_pool_item(ctx): return bool(ctx.item.GetMediaPoolItem())
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ── colour ───────────────────────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@probe("color.node_graph", "color", timeout=30, needs=("item",))
|
|
372
|
+
def _probe_color_node_graph(ctx): return bool(ctx.item.GetNodeGraph())
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@probe("color.num_nodes", "color", timeout=25, needs=("item",))
|
|
376
|
+
def _probe_color_num_nodes(ctx): return ctx.item.GetNodeGraph().GetNumNodes()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@probe("color.node_label", "color", timeout=25, needs=("item",))
|
|
380
|
+
def _probe_color_node_label(ctx): return ctx.item.GetNodeGraph().GetNodeLabel(1)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@probe("color.node_lut", "color", timeout=25, needs=("item",))
|
|
384
|
+
def _probe_color_node_lut(ctx): return ctx.item.GetNodeGraph().GetLUT(1)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@probe("color.tools_in_node", "color", timeout=25, needs=("item",))
|
|
388
|
+
def _probe_color_tools_in_node(ctx): return len(ctx.item.GetNodeGraph().GetToolsInNode(1) or [])
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@probe("color.set_node_enabled", "color", timeout=25, needs=("item",), destructive=True)
|
|
392
|
+
def _probe_color_set_node_enabled(ctx): return ctx.item.GetNodeGraph().SetNodeEnabled(1, True)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@probe("color.reset_all_grades", "color", timeout=40, needs=("item",), destructive=True)
|
|
396
|
+
def _probe_color_reset_all_grades(ctx): return ctx.item.GetNodeGraph().ResetAllGrades()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@probe("color.group_roundtrip", "color", timeout=45, needs=("item", "project"), destructive=True)
|
|
400
|
+
def _probe_color_group_roundtrip(ctx):
|
|
401
|
+
group = ctx.project.AddColorGroup("PROBE_GROUP")
|
|
402
|
+
if not group:
|
|
403
|
+
return False
|
|
404
|
+
assigned = ctx.item.AssignToColorGroup(group)
|
|
405
|
+
ctx.item.RemoveFromColorGroup()
|
|
406
|
+
ctx.project.DeleteColorGroup(group)
|
|
407
|
+
return assigned
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@probe("color.set_cdl", "color", timeout=30, needs=("item",), destructive=True)
|
|
411
|
+
def _probe_color_set_cdl(ctx):
|
|
412
|
+
return ctx.item.SetCDL({"NodeIndex": "1", "Slope": "1 1 1", "Offset": "0 0 0",
|
|
413
|
+
"Power": "1 1 1", "Saturation": "1"})
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ── gallery ──────────────────────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@probe("gallery.handle", "gallery", timeout=25, needs=("project",))
|
|
420
|
+
def _probe_gallery_handle(ctx): return bool(ctx.project.GetGallery())
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@probe("gallery.album_count", "gallery", timeout=25, needs=("project",))
|
|
424
|
+
def _probe_gallery_album_count(ctx): return len(ctx.project.GetGallery().GetGalleryStillAlbums() or [])
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@probe("gallery.export_stills", "gallery", timeout=90, needs=("timeline", "project"), destructive=True,
|
|
428
|
+
note="fails in BOTH modes without a visible Gallery panel")
|
|
429
|
+
def _probe_gallery_export_stills(ctx):
|
|
430
|
+
gallery = ctx.project.GetGallery()
|
|
431
|
+
album = gallery.GetCurrentStillAlbum()
|
|
432
|
+
still = ctx.timeline.GrabStill()
|
|
433
|
+
if not (album and still):
|
|
434
|
+
return False
|
|
435
|
+
target = ctx.work / "stills"
|
|
436
|
+
target.mkdir(exist_ok=True)
|
|
437
|
+
ok = album.ExportStills([still], str(target), "probe", "jpg")
|
|
438
|
+
produced = len(list(target.glob("probe*")))
|
|
439
|
+
album.DeleteStills([still])
|
|
440
|
+
return combine(ok, produced)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@probe("gallery.export_current_frame", "gallery", timeout=90, needs=("project",), destructive=True)
|
|
444
|
+
def _probe_gallery_export_current_frame(ctx):
|
|
445
|
+
target = ctx.work / "frame.png"
|
|
446
|
+
ok = ctx.project.ExportCurrentFrameAsStill(str(target))
|
|
447
|
+
return combine(ok, target.exists() and target.stat().st_size > 0)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# ── fusion ───────────────────────────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@probe("fusion.comp_count", "fusion", timeout=30, needs=("item",))
|
|
454
|
+
def _probe_fusion_comp_count(ctx): return ctx.item.GetFusionCompCount()
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@probe("fusion.add_delete_comp", "fusion", timeout=90, needs=("item",), destructive=True)
|
|
458
|
+
def _probe_fusion_add_delete_comp(ctx):
|
|
459
|
+
added = bool(ctx.item.AddFusionComp())
|
|
460
|
+
names = ctx.item.GetFusionCompNameList() or []
|
|
461
|
+
deleted = ctx.item.DeleteFusionCompByName(names[-1]) if names else None
|
|
462
|
+
return f"{added}/{deleted}"
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# ── render / deliver ─────────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@probe("render.format_count", "render", timeout=45, needs=("project",))
|
|
469
|
+
def _probe_render_format_count(ctx): return len(ctx.project.GetRenderFormats() or {})
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@probe("render.codec_count_mov", "render", timeout=45, needs=("project",))
|
|
473
|
+
def _probe_render_codec_count_mov(ctx): return len(ctx.project.GetRenderCodecs("mov") or {})
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@probe("render.preset_count", "render", timeout=45, needs=("project",))
|
|
477
|
+
def _probe_render_preset_count(ctx): return len(ctx.project.GetRenderPresetList() or [])
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@probe("render.set_format_codec", "render", timeout=45, needs=("project",), destructive=True)
|
|
481
|
+
def _probe_render_set_format_codec(ctx): return ctx.project.SetCurrentRenderFormatAndCodec("mov", "ProRes422HQ")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@probe("render.job_roundtrip", "render", timeout=120, needs=("project", "timeline"), destructive=True)
|
|
485
|
+
def _probe_render_job_roundtrip(ctx):
|
|
486
|
+
ctx.project.SetRenderSettings({"TargetDir": str(ctx.work), "CustomName": "probe_job",
|
|
487
|
+
"SelectAllFrames": True})
|
|
488
|
+
job = ctx.project.AddRenderJob()
|
|
489
|
+
if not job:
|
|
490
|
+
return False
|
|
491
|
+
count = len(ctx.project.GetRenderJobList() or [])
|
|
492
|
+
deleted = ctx.project.DeleteRenderJob(job)
|
|
493
|
+
return f"{count}/{deleted}"
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@probe("render.to_disk", "render", timeout=600, needs=("project", "timeline"), destructive=True,
|
|
497
|
+
note="the delivery path end to end; the only probe that writes real pixels")
|
|
498
|
+
def _probe_render_to_disk(ctx):
|
|
499
|
+
import time as _t
|
|
500
|
+
ctx.project.SetCurrentRenderFormatAndCodec("mov", "ProRes422Proxy")
|
|
501
|
+
ctx.project.SetRenderSettings({"TargetDir": str(ctx.work), "CustomName": "probe_render",
|
|
502
|
+
"SelectAllFrames": True})
|
|
503
|
+
job = ctx.project.AddRenderJob()
|
|
504
|
+
if not job:
|
|
505
|
+
return False
|
|
506
|
+
ctx.project.StartRendering(job, isInteractiveMode=False)
|
|
507
|
+
deadline = _t.monotonic() + 420
|
|
508
|
+
while ctx.project.IsRenderingInProgress() and _t.monotonic() < deadline:
|
|
509
|
+
_t.sleep(1)
|
|
510
|
+
status = (ctx.project.GetRenderJobStatus(job) or {}).get("JobStatus")
|
|
511
|
+
written = [p for p in ctx.work.glob("probe_render*") if p.stat().st_size > 0]
|
|
512
|
+
ctx.project.DeleteRenderJob(job)
|
|
513
|
+
return f"{status}/{len(written)}"
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# ── interchange export ───────────────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
_EXPORTS = (("aaf", "EXPORT_AAF", "EXPORT_AAF_NEW"), ("edl", "EXPORT_EDL", "EXPORT_NONE"),
|
|
519
|
+
("fcpxml", "EXPORT_FCP_7_XML", "EXPORT_NONE"), ("drt", "EXPORT_DRT", "EXPORT_NONE"),
|
|
520
|
+
("otio", "EXPORT_OTIO", "EXPORT_NONE"))
|
|
521
|
+
|
|
522
|
+
for _label, _type, _sub in _EXPORTS:
|
|
523
|
+
def _make(label, type_name, sub_name):
|
|
524
|
+
def body(ctx):
|
|
525
|
+
path = ctx.work / f"probe.{label}"
|
|
526
|
+
ok = ctx.timeline.Export(str(path), getattr(ctx.resolve, type_name),
|
|
527
|
+
getattr(ctx.resolve, sub_name))
|
|
528
|
+
return combine(ok, path.exists() and path.stat().st_size > 0)
|
|
529
|
+
return body
|
|
530
|
+
CATALOGUE.append(Probe(f"export.{_label}", "interchange", _make(_label, _type, _sub),
|
|
531
|
+
timeout=120, needs=("timeline",), destructive=True))
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ── audio / fairlight ────────────────────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
@probe("audio.track_count", "audio", timeout=25, needs=("timeline",))
|
|
538
|
+
def _probe_audio_track_count(ctx): return ctx.timeline.GetTrackCount("audio")
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@probe("audio.add_delete_track", "audio", timeout=45, needs=("timeline",), destructive=True)
|
|
542
|
+
def _probe_audio_add_delete_track(ctx):
|
|
543
|
+
added = ctx.timeline.AddTrack("audio", "stereo")
|
|
544
|
+
n = ctx.timeline.GetTrackCount("audio")
|
|
545
|
+
deleted = ctx.timeline.DeleteTrack("audio", n) if n else None
|
|
546
|
+
return f"{added}/{deleted}"
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@probe("audio.set_track_name", "audio", timeout=30, needs=("timeline",), destructive=True)
|
|
550
|
+
def _probe_audio_set_track_name(ctx):
|
|
551
|
+
if not ctx.timeline.GetTrackCount("audio"):
|
|
552
|
+
return None
|
|
553
|
+
return ctx.timeline.SetTrackName("audio", 1, "PROBE_AUDIO")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# ── layout / UI-shaped surfaces ──────────────────────────────────────────────
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@probe("layout.save_preset", "layout", timeout=60, destructive=True)
|
|
560
|
+
def _probe_layout_save_preset(ctx): return ctx.resolve.SaveLayoutPreset("PROBE_LAYOUT")
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@probe("layout.export_preset", "layout", timeout=60, destructive=True)
|
|
564
|
+
def _probe_layout_export_preset(ctx):
|
|
565
|
+
path = ctx.work / "layout.preset"
|
|
566
|
+
ok = ctx.resolve.ExportLayoutPreset("PROBE_LAYOUT", str(path))
|
|
567
|
+
return combine(ok, path.exists() and path.stat().st_size > 0)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@probe("layout.load_preset", "layout", timeout=60, destructive=True)
|
|
571
|
+
def _probe_layout_load_preset(ctx): return ctx.resolve.LoadLayoutPreset("PROBE_LAYOUT")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@probe("layout.delete_preset", "layout", timeout=45, destructive=True)
|
|
575
|
+
def _probe_layout_delete_preset(ctx): return ctx.resolve.DeleteLayoutPreset("PROBE_LAYOUT")
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# ── project lifecycle (last: these move the current project) ──────────────────
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
#: Lifecycle probes use a per-run unique suffix and the safe delete helper.
|
|
582
|
+
#:
|
|
583
|
+
#: They did neither at first, and it produced a false finding: the GUI pass left
|
|
584
|
+
#: a `PROBE_LIFECYCLE` behind (its DeleteProject hit the session lock), so the
|
|
585
|
+
#: headless pass's CreateProject collided with the existing name, returned False,
|
|
586
|
+
#: and was reported as `headless_degraded`. CreateProject with a fresh name works
|
|
587
|
+
#: identically in both modes. A fixed name plus an unreliable delete is a
|
|
588
|
+
#: cross-run dependency, and a differential must not have one.
|
|
589
|
+
def _unique(ctx, stem):
|
|
590
|
+
return f"{stem}_{ctx.project_name.split('_')[-1]}"
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _safe_delete(ctx, name):
|
|
594
|
+
from src.utils.project_cleanup import delete_project_safely
|
|
595
|
+
return delete_project_safely(ctx.pm, name, switch_to=ctx.project_name)["success"]
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
@probe("lifecycle.create_project", "lifecycle", timeout=90, destructive=True)
|
|
599
|
+
def _probe_lifecycle_create_project(ctx):
|
|
600
|
+
name = _unique(ctx, "PROBE_LIFECYCLE")
|
|
601
|
+
made = bool(ctx.pm.CreateProject(name))
|
|
602
|
+
if made:
|
|
603
|
+
_safe_delete(ctx, name)
|
|
604
|
+
return made
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@probe("lifecycle.load_and_close", "lifecycle", timeout=120, destructive=True)
|
|
608
|
+
def _probe_lifecycle_load_and_close(ctx):
|
|
609
|
+
name = _unique(ctx, "PROBE_LIFECYCLE2")
|
|
610
|
+
if not ctx.pm.CreateProject(name):
|
|
611
|
+
return False
|
|
612
|
+
loaded = bool(ctx.pm.LoadProject(name))
|
|
613
|
+
closed = bool(ctx.pm.CloseProject(ctx.pm.GetCurrentProject()))
|
|
614
|
+
ctx.pm.LoadProject(ctx.project_name)
|
|
615
|
+
deleted = _safe_delete(ctx, name)
|
|
616
|
+
return f"{loaded}/{closed}/{deleted}"
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
@probe("lifecycle.delete_after_close_releases_lock", "lifecycle", timeout=120, destructive=True,
|
|
620
|
+
note="LoadProject-away does NOT release the lock; CloseProject does")
|
|
621
|
+
def _probe_lifecycle_delete_after_close_releases_lock(ctx):
|
|
622
|
+
name = _unique(ctx, "PROBE_LOCK")
|
|
623
|
+
if not ctx.pm.CreateProject(name):
|
|
624
|
+
return False
|
|
625
|
+
ctx.pm.LoadProject(name)
|
|
626
|
+
ctx.pm.LoadProject(ctx.project_name) # switch away WITHOUT closing
|
|
627
|
+
without_close = bool(ctx.pm.DeleteProject(name))
|
|
628
|
+
if not without_close:
|
|
629
|
+
ctx.pm.LoadProject(name)
|
|
630
|
+
ctx.pm.CloseProject(ctx.pm.GetCurrentProject())
|
|
631
|
+
ctx.pm.LoadProject(ctx.project_name)
|
|
632
|
+
with_close = bool(ctx.pm.DeleteProject(name))
|
|
633
|
+
return f"without_close={without_close}/with_close={with_close}"
|
|
634
|
+
return f"without_close={without_close}"
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def fingerprint() -> str:
|
|
638
|
+
"""Identity of this catalogue: version plus the probe set it defines."""
|
|
639
|
+
import hashlib
|
|
640
|
+
|
|
641
|
+
names = ",".join(sorted(p.name for p in CATALOGUE))
|
|
642
|
+
digest = hashlib.sha256(names.encode()).hexdigest()[:12]
|
|
643
|
+
return f"v{CATALOGUE_VERSION}-{digest}"
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
# ── long-running / AI / media-management surfaces ────────────────────────────
|
|
647
|
+
#
|
|
648
|
+
# These are the surfaces most likely to behave differently without a UI: they
|
|
649
|
+
# either spawn background work, need Studio AI models, or write large files.
|
|
650
|
+
# Generous timeouts, because a slow op misreported as a hang is a false finding
|
|
651
|
+
# and this catalogue's whole point is not producing those.
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@probe("longop.transcribe_audio", "longop", timeout=300, needs=("clip",), destructive=True,
|
|
655
|
+
note="Studio AI; may need a language model present")
|
|
656
|
+
def _probe_longop_transcribe_audio(ctx):
|
|
657
|
+
ok = ctx.clip.TranscribeAudio()
|
|
658
|
+
if ok:
|
|
659
|
+
try:
|
|
660
|
+
ctx.clip.ClearTranscription()
|
|
661
|
+
except Exception:
|
|
662
|
+
pass
|
|
663
|
+
return ok
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@probe("longop.auto_sync_audio", "longop", timeout=180, needs=("project",), destructive=True,
|
|
667
|
+
note="needs >= 2 pool items, at least one video and one audio")
|
|
668
|
+
def _probe_longop_auto_sync_audio(ctx):
|
|
669
|
+
clips = ctx.project.GetMediaPool().GetRootFolder().GetClipList() or []
|
|
670
|
+
if len(clips) < 2:
|
|
671
|
+
return None
|
|
672
|
+
return ctx.project.GetMediaPool().AutoSyncAudio(clips[:2])
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
@probe("longop.create_subtitles_from_audio", "longop", timeout=300, needs=("timeline",),
|
|
676
|
+
destructive=True, note="Studio AI; enum-keyed settings dict")
|
|
677
|
+
def _probe_longop_subtitles(ctx):
|
|
678
|
+
return ctx.timeline.CreateSubtitlesFromAudio()
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
@probe("longop.link_unlink_proxy", "longop", timeout=120, needs=("clip",), destructive=True)
|
|
682
|
+
def _probe_longop_proxy(ctx):
|
|
683
|
+
path = ctx.clip.GetClipProperty("File Path")
|
|
684
|
+
linked = ctx.clip.LinkProxyMedia(path) if path else None
|
|
685
|
+
unlinked = ctx.clip.UnlinkProxyMedia()
|
|
686
|
+
return f"{linked}/{unlinked}"
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
# ── editorial constructs ─────────────────────────────────────────────────────
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
@probe("editorial.compound_clip", "editorial", timeout=180, needs=("timeline", "item"),
|
|
693
|
+
destructive=True)
|
|
694
|
+
def _probe_editorial_compound(ctx):
|
|
695
|
+
items = ctx.timeline.GetItemListInTrack("video", 1) or []
|
|
696
|
+
if not items:
|
|
697
|
+
return None
|
|
698
|
+
made = ctx.project.GetMediaPool().CreateCompoundClip(
|
|
699
|
+
items[:1], {"name": "PROBE_COMPOUND"})
|
|
700
|
+
return bool(made)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
@probe("editorial.fusion_clip", "editorial", timeout=180, needs=("timeline",), destructive=True)
|
|
704
|
+
def _probe_editorial_fusion_clip(ctx):
|
|
705
|
+
items = ctx.timeline.GetItemListInTrack("video", 1) or []
|
|
706
|
+
if not items:
|
|
707
|
+
return None
|
|
708
|
+
return bool(ctx.project.GetMediaPool().CreateFusionClip(items[:1]))
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@probe("editorial.take_selector", "editorial", timeout=180, needs=("item", "clip"),
|
|
712
|
+
destructive=True)
|
|
713
|
+
def _probe_editorial_takes(ctx):
|
|
714
|
+
added = ctx.item.AddTake(ctx.clip)
|
|
715
|
+
count = ctx.item.GetTakesCount()
|
|
716
|
+
selected = ctx.item.SelectTakeByIndex(1) if count else None
|
|
717
|
+
finalized = ctx.item.FinalizeTake() if count else None
|
|
718
|
+
return f"{added}/{count}/{selected}/{finalized}"
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
@probe("editorial.nested_timeline", "editorial", timeout=180, needs=("project", "timeline"),
|
|
722
|
+
destructive=True)
|
|
723
|
+
def _probe_editorial_nested(ctx):
|
|
724
|
+
"""A timeline appended into another timeline — the nesting an edit loop uses."""
|
|
725
|
+
pool = ctx.project.GetMediaPool()
|
|
726
|
+
outer = pool.CreateEmptyTimeline("PROBE_OUTER")
|
|
727
|
+
if outer is None:
|
|
728
|
+
return False
|
|
729
|
+
# Timelines appear in the media pool as clips; that is how one is nested.
|
|
730
|
+
nested = [c for c in (pool.GetRootFolder().GetClipList() or [])
|
|
731
|
+
if (c.GetClipProperty("Type") or "") == "Timeline"]
|
|
732
|
+
if not nested:
|
|
733
|
+
return None
|
|
734
|
+
ctx.project.SetCurrentTimeline(outer)
|
|
735
|
+
appended = pool.AppendToTimeline([{"mediaPoolItem": nested[0]}])
|
|
736
|
+
count = len(outer.GetItemListInTrack("video", 1) or [])
|
|
737
|
+
ctx.project.SetCurrentTimeline(ctx.timeline)
|
|
738
|
+
return f"{bool(appended)}/{count}"
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
@probe("editorial.set_clips_linked", "editorial", timeout=90, needs=("timeline",),
|
|
742
|
+
destructive=True)
|
|
743
|
+
def _probe_editorial_linked(ctx):
|
|
744
|
+
items = ctx.timeline.GetItemListInTrack("video", 1) or []
|
|
745
|
+
if not items:
|
|
746
|
+
return None
|
|
747
|
+
off = ctx.timeline.SetClipsLinked(items[:1], False)
|
|
748
|
+
ctx.timeline.SetClipsLinked(items[:1], True)
|
|
749
|
+
return off
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
# ── project-level / delivery presets ─────────────────────────────────────────
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
@probe("projectlevel.render_preset_roundtrip", "projectlevel", timeout=120, needs=("project",),
|
|
756
|
+
destructive=True)
|
|
757
|
+
def _probe_projectlevel_render_preset(ctx):
|
|
758
|
+
saved = ctx.project.SaveAsNewRenderPreset("PROBE_RENDER_PRESET")
|
|
759
|
+
deleted = ctx.project.DeleteRenderPreset("PROBE_RENDER_PRESET") if saved else None
|
|
760
|
+
return f"{saved}/{deleted}"
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
@probe("projectlevel.export_burn_in_preset", "projectlevel", timeout=120, destructive=True)
|
|
764
|
+
def _probe_projectlevel_burnin(ctx):
|
|
765
|
+
path = ctx.work / "burnin.preset"
|
|
766
|
+
ok = ctx.resolve.ExportBurnInPreset("Default", str(path))
|
|
767
|
+
return combine(ok, path.exists() and path.stat().st_size > 0)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
@probe("projectlevel.archive_project", "projectlevel", timeout=600, destructive=True,
|
|
771
|
+
note="writes a .dra bundle; media excluded to keep it small")
|
|
772
|
+
def _probe_projectlevel_archive(ctx):
|
|
773
|
+
target = ctx.work / "archive"
|
|
774
|
+
target.mkdir(exist_ok=True)
|
|
775
|
+
ok = ctx.pm.ArchiveProject(ctx.project_name, str(target / "probe.dra"),
|
|
776
|
+
False, False, False)
|
|
777
|
+
produced = len(list(target.rglob("*"))) if target.exists() else 0
|
|
778
|
+
return combine(ok, produced)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
@probe("projectlevel.multi_job_queue", "projectlevel", timeout=180, needs=("project", "timeline"),
|
|
782
|
+
destructive=True)
|
|
783
|
+
def _probe_projectlevel_multi_job(ctx):
|
|
784
|
+
"""Three queued jobs — a delivery queue is rarely one render."""
|
|
785
|
+
ids = []
|
|
786
|
+
for index in range(3):
|
|
787
|
+
ctx.project.SetRenderSettings({"TargetDir": str(ctx.work),
|
|
788
|
+
"CustomName": f"queue_{index}",
|
|
789
|
+
"SelectAllFrames": True})
|
|
790
|
+
job = ctx.project.AddRenderJob()
|
|
791
|
+
if job:
|
|
792
|
+
ids.append(job)
|
|
793
|
+
count = len(ctx.project.GetRenderJobList() or [])
|
|
794
|
+
for job in ids:
|
|
795
|
+
ctx.project.DeleteRenderJob(job)
|
|
796
|
+
return f"{len(ids)}/{count}"
|