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,157 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Platform-specific functionality for DaVinci Resolve MCP Server
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import platform
9
+
10
+ def get_platform():
11
+ """Identify the current operating system platform.
12
+
13
+ Returns:
14
+ str: 'windows', 'darwin' (macOS), or 'linux'
15
+ """
16
+ system = platform.system().lower()
17
+ if system == 'darwin':
18
+ return 'darwin'
19
+ elif system == 'windows':
20
+ return 'windows'
21
+ elif system == 'linux':
22
+ return 'linux'
23
+ return system
24
+
25
+ def get_resolve_paths():
26
+ """Get platform-specific paths for DaVinci Resolve scripting API.
27
+
28
+ Returns:
29
+ dict: Dictionary containing api_path, lib_path, and modules_path
30
+ """
31
+ platform_name = get_platform()
32
+
33
+ if platform_name == 'darwin': # macOS
34
+ api_path = "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting"
35
+ lib_path = "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so"
36
+ modules_path = os.path.join(api_path, "Modules")
37
+
38
+ elif platform_name == 'windows': # Windows
39
+ program_files = os.environ.get('PROGRAMDATA', 'C:\\ProgramData')
40
+ program_files_64 = os.environ.get('PROGRAMFILES', 'C:\\Program Files')
41
+
42
+ api_path = os.path.join(program_files, 'Blackmagic Design', 'DaVinci Resolve', 'Support', 'Developer', 'Scripting')
43
+ lib_path = os.path.join(program_files_64, 'Blackmagic Design', 'DaVinci Resolve', 'fusionscript.dll')
44
+ modules_path = os.path.join(api_path, "Modules")
45
+
46
+ elif platform_name == 'linux': # Linux (not fully implemented)
47
+ # Default locations for Linux - these may need to be adjusted
48
+ api_path = "/opt/resolve/Developer/Scripting"
49
+ lib_path = "/opt/resolve/libs/fusionscript.so"
50
+ modules_path = os.path.join(api_path, "Modules")
51
+
52
+ else:
53
+ # Fallback to macOS paths if unknown platform
54
+ api_path = "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting"
55
+ lib_path = "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so"
56
+ modules_path = os.path.join(api_path, "Modules")
57
+
58
+ return {
59
+ "api_path": api_path,
60
+ "lib_path": lib_path,
61
+ "modules_path": modules_path
62
+ }
63
+
64
+ def get_resolve_plugin_paths():
65
+ """Get platform-specific paths for Resolve plugin install dirs.
66
+
67
+ Returns:
68
+ dict: {
69
+ 'fuses_dir': Fusion Fuses directory,
70
+ 'dctl_dir': LUT directory (where regular .dctl files live),
71
+ 'aces_idt_dir': ACES IDT transforms (separate scan path; restart
72
+ required after install),
73
+ 'aces_odt_dir': ACES ODT transforms (same caveat),
74
+ }
75
+ """
76
+ platform_name = get_platform()
77
+ home = os.path.expanduser("~")
78
+
79
+ # NOTE: The Fuse SDK doc (June 2023) lists Fuses under "Support/Fusion/Fuses"
80
+ # on macOS, but the directory Resolve actually scans is the sibling
81
+ # "Fusion/Fuses" (without "Support") — verified live against Resolve Studio
82
+ # 20.3.2.9 by writing test fuses to both paths and observing which loaded.
83
+ # Per-platform conventions also differ from the SDK doc; we follow the
84
+ # canonical Fusion user-data layout where every Fusion user directory
85
+ # (Macros, Templates, Scripts, Modules, Fuses, ...) lives directly under
86
+ # the platform's Fusion user root.
87
+ if platform_name == 'darwin':
88
+ support = os.path.join(home, "Library", "Application Support",
89
+ "Blackmagic Design", "DaVinci Resolve")
90
+ fuses_dir = os.path.join(support, "Fusion", "Fuses")
91
+ dctl_dir = os.path.join(support, "LUT")
92
+ aces_root = os.path.join(support, "ACES Transforms")
93
+ elif platform_name == 'windows':
94
+ appdata = os.environ.get('APPDATA', os.path.join(home, 'AppData', 'Roaming'))
95
+ fuses_dir = os.path.join(appdata, 'Blackmagic Design', 'DaVinci Resolve',
96
+ 'Support', 'Fusion', 'Fuses')
97
+ dctl_dir = os.path.join(appdata, 'Blackmagic Design', 'DaVinci Resolve',
98
+ 'Support', 'LUT')
99
+ aces_root = os.path.join(appdata, 'Blackmagic Design', 'DaVinci Resolve',
100
+ 'Support', 'ACES Transforms')
101
+ elif platform_name == 'linux':
102
+ base = os.path.join(home, '.local', 'share', 'DaVinciResolve')
103
+ fuses_dir = os.path.join(base, 'Fusion', 'Fuses')
104
+ dctl_dir = os.path.join(base, 'LUT')
105
+ aces_root = os.path.join(base, 'ACES Transforms')
106
+ else:
107
+ support = os.path.join(home, "Library", "Application Support",
108
+ "Blackmagic Design", "DaVinci Resolve")
109
+ fuses_dir = os.path.join(support, "Fusion", "Fuses")
110
+ dctl_dir = os.path.join(support, "LUT")
111
+ aces_root = os.path.join(support, "ACES Transforms")
112
+
113
+ # Resolve scans these subdirs of Fusion/Scripts/ at startup and exposes
114
+ # them in Workspace → Scripts → <category>. Categories per Resolve docs.
115
+ if platform_name == 'darwin':
116
+ scripts_root = os.path.join(support, "Fusion", "Scripts")
117
+ elif platform_name == 'windows':
118
+ scripts_root = os.path.join(appdata, 'Blackmagic Design', 'DaVinci Resolve',
119
+ 'Support', 'Fusion', 'Scripts')
120
+ elif platform_name == 'linux':
121
+ scripts_root = os.path.join(base, 'Fusion', 'Scripts')
122
+ else:
123
+ scripts_root = os.path.join(support, "Fusion", "Scripts")
124
+
125
+ return {
126
+ "fuses_dir": fuses_dir,
127
+ "dctl_dir": dctl_dir,
128
+ "aces_idt_dir": os.path.join(aces_root, "IDT"),
129
+ "aces_odt_dir": os.path.join(aces_root, "ODT"),
130
+ "scripts_root": scripts_root,
131
+ # Category subdirs Resolve actually scans (verified live):
132
+ "scripts_categories": ("Edit", "Color", "Deliver", "Comp",
133
+ "Tool", "Utility", "Views"),
134
+ }
135
+
136
+
137
+ def setup_environment():
138
+ """Set up environment variables for DaVinci Resolve scripting.
139
+
140
+ Returns:
141
+ bool: True if setup was successful, False otherwise
142
+ """
143
+ try:
144
+ paths = get_resolve_paths()
145
+
146
+ os.environ["RESOLVE_SCRIPT_API"] = paths["api_path"]
147
+ os.environ["RESOLVE_SCRIPT_LIB"] = paths["lib_path"]
148
+
149
+ # Add modules path to Python's path if it's not already there
150
+ if paths["modules_path"] not in sys.path:
151
+ sys.path.append(paths["modules_path"])
152
+
153
+ return True
154
+
155
+ except Exception as e:
156
+ print(f"Error setting up environment: {str(e)}")
157
+ return False
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env python3
2
+ """Live Project / Database / Archive boundary probe."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import platform
8
+ import shutil
9
+ import sys
10
+ import tempfile
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Optional
14
+
15
+ from src.utils.timeline_kernel_probe import ProbeRecorder, render_markdown_report, utc_timestamp
16
+
17
+
18
+ def _require_success(label: str, result: Dict[str, Any]) -> Dict[str, Any]:
19
+ if not isinstance(result, dict):
20
+ raise AssertionError(f"{label}: expected dict, got {result!r}")
21
+ if result.get("error"):
22
+ raise AssertionError(f"{label}: {result['error']}")
23
+ if "success" in result and result["success"] is not True:
24
+ raise AssertionError(f"{label}: expected success=True, got {result!r}")
25
+ return result
26
+
27
+
28
+ def _record_tool_result(
29
+ recorder: ProbeRecorder,
30
+ category: str,
31
+ name: str,
32
+ result: Dict[str, Any],
33
+ *,
34
+ expected_status: Optional[str] = None,
35
+ ) -> None:
36
+ if not isinstance(result, dict):
37
+ recorder.record(category, name, "error", details={"reason": "non-dict result", "result": repr(result)})
38
+ return
39
+ if result.get("error"):
40
+ recorder.record(category, name, expected_status or "error", details={"reason": result.get("error")}, evidence=result)
41
+ return
42
+ if "success" in result and result["success"] is not True:
43
+ recorder.record(
44
+ category,
45
+ name,
46
+ expected_status or "partially_supported",
47
+ details={"reason": "success returned false"},
48
+ evidence=result,
49
+ )
50
+ return
51
+ recorder.record(category, name, expected_status or "supported", evidence=result)
52
+
53
+
54
+ def _delete_disposable_project(server, name: str) -> Dict[str, Any]:
55
+ result = server.project_manager(
56
+ "safe_project_delete",
57
+ {"name": name, "close_current": True, "save_current": True},
58
+ )
59
+ if result.get("error") and "currently open" in result.get("error", ""):
60
+ result = server.project_manager("safe_project_delete", {"name": name})
61
+ return result
62
+
63
+
64
+ def _first_setting_value(snapshot: Dict[str, Any], keys: list[str]) -> Optional[Dict[str, Any]]:
65
+ settings = snapshot.get("settings")
66
+ if not isinstance(settings, dict):
67
+ return None
68
+ for key in keys:
69
+ value = settings.get(key)
70
+ if value not in (None, ""):
71
+ return {key: value}
72
+ return None
73
+
74
+
75
+ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, Any]:
76
+ output_dir.mkdir(parents=True, exist_ok=True)
77
+ work_dir = Path(tempfile.mkdtemp(prefix="mcp_project_lifecycle_probe_"))
78
+ timestamp = int(time.time())
79
+ project_name = f"_mcp_project_lifecycle_probe_{timestamp}"
80
+ imported_name = f"_mcp_project_lifecycle_import_{timestamp}"
81
+ restored_name = f"_mcp_project_lifecycle_restore_{timestamp}"
82
+ archive_restored_name = f"_mcp_project_lifecycle_archive_restore_{timestamp}"
83
+ timeline_name = "Project Lifecycle Probe Timeline"
84
+ folder_name = f"_mcp_project_folder_{timestamp}"
85
+ layout_name = f"_mcp_layout_probe_{timestamp}"
86
+ layout_import_name = f"_mcp_layout_import_{timestamp}"
87
+ cleanup_projects: list[str] = []
88
+ cleanup_layouts: list[str] = []
89
+ delete_results: Dict[str, Any] = {}
90
+ recorder = ProbeRecorder()
91
+
92
+ metadata: Dict[str, Any] = {
93
+ "title": "Project / Database / Archive Kernel Capability Probe",
94
+ "timestamp_utc": utc_timestamp(),
95
+ "python": sys.version,
96
+ "platform": platform.platform(),
97
+ "output_dir": str(output_dir),
98
+ "work_dir": str(work_dir),
99
+ "project_name": project_name,
100
+ "imported_project_name": imported_name,
101
+ "restored_project_name": restored_name,
102
+ "archive_restored_project_name": archive_restored_name,
103
+ }
104
+
105
+ try:
106
+ version = _require_success("resolve_control.get_version", server.resolve_control("get_version"))
107
+ metadata.update(
108
+ {
109
+ "product": version.get("product"),
110
+ "version": version.get("version"),
111
+ "version_string": version.get("version_string"),
112
+ }
113
+ )
114
+ print(f"Connected to {metadata['product']} {metadata['version_string']}")
115
+
116
+ _record_tool_result(recorder, "capabilities", "project_capabilities_pre_create", server.project_manager("project_capabilities"))
117
+ _record_tool_result(recorder, "database", "database_capabilities_pre_create", server.project_manager("database_capabilities"))
118
+ db_caps = server.project_manager("database_capabilities")
119
+ if isinstance(db_caps, dict) and db_caps.get("current"):
120
+ _record_tool_result(
121
+ recorder,
122
+ "database",
123
+ "safe_set_current_database_dry_run",
124
+ server.project_manager("safe_set_current_database", {"db_info": db_caps["current"]}),
125
+ )
126
+
127
+ create_result = server.project_manager("safe_project_create", {"name": project_name})
128
+ _require_success("project_manager.safe_project_create", create_result)
129
+ cleanup_projects.append(project_name)
130
+ print(f"Created disposable project: {project_name}")
131
+
132
+ _record_tool_result(recorder, "lifecycle", "safe_project_create", create_result)
133
+ _record_tool_result(recorder, "lifecycle", "get_current", server.project_manager("get_current"))
134
+ _record_tool_result(recorder, "lifecycle", "create_empty_timeline", server.media_pool("create_timeline", {"name": timeline_name}))
135
+ _record_tool_result(recorder, "lifecycle", "save_project_before_export", server.project_manager("save"))
136
+ _record_tool_result(recorder, "lifecycle", "probe_project_lifecycle", server.project_manager("probe_project_lifecycle"))
137
+ _record_tool_result(recorder, "settings", "project_settings_snapshot", server.project_manager("project_settings_snapshot"))
138
+
139
+ snapshot = server.project_manager("project_settings_snapshot")
140
+ setting_payload = _first_setting_value(
141
+ snapshot if isinstance(snapshot, dict) else {},
142
+ ["timelineResolutionWidth", "timelineResolutionHeight", "timelineFrameRate"],
143
+ )
144
+ if setting_payload:
145
+ _record_tool_result(
146
+ recorder,
147
+ "settings",
148
+ "safe_set_project_settings_same_value_restore",
149
+ server.project_manager("safe_set_project_settings", {"settings": setting_payload, "restore": True}),
150
+ )
151
+ else:
152
+ recorder.record(
153
+ "settings",
154
+ "safe_set_project_settings_same_value_restore",
155
+ "not_applicable",
156
+ details={"reason": "No non-empty candidate project setting was readable"},
157
+ evidence=snapshot,
158
+ )
159
+
160
+ _record_tool_result(
161
+ recorder,
162
+ "settings",
163
+ "probe_project_settings_try_write_dry_run",
164
+ server.project_manager("probe_project_settings", {"try_write": True, "dry_run": True}),
165
+ )
166
+ _record_tool_result(recorder, "presets", "preset_lifecycle_probe", server.project_manager("preset_lifecycle_probe"))
167
+
168
+ drp_path = work_dir / "project-lifecycle-export.drp"
169
+ archive_path = work_dir / "project-lifecycle-archive.dra"
170
+ archive_folder_path = work_dir / "project-lifecycle-archive-folder"
171
+ _record_tool_result(
172
+ recorder,
173
+ "lifecycle",
174
+ "safe_project_export",
175
+ server.project_manager("safe_project_export", {"name": project_name, "path": str(drp_path)}),
176
+ )
177
+ if drp_path.exists():
178
+ import_result = server.project_manager("safe_project_import", {"name": imported_name, "path": str(drp_path)})
179
+ if import_result.get("success"):
180
+ cleanup_projects.append(imported_name)
181
+ _record_tool_result(recorder, "lifecycle", "safe_project_import", import_result)
182
+
183
+ restore_result = server.project_manager("safe_project_restore", {"name": restored_name, "path": str(drp_path)})
184
+ if restore_result.get("success"):
185
+ cleanup_projects.append(restored_name)
186
+ _record_tool_result(recorder, "lifecycle", "safe_project_restore_from_drp", restore_result)
187
+ else:
188
+ recorder.record(
189
+ "lifecycle",
190
+ "safe_project_import",
191
+ "not_applicable",
192
+ details={"reason": "DRP export path was not created"},
193
+ )
194
+ recorder.record(
195
+ "lifecycle",
196
+ "safe_project_restore_from_drp",
197
+ "not_applicable",
198
+ details={"reason": "DRP export path was not created"},
199
+ )
200
+
201
+ archive_file_result = server.project_manager(
202
+ "safe_project_archive",
203
+ {
204
+ "name": project_name,
205
+ "path": str(archive_path),
206
+ "src_media": False,
207
+ "render_cache": False,
208
+ "proxy_media": False,
209
+ },
210
+ )
211
+ _record_tool_result(
212
+ recorder,
213
+ "archive",
214
+ "safe_project_archive_no_media_dra_path",
215
+ archive_file_result,
216
+ expected_status=None if archive_file_result.get("success") else "partially_supported",
217
+ )
218
+ archive_folder_result = server.project_manager(
219
+ "safe_project_archive",
220
+ {
221
+ "name": project_name,
222
+ "path": str(archive_folder_path),
223
+ "src_media": False,
224
+ "render_cache": False,
225
+ "proxy_media": False,
226
+ },
227
+ )
228
+ _record_tool_result(
229
+ recorder,
230
+ "archive",
231
+ "safe_project_archive_no_media_folder_path",
232
+ archive_folder_result,
233
+ expected_status=None if archive_folder_result.get("success") else "partially_supported",
234
+ )
235
+ archive_restore_source = archive_path if archive_file_result.get("success") else archive_folder_path
236
+ if archive_file_result.get("success") or archive_folder_result.get("success"):
237
+ archive_restore = server.project_manager(
238
+ "safe_project_restore",
239
+ {"name": archive_restored_name, "path": str(archive_restore_source)},
240
+ )
241
+ if archive_restore.get("success"):
242
+ cleanup_projects.append(archive_restored_name)
243
+ _record_tool_result(
244
+ recorder,
245
+ "archive",
246
+ "safe_project_restore_from_archive",
247
+ archive_restore,
248
+ expected_status=None if archive_restore.get("success") else "partially_supported",
249
+ )
250
+ else:
251
+ recorder.record(
252
+ "archive",
253
+ "safe_project_restore_from_archive",
254
+ "not_applicable",
255
+ details={"reason": "No archive path was created successfully"},
256
+ )
257
+ _record_tool_result(
258
+ recorder,
259
+ "archive",
260
+ "safe_project_archive_rejects_media_flags",
261
+ server.project_manager(
262
+ "safe_project_archive",
263
+ {"name": project_name, "path": str(work_dir / "reject.dra"), "src_media": True, "dry_run": True},
264
+ ),
265
+ expected_status="unsupported",
266
+ )
267
+
268
+ _record_tool_result(recorder, "folders", "folder_list_before", server.project_manager_folders("list"))
269
+ _record_tool_result(recorder, "folders", "folder_create", server.project_manager_folders("create", {"name": folder_name}))
270
+ _record_tool_result(recorder, "folders", "folder_open", server.project_manager_folders("open", {"name": folder_name}))
271
+ _record_tool_result(recorder, "folders", "folder_get_current", server.project_manager_folders("get_current"))
272
+ _record_tool_result(recorder, "folders", "folder_goto_parent", server.project_manager_folders("goto_parent"))
273
+ _record_tool_result(recorder, "folders", "folder_delete", server.project_manager_folders("delete", {"name": folder_name}))
274
+
275
+ for page in ["media", "cut", "edit", "fusion", "color", "fairlight", "deliver"]:
276
+ _record_tool_result(
277
+ recorder,
278
+ "app_state",
279
+ f"open_page_{page}",
280
+ server.resolve_control("open_page", {"page": page}),
281
+ expected_status=None,
282
+ )
283
+ _record_tool_result(recorder, "app_state", "get_page", server.resolve_control("get_page"))
284
+ keyframe_mode = server.resolve_control("get_keyframe_mode")
285
+ _record_tool_result(recorder, "app_state", "get_keyframe_mode", keyframe_mode)
286
+ if isinstance(keyframe_mode, dict) and "mode" in keyframe_mode:
287
+ _record_tool_result(
288
+ recorder,
289
+ "app_state",
290
+ "set_keyframe_mode_same_value",
291
+ server.resolve_control("set_keyframe_mode", {"mode": keyframe_mode["mode"]}),
292
+ )
293
+
294
+ layout_path = work_dir / "layout-preset.layout"
295
+ layout_save = server.layout_presets("save", {"name": layout_name})
296
+ if layout_save.get("success"):
297
+ cleanup_layouts.append(layout_name)
298
+ _record_tool_result(recorder, "presets", "layout_save", layout_save)
299
+ _record_tool_result(recorder, "presets", "layout_update", server.layout_presets("update", {"name": layout_name}))
300
+ _record_tool_result(recorder, "presets", "layout_load", server.layout_presets("load", {"name": layout_name}))
301
+ _record_tool_result(recorder, "presets", "layout_export", server.layout_presets("export", {"name": layout_name, "path": str(layout_path)}))
302
+ if layout_path.exists():
303
+ layout_import = server.layout_presets("import_preset", {"name": layout_import_name, "path": str(layout_path)})
304
+ if layout_import.get("success"):
305
+ cleanup_layouts.append(layout_import_name)
306
+ _record_tool_result(recorder, "presets", "layout_import", layout_import)
307
+ else:
308
+ recorder.record(
309
+ "presets",
310
+ "layout_import",
311
+ "not_applicable",
312
+ details={"reason": "layout export path was not created"},
313
+ )
314
+
315
+ preset_probe = server.project_manager("preset_lifecycle_probe")
316
+ render_presets = []
317
+ if isinstance(preset_probe, dict):
318
+ render_presets = preset_probe.get("render_presets", {}).get("items", []) or []
319
+ if render_presets:
320
+ render_preset_name = render_presets[0].get("Name") if isinstance(render_presets[0], dict) else render_presets[0]
321
+ if render_preset_name:
322
+ _record_tool_result(
323
+ recorder,
324
+ "presets",
325
+ "render_preset_export",
326
+ server.render_presets("export_render", {"name": render_preset_name, "path": str(work_dir / "render-preset.xml")}),
327
+ expected_status=None,
328
+ )
329
+ else:
330
+ recorder.record(
331
+ "presets",
332
+ "render_preset_export",
333
+ "not_applicable",
334
+ details={"reason": "No render presets were listed"},
335
+ evidence=preset_probe,
336
+ )
337
+
338
+ _record_tool_result(recorder, "report", "project_boundary_report", server.project_manager("project_boundary_report"))
339
+
340
+ if keep_open:
341
+ server.project_manager("save")
342
+ print(f"LEFT PROJECTS OPEN FOR INSPECTION: {cleanup_projects}")
343
+ cleanup_projects = []
344
+
345
+ finally:
346
+ for layout_name_to_delete in reversed(cleanup_layouts):
347
+ delete_results[f"layout:{layout_name_to_delete}"] = server.layout_presets("delete", {"name": layout_name_to_delete})
348
+ print(f"Deleted layout preset {layout_name_to_delete}: {delete_results[f'layout:{layout_name_to_delete}']}")
349
+ if not keep_open:
350
+ for disposable_project in reversed(cleanup_projects):
351
+ delete_results[f"project:{disposable_project}"] = _delete_disposable_project(server, disposable_project)
352
+ print(f"Deleted disposable project {disposable_project}: {delete_results[f'project:{disposable_project}']}")
353
+
354
+ metadata["cleanup"] = delete_results
355
+ report = recorder.to_report(
356
+ metadata,
357
+ {
358
+ "json": str(output_dir / "project-lifecycle-probe.json"),
359
+ "markdown": str(output_dir / "project-lifecycle-probe.md"),
360
+ },
361
+ )
362
+ json_path = output_dir / "project-lifecycle-probe.json"
363
+ markdown_path = output_dir / "project-lifecycle-probe.md"
364
+ json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
365
+ markdown_path.write_text(render_markdown_report(report), encoding="utf-8")
366
+ print(f"Wrote JSON report: {json_path}")
367
+ print(f"Wrote Markdown report: {markdown_path}")
368
+ print(f"Counts: {json.dumps(report['counts'], sort_keys=True)}")
369
+ if not keep_open:
370
+ shutil.rmtree(work_dir, ignore_errors=True)
371
+ print(f"Removed project lifecycle work directory: {work_dir}")
372
+
373
+ failed_cleanup = {key: value for key, value in delete_results.items() if value.get("success") is not True and not value.get("error", "").startswith("No ")}
374
+ if failed_cleanup:
375
+ raise AssertionError(f"Cleanup failed: {failed_cleanup!r}")
376
+ return report