davinci-resolve-mcp 2.23.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.
Files changed (92) hide show
  1. package/AGENTS.md +85 -0
  2. package/CHANGELOG.md +802 -0
  3. package/CLAUDE.md +15 -0
  4. package/LICENSE +21 -0
  5. package/README.md +159 -0
  6. package/SECURITY.md +53 -0
  7. package/bin/davinci-resolve-mcp.mjs +376 -0
  8. package/docs/README.md +56 -0
  9. package/docs/SKILL.md +1145 -0
  10. package/docs/authoring/fuse-dctl-authoring.md +242 -0
  11. package/docs/authoring/script-plugin-authoring.md +195 -0
  12. package/docs/contributing.md +82 -0
  13. package/docs/guides/color-decision-guide.md +387 -0
  14. package/docs/guides/editorial-decision-guide.md +136 -0
  15. package/docs/guides/media-analysis-guide.md +615 -0
  16. package/docs/guides/multicam-setup-guide.md +138 -0
  17. package/docs/install.md +198 -0
  18. package/docs/integrations/workflow-integrations.md +120 -0
  19. package/docs/kernels/README.md +28 -0
  20. package/docs/kernels/audio-fairlight-kernel.md +86 -0
  21. package/docs/kernels/color-grade-kernel.md +103 -0
  22. package/docs/kernels/extension-authoring-kernel.md +101 -0
  23. package/docs/kernels/fusion-composition-kernel.md +91 -0
  24. package/docs/kernels/media-pool-ingest-kernel.md +147 -0
  25. package/docs/kernels/project-lifecycle-kernel.md +120 -0
  26. package/docs/kernels/render-deliver-kernel.md +92 -0
  27. package/docs/kernels/review-annotation-kernel.md +110 -0
  28. package/docs/kernels/timeline-conform-interchange-kernel.md +99 -0
  29. package/docs/kernels/timeline-edit-kernel.md +189 -0
  30. package/docs/notes/codec-plugin-notes.md +136 -0
  31. package/docs/notes/dctl-notes.md +234 -0
  32. package/docs/notes/fusion-template-notes.md +136 -0
  33. package/docs/notes/lut-notes.md +136 -0
  34. package/docs/notes/openfx-notes.md +120 -0
  35. package/docs/process/release-process.md +152 -0
  36. package/docs/reference/api-coverage.md +488 -0
  37. package/docs/reference/resolve_scripting_api.txt +1012 -0
  38. package/examples/README.md +53 -0
  39. package/examples/markers/README.md +81 -0
  40. package/examples/media/README.md +94 -0
  41. package/examples/timeline/README.md +98 -0
  42. package/install.py +1196 -0
  43. package/package.json +52 -0
  44. package/scripts/audit_api_parity.py +275 -0
  45. package/scripts/live_media_analysis_polish_probe.py +65 -0
  46. package/src/__init__.py +3 -0
  47. package/src/analysis_dashboard.py +4936 -0
  48. package/src/control_panel.py +13 -0
  49. package/src/granular/__init__.py +17 -0
  50. package/src/granular/common.py +727 -0
  51. package/src/granular/folder.py +287 -0
  52. package/src/granular/gallery.py +306 -0
  53. package/src/granular/graph.py +309 -0
  54. package/src/granular/media_pool.py +679 -0
  55. package/src/granular/media_pool_item.py +852 -0
  56. package/src/granular/media_storage.py +179 -0
  57. package/src/granular/project.py +1594 -0
  58. package/src/granular/resolve_control.py +521 -0
  59. package/src/granular/timeline.py +1074 -0
  60. package/src/granular/timeline_item.py +2251 -0
  61. package/src/resolve_mcp_server.py +43 -0
  62. package/src/server.py +15691 -0
  63. package/src/utils/__init__.py +3 -0
  64. package/src/utils/app_control.py +319 -0
  65. package/src/utils/audio_fairlight_live_probe.py +263 -0
  66. package/src/utils/cdl.py +20 -0
  67. package/src/utils/cloud_operations.py +192 -0
  68. package/src/utils/color_grade_live_probe.py +444 -0
  69. package/src/utils/dctl_templates.py +368 -0
  70. package/src/utils/extension_authoring_live_probe.py +292 -0
  71. package/src/utils/fuse_templates.py +1968 -0
  72. package/src/utils/fusion_composition_live_probe.py +284 -0
  73. package/src/utils/layout_presets.py +333 -0
  74. package/src/utils/mcp_stdio.py +32 -0
  75. package/src/utils/media_analysis.py +3618 -0
  76. package/src/utils/media_analysis_jobs.py +796 -0
  77. package/src/utils/media_pool_ingest_live_probe.py +592 -0
  78. package/src/utils/multicam.py +393 -0
  79. package/src/utils/object_inspection.py +287 -0
  80. package/src/utils/platform.py +157 -0
  81. package/src/utils/project_lifecycle_live_probe.py +376 -0
  82. package/src/utils/project_properties.py +601 -0
  83. package/src/utils/render_deliver_live_probe.py +384 -0
  84. package/src/utils/resolve_connection.py +77 -0
  85. package/src/utils/review_annotation_live_probe.py +352 -0
  86. package/src/utils/script_templates.py +1193 -0
  87. package/src/utils/sync_detection.py +887 -0
  88. package/src/utils/timeline_conform_live_probe.py +280 -0
  89. package/src/utils/timeline_kernel_live_probe.py +1091 -0
  90. package/src/utils/timeline_kernel_probe.py +185 -0
  91. package/src/utils/timeline_title_text.py +87 -0
  92. package/src/utils/update_check.py +610 -0
