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.
- package/AGENTS.md +85 -0
- package/CHANGELOG.md +802 -0
- package/CLAUDE.md +15 -0
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/SECURITY.md +53 -0
- package/bin/davinci-resolve-mcp.mjs +376 -0
- package/docs/README.md +56 -0
- package/docs/SKILL.md +1145 -0
- package/docs/authoring/fuse-dctl-authoring.md +242 -0
- package/docs/authoring/script-plugin-authoring.md +195 -0
- package/docs/contributing.md +82 -0
- package/docs/guides/color-decision-guide.md +387 -0
- package/docs/guides/editorial-decision-guide.md +136 -0
- package/docs/guides/media-analysis-guide.md +615 -0
- package/docs/guides/multicam-setup-guide.md +138 -0
- package/docs/install.md +198 -0
- package/docs/integrations/workflow-integrations.md +120 -0
- package/docs/kernels/README.md +28 -0
- package/docs/kernels/audio-fairlight-kernel.md +86 -0
- package/docs/kernels/color-grade-kernel.md +103 -0
- package/docs/kernels/extension-authoring-kernel.md +101 -0
- package/docs/kernels/fusion-composition-kernel.md +91 -0
- package/docs/kernels/media-pool-ingest-kernel.md +147 -0
- package/docs/kernels/project-lifecycle-kernel.md +120 -0
- package/docs/kernels/render-deliver-kernel.md +92 -0
- package/docs/kernels/review-annotation-kernel.md +110 -0
- package/docs/kernels/timeline-conform-interchange-kernel.md +99 -0
- package/docs/kernels/timeline-edit-kernel.md +189 -0
- package/docs/notes/codec-plugin-notes.md +136 -0
- package/docs/notes/dctl-notes.md +234 -0
- package/docs/notes/fusion-template-notes.md +136 -0
- package/docs/notes/lut-notes.md +136 -0
- package/docs/notes/openfx-notes.md +120 -0
- package/docs/process/release-process.md +152 -0
- package/docs/reference/api-coverage.md +488 -0
- package/docs/reference/resolve_scripting_api.txt +1012 -0
- package/examples/README.md +53 -0
- package/examples/markers/README.md +81 -0
- package/examples/media/README.md +94 -0
- package/examples/timeline/README.md +98 -0
- package/install.py +1196 -0
- package/package.json +52 -0
- package/scripts/audit_api_parity.py +275 -0
- package/scripts/live_media_analysis_polish_probe.py +65 -0
- package/src/__init__.py +3 -0
- package/src/analysis_dashboard.py +4936 -0
- package/src/control_panel.py +13 -0
- package/src/granular/__init__.py +17 -0
- package/src/granular/common.py +727 -0
- package/src/granular/folder.py +287 -0
- package/src/granular/gallery.py +306 -0
- package/src/granular/graph.py +309 -0
- package/src/granular/media_pool.py +679 -0
- package/src/granular/media_pool_item.py +852 -0
- package/src/granular/media_storage.py +179 -0
- package/src/granular/project.py +1594 -0
- package/src/granular/resolve_control.py +521 -0
- package/src/granular/timeline.py +1074 -0
- package/src/granular/timeline_item.py +2251 -0
- package/src/resolve_mcp_server.py +43 -0
- package/src/server.py +15691 -0
- package/src/utils/__init__.py +3 -0
- package/src/utils/app_control.py +319 -0
- package/src/utils/audio_fairlight_live_probe.py +263 -0
- package/src/utils/cdl.py +20 -0
- package/src/utils/cloud_operations.py +192 -0
- package/src/utils/color_grade_live_probe.py +444 -0
- package/src/utils/dctl_templates.py +368 -0
- package/src/utils/extension_authoring_live_probe.py +292 -0
- package/src/utils/fuse_templates.py +1968 -0
- package/src/utils/fusion_composition_live_probe.py +284 -0
- package/src/utils/layout_presets.py +333 -0
- package/src/utils/mcp_stdio.py +32 -0
- package/src/utils/media_analysis.py +3618 -0
- package/src/utils/media_analysis_jobs.py +796 -0
- package/src/utils/media_pool_ingest_live_probe.py +592 -0
- package/src/utils/multicam.py +393 -0
- package/src/utils/object_inspection.py +287 -0
- package/src/utils/platform.py +157 -0
- package/src/utils/project_lifecycle_live_probe.py +376 -0
- package/src/utils/project_properties.py +601 -0
- package/src/utils/render_deliver_live_probe.py +384 -0
- package/src/utils/resolve_connection.py +77 -0
- package/src/utils/review_annotation_live_probe.py +352 -0
- package/src/utils/script_templates.py +1193 -0
- package/src/utils/sync_detection.py +887 -0
- package/src/utils/timeline_conform_live_probe.py +280 -0
- package/src/utils/timeline_kernel_live_probe.py +1091 -0
- package/src/utils/timeline_kernel_probe.py +185 -0
- package/src/utils/timeline_title_text.py +87 -0
- 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
|