davinci-resolve-mcp 2.69.3 → 2.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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}"