@@ -0,0 +1,1091 @@
1
+ #!/usr/bin/env python3
2
+ """Live timeline edit kernel boundary probe.
3
+
4
+ Creates a disposable Resolve project with synthetic media, probes the deepest
5
+ timeline-editing API surfaces this project knows about, writes JSON/Markdown
6
+ evidence reports, and deletes the project unless --keep-open is provided.
7
+
8
+ Run with Python 3.10-3.12 against a running Resolve Studio instance:
9
+
10
+ python3.11 tests/live_duplicate_clips_validation.py --output-dir /tmp/timeline-kernel-probe
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import platform
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import time
21
+ import traceback
22
+ from pathlib import Path
23
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
24
+
25
+ from src.utils.timeline_kernel_probe import (
26
+ ProbeRecorder,
27
+ ordered_unique,
28
+ parse_api_class_methods,
29
+ parse_timeline_item_property_keys,
30
+ render_markdown_report,
31
+ utc_timestamp,
32
+ values_match,
33
+ )
34
+
35
+ PROPERTY_CANDIDATES: Dict[str, Any] = {
36
+ "Pan": 0.33,
37
+ "Tilt": -0.25,
38
+ "ZoomX": 1.15,
39
+ "ZoomY": 1.1,
40
+ "ZoomGang": False,
41
+ "RotationAngle": 8.0,
42
+ "AnchorPointX": 0.05,
43
+ "AnchorPointY": -0.05,
44
+ "Pitch": 0.1,
45
+ "Yaw": -0.1,
46
+ "FlipX": True,
47
+ "FlipY": False,
48
+ "CropLeft": 2.0,
49
+ "CropRight": 1.0,
50
+ "CropTop": 1.0,
51
+ "CropBottom": 2.0,
52
+ "CropSoftness": 4.0,
53
+ "CropRetain": True,
54
+ "DynamicZoomEnable": True,
55
+ "DynamicZoomMode": 1,
56
+ "DynamicZoomEase": 2,
57
+ "CompositeMode": 1,
58
+ "Opacity": 72.0,
59
+ "Distortion": 0.1,
60
+ "Speed": 100.0,
61
+ "RetimeProcess": 1,
62
+ "MotionEstimation": 1,
63
+ "Scaling": 2,
64
+ "ResizeFilter": 3,
65
+ "StabilizationEnable": True,
66
+ "StabilizationMethod": 1,
67
+ "StabilizationStrength": 0.75,
68
+ "Volume": -6.0,
69
+ "AudioSyncOffsetIsManual": False,
70
+ "AudioSyncOffset": 0,
71
+ "EQEnable": False,
72
+ "NormalizeEnable": False,
73
+ "NormalizeLevel": -12.0,
74
+ }
75
+
76
+ EXTRA_TIMELINE_METHODS = [
77
+ "GetItemsInTrack",
78
+ "GetCurrentVideoItem",
79
+ "GetCurrentTimecode",
80
+ "SetCurrentTimecode",
81
+ ]
82
+
83
+ EXTRA_TIMELINE_ITEM_METHODS = [
84
+ "GetType",
85
+ "GetMediaType",
86
+ "AddKeyframe",
87
+ "GetKeyframeCount",
88
+ "GetKeyframeAtIndex",
89
+ "GetPropertyAtKeyframeIndex",
90
+ "SetKeyframeInterpolation",
91
+ "GetIsColorOutputCacheEnabled",
92
+ "SetColorOutputCache",
93
+ "GetIsFusionOutputCacheEnabled",
94
+ "SetFusionOutputCache",
95
+ "GetVoiceIsolationState",
96
+ "SetVoiceIsolationState",
97
+ ]
98
+
99
+
100
+ def _install_mcp_stubs() -> None:
101
+ """Allow importing src.server when MCP deps are absent from Python 3.11."""
102
+
103
+ class FastMCP:
104
+ def __init__(self, *args, **kwargs):
105
+ pass
106
+
107
+ def tool(self, *args, **kwargs):
108
+ def decorate(func):
109
+ return func
110
+
111
+ return decorate
112
+
113
+ def resource(self, *args, **kwargs):
114
+ def decorate(func):
115
+ return func
116
+
117
+ return decorate
118
+
119
+ def stdio_server(*args, **kwargs):
120
+ raise RuntimeError("stdio_server is not used by the live timeline kernel probe")
121
+
122
+ anyio = types.ModuleType("anyio")
123
+ anyio.run = lambda func: func()
124
+
125
+ mcp = types.ModuleType("mcp")
126
+ server = types.ModuleType("mcp.server")
127
+ fastmcp = types.ModuleType("mcp.server.fastmcp")
128
+ stdio = types.ModuleType("mcp.server.stdio")
129
+
130
+ fastmcp.FastMCP = FastMCP
131
+ stdio.stdio_server = stdio_server
132
+
133
+ sys.modules.setdefault("anyio", anyio)
134
+ sys.modules.setdefault("mcp", mcp)
135
+ sys.modules.setdefault("mcp.server", server)
136
+ sys.modules.setdefault("mcp.server.fastmcp", fastmcp)
137
+ sys.modules.setdefault("mcp.server.stdio", stdio)
138
+
139
+
140
+ def _require_success(label: str, result: Dict[str, Any]) -> Dict[str, Any]:
141
+ if not isinstance(result, dict):
142
+ raise AssertionError(f"{label}: expected dict, got {result!r}")
143
+ if result.get("error"):
144
+ raise AssertionError(f"{label}: {result['error']}")
145
+ if "success" in result and result["success"] is not True:
146
+ raise AssertionError(f"{label}: expected success=True, got {result!r}")
147
+ return result
148
+
149
+
150
+ def _frame_int(value) -> int:
151
+ return int(round(float(value)))
152
+
153
+
154
+ def _source_start(item) -> int:
155
+ if hasattr(item, "GetSourceStartFrame"):
156
+ try:
157
+ value = item.GetSourceStartFrame()
158
+ if value is not None:
159
+ return _frame_int(value)
160
+ except Exception:
161
+ pass
162
+ return _frame_int(item.GetLeftOffset())
163
+
164
+
165
+ def _make_synthetic_media(work_dir: Path) -> Path:
166
+ media_path = work_dir / "timeline_kernel_probe_source.mov"
167
+ subprocess.run(
168
+ [
169
+ "ffmpeg",
170
+ "-hide_banner",
171
+ "-loglevel",
172
+ "error",
173
+ "-f",
174
+ "lavfi",
175
+ "-i",
176
+ "testsrc2=size=320x180:rate=24:duration=5",
177
+ "-f",
178
+ "lavfi",
179
+ "-i",
180
+ "sine=frequency=440:sample_rate=48000:duration=5",
181
+ "-shortest",
182
+ "-pix_fmt",
183
+ "yuv420p",
184
+ "-c:v",
185
+ "libx264",
186
+ "-c:a",
187
+ "aac",
188
+ "-y",
189
+ str(media_path),
190
+ ],
191
+ check=True,
192
+ )
193
+ return media_path
194
+
195
+
196
+ def _safe_call(func, *args):
197
+ try:
198
+ return func(*args), None
199
+ except Exception as exc:
200
+ return None, exc
201
+
202
+
203
+ def _safe_item_id(item) -> Optional[str]:
204
+ if not item:
205
+ return None
206
+ try:
207
+ return str(item.GetUniqueId())
208
+ except Exception:
209
+ return None
210
+
211
+
212
+ def _safe_timeline_id(timeline) -> Optional[str]:
213
+ if not timeline:
214
+ return None
215
+ try:
216
+ return str(timeline.GetUniqueId())
217
+ except Exception:
218
+ return None
219
+
220
+
221
+ def _ensure_current_timeline(
222
+ recorder: ProbeRecorder,
223
+ project,
224
+ timeline,
225
+ label: str,
226
+ *,
227
+ required: bool = False,
228
+ ) -> bool:
229
+ """Best-effort current timeline guard for Resolve bridge versions with flaky setters."""
230
+ if not project or not timeline:
231
+ if required:
232
+ raise AssertionError("No project or timeline available to set current timeline")
233
+ recorder.record(
234
+ "runtime.current_timeline",
235
+ label,
236
+ "unsupported",
237
+ details={"reason": "No project or timeline available"},
238
+ )
239
+ return False
240
+
241
+ target_id = _safe_timeline_id(timeline)
242
+ get_current = getattr(project, "GetCurrentTimeline", None)
243
+ if callable(get_current):
244
+ current, current_exc = _safe_call(get_current)
245
+ if current:
246
+ current_id = _safe_timeline_id(current)
247
+ if not target_id or not current_id or current_id == target_id:
248
+ return True
249
+ elif current_exc:
250
+ recorder.record(
251
+ "runtime.current_timeline",
252
+ f"{label}.get_current",
253
+ "error",
254
+ details={"exception": repr(current_exc)},
255
+ )
256
+
257
+ setter = getattr(project, "SetCurrentTimeline", None)
258
+ if callable(setter):
259
+ ok, set_exc = _safe_call(setter, timeline)
260
+ if ok:
261
+ return True
262
+ recorder.record(
263
+ "runtime.current_timeline",
264
+ f"{label}.set_current",
265
+ "partially_supported",
266
+ details={"returned": ok, "exception": repr(set_exc) if set_exc else None},
267
+ )
268
+ else:
269
+ recorder.record(
270
+ "runtime.current_timeline",
271
+ f"{label}.set_current",
272
+ "version_or_page_dependent",
273
+ details={"reason": "Project.SetCurrentTimeline is not callable in this Resolve bridge state"},
274
+ )
275
+
276
+ if callable(get_current):
277
+ current, _ = _safe_call(get_current)
278
+ if current:
279
+ current_id = _safe_timeline_id(current)
280
+ if not target_id or not current_id or current_id == target_id:
281
+ return True
282
+
283
+ if required:
284
+ raise AssertionError("Failed to create or set current timeline")
285
+ return False
286
+
287
+
288
+ def _record_method_availability(
289
+ recorder: ProbeRecorder,
290
+ obj,
291
+ category: str,
292
+ class_name: str,
293
+ method_names: Iterable[str],
294
+ ) -> None:
295
+ for method_name in ordered_unique(method_names):
296
+ recorder.record(
297
+ category,
298
+ f"{class_name}.{method_name}",
299
+ "supported" if callable(getattr(obj, method_name, None)) else "unsupported",
300
+ )
301
+
302
+
303
+ def _record_tool_result(
304
+ recorder: ProbeRecorder,
305
+ category: str,
306
+ name: str,
307
+ result: Dict[str, Any],
308
+ *,
309
+ expected_boundary: bool = False,
310
+ ) -> None:
311
+ if not isinstance(result, dict):
312
+ recorder.record(category, name, "error", details={"reason": "non-dict result", "result": repr(result)})
313
+ return
314
+ if result.get("error"):
315
+ recorder.record(
316
+ category,
317
+ name,
318
+ "unsupported" if expected_boundary else "error",
319
+ details={"reason": result.get("error"), "expected_boundary": expected_boundary},
320
+ evidence=result,
321
+ )
322
+ return
323
+ if "success" in result and result["success"] is not True:
324
+ recorder.record(
325
+ category,
326
+ name,
327
+ "partially_supported",
328
+ details={"reason": "success flag was false"},
329
+ evidence=result,
330
+ )
331
+ return
332
+ if result.get("results") and any(row.get("success") is False for row in result["results"] if isinstance(row, dict)):
333
+ recorder.record(category, name, "partially_supported", evidence=result)
334
+ return
335
+ recorder.record(category, name, "supported", evidence=result)
336
+
337
+
338
+ def _probe_property(recorder: ProbeRecorder, item, item_label: str, key: str) -> None:
339
+ get_property = getattr(item, "GetProperty", None)
340
+ set_property = getattr(item, "SetProperty", None)
341
+ if not callable(get_property):
342
+ recorder.record(f"properties.{item_label}", key, "unsupported", details={"reason": "GetProperty missing"})
343
+ return
344
+
345
+ original, read_error = _safe_call(get_property, key)
346
+ read_available = read_error is None and original is not None
347
+ candidate = PROPERTY_CANDIDATES.get(key, original if original is not None else 1)
348
+
349
+ if not callable(set_property):
350
+ status = "read_only" if read_available else "unsupported"
351
+ recorder.record(
352
+ f"properties.{item_label}",
353
+ key,
354
+ status,
355
+ details={"read": original, "reason": "SetProperty missing" if read_available else "property unavailable"},
356
+ )
357
+ return
358
+
359
+ write_result, write_error = _safe_call(set_property, key, candidate)
360
+ readback, readback_error = _safe_call(get_property, key)
361
+ restore_result = None
362
+ if read_available and write_result:
363
+ restore_result, _ = _safe_call(set_property, key, original)
364
+
365
+ details = {
366
+ "read": original,
367
+ "write": bool(write_result) if write_error is None else False,
368
+ "write_error": repr(write_error) if write_error else None,
369
+ "readback": readback,
370
+ "readback_error": repr(readback_error) if readback_error else None,
371
+ "restore": bool(restore_result) if restore_result is not None else None,
372
+ }
373
+
374
+ if read_error is not None:
375
+ status = "error"
376
+ details["read_error"] = repr(read_error)
377
+ elif write_error is not None:
378
+ status = "partially_supported" if read_available else "unsupported"
379
+ elif not write_result:
380
+ status = "partially_supported" if read_available else "unsupported"
381
+ details["reason"] = "SetProperty returned false"
382
+ elif readback is None:
383
+ status = "write_only_unverifiable"
384
+ elif values_match(readback, candidate):
385
+ status = "supported"
386
+ else:
387
+ status = "partially_supported"
388
+ details["reason"] = "SetProperty returned true but readback did not match"
389
+
390
+ recorder.record(f"properties.{item_label}", key, status, details=details)
391
+
392
+
393
+ def _probe_keyframe(recorder: ProbeRecorder, item, item_label: str, key: str) -> None:
394
+ add_keyframe = getattr(item, "AddKeyframe", None)
395
+ get_count = getattr(item, "GetKeyframeCount", None)
396
+ get_at_index = getattr(item, "GetKeyframeAtIndex", None)
397
+ get_value = getattr(item, "GetPropertyAtKeyframeIndex", None)
398
+ if not callable(add_keyframe):
399
+ recorder.record(f"keyframes.{item_label}", key, "unsupported", details={"reason": "AddKeyframe missing"})
400
+ return
401
+
402
+ before = None
403
+ if callable(get_count):
404
+ before, _ = _safe_call(get_count, key)
405
+ value = PROPERTY_CANDIDATES.get(key, 1)
406
+ added, add_error = _safe_call(add_keyframe, key, 0, value)
407
+ after = None
408
+ if callable(get_count):
409
+ after, _ = _safe_call(get_count, key)
410
+
411
+ evidence: List[Dict[str, Any]] = []
412
+ if callable(get_at_index) and callable(get_value) and after:
413
+ for index in range(int(after)):
414
+ keyframe, keyframe_error = _safe_call(get_at_index, key, index)
415
+ prop_value, value_error = _safe_call(get_value, key, index)
416
+ evidence.append(
417
+ {
418
+ "index": index,
419
+ "keyframe": keyframe,
420
+ "keyframe_error": repr(keyframe_error) if keyframe_error else None,
421
+ "value": prop_value,
422
+ "value_error": repr(value_error) if value_error else None,
423
+ }
424
+ )
425
+
426
+ details = {
427
+ "before": before,
428
+ "added": bool(added) if add_error is None else False,
429
+ "add_error": repr(add_error) if add_error else None,
430
+ "after": after,
431
+ "read_methods": {
432
+ "GetKeyframeCount": callable(get_count),
433
+ "GetKeyframeAtIndex": callable(get_at_index),
434
+ "GetPropertyAtKeyframeIndex": callable(get_value),
435
+ },
436
+ }
437
+ if add_error:
438
+ status = "error"
439
+ elif added and after is not None and int(after) > int(before or 0):
440
+ status = "supported"
441
+ elif added:
442
+ status = "write_only_unverifiable"
443
+ elif before is not None:
444
+ status = "partially_supported"
445
+ details["reason"] = "AddKeyframe returned false"
446
+ else:
447
+ status = "unsupported"
448
+ details["reason"] = "AddKeyframe returned false and keyframe readback is unavailable"
449
+ recorder.record(f"keyframes.{item_label}", key, status, details=details, evidence=evidence)
450
+
451
+
452
+ def _probe_markers_flags_color_enabled(recorder: ProbeRecorder, item, item_label: str) -> None:
453
+ marker_frame = 9
454
+ add_marker = getattr(item, "AddMarker", None)
455
+ if callable(add_marker):
456
+ added, exc = _safe_call(add_marker, marker_frame, "Green", "Probe marker", "Timeline kernel probe", 1, "kernel-probe")
457
+ markers, markers_exc = _safe_call(item.GetMarkers) if callable(getattr(item, "GetMarkers", None)) else ({}, None)
458
+ custom, custom_exc = (
459
+ _safe_call(item.GetMarkerCustomData, marker_frame)
460
+ if callable(getattr(item, "GetMarkerCustomData", None))
461
+ else (None, None)
462
+ )
463
+ deleted, delete_exc = (
464
+ _safe_call(item.DeleteMarkerByCustomData, "kernel-probe")
465
+ if callable(getattr(item, "DeleteMarkerByCustomData", None))
466
+ else (None, None)
467
+ )
468
+ status = "supported" if added and markers_exc is None else "partially_supported"
469
+ recorder.record(
470
+ f"metadata.{item_label}",
471
+ "markers",
472
+ status,
473
+ details={
474
+ "added": bool(added),
475
+ "add_error": repr(exc) if exc else None,
476
+ "marker_count": len(markers or {}) if isinstance(markers, dict) else None,
477
+ "custom_data": custom,
478
+ "custom_error": repr(custom_exc) if custom_exc else None,
479
+ "deleted": bool(deleted) if deleted is not None else None,
480
+ "delete_error": repr(delete_exc) if delete_exc else None,
481
+ },
482
+ )
483
+ else:
484
+ recorder.record(f"metadata.{item_label}", "markers", "unsupported", details={"reason": "AddMarker missing"})
485
+
486
+ if callable(getattr(item, "AddFlag", None)):
487
+ added, exc = _safe_call(item.AddFlag, "Green")
488
+ flags, flags_exc = _safe_call(item.GetFlagList) if callable(getattr(item, "GetFlagList", None)) else ([], None)
489
+ cleared, clear_exc = _safe_call(item.ClearFlags, "Green") if callable(getattr(item, "ClearFlags", None)) else (None, None)
490
+ recorder.record(
491
+ f"metadata.{item_label}",
492
+ "flags",
493
+ "supported" if added and flags_exc is None else "partially_supported",
494
+ details={
495
+ "added": bool(added),
496
+ "add_error": repr(exc) if exc else None,
497
+ "flags": flags,
498
+ "flags_error": repr(flags_exc) if flags_exc else None,
499
+ "cleared": bool(cleared) if cleared is not None else None,
500
+ "clear_error": repr(clear_exc) if clear_exc else None,
501
+ },
502
+ )
503
+ else:
504
+ recorder.record(f"metadata.{item_label}", "flags", "unsupported", details={"reason": "AddFlag missing"})
505
+
506
+ if callable(getattr(item, "SetClipColor", None)):
507
+ original, _ = _safe_call(item.GetClipColor) if callable(getattr(item, "GetClipColor", None)) else (None, None)
508
+ set_result, exc = _safe_call(item.SetClipColor, "Teal")
509
+ readback, read_exc = _safe_call(item.GetClipColor) if callable(getattr(item, "GetClipColor", None)) else (None, None)
510
+ clear_result, clear_exc = _safe_call(item.ClearClipColor) if callable(getattr(item, "ClearClipColor", None)) else (None, None)
511
+ if original:
512
+ _safe_call(item.SetClipColor, original)
513
+ recorder.record(
514
+ f"metadata.{item_label}",
515
+ "clip_color",
516
+ "supported" if set_result and readback == "Teal" else "partially_supported",
517
+ details={
518
+ "read": original,
519
+ "write": bool(set_result),
520
+ "write_error": repr(exc) if exc else None,
521
+ "readback": readback,
522
+ "readback_error": repr(read_exc) if read_exc else None,
523
+ "clear": bool(clear_result) if clear_result is not None else None,
524
+ "clear_error": repr(clear_exc) if clear_exc else None,
525
+ },
526
+ )
527
+ else:
528
+ recorder.record(f"metadata.{item_label}", "clip_color", "unsupported", details={"reason": "SetClipColor missing"})
529
+
530
+ if callable(getattr(item, "SetClipEnabled", None)):
531
+ original, _ = _safe_call(item.GetClipEnabled) if callable(getattr(item, "GetClipEnabled", None)) else (None, None)
532
+ set_result, exc = _safe_call(item.SetClipEnabled, False)
533
+ readback, read_exc = _safe_call(item.GetClipEnabled) if callable(getattr(item, "GetClipEnabled", None)) else (None, None)
534
+ if original is not None:
535
+ _safe_call(item.SetClipEnabled, bool(original))
536
+ recorder.record(
537
+ f"metadata.{item_label}",
538
+ "enabled_state",
539
+ "supported" if set_result and readback is False else "partially_supported",
540
+ details={
541
+ "read": original,
542
+ "write": bool(set_result),
543
+ "write_error": repr(exc) if exc else None,
544
+ "readback": readback,
545
+ "readback_error": repr(read_exc) if read_exc else None,
546
+ },
547
+ )
548
+ else:
549
+ recorder.record(f"metadata.{item_label}", "enabled_state", "unsupported", details={"reason": "SetClipEnabled missing"})
550
+
551
+
552
+ def _probe_cache_voice_takes_fusion_grade(
553
+ recorder: ProbeRecorder,
554
+ item,
555
+ duplicate_item,
556
+ media_pool_item,
557
+ item_label: str,
558
+ output_dir: Path,
559
+ ) -> None:
560
+ for cache_name, getter_name, setter_name, value in (
561
+ ("color_cache", "GetIsColorOutputCacheEnabled", "SetColorOutputCache", "On"),
562
+ ("fusion_cache", "GetIsFusionOutputCacheEnabled", "SetFusionOutputCache", "Auto"),
563
+ ):
564
+ getter = getattr(item, getter_name, None)
565
+ setter = getattr(item, setter_name, None)
566
+ if not callable(getter) or not callable(setter):
567
+ recorder.record(f"advanced.{item_label}", cache_name, "unsupported", details={"reason": "cache API missing"})
568
+ continue
569
+ original, read_exc = _safe_call(getter)
570
+ set_result, set_exc = _safe_call(setter, value)
571
+ readback, readback_exc = _safe_call(getter)
572
+ if original is not None:
573
+ _safe_call(setter, original)
574
+ recorder.record(
575
+ f"advanced.{item_label}",
576
+ cache_name,
577
+ "supported" if set_result and readback is not None else "partially_supported",
578
+ details={
579
+ "read": original,
580
+ "read_error": repr(read_exc) if read_exc else None,
581
+ "write": bool(set_result),
582
+ "write_error": repr(set_exc) if set_exc else None,
583
+ "readback": readback,
584
+ "readback_error": repr(readback_exc) if readback_exc else None,
585
+ },
586
+ )
587
+
588
+ if callable(getattr(item, "GetVoiceIsolationState", None)) and callable(getattr(item, "SetVoiceIsolationState", None)):
589
+ original, read_exc = _safe_call(item.GetVoiceIsolationState)
590
+ set_result, set_exc = _safe_call(item.SetVoiceIsolationState, {"isEnabled": True, "amount": 25})
591
+ readback, readback_exc = _safe_call(item.GetVoiceIsolationState)
592
+ if original:
593
+ _safe_call(item.SetVoiceIsolationState, original)
594
+ recorder.record(
595
+ f"advanced.{item_label}",
596
+ "item_voice_isolation",
597
+ "supported" if set_result and readback else "partially_supported",
598
+ details={
599
+ "read": original,
600
+ "read_error": repr(read_exc) if read_exc else None,
601
+ "write": bool(set_result),
602
+ "write_error": repr(set_exc) if set_exc else None,
603
+ "readback": readback,
604
+ "readback_error": repr(readback_exc) if readback_exc else None,
605
+ },
606
+ )
607
+ else:
608
+ recorder.record(
609
+ f"advanced.{item_label}",
610
+ "item_voice_isolation",
611
+ "unsupported",
612
+ details={"reason": "item voice isolation API missing"},
613
+ )
614
+
615
+ if callable(getattr(item, "AddTake", None)):
616
+ added, add_exc = _safe_call(item.AddTake, media_pool_item, 24, 71)
617
+ count, count_exc = _safe_call(item.GetTakesCount) if callable(getattr(item, "GetTakesCount", None)) else (None, None)
618
+ take, take_exc = (
619
+ _safe_call(item.GetTakeByIndex, int(count or 1))
620
+ if callable(getattr(item, "GetTakeByIndex", None)) and count
621
+ else (None, None)
622
+ )
623
+ selected, select_exc = (
624
+ _safe_call(item.SelectTakeByIndex, int(count))
625
+ if callable(getattr(item, "SelectTakeByIndex", None)) and count
626
+ else (None, None)
627
+ )
628
+ deleted, delete_exc = (
629
+ _safe_call(item.DeleteTakeByIndex, int(count))
630
+ if callable(getattr(item, "DeleteTakeByIndex", None)) and count
631
+ else (None, None)
632
+ )
633
+ recorder.record(
634
+ f"advanced.{item_label}",
635
+ "takes",
636
+ "supported" if added and count else "partially_supported",
637
+ details={
638
+ "added": bool(added),
639
+ "add_error": repr(add_exc) if add_exc else None,
640
+ "count": count,
641
+ "count_error": repr(count_exc) if count_exc else None,
642
+ "take_read": bool(take),
643
+ "take_error": repr(take_exc) if take_exc else None,
644
+ "selected": bool(selected) if selected is not None else None,
645
+ "select_error": repr(select_exc) if select_exc else None,
646
+ "deleted": bool(deleted) if deleted is not None else None,
647
+ "delete_error": repr(delete_exc) if delete_exc else None,
648
+ },
649
+ )
650
+ else:
651
+ recorder.record(f"advanced.{item_label}", "takes", "unsupported", details={"reason": "AddTake missing"})
652
+
653
+ if callable(getattr(item, "AddFusionComp", None)):
654
+ comp, add_exc = _safe_call(item.AddFusionComp)
655
+ count, count_exc = _safe_call(item.GetFusionCompCount) if callable(getattr(item, "GetFusionCompCount", None)) else (None, None)
656
+ names, names_exc = (
657
+ _safe_call(item.GetFusionCompNameList)
658
+ if callable(getattr(item, "GetFusionCompNameList", None))
659
+ else (None, None)
660
+ )
661
+ export_path = output_dir / f"{item_label}_fusion_comp.setting"
662
+ exported, export_exc = (
663
+ _safe_call(item.ExportFusionComp, str(export_path), 1)
664
+ if callable(getattr(item, "ExportFusionComp", None)) and count
665
+ else (None, None)
666
+ )
667
+ recorder.record(
668
+ f"advanced.{item_label}",
669
+ "fusion_comps",
670
+ "supported" if comp and count else "partially_supported",
671
+ details={
672
+ "added": bool(comp),
673
+ "add_error": repr(add_exc) if add_exc else None,
674
+ "count": count,
675
+ "count_error": repr(count_exc) if count_exc else None,
676
+ "names": names,
677
+ "names_error": repr(names_exc) if names_exc else None,
678
+ "exported": bool(exported) if exported is not None else None,
679
+ "export_error": repr(export_exc) if export_exc else None,
680
+ "export_path": str(export_path) if exported else None,
681
+ },
682
+ )
683
+ else:
684
+ recorder.record(f"advanced.{item_label}", "fusion_comps", "unsupported", details={"reason": "AddFusionComp missing"})
685
+
686
+ if callable(getattr(item, "CopyGrades", None)) and duplicate_item:
687
+ copied, copy_exc = _safe_call(item.CopyGrades, [duplicate_item])
688
+ recorder.record(
689
+ f"advanced.{item_label}",
690
+ "copy_grades",
691
+ "supported" if copied else "partially_supported",
692
+ details={"copied": bool(copied), "copy_error": repr(copy_exc) if copy_exc else None},
693
+ )
694
+ else:
695
+ recorder.record(f"advanced.{item_label}", "copy_grades", "unsupported", details={"reason": "CopyGrades missing"})
696
+
697
+
698
+ def _probe_timeline_operations(recorder: ProbeRecorder, server, timeline, source_id: str, source_duration: int) -> Optional[str]:
699
+ duplicate_ids: List[str] = []
700
+
701
+ def duplicate(name: str, params: Dict[str, Any]) -> Optional[str]:
702
+ payload = dict(params)
703
+ payload.setdefault("clip_ids", [source_id])
704
+ result = server.timeline("duplicate_clips", payload)
705
+ _record_tool_result(recorder, "operations.duplicate", name, result)
706
+ try:
707
+ item_id = result["results"][0]["timeline_item_id"]
708
+ except Exception:
709
+ return None
710
+ if item_id:
711
+ duplicate_ids.append(item_id)
712
+ return item_id
713
+
714
+ duplicate("same_time_cross_track", {"placement": "same_time", "target_track_index": 2})
715
+ duplicate("offset", {"record_frame_offset": 180, "target_track_index": 2})
716
+ duplicate("track_above", {"placement": "track_above"})
717
+ duplicate("after_source", {"placement": "after_source"})
718
+ duplicate("next_gap", {"placement": "next_gap", "target_track_index": 2})
719
+ server.timeline_markers("set_current_timecode", {"timecode": "00:00:28:00"})
720
+ duplicate("at_playhead", {"placement": "at_playhead", "target_track_index": 2})
721
+ duplicate("include_linked_audio", {"record_frame": 780, "target_track_index": 2, "include_linked": True})
722
+
723
+ copy_alias = server.timeline("copy_clips", {"clip_ids": [source_id], "target_track_index": 2, "record_frame": 840})
724
+ _record_tool_result(recorder, "operations.duplicate", "copy_clips_alias", copy_alias)
725
+
726
+ selected = server.timeline("duplicate_clips", {"selected": True, "target_track_index": 2, "record_frame": 880})
727
+ if isinstance(selected, dict) and selected.get("error"):
728
+ recorder.record(
729
+ "operations.duplicate",
730
+ "selected_or_current_fallback",
731
+ "version_or_page_dependent",
732
+ details={"reason": selected.get("error")},
733
+ evidence=selected,
734
+ )
735
+ else:
736
+ _record_tool_result(recorder, "operations.duplicate", "selected_or_current_fallback", selected)
737
+
738
+ copy_range = server.timeline(
739
+ "copy_range",
740
+ {"start_frame": 110, "end_frame": 130, "record_frame": 930, "track_types": ["video"], "target_track_index": 2},
741
+ )
742
+ _record_tool_result(recorder, "operations.range", "copy_range", copy_range)
743
+
744
+ duplicate_range = server.timeline(
745
+ "duplicate_range",
746
+ {"start_frame": 112, "end_frame": 128, "record_frame": 970, "track_types": ["video"], "target_track_index": 2},
747
+ )
748
+ _record_tool_result(recorder, "operations.range", "duplicate_range", duplicate_range)
749
+
750
+ occupant_id = duplicate("overwrite_destination_fixture", {"target_track_index": 2, "record_frame": 1020})
751
+ overwrite = server.timeline(
752
+ "overwrite_range",
753
+ {"start_frame": 100, "end_frame": 120, "record_frame": 1020, "track_types": ["video"], "target_track_index": 2},
754
+ )
755
+ _record_tool_result(recorder, "operations.range", "overwrite_range", overwrite)
756
+ if occupant_id and not server._find_timeline_item_by_id(timeline, occupant_id):
757
+ recorder.record("operations.range", "overwrite_deleted_destination_overlap", "supported")
758
+ elif occupant_id:
759
+ recorder.record("operations.range", "overwrite_deleted_destination_overlap", "partially_supported")
760
+
761
+ lift_fixture_id = duplicate("lift_fixture", {"target_track_index": 1, "record_frame": 1120})
762
+ lift = server.timeline(
763
+ "lift_range",
764
+ {"start_frame": 1120, "end_frame": 1120 + source_duration, "track_types": ["video"], "track_indices": [1]},
765
+ )
766
+ _record_tool_result(recorder, "operations.range", "lift_range_exact_item", lift)
767
+ if lift_fixture_id and not server._find_timeline_item_by_id(timeline, lift_fixture_id):
768
+ recorder.record("operations.range", "lift_deleted_exact_item", "supported")
769
+ elif lift_fixture_id:
770
+ recorder.record("operations.range", "lift_deleted_exact_item", "partially_supported")
771
+
772
+ partial_lift = server.timeline(
773
+ "lift_range",
774
+ {"start_frame": 105, "end_frame": 110, "track_types": ["video"], "track_indices": [1]},
775
+ )
776
+ _record_tool_result(recorder, "operations.boundaries", "partial_lift_without_razor", partial_lift, expected_boundary=True)
777
+
778
+ move_fixture_id = duplicate("move_fixture", {"target_track_index": 1, "record_frame": 1220})
779
+ if move_fixture_id:
780
+ move = server.timeline(
781
+ "move_clips",
782
+ {"clip_ids": [move_fixture_id], "target_track_index": 2, "record_frame": 1280},
783
+ )
784
+ _record_tool_result(recorder, "operations.duplicate", "move_clips", move)
785
+
786
+ invalid_track = server.timeline("duplicate_clips", {"clip_ids": [source_id], "target_track_index": 99})
787
+ _record_tool_result(recorder, "operations.boundaries", "invalid_target_track", invalid_track, expected_boundary=True)
788
+
789
+ return duplicate_ids[0] if duplicate_ids else None
790
+
791
+
792
+ def _probe_source_less_boundaries(recorder: ProbeRecorder, server, timeline) -> None:
793
+ probes: List[Tuple[str, Any]] = []
794
+ for name, call in (
795
+ ("insert_fusion_composition", lambda: timeline.InsertFusionCompositionIntoTimeline()),
796
+ ("insert_title_text", lambda: timeline.InsertTitleIntoTimeline("Text")),
797
+ ):
798
+ item, exc = _safe_call(call)
799
+ if exc:
800
+ recorder.record("operations.source_less", name, "error", details={"exception": repr(exc)})
801
+ continue
802
+ if not item:
803
+ recorder.record("operations.source_less", name, "unsupported", details={"reason": "Resolve returned no item"})
804
+ continue
805
+ probes.append((name, item))
806
+ recorder.record("operations.source_less", name, "supported", details={"timeline_item_id": _safe_item_id(item)})
807
+
808
+ for name, item in probes:
809
+ item_id = _safe_item_id(item)
810
+ if not item_id:
811
+ continue
812
+ result = server.timeline("duplicate_clips", {"clip_ids": [item_id], "target_track_index": 2, "record_frame": 1400})
813
+ _record_tool_result(
814
+ recorder,
815
+ "operations.boundaries",
816
+ f"{name}_append_clone_without_media_pool_item",
817
+ result,
818
+ expected_boundary=True,
819
+ )
820
+
821
+
822
+ def _probe_track_controls(recorder: ProbeRecorder, server, timeline) -> None:
823
+ for track_type, index in (("video", 1), ("audio", 1)):
824
+ name = f"{track_type}.{index}"
825
+ original_name = None
826
+ try:
827
+ original_name = timeline.GetTrackName(track_type, index)
828
+ except Exception:
829
+ pass
830
+ result = server.timeline("set_track_name", {"track_type": track_type, "index": index, "name": f"Probe {name}"})
831
+ _record_tool_result(recorder, "operations.tracks", f"set_track_name.{name}", result)
832
+ enabled = server.timeline("set_track_enable", {"track_type": track_type, "index": index, "enabled": True})
833
+ _record_tool_result(recorder, "operations.tracks", f"set_track_enable.{name}", enabled)
834
+ locked = server.timeline("set_track_lock", {"track_type": track_type, "index": index, "locked": False})
835
+ _record_tool_result(recorder, "operations.tracks", f"set_track_lock.{name}", locked)
836
+ if original_name:
837
+ server.timeline("set_track_name", {"track_type": track_type, "index": index, "name": original_name})
838
+
839
+ voice_get = server.timeline("get_voice_isolation_state", {"track_index": 1})
840
+ _record_tool_result(recorder, "operations.tracks", "audio_track_voice_isolation_get", voice_get)
841
+ voice_set = server.timeline("set_voice_isolation_state", {"track_index": 1, "state": {"isEnabled": True, "amount": 20}})
842
+ _record_tool_result(recorder, "operations.tracks", "audio_track_voice_isolation_set", voice_set)
843
+
844
+
845
+ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, Any]:
846
+ recorder = ProbeRecorder()
847
+ project_name = f"_mcp_timeline_kernel_probe_{int(time.time())}"
848
+ timeline_name = "timeline_kernel_boundary_probe"
849
+ work_dir = Path(tempfile.mkdtemp(prefix="mcp_timeline_kernel_probe_"))
850
+ created_project = False
851
+ delete_result = None
852
+ metadata: Dict[str, Any] = {
853
+ "timestamp_utc": utc_timestamp(),
854
+ "python": sys.version.replace("\n", " "),
855
+ "platform": platform.platform(),
856
+ "project_name": project_name,
857
+ }
858
+
859
+ try:
860
+ version = _require_success("resolve_control.get_version", server.resolve_control("get_version"))
861
+ metadata.update(
862
+ {
863
+ "product": version.get("product"),
864
+ "version": version.get("version"),
865
+ "version_string": version.get("version_string"),
866
+ }
867
+ )
868
+ print(f"Connected to {metadata['product']} {metadata['version_string']}")
869
+
870
+ _require_success("project_manager.create", server.project_manager("create", {"name": project_name}))
871
+ created_project = True
872
+ print(f"Created disposable project: {project_name}")
873
+ _require_success("resolve_control.open_page", server.resolve_control("open_page", {"page": "edit"}))
874
+
875
+ media_path = _make_synthetic_media(work_dir)
876
+ metadata["synthetic_media"] = str(media_path)
877
+ print(f"Generated synthetic media: {media_path}")
878
+
879
+ resolve = server.get_resolve()
880
+ project = resolve.GetProjectManager().GetCurrentProject()
881
+ media_pool = project.GetMediaPool()
882
+ imported = media_pool.ImportMedia([str(media_path)])
883
+ if not imported:
884
+ raise AssertionError(f"Failed to import synthetic media: {media_path}")
885
+ media_pool_item = imported[0]
886
+ media_pool_item_id = media_pool_item.GetUniqueId()
887
+
888
+ timeline = media_pool.CreateEmptyTimeline(timeline_name)
889
+ if not timeline:
890
+ raise AssertionError("Failed to create timeline")
891
+ _ensure_current_timeline(recorder, project, timeline, "initial", required=True)
892
+
893
+ for _ in range(max(0, 3 - int(timeline.GetTrackCount("video") or 0))):
894
+ _require_success("timeline.add_track video", server.timeline("add_track", {"track_type": "video"}))
895
+ for _ in range(max(0, 2 - int(timeline.GetTrackCount("audio") or 0))):
896
+ _require_success(
897
+ "timeline.add_track audio",
898
+ server.timeline("add_track", {"track_type": "audio", "options": {"audio_type": "stereo"}}),
899
+ )
900
+ server.timeline("set_start_timecode", {"timecode": "00:00:00:00"})
901
+
902
+ append = _require_success(
903
+ "media_pool.append_to_timeline video",
904
+ server.media_pool(
905
+ "append_to_timeline",
906
+ {
907
+ "clip_infos": [
908
+ {
909
+ "media_pool_item_id": media_pool_item_id,
910
+ "start_frame": 24,
911
+ "end_frame": 71,
912
+ "record_frame": 100,
913
+ "track_index": 1,
914
+ "media_type": 1,
915
+ }
916
+ ]
917
+ },
918
+ ),
919
+ )
920
+ source_id = append["items"][0]["timeline_item_id"]
921
+ source_item = server._find_timeline_item_by_id(timeline, source_id)
922
+ if not source_item:
923
+ raise AssertionError(f"Could not recover source item: {source_id}")
924
+
925
+ audio_append = _require_success(
926
+ "media_pool.append_to_timeline linked audio",
927
+ server.media_pool(
928
+ "append_to_timeline",
929
+ {
930
+ "clip_infos": [
931
+ {
932
+ "media_pool_item_id": media_pool_item_id,
933
+ "start_frame": 24,
934
+ "end_frame": 71,
935
+ "record_frame": 100,
936
+ "track_index": 1,
937
+ "media_type": 2,
938
+ }
939
+ ]
940
+ },
941
+ ),
942
+ )
943
+ audio_id = audio_append["items"][0]["timeline_item_id"]
944
+ audio_item = server._find_timeline_item_by_id(timeline, audio_id)
945
+ if not audio_item:
946
+ raise AssertionError(f"Could not recover audio item: {audio_id}")
947
+ timeline.SetClipsLinked([source_item, audio_item], True)
948
+
949
+ source_duration = _frame_int(source_item.GetDuration())
950
+ metadata["source"] = {
951
+ "timeline_item_id": source_id,
952
+ "duration": source_duration,
953
+ "source_start": _source_start(source_item),
954
+ }
955
+
956
+ api_text = Path("docs/reference/resolve_scripting_api.txt").read_text(encoding="utf-8")
957
+ documented_property_keys = parse_timeline_item_property_keys(api_text)
958
+ local_property_keys = []
959
+ for keys in server._DUPLICATE_COPY_PROPERTY_KEYS.values():
960
+ local_property_keys.extend(keys)
961
+ property_keys = ordered_unique(documented_property_keys + local_property_keys + list(PROPERTY_CANDIDATES))
962
+
963
+ timeline_methods = ordered_unique(parse_api_class_methods(api_text, "Timeline") + EXTRA_TIMELINE_METHODS)
964
+ timeline_item_methods = ordered_unique(parse_api_class_methods(api_text, "TimelineItem") + EXTRA_TIMELINE_ITEM_METHODS)
965
+ _record_method_availability(recorder, timeline, "runtime_methods.timeline", "Timeline", timeline_methods)
966
+ _record_method_availability(recorder, source_item, "runtime_methods.timeline_item.video", "TimelineItem", timeline_item_methods)
967
+ _record_method_availability(recorder, audio_item, "runtime_methods.timeline_item.audio", "TimelineItem", timeline_item_methods)
968
+
969
+ duplicate_for_advanced_id = _probe_timeline_operations(recorder, server, timeline, source_id, source_duration)
970
+ duplicate_for_advanced = (
971
+ server._find_timeline_item_by_id(timeline, duplicate_for_advanced_id) if duplicate_for_advanced_id else None
972
+ )
973
+ _probe_track_controls(recorder, server, timeline)
974
+ _probe_source_less_boundaries(recorder, server, timeline)
975
+ _ensure_current_timeline(recorder, project, timeline, "before_property_probe")
976
+
977
+ for key in property_keys:
978
+ _probe_property(recorder, source_item, "video", key)
979
+ _probe_property(recorder, audio_item, "audio", key)
980
+
981
+ for key in property_keys:
982
+ _probe_keyframe(recorder, source_item, "video", key)
983
+
984
+ _probe_markers_flags_color_enabled(recorder, source_item, "video")
985
+ _probe_markers_flags_color_enabled(recorder, audio_item, "audio")
986
+ _probe_cache_voice_takes_fusion_grade(
987
+ recorder,
988
+ source_item,
989
+ duplicate_for_advanced,
990
+ media_pool_item,
991
+ "video",
992
+ output_dir,
993
+ )
994
+
995
+ _ensure_current_timeline(recorder, project, timeline, "before_mcp_capabilities")
996
+ capabilities = server.timeline("edit_kernel_capabilities")
997
+ _record_tool_result(recorder, "mcp_capabilities", "edit_kernel_capabilities", capabilities)
998
+ probe = server.timeline("probe_edit_kernel_item", {"clip_ids": [source_id, audio_id]})
999
+ if isinstance(probe, dict) and probe.get("error") == "No current timeline":
1000
+ recorder.record(
1001
+ "mcp_capabilities",
1002
+ "probe_edit_kernel_item",
1003
+ "version_or_page_dependent",
1004
+ details={
1005
+ "reason": (
1006
+ "Project.SetCurrentTimeline was not callable after source-less item probing; "
1007
+ "the live validation covers this tool when Resolve keeps a current timeline"
1008
+ )
1009
+ },
1010
+ evidence=probe,
1011
+ )
1012
+ direct_probe = server._timeline_probe_edit_kernel_item(timeline, {"clip_ids": [source_id, audio_id]})
1013
+ if isinstance(direct_probe, dict) and direct_probe.get("error"):
1014
+ recorder.record(
1015
+ "mcp_capabilities",
1016
+ "probe_edit_kernel_item_direct_timeline_object",
1017
+ "version_or_page_dependent",
1018
+ details={
1019
+ "reason": (
1020
+ "The retained timeline object could not resolve the original item IDs after "
1021
+ "source-less item probing changed Resolve's timeline focus"
1022
+ )
1023
+ },
1024
+ evidence=direct_probe,
1025
+ )
1026
+ else:
1027
+ _record_tool_result(
1028
+ recorder,
1029
+ "mcp_capabilities",
1030
+ "probe_edit_kernel_item_direct_timeline_object",
1031
+ direct_probe,
1032
+ )
1033
+ else:
1034
+ _record_tool_result(recorder, "mcp_capabilities", "probe_edit_kernel_item", probe)
1035
+
1036
+ recorder.record(
1037
+ "operations.boundaries",
1038
+ "transition_cloning",
1039
+ "unsupported",
1040
+ details={"reason": "Resolve public API exposes no transition cloning primitive on TimelineItem"},
1041
+ )
1042
+ recorder.record(
1043
+ "operations.boundaries",
1044
+ "razor_or_split",
1045
+ "unsupported",
1046
+ details={"reason": "Resolve public API exposes no direct timeline split/razor primitive"},
1047
+ )
1048
+ recorder.record(
1049
+ "operations.boundaries",
1050
+ "opaque_speed_ramp_internals",
1051
+ "partially_supported",
1052
+ details={
1053
+ "reason": "Speed/RetimeProcess/MotionEstimation and keyframes are visible when Resolve exposes them; opaque speed ramp curves are not independently inspectable"
1054
+ },
1055
+ )
1056
+
1057
+ if keep_open:
1058
+ server.project_manager("save")
1059
+ print(f"LEFT PROJECT OPEN FOR INSPECTION: {project_name}")
1060
+ created_project = False
1061
+
1062
+ except Exception as exc:
1063
+ recorder.record(
1064
+ "probe",
1065
+ "fatal",
1066
+ "error",
1067
+ details={"exception": repr(exc), "traceback": traceback.format_exc()},
1068
+ )
1069
+ raise
1070
+ finally:
1071
+ if created_project:
1072
+ server.project_manager("save")
1073
+ server.project_manager("close")
1074
+ delete_result = server.project_manager("delete", {"name": project_name})
1075
+ print(f"Deleted disposable project: {delete_result}")
1076
+
1077
+ if delete_result is not None and delete_result.get("success") is not True:
1078
+ raise AssertionError(f"Cleanup failed for {project_name}: {delete_result!r}")
1079
+
1080
+ output_dir.mkdir(parents=True, exist_ok=True)
1081
+ metadata["output_dir"] = str(output_dir)
1082
+ report = recorder.to_report(metadata, artifacts={})
1083
+ json_path = output_dir / "timeline-edit-kernel-probe.json"
1084
+ markdown_path = output_dir / "timeline-edit-kernel-probe.md"
1085
+ report["artifacts"] = {"json": str(json_path), "markdown": str(markdown_path)}
1086
+ json_path.write_text(json.dumps(report, indent=2, sort_keys=True, default=str), encoding="utf-8")
1087
+ markdown_path.write_text(render_markdown_report(report), encoding="utf-8")
1088
+ print(f"Wrote JSON report: {json_path}")
1089
+ print(f"Wrote Markdown report: {markdown_path}")
1090
+ print(f"Counts: {json.dumps(report['counts'], sort_keys=True)}")
1091
+ return report