davinci-resolve-mcp 2.69.3 → 2.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.69.3"
14
+ VERSION = "2.70.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -105,6 +105,7 @@ from src.utils.media_analysis_jobs import (
105
105
  )
106
106
  from src.utils.platform import get_resolve_paths, get_resolve_plugin_paths
107
107
  from src.utils.resolve_connection import connect_resolve
108
+ from src.utils import resolve_runtime as _resolve_runtime
108
109
  from src.utils.lut_paths import master_lut_dir, ensure_lut_in_master
109
110
  from src.utils import fuse_templates, dctl_templates, script_templates
110
111
  from src.utils.timeline_title_text import (
@@ -855,18 +856,16 @@ def _try_connect():
855
856
  resolve = None
856
857
  return None
857
858
 
858
- #: macOS puts the two editions in different places, and only the first was ever
859
- #: checked — so an auto-launch on a machine with both would start Studio even
860
- #: when the free edition was the one in use. Ordered installer-first to keep the
861
- #: previous behaviour where Studio is present.
862
- _MACOS_RESOLVE_APPS = (
863
- "/Applications/DaVinci Resolve/DaVinci Resolve.app", # installer / Studio
864
- "/Applications/DaVinci Resolve.app", # App Store, free
865
- )
866
-
859
+ def _launch_resolve(headless: Optional[bool] = None):
860
+ """Launch DaVinci Resolve and wait for it to become available.
867
861
 
868
- def _launch_resolve():
869
- """Launch DaVinci Resolve and wait for it to become available."""
862
+ `headless=None` defers to the DAVINCI_RESOLVE_HEADLESS environment variable.
863
+ A headless launch is capability-identical to a GUI one (see
864
+ docs/reference/headless-cli.md) and immune to the modal dialogs that
865
+ otherwise wedge an agent-driven session, so it is the better default for
866
+ unattended work — but it stays opt-in, because starting an invisible
867
+ application on a workstation is not something to do behind the user's back.
868
+ """
870
869
  if _bridge_requested():
871
870
  # Launching cannot produce a bridge. The listener only exists once
872
871
  # someone runs Workspace > Scripts > resolve_bridge inside an already
@@ -878,28 +877,17 @@ def _launch_resolve():
878
877
  "launching the application cannot start it."
879
878
  )
880
879
  return False
881
- sys_name = platform.system().lower()
882
- if sys_name == "darwin":
883
- app_path = next((p for p in _MACOS_RESOLVE_APPS if os.path.exists(p)), None)
884
- if app_path is None:
885
- logger.error("DaVinci Resolve not found in %s", list(_MACOS_RESOLVE_APPS))
886
- return False
887
- subprocess.Popen(["open", app_path], stdin=subprocess.DEVNULL)
888
- elif sys_name == "windows":
889
- app_path = r"C:\Program Files\Blackmagic Design\DaVinci Resolve\Resolve.exe"
890
- if not os.path.exists(app_path):
891
- logger.error(f"DaVinci Resolve not found at {app_path}")
892
- return False
893
- subprocess.Popen([app_path], stdin=subprocess.DEVNULL)
894
- elif sys_name == "linux":
895
- app_path = "/opt/resolve/bin/resolve"
896
- if not os.path.exists(app_path):
897
- logger.error(f"DaVinci Resolve not found at {app_path}")
898
- return False
899
- subprocess.Popen([app_path], stdin=subprocess.DEVNULL)
900
- else:
880
+ if headless is None:
881
+ headless = _resolve_runtime.prefers_headless()
882
+ command = _resolve_runtime.launch_command(bool(headless))
883
+ if command is None:
884
+ logger.error("DaVinci Resolve not found on this platform's known install paths")
901
885
  return False
902
- logger.info("Launched DaVinci Resolve, waiting for it to respond...")
886
+ subprocess.Popen(command, stdin=subprocess.DEVNULL)
887
+ logger.info(
888
+ "Launched DaVinci Resolve (%s), waiting for it to respond...",
889
+ "headless" if headless else "with UI",
890
+ )
903
891
  for i in range(30):
904
892
  time.sleep(2)
905
893
  if _try_connect():
@@ -908,16 +896,6 @@ def _launch_resolve():
908
896
  logger.warning("Resolve did not respond within 60s after launch")
909
897
  return False
910
898
 
911
- #: Process names that mean a DaVinci Resolve application is already running.
912
- #: Deliberately not the full path: the App Store build lives at
913
- #: `/Applications/DaVinci Resolve.app` and the installer build inside
914
- #: `/Applications/DaVinci Resolve/`, and either one counts.
915
- _RESOLVE_PROCESS_PATTERNS = (
916
- "DaVinci Resolve.app/Contents/MacOS/Resolve", # macOS, both editions
917
- "Resolve.exe", # Windows
918
- "/opt/resolve/bin/resolve", # Linux
919
- )
920
-
921
899
 
922
900
  def resolve_is_running() -> Optional[bool]:
923
901
  """Is a Resolve application already up? None when it cannot be determined.
@@ -926,17 +904,7 @@ def resolve_is_running() -> Optional[bool]:
926
904
  unanswerable question never silently becomes "nothing is running" — which is
927
905
  the answer that leads to launching something.
928
906
  """
929
- try:
930
- if platform.system().lower() == "windows":
931
- out = subprocess.run(["tasklist"], capture_output=True, text=True, timeout=10, check=False)
932
- else:
933
- out = subprocess.run(["ps", "-Ao", "command="], capture_output=True, text=True, timeout=10, check=False)
934
- if out.returncode != 0 and not out.stdout:
935
- return None
936
- listing = out.stdout or ""
937
- except Exception: # pragma: no cover - defensive; an unknown answer is None
938
- return None
939
- return any(pattern in listing for pattern in _RESOLVE_PROCESS_PATTERNS)
907
+ return _resolve_runtime.runtime_mode()["running"]
940
908
 
941
909
 
942
910
  def get_resolve():
@@ -1017,7 +985,11 @@ def _not_connected_error():
1017
985
  remediation="On Studio: Preferences > General > 'External scripting using' = Local. "
1018
986
  "On the free edition: install the in-app bridge, run "
1019
987
  "Workspace > Scripts > resolve_bridge, and set DAVINCI_RESOLVE_BRIDGE=1.",
1020
- state={"resolve_running": True, "bridge_enabled": False},
988
+ # Which Resolve is refusing matters to the reader: a headless render
989
+ # worker and the editor the user is looking at warrant different
990
+ # responses, and the in-app bridge is not an option for the former.
991
+ state={"resolve_running": True, "bridge_enabled": False,
992
+ "headless": _resolve_runtime.is_headless()},
1021
993
  )
1022
994
  return _err(
1023
995
  "DaVinci Resolve is not running and could not be started.",
@@ -13074,7 +13046,18 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13074
13046
  """App-level DaVinci Resolve operations.
13075
13047
 
13076
13048
  Actions:
13077
- launch() -> {success, message} — Launch DaVinci Resolve if not running. Call this FIRST if any tool returns a 'Not connected' error.
13049
+ launch(headless?) -> {success, message, running, headless, guidance}
13050
+ — Launch DaVinci Resolve if not running. Call this FIRST if any tool returns
13051
+ a 'Not connected' error. headless=true starts it with no UI (-nogui):
13052
+ capability-identical to the GUI and immune to the modal dialogs that can
13053
+ block a script indefinitely. Errors rather than starting a second instance
13054
+ if one is already running in the other mode.
13055
+ runtime_mode() -> {running, headless, instances, guidance}
13056
+ — Is Resolve up, and does it have a UI? Answers with no connection needed.
13057
+ headless is null when it cannot be determined — do NOT read that as false.
13058
+ There is no way to tell from the scripting API itself; this reads the
13059
+ process argv. Consult before any LoadProject/CloseProject: with a UI,
13060
+ an unsaved outgoing project raises a dialog no script can dismiss.
13078
13061
  get_version() -> {product, version, version_string, mcp: {version, update, update_decision}}
13079
13062
  — mcp.update_decision piggybacks the cached update check: if its action is
13080
13063
  "notify" or "prompt", mention the available MCP update to the user ONCE
@@ -13172,11 +13155,58 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13172
13155
  clear_update_prompt_preferences(project_dir, env=_setup_update_env())
13173
13156
  return _ok(**_mcp_update_status_payload())
13174
13157
 
13158
+ # runtime_mode reads the operating system, not Resolve, so it answers even
13159
+ # when nothing is running — which is exactly when an agent needs it.
13160
+ if action == "runtime_mode":
13161
+ payload = _resolve_runtime.describe()
13162
+ # If something IS running, add the one reading that distinguishes a
13163
+ # healthy instance from a wedged one. A Resolve can come up attached to
13164
+ # NO database: it answers GetProductName, GetVersionString and
13165
+ # GetCurrentPage normally, so every liveness check passes, while
13166
+ # CreateProject and LoadProject return False forever and SaveProject
13167
+ # returns None. Observed after an unclean shutdown, and it does not
13168
+ # recover on its own — only a restart clears it.
13169
+ if payload.get("running"):
13170
+ try:
13171
+ r = _try_connect() and resolve
13172
+ database = r.GetProjectManager().GetCurrentDatabase() if r else None
13173
+ except Exception:
13174
+ database = None
13175
+ payload["database"] = database
13176
+ payload["database_attached"] = bool(database)
13177
+ if payload.get("determinable") and not database:
13178
+ payload["guidance"] = (
13179
+ "WEDGED: Resolve is running and answers version queries, but no "
13180
+ "database is attached — CreateProject/LoadProject will return "
13181
+ "False indefinitely and SaveProject returns None. This does not "
13182
+ "recover; quit and restart Resolve before doing any work."
13183
+ ) + " || " + payload["guidance"]
13184
+ return _ok(**payload)
13185
+
13175
13186
  # launch works even when Resolve is not connected
13176
13187
  if action == "launch":
13188
+ headless = p.get("headless")
13189
+ if headless is not None:
13190
+ # An explicit request has to survive the "already running" shortcut
13191
+ # in get_resolve: connecting to the GUI instance someone asked to
13192
+ # replace with a headless one is not what was asked for.
13193
+ mode = _resolve_runtime.runtime_mode()
13194
+ if mode["running"] and bool(headless) != bool(mode["headless"]):
13195
+ return _err(
13196
+ "DaVinci Resolve is already running in the other mode.",
13197
+ code="RESOLVE_MODE_CONFLICT", category="not_connected", retryable=False,
13198
+ reason="Only one Resolve can run at a time; a second instance fights the "
13199
+ "singleton and has been observed to crash-loop rather than fail cleanly.",
13200
+ remediation="Quit the running instance first (resolve_control quit), then "
13201
+ "launch again with the mode you want.",
13202
+ state=mode,
13203
+ )
13204
+ if not mode["running"]:
13205
+ _launch_resolve(headless=bool(headless))
13177
13206
  r = get_resolve() # auto-launches if not running
13178
13207
  if r is not None:
13179
- return _ok(message="DaVinci Resolve is running and connected.")
13208
+ return _ok(message="DaVinci Resolve is running and connected.",
13209
+ **_resolve_runtime.describe())
13180
13210
  return _not_connected_error()
13181
13211
 
13182
13212
  r = get_resolve() # auto-launches if not running
@@ -13228,7 +13258,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13228
13258
  return missing
13229
13259
  r.DisableBackgroundTasksForCurrentResolveSession()
13230
13260
  return _ok()
13231
- return _unknown(action, ["launch","get_version","api_truth","verification_stats","job_status","list_jobs","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
13261
+ return _unknown(action, ["launch","runtime_mode","get_version","api_truth","verification_stats","job_status","list_jobs","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
13232
13262
 
13233
13263
 
13234
13264
  # ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
@@ -20440,7 +20470,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
20440
20470
  set_mark_in_out(mark_in, mark_out, type?) -> {success}
20441
20471
  clear_mark_in_out(type?) -> {success}
20442
20472
  convert_to_stereo() -> {success}
20443
- get_items_in_track(track_type, track_index|index) -> {items} — full serialization of each item
20473
+ get_items_in_track(track_type, track_index|index) -> {items} — alias of get_items
20444
20474
  get_voice_isolation_state(track_index) -> {isEnabled, amount}
20445
20475
  set_voice_isolation_state(track_index, state) -> {success}
20446
20476
  extract_source_frame_ranges(handles?, gap_max?, skip_extensions?) -> {timeline_name, frame_ranges, occurrences, ...}
@@ -20645,7 +20675,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
20645
20675
  return {"name": tl.GetTrackName(p["track_type"], p["index"])}
20646
20676
  elif action == "set_track_name":
20647
20677
  return {"success": bool(tl.SetTrackName(p["track_type"], p["index"], p["name"]))}
20648
- elif action == "get_items":
20678
+ elif action in {"get_items", "get_items_in_track"}:
20649
20679
  track_type, track_index, err = _track_selector(p)
20650
20680
  if err:
20651
20681
  return _err(err)
@@ -20884,11 +20914,6 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
20884
20914
  return {"success": bool(tl.ClearMarkInOut(p.get("type", "all")))}
20885
20915
  elif action == "convert_to_stereo":
20886
20916
  return {"success": bool(tl.ConvertTimelineToStereo())}
20887
- elif action == "get_items_in_track":
20888
- track_type, track_index, err = _track_selector(p)
20889
- if err:
20890
- return _err(err)
20891
- return {"items": _ser(tl.GetItemListInTrack(track_type, track_index))}
20892
20917
  elif action == "get_voice_isolation_state":
20893
20918
  missing = _requires_method(tl, "GetVoiceIsolationState", "20.1")
20894
20919
  if missing:
@@ -22235,9 +22260,9 @@ _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = {
22235
22260
  "example": 'timeline(action="get_items", params={"track_type": "video", "index": 1})',
22236
22261
  },
22237
22262
  "get_items_in_track": {
22238
- "summary": "List items on one track with full per-item serialization.",
22263
+ "summary": "Alias of get_items: list items on one track (name/id/start/end/duration).",
22239
22264
  "params": "track_type (video|audio|subtitle), track_index|index (1-based)",
22240
- "returns": "{items}",
22265
+ "returns": "{items: [{name, id, start, end, duration}]}",
22241
22266
  "example": 'timeline(action="get_items_in_track", params={"track_type": "audio", "track_index": 1})',
22242
22267
  },
22243
22268
  "duplicate_clips": {
@@ -101,10 +101,233 @@ API_TRUTH: List[Dict[str, Any]] = [
101
101
  "reality": "Returns False (no deletion) when the target project is, or "
102
102
  "recently was, the current project, and is flaky on the first "
103
103
  "attempt — so a single bool() call leaves the project undeleted "
104
- "with no useful error.",
105
- "recommended": "Load/close away from the target first, then retry; use "
106
- "src/utils/project_cleanup.py:delete_project_safely.",
107
- "tags": ["unreliable-return", "project", "flaky"],
104
+ "with no useful error. CloseProject reliably releases it: a "
105
+ "delete that had failed six times in a row, a second apart, "
106
+ "succeeded on the first attempt after one. Switching away with "
107
+ "LoadProject is NOT reliable — it left the delete failing "
108
+ "permanently after a heavily-used project, yet succeeded for a "
109
+ "project that had only been created and loaded. Whatever "
110
+ "distinguishes the two (modification? an open timeline?) is "
111
+ "not established, so LoadProject-away cannot be depended on.",
112
+ "recommended": "CloseProject the target FIRST (that is what releases it), "
113
+ "then LoadProject some named project so the session is not "
114
+ "left on the unsaveable 'Untitled Project' fallback, then "
115
+ "delete. Use src/utils/project_cleanup.py:delete_project_safely, "
116
+ "which does exactly that.",
117
+ "tags": ["unreliable-return", "project", "flaky", "session-lock"],
118
+ "submit": "bug",
119
+ },
120
+ {
121
+ "symbol": "Project.SetSetting('timelinePlaybackFrameRate')",
122
+ "object": "Project",
123
+ "reality": "Returns False for every value form tried (string, int, "
124
+ "float), both before and after a timeline exists, so the "
125
+ "playback frame rate cannot be set from the API at all. "
126
+ "Reported by a community contributor against Resolve Studio "
127
+ "while assembling a vertical timeline (PR #99).",
128
+ "recommended": "Ask the user to set it in Project Settings > Master "
129
+ "Settings > Playback frame rate as a SETUP step, before "
130
+ "any timeline exists. Read it back to confirm; do not "
131
+ "report it as set on the strength of the call alone.",
132
+ "tags": ["project-settings", "silent-failure", "timeline"],
133
+ "submit": "missing",
134
+ },
135
+ {
136
+ "symbol": "ProjectManager.GetCurrentDatabase",
137
+ "object": "ProjectManager",
138
+ "signature": "() -> {DbType, DbName}",
139
+ "reality": "Returns None when Resolve has come up without attaching to a "
140
+ "project database — a state it reaches after an unclean "
141
+ "shutdown, and does not recover from on its own. In that state "
142
+ "the application looks entirely healthy: it accepts scripting "
143
+ "connections, and GetProductName, GetVersionString, "
144
+ "GetCurrentPage and GetCurrentProject all answer normally. But "
145
+ "CreateProject and LoadProject return False indefinitely, "
146
+ "SaveProject returns None, and some calls block forever in "
147
+ "the scripting transport rather than returning at all. "
148
+ "Observed headless on Studio 19.1.3.7 after force-killing a "
149
+ "wedged instance; the replacement took 2m05s to become "
150
+ "scriptable and came up with no database.",
151
+ "recommended": "Treat a non-None GetCurrentDatabase() as the liveness "
152
+ "check, not a successful connection — every cheaper check "
153
+ "passes in the wedged state. `resolve_control "
154
+ "runtime_mode` reports `database_attached` for exactly "
155
+ "this. There is no repair: quit and restart Resolve.",
156
+ "tags": ["project", "database", "headless", "silent-failure", "startup"],
157
+ "mitigation": ["runtime_mode"],
158
+ "submit": "bug",
159
+ },
160
+ {
161
+ "symbol": "MediaPool.ImportTimelineFromFile",
162
+ "object": "MediaPool",
163
+ "signature": "(filePath, {importOptions}) -> Timeline",
164
+ "reality": "Returns None — no error, no exception — when the requested "
165
+ "`timelineName` already exists. Measured: importing one file "
166
+ "three times with the same name succeeded once and returned "
167
+ "None twice. An iterative loop that reuses a fixed name "
168
+ "therefore works exactly once and then silently does nothing. "
169
+ "DRT ignores importOptions entirely (timelineName, "
170
+ "importSourceClips and sourceClipsFolders are all invalid for "
171
+ "it): the timeline is named after the FILE, repeats "
172
+ "auto-uniquify ('iter', 'iter 2', 'iter 3'), and because "
173
+ "importSourceClips cannot be disabled each DRT import adds "
174
+ "another copy of the source media to the pool. OTIO is the one "
175
+ "format that will NOT relink from the pool — with "
176
+ "importSourceClips=False its timeline rebuilds with correct "
177
+ "structure and every item OFFLINE, in both GUI and headless.",
178
+ "recommended": "For a repeatable loop use FCP7 XML or AAF with a UNIQUE "
179
+ "timelineName per iteration plus importSourceClips=False "
180
+ "and sourceClipsFolders=[root] — verified frame-exact over "
181
+ "five consecutive imports with no media duplicated. Use DRT "
182
+ "for one-shot hand-offs only. For OTIO, pass "
183
+ "importSourceClips=True with a sourceClipsPath. See "
184
+ "docs/guides/headless-edit-loop.md.",
185
+ "tags": ["timeline", "import", "interchange", "silent-failure", "conform"],
186
+ "submit": "bug",
187
+ },
188
+ {
189
+ "symbol": "Timeline.Export / ImportTimelineFromFile (format fidelity)",
190
+ "object": "Timeline",
191
+ "reality": "The formats are NOT interchangeable, and which one is right "
192
+ "depends on the job — measured identically in GUI and "
193
+ "headless. On a rich cut (2 video tracks, audio, per-item "
194
+ "transform, colours, flags, item and timeline markers) only "
195
+ "DRT kept everything. FCP7 XML kept the cut, tracks and most "
196
+ "of the transform, losing only FlipX. AAF kept the cut and "
197
+ "tracks but RESET THE WHOLE TRANSFORM to defaults. FCPXML 1.10 "
198
+ "silently dropped an audio track (2v2a in, 2v1a out). OTIO "
199
+ "lost the source association. EDL collapsed to 1v1a. "
200
+ "But with media MOVED and sourceClipsPath given, the ranking "
201
+ "inverts: FCP7 XML, AAF and FCPXML 1.10 relinked 3/3 and stayed "
202
+ "frame-exact, while DRT relinked 0/3 (sourceClipsPath is not "
203
+ "valid for DRT, so it cannot be told where the media went), EDL "
204
+ "relinked 0/3, and OTIO's import returned None outright.",
205
+ "recommended": "Pick per job: DRT for full fidelity with media in place; "
206
+ "FCP7 XML or AAF for an iterative loop or any conform where "
207
+ "media may move; never DRT/OTIO/EDL for a relink. Note the "
208
+ "cut stays frame-exact even when relinking fails, so a "
209
+ "failed relink leaves a correct offline timeline rather "
210
+ "than a wrong one. See docs/guides/headless-edit-loop.md.",
211
+ "tags": ["timeline", "export", "import", "interchange", "conform"],
212
+ },
213
+ {
214
+ "symbol": "BMD_RESOLVE_*_DIR environment variables",
215
+ "object": "Resolve",
216
+ "reality": "BMD_RESOLVE_CONFIG_DIR / SUPPORT_DIR / LUT_DIR genuinely "
217
+ "isolate an instance — a launch with them set builds a private "
218
+ "config.dat plus its own Resolve Project Library, Fairlight, "
219
+ "DolbyVision and easyDCP trees, and the resulting instance sees "
220
+ "ZERO of the machine's real projects. They do NOT defeat the "
221
+ "single-instance guard: started while another Resolve was "
222
+ "running, the second process created its private tree and then "
223
+ "exited silently, logging nothing. So isolated instances are "
224
+ "possible and PARALLEL instances are not. A fresh config also "
225
+ "omits `System.Scripting.Mode = 1`, so an isolated instance is "
226
+ "unreachable by scripting until that line is appended to its "
227
+ "config.dat (plain ASCII) — until then it runs, opens no "
228
+ "listener, and looks exactly like a hang. "
229
+ "BMD_RESOLVE_LOGS_DIR only partly takes: LogArchive/ and "
230
+ "gpudetect.bin appear there, the main log does not.",
231
+ "recommended": "Use for a CI/render worker that must not see or lock the "
232
+ "operator's projects. Seed the scripting preference after "
233
+ "the first boot. Do not expect to run two at once. Recipe "
234
+ "in docs/reference/headless-cli.md.",
235
+ "tags": ["headless", "isolation", "singleton", "ci", "startup"],
236
+ },
237
+ {
238
+ "symbol": "Resolve -fastmode",
239
+ "object": "Resolve",
240
+ "reality": "Starts a process that stays alive at 0% CPU and never opens "
241
+ "the scripting listener — port 15000 is not bound at all, and "
242
+ "it was still unreachable after two minutes. Exercised in an "
243
+ "isolated sandbox on Studio 19.1.3.7.",
244
+ "recommended": "Not usable for automation; do not pass it. (`-activate` "
245
+ "and `-deactivate` remain deliberately unexercised: their "
246
+ "adjacent strings show they mutate this machine's Studio "
247
+ "licence activation.)",
248
+ "tags": ["headless", "cli-flags"],
249
+ },
250
+ {
251
+ "symbol": "Timeline.Export(EXPORT_FCPXML_1_10)",
252
+ "object": "Timeline",
253
+ "signature": "(fileName, EXPORT_FCPXML_1_10, EXPORT_NONE) -> bool",
254
+ "reality": "Returns True and creates a BUNDLE DIRECTORY at the given "
255
+ "path containing `Info.fcpxml`, rather than a file — the "
256
+ "`.fcpxmld` shape Final Cut uses. Two consequences bite "
257
+ "immediately: a `stat().st_size` check reads the directory "
258
+ "inode (96 bytes here) and concludes the export is empty, and "
259
+ "ImportTimelineFromFile on that path fails because it is a "
260
+ "directory. Every other EXPORT_* type in this build writes a "
261
+ "plain file, so code that treats them uniformly gets this one "
262
+ "wrong. Pointed at the inner member, the format round-trips "
263
+ "frame-exactly in both modes.",
264
+ "recommended": "After exporting, check `Path(p).is_dir()` and import "
265
+ "`next(Path(p).glob('*.fcpxml'))` instead. Do not size-"
266
+ "check the export path itself.",
267
+ "tags": ["timeline", "export", "interchange", "fcpxml", "silent-failure"],
268
+ "submit": "bug",
269
+ },
270
+ {
271
+ "symbol": "MediaPool.CreateTimelineFromClips",
272
+ "object": "MediaPool",
273
+ "reality": "Fails with a bare 'Failed to create timeline from clip_infos' "
274
+ "— naming nothing actionable — when the Media Pool's CURRENT "
275
+ "folder is not the folder holding the clips, even though every "
276
+ "clip_id passed is valid and resolvable. Reported against "
277
+ "Resolve Studio in PR #99.",
278
+ "recommended": "Call MediaPool.SetCurrentFolder() to the clips' bin "
279
+ "before creating the timeline (media_pool "
280
+ "set_current_folder). Valid ids are not sufficient.",
281
+ "tags": ["media-pool", "timeline", "unhelpful-error"],
282
+ "submit": "bug",
283
+ },
284
+ {
285
+ "symbol": "TimelineItem.AddFusionComp / LoadFusionCompByName",
286
+ "object": "TimelineItem (media-backed clip)",
287
+ "reality": "A Fusion composition created on a media clip through the API "
288
+ "is not applied at render WHEN MEDIAOUT HAS NO PATH FROM "
289
+ "MEDIAIN. The original blanket form of this entry — 'never "
290
+ "applied at render' — was too broad and was corrected on "
291
+ "2026-08-02: a comp wired MediaIn -> Blur -> MediaOut, created "
292
+ "entirely through the API on an ordinary media clip, DOES "
293
+ "render. PSNR between the plain and Fusion renders of the same "
294
+ "timeline was 22.7 dB (identical would be infinite), the file "
295
+ "shrank 22.5 MB -> 14.8 MB as a blur should, and the output was "
296
+ "frame-for-frame identical in GUI and headless. A first attempt "
297
+ "that wired ONLY MediaOut -> Blur, leaving the Blur with no "
298
+ "source, made the render job come back 'Failed' with an "
299
+ "887-byte file — so an unrooted graph does not merely get "
300
+ "bypassed, it can take the render down. "
301
+ "What still stands is the original observation for the "
302
+ "configuration it actually tested, which is retained below and "
303
+ "has NOT been re-measured: AddFusionComp() returns the comp, "
304
+ "AddTool/Connect/SetInput all succeed, and the whole graph "
305
+ "reads back correctly (GetCompCount 1, MediaOut1.Input wired "
306
+ "to the new tool, StyledText returning the value just set) — "
307
+ "but the rendered output is byte-for-byte the untouched source "
308
+ "media. Verified live on Studio 19.1.3.7 with the strongest "
309
+ "form of the test: MediaOut1 fed ONLY by a Text+, with no path "
310
+ "from MediaIn at all, still rendered the unmodified clip. "
311
+ "LoadFusionCompByName on the sole comp does not activate it "
312
+ "either. Contrast InsertFusionTitleIntoTimeline, whose comp "
313
+ "DOES render — text set via SetInput('StyledText') appears in "
314
+ "the output — so this is specific to comps attached to "
315
+ "media-backed clips, not to Fusion through the API generally.",
316
+ "recommended": "Wire the graph so MediaOut descends from MediaIn — that "
317
+ "is the difference between a comp that renders and one "
318
+ "that is silently bypassed, and it is what made this look "
319
+ "like 'Fusion never renders from the API'. Never leave a "
320
+ "tool unrooted: a MediaOut fed by a tool with no source "
321
+ "failed the render job outright. "
322
+ "For text or effects over picture, insert a Fusion title/"
323
+ "generator as its own timeline clip and set its Text+ "
324
+ "(fusion_comp set_text_plus), rather than attaching a comp "
325
+ "to the media clip. Note the destination track cannot be "
326
+ "chosen from the API (see the Track Selector entry), so "
327
+ "overlaying onto an existing clip's track is not currently "
328
+ "reachable end-to-end. Building the comp in the Fusion page "
329
+ "UI works; only the API-created comp is ignored.",
330
+ "tags": ["fusion", "silent-failure", "render"],
108
331
  "submit": "bug",
109
332
  },
110
333
  {
@@ -636,6 +859,81 @@ API_TRUTH: List[Dict[str, Any]] = [
636
859
  "tags": ["render", "deliver", "audio", "unsupported"],
637
860
  "submit": "missing",
638
861
  },
862
+ {
863
+ "symbol": "ProjectManager.SaveProject",
864
+ "object": "ProjectManager",
865
+ "signature": "() -> bool",
866
+ "reality": "On the default, never-saved project named 'Untitled Project' "
867
+ "this call CANNOT succeed — the project has no location and "
868
+ "there is no SaveProjectAs to give it one — and the two modes "
869
+ "fail differently. In the GUI it returns False. HEADLESS IT "
870
+ "BLOCKS FOREVER: measured on a cold -nogui boot with the "
871
+ "database verified attached immediately beforehand, no return "
872
+ "after 45s, the client parked in Fusion::RemoteApp::WaitPkt. "
873
+ "Resolve wants a Save-As dialog and waits for an answer that "
874
+ "cannot arrive. It also degrades the instance: after the hung "
875
+ "call was interrupted, SaveProject began returning None "
876
+ "instantly on that instance. On a named project it is fine in "
877
+ "both modes.",
878
+ "recommended": "Guard it: only call SaveProject when "
879
+ "GetCurrentProject().GetName() != 'Untitled Project'. "
880
+ "There is nothing to save on the default project and the "
881
+ "call cannot succeed, so skipping it loses nothing. Do NOT "
882
+ "reach for headless to dodge the GUI's save dialog — "
883
+ "headless turns that dialog into an unbounded hang that no "
884
+ "human can clear.",
885
+ "tags": ["project", "modal", "headless", "hang", "silent-failure", "unreliable-return"],
886
+ "submit": "bug",
887
+ "mitigation": ["save_project_if_safe"],
888
+ },
889
+ {
890
+ "symbol": "Resolve.GetCurrentPage",
891
+ "object": "Resolve",
892
+ "signature": "() -> string",
893
+ "reality": "A headless (-nogui) instance answers with a real page name, "
894
+ "not None, and OpenPage() succeeds and reads back for all "
895
+ "seven pages with no UI present. Nothing on the scripting API "
896
+ "distinguishes a headless Resolve from one with a window — "
897
+ "product name, version, layout presets, Gallery handles and "
898
+ "Fusion comps all behave identically (Studio 19.1.3.7, 238 "
899
+ "paired observations, zero headless-only failures).",
900
+ "recommended": "Do not infer headlessness from any API reading. Use "
901
+ "resolve_control runtime_mode, which reads -nogui out of "
902
+ "the process argv — the only place it is visible.",
903
+ "tags": ["headless", "page", "detection"],
904
+ "mitigation": ["runtime_mode", "resolve_runtime.is_headless"],
905
+ },
906
+ {
907
+ "symbol": "GalleryStillAlbum.ExportStills",
908
+ "object": "GalleryStillAlbum",
909
+ "signature": "(galleryStills, folderPath, filePrefix, format) -> bool",
910
+ "reality": "PANEL-dependent, not mode-dependent — and the distinction "
911
+ "took three revisions of this entry to pin down, so the "
912
+ "evidence is recorded rather than summarised. Across four "
913
+ "controlled 92-probe sweeps (2 GUI, 2 headless, 2026-08-01) it "
914
+ "returned False and wrote nothing in ALL FOUR, while "
915
+ "Timeline.GrabStill() succeeded in all four — so it is not "
916
+ "being handed an empty still. In one earlier GUI session it "
917
+ "DID work, returning True and writing 2 files. The variable "
918
+ "that differed is not the mode: it is whether the Gallery "
919
+ "panel was visible on the Color page, which depends on the "
920
+ "restored workspace layout and which the harness does not "
921
+ "control. A headless session can never satisfy it, so in "
922
+ "practice the call never works headless; a GUI session "
923
+ "satisfies it only sometimes. "
924
+ "Project.ExportCurrentFrameAsStill worked in all four runs in "
925
+ "both modes.",
926
+ "recommended": "Do not use ExportStills unattended in either mode — a "
927
+ "GUI session is not sufficient, only a GUI session with "
928
+ "the Gallery panel open. Use "
929
+ "Project.ExportCurrentFrameAsStill for pixels (verified in "
930
+ "both modes, four for four) or drp.extract_node_graphs for "
931
+ "grades. If ExportStills must be used, have the user open "
932
+ "Workspace > Gallery first and verify the written files "
933
+ "rather than trusting the return.",
934
+ "tags": ["gallery", "stills", "headless", "unreliable-return"],
935
+ "submit": "bug",
936
+ },
639
937
  ]
640
938
 
641
939